[openthread] Update OpenThread

Update to July 8, 2026 version.
Commit: d11cc040fdbc241a633cb23da1f3b17084cf42fb

Bug: 532696747
Change-Id: I0ecf8672cc03db94c9ee7adc3c048d53398cb026
Reviewed-on: https://fuchsia-review.googlesource.com/c/third_party/openthread/+/1698754
Reviewed-by: Xiao Ma <xiaom@google.com>
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..4791e98
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,49 @@
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+changelog:
+  exclude:
+    labels:
+      - duplicate
+      - question
+      - invalid
+      - wontfix
+    authors:
+      - dependabot[bot]
+  categories:
+    - title: "🚀 Features"
+      labels:
+        - feature
+        - enhancement
+    - title: "🐛 Bug Fixes"
+      labels:
+        - bug
+        - fix
+    - title: "📖 Documentation"
+      labels:
+        - documentation
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2344687..9e3f3d8 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -49,7 +49,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -73,13 +73,16 @@
     runs-on: ubuntu-22.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+
     - name: Run linkspector
-      uses: umbrelladocs/action-linkspector@37c85bcde51b30bf929936502bac6bfb7e8f0a4d # v1.4.1
+      uses: umbrelladocs/action-linkspector@6c637d70424624231467a4ca918be54fa3b792d0 # v1.5.4
+      env:
+        PUPPETEER_EXECUTABLE_PATH: /usr/bin/google-chrome
       with:
         github_token: ${{ secrets.GITHUB_TOKEN }}
         reporter: github-pr-review
@@ -89,7 +92,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -104,11 +107,30 @@
         cmake --build --preset simulation
         ctest --preset simulation
 
+  cmake-presets-ccm-one-shot:
+    runs-on: ubuntu-24.04
+    steps:
+    - name: Harden Runner
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+      with:
+        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+      with:
+        submodules: recursive
+    - name: Bootstrap
+      run: |
+        sudo apt-get --no-install-recommends install -y build-essential ninja-build libreadline-dev libncurses-dev
+    - name: Build and test with platform CCM one-shot enabled
+      run: |
+        cmake --preset simulation -DOT_CRYPTO_CCM_ONE_SHOT=ON
+        cmake --build --preset simulation
+        ctest --preset simulation
+
   cmake-version:
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -148,7 +170,7 @@
       CXX: ${{ matrix.compiler_cpp }}
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -167,7 +189,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -186,7 +208,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -238,9 +260,12 @@
           - gcc_ver: 14
             gcc_download_url: https://developer.arm.com/-/media/Files/downloads/gnu/14.2.rel1/binrel/arm-gnu-toolchain-14.2.rel1-x86_64-arm-none-eabi.tar.xz
             gcc_extract_dir: arm-gnu-toolchain-14.2.rel1-x86_64-arm-none-eabi
+          - gcc_ver: 15
+            gcc_download_url: https://developer.arm.com/-/media/Files/downloads/gnu/15.2.rel1/binrel/arm-gnu-toolchain-15.2.rel1-x86_64-arm-none-eabi.tar.xz
+            gcc_extract_dir: arm-gnu-toolchain-15.2.rel1-x86_64-arm-none-eabi
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -273,7 +298,7 @@
       CXX: g++-${{ matrix.gcc_ver }}
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -306,7 +331,7 @@
       CXX: clang++-${{ matrix.clang_ver }}
     steps:
       - name: Harden Runner
-        uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+        uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
         with:
           egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -329,7 +354,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -360,7 +385,7 @@
       CXX: ${{ matrix.CXX }}
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 02acad2..f8a6b71 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -54,7 +54,7 @@
 
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -67,7 +67,7 @@
         sudo apt-get --no-install-recommends install -y ninja-build libreadline-dev libncurses-dev
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
+      uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
       with:
         languages: ${{ matrix.language }}
         # If you wish to specify custom queries, you can do so here or in a config file.
@@ -81,6 +81,6 @@
         ./script/test build
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
+      uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
       with:
         category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index b9f36dc..1286e80 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -61,7 +61,7 @@
 
     steps:
       - name: Harden Runner
-        uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+        uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
         with:
           egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -89,12 +89,12 @@
           password: ${{ secrets.DOCKER_PASSWORD }}
 
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
+        uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
 
       - name: Build and push by digest
         if: success()
         id: build
-        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
+        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
         with:
           file: etc/docker/environment/Dockerfile
           platforms: ${{ matrix.platform }}
@@ -111,7 +111,7 @@
 
       - name: Upload digest
         if: success() && github.repository == 'openthread/openthread' && github.event_name != 'pull_request'
-        uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
         with:
           name: digests-${{ env.PLATFORM_PAIR }}
           path: ${{ runner.temp }}/digests/*
@@ -125,12 +125,12 @@
       - build
     steps:
       - name: Harden Runner
-        uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+        uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
         with:
           egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
       - name: Download digests
-        uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
         with:
           path: ${{ runner.temp }}/digests
           pattern: digests-*
@@ -143,7 +143,7 @@
           password: ${{ secrets.DOCKER_PASSWORD }}
 
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
+        uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
 
       - name: Docker meta
         id: meta
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
index 5aefd2a..2a34552 100644
--- a/.github/workflows/fuzz.yml
+++ b/.github/workflows/fuzz.yml
@@ -53,7 +53,7 @@
 
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
     
@@ -75,7 +75,7 @@
         output-sarif: true
 
     - name: Upload Crash
-      uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+      uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: failure() && steps.build.outcome == 'success'
       with:
         name: ${{ matrix.sanitizer }}-artifacts
@@ -83,7 +83,7 @@
 
     - name: Upload Sarif
       if: always() && steps.build.outcome == 'success'
-      uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
+      uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
       with:
         sarif_file: cifuzz-sarif/results.sarif
         checkout_path: cifuzz-sarif
diff --git a/.github/workflows/makefile-check.yml b/.github/workflows/makefile-check.yml
index 0baa980..240f8b1 100644
--- a/.github/workflows/makefile-check.yml
+++ b/.github/workflows/makefile-check.yml
@@ -48,7 +48,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -63,7 +63,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
diff --git a/.github/workflows/monthly-release.yml b/.github/workflows/monthly-release.yml
new file mode 100644
index 0000000..6fde258
--- /dev/null
+++ b/.github/workflows/monthly-release.yml
@@ -0,0 +1,89 @@
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+name: Monthly CalVer Release
+
+on:
+  schedule:
+    # Runs at 00:00 UTC on the 1st day of every month
+    - cron: '0 0 1 * *'
+  
+  # Allows you to trigger the workflow manually from the GitHub Actions UI
+  workflow_dispatch:
+    inputs:
+      tag_override:
+        description: 'Custom Tag Name (e.g., v2026.05.1). Leave blank for auto CalVer.'
+        required: false
+        type: string
+
+jobs:
+  create-release:
+    name: Create Monthly Release
+    runs-on: ubuntu-24.04
+    
+    # Required permission to create tags and releases
+    permissions:
+      contents: write 
+
+    steps:
+      - name: Harden Runner
+        uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+        with:
+          egress-policy: block
+          allowed-endpoints: >
+            api.github.com:443
+            github.com:443
+
+      - name: Checkout repository
+        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+        with:
+          persist-credentials: false
+
+      - name: Generate CalVer Tag Name
+        id: generate_tag
+        env:
+          TAG_OVERRIDE: ${{ inputs.tag_override }}
+        # Formats the date as vYYYY.MM.0 (e.g., v2026.06.0) or uses override if provided
+        run: |
+          if [ -n "$TAG_OVERRIDE" ]; then
+            CALVER_TAG="$TAG_OVERRIDE"
+          else
+            CALVER_TAG="v$(date +'%Y.%m').0"
+          fi
+          echo "TAG_NAME=$CALVER_TAG" >> $GITHUB_ENV
+          echo "Generated tag: $CALVER_TAG"
+
+      - name: Create GitHub Release
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        # Uses the built-in GitHub CLI to create the tag and release simultaneously
+        run: |
+          gh release create "$TAG_NAME" \
+            --target "${{ github.ref_name }}" \
+            --title "OpenThread $TAG_NAME" \
+            --generate-notes
diff --git a/.github/workflows/nexus.yml b/.github/workflows/nexus.yml
index c77c182..92ae558 100644
--- a/.github/workflows/nexus.yml
+++ b/.github/workflows/nexus.yml
@@ -50,10 +50,13 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit
 
+    - name: Free Disk Space (Ubuntu)
+      uses: jlumbroso/free-disk-space@main
+
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
       with:
         submodules: recursive
@@ -79,10 +82,13 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
+    - name: Free Disk Space (Ubuntu)
+      uses: jlumbroso/free-disk-space@main
+
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
       with:
         submodules: recursive
@@ -96,21 +102,29 @@
 
     - name: Build Nexus
       run: |
-        ./tests/nexus/build.sh
+        mkdir -p build/nexus
+        top_builddir=build/nexus ./tests/nexus/build.sh
 
     - name: Run Core Tests
       run: |
-        ctest -L core --output-on-failure
+        cd build/nexus && ctest -L core --output-on-failure
 
-  nexus-trel-tests:
-    name: nexus-trel-tests
+    - name: Run TREL Tests
+      run: |
+        cd build/nexus && ctest -L trel --output-on-failure
+
+  nexus-long-routes-tests:
+    name: nexus-long-routes-tests
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
+    - name: Free Disk Space (Ubuntu)
+      uses: jlumbroso/free-disk-space@main
+
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
       with:
         submodules: recursive
@@ -124,8 +138,72 @@
 
     - name: Build Nexus
       run: |
-        ./tests/nexus/build.sh trel
+        mkdir -p build/nexus
+        top_builddir=build/nexus ./tests/nexus/build.sh long_routes
 
-    - name: Run Core Tests
+    - name: Run LONG_ROUTES Tests
       run: |
-        ctest -L trel --output-on-failure
+        cd build/nexus && ctest -L long_routes --output-on-failure
+
+  nexus-grpc-tests:
+    name: nexus-grpc-tests
+    runs-on: ubuntu-24.04
+    steps:
+    - name: Harden Runner
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+      with:
+        egress-policy: audit
+
+    - name: Free Disk Space (Ubuntu)
+      uses: jlumbroso/free-disk-space@main
+
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+      with:
+        submodules: recursive
+
+    - name: Bootstrap
+      run: |
+        sudo apt-get update
+        sudo apt-get --no-install-recommends install -y ninja-build libgrpc++-dev libprotobuf-dev protobuf-compiler-grpc
+
+    - name: Build Nexus
+      run: |
+        mkdir -p build/nexus
+        OT_NEXUS_GRPC=ON top_builddir=build/nexus ./tests/nexus/build.sh
+
+    - name: Run GRPC Tests
+      run: |
+        cd build/nexus && ./tests/nexus/nexus_grpc
+
+  nexus-wasm-tests:
+    name: nexus-wasm-tests
+    runs-on: ubuntu-24.04
+    steps:
+    - name: Harden Runner
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+      with:
+        egress-policy: audit
+
+    - name: Free Disk Space (Ubuntu)
+      uses: jlumbroso/free-disk-space@main
+
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+      with:
+        submodules: recursive
+
+    - name: Bootstrap
+      run: |
+        sudo apt-get update
+        sudo apt-get --no-install-recommends install -y ninja-build
+
+    - name: Set up Emscripten
+      uses: mymindstorm/setup-emsdk@v14
+
+    - name: Build Nexus WASM
+      run: |
+        mkdir -p build/nexus
+        top_builddir=build/nexus ./tests/nexus/build.sh wasm
+
+    - name: Run WASM Smoke Test
+      run: |
+        node tests/nexus/test_wasm_bindings.mjs build/nexus/tests/nexus/nexus_live_demo.js
diff --git a/.github/workflows/otbr-posix-dind.yml b/.github/workflows/otbr-posix-dind.yml
new file mode 100644
index 0000000..037ca86
--- /dev/null
+++ b/.github/workflows/otbr-posix-dind.yml
@@ -0,0 +1,91 @@
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+name: OTBR DinD
+
+on:
+  push:
+    branches-ignore:
+      - 'dependabot/**'
+  pull_request:
+    branches:
+      - 'main'
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.repository == 'openthread/openthread' && github.run_id) || github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+
+jobs:
+
+  dind-dns-sd:
+    runs-on: ubuntu-24.04
+    strategy:
+      fail-fast: false
+      matrix:
+        mdns: ["", "--mdnsresponder"]
+    env:
+      PR_BODY: "${{ github.event.pull_request.body }}"
+    steps:
+    - name: Harden Runner
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+      with:
+        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
+
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+      with:
+        submodules: recursive
+
+    - name: Clone ot-br-posix and replace openthread submodule
+      run: |
+        OPENTHREAD_DIR=$(pwd)
+        ./script/git-tool clone https://github.com/openthread/ot-br-posix.git --depth 1 --recurse-submodules --shallow-submodules /tmp/ot-br-posix
+        cd /tmp/ot-br-posix
+        rm -rf third_party/openthread/repo
+        mkdir -p third_party/openthread/repo
+        rsync -r --exclude=.git --exclude=build --exclude=ot_testing "${OPENTHREAD_DIR}/." third_party/openthread/repo
+
+    - name: Set up Docker Buildx
+      uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+
+    - name: Build DinD Runner Image
+      run: |
+        cd /tmp/ot-br-posix
+        docker build -t otbr-dind-runner -f etc/docker/test/Dockerfile.dind_runner .
+
+    - name: Run DinD Integration Test
+      run: |
+        cd /tmp/ot-br-posix
+        docker run --privileged --rm \
+          -v /tmp/ot-br-posix:/usr/src/ot-br-posix \
+          -w /usr/src/ot-br-posix \
+          -e DOCKER_HOST=unix:///var/run/docker.sock \
+          otbr-dind-runner \
+          bash -c "dockerd --host=unix:///var/run/docker.sock >/dev/null 2>&1 & ./tests/scripts/test_dind_dns_sd.sh ${{ matrix.mdns }}"
diff --git a/.github/workflows/otbr.yml b/.github/workflows/otbr.yml
index e38fa80..f22895b 100644
--- a/.github/workflows/otbr.yml
+++ b/.github/workflows/otbr.yml
@@ -45,74 +45,6 @@
 
 jobs:
 
-  backbone-router:
-    runs-on: ubuntu-22.04
-    env:
-      REFERENCE_DEVICE: 1
-      VIRTUAL_TIME: 0
-      TEST_TIMEOUT: 1800
-      PACKET_VERIFICATION: 1
-      THREAD_VERSION: 1.4
-      INTER_OP: 1
-      COVERAGE: 1
-      MULTIPLY: 1
-      PYTHONUNBUFFERED: 1
-      VERBOSE: 1
-      # The Border Routing and DUA feature can coexist, but current wireshark
-      # packet verification can't handle it because of the order of context ID
-      # of OMR prefix and Domain prefix is not deterministic.
-      BORDER_ROUTING: 0
-      DISCOVERY_PROXY: 0
-    steps:
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - name: Build OTBR Docker
-      env:
-        PR_BODY: "${{ github.event.pull_request.body }}"
-      run: |
-        ./script/test build_otbr_docker
-    - name: Bootstrap
-      run: |
-        sudo rm /etc/apt/sources.list.d/* && sudo apt-get update
-        sudo apt-get --no-install-recommends install -y python3-setuptools python3-wheel ninja-build lcov
-        sudo bash script/install_socat
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
-    - name: Build
-      run: |
-        ./script/test build
-    - name: Get Thread-Wireshark
-      run: |
-        ./script/test get_thread_wireshark
-    - name: Run
-      run: |
-        export CI_ENV="$(bash <(curl -s https://codecov.io/env)) -e GITHUB_ACTIONS -e COVERAGE"
-        echo "CI_ENV=${CI_ENV}"
-        sudo -E ./script/test cert_suite ./tests/scripts/thread-cert/backbone/*.py || (sudo chmod a+r ot_testing/* && false)
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-thread-1-3-backbone-docker
-        path: /tmp/coverage/
-        retention-days: 1
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: thread-1-3-backbone-results
-        path: |
-          ot_testing/*.pcap
-          ot_testing/*.json
-          ot_testing/*.log
-          ot_testing/coredump_*
-          ot_testing/otbr-agent_*
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-thread-1-3-backbone
-        path: tmp/coverage.info
-        retention-days: 1
-
   thread-border-router:
     runs-on: ubuntu-22.04
     strategy:
@@ -133,29 +65,10 @@
             description: ""
           - otbr_mdns: "mDNSResponder"
             otbr_trel: 0
-            cert_scripts: ./tests/scripts/thread-cert/border_router/MATN/*.py
-            packet_verification: 1
-            nat64: 0
-            description: "MATN"
-          - otbr_mdns: "mDNSResponder"
-            otbr_trel: 0
             cert_scripts: ./tests/scripts/thread-cert/border_router/internet/*.py
             packet_verification: 1
             nat64: 1
             description: "internet access"
-          - otbr_mdns: "avahi"
-            otbr_trel: 0
-            cert_scripts: ./tests/scripts/thread-cert/border_router/*.py
-            packet_verification: 1
-            nat64: 0
-            description: ""
-          - otbr_mdns: "avahi"
-            otbr_trel: 0
-            cert_scripts: ./tests/scripts/thread-cert/border_router/*.py
-            packet_verification: 1
-            nat64: 0
-            use_core_firewall: 1
-            description: "core-firewall"
     name: BR ${{ matrix.description }} (${{ matrix.otbr_mdns }}, TREL=${{matrix.otbr_trel}})
     env:
       REFERENCE_DEVICE: 1
@@ -203,12 +116,12 @@
         export CI_ENV="$(bash <(curl -s https://codecov.io/env)) -e GITHUB_ACTIONS -e COVERAGE"
         echo "CI_ENV=${CI_ENV}"
         sudo -E ./script/test cert_suite ${{ matrix.cert_scripts }} || (sudo chmod a+r ot_testing/* && false)
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-br-docker-${{ matrix.description }}-${{ matrix.otbr_mdns }}-${{matrix.otbr_trel}}
         path: /tmp/coverage/
         retention-days: 1
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: ${{ failure() }}
       with:
         name: br-results-${{ matrix.description }}-${{ matrix.otbr_mdns }}-${{matrix.otbr_trel}}
@@ -221,7 +134,7 @@
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-br-${{ matrix.description }}-${{ matrix.otbr_mdns }}-${{matrix.otbr_trel}}
         path: tmp/coverage.info
@@ -229,7 +142,6 @@
 
   upload-coverage:
     needs:
-    - backbone-router
     - thread-border-router
     runs-on: ubuntu-22.04
     steps:
@@ -239,7 +151,7 @@
     - name: Bootstrap
       run: |
         sudo apt-get --no-install-recommends install -y lcov
-    - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+    - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
       with:
         path: coverage/
         pattern: cov-*
@@ -250,7 +162,7 @@
         script/test combine_coverage
     - name: Upload Coverage
       continue-on-error: true
-      uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+      uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
       env:
         CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
       with:
diff --git a/.github/workflows/otci.yml b/.github/workflows/otci.yml
index dcc8b57..17a99ec 100644
--- a/.github/workflows/otci.yml
+++ b/.github/workflows/otci.yml
@@ -58,7 +58,7 @@
       REAL_DEVICE: 0
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -80,7 +80,7 @@
         PYTHONPATH=./tests/scripts/thread-cert pytype tools/otci
     - name: Build
       run: |
-        ./script/cmake-build simulation -DOT_THREAD_VERSION=1.4 -DOT_DUA=ON -DOT_MLR=ON -DOT_BACKBONE_ROUTER=ON \
+        ./script/cmake-build simulation -DOT_THREAD_VERSION=1.4 -DOT_MLR=ON -DOT_BACKBONE_ROUTER=ON \
         -DOT_CSL_RECEIVER=ON -DOT_SIMULATION_VIRTUAL_TIME=${VIRTUAL_TIME}
     - name: Install OTCI Python Library
       run: |
diff --git a/.github/workflows/otns.yml b/.github/workflows/otns.yml
index a01576c..5bbbbaf 100644
--- a/.github/workflows/otns.yml
+++ b/.github/workflows/otns.yml
@@ -58,14 +58,14 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
       with:
         submodules: recursive
-    - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+    - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
       with:
         go-version: "1.23"
     - name: Set up Python
@@ -84,7 +84,7 @@
           cd /tmp/otns
           ./script/test py-unittests
         )
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: ${{ failure() }}
       with:
         name: unittests-pcaps
@@ -94,7 +94,7 @@
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-otns-unittests
         path: tmp/coverage.info
@@ -107,7 +107,7 @@
       - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
         with:
           submodules: recursive
-      - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+      - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
         with:
           go-version: "1.23"
       - name: Set up Python
@@ -126,7 +126,7 @@
             cd /tmp/otns
             ./script/test py-examples
           )
-      - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
         if: ${{ failure() }}
         with:
           name: examples-pcaps
@@ -136,7 +136,7 @@
       - name: Generate Coverage
         run: |
           ./script/test generate_coverage gcc
-      - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
         with:
           name: cov-otns-examples
           path: tmp/coverage.info
@@ -164,14 +164,14 @@
       STRESS_LEVEL: ${{ matrix.stress_level }}
     steps:
       - name: Harden Runner
-        uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+        uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
         with:
           egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
       - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
         with:
           submodules: recursive
-      - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
+      - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
         with:
           go-version: "1.23"
       - name: Set up Python
@@ -190,7 +190,7 @@
             cd /tmp/otns
             ./script/test stress-tests ${{ matrix.suite }}
           )
-      - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
         if: ${{ failure() }}
         with:
           name: stress-tests-${{ matrix.suite }}-pcaps
@@ -200,7 +200,7 @@
       - name: Generate Coverage
         run: |
           ./script/test generate_coverage gcc
-      - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
         with:
           name: cov-otns-stress-tests-${{ matrix.suite }}
           path: tmp/coverage.info
@@ -214,7 +214,7 @@
     runs-on: ubuntu-24.04
     steps:
       - name: Harden Runner
-        uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+        uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
         with:
           egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -224,7 +224,7 @@
       - name: Bootstrap
         run: |
           sudo apt-get --no-install-recommends install -y lcov
-      - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
         with:
           path: coverage/
           pattern: cov-*
diff --git a/.github/workflows/posix.yml b/.github/workflows/posix.yml
index 3df6f0b..200d8c2 100644
--- a/.github/workflows/posix.yml
+++ b/.github/workflows/posix.yml
@@ -52,7 +52,7 @@
       CXXFLAGS: -DCLI_COAP_SECURE_USE_COAP_DEFAULT_HANDLER=1 -DOPENTHREAD_CONFIG_MLE_MAX_CHILDREN=15
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -83,7 +83,7 @@
           CRASHED=$(./script/test check_crash | tail -1)
           [[ $CRASHED -eq "1" ]] && echo "Crashed!" || echo "Not crashed."
           echo "CRASHED_RCP=$CRASHED" >> $GITHUB_ENV
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: ${{ failure() && env.CRASHED_RCP == '1' }}
       with:
         name: core-expect-rcp
@@ -92,7 +92,7 @@
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-expects-linux-1
         path: tmp/coverage.info
@@ -117,13 +117,13 @@
           CRASHED=$(./script/test check_crash | tail -1)
           [[ $CRASHED -eq "1" ]] && echo "Crashed!" || echo "Not crashed."
           echo "CRASHED_TUN=$CRASHED" >> $GITHUB_ENV
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: ${{ failure() && env.CRASHED_TUN == '1' }}
       with:
         name: core-expect-linux
         path: |
           ./ot-core-dump/*
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: ${{ failure() }}
       with:
         name: syslog-expect-linux
@@ -131,57 +131,12 @@
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-expects-linux-2
         path: tmp/coverage.info
         retention-days: 1
 
-  thread-cert:
-    runs-on: ubuntu-22.04
-    env:
-      COVERAGE: 1
-      PYTHONUNBUFFERED: 1
-      THREAD_VERSION: 1.1
-      VIRTUAL_TIME: 1
-      OT_VT_USE_UNIX_SOCKET: 1
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      run: |
-        sudo apt-get update
-        sudo apt-get --no-install-recommends install -y lcov ninja-build
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
-    - name: Build
-      run: |
-        OT_NODE_TYPE=rcp ./script/test build
-    - name: Run
-      run: |
-        MAX_JOBS=$(getconf _NPROCESSORS_ONLN) ./script/test cert_suite ./tests/scripts/thread-cert/Cert_*.py ./tests/scripts/thread-cert/test_*.py
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: thread-cert
-        path: ot_testing
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-thread-cert
-        path: tmp/coverage.info
-
   pty-linux:
     name: pty-linux OT_DAEMON=${{ matrix.OT_DAEMON }}
     runs-on: ubuntu-24.04
@@ -195,7 +150,7 @@
       OT_READLINE: 'readline'
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -230,7 +185,7 @@
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-pty-linux-${{ matrix.OT_DAEMON }}
         path: tmp/coverage.info
@@ -241,7 +196,7 @@
     runs-on: ubuntu-22.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -271,7 +226,7 @@
       OT_READLINE: 'off'
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -304,7 +259,7 @@
     runs-on: ubuntu-22.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -327,7 +282,7 @@
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-rcp-stack-reset
         path: tmp/coverage.info
@@ -337,11 +292,10 @@
     needs:
     - expects-linux
     - pty-linux
-    - thread-cert
     runs-on: ubuntu-22.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -351,7 +305,7 @@
     - name: Bootstrap
       run: |
         sudo apt-get --no-install-recommends install -y lcov
-    - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+    - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
       with:
         path: coverage/
         pattern: cov-*
@@ -360,7 +314,7 @@
       run: |
         script/test combine_coverage
     - name: Upload Coverage
-      uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+      uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
       env:
         CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
       with:
diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml
index c0fae5e..20a274c 100644
--- a/.github/workflows/scorecards.yml
+++ b/.github/workflows/scorecards.yml
@@ -87,7 +87,7 @@
       # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
       # format to the repository Actions tab.
       - name: "Upload artifact"
-        uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v3.1.0
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v3.1.0
         with:
           name: SARIF file
           path: results.sarif
@@ -95,6 +95,6 @@
 
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v2.1.27
+        uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v2.1.27
         with:
           sarif_file: results.sarif
diff --git a/.github/workflows/simulation-1.1.yml b/.github/workflows/simulation-1.1.yml
deleted file mode 100644
index 0a8baaf..0000000
--- a/.github/workflows/simulation-1.1.yml
+++ /dev/null
@@ -1,472 +0,0 @@
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-name: Simulation 1.1
-
-on:
-  push:
-    branches-ignore:
-      - 'dependabot/**'
-  pull_request:
-    branches:
-      - 'main'
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.repository == 'openthread/openthread' && github.run_id) || github.ref }}
-  cancel-in-progress: true
-
-permissions:
-  contents: read
-
-jobs:
-
-  packet-verification:
-    runs-on: ubuntu-24.04
-    env:
-      PACKET_VERIFICATION: 1
-      REFERENCE_DEVICE: 1
-      THREAD_VERSION: 1.1
-      VIRTUAL_TIME: 1
-      TEST_TIMEOUT: 300
-      OT_VT_USE_UNIX_SOCKET: 1
-      MULTIPLY: 3
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      run: |
-        sudo apt-get update
-        sudo apt-get --no-install-recommends install -y ninja-build lcov
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
-    - name: Build
-      run: |
-        ./script/test build
-    - name: Get Thread-Wireshark
-      run: |
-        ./script/test get_thread_wireshark
-    - name: Run
-      run: |
-        ./script/test cert_suite ./tests/scripts/thread-cert/Cert_*.py ./tests/scripts/thread-cert/test_*.py
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: packet-verification-pcaps
-        path: |
-          *.pcap
-          *.json
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-packet-verification
-        path: tmp/coverage.info
-        retention-days: 1
-
-  cli-ftd:
-    runs-on: ubuntu-24.04
-    env:
-      CFLAGS: -m32
-      CXXFLAGS: -m32
-      LDFLAGS: -m32
-      COVERAGE: 1
-      REFERENCE_DEVICE: 1
-      THREAD_VERSION: 1.1
-      VIRTUAL_TIME: 1
-      TEST_TIMEOUT: 300
-      OT_VT_USE_UNIX_SOCKET: 1
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      run: |
-        sudo apt-get update
-        sudo apt-get --no-install-recommends install -y lcov ninja-build g++-multilib
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
-    - name: Build
-      run: |
-        ./script/test build
-    - name: Run
-      run: |
-        ./script/test cert_suite ./tests/scripts/thread-cert/Cert_*.py ./tests/scripts/thread-cert/test_*.py
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: cli-ftd-thread-cert
-        path: ot_testing
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-cli-ftd
-        path: tmp/coverage.info
-        retention-days: 1
-
-  cli-mtd:
-    name: cli-mtd MESSAGE_USE_HEAP=${{ matrix.message_use_heap }}
-    runs-on: ubuntu-24.04
-    strategy:
-      fail-fast: false
-      matrix:
-        message_use_heap: [0, 1]
-    env:
-      CFLAGS: -m32
-      CXXFLAGS: -m32
-      LDFLAGS: -m32
-      COVERAGE: 1
-      REFERENCE_DEVICE: 1
-      THREAD_VERSION: 1.1
-      USE_MTD: 1
-      VIRTUAL_TIME: 1
-      TEST_TIMEOUT: 300
-      OT_VT_USE_UNIX_SOCKET: 1
-      MESSAGE_USE_HEAP: ${{ matrix.message_use_heap }}
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      run: |
-        sudo apt-get update
-        sudo apt-get --no-install-recommends install -y lcov ninja-build g++-multilib
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
-    - name: Build
-      run: |
-        ./script/test build
-    - name: Run
-      run: |
-        ./script/test cert_suite ./tests/scripts/thread-cert/Cert_*.py ./tests/scripts/thread-cert/test_*.py
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: cli-mtd-thread-cert
-        path: ot_testing
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-cli-mtd-${{ matrix.message_use_heap }}
-        path: tmp/coverage.info
-        retention-days: 1
-
-  cli-time-sync:
-    runs-on: ubuntu-24.04
-    env:
-      CFLAGS: -m32
-      CXXFLAGS: -m32
-      LDFLAGS: -m32
-      COVERAGE: 1
-      REFERENCE_DEVICE: 1
-      THREAD_VERSION: 1.1
-      VIRTUAL_TIME: 1
-      TEST_TIMEOUT: 300
-      OT_VT_USE_UNIX_SOCKET: 1
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      run: |
-        sudo apt-get update
-        sudo apt-get --no-install-recommends install -y g++-multilib lcov ninja-build
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
-    - name: Build
-      run: |
-        OT_OPTIONS="-DOT_TIME_SYNC=ON" ./script/test build
-    - name: Run
-      run: |
-        ./script/test cert_suite ./tests/scripts/thread-cert/Cert_*.py ./tests/scripts/thread-cert/test_*.py
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: cli-time-sync-thread-cert
-        path: ot_testing
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-cli-time-sync
-        path: tmp/coverage.info
-        retention-days: 1
-
-  expects:
-    runs-on: ubuntu-24.04
-    env:
-      CFLAGS: -DCLI_COAP_SECURE_USE_COAP_DEFAULT_HANDLER=1 -DOPENTHREAD_CONFIG_MLE_MAX_CHILDREN=15
-      CXXFLAGS: -DCLI_COAP_SECURE_USE_COAP_DEFAULT_HANDLER=1 -DOPENTHREAD_CONFIG_MLE_MAX_CHILDREN=15
-      THREAD_VERSION: 1.1
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      run: |
-        sudo apt-get --no-install-recommends install -y expect ninja-build lcov
-        sudo bash script/install_socat
-        pip install bleak 'cryptography==43.0.0'
-    - name: Run
-      run: |
-        ulimit -c unlimited
-        ./script/test prepare_coredump_upload
-        OT_OPTIONS='-DOT_TIME_SYNC=ON -DOT_FULL_LOGS=ON -DOT_LOG_OUTPUT=PLATFORM_DEFINED' VIRTUAL_TIME=0 ./script/test build expect
-    - name: Check Crash
-      if: ${{ failure() }}
-      run: |
-          CRASHED=$(./script/test check_crash | tail -1)
-          [[ $CRASHED -eq "1" ]] && echo "Crashed!" || echo "Not crashed."
-          echo "CRASHED_CLI=$CRASHED" >> $GITHUB_ENV
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() && env.CRASHED_CLI == '1' }}
-      with:
-        name: core-expect-cli
-        path: |
-          ./ot-core-dump/*
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-expects
-        path: tmp/coverage.info
-        retention-days: 1
-
-  ot-commissioner:
-    runs-on: ubuntu-24.04
-    env:
-      THREAD_VERSION: 1.1
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      env:
-        PR_BODY: "${{ github.event.pull_request.body }}"
-      run: |
-        sudo apt-get update
-        sudo apt-get install -y avahi-daemon avahi-utils lcov
-        script/git-tool clone https://github.com/openthread/ot-commissioner.git /tmp/ot-commissioner --depth 1 --branch main
-    - name: Build
-      run: |
-        cd /tmp/ot-commissioner
-        script/bootstrap.sh
-        cmake -GNinja                            \
-              -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-              -DCMAKE_CXX_STANDARD=11            \
-              -DCMAKE_CXX_STANDARD_REQUIRED=ON   \
-              -DCMAKE_BUILD_TYPE=Release         \
-              -DCMAKE_INSTALL_PREFIX=/usr/local  \
-              -DOT_COMM_COVERAGE=ON              \
-              -DOT_COMM_CCM=OFF                  \
-              -S . -B build
-        cmake --build build
-        sudo cmake --install build
-    - name: Run
-      run: |
-        export OT_COMM_OPENTHREAD="$(pwd)"
-        cd /tmp/ot-commissioner/tests/integration
-        ./bootstrap.sh
-        ./run_tests.sh
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-ot-commissioner
-        path: tmp/coverage.info
-        retention-days: 1
-
-  multiple-instance:
-    runs-on: ubuntu-24.04
-    env:
-      COVERAGE: 1
-      THREAD_VERSION: 1.1
-      VIRTUAL_TIME: 1
-      TEST_TIMEOUT: 300
-      OT_VT_USE_UNIX_SOCKET: 1
-      CXXFLAGS: "-DOPENTHREAD_CONFIG_LOG_PREPEND_UPTIME=0"
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      run: |
-        sudo apt-get --no-install-recommends install -y lcov ninja-build
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
-    - name: Build
-      run: |
-        OT_OPTIONS="-DOT_MULTIPLE_INSTANCE=ON" ./script/test build
-    - name: Run
-      run: |
-        ./script/test cert_suite ./tests/scripts/thread-cert/Cert_*.py ./tests/scripts/thread-cert/test_*.py
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: ot_testing
-        path: build/simulation/tests/scripts/thread-cert
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-multiple-instance
-        path: tmp/coverage.info
-        retention-days: 1
-
-  simulation-local-host:
-    runs-on: ubuntu-24.04
-    env:
-      COVERAGE: 1
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - name: Bootstrap
-      run: |
-        sudo apt-get --no-install-recommends install -y expect ninja-build lcov
-    - name: Run
-      run: |
-        ./script/check-simulation-local-host
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-simulation-local-host
-        path: tmp/coverage.info
-        retention-days: 1
-
-  upload-coverage:
-    needs:
-    - packet-verification
-    - cli-ftd
-    - cli-mtd
-    - cli-time-sync
-    - expects
-    - ot-commissioner
-    - multiple-instance
-    runs-on: ubuntu-22.04
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - name: Bootstrap
-      run: |
-        sudo apt-get --no-install-recommends install -y lcov
-    - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
-      with:
-        path: coverage/
-        pattern: cov-*
-        merge-multiple: true
-    - name: Combine Coverage
-      run: |
-        script/test combine_coverage
-    - name: Upload Coverage
-      uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
-      env:
-        CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
-      with:
-        files: final.info
-        fail_ci_if_error: true
diff --git a/.github/workflows/simulation-1.4.yml b/.github/workflows/simulation.yml
similarity index 60%
rename from .github/workflows/simulation-1.4.yml
rename to .github/workflows/simulation.yml
index 26d5133..505d5f1 100644
--- a/.github/workflows/simulation-1.4.yml
+++ b/.github/workflows/simulation.yml
@@ -26,7 +26,7 @@
 #  POSSIBILITY OF SUCH DAMAGE.
 #
 
-name: Simulation 1.4
+name: Simulation
 
 on:
   push:
@@ -40,100 +40,16 @@
   group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.repository == 'openthread/openthread' && github.run_id) || github.ref }}
   cancel-in-progress: true
 
-permissions:  # added using https://github.com/step-security/secure-workflows
+permissions:
   contents: read
 
 jobs:
 
-  thread-1-4:
-    name: thread-1-4-${{ matrix.compiler.c }}-${{ matrix.arch }}
-    runs-on: ubuntu-22.04
-    env:
-      CFLAGS: -${{ matrix.arch }}
-      CXXFLAGS: -${{ matrix.arch }}
-      LDFLAGS: -${{ matrix.arch }}
-      COVERAGE: 1
-      THREAD_VERSION: 1.4
-      VIRTUAL_TIME: 1
-      TEST_TIMEOUT: 300
-      OT_VT_USE_UNIX_SOCKET: 1
-      INTER_OP: 1
-      INTER_OP_BBR: 1
-      CC: ${{ matrix.compiler.c }}
-      CXX: ${{ matrix.compiler.cxx }}
-    strategy:
-      fail-fast: false
-      matrix:
-        compiler: [{c: "gcc", cxx: "g++", gcov: "gcc"}, { c: "clang", cxx: "clang++", gcov: "llvm"}]
-        arch: ["m32", "m64"]
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-      with:
-        submodules: recursive
-    - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
-      with:
-        python-version: '3.12'
-        cache: pip
-    - name: Bootstrap
-      run: |
-        sudo dpkg --add-architecture i386
-        sudo apt-get update
-        sudo apt-get --no-install-recommends install -y ninja-build llvm lcov
-        sudo apt-get --no-install-recommends install -y g++-multilib libreadline-dev:i386 libncurses-dev:i386
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
-    - name: Build
-      run: |
-        ./script/test build
-    - name: Run
-      run: |
-        ulimit -c unlimited
-        ./script/test prepare_coredump_upload
-        ./script/test unit
-        ./script/test cert_suite tests/scripts/thread-cert/v1_2_*
-    - name: Check Crash
-      if: ${{ failure() }}
-      run: |
-          CRASHED=$(./script/test check_crash | tail -1)
-          [[ $CRASHED -eq "1" ]] && echo "Crashed!" || echo "Not crashed."
-          echo "CRASHED=$CRASHED" >> $GITHUB_ENV
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: thread-1-4-${{ matrix.compiler.c }}-${{ matrix.arch }}-pcaps
-        path: "*.pcap"
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() && env.CRASHED == '1' }}
-      with:
-        name: core-packet-verification-thread-1-4
-        path: |
-          ./ot-core-dump/*
-    - name: Generate Coverage
-      run: |
-        ./script/test generate_coverage "${{ matrix.compiler.gcov }}"
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: cov-thread-1-4-${{ matrix.compiler.c }}-${{ matrix.arch }}
-        path: tmp/coverage.info
-        retention-days: 1
-
-  packet-verification-1-1-on-1-4:
+  ot-commissioner:
     runs-on: ubuntu-24.04
-    env:
-      REFERENCE_DEVICE: 1
-      VIRTUAL_TIME: 1
-      OT_VT_USE_UNIX_SOCKET: 1
-      PACKET_VERIFICATION: 1
-      THREAD_VERSION: 1.4
-      INTER_OP_BBR: 1
-      MULTIPLY: 3
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -145,32 +61,67 @@
         python-version: '3.12'
         cache: pip
     - name: Bootstrap
+      env:
+        PR_BODY: "${{ github.event.pull_request.body }}"
       run: |
         sudo apt-get update
-        sudo apt-get --no-install-recommends install -y ninja-build lcov
-        python3 -m pip install -r tests/scripts/thread-cert/requirements.txt
+        sudo apt-get install -y avahi-daemon avahi-utils lcov
+        script/git-tool clone https://github.com/openthread/ot-commissioner.git /tmp/ot-commissioner --depth 1 --branch main
     - name: Build
       run: |
-        ./script/test build
-    - name: Get Thread-Wireshark
-      run: |
-        ./script/test get_thread_wireshark
+        cd /tmp/ot-commissioner
+        script/bootstrap.sh
+        cmake -GNinja                            \
+              -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
+              -DCMAKE_CXX_STANDARD=11            \
+              -DCMAKE_CXX_STANDARD_REQUIRED=ON   \
+              -DCMAKE_BUILD_TYPE=Release         \
+              -DCMAKE_INSTALL_PREFIX=/usr/local  \
+              -DOT_COMM_COVERAGE=ON              \
+              -DOT_COMM_CCM=OFF                  \
+              -S . -B build
+        cmake --build build
+        sudo cmake --install build
     - name: Run
       run: |
-        ./script/test cert_suite ./tests/scripts/thread-cert/Cert_*.py ./tests/scripts/thread-cert/test_*.py
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      if: ${{ failure() }}
-      with:
-        name: packet-verification-1.1-on-1.4-pcaps
-        path: |
-          *.pcap
-          *.json
+        export OT_COMM_OPENTHREAD="$(pwd)"
+        cd /tmp/ot-commissioner/tests/integration
+        ./bootstrap.sh
+        ./run_tests.sh
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
-        name: cov-packet-verification-1-1-on-1-4
+        name: cov-ot-commissioner
+        path: tmp/coverage.info
+        retention-days: 1
+
+  simulation-local-host:
+    runs-on: ubuntu-24.04
+    env:
+      COVERAGE: 1
+    steps:
+    - name: Harden Runner
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+      with:
+        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
+
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+      with:
+        submodules: recursive
+    - name: Bootstrap
+      run: |
+        sudo apt-get --no-install-recommends install -y expect ninja-build lcov
+    - name: Run
+      run: |
+        ./script/check-simulation-local-host
+    - name: Generate Coverage
+      run: |
+        ./script/test generate_coverage gcc
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+      with:
+        name: cov-simulation-local-host
         path: tmp/coverage.info
         retention-days: 1
 
@@ -181,12 +132,11 @@
       CXXFLAGS: -m32
       LDFLAGS: -m32
       COVERAGE: 1
-      THREAD_VERSION: 1.4
       VIRTUAL_TIME: 1
       OT_VT_USE_UNIX_SOCKET: 1
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -209,7 +159,7 @@
       run: |
         ulimit -c unlimited
         ./script/test cert_suite ./tests/scripts/thread-cert/addon_test_channel_manager_autocsl*.py
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: ${{ failure() }}
       with:
         name: channel-manager-csl
@@ -217,7 +167,7 @@
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-channel-manager-csl
         path: tmp/coverage.info
@@ -227,11 +177,10 @@
     runs-on: ubuntu-24.04
     env:
       COVERAGE: 1
-      THREAD_VERSION: 1.4
       VIRTUAL_TIME: 0
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -258,16 +207,16 @@
           CRASHED=$(./script/test check_crash | tail -1)
           [[ $CRASHED -eq "1" ]] && echo "Crashed!" || echo "Not crashed."
           echo "CRASHED=$CRASHED" >> $GITHUB_ENV
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: ${{ failure() && env.CRASHED == '1' }}
       with:
-        name: core-expect-1-4
+        name: core-expect
         path: |
           ./ot-core-dump/*
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-expects
         path: tmp/coverage.info
@@ -275,13 +224,14 @@
 
   upload-coverage:
     needs:
-    - thread-1-4
-    - packet-verification-1-1-on-1-4
+    - ot-commissioner
+    - simulation-local-host
+    - channel-manager-csl
     - expects
     runs-on: ubuntu-22.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -291,7 +241,7 @@
     - name: Bootstrap
       run: |
         sudo apt-get --no-install-recommends install -y lcov
-    - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+    - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
       with:
         path: coverage/
         pattern: cov-*
@@ -300,7 +250,7 @@
       run: |
         script/test combine_coverage
     - name: Upload Coverage
-      uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+      uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
       env:
         CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
       with:
diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml
new file mode 100644
index 0000000..abe1600
--- /dev/null
+++ b/.github/workflows/size-check.yml
@@ -0,0 +1,70 @@
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+name: Size Check
+
+on:
+  push:
+    branches:
+      - 'main'
+  pull_request:
+    branches:
+      - 'main'
+
+permissions:
+  contents: read
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.repository == 'openthread/openthread' && github.run_id) || github.ref }}
+  cancel-in-progress: true
+
+jobs:
+
+  size-check:
+    runs-on: ubuntu-24.04
+    steps:
+    - name: Harden Runner
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+      with:
+        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
+
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+    - name: Run
+      env:
+        PR_BODY: "${{ github.event.pull_request.body }}"
+        PR_NUMBER: "${{ github.event.pull_request.number }}"
+      run: |
+        ./script/check-size
+        cat /tmp/ot-size-report/report_pr >> $GITHUB_STEP_SUMMARY
+        echo "${{ github.event.pull_request.number }}" > /tmp/ot-size-report/pr_number
+    - name: Upload report
+      if: ${{ github.event_name == 'pull_request' }}
+      uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+      with:
+        name: report_pr
+        path: /tmp/ot-size-report
diff --git a/.github/workflows/size-report.yml b/.github/workflows/size-report.yml
new file mode 100644
index 0000000..7e625c8
--- /dev/null
+++ b/.github/workflows/size-report.yml
@@ -0,0 +1,86 @@
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+name: Size Report
+
+on:
+  workflow_run:
+    workflows: ["Size Check"]
+    types:
+      - completed
+
+permissions:
+  contents: read
+  pull-requests: write
+
+jobs:
+
+  size-report:
+    if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
+    runs-on: ubuntu-24.04
+    steps:
+    - name: Download report
+      uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+      with:
+        name: report_pr
+        path: /tmp/ot-size-report
+        run-id: ${{ github.event.workflow_run.id }}
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+
+    - name: Post Report
+      uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
+      id: post-report
+      with:
+        script: |
+          const fs = require('fs')
+
+          const report = fs.readFileSync('/tmp/ot-size-report/report_pr', 'utf8');
+          const pr_number = parseInt(fs.readFileSync('/tmp/ot-size-report/pr_number', 'utf8').trim());
+
+          const params = {
+              issue_number: pr_number,
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              body: report,
+          }
+
+          const response = await github.rest.issues.listComments({
+            issue_number: pr_number,
+            owner: context.repo.owner,
+            repo: context.repo.repo,
+          });
+
+          const kMagicHeader = '<!-- Size Report of **OpenThread** -->'
+          const comment = response.data.find(comment => comment.body.startsWith(kMagicHeader))
+
+          if (comment) {
+            params.comment_id = comment.id;
+            await github.rest.issues.updateComment(params)
+          } else {
+            await github.rest.issues.createComment(params)
+          }
diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml
deleted file mode 100644
index 0fdfb3f..0000000
--- a/.github/workflows/size.yml
+++ /dev/null
@@ -1,119 +0,0 @@
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-name: Size
-
-on:
-  push:
-    branches-ignore:
-      - 'dependabot/**'
-  pull_request_target:
-    branches:
-      - 'main'
-
-permissions:
-  contents: read
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.event.pull_request.number || (github.repository == 'openthread/openthread' && github.run_id) || github.ref }}
-  cancel-in-progress: true
-
-jobs:
-
-  size-check:
-    runs-on: ubuntu-24.04
-    steps:
-    - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
-      with:
-        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
-
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
-    - name: Fetch Pull Request ref
-      if: ${{ github.event_name == 'pull_request_target' }}
-      run: |
-        git fetch --depth 2 origin pull/${{ github.event.pull_request.number }}/merge
-        echo "OT_SHA_NEW=$(git rev-parse FETCH_HEAD)" >> $GITHUB_ENV
-    - name: Run
-      env:
-        PR_BODY: "${{ github.event.pull_request.body }}"
-        PR_NUMBER: "${{ github.event.pull_request.number }}"
-      run: |
-        ./script/check-size
-        cat /tmp/ot-size-report/report_pr >> $GITHUB_STEP_SUMMARY
-    - name: Upload report
-      uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
-      with:
-        name: report_pr
-        path: /tmp/ot-size-report/report_pr
-
-
-  size-report:
-    needs:
-    - size-check
-    permissions:
-      pull-requests: write
-    if: github.event_name == 'pull_request_target'
-    runs-on: ubuntu-24.04
-    steps:
-    - name: Download report
-      uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
-      with:
-        name: report_pr
-        path: /tmp/ot-size-report
-
-    - name: Post Report
-      uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
-      id: post-report
-      with:
-        script: |
-          const fs = require('fs')
-
-          const report = fs.readFileSync('/tmp/ot-size-report/report_pr', 'utf8');
-          const params = {
-              issue_number: context.issue.number,
-              owner: context.repo.owner,
-              repo: context.repo.repo,
-              body: report,
-          }
-
-          const response = await github.rest.issues.listComments({
-            issue_number: context.issue.number,
-            owner: context.repo.owner,
-            repo: context.repo.repo,
-          });
-
-          const kMagicHeader = '<!-- Size Report of **OpenThread** -->'
-          const comment = response.data.find(comment => comment.body.startsWith(kMagicHeader))
-
-          if (comment) {
-            params.comment_id = comment.id;
-            await github.rest.issues.updateComment(params)
-          } else {
-            await github.rest.issues.createComment(params)
-          }
diff --git a/.github/workflows/toranj.yml b/.github/workflows/toranj.yml
index b18753d..336f168 100644
--- a/.github/workflows/toranj.yml
+++ b/.github/workflows/toranj.yml
@@ -59,7 +59,7 @@
       TORANJ_EVENT_NAME: ${{ github.event_name }}
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -94,7 +94,7 @@
       TORANJ_CLI: 1
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -119,7 +119,7 @@
       if: "matrix.TORANJ_RADIO != 'multi'"
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       if: "matrix.TORANJ_RADIO != 'multi'"
       with:
         name: cov-toranj-cli-${{ matrix.TORANJ_RADIO }}
@@ -131,7 +131,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -189,7 +189,7 @@
     runs-on: macos-14
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -212,7 +212,7 @@
     runs-on: ubuntu-22.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -222,7 +222,7 @@
     - name: Bootstrap
       run: |
         sudo apt-get --no-install-recommends install -y lcov
-    - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+    - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
       with:
         path: coverage/
         pattern: cov-*
@@ -231,7 +231,7 @@
       run: |
         script/test combine_coverage
     - name: Upload Coverage
-      uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+      uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
       env:
         CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
       with:
diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml
index e38fa1d..5c2c28e 100644
--- a/.github/workflows/unit.yml
+++ b/.github/workflows/unit.yml
@@ -49,7 +49,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -67,7 +67,7 @@
       COVERAGE: 1
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -98,7 +98,7 @@
     - name: Generate Coverage
       run: |
         ./script/test generate_coverage gcc
-    - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+    - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: cov-unit-tests
         path: tmp/coverage.info
@@ -109,7 +109,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
@@ -119,7 +119,7 @@
     - name: Bootstrap
       run: |
         sudo apt-get --no-install-recommends install -y lcov
-    - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
+    - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
       with:
         path: coverage/
         pattern: cov-*
@@ -128,7 +128,7 @@
       run: |
         script/test combine_coverage
     - name: Upload Coverage
-      uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+      uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
       env:
         CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
       with:
diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml
index 4c82993..a7798d6 100644
--- a/.github/workflows/version.yml
+++ b/.github/workflows/version.yml
@@ -45,7 +45,7 @@
     runs-on: ubuntu-24.04
     steps:
     - name: Harden Runner
-      uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+      uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
       with:
         egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
 
diff --git a/doc/ot_api_doc.h b/doc/ot_api_doc.h
index 07ebf90..86a9768 100644
--- a/doc/ot_api_doc.h
+++ b/doc/ot_api_doc.h
@@ -215,6 +215,7 @@
  * @defgroup plat-radio                  Radio
  * @defgroup plat-settings               Settings
  * @defgroup plat-spi-slave              SPI Slave
+ * @defgroup plat-tcp                    TCP - Platform
  * @defgroup plat-time                   Time Service
  * @defgroup plat-toolchain              Toolchain
  * @defgroup plat-trel                   TREL - Platform
diff --git a/etc/cmake/options.cmake b/etc/cmake/options.cmake
index 5763654..82aff72 100644
--- a/etc/cmake/options.cmake
+++ b/etc/cmake/options.cmake
@@ -170,7 +170,6 @@
 ot_option(OT_ANYCAST_LOCATOR OPENTHREAD_CONFIG_TMF_ANYCAST_LOCATOR_ENABLE "anycast locator")
 ot_option(OT_ASSERT OPENTHREAD_CONFIG_ASSERT_ENABLE "assert function OT_ASSERT()")
 ot_option(OT_BACKBONE_ROUTER OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE "backbone router functionality")
-ot_option(OT_BACKBONE_ROUTER_DUA_NDPROXYING OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE "BBR DUA ND Proxy")
 ot_option(OT_BACKBONE_ROUTER_MULTICAST_ROUTING OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE "BBR MR")
 ot_option(OT_BLE_TCAT OPENTHREAD_CONFIG_BLE_TCAT_ENABLE "Ble based thread commissioning")
 ot_option(OT_BORDER_ADMITTER OPENTHREAD_CONFIG_BORDER_AGENT_ADMITTER_ENABLE "border agent admitter")
@@ -211,7 +210,6 @@
 ot_option(OT_DNS_UPSTREAM_QUERY OPENTHREAD_CONFIG_DNS_UPSTREAM_QUERY_ENABLE "Allow sending DNS queries to upstream")
 ot_option(OT_DNSSD_DISCOVERY_PROXY OPENTHREAD_CONFIG_DNSSD_DISCOVERY_PROXY_ENABLE "DNS-SD discovery proxy")
 ot_option(OT_DNSSD_SERVER OPENTHREAD_CONFIG_DNSSD_SERVER_ENABLE "DNS-SD server")
-ot_option(OT_DUA OPENTHREAD_CONFIG_DUA_ENABLE "Domain Unicast Address (DUA)")
 ot_option(OT_DYNAMIC_STORE_FRAME_AHEAD_COUNTER OPENTHREAD_CONFIG_DYNAMIC_STORE_FRAME_AHEAD_COUNTER_ENABLE "dynamic store frame ahead counter")
 ot_option(OT_ECDSA OPENTHREAD_CONFIG_ECDSA_ENABLE "ECDSA")
 ot_option(OT_EXTERNAL_HEAP OPENTHREAD_CONFIG_HEAP_EXTERNAL_ENABLE "external heap")
@@ -252,6 +250,7 @@
 ot_option(OT_PLATFORM_LOG_CRASH_DUMP OPENTHREAD_CONFIG_PLATFORM_LOG_CRASH_DUMP_ENABLE "platform log crash dump")
 ot_option(OT_PLATFORM_NETIF OPENTHREAD_CONFIG_PLATFORM_NETIF_ENABLE "platform netif")
 ot_option(OT_PLATFORM_POWER_CALIBRATION OPENTHREAD_CONFIG_PLATFORM_POWER_CALIBRATION_ENABLE "power calibration")
+ot_option(OT_PLATFORM_TCP OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE "Platform TCP")
 ot_option(OT_PLATFORM_UDP OPENTHREAD_CONFIG_PLATFORM_UDP_ENABLE "platform UDP")
 ot_option(OT_REFERENCE_DEVICE OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE "test harness reference device")
 ot_option(OT_SEEKER OPENTHREAD_CONFIG_SEEKER_ENABLE "seeker")
@@ -308,6 +307,7 @@
 # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 set(OT_CRYPTO_LIB_VALUES "MBEDTLS" "PSA" "PLATFORM")
 ot_multi_option(OT_CRYPTO_LIB OT_CRYPTO_LIB_VALUES OPENTHREAD_CONFIG_CRYPTO_LIB OPENTHREAD_CONFIG_CRYPTO_LIB_ "set Crypto backend library")
+ot_option(OT_CRYPTO_CCM_ONE_SHOT OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE "platform one-shot AES-CCM* hook")
 
 # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 set(OT_THREAD_VERSION_VALUES "1.1" "1.2" "1.3" "1.3.1" "1.4")
@@ -362,6 +362,7 @@
 ot_int_option(OT_RCP_RESTORATION_MAX_COUNT OPENTHREAD_SPINEL_CONFIG_RCP_RESTORATION_MAX_COUNT "set max RCP restoration count")
 ot_int_option(OT_RCP_TIME_SYNC_INTERVAL OPENTHREAD_SPINEL_CONFIG_RCP_TIME_SYNC_INTERVAL "set host-RCP time sync interval in microseconds")
 ot_int_option(OT_RCP_TX_WAIT_TIME_SECS OPENTHREAD_SPINEL_CONFIG_RCP_TX_WAIT_TIME_SECS "set RCP TX wait TIME in seconds")
+ot_int_option(OT_VENDOR_OUI OPENTHREAD_CONFIG_NET_DIAG_VENDOR_OUI "set the vendor OUI")
 
 # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 
diff --git a/etc/docker/environment/Dockerfile b/etc/docker/environment/Dockerfile
index f3e1f67..a146698 100644
--- a/etc/docker/environment/Dockerfile
+++ b/etc/docker/environment/Dockerfile
@@ -37,7 +37,7 @@
 RUN set -x \
     && cd openthread \
     && ./script/bootstrap \
-    && mkdir build \
+    && mkdir -p build \
     && cd build \
     && cmake -GNinja -DOT_COMMISSIONER=ON -DOT_JOINER=ON -DOT_PLATFORM=simulation .. \
     && ninja
diff --git a/etc/gn/openthread.gni b/etc/gn/openthread.gni
index 1eed3dc..df1c492 100644
--- a/etc/gn/openthread.gni
+++ b/etc/gn/openthread.gni
@@ -141,9 +141,6 @@
     # Enable ECDSA support
     openthread_config_ecdsa_enable = false
 
-    # Enable Domain Unicast Address feature for Thread 1.2
-    openthread_config_dua_enable = false
-
     # Enable Multicast Listener Registration feature for Thread 1.2
     openthread_config_mlr_enable = false
 
diff --git a/include/ble.c b/include/ble.c
index 2264ea3..7a57c52 100644
--- a/include/ble.c
+++ b/include/ble.c
@@ -28,12 +28,15 @@
 
 #include "platform-simulation.h"
 
+#if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
+
 #include <errno.h>
 
 #include <stdio.h>
 #include <stdlib.h>
 
 #include <openthread/error.h>
+#include <openthread/logging.h>
 #include <openthread/tcat.h>
 #include <openthread/platform/ble.h>
 
@@ -43,7 +46,10 @@
 #define PLAT_BLE_MSG_DATA_MAX 2048
 static uint8_t sBleBuffer[PLAT_BLE_MSG_DATA_MAX];
 
-static int sFd = -1;
+static int  sFd              = -1;
+static bool sIsConnected     = false;
+static bool sIsDisconnecting = false;
+static bool sIsEnabled       = false;
 
 static const uint16_t kPortBase = 10000;
 static uint16_t       sPort     = 0;
@@ -103,33 +109,59 @@
 otError otPlatBleEnable(otInstance *aInstance)
 {
     OT_UNUSED_VARIABLE(aInstance);
-    initFds();
+    if (!sIsEnabled)
+    {
+        initFds();
+        sIsEnabled       = true;
+        sIsConnected     = false;
+        sIsDisconnecting = false;
+    }
     return OT_ERROR_NONE;
 }
 
 otError otPlatBleDisable(otInstance *aInstance)
 {
-    deinitFds();
     OT_UNUSED_VARIABLE(aInstance);
+    if (sIsEnabled)
+    {
+        deinitFds();
+        sIsEnabled = false;
+    }
     return OT_ERROR_NONE;
 }
 
 otError otPlatBleGapAdvStart(otInstance *aInstance, uint16_t aInterval)
 {
     OT_UNUSED_VARIABLE(aInstance);
-    OT_UNUSED_VARIABLE(aInterval);
+    if (sIsConnected || !sIsEnabled)
+    {
+        return OT_ERROR_INVALID_STATE;
+    }
+    otLogDebgPlat("BLE adv start (interval %u)", aInterval);
     return OT_ERROR_NONE;
 }
 
 otError otPlatBleGapAdvStop(otInstance *aInstance)
 {
     OT_UNUSED_VARIABLE(aInstance);
+    if (!sIsEnabled)
+    {
+        return OT_ERROR_INVALID_STATE;
+    }
+    otLogDebgPlat("BLE adv stop");
     return OT_ERROR_NONE;
 }
 
 otError otPlatBleGapDisconnect(otInstance *aInstance)
 {
     OT_UNUSED_VARIABLE(aInstance);
+    if (!sIsConnected)
+    {
+        return OT_ERROR_INVALID_STATE;
+    }
+    // Only flag the disconnection here. The 'disconnected' event is delivered asynchronously
+    // by platformBleProcess() (via otPlatBleGapOnDisconnected), per API contract.
+    sIsDisconnecting = true;
     return OT_ERROR_NONE;
 }
 
@@ -164,7 +196,6 @@
 
 void platformBleUpdateFdSet(fd_set *aReadFdSet, fd_set *aWriteFdSet, struct timeval *aTimeout, int *aMaxFd)
 {
-    OT_UNUSED_VARIABLE(aTimeout);
     OT_UNUSED_VARIABLE(aWriteFdSet);
 
     if (aReadFdSet != NULL && sFd != -1)
@@ -176,6 +207,13 @@
             *aMaxFd = sFd;
         }
     }
+
+    // A pending disconnection must be delivered promptly; ensure the main loop does not block.
+    if (sIsDisconnecting && aTimeout != NULL)
+    {
+        aTimeout->tv_sec  = 0;
+        aTimeout->tv_usec = 0;
+    }
 }
 
 void platformBleProcess(otInstance *aInstance, const fd_set *aReadFdSet, const fd_set *aWriteFdSet)
@@ -184,6 +222,14 @@
 
     otEXPECT(sFd != -1);
 
+    // Deliver a pending disconnection (requested earlier via otPlatBleGapDisconnect)
+    if (sIsDisconnecting)
+    {
+        sIsConnected = false;
+        otPlatBleGapOnDisconnected(aInstance, 0);
+        sIsDisconnecting = false;
+    }
+
     if (FD_ISSET(sFd, aReadFdSet))
     {
         socklen_t len = sizeof(sSockaddr);
@@ -193,6 +239,14 @@
         if (rval > 0)
         {
             otBleRadioPacket myPacket;
+
+            if (!sIsConnected)
+            {
+                sIsConnected = true;
+                otLogDebgPlat("BLE client connected");
+                otPlatBleGapOnConnected(aInstance, 0);
+            }
+
             myPacket.mValue  = sBleBuffer;
             myPacket.mLength = (uint16_t)rval;
             myPacket.mPower  = 0;
@@ -211,10 +265,27 @@
             DieNow(OT_EXIT_FAILURE);
         }
     }
+
 exit:
     return;
 }
 
+/* Weak stubs for callbacks defined in the FTD/MTD core library, not available for RCP targets. */
+
+OT_TOOL_WEAK void otPlatBleGapOnConnected(otInstance *aInstance, uint16_t aConnectionId)
+{
+    OT_UNUSED_VARIABLE(aInstance);
+    OT_UNUSED_VARIABLE(aConnectionId);
+    assert(false);
+}
+
+OT_TOOL_WEAK void otPlatBleGapOnDisconnected(otInstance *aInstance, uint16_t aConnectionId)
+{
+    OT_UNUSED_VARIABLE(aInstance);
+    OT_UNUSED_VARIABLE(aConnectionId);
+    assert(false);
+}
+
 OT_TOOL_WEAK void otPlatBleGattServerOnWriteRequest(otInstance             *aInstance,
                                                     uint16_t                aHandle,
                                                     const otBleRadioPacket *aPacket)
@@ -223,9 +294,6 @@
     OT_UNUSED_VARIABLE(aHandle);
     OT_UNUSED_VARIABLE(aPacket);
     assert(false);
-    /* In case of rcp there is a problem with linking to otPlatBleGattServerOnWriteRequest
-     * which is available in FTD/MTD library.
-     */
 }
 
 void otPlatBleGetLinkCapabilities(otInstance *aInstance, otBleLinkCapabilities *aBleLinkCapabilities)
@@ -241,6 +309,10 @@
     OT_UNUSED_VARIABLE(aInstance);
     OT_UNUSED_VARIABLE(aAdvertisementData);
     OT_UNUSED_VARIABLE(aAdvertisementLen);
+    if (!sIsEnabled)
+    {
+        return OT_ERROR_INVALID_STATE;
+    }
     return OT_ERROR_NONE;
 }
 
@@ -249,11 +321,17 @@
     OT_UNUSED_VARIABLE(aInstance);
     OT_UNUSED_VARIABLE(aAdvertisementData);
     OT_UNUSED_VARIABLE(aAdvertisementLen);
+    if (!sIsEnabled)
+    {
+        return OT_ERROR_INVALID_STATE;
+    }
     return OT_ERROR_NONE;
 }
 
 bool otPlatBleSupportsMultiRadio(otInstance *aInstance)
 {
     OT_UNUSED_VARIABLE(aInstance);
-    return false;
+    return true;
 }
+
+#endif // OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
diff --git a/include/openthread/BUILD.gn b/include/openthread/BUILD.gn
index 2d4862f..9309404 100644
--- a/include/openthread/BUILD.gn
+++ b/include/openthread/BUILD.gn
@@ -126,6 +126,7 @@
     "platform/radio.h",
     "platform/settings.h",
     "platform/spi-slave.h",
+    "platform/tcp.h",
     "platform/time.h",
     "platform/toolchain.h",
     "platform/trel.h",
diff --git a/include/openthread/backbone_router_ftd.h b/include/openthread/backbone_router_ftd.h
index f4a5e39..28f81c1 100644
--- a/include/openthread/backbone_router_ftd.h
+++ b/include/openthread/backbone_router_ftd.h
@@ -43,7 +43,6 @@
 #include <openthread/error.h>
 #include <openthread/instance.h>
 #include <openthread/ip6.h>
-#include <openthread/netdata.h>
 
 #ifdef __cplusplus
 extern "C" {
@@ -176,34 +175,6 @@
 void otBackboneRouterSetRegistrationJitter(otInstance *aInstance, uint8_t aJitter);
 
 /**
- * Gets the local Domain Prefix configuration.
- *
- * @param[in]  aInstance A pointer to an OpenThread instance.
- * @param[out] aConfig   A pointer to the Domain Prefix configuration.
- *
- * @retval OT_ERROR_NONE       Successfully got the Domain Prefix configuration.
- * @retval OT_ERROR_NOT_FOUND  No Domain Prefix was configured.
- */
-otError otBackboneRouterGetDomainPrefix(otInstance *aInstance, otBorderRouterConfig *aConfig);
-
-/**
- * Configures response status for next DUA registration.
- *
- * Note: available only when `OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE` is enabled.
- *       Only used for test and certification.
- *
- * TODO: (DUA) support coap error code and corresponding process for certification purpose.
- *
- * @param[in] aInstance A pointer to an OpenThread instance.
- * @param[in] aMlIid    A pointer to the Mesh Local IID. If NULL, respond with @p aStatus for any
- *                      coming DUA.req, otherwise only respond the one with matching @p aMlIid.
- * @param[in] aStatus   The status to respond.
- */
-void otBackboneRouterConfigNextDuaRegistrationResponse(otInstance                     *aInstance,
-                                                       const otIp6InterfaceIdentifier *aMlIid,
-                                                       uint8_t                         aStatus);
-
-/**
  * Configures the response status for the next Multicast Listener Registration.
  *
  * Available when `OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE`,
@@ -318,95 +289,6 @@
                                                  otBackboneRouterMulticastListenerInfo     *aListenerInfo);
 
 /**
- * Represents the ND Proxy events.
- */
-typedef enum
-{
-    OT_BACKBONE_ROUTER_NDPROXY_ADDED   = 0, ///< ND Proxy was added.
-    OT_BACKBONE_ROUTER_NDPROXY_REMOVED = 1, ///< ND Proxy was removed.
-    OT_BACKBONE_ROUTER_NDPROXY_RENEWED = 2, ///< ND Proxy was renewed.
-    OT_BACKBONE_ROUTER_NDPROXY_CLEARED = 3, ///< All ND Proxies were cleared.
-} otBackboneRouterNdProxyEvent;
-
-/**
- * Pointer is called whenever the Nd Proxy changed.
- *
- * @param[in] aContext  The user context pointer.
- * @param[in] aEvent    The ND Proxy event.
- * @param[in] aDua      The Domain Unicast Address of the ND Proxy, or NULL if @p aEvent is
- *                      `OT_BACKBONE_ROUTER_NDPROXY_CLEARED`.
- */
-typedef void (*otBackboneRouterNdProxyCallback)(void                        *aContext,
-                                                otBackboneRouterNdProxyEvent aEvent,
-                                                const otIp6Address          *aDua);
-
-/**
- * Sets the Backbone Router ND Proxy callback.
- *
- * @param[in] aInstance  A pointer to an OpenThread instance.
- * @param[in] aCallback  A pointer to the ND Proxy callback.
- * @param[in] aContext   A user context pointer.
- */
-void otBackboneRouterSetNdProxyCallback(otInstance                     *aInstance,
-                                        otBackboneRouterNdProxyCallback aCallback,
-                                        void                           *aContext);
-
-/**
- * Represents the Backbone Router ND Proxy info.
- */
-typedef struct otBackboneRouterNdProxyInfo
-{
-    otIp6InterfaceIdentifier *mMeshLocalIid;             ///< Mesh-local IID
-    uint32_t                  mTimeSinceLastTransaction; ///< Time since last transaction (Seconds)
-    uint16_t                  mRloc16;                   ///< RLOC16
-} otBackboneRouterNdProxyInfo;
-
-/**
- * Gets the Backbone Router ND Proxy info.
- *
- * @param[in]   aInstance     A pointer to an OpenThread instance.
- * @param[in]   aDua          The Domain Unicast Address.
- * @param[out]  aNdProxyInfo  A pointer to the ND Proxy info.
- *
- * @retval OT_ERROR_NONE       Successfully got the ND Proxy info.
- * @retval OT_ERROR_NOT_FOUND  Failed to find the Domain Unicast Address in the ND Proxy table.
- */
-otError otBackboneRouterGetNdProxyInfo(otInstance                  *aInstance,
-                                       const otIp6Address          *aDua,
-                                       otBackboneRouterNdProxyInfo *aNdProxyInfo);
-
-/**
- * Represents the Domain Prefix events.
- */
-typedef enum
-{
-    OT_BACKBONE_ROUTER_DOMAIN_PREFIX_ADDED   = 0, ///< Domain Prefix was added.
-    OT_BACKBONE_ROUTER_DOMAIN_PREFIX_REMOVED = 1, ///< Domain Prefix was removed.
-    OT_BACKBONE_ROUTER_DOMAIN_PREFIX_CHANGED = 2, ///< Domain Prefix was changed.
-} otBackboneRouterDomainPrefixEvent;
-
-/**
- * Pointer is called whenever the Domain Prefix changed.
- *
- * @param[in] aContext       The user context pointer.
- * @param[in] aEvent         The Domain Prefix event.
- * @param[in] aDomainPrefix  The new Domain Prefix if added or changed, NULL otherwise.
- */
-typedef void (*otBackboneRouterDomainPrefixCallback)(void                             *aContext,
-                                                     otBackboneRouterDomainPrefixEvent aEvent,
-                                                     const otIp6Prefix                *aDomainPrefix);
-/**
- * Sets the Backbone Router Domain Prefix callback.
- *
- * @param[in] aInstance  A pointer to an OpenThread instance.
- * @param[in] aCallback  A pointer to the Domain Prefix callback.
- * @param[in] aContext   A user context pointer.
- */
-void otBackboneRouterSetDomainPrefixCallback(otInstance                          *aInstance,
-                                             otBackboneRouterDomainPrefixCallback aCallback,
-                                             void                                *aContext);
-
-/**
  * @}
  */
 
diff --git a/include/openthread/ble_secure.h b/include/openthread/ble_secure.h
index ef34828..963e8b6 100644
--- a/include/openthread/ble_secure.h
+++ b/include/openthread/ble_secure.h
@@ -138,6 +138,9 @@
 /**
  * Enables the TCAT protocol over BLE Secure.
  *
+ * Vendor info must be set before calling this function. Depending on the policy defined in the vendor info, TCAT may
+ * start in standby mode if the device is commissioned and Thread is enabled.
+ *
  * @param[in]  aInstance         A pointer to an OpenThread instance.
  * @param[in]  aJoinHandler      A pointer to a function that is called when a network join or leave
  *                               operation is requested under guidance of the TCAT Commissioner.
diff --git a/include/openthread/border_agent.h b/include/openthread/border_agent.h
index 8f04e57..fad95f6 100644
--- a/include/openthread/border_agent.h
+++ b/include/openthread/border_agent.h
@@ -258,10 +258,11 @@
 /**
  * Maximum string length of base name used in `otBorderAgentSetMeshCoPServiceBaseName()`.
  *
- * The full DNS label is constructed by appending the Extended Address of the device (as 16-character hex digits) to
+ * To ensure name uniqueness and handle potential name conflicts, the OpenThread Border Agent module appends a
+ * suffix (e.g., " #XXXX" where "XXXX" represents the last two bytes of the device's Extended Address in hex) to
  * the given base name.
  */
-#define OT_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME_MAX_LENGTH (OT_DNS_MAX_LABEL_SIZE - 17)
+#define OT_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME_MAX_LENGTH (OT_DNS_MAX_LABEL_SIZE - 13)
 
 /**
  * Sets the base name to construct the service instance name used when advertising the mDNS `_meshcop._udp` service by
@@ -276,8 +277,9 @@
  * Per the Thread specification, the service instance should be a user-friendly name identifying the device model or
  * product. A recommended format is "VendorName ProductName".
  *
- * To construct the full name and ensure name uniqueness, the OpenThread Border Agent module will append the Extended
- * Address of the device (as 16-character hex digits) to the given base name.
+ * To construct the full name and ensure name uniqueness, the OpenThread Border Agent module appends a suffix
+ * (e.g., " #XXXX" where "XXXX" represents the last two bytes of the device's Extended Address in hex) to the given
+ * base name. If a name conflict is detected on the network, an additional index may be appended (e.g., " #XXXX (1)").
  *
  * Note that the same name will be used for the ephemeral key service `_meshcop-e._udp` when the ephemeral key feature
  * is enabled and used.
diff --git a/include/openthread/border_agent_txt_data.h b/include/openthread/border_agent_txt_data.h
index babae40..6547d1f 100644
--- a/include/openthread/border_agent_txt_data.h
+++ b/include/openthread/border_agent_txt_data.h
@@ -42,6 +42,7 @@
 #include <openthread/dataset.h>
 #include <openthread/error.h>
 #include <openthread/ip6.h>
+#include <openthread/netdiag.h>
 #include <openthread/platform/radio.h>
 
 #ifdef __cplusplus
@@ -152,6 +153,7 @@
     bool                     mHasExtAddress : 1;      ///< Indicates whether Extended Address is present.
     bool                     mHasVendorName : 1;      ///< Indicates whether Vendor Name is present.
     bool                     mHasModelName : 1;       ///< Indicates whether Model Name is present.
+    bool                     mHasVendorOui : 1;       ///< Indicates whether Vendor OUI is present.
     char                     mRecordVersion[OT_BORDER_AGENT_RECORD_VERSION_SIZE]; ///< Record Version string.
     otBorderAgentId          mAgentId;                                            ///< Agent ID.
     char                     mThreadVersion[OT_BORDER_AGENT_THREAD_VERSION_SIZE]; ///< Thread Version string.
@@ -167,6 +169,7 @@
     otExtAddress             mExtAddress;                                         ///< Extended Address.
     char                     mVendorName[OT_BORDER_AGENT_VENDOR_NAME_SIZE];       ///< Vendor Name string.
     char                     mModelName[OT_BORDER_AGENT_MODEL_NAME_SIZE];         ///< Model Name string.
+    otThreadVendorOui        mVendorOui;                                          ///< Vendor OUI.
 } otBorderAgentTxtDataInfo;
 
 /**
diff --git a/include/openthread/border_routing.h b/include/openthread/border_routing.h
index 4b957f5..5d365bd 100644
--- a/include/openthread/border_routing.h
+++ b/include/openthread/border_routing.h
@@ -603,7 +603,6 @@
  *
  * - It has added at least one external route entry.
  * - It has added at least one prefix entry with both the default-route and on-mesh flags set.
- * - It has added at least one domain prefix (with both the domain and on-mesh flags set).
  *
  * The list of peer BRs specifically excludes the current device, even if it is itself acting as a BR.
  *
@@ -629,7 +628,6 @@
  *
  * - It has added at least one external route entry.
  * - It has added at least one prefix entry with both the default-route and on-mesh flags set.
- * - It has added at least one domain prefix (with both the domain and on-mesh flags set).
  *
  * The list of peer BRs specifically excludes the current device, even if it is itself acting as a BR.
  *
diff --git a/include/openthread/cli.h b/include/openthread/cli.h
index df7cdc8..5000d22 100644
--- a/include/openthread/cli.h
+++ b/include/openthread/cli.h
@@ -36,6 +36,8 @@
 #define OPENTHREAD_CLI_H_
 
 #include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
 #include <stdint.h>
 
 #include <openthread/error.h>
@@ -48,17 +50,6 @@
 #endif
 
 /**
- * Represents a CLI command.
- */
-typedef struct otCliCommand
-{
-    const char *mName; ///< A pointer to the command string.
-    otError (*mCommand)(void   *aContext,
-                        uint8_t aArgsLength,
-                        char   *aArgs[]); ///< A function pointer to process the command.
-} otCliCommand;
-
-/**
  * @addtogroup api-cli
  *
  * @brief
@@ -68,7 +59,12 @@
  */
 
 /**
- * Pointer is called to notify about Console output.
+ * Opaque type for a CLI interpreter.
+ */
+typedef struct otCliInterpreter otCliInterpreter;
+
+/**
+ * Pointer is called to notify about CLI interpreter output.
  *
  * @param[out] aContext    A user context pointer.
  * @param[in]  aFormat     The format string.
@@ -80,7 +76,62 @@
     OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(2, 0);
 
 /**
- * Initialize the CLI module.
+ * Gets the size of the CLI interpreter object.
+ *
+ * @returns The size of the CLI interpreter object in bytes.
+ */
+size_t otCliInterpreterGetSize(void);
+
+/**
+ * Initializes a CLI interpreter.
+ *
+ * @param[in]  aBuffer    A pointer to a memory buffer for the CLI interpreter.
+ * @param[in]  aSize      The size of the memory buffer.
+ * @param[in]  aInstance  The OpenThread instance structure.
+ * @param[in]  aCallback  A callback method called to process CLI output.
+ * @param[in]  aContext   A user context pointer.
+ *
+ * @returns A pointer to the initialized CLI interpreter, or `NULL` if @p aSize is too small.
+ */
+otCliInterpreter *otCliInterpreterInit(void               *aBuffer,
+                                       size_t              aSize,
+                                       otInstance         *aInstance,
+                                       otCliOutputCallback aCallback,
+                                       void               *aContext);
+
+/**
+ * Configures whether or not the CLI interpreter outputs the prompt string.
+ *
+ * Requires `OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE`.
+ *
+ * It is enabled by default.
+ *
+ * @param[in]  aInterpreter  A pointer to a CLI interpreter.
+ * @param[in]  aEnable       TRUE to enable outputting the prompt, FALSE to disable.
+ */
+void otCliInterpreterSetPromptConfig(otCliInterpreter *aInterpreter, bool aEnable);
+
+/**
+ * Feeds input to the CLI interpreter.
+ *
+ * @param[in]  aInterpreter  A pointer to a CLI interpreter.
+ * @param[in]  aLine         A pointer to a null-terminated string.
+ */
+void otCliInterpreterInputLine(otCliInterpreter *aInterpreter, char *aLine);
+
+/**
+ * Finalizes the CLI interpreter.
+ *
+ * @param[in]  aInterpreter  A pointer to a CLI interpreter.
+ */
+void otCliInterpreterFinalize(otCliInterpreter *aInterpreter);
+
+//--------------------------------------------------------------------------------------------------------------------
+
+/**
+ * Initialize the static CLI interpreter.
+ *
+ * Requires `OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE`.
  *
  * @param[in]  aInstance   The OpenThread instance structure.
  * @param[in]  aCallback   A callback method called to process CLI output.
@@ -89,26 +140,52 @@
 void otCliInit(otInstance *aInstance, otCliOutputCallback aCallback, void *aContext);
 
 /**
- * Is called to feed in a console input line.
+ * Gets the pointer to the static CLI interpreter.
  *
- * @param[in]  aBuf        A pointer to a null-terminated string.
+ * Requires `OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE`.
+ *
+ * @returns A pointer to the static CLI interpreter.
  */
-void otCliInputLine(char *aBuf);
+otCliInterpreter *otCliGetStaticInterpreter(void);
 
 /**
- * Set a user command table.
+ * Feeds input to the static CLI interpreter.
+ *
+ * Requires `OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE`.
+ *
+ * @param[in]  aLine        A pointer to a null-terminated string.
+ */
+void otCliInputLine(char *aLine);
+
+/**
+ * Represents a user provided CLI command entry.
+ */
+typedef struct otCliCommand
+{
+    const char *mName;                                                       ///< The command string.
+    otError (*mCommand)(void *aContext, uint8_t aArgsLength, char *aArgs[]); ///< Command handler function pointer.
+} otCliCommand;
+
+/**
+ * Set a user command table on the static CLI interpreter.
+ *
+ * Requires `OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE`.
  *
  * @param[in]  aUserCommands  A pointer to an array with user commands.
- * @param[in]  aLength        @p aUserCommands length.
- * @param[in]  aContext       @p The context passed to the handler.
+ * @param[in]  aLength        The @p aUserCommands length.
+ * @param[in]  aContext       The context passed to the handler.
  *
- * @retval OT_ERROR_NONE    Successfully updated command table with commands from @p aUserCommands.
- * @retval OT_ERROR_FAILED  Maximum number of command entries have already been set.
+ * @retval OT_ERROR_NONE     Successfully updated command table with commands from @p aUserCommands.
+ * @retval OT_ERROR_NO_BUFS  Maximum number of command entries have already been set.
  */
 otError otCliSetUserCommands(const otCliCommand *aUserCommands, uint8_t aLength, void *aContext);
 
 /**
- * Write a number of bytes to the CLI console as a hex string.
+ * Write a number of bytes to the static CLI interpreter output as a hex string.
+ *
+ * Requires `OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE`.
+ *
+ * This is intended for use by user-provided CLI command handlers.
  *
  * @param[in]  aBytes   A pointer to data which should be printed.
  * @param[in]  aLength  @p aBytes length.
@@ -116,7 +193,11 @@
 void otCliOutputBytes(const uint8_t *aBytes, uint8_t aLength);
 
 /**
- * Write formatted string to the CLI console
+ * Write formatted string to the static CLI interpreter output.
+ *
+ * Requires `OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE`.
+ *
+ * This is intended for use by user-provided CLI command handlers.
  *
  * @param[in]  aFmt   A pointer to the format string.
  * @param[in]  ...    A matching list of arguments.
@@ -124,7 +205,11 @@
 void otCliOutputFormat(const char *aFmt, ...) OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(1, 2);
 
 /**
- * Write error code to the CLI console
+ * Write a given error code as the result of previous command to the static CLI interpreter output.
+ *
+ * Requires `OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE`.
+ *
+ * This is intended for use by user-provided CLI command handlers.
  *
  * If the @p aError is `OT_ERROR_PENDING` nothing will be outputted.
  *
@@ -133,7 +218,7 @@
 void otCliAppendResult(otError aError);
 
 /**
- * Callback to write the OpenThread Log to the CLI console
+ * Callback to write the OpenThread Log to the static CLI interpreter output.
  *
  * @param[in]  aLogLevel   The log level.
  * @param[in]  aLogRegion  The log region.
@@ -148,6 +233,8 @@
  *
  * Available when `OPENTHREAD_CONFIG_CLI_VENDOR_COMMANDS_ENABLE` is enabled and
  * `OPENTHREAD_CONFIG_CLI_MAX_USER_CMD_ENTRIES` is greater than 1.
+ *
+ * Requires `OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE`.
  */
 extern void otCliVendorSetUserCommands(void);
 
diff --git a/include/openthread/dataset.h b/include/openthread/dataset.h
index 9f91deb..b2b08ad 100644
--- a/include/openthread/dataset.h
+++ b/include/openthread/dataset.h
@@ -568,6 +568,24 @@
 otError otNetworkNameFromString(otNetworkName *aNetworkName, const char *aNameString);
 
 /**
+ * Indicates whether or not the given Operational Dataset TLVs is a valid Active or Pending Dataset.
+ *
+ * A valid Active Dataset MUST contain all the required TLVs (Active Timestamp, Channel, Channel Mask, Extended PAN ID,
+ * Mesh-Local Prefix, Network Key, Network Name, PAN ID, PSKc, and Security Policy).
+ *
+ * A valid Pending Dataset MUST contain all the required TLVs for an Active Dataset and additionally MUST contain
+ * Pending Timestamp and Delay Timer TLVs.
+ *
+ * This method also checks whether there are duplicated TLVs or the TLVs are not well-formed in the @p aDatasetTlvs.
+ *
+ * @param[in]  aDatasetTlvs  A pointer to dataset TLVs.
+ * @param[in]  aActive       TRUE for Active Dataset, FALSE for Pending Dataset.
+ *
+ * @returns TRUE if @p aDatasetTlvs is a valid Dataset, FALSE otherwise.
+ */
+bool otDatasetIsValid(const otOperationalDatasetTlvs *aDatasetTlvs, bool aActive);
+
+/**
  * Parses an Operational Dataset from a given `otOperationalDatasetTlvs`.
  *
  * @param[in]  aDatasetTlvs  A pointer to dataset TLVs.
@@ -616,6 +634,24 @@
 otError otDatasetUpdateTlvs(const otOperationalDataset *aDataset, otOperationalDatasetTlvs *aDatasetTlvs);
 
 /**
+ * Indicates whether or not a given Operational Dataset (in TLVs format) affects connectivity.
+ *
+ * A Dataset affects connectivity if it contains a different Channel, PAN ID, Mesh Local Prefix, Network Key, or
+ * Security Policy than the current values in use.
+ *
+ * The following security policy changes are considered to affect connectivity:
+ * - Disabling routers (R bit: 1 to 0).
+ * - Enabling non-CCM routers (NCR bit: 0 to 1).
+ * - Increasing the version threshold for routing (VR field).
+ *
+ * @param[in]  aInstance     A pointer to an OpenThread instance.
+ * @param[in]  aDatasetTlvs  A pointer to Operational Dataset TLVs.
+ *
+ * @returns TRUE if @p aDatasetTlvs affects connectivity, FALSE otherwise.
+ */
+bool otDatasetAffectsConnectivity(otInstance *aInstance, const otOperationalDatasetTlvs *aDatasetTlvs);
+
+/**
  * @}
  */
 
diff --git a/include/openthread/instance.h b/include/openthread/instance.h
index 53df177..c6e7c36 100644
--- a/include/openthread/instance.h
+++ b/include/openthread/instance.h
@@ -52,7 +52,7 @@
  *
  * @note This number versions both OpenThread platform and user APIs.
  */
-#define OPENTHREAD_API_VERSION (583)
+#define OPENTHREAD_API_VERSION (610)
 
 /**
  * @addtogroup api-instance
diff --git a/include/openthread/ip6.h b/include/openthread/ip6.h
index 0d9be5f..3d6acb1 100644
--- a/include/openthread/ip6.h
+++ b/include/openthread/ip6.h
@@ -606,6 +606,29 @@
 const uint16_t *otIp6GetUnsecurePorts(otInstance *aInstance, uint8_t *aNumEntries);
 
 /**
+ * Sets whether to allow link-local unsecure IPv6 datagrams when the Thread role is disabled.
+ *
+ * Available only when `OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE` is enabled. This is intended for testing. By default,
+ * this is disabled (i.e., unsecure traffic is always dropped regardless of the device's role).
+ *
+ * @param[in] aInstance  A pointer to an OpenThread instance.
+ * @param[in] aAllow     TRUE to allow, FALSE otherwise.
+ */
+void otIp6SetAllowUnsecureWhenDisabled(otInstance *aInstance, bool aAllow);
+
+/**
+ * Indicates whether allowing link-local unsecure IPv6 datagrams when the Thread role is disabled is enabled.
+ *
+ * Available only when `OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE` is enabled.
+ *
+ * @param[in] aInstance  A pointer to an OpenThread instance.
+ *
+ * @retval TRUE   Does allow unsecure IPv6 datagrams when the Thread role is disabled.
+ * @retval FALSE  Does not allow unsecure IPv6 datagrams when the Thread role is disabled.
+ */
+bool otIp6IsUnsecureAllowedWhenDisabled(otInstance *aInstance);
+
+/**
  * Test if two IPv6 addresses are the same.
  *
  * @param[in]  aFirst   A pointer to the first IPv6 address to compare.
diff --git a/include/openthread/logging.h b/include/openthread/logging.h
index 74f08d9..fc6d7ca 100644
--- a/include/openthread/logging.h
+++ b/include/openthread/logging.h
@@ -39,6 +39,7 @@
 #include <stdint.h>
 
 #include <openthread/error.h>
+#include <openthread/instance.h>
 #include <openthread/platform/logging.h>
 #include <openthread/platform/toolchain.h>
 
@@ -56,24 +57,69 @@
  */
 
 /**
- * Returns the current log level.
+ * Returns the current log level for a given OpenThread instance.
  *
  * If dynamic log level feature `OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE` is enabled, this function returns the
- * currently set dynamic log level. Otherwise, this function returns the build-time configured log level.
+ * currently set dynamic log level:
+ * - In a single-instance configuration, it returns the instance's log level.
+ * - In a multi-instance configuration, it returns the instance-specific log level if it has been explicitly set
+ *   (see `otSetLogLevel()`). Otherwise, it returns the global log level (see `otLoggingGetLevel()`).
+ *
+ * If the dynamic log level feature is not enabled, this function returns the build-time configured log level.
+ *
+ * @param[in] aInstance  The OpenThread instance.
  *
  * @returns The log level.
  */
+otLogLevel otGetLogLevel(otInstance *aInstance);
+
+/**
+ * Sets the log level for a given OpenThread instance.
+ *
+ * @note This function requires `OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE=1`.
+ *
+ * In a single-instance configuration, this function sets the log level for the instance.
+ *
+ * In a multi-instance configuration (`OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE`), if
+ * `OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE` is not enabled, this function returns `OT_ERROR_NOT_CAPABLE`.
+ * When the log level is explicitly set on an instance, it overrides the global log level set using
+ * `otLoggingSetLevel()`.
+ *
+ * @param[in] aInstance  The OpenThread instance.
+ * @param[in] aLogLevel  The log level.
+ *
+ * @retval OT_ERROR_NONE          Successfully updated the log level.
+ * @retval OT_ERROR_INVALID_ARGS  Log level value is invalid.
+ * @retval OT_ERROR_NOT_CAPABLE   Instance-aware logging is not enabled in a multi-instance configuration.
+ */
+otError otSetLogLevel(otInstance *aInstance, otLogLevel aLogLevel);
+
+/**
+ * Returns the current global log level.
+ *
+ * In a single-instance configuration, this function behaves the same as `otGetLogLevel()`.
+ * In a multi-instance configuration, it returns the global log level which is used for all instances.
+ *
+ * @returns The global log level.
+ */
 otLogLevel otLoggingGetLevel(void);
 
 /**
- * Sets the log level.
+ * Sets the global log level.
  *
  * @note This function requires `OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE=1`.
  *
- * @param[in]  aLogLevel               The log level.
+ * In a single-instance configuration, this function behaves the same as `otSetLogLevel()` (which is the recommended
+ * function to use).
  *
- * @retval OT_ERROR_NONE            Successfully updated log level.
- * @retval OT_ERROR_INVALID_ARGS    Log level value is invalid.
+ * In a multi-instance configuration, it sets the global log level which is used by all instances. The log level can
+ * be explicitly set on a specific instance using `otSetLogLevel()`, which will then be used instead of the global
+ * value.
+ *
+ * @param[in]  aLogLevel  The log level.
+ *
+ * @retval OT_ERROR_NONE          Successfully updated the log level.
+ * @retval OT_ERROR_INVALID_ARGS  Log level value is invalid.
  */
 otError otLoggingSetLevel(otLogLevel aLogLevel);
 
diff --git a/include/openthread/mesh_diag.h b/include/openthread/mesh_diag.h
index 66473f5..2655b8b 100644
--- a/include/openthread/mesh_diag.h
+++ b/include/openthread/mesh_diag.h
@@ -41,6 +41,7 @@
 #include <openthread/error.h>
 #include <openthread/instance.h>
 #include <openthread/ip6.h>
+#include <openthread/netdiag.h>
 #include <openthread/thread.h>
 #include <openthread/platform/radio.h>
 
@@ -62,11 +63,15 @@
 /**
  * Represents the set of configurations used when discovering mesh topology indicating which items to
  * discover.
+ *
+ * The `mExtraTlvTypes` pointer can be NULL if `mExtraTlvTypesLength` is zero.
  */
 typedef struct otMeshDiagDiscoverConfig
 {
-    bool mDiscoverIp6Addresses : 1; ///< Whether or not to discover IPv6 addresses of every router.
-    bool mDiscoverChildTable : 1;   ///< Whether or not to discover children of every router.
+    bool           mDiscoverIp6Addresses : 1; ///< Whether or not to discover IPv6 addresses of every router.
+    bool           mDiscoverChildTable : 1;   ///< Whether or not to discover children of every router.
+    const uint8_t *mExtraTlvTypes;            ///< An array of extra Net Diag TLV types to request from every router.
+    uint8_t        mExtraTlvTypesLength;      ///< The length of the `mExtraTlvTypes` array. Can be zero.
 } otMeshDiagDiscoverConfig;
 
 /**
@@ -84,6 +89,18 @@
 typedef struct otMeshDiagChildIterator otMeshDiagChildIterator;
 
 /**
+ * An opaque iterator to iterate over the list of extra Network Diagnostic TLVs returned by a router.
+ *
+ * Pointers to instances of this type are provided in `otMeshDiagRouterInfo`.
+ */
+typedef struct otMeshDiagTlvIterator otMeshDiagTlvIterator;
+
+/**
+ * Represents information about a parsed Network Diagnostic TLV.
+ */
+typedef otNetworkDiagTlv otMeshDiagTlvInfo;
+
+/**
  * Specifies that Thread Version is unknown.
  *
  * This is used in `otMeshDiagRouterInfo` for `mVersion` property when device does not provide its version. This
@@ -136,6 +153,16 @@
      * if the router did not provide the list.
      */
     otMeshDiagChildIterator *mChildIterator;
+
+    /**
+     * A pointer to an iterator to go through the list of extra Network Diagnostic TLVs returned by the router.
+     *
+     * The pointer is valid only while `otMeshDiagRouterInfo` is valid. It can be used in `otMeshDiagGetNextTlvInfo`
+     * to iterate through the extra TLVs returned by the router.
+     *
+     * The pointer may be NULL if there are no extra TLVs (in `otMeshDiagDiscoverConfig`).
+     */
+    otMeshDiagTlvIterator *mTlvIterator;
 } otMeshDiagRouterInfo;
 
 /**
@@ -168,6 +195,39 @@
 /**
  * Starts network topology discovery.
  *
+ * This function initiates a query to discover routers in the Thread network.
+ *
+ * The @p aConfig configuration controls what optional topology information is discovered:
+ * - If `mDiscoverIp6Addresses` is set to true, the list of IPv6 addresses for each router is discovered.
+ * - If `mDiscoverChildTable` is set to true, the list of children for each router is discovered.
+ *
+ * The @p aConfig parameter can be used to request additional standard Network Diagnostic TLVs to be retrieved from
+ * each discovered router during topology discovery. These extra TLVs can then be accessed via the `mTlvIterator` in
+ * the callback's router info.
+ *
+ * The following restrictions and recommendations apply to the use of `mExtraTlvTypes`:
+ * - It MUST NOT contain any of the TLV types that are already requested by the discovery process itself.
+ *   These are:
+ *   - `OT_NETWORK_DIAGNOSTIC_TLV_SHORT_ADDRESS`
+ *   - `OT_NETWORK_DIAGNOSTIC_TLV_EXT_ADDRESS`
+ *   - `OT_NETWORK_DIAGNOSTIC_TLV_ROUTE`
+ *   - `OT_NETWORK_DIAGNOSTIC_TLV_VERSION`
+ *   - `OT_NETWORK_DIAGNOSTIC_TLV_IP6_ADDR_LIST`
+ *   - `OT_NETWORK_DIAGNOSTIC_TLV_CHILD_TABLE`
+ *   If any of these types are included in @p aConfig.mExtraTlvTypes, `OT_ERROR_INVALID_ARGS` is returned.
+ * - The total number of requested TLV types is limited to 32. This limit applies to all TLV types combined, including
+ *   those automatically added by the discovery process and any additional TLVs specified by the caller in
+ *   `mExtraTlvTypes`. If the total count exceeds this limit, `OT_ERROR_NO_BUFS` is returned.
+ * - It is highly recommended to keep the number of additional TLVs small. Requesting many or large TLVs increases the
+ *   size of the Network Diagnostics responses, which can cause message fragmentation, higher network traffic, or
+ *   response packet drops.
+ * - Additional TLVs should be restricted to small metadata elements useful during topology discovery (for example,
+ *   `OT_NETWORK_DIAGNOSTIC_TLV_VENDOR_NAME`, `OT_NETWORK_DIAGNOSTIC_TLV_VENDOR_MODEL`, etc).
+ *   For retrieving larger information (like counters, etc.), separate individual queries should be sent to specific
+ *   nodes instead of using this method.
+ * - The @p aConfig struct and the memory pointed to by its @p mExtraTlvTypes array do not need to persist beyond
+ *   the call to this function.
+ *
  * @param[in] aInstance        The OpenThread instance.
  * @param[in] aConfig          The configuration to use for discovery (e.g., which items to discover).
  * @param[in] aCallback        The callback to report the discovered routers.
@@ -176,7 +236,8 @@
  * @retval OT_ERROR_NONE            The network topology discovery started successfully.
  * @retval OT_ERROR_BUSY            A previous discovery request is still ongoing.
  * @retval OT_ERROR_INVALID_STATE   Device is not attached.
- * @retval OT_ERROR_NO_BUFS         Could not allocate buffer to send discovery messages.
+ * @retval OT_ERROR_NO_BUFS         Could not allocate buffer to send discovery messages or too many extra TLVs.
+ * @retval OT_ERROR_INVALID_ARGS    Invalid @p aConfig (e.g., includes restricted extra TLVs as listed above).
  */
 otError otMeshDiagDiscoverTopology(otInstance                     *aInstance,
                                    const otMeshDiagDiscoverConfig *aConfig,
@@ -221,6 +282,20 @@
 otError otMeshDiagGetNextChildInfo(otMeshDiagChildIterator *aIterator, otMeshDiagChildInfo *aChildInfo);
 
 /**
+ * Iterates through the discovered extra Network Diagnostic TLVs of a router.
+ *
+ * This function MUST be used from the callback `otMeshDiagDiscoverCallback()` and use the `mTlvIterator` from the
+ * `aRouterInfo` struct that is provided as input to the callback.
+ *
+ * @param[in,out]  aIterator    The TLV iterator to use.
+ * @param[out]     aTlvInfo     A pointer to return the extra TLV info (if any).
+ *
+ * @retval OT_ERROR_NONE       Successfully retrieved the next extra TLV. @p aTlvInfo and @p aIterator are updated.
+ * @retval OT_ERROR_NOT_FOUND  No more extra TLVs. Reached the end of the list.
+ */
+otError otMeshDiagGetNextTlvInfo(otMeshDiagTlvIterator *aIterator, otMeshDiagTlvInfo *aTlvInfo);
+
+/**
  * Represents information about a child entry from `otMeshDiagQueryChildTable()`.
  *
  * `mSupportsErrRate` indicates whether or not the error tracking feature is supported and `mFrameErrorRate` and
diff --git a/include/openthread/message.h b/include/openthread/message.h
index 71c5eaf..047ab41 100644
--- a/include/openthread/message.h
+++ b/include/openthread/message.h
@@ -234,7 +234,7 @@
  * Indicates whether the given message may be looped back in a case of a multicast destination address.
  *
  * If @p aMessage is used along with an `otMessageInfo`, the `mMulticastLoop` field from `otMessageInfo` structure
- * takes precedence and will be used instead of the the value set on @p aMessage.
+ * takes precedence and will be used instead of the value set on @p aMessage.
  *
  * This API is mainly intended for use along with `otIp6Send()` which expects an already prepared IPv6 message.
  *
diff --git a/include/openthread/netdata.h b/include/openthread/netdata.h
index f943ec0..e36773a 100644
--- a/include/openthread/netdata.h
+++ b/include/openthread/netdata.h
@@ -73,7 +73,7 @@
     bool        mOnMesh : 1;       ///< Whether this prefix is considered on-mesh.
     bool        mStable : 1;       ///< Whether this configuration is considered Stable Network Data.
     bool        mNdDns : 1;        ///< Whether this border router can supply DNS information via ND.
-    bool        mDp : 1;           ///< Whether prefix is a Thread Domain Prefix (added since Thread 1.2).
+    bool        mDp : 1;           ///< Reserved (previously Thread Domain Prefix flag).
     uint16_t    mRloc16;           ///< The border router's RLOC16 (value ignored on config add).
 } otBorderRouterConfig;
 
diff --git a/include/openthread/netdiag.h b/include/openthread/netdiag.h
index 19a6ab1..d3e76ac 100644
--- a/include/openthread/netdiag.h
+++ b/include/openthread/netdiag.h
@@ -94,6 +94,7 @@
 #define OT_NETWORK_DIAGNOSTIC_TLV_BR_DHCP6_PD_OMR_PREFIX 41 ///< Border Router DHCPv6-PD OMR Prefix TLV
 #define OT_NETWORK_DIAGNOSTIC_TLV_BR_LOCAL_OL_PREFIX 42     ///< Border Router Local On-link Prefix TLV
 #define OT_NETWORK_DIAGNOSTIC_TLV_BR_FAVORED_OL_PREFIX 43   ///< Border Router Favored On-link Prefix TLV
+#define OT_NETWORK_DIAGNOSTIC_TLV_VENDOR_OUI 44             ///< Vendor OUI TLV
 
 #define OT_NETWORK_DIAGNOSTIC_MAX_VENDOR_NAME_TLV_LENGTH 32          ///< Max length of Vendor Name TLV.
 #define OT_NETWORK_DIAGNOSTIC_MAX_VENDOR_MODEL_TLV_LENGTH 32         ///< Max length of Vendor Model TLV.
@@ -274,6 +275,50 @@
 typedef otBorderRoutingState otNetworkDiagBrState;
 
 /**
+ * Specifies the maximum size of a Thread Vendor OUI in bytes.
+ */
+#define OT_THREAD_VENDOR_OUI_MAX_SIZE 5
+
+/**
+ * Specifies the bit length of a MAC Address Block Large (MA-L) Vendor OUI.
+ */
+#define OT_THREAD_VENDOR_OUI_MA_L_BIT_LENGTH 24
+
+/**
+ * Specifies the bit length of a MAC Address Block Medium (MA-M) Vendor OUI.
+ */
+#define OT_THREAD_VENDOR_OUI_MA_M_BIT_LENGTH 28
+
+/**
+ * Specifies the bit length of a MAC Address Block Small (MA-S) Vendor OUI.
+ */
+#define OT_THREAD_VENDOR_OUI_MA_S_BIT_LENGTH 36
+
+/**
+ * Represents a Thread Vendor OUI (Organizationally Unique Identifier) which can have different lengths.
+ *
+ * A Vendor OUI can be assigned in one of the following formats:
+ *
+ * - 24-bit Prefix (MA-L): Exactly 3 bytes (24 bits).
+ *   Example: `00-1A-2B` is represented with `mBitLength = 24` and `mBytes = [0x00, 0x1A, 0x2B, 0x00, 0x00]`.
+ *
+ * - 28-bit Prefix (MA-M): Exactly 3.5 bytes (28 bits).
+ *   The half-byte (4 bits) at the end of the prefix occupies the Most Significant Nibble of the 4th byte.
+ *   The Least Significant Nibble of the 4th byte is set to zero.
+ *   Example: `00-1A-2B-3` is represented with `mBitLength = 28` and `mBytes = [0x00, 0x1A, 0x2B, 0x30, 0x00]`.
+ *
+ * - 36-bit Prefix (MA-S): Exactly 4.5 bytes (36 bits).
+ *   The half-byte (4 bits) at the end of the prefix occupies the Most Significant Nibble of the 5th byte.
+ *   The Least Significant Nibble of the 5th byte is set to zero.
+ *   Example: `00-1A-2B-3C-4` is represented with `mBitLength = 36` and `mBytes = [0x00, 0x1A, 0x2B, 0x3C, 0x40]`.
+ */
+typedef struct otThreadVendorOui
+{
+    uint8_t mBitLength;                            ///< The OUI prefix length in bits (24, 28, or 36).
+    uint8_t mBytes[OT_THREAD_VENDOR_OUI_MAX_SIZE]; ///< The OUI bytes in big-endian order.
+} otThreadVendorOui;
+
+/**
  * Represents a Network Diagnostic TLV.
  */
 typedef struct otNetworkDiagTlv
@@ -304,6 +349,7 @@
         char                      mVendorSwVersion[OT_NETWORK_DIAGNOSTIC_MAX_VENDOR_SW_VERSION_TLV_LENGTH + 1];
         char                      mThreadStackVersion[OT_NETWORK_DIAGNOSTIC_MAX_THREAD_STACK_VERSION_TLV_LENGTH + 1];
         char                      mVendorAppUrl[OT_NETWORK_DIAGNOSTIC_MAX_VENDOR_APP_URL_TLV_LENGTH + 1];
+        otThreadVendorOui         mVendorOui;
         otChannelMask             mNonPreferredChannels;
         otNetworkDiagData         mChannelPages;
         otNetworkDiagChildTable   mChildTable;
@@ -426,6 +472,79 @@
 const char *otThreadGetVendorAppUrl(otInstance *aInstance);
 
 /**
+ * Gets the vendor OUI.
+ *
+ * If no vendor OUI is yet set/configured on device, the `mBitLength` in @p aOui will be zero.
+ *
+ * @param[in]   aInstance  A pointer to an OpenThread instance.
+ * @param[out]  aOui       A pointer to an `otThreadVendorOui` to return the vendor OUI.
+ */
+void otThreadGetVendorOuiInfo(otInstance *aInstance, otThreadVendorOui *aOui);
+
+/**
+ * Sets the vendor OUI.
+ *
+ * Requires `OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE`.
+ *
+ * @param[in]  aInstance  A pointer to an OpenThread instance.
+ * @param[in]  aOui       A pointer to the `otThreadVendorOui` to set.
+ *
+ * @retval OT_ERROR_NONE           Successfully set the vendor OUI.
+ * @retval OT_ERROR_INVALID_ARGS   @p aOui has an invalid length.
+ */
+otError otThreadSetVendorOuiInfo(otInstance *aInstance, const otThreadVendorOui *aOui);
+
+#define OT_THREAD_VENDOR_OUI_STRING_SIZE 16 ///< Recommended size for string representation of a vendor OUI.
+
+/**
+ * Converts a given vendor OUI to a human-readable string.
+ *
+ * The generated string format is hyphen-separated uppercase hexadecimal bytes (e.g., "00-1A-2B" for a 24-bit OUI).
+ * For 28-bit and 36-bit OUIs, the trailing 4-bit nibble is appended as a single hexadecimal digit (e.g., "00-1A-2B-3"
+ * for a 28-bit OUI). If @p aOui is invalid or unspecified, the string "unspecified" is returned.
+ *
+ * If the resulting string does not fit in @p aBuffer (within its @p aSize characters), the string will be truncated
+ * but the outputted string is always null-terminated.
+ *
+ * @param[in]  aOui      The vendor OUI to convert.
+ * @param[out] aBuffer   A pointer to a char array to output the string (MUST NOT be NULL).
+ * @param[in]  aSize     The size of @p aBuffer (in bytes). Recommended to use `OT_THREAD_VENDOR_OUI_STRING_SIZE`.
+ */
+void otThreadVendorOuiToString(const otThreadVendorOui *aOui, char *aBuffer, uint16_t aSize);
+
+#define OT_THREAD_UNSPECIFIED_VENDOR_OUI (0xffffffff) ///< Represents an unspecified Vendor OUI.
+
+/**
+ * Gets the vendor OUI-24.
+ *
+ * @deprecated This function is deprecated. Use `otThreadGetVendorOuiInfo()` instead.
+ *
+ * If the configured Vendor OUI has a prefix length greater than 24 bits, this function returns the most significant
+ * 24 bits (first 3 bytes) of the OUI to maintain backward compatibility.
+ *
+ * @param[in]  aInstance      A pointer to an OpenThread instance.
+ *
+ * @returns The vendor OUI-24 value, or `OT_THREAD_UNSPECIFIED_VENDOR_OUI` if not specified.
+ */
+uint32_t otThreadGetVendorOui(otInstance *aInstance);
+
+/**
+ * Sets the vendor OUI-24.
+ *
+ * @deprecated This function is deprecated. Use `otThreadSetVendorOuiInfo()` instead.
+ *
+ * Requires `OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE`.
+ *
+ * @param[in] aInstance    A pointer to an OpenThread instance.
+ * @param[in] aVendorOui   The vendor OUI-24 value in Hexadecimal representation (e.g., OUI 64-16-66 is represented as
+ *                         `0x641666`). Must be a 24-bit value.
+ *
+ * @retval OT_ERROR_NONE          Successfully set the vendor OUI.
+ * @retval OT_ERROR_INVALID_ARGS  @p aVendorOui is not a valid 24-bit value.
+ */
+otError otThreadSetVendorOui(otInstance *aInstance, uint32_t aVendorOui);
+
+/**
  * Set the vendor name string.
  *
  * Requires `OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE`.
diff --git a/include/openthread/platform/ble.h b/include/openthread/platform/ble.h
index 1285fa1..f744e8e 100644
--- a/include/openthread/platform/ble.h
+++ b/include/openthread/platform/ble.h
@@ -74,10 +74,10 @@
 #define OT_BLE_ADV_INTERVAL_MAX 0x4000
 
 /**
- * Default interval for advertising packet (ms).
+ * Default interval for advertising packet in OT_BLE_ADV_INTERVAL_UNIT units (100 ms).
  */
 
-#define OT_BLE_ADV_INTERVAL_DEFAULT 100
+#define OT_BLE_ADV_INTERVAL_DEFAULT 160
 
 /**
  * Unit used to calculate interval duration (0.625ms).
@@ -104,13 +104,13 @@
 #define OT_BLE_ATT_MTU_DEFAULT 23
 
 /**
- * Default power value for BLE.
+ * Default Tx power value for BLE in dBm.
  */
 
 #define OT_BLE_DEFAULT_POWER 0
 
 /**
- * TOBLE service UUID
+ * ToBLE service UUID (a GATT service UUID for Thread over BLE)
  */
 
 #define OT_TOBLE_SERVICE_UUID 0xfffb
@@ -130,7 +130,7 @@
  */
 typedef struct otBleRadioPacket
 {
-    uint8_t *mValue;  ///< The value of an attribute
+    uint8_t *mValue;  ///< Pointer to the packet data
     uint16_t mLength; ///< Length of the @p mValue.
     int8_t   mPower;  ///< Transmit/receive power in dBm.
 } otBleRadioPacket;
@@ -171,18 +171,16 @@
  * @section Bluetooth Low Energy GAP.
  ***************************************************************************/
 /**
- * Gets BLE Advertising buffer.
+ * Gets a platform-provided buffer for BLE advertising data.
  *
  * @note This function shall be used only for BLE Peripheral role.
- * Returned buffer should have enough space to fit max advertisement
- * defined by specification.
+ * The platform must provide a buffer of at least @p OT_TCAT_ADVERTISEMENT_MAX_LEN bytes.
  *
- * @param[in] aInstance          The OpenThread instance structure.
- * @param[in] aAdvertisementData The formatted TCAT advertisement frame.
- * @param[in] aAdvertisementLen  The TCAT advertisement frame length.
+ * @param[in]  aInstance             The OpenThread instance structure.
+ * @param[out] aAdvertisementBuffer  A pointer to be set to the platform-provided advertisement buffer.
  *
- * @retval OT_ERROR_NONE           Advertising procedure has been started.
- * @retval OT_ERROR_NO_BUFS        No bufferspace available.
+ * @retval OT_ERROR_NONE      Successfully retrieved the advertisement buffer.
+ * @retval OT_ERROR_NO_BUFS   No buffer space available.
  */
 otError otPlatBleGetAdvertisementBuffer(otInstance *aInstance, uint8_t **aAdvertisementBuffer);
 
@@ -190,13 +188,15 @@
  * Sets BLE Advertising data.
  *
  * @note This function shall be used only for BLE Peripheral role.
+ * It shall only be called while advertising is not active.
  *
  * @param[in] aInstance          The OpenThread instance structure.
  * @param[in] aAdvertisementData The formatted TCAT advertisement frame.
- * @param[in] aAdvertisementLen  The TCAT advertisement frame length.
+ * @param[in] aAdvertisementLen  The length of the @p aAdvertisementData frame.
  *
- * @retval OT_ERROR_NONE           Advertising procedure has been started.
+ * @retval OT_ERROR_NONE           Advertising data set successfully.
  * @retval OT_ERROR_INVALID_STATE  BLE Device is in invalid state.
+ * @retval OT_ERROR_FAILED         Setting of data failed.
  * @retval OT_ERROR_INVALID_ARGS   Invalid value has been supplied.
  */
 otError otPlatBleGapAdvSetData(otInstance *aInstance, uint8_t *aAdvertisementData, uint16_t aAdvertisementLen);
@@ -205,12 +205,14 @@
  * Updates BLE Advertising data.
  *
  * @note This function shall be used only for BLE Peripheral role.
+ * It shall only be called while advertising is active.
  *
  * @param[in] aInstance          The OpenThread instance structure.
  * @param[in] aAdvertisementData The formatted TCAT advertisement frame.
- * @param[in] aAdvertisementLen  The TCAT advertisement frame length.
+ * @param[in] aAdvertisementLen  The length of the @p aAdvertisementData frame.
  *
- * @retval OT_ERROR_NONE           Advertising procedure has been started.
+ * @retval OT_ERROR_NONE           Advertising data updated successfully.
+ * @retval OT_ERROR_INVALID_STATE  BLE Device is in invalid state.
  * @retval OT_ERROR_FAILED         Update of data failed.
  * @retval OT_ERROR_INVALID_ARGS   Invalid value has been supplied.
  */
@@ -222,11 +224,15 @@
  * The BLE device shall use undirected advertising with no filter applied.
  * A single BLE Advertising packet must be sent on all advertising
  * channels (37, 38 and 39).
+ * The advertising shall remain active until either otPlatBleGapAdvStop() is
+ * called or a BLE Central Device connects (otPlatBleGapOnConnected()).
+ * The BLE platform is not obliged to exactly match the requested interval
+ * between subsequent advertising packets: it is a requested/desired value.
  *
  * @note This function shall be used only for BLE Peripheral role.
  *
  * @param[in] aInstance  The OpenThread instance structure.
- * @param[in] aInterval  The interval between subsequent advertising packets
+ * @param[in] aInterval  The requested interval between subsequent advertising packets
  *                       in OT_BLE_ADV_INTERVAL_UNIT units.
  *                       Shall be within OT_BLE_ADV_INTERVAL_MIN and
  *                       OT_BLE_ADV_INTERVAL_MAX range or OT_BLE_ADV_INTERVAL_DEFAULT
@@ -251,8 +257,8 @@
 otError otPlatBleGapAdvStop(otInstance *aInstance);
 
 /**
- * The BLE driver calls this method to notify OpenThread that a BLE Central Device has
- * been connected.
+ * The BLE driver calls this function to notify OpenThread that a BLE Central Device has
+ * been connected. The BLE driver MUST stop advertising before calling this function.
  *
  * @param[in]  aInstance     The OpenThread instance structure.
  * @param[in]  aConnectionId The identifier of the open connection.
@@ -260,8 +266,9 @@
 extern void otPlatBleGapOnConnected(otInstance *aInstance, uint16_t aConnectionId);
 
 /**
- * The BLE driver calls this method to notify OpenThread that the BLE Central Device
- * has been disconnected.
+ * The BLE driver calls this function to notify OpenThread that the BLE Central Device
+ * has been disconnected. The BLE driver MUST NOT start advertising before or after this
+ * call: this is controlled explicitly via otPlatBleGapAdvStart().
  *
  * @param[in]  aInstance     The OpenThread instance structure.
  * @param[in]  aConnectionId The identifier of the closed connection.
@@ -272,7 +279,11 @@
  * Disconnects BLE connection.
  *
  * The BLE device shall use the Remote User Terminated Connection (0x13) reason
- * code when disconnecting from the peer BLE device..
+ * code when disconnecting from the peer BLE device.
+ *
+ * This function only triggers the disconnection procedure. When OT_ERROR_NONE is returned,
+ * the platform MUST report completion of the disconnection asynchronously, by invoking
+ * otPlatBleGapOnDisconnected().
  *
  * @param[in] aInstance  The OpenThread instance structure.
  *
@@ -297,7 +308,7 @@
 otError otPlatBleGattMtuGet(otInstance *aInstance, uint16_t *aMtu);
 
 /**
- * The BLE driver calls this method to notify OpenThread that ATT_MTU has been updated.
+ * The BLE driver calls this function to notify OpenThread that ATT_MTU has been updated.
  *
  * @param[in]  aInstance     The OpenThread instance structure.
  * @param[in]  aMtu          The updated ATT_MTU value. It MUST be >=OT_BLE_ATT_MTU_MIN.
@@ -315,7 +326,7 @@
  *
  * @param[in] aInstance   The OpenThread instance structure.
  * @param[in] aHandle     The handle of the attribute to be indicated.
- * @param[in] aPacket     A pointer to the packet contains value to be indicated.
+ * @param[in] aPacket     A pointer to the packet containing the value to be indicated.
  *
  * @retval OT_ERROR_NONE           ATT Handle Value Indication has been sent.
  * @retval OT_ERROR_INVALID_STATE  BLE Device is in invalid state.
@@ -325,29 +336,31 @@
 otError otPlatBleGattServerIndicate(otInstance *aInstance, uint16_t aHandle, const otBleRadioPacket *aPacket);
 
 /**
- * The BLE driver calls this method to notify OpenThread that an ATT Write Request
+ * The BLE driver calls this function to notify OpenThread that an ATT Write Request
  * packet has been received.
  *
  * @note This function shall be used only for GATT Server.
  *
  * @param[in] aInstance   The OpenThread instance structure.
  * @param[in] aHandle     The handle of the attribute to be written.
- * @param[in] aPacket     A pointer to the packet contains value to be written to the attribute.
+ * @param[in] aPacket     A pointer to the packet containing the value to be written to the attribute.
  */
 extern void otPlatBleGattServerOnWriteRequest(otInstance *aInstance, uint16_t aHandle, const otBleRadioPacket *aPacket);
 
 /**
- * Function to retrieve from platform BLE link capabilities.
+ * Retrieve BLE link capabilities from the platform.
  *
  * @param[in]   aInstance             The OpenThread instance structure.
- * @param[out]  aBleLinkCapabilities  The pointer to retrieve the BLE ling capabilities.
+ * @param[out]  aBleLinkCapabilities  The pointer to retrieve the BLE link capabilities into.
  */
 void otPlatBleGetLinkCapabilities(otInstance *aInstance, otBleLinkCapabilities *aBleLinkCapabilities);
 
 /**
- * Function to retrieve from platform multiradio support of BLE and IEEE.
+ * Check if the platform has multi-radio support for BLE and IEEE 802.15.4.
  *
- * @param[in] aInstance             The OpenThread instance structure.
+ * @param[in] aInstance The OpenThread instance structure.
+ *
+ * @returns TRUE if the platform supports simultaneous BLE and IEEE 802.15.4 operation, FALSE otherwise.
  */
 bool otPlatBleSupportsMultiRadio(otInstance *aInstance);
 /**
diff --git a/include/openthread/platform/crypto.h b/include/openthread/platform/crypto.h
index 852da7a..2de0b07 100644
--- a/include/openthread/platform/crypto.h
+++ b/include/openthread/platform/crypto.h
@@ -123,6 +123,9 @@
  * @struct otCryptoContext
  *
  * Stores the context object for platform APIs.
+ *
+ * If `OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT` is enabled, the platform allocates and populates this.
+ * Otherwise OpenThread core allocates and populates this.
  */
 typedef struct otCryptoContext
 {
@@ -325,6 +328,9 @@
  * @retval OT_ERROR_FAILED        Failed to initialize HMAC operation.
  * @retval OT_ERROR_INVALID_ARGS  @p aContext was NULL
  *
+ * @note If `OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT` is enabled, @p aContext is populated by the platform.
+ *       Otherwise OpenThread core allocates and populates it.
+ *
  * @note The platform driver shall point the context to the correct object such as psa_mac_operation_t or
  *       mbedtls_md_context_t.
  */
@@ -389,6 +395,9 @@
  * @retval OT_ERROR_INVALID_ARGS  @p aContext was NULL
  * @retval OT_ERROR_NO_BUFS       Cannot allocate the context.
  *
+ * @note If `OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT` is enabled, @p aContext is populated by the platform.
+ *       Otherwise OpenThread core allocates and populates it.
+ *
  * @note The platform driver shall point the context to the correct object such as psa_key_id
  *       or mbedtls_aes_context_t.
  */
@@ -435,10 +444,13 @@
  *
  * @param[in]  aContext           Context for HKDF operation.
  *
- * @retval OT_ERROR_NONE          Successfully Initialised AES operation.
- * @retval OT_ERROR_FAILED        Failed to Initialise AES operation.
+ * @retval OT_ERROR_NONE          Successfully Initialised HKDF operation.
+ * @retval OT_ERROR_FAILED        Failed to Initialise HKDF operation.
  * @retval OT_ERROR_INVALID_ARGS  @p aContext was NULL
  *
+ * @note If `OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT` is enabled, @p aContext is populated by the platform.
+ *       Otherwise OpenThread core allocates and populates it.
+ *
  * @note The platform driver shall point the context to the correct object such as psa_key_derivation_operation_t
  *       or HmacSha256::Hash
  */
@@ -499,6 +511,8 @@
  * @retval OT_ERROR_FAILED        Failed to initialise SHA-256 operation.
  * @retval OT_ERROR_INVALID_ARGS  @p aContext was NULL
  *
+ * @note If `OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT` is enabled, @p aContext is populated by the platform.
+ *       Otherwise OpenThread core allocates and populates it.
  *
  * @note The platform driver shall point the context to the correct object such as psa_hash_operation_t
  *       or mbedtls_sha256_context.
@@ -736,6 +750,51 @@
                                       uint8_t       *aKey);
 
 /**
+ * @struct otPlatCryptoAesCcmConfig
+ *
+ * Holds the parameters for a one-shot AES-CCM* operation passed to `otPlatCryptoAesCcmProcessOneShot`.
+ */
+typedef struct otPlatCryptoAesCcmConfig
+{
+    otCryptoKey    mKey;             ///< The encryption key.
+    const uint8_t *mNonce;           ///< Pointer to the nonce buffer (IEEE 802.15.4 CCM* format, 13 bytes).
+    uint8_t        mNonceLength;     ///< Length of @p mNonce in bytes.
+    uint8_t        mTagLength;       ///< Authentication tag length in bytes (even)
+    uint32_t       mHeaderLength;    ///< Length of the additional authenticated data (header) in bytes.
+    uint32_t       mPlainTextLength; ///< Payload length in bytes (excluding tag).
+} otPlatCryptoAesCcmConfig;
+
+/**
+ * Performs in-place AES-CCM* authenticated encryption or decryption in a single call.
+ *
+ * For encryption (@p aEncrypt == true):
+ *   - Plaintext at @p aData is replaced with ciphertext in-place.
+ *   - The authentication tag is written to @p aData + @p aConfig->mPlainTextLength.
+ *
+ * For decryption (@p aEncrypt == false):
+ *   - Ciphertext at @p aData is replaced with plaintext in-place.
+ *   - The tag to verify must be at @p aData + @p aConfig->mPlainTextLength.
+ *
+ * Requires `OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE`.
+ *
+ * Default weak mbedTLS and PSA implementations are provided.
+ *
+ * @param[in]      aEncrypt  True to encrypt and generate tag; false to decrypt and verify tag.
+ * @param[in]      aConfig   CCM* parameters (key, nonce, lengths).
+ * @param[in]      aHeader   Additional authenticated data (not encrypted). May be NULL if header length is 0.
+ * @param[in,out]  aData     Payload buffer (plaintext on encrypt entry, ciphertext on decrypt entry).
+ *                           The buffer must hold @p aConfig->mPlainTextLength + @p aConfig->mTagLength bytes.
+ *
+ * @retval OT_ERROR_NONE      Success.
+ * @retval OT_ERROR_SECURITY  Tag mismatch (decrypt only).
+ * @retval OT_ERROR_FAILED    Operation failed.
+ */
+otError otPlatCryptoAesCcmProcessOneShot(bool                            aEncrypt,
+                                         const otPlatCryptoAesCcmConfig *aConfig,
+                                         const uint8_t                  *aHeader,
+                                         uint8_t                        *aData);
+
+/**
  * @}
  */
 
diff --git a/include/openthread/platform/dso_transport.h b/include/openthread/platform/dso_transport.h
index 01afbaf..d8acfa5 100644
--- a/include/openthread/platform/dso_transport.h
+++ b/include/openthread/platform/dso_transport.h
@@ -53,8 +53,7 @@
 typedef struct otPlatDsoConnection otPlatDsoConnection;
 
 /**
- * Can be used by DSO platform implementation to get the the OpenThread instance associated with a
- * connection instance.
+ * Can be used by DSO platform implementation to get the OpenThread instance associated with a connection instance.
  *
  * @param[in] aConnection   A pointer to the DSO connection.
  *
@@ -124,7 +123,7 @@
  * Passes the ownership of the @p aMessage to the DSO platform layer, and the platform implementation is
  * expected to free the message once it is no longer needed.
  *
- * The @p aMessage contains the DNS message (starting with DNS header). Note that it does not contain the the length
+ * The @p aMessage contains the DNS message (starting with DNS header). Note that it does not contain the length
  * field that is needed when sending over TLS/TCP transport. The platform layer MUST therefore include the length
  * field when passing the message to TLS/TCP layer.
  *
diff --git a/include/openthread/platform/infra_if.h b/include/openthread/platform/infra_if.h
index ab4e8ce..9a55c17 100644
--- a/include/openthread/platform/infra_if.h
+++ b/include/openthread/platform/infra_if.h
@@ -47,6 +47,15 @@
 extern "C" {
 #endif
 
+/**
+ * @addtogroup plat-infra-if
+ *
+ * @brief
+ *   This module includes the platform abstraction for the adjacent infrastructure network interface.
+ *
+ * @{
+ */
+
 #define OT_PLAT_INFRA_IF_MAX_LINK_LAYER_ADDR_LENGTH 16 ///< Maximum InfraIf Link-layer address length.
 
 /**
@@ -59,15 +68,6 @@
 } otPlatInfraIfLinkLayerAddress;
 
 /**
- * @addtogroup plat-infra-if
- *
- * @brief
- *   This module includes the platform abstraction for the adjacent infrastructure network interface.
- *
- * @{
- */
-
-/**
  * Tells whether an infra interface has the given IPv6 address assigned.
  *
  * @param[in]  aInstance      The OpenThread instance.
diff --git a/include/openthread/platform/logging.h b/include/openthread/platform/logging.h
index 45971d6..bd64871 100644
--- a/include/openthread/platform/logging.h
+++ b/include/openthread/platform/logging.h
@@ -35,6 +35,7 @@
 #ifndef OPENTHREAD_PLATFORM_LOGGING_H_
 #define OPENTHREAD_PLATFORM_LOGGING_H_
 
+#include <openthread/instance.h>
 #include <openthread/platform/toolchain.h>
 
 #ifdef __cplusplus
@@ -140,6 +141,9 @@
 /**
  * Outputs logs.
  *
+ * This platform API is used to output logs when the configuration `OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE`
+ * is disabled. When the configuration is enabled, `otPlatLogOutput()` is used instead.
+ *
  * Note that the support for log region is removed. The OT core will always emit all logs with `OT_LOG_REGION_CORE`
  * as @p aLogRegion.
  *
@@ -152,10 +156,30 @@
     OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(3, 4);
 
 /**
- * This function handles OpenThread log level changes.
+ * Outputs a log line.
  *
- * This platform function is called whenever the OpenThread log level changes.
- * This platform function is optional since an empty weak implementation has been provided.
+ * This platform API is an alternative to `otPlatLog()` and is used when the configuration
+ * `OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE` is enabled.
+ *
+ * Unlike `otPlatLog()`, this API also provides a pointer to the OpenThread instance  (`otInstance*`) from which the
+ * log is generated. This is particularly helpful in a multi-instance setup to distinguish logs from different
+ * instances. Additionally, it provides the log line as a fully formatted null-terminated string instead of
+ * a format string and variable arguments.
+ *
+ * @param[in]  aInstance   A pointer to the OpenThread instance.
+ * @param[in]  aLogLevel   The log level.
+ * @param[in]  aLogLine    A pointer to the null-terminated string containing the log line.
+ */
+void otPlatLogOutput(otInstance *aInstance, otLogLevel aLogLevel, const char *aLogLine);
+
+/**
+ * Handles OpenThread log level changes.
+ *
+ * This platform function is optional. An empty weak implementation is provided by OpenThread core.
+ *
+ * This platform function is called whenever the OpenThread log level changes:
+ * - In a single-instance configuration, this is called when the log level changes.
+ * - In a multi-instance configuration, this is called when the global log level changes.
  *
  * @note Only applicable when `OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE=1`.
  *
@@ -164,6 +188,21 @@
 void otPlatLogHandleLevelChanged(otLogLevel aLogLevel);
 
 /**
+ * Handles OpenThread instance-specific log level changes.
+ *
+ * This platform function is optional. An empty weak implementation is provided by OpenThread core.
+ *
+ * This platform function is called whenever the instance-specific log level changes:
+ * - In a single-instance configuration, this is called along with `otPlatLogHandleLevelChanged()`.
+ * - In a multi-instance configuration, if `OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE` is used, this is called
+ *   when the instance-specific log level changes.
+ *
+ * @param[in]  aInstance  A pointer to the OpenThread instance.
+ * @param[in]  aLogLevel  The new OpenThread log level for the instance.
+ */
+void otPlatLogHandleLogLevelChanged(otInstance *aInstance, otLogLevel aLogLevel);
+
+/**
  * @}
  */
 
diff --git a/include/openthread/platform/radio.h b/include/openthread/platform/radio.h
index c93458e..abcbe93 100644
--- a/include/openthread/platform/radio.h
+++ b/include/openthread/platform/radio.h
@@ -183,6 +183,25 @@
  */
 typedef struct otExtAddress otExtAddress;
 
+/**
+ * Represents a 64-bit radio time in microseconds referenced to a continuous monotonic local radio clock.
+ *
+ * This type is returned by `otPlatRadioGetNow()` and is used as the timestamp field (`mTimestamp`) in radio frames
+ * (`otRadioFrame`).
+ */
+typedef uint64_t otRadioTime64;
+
+/**
+ * Represents a 32-bit radio time in microseconds.
+ *
+ * This type holds the lower 32 bits (least significant bits) of a full 64-bit radio time (`otRadioTime64`).
+ *
+ * It is used in APIs such as `otPlatRadioReceiveAt()` and `otPlatRadioUpdateCslSampleTime()` and as the transmission
+ * delay base time (`mTxDelayBaseTime`) in `otRadioFrame`. It is important for radio platform implementations to
+ * correctly account for its roll-over.
+ */
+typedef uint32_t otRadioTime32;
+
 #define OT_MAC_KEY_SIZE 16 ///< Size of the MAC Key in bytes.
 
 /**
@@ -273,7 +292,7 @@
              *
              * This field does not affect CCA behavior which is controlled by `mCsmaCaEnabled`.
              */
-            uint32_t mTxDelayBaseTime;
+            otRadioTime32 mTxDelayBaseTime;
 
             /**
              * The delay time in microseconds for this transmission referenced
@@ -359,7 +378,7 @@
              * If `mIsHeaderUpdated` is not set, then the frame counter and key CSL IE not set in the frame by
              * OpenThread core and it is the responsibility of the radio platform to assign them. The platform
              * must update the frame header (assign counter and CSL IE values) before sending the frame over the air,
-             * however if the the transmission gets aborted and the frame is never sent over the air (e.g., channel
+             * however if the transmission gets aborted and the frame is never sent over the air (e.g., channel
              * access error) the platform may choose to not update the header. If the platform updates the header,
              * it must also set this flag before passing the frame back from the `otPlatRadioTxDone()` callback.
              */
@@ -381,7 +400,7 @@
              *
              * The platform should update this field before otPlatRadioTxStarted() is fired for each transmit attempt.
              */
-            uint64_t mTimestamp;
+            otRadioTime64 mTimestamp;
         } mTxInfo;
 
         /**
@@ -393,7 +412,7 @@
              * The time of the local radio clock in microseconds when the end of
              * the SFD was present at the local antenna.
              */
-            uint64_t mTimestamp;
+            otRadioTime64 mTimestamp;
 
             uint32_t mAckFrameCounter; ///< ACK security frame counter (applicable when `mAckedWithSecEnhAck` is set).
             uint8_t  mAckKeyId;        ///< ACK security key index (applicable when `mAckedWithSecEnhAck` is set).
@@ -765,7 +784,7 @@
  * @returns The current time in microseconds. UINT64_MAX when platform does not
  * support or radio time is not ready.
  */
-uint64_t otPlatRadioGetNow(otInstance *aInstance);
+otRadioTime64 otPlatRadioGetNow(otInstance *aInstance);
 
 /**
  * Get the bus speed in bits/second between the host and the radio chip.
@@ -894,7 +913,7 @@
  * @retval OT_ERROR_NONE    Successfully scheduled receive window.
  * @retval OT_ERROR_FAILED  The receive window could not be scheduled. For example, if @p aStart is in the past.
  */
-otError otPlatRadioReceiveAt(otInstance *aInstance, uint8_t aChannel, uint32_t aStart, uint32_t aDuration);
+otError otPlatRadioReceiveAt(otInstance *aInstance, uint8_t aChannel, otRadioTime32 aStart, uint32_t aDuration);
 
 /**
  * The radio driver calls this function to notify OpenThread of a received frame.
@@ -1225,7 +1244,7 @@
  *                               the time when the first symbol of the MHR of
  *                               the frame is expected.
  */
-void otPlatRadioUpdateCslSampleTime(otInstance *aInstance, uint32_t aCslSampleTime);
+void otPlatRadioUpdateCslSampleTime(otInstance *aInstance, otRadioTime32 aCslSampleTime);
 
 /**
  * Get the current estimated worst case accuracy (maximum ± deviation from the
diff --git a/include/openthread/platform/tcp.h b/include/openthread/platform/tcp.h
new file mode 100644
index 0000000..df9432c
--- /dev/null
+++ b/include/openthread/platform/tcp.h
@@ -0,0 +1,439 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ * @brief
+ *   This file includes the abstraction for the platform TCP
+ */
+
+#ifndef OPENTHREAD_PLATFORM_TCP_H_
+#define OPENTHREAD_PLATFORM_TCP_H_
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#include <openthread/error.h>
+#include <openthread/instance.h>
+#include <openthread/ip6.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @addtogroup plat-tcp
+ *
+ * @brief
+ *   This module includes the platform abstraction for TCP connections and listeners.
+ *
+ *   All APIs in this module are applicable only when `OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE` feature is enabled.
+ *
+ * @{
+ */
+
+/**
+ * Represents platform-specific data associated with a connection or a listener.
+ *
+ * This union is provided to add flexibility for the platform. A platform can choose to store a file descriptor
+ * (e.g., an `int` for a POSIX socket) or a pointer to an arbitrary context or state structure needed by the
+ * platform implementation.
+ *
+ * The OpenThread stack guarantees that the `otPlatTcpPlatformData` is fully cleared (all bytes set to zero) when a
+ * new listener or connection instance is initialized.
+ *
+ * For an `otPlatTcpListener`, the `otPlatTcpEnableListener()` call provides an opportunity for the platform to allocate
+ * or update this information. The OpenThread stack guarantees that `otPlatTcpDisableListener()` will be invoked on any
+ * previously enabled listener, providing a deterministic point for the platform implementation to perform cleanup
+ * (e.g., deallocating memory or context structures).
+ *
+ * For an `otPlatTcpConnection`, the `otPlatTcpConnect()` call or the `otPlatTcpAccept()` callback indicate when a new
+ * connection instance is provided, allowing the platform data to be initialized. The platform is responsible for
+ * cleaning up this data either before invoking `otPlatTcpHandleDisconnected()` (which invalidates the connection) or
+ * from an `otPlatTcpAbort()` call. The OpenThread stack guarantees that it will eventually disconnect or abort any
+ * active connection, ensuring a reliable cleanup path.
+ */
+typedef union
+{
+    int   mDescriptor; ///< A value (like a file descriptor).
+    void *mContext;    ///< Pointer to arbitrary platform data.
+} otPlatTcpPlatformData;
+
+/**
+ * Represents a TCP listener.
+ *
+ * The OpenThread core owns and manages the `otPlatTcpListener` instances. The platform should track the pointers
+ * to these instances and use them when invoking the callbacks. The `otPlatTcpListener *` can be viewed as
+ * a "descriptor" or "handle" to the listener.
+ */
+typedef struct otPlatTcpListener
+{
+    otPlatTcpPlatformData mData; ///< Platform implementation specific data.
+} otPlatTcpListener;
+
+/**
+ * Represents a TCP connection.
+ *
+ * The OpenThread core owns and manages the `otPlatTcpConnection` instances. The platform should track the pointers
+ * to these instances and pass them when invoking the `otPlatTcpHandle*` callbacks. The `otPlatTcpConnection *` can
+ * be viewed as a "descriptor" or "handle" to the connection.
+ *
+ * The `otPlatTcpConnection` instance remains valid as long as the connection is active.
+ */
+typedef struct otPlatTcpConnection
+{
+    otPlatTcpPlatformData mData; ///< Platform implementation specific data.
+} otPlatTcpConnection;
+
+/**
+ * Represents a TCP socket address.
+ */
+typedef struct otPlatTcpSockAddr
+{
+    otSockAddr mSockAddr; ///< The socket address (IP address and port number). Use IPv4-mapped IPv6 for IPv4.
+    uint32_t   mIfIndex;  ///< Interface index. Zero indicates any/unspecified.
+} otPlatTcpSockAddr;
+
+/**
+ * Defines the reason for a TCP connection disconnection.
+ */
+typedef enum otPlatTcpDisconnectReason
+{
+    OT_PLAT_TCP_DISCONNECT_REASON_CLOSED,  ///< Connection was gracefully closed.
+    OT_PLAT_TCP_DISCONNECT_REASON_TIMEOUT, ///< Connection timed out (e.g., failed to connect or keepalive failure).
+    OT_PLAT_TCP_DISCONNECT_REASON_REFUSED, ///< Connection was refused by the peer (RST received during handshake).
+    OT_PLAT_TCP_DISCONNECT_REASON_RESET,   ///< Connection was reset by the peer (RST received on established conn).
+    OT_PLAT_TCP_DISCONNECT_REASON_ERROR,   ///< Connection was aborted due to other errors.
+} otPlatTcpDisconnectReason;
+
+/**
+ * Enables a TCP listener.
+ *
+ * The platform should start listening for incoming TCP connections on the provided @p aLocalSockAddr. When an
+ * incoming connection request is received, the platform must invoke the `otPlatTcpAccept()` callback to accept the
+ * request.
+ *
+ * The @p aLocalSockAddr specifies the local interface, address, and port to bind to. Importantly, the port number
+ * within @p aLocalSockAddr must not be zero. The IP address may be unspecified (all zeros) to indicate that the
+ * listener should accept connections on any local address.
+ *
+ * @param[in] aListener      The TCP listener.
+ * @param[in] aLocalSockAddr The local socket address to listen on.
+ *
+ * @retval OT_ERROR_NONE     Successfully enabled or disabled the listener.
+ * @retval OT_ERROR_ALREADY  Already listening on the same port/address.
+ * @retval OT_ERROR_FAILED   Failed to enable the listener.
+ */
+otError otPlatTcpEnableListener(otPlatTcpListener *aListener, const otPlatTcpSockAddr *aLocalSockAddr);
+
+/**
+ * Disables a TCP listener.
+ *
+ * The platform should stop listening for incoming connections on the socket associated with the listener.
+ * Any incoming connection requests that have not yet been accepted should be discarded.
+ *
+ * @param[in] aListener  The TCP listener.
+ */
+void otPlatTcpDisableListener(otPlatTcpListener *aListener);
+
+/**
+ * Callback to accept an incoming TCP connection request on an active listener.
+ *
+ * This function is implemented and provided by the OpenThread stack for the platform to use.
+ *
+ * The callback returns a pointer to an `otPlatTcpConnection` for the new connection. If the callback returns NULL,
+ * the incoming connection is rejected.
+ *
+ * @param[in] aListener      The TCP listener.
+ * @param[in] aPeerSockAddr  The peer's socket address.
+ *
+ * @returns A pointer for the newly accepted connection, or NULL to reject the connection request.
+ */
+extern otPlatTcpConnection *otPlatTcpAccept(otPlatTcpListener *aListener, const otPlatTcpSockAddr *aPeerSockAddr);
+
+/**
+ * Initiates a TCP connection to a peer.
+ *
+ * The platform should initiate a TCP connection to the @p aPeerSockAddr.
+ *
+ * The @p aLocalSockAddr specifies the local address and port to bind to before connecting. It can be NULL if the
+ * OpenThread stack does not specify a preference. If provided, fields within @p aLocalSockAddr may still be left
+ * unspecified (e.g., the IP address can be all zeros, or the port can be zero). In all such cases, the platform
+ * and the underlying TCP stack should automatically select an appropriate local IP address and/or an ephemeral port.
+ *
+ * If `OT_ERROR_NONE` is returned (indicating successful initialization of the connection process), the platform must
+ * subsequently report the status. Upon successful connection establishment, the platform must invoke the
+ * `otPlatTcpHandleConnected` callback. If it fails to establish the connection, the `otPlatTcpHandleDisconnected`
+ * callback must be called to indicate the failure.
+ *
+ * @param[in] aConn          The TCP connection.
+ * @param[in] aPeerSockAddr  The peer's socket address.
+ * @param[in] aLocalSockAddr The local socket address. Can be NULL.
+ *
+ * @retval OT_ERROR_NONE     Successfully initiated the connection.
+ * @retval OT_ERROR_FAILED   Failed to initiate the connection.
+ */
+otError otPlatTcpConnect(otPlatTcpConnection     *aConn,
+                         const otPlatTcpSockAddr *aPeerSockAddr,
+                         const otPlatTcpSockAddr *aLocalSockAddr);
+
+/**
+ * Indicates whether the TCP connection is currently in the connecting state.
+ *
+ * This function is provided by the OpenThread stack. The platform can use it to determine if a TCP connection is still
+ * waiting for the TCP handshake to complete.
+ *
+ * @param[in] aConn  The TCP connection.
+ *
+ * @retval TRUE   The connection is currently in the connecting state.
+ * @retval FALSE  The connection is not in the connecting state.
+ */
+extern bool otPlatTcpIsConnecting(otPlatTcpConnection *aConn);
+
+/**
+ * Callback to notify the connection establishment.
+ *
+ * This callback is implemented and provided by the OpenThread stack. It must be invoked by the platform to indicate
+ * that the TCP handshake is complete and that the connection is now established.
+ *
+ * The platform must call this after a successful call to `otPlatTcpConnect()` when the connection is established.
+ * For incoming connection requests (on an `otPlatTcpListener`), the platform must call this after the
+ * `otPlatTcpAccept()` callback returns successfully and when the connection is established.
+ *
+ * @param[in] aConn   The TCP connection.
+ */
+extern void otPlatTcpHandleConnected(otPlatTcpConnection *aConn);
+
+/**
+ * Notifies the platform that there is pending data for transmission.
+ *
+ * This function is called by the OpenThread stack when it has new data for transmission. After this call, the platform
+ * should indicate when it is ready to accept the data by invoking the `otPlatTcpHandleTxReady()` callback.
+ *
+ * The platform can also use `otPlatTcpIsTxPending()` to check if there is pending data for transmission.
+ *
+ * It is permissible for the platform implementation to invoke the `otPlatTcpHandleTxReady()` callback directly from
+ * within `otPlatTcpNotifyTxPending()` before returning, if the underlying TCP transmit buffer is already available.
+ * The OpenThread stack will handle this correctly.
+ *
+ * @param[in] aConn  The TCP connection.
+ */
+void otPlatTcpNotifyTxPending(otPlatTcpConnection *aConn);
+
+/**
+ * Indicates whether the TCP connection has pending data for transmission.
+ *
+ * This function is provided by the OpenThread stack. The platform can use it to check if there is any pending data
+ * for transmission over the TCP connection.
+ *
+ * @param[in] aConn  The TCP connection.
+ *
+ * @retval TRUE   The connection has pending transmit data.
+ * @retval FALSE  The connection does not have pending transmit data.
+ */
+extern bool otPlatTcpIsTxPending(otPlatTcpConnection *aConn);
+
+/**
+ * Callback to notify that the platform is ready to accept more transmit data.
+ *
+ * This function is implemented and provided by the OpenThread stack for the platform to use.
+ *
+ * The platform should invoke this callback when it is ready to accept more data for transmission over the TCP
+ * connection, in response to a prior `otPlatTcpNotifyTxPending()` call. Upon being called, the OpenThread stack will
+ * use `otPlatTcpSend()` to provide the pending TX data to the platform. The stack may call `otPlatTcpSend()` multiple
+ * times during the execution of this callback.
+ *
+ * @param[in] aConn  The TCP connection.
+ */
+extern void otPlatTcpHandleTxReady(otPlatTcpConnection *aConn);
+
+/**
+ * Sends data over an active TCP connection.
+ *
+ * This function is called by the OpenThread stack to provide data for the platform to transmit. The data is provided
+ * in a buffer. The platform should copy as much data as it can from the given buffer into its underlying platform
+ * transmit buffer.
+ *
+ * The provided @p aBuffer is temporary. The platform must not store the pointer or assume the content remains valid
+ * after this function returns. All required data must be copied during this call.
+ *
+ * The OpenThread stack typically invokes this function from the `otPlatTcpHandleTxReady()` callback. However, the
+ * platform implementation must not assume this and should support being called at any time. If there is no space
+ * available to accept any data, the platform can return zero.
+ *
+ * The OpenThread stack may call this function multiple times back-to-back to provide all queued transmit content
+ * in chunks. The platform should be prepared to handle consecutive calls efficiently.
+ *
+ * @param[in] aConn    The TCP connection.
+ * @param[in] aBuffer  A pointer to the buffer containing the data to send.
+ * @param[in] aLength  The length (in bytes) of the data in the buffer.
+ *
+ * @returns The actual number of bytes accepted for transmission.
+ */
+uint16_t otPlatTcpSend(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength);
+
+/**
+ * Callback to notify the reception of data on a connection.
+ *
+ * This function is implemented and provided by the OpenThread stack for the platform to use.
+ *
+ * The platform invokes this callback to provide received data to the OpenThread stack. The provided @p aBuffer
+ * only needs to remain valid for the duration of this call. The OpenThread stack will process and copy the
+ * bytes as needed, and will not retain the @p aBuffer pointer after the function returns.
+ *
+ * Since TCP is a stream protocol, data can arrive in arbitrarily sized chunks. The platform does not need to
+ * buffer or reassemble these; it can invoke this callback immediately as data is received, even if it is expecting
+ * more data. The OpenThread stack handles all stream-level behavior, processing, and retention of the
+ * received data. This helps simplify the platform implementation.
+ *
+ * On certain platforms (such as standard POSIX), a return value of 0 from `read()` or `recv()` indicates an
+ * End-of-File (EOF) or graceful closure by the peer. The platform implementation must check for this condition and
+ * report it by invoking `otPlatTcpHandleDisconnected()` with the reason set to `OT_PLAT_TCP_DISCONNECT_REASON_CLOSED`.
+ * Importantly, calling `otPlatTcpHandleReceive()` with `aLength` set to zero does not signify a graceful closure in
+ * the `otPlatTcp` APIs; such a call is treated as a no-op receive event by the OpenThread stack and is ignored.
+ *
+ * @param[in] aConn    The TCP connection.
+ * @param[in] aBuffer  A pointer to the buffer containing the received data. Must not be NULL if @p aLength > 0.
+ * @param[in] aLength  The length (in bytes) of the received data.
+ */
+extern void otPlatTcpHandleReceive(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength);
+
+/**
+ * Gracefully closes the TCP connection.
+ *
+ * This function initiates a graceful closure of the connection. The platform should transmit any remaining data
+ * before performing the standard TCP connection termination.
+ *
+ * Once the connection is fully disconnected, or if it is already closed, or if an error occurs during the close
+ * operation, the platform must indicate this by invoking the `otPlatTcpHandleDisconnected` callback.
+ *
+ * The platform must always call `otPlatTcpHandleDisconnected()` to report the final outcome of the connection.
+ * It is permissible for the platform implementation to invoke this callback directly from within `otPlatTcpClose()`
+ * before returning. The OpenThread stack will handle this correctly.
+ *
+ * @param[in] aConn   The TCP connection.
+ */
+void otPlatTcpClose(otPlatTcpConnection *aConn);
+
+/**
+ * Aborts the TCP connection.
+ *
+ * This function forcefully terminates the connection. Any unsent data is discarded.
+ *
+ * After this call, the platform must forget the @p aConn. Importantly, it must not invoke any callbacks using the
+ * @p aConn any longer, including `otPlatTcpHandleDisconnected`. This effectively indicates to the platform that the
+ * OpenThread core is de-allocating the @p aConn instance and it is no longer valid.
+ *
+ * @param[in] aConn   The TCP connection.
+ */
+void otPlatTcpAbort(otPlatTcpConnection *aConn);
+
+/**
+ * Callback to notify the connection disconnection.
+ *
+ * This function is implemented and provided by the OpenThread stack for the platform to use.
+ *
+ * This callback should be invoked by the platform when it fails to establish a connection, when an established
+ * connection is successfully closed (by both endpoints), when it times out, or is reset or aborted.
+ *
+ * After this callback is invoked, the `otPlatTcpConnection` instance is no longer valid. The platform must not use it
+ * in any future callbacks.
+ *
+ * @param[in] aConn   The TCP connection.
+ * @param[in] aReason The reason for the disconnection.
+ */
+extern void otPlatTcpHandleDisconnected(otPlatTcpConnection *aConn, otPlatTcpDisconnectReason aReason);
+
+/**
+ * Gets the OpenThread instance associated with a given TCP connection.
+ *
+ * This function is provided by OpenThread core. Platform implementations can use it to get the OpenThread instance
+ * associated with an active `otPlatTcpConnection`.
+ *
+ * @param[in] aConn  The TCP connection.
+ *
+ * @returns The OpenThread instance.
+ */
+extern otInstance *otPlatTcpGetInstanceForConnection(otPlatTcpConnection *aConn);
+
+/**
+ * Gets the OpenThread instance associated with a given TCP listener.
+ *
+ * This function is provided by OpenThread core. Platform implementations can use it to get the OpenThread instance
+ * associated with an active `otPlatTcpListener`.
+ *
+ * @param[in] aListener  The TCP listener.
+ *
+ * @returns The OpenThread instance.
+ */
+extern otInstance *otPlatTcpGetInstanceForListener(otPlatTcpListener *aListener);
+
+/**
+ * Iterates through the active TCP listeners.
+ *
+ * This function can be used to iterate over all currently active TCP listeners associated with the OpenThread
+ * instance. It allows platform implementations to process or manage listeners without needing to maintain their own
+ * list of active listeners.
+ *
+ * The iteration is guaranteed to remain consistent even if callbacks (e.g., `otPlatTcpAccept`) are invoked during the
+ * process.
+ *
+ * @param[in] aInstance      The OpenThread instance.
+ * @param[in] aPrevListener  A pointer to the previous listener, or `NULL` to start the iteration from the beginning.
+ *
+ * @returns A pointer to the next listener, or `NULL` if there are no more listeners.
+ */
+extern otPlatTcpListener *otPlatTcpIterateListeners(otInstance *aInstance, otPlatTcpListener *aPrevListener);
+
+/**
+ * Iterates through the active TCP connections.
+ *
+ * This function can be used to iterate over all currently active TCP connections associated with the OpenThread
+ * instance. It allows platform implementations to process or manage connections without needing to maintain their own
+ * list of active connections.
+ *
+ * The iteration is guaranteed to remain consistent and safe even if callbacks are invoked during the process. For
+ * example, if a connection is reported as disconnected via `otPlatTcpHandleDisconnected` during iteration, the
+ * OpenThread stack ensures that the connection entry remains valid until the iteration is completed.
+ *
+ * @param[in] aInstance  The OpenThread instance.
+ * @param[in] aPrevConn  A pointer to the previous connection, or `NULL` to start the iteration from the beginning.
+ *
+ * @returns A pointer to the next connection, or `NULL` if there are no more connections.
+ */
+extern otPlatTcpConnection *otPlatTcpIterateConnections(otInstance *aInstance, otPlatTcpConnection *aPrevConn);
+
+/**
+ * @}
+ */
+
+#ifdef __cplusplus
+} // extern "C"
+#endif
+
+#endif // OPENTHREAD_PLATFORM_TCP_H_
diff --git a/include/openthread/tcat.h b/include/openthread/tcat.h
index 2dce6e8..76619d2 100644
--- a/include/openthread/tcat.h
+++ b/include/openthread/tcat.h
@@ -76,6 +76,58 @@
 #define OT_TCAT_ENABLE_MAX 600                 ///< TCAT_ENABLE_MAX, default max TMF TCAT enable time,  in seconds.
 
 /**
+ * Represents TCAT command TLV type.
+ */
+typedef enum otTcatCommandTlvType
+{
+    // Command Class General
+    OT_TCAT_TLV_RESPONSE_WITH_STATUS      = 0x01, ///< TCAT response with status value TLV
+    OT_TCAT_TLV_RESPONSE_WITH_PAYLOAD     = 0x02, ///< TCAT response with payload TLV
+    OT_TCAT_TLV_RESPONSE_EVENT            = 0x03, ///< TCAT response event TLV (reserved)
+    OT_TCAT_TLV_GET_NETWORK_NAME          = 0x08, ///< TCAT network name query TLV
+    OT_TCAT_TLV_DISCONNECT                = 0x09, ///< TCAT disconnect request TLV
+    OT_TCAT_TLV_PING                      = 0x0A, ///< TCAT ping request TLV
+    OT_TCAT_TLV_GET_DEVICE_ID             = 0x0B, ///< TCAT device ID query TLV
+    OT_TCAT_TLV_GET_EXTENDED_PAN_ID       = 0x0C, ///< TCAT extended PAN ID query TLV
+    OT_TCAT_TLV_GET_PROVISIONING_URL      = 0x0D, ///< TCAT provisioning URL query TLV
+    OT_TCAT_TLV_PRESENT_PSKD_HASH         = 0x10, ///< TCAT rights elevation request TLV using PSKd hash
+    OT_TCAT_TLV_PRESENT_PSKC_HASH         = 0x11, ///< TCAT rights elevation request TLV using PSKc hash
+    OT_TCAT_TLV_PRESENT_INSTALL_CODE_HASH = 0x12, ///< TCAT rights elevation TLV using install code
+    OT_TCAT_TLV_REQUEST_RANDOM_CHALLENGE  = 0x13, ///< TCAT random number challenge query TLV
+
+    // Command Class Commissioning
+    OT_TCAT_TLV_SET_ACTIVE_OPERATIONAL_DATASET     = 0x20, ///< TCAT active operational dataset TLV
+    OT_TCAT_TLV_SET_ACTIVE_OPERATIONAL_DATASET_ALT = 0x21, ///< TCAT active dataset alt #1 TLV (reserved)
+    OT_TCAT_TLV_GET_COMMISSIONER_CERTIFICATE       = 0x25, ///< TCAT commissioner certificate query TLV
+    OT_TCAT_TLV_GET_DIAGNOSTIC_TLVS                = 0x26, ///< TCAT diagnostics TLVs query TLV
+    OT_TCAT_TLV_START_THREAD_INTERFACE             = 0x27, ///< TCAT start thread interface request TLV
+    OT_TCAT_TLV_STOP_THREAD_INTERFACE              = 0x28, ///< TCAT stop thread interface request TLV
+
+    // Command Class Extraction
+    OT_TCAT_TLV_GET_ACTIVE_OPERATIONAL_DATASET     = 0x40, ///< TCAT active operational dataset query TLV
+    OT_TCAT_TLV_GET_ACTIVE_OPERATIONAL_DATASET_ALT = 0x41, ///< TCAT active dataset alt #1 query TLV (reserved)
+
+    // Command Class Decommissioning
+    OT_TCAT_TLV_DECOMMISSION = 0x60, ///< TCAT decommission request TLV
+
+    // Command Class Application
+    OT_TCAT_TLV_GET_APPLICATION_LAYERS    = 0x80, ///< TCAT get application layers request TLV
+    OT_TCAT_TLV_SEND_APPLICATION_DATA_1   = 0x81, ///< TCAT send application data 1 TLV
+    OT_TCAT_TLV_SEND_APPLICATION_DATA_2   = 0x82, ///< TCAT send application data 2 TLV
+    OT_TCAT_TLV_SEND_APPLICATION_DATA_3   = 0x83, ///< TCAT send application data 3 TLV
+    OT_TCAT_TLV_SEND_APPLICATION_DATA_4   = 0x84, ///< TCAT send application data 4 TLV
+    OT_TCAT_TLV_SERVICE_NAME_UDP          = 0x89, ///< TCAT service name UDP sub-TLV (not used as a command)
+    OT_TCAT_TLV_SERVICE_NAME_TCP          = 0x8A, ///< TCAT service name TCP sub-TLV (not used as a command)
+    OT_TCAT_TLV_SEND_VENDOR_SPECIFIC_DATA = 0x9F, ///< TCAT send vendor specific command or data TLV
+
+    // Command Class CCM
+    OT_TCAT_TLV_SET_LDEV_ID_OPERATIONAL_CERT = 0xA0, ///< TCAT set LDevID certificate TLV (reserved)
+    OT_TCAT_TLV_SET_LDEV_ID_PRIVATE_KEY      = 0xA1, ///< TCAT set LDevID certificate private key TLV (reserved)
+    OT_TCAT_TLV_SET_DOMAIN_CA_CERT           = 0xA2, ///< TCAT set domain CA certificate TLV (reserved)
+
+} otTcatCommandTlvType;
+
+/**
  * Represents TCAT status code.
  */
 typedef enum otTcatStatusCode
@@ -154,35 +206,6 @@
 } otTcatGeneralDeviceId;
 
 /**
- * This structure represents a TCAT vendor information.
- *
- * The content of this structure MUST persist and remain unchanged while a TCAT session is running.
- */
-typedef struct otTcatVendorInfo
-{
-    const char *mProvisioningUrl; ///< Provisioning URL path string
-    const char *mVendorName;      ///< Vendor name string
-    const char *mVendorModel;     ///< Vendor model string
-    const char *mVendorSwVersion; ///< Vendor software version string
-    const char *mVendorData;      ///< Vendor specific data string
-    const char *mPskdString;      ///< Vendor managed pre-shared key for device
-    const char *mInstallCode;     ///< Vendor managed install code string
-    const otTcatAdvertisedDeviceId
-        *mAdvertisedDeviceIds;                     /** Vendor managed advertised device ID array.
-                                                       Array is terminated like C string with OT_TCAT_DEVICE_ID_EMPTY */
-    const otTcatGeneralDeviceId *mGeneralDeviceId; /** Vendor managed general device ID array.
-                                                       (if NULL: device ID is set to EUI-64 in binary format) */
-    const char *mApplicationServiceName[OT_TCAT_APPLICATION_LAYER_MAX_COUNT]; /** Array with application service names
-                                                                                  as C string with maximum length
-                                                                                  OT_TCAT_SERVICE_NAME_MAX_LENGTH or
-                                                                                  NULL if not supported */
-    bool mApplicationServiceIsTcp[OT_TCAT_APPLICATION_LAYER_MAX_COUNT];       /** Array with boolean values indicating
-                                                                                  if the service is of TCP type (otherwise
-                                                                                  UDP) */
-
-} otTcatVendorInfo;
-
-/**
  * Pointer to call when application data or vendor-specific data was received over a TCAT TLS connection.
  * The application may generate a response to an incoming TCAT application data packet. The TCAT agent
  * automatically responds with status OT_TCAT_STATUS_UNSUPPORTED if no response has been generated or
@@ -201,9 +224,12 @@
                                                    void                     *aContext);
 
 /**
- * Pointer to call to notify the completion of a network join/leave operation performed under
- * guidance of a TCAT Commissioner.
+ * Pointer to call to notify of a network join/leave operation initiated under guidance of a TCAT Commissioner.
  *
+ * @param[in]  aInstance        A pointer to an OpenThread instance.
+ * @param[in]  aIsJoin          True if the operation was a network join (OT_TCAT_TLV_START_THREAD_INTERFACE),
+ *                              false if it was a network leave (OT_TCAT_TLV_STOP_THREAD_INTERFACE or
+ *                              OT_TCAT_TLV_DECOMMISSION).
  * @param[in]  aError           OT_ERROR_NONE if the network join/leave operation was successfully started.
  *                              OT_ERROR_INVALID_STATE if network join was requested but network credentials
  *                                                     were missing or incomplete.
@@ -213,7 +239,71 @@
  *                                                credential mismatch.
  * @param[in]  aContext         A pointer to arbitrary context information.
  */
-typedef void (*otHandleTcatJoin)(otError aError, void *aContext);
+typedef void (*otHandleTcatJoin)(otInstance *aInstance, bool aIsJoin, otError aError, void *aContext);
+
+/**
+ * Pointer to call to control if a TCAT TLV of a specific type is supported. The application may allow
+ * or reject processing of a received TCAT command based on an application defined policy.
+ * If no handler is defined, all received TCAT commands will be allowed if the respective command class
+ * is authorized. If a handler is defined and returns false, the TCAT command will be rejected with status
+ * OT_TCAT_STATUS_UNSUPPORTED. If the handler returns true, the TCAT command will be allowed if the respective
+ * command class is authorized.
+ *
+ * @param[in]  aInstance                 A pointer to an OpenThread instance.
+ * @param[in]  aTlvType                  A TLV type to be authorized.
+ * @param[in]  aContext                  A pointer to arbitrary context information.
+ *
+ * @returns a boolean value indicating whether the TLV type is supported, based on current policy.
+ */
+typedef bool (*otHandleTcatTlvSupport)(otInstance *aInstance, otTcatCommandTlvType aTlvType, void *aContext);
+
+/**
+ * This structure represents a TCAT vendor information.
+ *
+ * The content of this structure MUST persist and remain unchanged while a TCAT session is running.
+ */
+typedef struct otTcatVendorInfo
+{
+    const char *mProvisioningUrl; ///< Provisioning URL path string
+    const char *mVendorName;      ///< Vendor name string
+    const char *mVendorModel;     ///< Vendor model string
+    const char *mVendorSwVersion; ///< Vendor software version string
+    const char *mVendorData;      ///< Vendor specific data string
+    const char *mPskdString;      ///< Vendor managed pre-shared key for device
+    const char *mInstallCode;     ///< Vendor managed install code string
+
+    /**
+     * Vendor managed advertised device ID array. Array is terminated like C string with OT_TCAT_DEVICE_ID_EMPTY.
+     */
+    const otTcatAdvertisedDeviceId *mAdvertisedDeviceIds;
+
+    /**
+     * Vendor managed general device ID array (if NULL: device ID is set to EUI-64 in binary format)
+     */
+    const otTcatGeneralDeviceId *mGeneralDeviceId;
+
+    /**
+     * Array with application service names as C string with maximum length OT_TCAT_SERVICE_NAME_MAX_LENGTH or NULL if
+     * not supported.
+     */
+    const char *mApplicationServiceName[OT_TCAT_APPLICATION_LAYER_MAX_COUNT];
+
+    /**
+     * Array with boolean values indicating if the service is of TCP type (otherwise UDP).
+     */
+    bool mApplicationServiceIsTcp[OT_TCAT_APPLICATION_LAYER_MAX_COUNT];
+
+    bool mKeepActiveAfterJoining; ///< Continue advertising after thread interface has joined a network
+
+    /**
+     * Prevent activating advertising indefinitely after the TCAT command OT_TCAT_TLV_STOP_THREAD_INTERFACE or
+     * OT_TCAT_TLV_DECOMMISSION has been received.
+     */
+    bool mDoNotActivateAfterLeaving;
+
+    otHandleTcatTlvSupport mTlvSupportHandler; ///< Optional pointer to a function to control TCAT TLV support
+
+} otTcatVendorInfo;
 
 /**
  * @}
diff --git a/include/openthread/thread.h b/include/openthread/thread.h
index c7b7ea2..de5b137 100644
--- a/include/openthread/thread.h
+++ b/include/openthread/thread.h
@@ -629,38 +629,6 @@
 otError otThreadSetDomainName(otInstance *aInstance, const char *aDomainName);
 
 /**
- * Sets or clears the Interface Identifier manually specified for the Thread Domain Unicast Address.
- *
- * Available when `OPENTHREAD_CONFIG_DUA_ENABLE` is enabled.
- *
- * @note Only available since Thread 1.2.
- *
- * @param[in]  aInstance   A pointer to an OpenThread instance.
- * @param[in]  aIid        A pointer to the Interface Identifier to set or NULL to clear.
- *
- * @retval OT_ERROR_NONE           Successfully set/cleared the Interface Identifier.
- * @retval OT_ERROR_INVALID_ARGS   The specified Interface Identifier is reserved.
- *
- * @sa otThreadGetFixedDuaInterfaceIdentifier
- */
-otError otThreadSetFixedDuaInterfaceIdentifier(otInstance *aInstance, const otIp6InterfaceIdentifier *aIid);
-
-/**
- * Gets the Interface Identifier manually specified for the Thread Domain Unicast Address.
- *
- * Available when `OPENTHREAD_CONFIG_DUA_ENABLE` is enabled.
- *
- * @note Only available since Thread 1.2.
- *
- * @param[in]  aInstance A pointer to an OpenThread instance.
- *
- * @returns A pointer to the Interface Identifier which was set manually, or NULL if none was set.
- *
- * @sa otThreadSetFixedDuaInterfaceIdentifier
- */
-const otIp6InterfaceIdentifier *otThreadGetFixedDuaInterfaceIdentifier(otInstance *aInstance);
-
-/**
  * Gets the thrKeySequenceCounter.
  *
  * @param[in]  aInstance A pointer to an OpenThread instance.
@@ -1073,24 +1041,6 @@
                                      otIp6InterfaceIdentifier *aMlIid);
 
 /**
- * Sends a Proactive Backbone Notification (PRO_BB.ntf) message on the Backbone link.
- *
- * Is only available when `OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE` is enabled.
- *
- * @param[in]  aInstance                    A pointer to an OpenThread instance.
- * @param[in]  aTarget                      The target address of the PRO_BB.ntf message.
- * @param[in]  aMlIid                       The ML-IID of the PRO_BB.ntf message.
- * @param[in]  aTimeSinceLastTransaction    Time since last transaction (in seconds).
- *
- * @retval OT_ERROR_NONE           Successfully sent PRO_BB.ntf on backbone link.
- * @retval OT_ERROR_NO_BUFS        If insufficient message buffers available.
- */
-otError otThreadSendProactiveBackboneNotification(otInstance               *aInstance,
-                                                  otIp6Address             *aTarget,
-                                                  otIp6InterfaceIdentifier *aMlIid,
-                                                  uint32_t                  aTimeSinceLastTransaction);
-
-/**
  * Notifies other nodes in the network (if any) and then stops Thread protocol operation.
  *
  * It sends an Address Release if it's a router, or sets its child timeout to 0 if it's a child.
diff --git a/include/openthread/thread_ftd.h b/include/openthread/thread_ftd.h
index a9e66e9..faea9a3 100644
--- a/include/openthread/thread_ftd.h
+++ b/include/openthread/thread_ftd.h
@@ -153,12 +153,15 @@
 otError otThreadSetMaxAllowedChildren(otInstance *aInstance, uint16_t aMaxChildren);
 
 /**
- * Indicates whether or not the device is router-eligible.
+ * Indicates whether or not the device is allowed to take router or leader roles.
+ *
+ * A device is allowed to become a router if it is a Full Thread Device (FTD), is currently configured to be
+ * router-eligible (see `otThreadSetRouterEligible(true)`), and the active Security Policy permits routers.
  *
  * @param[in]  aInstance A pointer to an OpenThread instance.
  *
- * @retval TRUE   If device is router-eligible.
- * @retval FALSE  If device is not router-eligible.
+ * @retval TRUE   If the router role is allowed.
+ * @retval FALSE  If the router role is not allowed.
  */
 bool otThreadIsRouterEligible(otInstance *aInstance);
 
@@ -177,14 +180,16 @@
 otError otThreadSetRouterEligible(otInstance *aInstance, bool aEligible);
 
 /**
- * Set the preferred Router Id.
+ * Sets the preferred Router Id.
+ *
+ * Requires `OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE`.
  *
  * Upon becoming a router/leader the node attempts to use this Router Id. If the preferred Router Id is not set or if
  * it can not be used, a randomly generated router id is picked. This property can be set only when the device role is
  * either detached or disabled.
  *
- * @note This API is reserved for testing and demo purposes only. Changing settings with
- * this API will render a production application non-compliant with the Thread Specification.
+ * @note This API is reserved for testing and demo purposes only. Changing settings with this API will render a
+ * production application non-compliant with the Thread Specification.
  *
  * @param[in]  aInstance    A pointer to an OpenThread instance.
  * @param[in]  aRouterId    The preferred Router Id.
@@ -389,11 +394,17 @@
 uint8_t otThreadGetRouterUpgradeThreshold(otInstance *aInstance);
 
 /**
- * Set the ROUTER_UPGRADE_THRESHOLD parameter used in the Leader role.
+ * Sets the ROUTER_UPGRADE_THRESHOLD parameter.
  *
  * @note This API is reserved for testing and demo purposes only. Changing settings with
  * this API will render a production application non-compliant with the Thread Specification.
  *
+ * This API historically set a single threshold value that was used for both local role transitions (deciding when
+ * the device itself should upgrade to a router) and by the leader (deciding whether to allow other devices to
+ * upgrade). These behaviors have now been separated into distinct router and leader thresholds. To preserve backward
+ * compatibility with existing applications and test scripts, this function continues to configure both thresholds
+ * (both the local router upgrade threshold and the leader upgrade threshold).
+ *
  * @param[in]  aInstance   A pointer to an OpenThread instance.
  * @param[in]  aThreshold  The ROUTER_UPGRADE_THRESHOLD value.
  *
@@ -625,6 +636,15 @@
 otError otThreadGetNextCacheEntry(otInstance *aInstance, otCacheEntryInfo *aEntryInfo, otCacheEntryIterator *aIterator);
 
 /**
+ * Clears the EID cache.
+ *
+ * Intended for testing only.
+ *
+ * @param[in]     aInstance   A pointer to an OpenThread instance.
+ */
+void otThreadClearEidCache(otInstance *aInstance);
+
+/**
  * Get the Thread PSKc
  *
  * @param[in]   aInstance   A pointer to an OpenThread instance.
diff --git a/include/simul_utils.c b/include/simul_utils.c
index 2099f1b..4b90bc2 100644
--- a/include/simul_utils.c
+++ b/include/simul_utils.c
@@ -50,6 +50,7 @@
 #define UTILS_SOCKET_LOCAL_HOST_ADDR "127.0.0.1"
 #define UTILS_SOCKET_GROUP_ADDR "224.0.0.116"
 #define UTILS_SOCKET_GROUP_ADDR6 "ff02::116"
+#define UTILS_SOCKET_GROUP_ADDR6_LO "ff01::116"
 
 const char *gLocalInterface = UTILS_SOCKET_LOCAL_HOST_ADDR;
 
@@ -76,11 +77,21 @@
     return ((aAddress->s6_addr[0] & 0xff) == 0xfe) && ((aAddress->s6_addr[1] & 0xc0) == 0x80);
 }
 
-static void InitRxSocket(utilsSocket *aSocket, const struct in_addr *aIp4Address, unsigned int aIfIndex)
+static bool IsAddressLoopback(const struct in6_addr *aAddress)
+{
+    static const uint8_t sLoopbackAddr[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1};
+    return memcmp(aAddress->s6_addr, sLoopbackAddr, sizeof(aAddress->s6_addr)) == 0;
+}
+
+static void InitRxSocket(utilsSocket           *aSocket,
+                         const struct in_addr  *aIp4Address,
+                         const struct in6_addr *aIp6Address,
+                         unsigned int           aIfIndex)
 {
     int fd;
     int one = 1;
     int rval;
+    int rcvBufSize = 2 * 1024 * 1024;
 
     fd = socket(aIp4Address ? AF_INET : AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
     ExpectOrExitWithErrorMsg(fd != -1, "socket(RxFd)");
@@ -91,6 +102,9 @@
     rval = setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));
     ExpectOrExitWithErrorMsg(rval != -1, "setsockopt(RxFd, SO_REUSEPORT)");
 
+    rval = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvBufSize, sizeof(rcvBufSize));
+    ExpectOrExitWithErrorMsg(rval != -1, "setsockopt(RxFd, SO_RCVBUF)");
+
     if (aIp4Address)
     {
         struct ip_mreqn     mreq;
@@ -118,7 +132,13 @@
     else
     {
         struct ipv6_mreq     mreq;
-        struct sockaddr_in6 *sockaddr = &aSocket->mGroupAddr.mSockAddr6;
+        struct sockaddr_in6 *sockaddr  = &aSocket->mGroupAddr.mSockAddr6;
+        const char          *groupAddr = UTILS_SOCKET_GROUP_ADDR6;
+
+        if (aIp6Address != NULL && IsAddressLoopback(aIp6Address))
+        {
+            groupAddr = UTILS_SOCKET_GROUP_ADDR6_LO;
+        }
 
         rval = setsockopt(fd, IPPROTO_IPV6, IPV6_MULTICAST_IF, &aIfIndex, sizeof(aIfIndex));
         ExpectOrExitWithErrorMsg(rval != -1, "setsockopt(RxFd, IPV6_MULTICAST_IF)");
@@ -127,8 +147,7 @@
         sockaddr->sin6_family   = AF_INET6;
         sockaddr->sin6_port     = htons(aSocket->mPortBase);
         sockaddr->sin6_scope_id = aIfIndex; // This specifies network interface for link local scope
-        ExpectOrExitWithErrorMsg(inet_pton(AF_INET6, UTILS_SOCKET_GROUP_ADDR6, &sockaddr->sin6_addr),
-                                 "inet_pton(AF_INET6)");
+        ExpectOrExitWithErrorMsg(inet_pton(AF_INET6, groupAddr, &sockaddr->sin6_addr), "inet_pton(AF_INET6)");
 
         memset(&mreq, 0, sizeof(mreq));
         mreq.ipv6mr_multiaddr = sockaddr->sin6_addr;
@@ -150,7 +169,7 @@
     }
 }
 
-void InitTxSocketIp6(utilsSocket *aSocket, const struct in6_addr *aAddress, unsigned int aIfIndex)
+static void InitTxSocketIp6(utilsSocket *aSocket, const struct in6_addr *aAddress, unsigned int aIfIndex)
 {
     int                 fd;
     int                 one = 1;
@@ -164,7 +183,7 @@
     sockaddr.sin6_family = AF_INET6;
     sockaddr.sin6_addr   = *aAddress;
     sockaddr.sin6_port   = htons(aSocket->mPort);
-    if (IsAddressLinkLocal(aAddress))
+    if (IsAddressLinkLocal(aAddress) || IsAddressLoopback(aAddress))
     {
         sockaddr.sin6_scope_id = aIfIndex;
     }
@@ -283,7 +302,7 @@
         DieNow(OT_EXIT_FAILURE);
     }
 
-    InitRxSocket(aSocket, (addr6 ? NULL : addr4), ifIndex);
+    InitRxSocket(aSocket, (addr6 ? NULL : addr4), addr6, ifIndex);
     aSocket->mInitialized = true;
     aSocket->mUseIp6      = (addr6 != NULL);
 
@@ -299,7 +318,7 @@
     ExpectOrExitWithErrorMsg(inet_pton(AF_INET, aLocalInterface, &addr4), "inet_pton(AF_INET)");
 
     InitTxSocketIp4(aSocket, &addr4);
-    InitRxSocket(aSocket, &addr4, 0);
+    InitRxSocket(aSocket, &addr4, NULL, 0);
     aSocket->mInitialized = true;
     aSocket->mUseIp6      = false;
 
@@ -344,7 +363,7 @@
         }
 
         InitTxSocketIp6(aSocket, &addr6, ifIndex);
-        InitRxSocket(aSocket, NULL, ifIndex);
+        InitRxSocket(aSocket, NULL, &addr6, ifIndex);
         aSocket->mInitialized = true;
         aSocket->mUseIp6      = true;
         break;
diff --git a/include/tcp.c b/include/tcp.c
new file mode 100644
index 0000000..06aaebc
--- /dev/null
+++ b/include/tcp.c
@@ -0,0 +1,71 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "platform-simulation.h"
+
+#include <openthread/platform/tcp.h>
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+otError otPlatTcpEnableListener(otPlatTcpListener *aListener, const otPlatTcpSockAddr *aLocalSockAddr)
+{
+    OT_UNUSED_VARIABLE(aListener);
+    OT_UNUSED_VARIABLE(aLocalSockAddr);
+
+    return OT_ERROR_FAILED;
+}
+
+void otPlatTcpDisableListener(otPlatTcpListener *aListener) { OT_UNUSED_VARIABLE(aListener); }
+
+otError otPlatTcpConnect(otPlatTcpConnection     *aConn,
+                         const otPlatTcpSockAddr *aPeerSockAddr,
+                         const otPlatTcpSockAddr *aLocalSockAddr)
+{
+    OT_UNUSED_VARIABLE(aConn);
+    OT_UNUSED_VARIABLE(aPeerSockAddr);
+    OT_UNUSED_VARIABLE(aLocalSockAddr);
+
+    return OT_ERROR_FAILED;
+}
+
+void otPlatTcpNotifyTxPending(otPlatTcpConnection *aConn) { OT_UNUSED_VARIABLE(aConn); }
+
+uint16_t otPlatTcpSend(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength)
+{
+    OT_UNUSED_VARIABLE(aConn);
+    OT_UNUSED_VARIABLE(aBuffer);
+    OT_UNUSED_VARIABLE(aLength);
+
+    return 0;
+}
+
+void otPlatTcpClose(otPlatTcpConnection *aConn) { OT_UNUSED_VARIABLE(aConn); }
+
+void otPlatTcpAbort(otPlatTcpConnection *aConn) { OT_UNUSED_VARIABLE(aConn); }
+
+#endif // #if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
diff --git a/openthread_upstream_version.gni b/openthread_upstream_version.gni
index b93055f..0d4bc58 100644
--- a/openthread_upstream_version.gni
+++ b/openthread_upstream_version.gni
@@ -1,3 +1,3 @@
 # This file is added to support soft-transition in Fuchsia.
 
-openthread_upstream_version = "d5bde0bc31a0733a6aec1cd66ff909018a177ff0"
+openthread_upstream_version = "d11cc040fdbc241a633cb23da1f3b17084cf42fb"
diff --git a/script/bootstrap b/script/bootstrap
index 3d1b865..54dde12 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -74,11 +74,9 @@
         sudo apt-get --no-install-recommends install -y binutils-arm-none-eabi gcc-arm-none-eabi gdb-arm-none-eabi libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib
     elif [ "$PLATFORM" = "Ubuntu" ]; then
         sudo apt-get --no-install-recommends install -y bzip2 ca-certificates wget
-        (cd /tmp \
-            && wget --tries 4 --no-check-certificate --quiet -c https://developer.arm.com/-/media/Files/downloads/gnu-rm/9-2020q2/gcc-arm-none-eabi-9-2020-q2-update-"$ARCH"-linux.tar.bz2 \
-            && sudo tar xjf gcc-arm-none-eabi-9-2020-q2-update-"$ARCH"-linux.tar.bz2 -C /opt \
-            && rm gcc-arm-none-eabi-9-2020-q2-update-"$ARCH"-linux.tar.bz2 \
-            && sudo ln -s -f /opt/gcc-arm-none-eabi-9-2020-q2-update/bin/* /usr/local/bin/.)
+        (wget --tries 4 --no-check-certificate -O - --quiet https://developer.arm.com/-/media/Files/downloads/gnu-rm/9-2020q2/gcc-arm-none-eabi-9-2020-q2-update-"$ARCH"-linux.tar.bz2 \
+            | sudo tar xj -C /opt) \
+            && sudo ln -s -f /opt/gcc-arm-none-eabi-9-2020-q2-update/bin/* /usr/local/bin/.
     fi
 
     if [ "$PLATFORM" != "Raspbian" ] && [ "${INSTALL_FORMAT_TOOLS}" = "1" ]; then
diff --git a/script/check-arm-build b/script/check-arm-build
index 4806d5a..a9de9c9 100755
--- a/script/check-arm-build
+++ b/script/check-arm-build
@@ -54,7 +54,6 @@
         "-DOT_DIAGNOSTIC=ON"
         "-DOT_DNSSD_SERVER=ON"
         "-DOT_DNS_CLIENT=ON"
-        "-DOT_DUA=ON"
         "-DOT_ECDSA=ON"
         "-DOT_FULL_LOGS=ON"
         "-DOT_JAM_DETECTION=ON"
diff --git a/script/check-simulation-build-cmake b/script/check-simulation-build-cmake
index a60e811..23865fe 100755
--- a/script/check-simulation-build-cmake
+++ b/script/check-simulation-build-cmake
@@ -158,31 +158,23 @@
     # Build Thread 1.4 with full features
     reset_source
     CFLAGS="${cppflags[*]} ${CFLAGS}" CXXFLAGS="${cppflags[*]} ${CXXFLAGS}" \
-        "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_DUA=ON
+        "$(dirname "$0")"/cmake-build simulation "${options[@]}"
 
     # Build Thread 1.4 with external heap and msg pool using heap
     reset_source
     CFLAGS="${cppflags[*]} ${CFLAGS} -DOPENTHREAD_CONFIG_MESSAGE_USE_HEAP_ENABLE=1" \
         CXXFLAGS="${cppflags[*]} ${CXXFLAGS} -DOPENTHREAD_CONFIG_MESSAGE_USE_HEAP_ENABLE=1" \
-        "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_DUA=ON
+        "$(dirname "$0")"/cmake-build simulation "${options[@]}"
 
     # Build Thread 1.4 with full features and no log
     reset_source
     CFLAGS="${cppflags[*]} ${CFLAGS}" CXXFLAGS="${cppflags[*]} ${CXXFLAGS}" \
-        "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_DUA=ON -DOT_LOG_OUTPUT=NONE
+        "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_LOG_OUTPUT=NONE
 
     # Build Thread 1.4 with full features and full logs
     reset_source
     CFLAGS="${cppflags[*]} ${CFLAGS}" CXXFLAGS="${cppflags[*]} ${CXXFLAGS}" \
-        "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_DUA=ON -DOT_FULL_LOGS=ON
-
-    # Build Thread 1.4 Backbone Router without DUA ND Proxying
-    reset_source
-    "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_BACKBONE_ROUTER_DUA_NDPROXYING=OFF
-
-    # Build Thread 1.4 Backbone Router without Multicast Routing
-    reset_source
-    "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_BACKBONE_ROUTER_MULTICAST_ROUTING=OFF
+        "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_FULL_LOGS=ON
 
     # Build with Vendor Extension
     reset_source
@@ -196,7 +188,7 @@
 
     # Build Thread 1.4 with full features and OT_ASSERT=OFF
     reset_source
-    "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_DUA=ON -DOT_ASSERT=OFF
+    "$(dirname "$0")"/cmake-build simulation "${options[@]}" -DOT_ASSERT=OFF
 
     # Build with RAM settings
     reset_source
@@ -204,6 +196,10 @@
 
     reset_source
     "$(dirname "$0")"/cmake-build simulation -DOT_BLE_TCAT=ON
+
+    # Build with platform CCM one-shot enabled
+    reset_source
+    "$(dirname "$0")"/cmake-build simulation -DOT_CRYPTO_CCM_ONE_SHOT=ON
 }
 
 build_nest_common()
diff --git a/script/check-simulation-local-host b/script/check-simulation-local-host
index 29f2182..3f51fbd 100755
--- a/script/check-simulation-local-host
+++ b/script/check-simulation-local-host
@@ -64,6 +64,7 @@
 
     OT_NODE_TYPE=rcp OT_SIMULATION_LOCAL_HOST=$IFACE_NAME $EXPECT_TEST
     OT_NODE_TYPE=rcp OT_SIMULATION_LOCAL_HOST=$IP6ADDR $EXPECT_TEST
+    OT_NODE_TYPE=rcp OT_SIMULATION_LOCAL_HOST=::1 $EXPECT_TEST
 }
 
 test_ipv4()
diff --git a/script/check-size b/script/check-size
index fde770b..f17ad44 100755
--- a/script/check-size
+++ b/script/check-size
@@ -125,7 +125,7 @@
     git archive "${sha}" | tar x -C "${OT_TMP_DIR}/${folder}/openthread"
 
     if [ ! -e "${OT_TMP_DIR}/${folder}/openthread/examples/config/${config_name}" ]; then
-        # Check if the the config headers are not present, copy from
+        # Check if the config headers are not present, copy from
         # the main sha.
         case "$1" in
             br)
diff --git a/script/git-tool b/script/git-tool
index 6698e3e..0b96768 100755
--- a/script/git-tool
+++ b/script/git-tool
@@ -65,7 +65,8 @@
         shift
         git clone "$@" 2>&1
     else
-        dest_dir="$(git clone "$@" 2>&1 | tee | cut -d\' -f2)"
+        dest_dir="$(LC_ALL=C git clone "$@" 2>&1 | tee /dev/stderr | sed -n "s/^Cloning into '\([^']*\)'.*/\1/p")"
+        dest_dir="${dest_dir%%$'\n'*}"
 
         cd "${dest_dir}"
         apply_dependencies
diff --git a/script/install-llvm.sh b/script/install-llvm.sh
index ef465a1..5efcc8a 100755
--- a/script/install-llvm.sh
+++ b/script/install-llvm.sh
@@ -28,7 +28,7 @@
 #
 # This script downloads and installs a specific version of clang-format and clang-tidy.
 
-set -e
+set -e -o pipefail
 
 LLVM_VERSION="19.1.7"
 ARCH=$(uname -m)
@@ -42,26 +42,15 @@
 LLVM_PACKAGE="LLVM-${LLVM_VERSION}-Linux-X64"
 LLVM_URL="https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/${LLVM_PACKAGE}.tar.xz"
 INSTALL_DIR="/opt/llvm-${LLVM_VERSION}"
-TEMP_DIR=$(mktemp -d)
+INSTALL_DIR_tmp="$INSTALL_DIR.tmp"
 
-cleanup()
-{
-    rm -rf "${TEMP_DIR}"
-}
+sudo mkdir -p "${INSTALL_DIR_tmp}"
+trap 'sudo rm -rf "${INSTALL_DIR_tmp}"' EXIT
 
-trap cleanup EXIT
-
-cd "${TEMP_DIR}"
-
-echo "Downloading LLVM from ${LLVM_URL}..."
-wget -O llvm.tar.xz "${LLVM_URL}"
-
-echo "Uncompressing to ${TEMP_DIR}..."
-tar xf llvm.tar.xz
-
-echo "Installing to ${INSTALL_DIR}..."
-sudo mkdir -p /opt
-sudo mv "${LLVM_PACKAGE}" "${INSTALL_DIR}"
+echo "Downloading LLVM from '${LLVM_URL}' into '${INSTALL_DIR}' ..."
+wget -O - "${LLVM_URL}" | sudo tar -f - -C "${INSTALL_DIR_tmp}" --strip-components=1 -xJ
+sudo rm -rf "${INSTALL_DIR}"
+sudo mv "${INSTALL_DIR_tmp}" "${INSTALL_DIR}"
 
 echo "Creating symlinks in /usr/local/bin..."
 sudo ln -sf "${INSTALL_DIR}/bin/clang-format" "/usr/local/bin/clang-format-19"
diff --git a/script/make-pretty b/script/make-pretty
index c4a61d2..cf20bb4 100755
--- a/script/make-pretty
+++ b/script/make-pretty
@@ -112,7 +112,6 @@
     '-DOT_DNS_UPSTREAM_QUERY=ON'
     "-DOT_DNSSD_DISCOVERY_PROXY=ON"
     '-DOT_DNSSD_SERVER=ON'
-    '-DOT_DUA=ON'
     '-DOT_MLR=ON'
     '-DOT_ECDSA=ON'
     '-DOT_HISTORY_TRACKER=ON'
diff --git a/script/test b/script/test
index 5c0e093..1de1493 100755
--- a/script/test
+++ b/script/test
@@ -125,7 +125,6 @@
     fi
 
     if [[ ${version} != "1.1" ]]; then
-        options+=("-DOT_DUA=ON")
         options+=("-DOT_MLR=ON")
     fi
 
@@ -184,7 +183,6 @@
     )
 
     if [[ ${version} != "1.1" ]]; then
-        options+=("-DOT_DUA=ON")
         options+=("-DOT_MLR=ON")
         options+=("-DOT_LINK_METRICS_INITIATOR=ON")
         options+=("-DOT_LINK_METRICS_SUBJECT=ON")
@@ -346,14 +344,12 @@
         "-DOT_ANYCAST_LOCATOR=ON"
         "-DOT_COVERAGE=ON"
         "-DOT_DNS_CLIENT=ON"
-        "-DOT_DUA=ON"
         "-DOT_MLR=ON"
         "-DOT_NETDATA_PUBLISHER=ON"
         "-DOT_SLAAC=ON"
         "-DOT_SRP_CLIENT=ON"
         "-DOT_FULL_LOGS=ON"
         "-DOT_UPTIME=ON"
-        "-DOTBR_DUA_ROUTING=ON"
         "-DOTBR_DHCP6_PD=ON"
     )
     local args=(
@@ -395,7 +391,7 @@
 
     (
         if [[ -z ${LOCAL_OTBR_DIR} ]]; then
-            ./script/git-tool clone https://github.com/openthread/ot-br-posix.git --depth 1 "${otbrdir}"
+            ./script/git-tool clone https://github.com/openthread/ot-br-posix.git --depth 1 --recurse-submodules --shallow-submodules "${otbrdir}"
         else
             rsync -r \
                 --exclude=third_party/openthread/repo \
diff --git a/src/cli/BUILD.gn b/src/cli/BUILD.gn
index 3c47714..d942db7 100644
--- a/src/cli/BUILD.gn
+++ b/src/cli/BUILD.gn
@@ -30,6 +30,7 @@
 openthread_cli_sources = [
   "cli.cpp",
   "cli.hpp",
+  "cli_api.cpp",
   "cli_ba.cpp",
   "cli_ba.hpp",
   "cli_bbr.cpp",
@@ -62,6 +63,8 @@
   "cli_network_data.hpp",
   "cli_ping.cpp",
   "cli_ping.hpp",
+  "cli_plat_tcp.cpp",
+  "cli_plat_tcp.hpp",
   "cli_srp_client.cpp",
   "cli_srp_client.hpp",
   "cli_srp_server.cpp",
diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt
index 4c85a26..31873dc 100644
--- a/src/cli/CMakeLists.txt
+++ b/src/cli/CMakeLists.txt
@@ -33,6 +33,7 @@
 
 set(COMMON_SOURCES
     cli.cpp
+    cli_api.cpp
     cli_ba.cpp
     cli_bbr.cpp
     cli_br.cpp
@@ -50,6 +51,7 @@
     cli_mesh_diag.cpp
     cli_network_data.cpp
     cli_ping.cpp
+    cli_plat_tcp.cpp
     cli_srp_client.cpp
     cli_srp_server.cpp
     cli_tcat.cpp
diff --git a/src/cli/README.md b/src/cli/README.md
index f4abcb5..2391f04 100644
--- a/src/cli/README.md
+++ b/src/cli/README.md
@@ -50,7 +50,6 @@
 - [discover](#discover-channel)
 - [dns](#dns-config)
 - [domainname](#domainname)
-- [dua](#dua-iid)
 - [eidcache](#eidcache)
 - [eui64](#eui64)
 - [extaddr](#extaddr)
@@ -174,30 +173,6 @@
 Done
 ```
 
-### bbr mgmt dua \<status\|coap-code\> [meshLocalIid]
-
-Configure the response status for DUA.req with meshLocalIid in payload. Without meshLocalIid, simply respond any coming DUA.req next with the specified status or COAP code.
-
-Only for testing/reference device.
-
-known status value:
-
-- 0: ST_DUA_SUCCESS
-- 1: ST_DUA_REREGISTER
-- 2: ST_DUA_INVALID
-- 3: ST_DUA_DUPLICATE
-- 4: ST_DUA_NO_RESOURCES
-- 5: ST_DUA_BBR_NOT_PRIMARY
-- 6: ST_DUA_GENERAL_FAILURE
-- 160: COAP code 5.00
-
-```bash
-> bbr mgmt dua 1 2f7c235e5025a2fd
-Done
-> bbr mgmt dua 160
-Done
-```
-
 ### bbr mgmt mlr listener
 
 Show the Multicast Listeners.
@@ -421,7 +396,7 @@
 
 The name can also be configured using the `OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME` configuration option (which is the recommended way to specify this name). This CLI command (and its corresponding API) is provided for projects where the name needs to be set after device initialization and at run-time.
 
-Per the Thread specification, the service instance should be a user-friendly name identifying the device model or product. A recommended format is "VendorName ProductName". To construct the full name and ensure name uniqueness, the OpenThread Border Agent module will append the Extended Address of the device (as 16-character hex digits) to the given base name. Note that the same name will be used for the ephemeral key service `_meshcop-e._udp` when the ephemeral key feature is enabled and used.
+Per the Thread specification, the service instance should be a user-friendly name identifying the device model or product. A recommended format is "VendorName ProductName". To construct the full name and ensure name uniqueness, the OpenThread Border Agent module appends a suffix (e.g., " #XXXX" where "XXXX" represents the last two bytes of the device's Extended Address in hex) to the given base name. If a name conflict is detected on the network, an additional index may be appended (e.g., " #XXXX (1)"). Note that the same name will be used for the ephemeral key service `_meshcop-e._udp` when the ephemeral key feature is enabled and used.
 
 ```bash
 ba servicebasename OpenThreadBorderAgent
@@ -1536,11 +1511,10 @@
 - Version
 - Current state
 - Uptime and attach time
-- Channel
-- PAN IDs, extended MAC address, and RLOC16
+- Extended MAC address and RLOC16
+- Active Operational Dataset (redacted)
 - Unicast and multicast IPv6 address list
 - Network Data
-- Partition ID
 - Leader Data
 - Buffer info
 - Network statistics
@@ -1960,34 +1934,6 @@
 Done
 ```
 
-### dua iid
-
-Get the Interface Identifier manually specified for Thread Domain Unicast Address on Thread 1.2 device.
-
-```bash
-> dua iid
-0004000300020001
-Done
-```
-
-### dua iid \<iid\>
-
-Set the Interface Identifier manually specified for Thread Domain Unicast Address on Thread 1.2 device.
-
-```bash
-> dua iid 0004000300020001
-Done
-```
-
-### dua iid clear
-
-Clear the Interface Identifier manually specified for Thread Domain Unicast Address on Thread 1.2 device.
-
-```bash
-> dua iid clear
-Done
-```
-
 ### eidcache
 
 Print the EID-to-RLOC cache entries.
@@ -2557,7 +2503,7 @@
 
 `OPENTHREAD_CONFIG_TMF_ANYCAST_LOCATOR_ENABLE` is required.
 
-The closest destination is determined based on the the current routing table and path costs within the Thread mesh.
+The closest destination is determined based on the current routing table and path costs within the Thread mesh.
 
 Locate the leader using its anycast address:
 
@@ -3597,7 +3543,7 @@
 
 ### prefix
 
-Get the prefix list in the local Network Data. Note: For the Thread 1.2 border router with backbone capability, the local Domain Prefix would be listed as well (with flag `D`), with preceding `-` if backbone functionality is disabled.
+Get the prefix list in the local Network Data.
 
 ```bash
 > prefix
@@ -3610,8 +3556,6 @@
 
 Add a valid prefix to the Network Data.
 
-Note: The Domain Prefix flag (`D`) is only available for Thread 1.2.
-
 - p: Preferred flag
 - a: Stateless IPv6 Address Autoconfiguration flag
 - d: DHCPv6 IPv6 Address Configuration flag
@@ -3620,7 +3564,6 @@
 - o: On Mesh flag
 - s: Stable flag
 - n: Nd Dns flag
-- D: Domain Prefix flag
 - prf: Default router preference, which may be 'high', 'med', or 'low'.
 
 ```bash
diff --git a/src/cli/README_BR.md b/src/cli/README_BR.md
index 752d245..734d026 100644
--- a/src/cli/README_BR.md
+++ b/src/cli/README_BR.md
@@ -430,7 +430,6 @@
 
 - It has added at least one external route entry.
 - It has added at least one prefix entry with both the default-route and on-mesh flags set.
-- It has added at least one domain prefix (with both the domain and on-mesh flags set).
 
 The list of peer BRs specifically excludes the current device, even if it is itself acting as a BR.
 
diff --git a/src/cli/README_HISTORY.md b/src/cli/README_HISTORY.md
index e1e5c23..c0b6829 100644
--- a/src/cli/README_HISTORY.md
+++ b/src/cli/README_HISTORY.md
@@ -466,7 +466,6 @@
 - `o`: On Mesh flag
 - `s`: Stable flag
 - `n`: Nd Dns flag
-- `D`: Domain Prefix flag
 
 Print the history as a table.
 
diff --git a/src/cli/README_NETDATA.md b/src/cli/README_NETDATA.md
index 365392b..53a4ce0 100644
--- a/src/cli/README_NETDATA.md
+++ b/src/cli/README_NETDATA.md
@@ -283,7 +283,6 @@
 - o: On Mesh flag
 - s: Stable flag
 - n: Nd Dns flag
-- D: Domain Prefix flag (only available for Thread 1.2).
 - prf: Preference, which may be 'high', 'med', or 'low'.
 
 ```bash
@@ -352,7 +351,6 @@
   - o: On Mesh flag
   - s: Stable flag
   - n: Nd Dns flag
-  - D: Domain Prefix flag (only available for Thread 1.2).
 - Preference `high`, `med`, or `low`
 - RLOC16 of device which added the on-mesh prefix
 
diff --git a/src/cli/cli.cpp b/src/cli/cli.cpp
index 570205e..53b01d0 100644
--- a/src/cli/cli.cpp
+++ b/src/cli/cli.cpp
@@ -71,14 +71,14 @@
 namespace ot {
 namespace Cli {
 
-Interpreter *Interpreter::sInterpreter = nullptr;
-static OT_DEFINE_ALIGNED_VAR(sInterpreterRaw, sizeof(Interpreter), uint64_t);
-
 Interpreter::Interpreter(Instance *aInstance, otCliOutputCallback aCallback, void *aContext)
     : OutputImplementer(aCallback, aContext)
     , Utils(aInstance, *this)
     , mCommandIsPending(false)
     , mInternalDebugCommand(false)
+#if OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE
+    , mPromptEnabled(true)
+#endif
     , mTimer(*aInstance, HandleTimer, this)
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
 #if OPENTHREAD_CONFIG_SNTP_CLIENT_ENABLE
@@ -108,6 +108,9 @@
 #if OPENTHREAD_CONFIG_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_TCP_ENABLE
     , mTcp(aInstance, *this)
 #endif
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+    , mPlatTcp(aInstance, *this)
+#endif
 #if OPENTHREAD_CONFIG_COAP_API_ENABLE
     , mCoap(aInstance, *this)
 #endif
@@ -397,24 +400,35 @@
 
 otError Interpreter::SetUserCommands(const otCliCommand *aCommands, uint8_t aLength, void *aContext)
 {
-    otError error = OT_ERROR_FAILED;
+    otError error = OT_ERROR_NONE;
 
     for (UserCommandsEntry &entry : mUserCommands)
     {
+        if (entry.mCommands == aCommands)
+        {
+            // Ignore if already registered.
+            ExitNow();
+        }
+
         if (entry.mCommands == nullptr)
         {
             entry.mCommands = aCommands;
             entry.mLength   = aLength;
             entry.mContext  = aContext;
-
-            error = OT_ERROR_NONE;
-            break;
+            ExitNow();
         }
     }
 
+    error = OT_ERROR_NO_BUFS;
+
+exit:
     return error;
 }
 
+#if OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE
+void Interpreter::SetPromptConfig(bool aEnabled) { mPromptEnabled = aEnabled; }
+#endif
+
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
 
 /**
@@ -740,6 +754,12 @@
         OutputLine(aIndentSize, "ModelName: %s", aInfo.mModelName);
     }
 
+    if (aInfo.mHasVendorOui)
+    {
+        OutputFormat(aIndentSize, "VendorOui: ");
+        OutputVendorOuiLine(aInfo.mVendorOui);
+    }
+
     if (aInfo.mHasStateBitmap)
     {
         uint8_t indent = aIndentSize + kIndentSize;
@@ -1085,70 +1105,6 @@
     return ProcessGetSet(aArgs, otThreadGetDomainName, otThreadSetDomainName);
 }
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-template <> otError Interpreter::Process<Cmd("dua")>(Arg aArgs[])
-{
-    otError error = OT_ERROR_NONE;
-
-    /**
-     * @cli dua iid
-     * @code
-     * dua iid
-     * 0004000300020001
-     * Done
-     * @endcode
-     * @par api_copy
-     * #otThreadGetFixedDuaInterfaceIdentifier
-     */
-    if (aArgs[0] == "iid")
-    {
-        if (aArgs[1].IsEmpty())
-        {
-            const otIp6InterfaceIdentifier *iid = otThreadGetFixedDuaInterfaceIdentifier(GetInstancePtr());
-
-            if (iid != nullptr)
-            {
-                OutputBytesLine(iid->mFields.m8);
-            }
-        }
-        /**
-         * @cli dua iid (set,clear)
-         * @code
-         * dua iid 0004000300020001
-         * Done
-         * @endcode
-         * @code
-         * dua iid clear
-         * Done
-         * @endcode
-         * @cparam dua iid @ca{iid|clear}
-         * `dua iid clear` passes a `nullptr` to #otThreadSetFixedDuaInterfaceIdentifier.
-         * Otherwise, you can pass the `iid`.
-         * @par api_copy
-         * #otThreadSetFixedDuaInterfaceIdentifier
-         */
-        else if (aArgs[1] == "clear")
-        {
-            error = otThreadSetFixedDuaInterfaceIdentifier(GetInstancePtr(), nullptr);
-        }
-        else
-        {
-            otIp6InterfaceIdentifier iid;
-
-            SuccessOrExit(error = aArgs[1].ParseAsHexString(iid.mFields.m8));
-            error = otThreadSetFixedDuaInterfaceIdentifier(GetInstancePtr(), &iid);
-        }
-    }
-    else
-    {
-        error = OT_ERROR_INVALID_COMMAND;
-    }
-
-exit:
-    return error;
-}
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE
-
 #endif // (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
 
 /**
@@ -2921,35 +2877,53 @@
     OutputNewLine();
 }
 
-/**
- * @cli eidcache
- * @code
- * eidcache
- * fd49:caf4:a29f:dc0e:97fc:69dd:3c16:df7d 2000 cache canEvict=1 transTime=0 eid=fd49:caf4:a29f:dc0e:97fc:69dd:3c16:df7d
- * fd49:caf4:a29f:dc0e:97fc:69dd:3c16:df7f fffe retry canEvict=1 timeout=10 retryDelay=30
- * Done
- * @endcode
- * @par
- * Returns the EID-to-RLOC cache entries.
- * @sa otThreadGetNextCacheEntry
- */
 template <> otError Interpreter::Process<Cmd("eidcache")>(Arg aArgs[])
 {
-    OT_UNUSED_VARIABLE(aArgs);
+    otError error = OT_ERROR_NONE;
 
-    otCacheEntryIterator iterator;
-    otCacheEntryInfo     entry;
-
-    ClearAllBytes(iterator);
-
-    while (true)
+    /**
+     * @cli eidcache
+     * @code
+     * eidcache
+     * fd49:caf4:a29f:dc0e:97fc:69dd:3c16:df7d 2000 cache canEvict=1 transTime=0
+     * eid=fd49:caf4:a29f:dc0e:97fc:69dd:3c16:df7d fd49:caf4:a29f:dc0e:97fc:69dd:3c16:df7f fffe retry
+     * canEvict=1 timeout=10 retryDelay=30 Done
+     * @endcode
+     * @par
+     * Returns the EID-to-RLOC cache entries.
+     * @sa otThreadGetNextCacheEntry
+     */
+    if (aArgs[0].IsEmpty())
     {
-        SuccessOrExit(otThreadGetNextCacheEntry(GetInstancePtr(), &entry, &iterator));
-        OutputEidCacheEntry(entry);
-    }
+        otCacheEntryIterator iterator;
+        otCacheEntryInfo     entry;
 
+        ClearAllBytes(iterator);
+        while (true)
+        {
+            SuccessOrExit(otThreadGetNextCacheEntry(GetInstancePtr(), &entry, &iterator));
+            OutputEidCacheEntry(entry);
+        }
+    }
+    /**
+     * @cli eidcache clear
+     * @code
+     * eidcache clear
+     * Done
+     * @endcode
+     * @par api_copy
+     * #otThreadClearEidCache
+     */
+    else if (aArgs[0] == "clear")
+    {
+        otThreadClearEidCache(GetInstancePtr());
+    }
+    else
+    {
+        error = OT_ERROR_INVALID_ARGS;
+    }
 exit:
-    return OT_ERROR_NONE;
+    return error;
 }
 #endif
 
@@ -3033,13 +3007,13 @@
      * @endcode
      * @par
      * Get the log level.
-     * @sa otLoggingGetLevel
+     * @sa otGetLogLevel
      */
     if (aArgs[0] == "level")
     {
         if (aArgs[1].IsEmpty())
         {
-            OutputLine("%d", otLoggingGetLevel());
+            OutputLine("%u", otGetLogLevel(GetInstancePtr()));
         }
         else
         {
@@ -3053,12 +3027,12 @@
              * Done
              * @endcode
              * @par api_copy
-             * #otLoggingSetLevel
+             * #otSetLogLevel
              * @cparam log level @ca{level}
              */
             VerifyOrExit(aArgs[2].IsEmpty(), error = OT_ERROR_INVALID_ARGS);
             SuccessOrExit(error = aArgs[1].ParseAsUint8(level));
-            error = otLoggingSetLevel(static_cast<otLogLevel>(level));
+            error = otSetLogLevel(GetInstancePtr(), static_cast<otLogLevel>(level));
 #else
             error = OT_ERROR_INVALID_ARGS;
 #endif
@@ -3180,20 +3154,6 @@
         SuccessOrExit(error = aArgs[3].ParseAsHexString(mlIid.mFields.m8));
         otThreadSendAddressNotification(GetInstancePtr(), &destination, &target, &mlIid);
     }
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    else if (aArgs[0] == "/b/ba")
-    {
-        otIp6Address             target;
-        otIp6InterfaceIdentifier mlIid;
-        uint32_t                 timeSinceLastTransaction;
-
-        SuccessOrExit(error = aArgs[1].ParseAsIp6Address(target));
-        SuccessOrExit(error = aArgs[2].ParseAsHexString(mlIid.mFields.m8));
-        SuccessOrExit(error = aArgs[3].ParseAsUint32(timeSinceLastTransaction));
-
-        error = otThreadSendProactiveBackboneNotification(GetInstancePtr(), &target, &mlIid, timeSinceLastTransaction);
-    }
-#endif
 
 exit:
     return error;
@@ -5493,9 +5453,6 @@
      * @endcode
      * @par
      * Get the prefix list in the local Network Data.
-     * @note For the Thread 1.2 border router with backbone capability, the local Domain Prefix
-     * is listed as well and includes the `D` flag. If backbone functionality is disabled, a dash
-     * `-` is printed before the local Domain Prefix.
      * @par
      * For more information about #otBorderRouterConfig flags, refer to @overview.
      * @sa otBorderRouterGetNextOnMeshPrefix
@@ -5509,15 +5466,6 @@
         {
             mNetworkData.OutputPrefix(config);
         }
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-        if (otBackboneRouterGetState(GetInstancePtr()) == OT_BACKBONE_ROUTER_STATE_DISABLED)
-        {
-            SuccessOrExit(otBackboneRouterGetDomainPrefix(GetInstancePtr(), &config));
-            OutputFormat("- ");
-            mNetworkData.OutputPrefix(config);
-        }
-#endif
     }
     /**
      * @cli prefix add
@@ -5605,7 +5553,7 @@
  * Specifies the preferred router ID that the leader should provide when solicited.
  * @sa otThreadSetPreferredRouterId
  */
-#if OPENTHREAD_FTD
+#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
 template <> otError Interpreter::Process<Cmd("preferrouterid")>(Arg aArgs[])
 {
     return ProcessSet(aArgs, otThreadSetPreferredRouterId);
@@ -6349,10 +6297,10 @@
 #if OPENTHREAD_CONFIG_SNTP_CLIENT_ENABLE
 template <> otError Interpreter::Process<Cmd("sntp")>(Arg aArgs[])
 {
-    otError          error = OT_ERROR_NONE;
-    uint16_t         port  = OT_SNTP_DEFAULT_SERVER_PORT;
-    Ip6::MessageInfo messageInfo;
-    otSntpQuery      query;
+    otError       error = OT_ERROR_NONE;
+    uint16_t      port  = OT_SNTP_DEFAULT_SERVER_PORT;
+    otMessageInfo messageInfo;
+    otSntpQuery   query;
 
     /**
      * @cli sntp query
@@ -6378,14 +6326,16 @@
     {
         VerifyOrExit(!mSntpQueryingInProgress, error = OT_ERROR_BUSY);
 
+        ClearAllBytes(messageInfo);
+
         if (!aArgs[1].IsEmpty())
         {
-            SuccessOrExit(error = aArgs[1].ParseAsIp6Address(messageInfo.GetPeerAddr()));
+            SuccessOrExit(error = aArgs[1].ParseAsIp6Address(messageInfo.mPeerAddr));
         }
         else
         {
             // Use IPv6 address of default SNTP server.
-            SuccessOrExit(error = messageInfo.GetPeerAddr().FromString(OT_SNTP_DEFAULT_SERVER_IP));
+            SuccessOrExit(error = otIp6AddressFromString(OT_SNTP_DEFAULT_SERVER_IP, &messageInfo.mPeerAddr));
         }
 
         if (!aArgs[2].IsEmpty())
@@ -6393,9 +6343,8 @@
             SuccessOrExit(error = aArgs[2].ParseAsUint16(port));
         }
 
-        messageInfo.SetPeerPort(port);
-
-        query.mMessageInfo = static_cast<const otMessageInfo *>(&messageInfo);
+        messageInfo.mPeerPort = port;
+        query.mMessageInfo    = &messageInfo;
 
         SuccessOrExit(error = otSntpClientQuery(GetInstancePtr(), &query, &Interpreter::HandleSntpResponse, this));
 
@@ -6857,14 +6806,11 @@
         "uptime",
 #endif
         "attachtime",
-        "channel",
-        "panid",
-        "extpanid",
+        "dataset active -ns",
         "ipaddr -v",
         "ipmaddr",
         "netdata show",
         "netdata show -x",
-        "partitionid",
         "leaderdata",
         "bufferinfo",
         "netstat",
@@ -6992,6 +6938,10 @@
 template <> otError Interpreter::Process<Cmd("tcp")>(Arg aArgs[]) { return mTcp.Process(aArgs); }
 #endif
 
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+template <> otError Interpreter::Process<Cmd("plattcp")>(Arg aArgs[]) { return mPlatTcp.Process(aArgs); }
+#endif
+
 template <> otError Interpreter::Process<Cmd("udp")>(Arg aArgs[]) { return mUdp.Process(aArgs); }
 
 template <> otError Interpreter::Process<Cmd("unsecureport")>(Arg aArgs[])
@@ -7067,6 +7017,32 @@
 
         OutputNewLine();
     }
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    /**
+     * @cli unsecureport allwhendisabled
+     * @code
+     * unsecureport allwhendisabled
+     * Disabled
+     * Done
+     * @endcode
+     * @par api_copy
+     * #otIp6IsUnsecureAllowedWhenDisabled
+     */
+    else if (aArgs[0] == "allwhendisabled")
+    {
+        /**
+         * @cli unsecureport allwhendisabled (enable, disable)
+         * @code
+         * unsecureport allwhendisabled enable
+         * Done
+         * @endcode
+         * @cparam unsecureport allwhendisabled @ca{enable|disable}
+         * @par api_copy
+         * #otIp6SetAllowUnsecureWhenDisabled
+         */
+        error = ProcessEnableDisable(aArgs + 1, otIp6IsUnsecureAllowedWhenDisabled, otIp6SetAllowUnsecureWhenDisabled);
+    }
+#endif // OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     else
     {
         error = OT_ERROR_INVALID_COMMAND;
@@ -7616,6 +7592,46 @@
         error = ProcessGetSet(aArgs, otThreadGetVendorAppUrl, otThreadSetVendorAppUrl);
 #endif
     }
+    /**
+     * @cli vendor oui
+     * @code
+     * vendor oui
+     * B4-A6-61
+     * Done
+     * @endcode
+     * @par api_copy
+     * #otThreadGetVendorOuiInfo
+     */
+    else if (aArgs[0] == "oui")
+    {
+        if (aArgs[1].IsEmpty())
+        {
+            otThreadVendorOui oui;
+
+            otThreadGetVendorOuiInfo(GetInstancePtr(), &oui);
+            OutputVendorOuiLine(oui);
+
+            error = OT_ERROR_NONE;
+        }
+        else
+        {
+#if OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE
+            /**
+             * @cli vendor oui (set)
+             * @code
+             * vendor oui 0xb4a661
+             * Done
+             * @endcode
+             * @par api_copy
+             * #otThreadSetVendorOui
+             * @cparam vendor oui @ca{oui}
+             */
+            error = ProcessSet(aArgs + 1, otThreadSetVendorOui);
+#else
+            error = OT_ERROR_INVALID_ARGS;
+#endif
+        }
+    }
 
     return error;
 }
@@ -7741,6 +7757,7 @@
      * - `41`: Border Router DHCPv6-PD OMR Prefix TLV
      * - `42`: Border Router Local On-link Prefix TLV
      * - `43`: Border Router Favored On-link Prefix TLV
+     * - `44`: Vendor OUI TLV
      *
      * @par
      * Sends a network diagnostic request to retrieve specified Type Length Values (TLVs)
@@ -7787,13 +7804,12 @@
                                               const otMessageInfo *aMessageInfo,
                                               void                *aContext)
 {
-    static_cast<Interpreter *>(aContext)->HandleDiagnosticGetResponse(
-        aError, aMessage, static_cast<const Ip6::MessageInfo *>(aMessageInfo));
+    static_cast<Interpreter *>(aContext)->HandleDiagnosticGetResponse(aError, aMessage, aMessageInfo);
 }
 
-void Interpreter::HandleDiagnosticGetResponse(otError                 aError,
-                                              const otMessage        *aMessage,
-                                              const Ip6::MessageInfo *aMessageInfo)
+void Interpreter::HandleDiagnosticGetResponse(otError              aError,
+                                              const otMessage     *aMessage,
+                                              const otMessageInfo *aMessageInfo)
 {
     uint8_t               buf[16];
     uint16_t              bytesToPrint;
@@ -7912,6 +7928,10 @@
         case OT_NETWORK_DIAGNOSTIC_TLV_VENDOR_APP_URL:
             OutputLine("Vendor App URL: %s", diagTlv.mData.mVendorAppUrl);
             break;
+        case OT_NETWORK_DIAGNOSTIC_TLV_VENDOR_OUI:
+            OutputFormat("Vendor OUI: ");
+            OutputVendorOuiLine(diagTlv.mData.mVendorOui);
+            break;
         case OT_NETWORK_DIAGNOSTIC_TLV_THREAD_STACK_VERSION:
             OutputLine("Thread Stack Version: %s", diagTlv.mData.mThreadStackVersion);
             break;
@@ -8414,17 +8434,57 @@
 
 #endif // OPENTHREAD_FTD || OPENTHREAD_MTD
 
-void Interpreter::Initialize(otInstance *aInstance, otCliOutputCallback aCallback, void *aContext)
-{
-    Instance *instance = static_cast<Instance *>(aInstance);
+size_t Interpreter::GetSize(void) { return sizeof(Interpreter); }
 
-    Interpreter::sInterpreter = new (&sInterpreterRaw) Interpreter(instance, aCallback, aContext);
+Interpreter *Interpreter::Init(void               *aBuffer,
+                               size_t              aSize,
+                               otInstance         *aInstance,
+                               otCliOutputCallback aCallback,
+                               void               *aContext)
+{
+    Interpreter *interpreter = nullptr;
+    Instance    *instance    = static_cast<Instance *>(aInstance);
+
+    VerifyOrExit(aSize >= sizeof(Interpreter));
+    interpreter = new (aBuffer) Interpreter(instance, aCallback, aContext);
+
+exit:
+    return interpreter;
+}
+
+#if OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE
+
+Interpreter *Interpreter::sInterpreter = nullptr;
+
+static OT_DEFINE_ALIGNED_VAR(sInterpreterRaw, sizeof(Interpreter), uint64_t);
+
+void Interpreter::Init(otInstance *aInstance, otCliOutputCallback aCallback, void *aContext)
+{
+    sInterpreter = Init(&sInterpreterRaw, sizeof(sInterpreterRaw), aInstance, aCallback, aContext);
+}
+
+#endif
+
+void Interpreter::Finalize(void)
+{
+    mTimer.Stop();
+
+#if (OPENTHREAD_FTD || OPENTHREAD_MTD) && OPENTHREAD_CONFIG_CLI_REGISTER_IP6_RECV_CALLBACK
+    otIp6SetReceiveCallback(GetInstancePtr(), nullptr, nullptr);
+#endif
+#if OPENTHREAD_CONFIG_DIAG_ENABLE
+    otDiagSetOutputCallback(GetInstancePtr(), nullptr, nullptr);
+#endif
+
+    this->~Interpreter();
 }
 
 void Interpreter::OutputPrompt(void)
 {
 #if OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE
-    static const char sPrompt[] = "> ";
+    static const char kPrompt[] = "> ";
+
+    VerifyOrExit(mPromptEnabled);
 
     // The `OutputFormat()` below is adding the prompt which is not
     // part of any command output, so we set the `EmittingCommandOutput`
@@ -8432,9 +8492,12 @@
     // log (under `OPENTHREAD_CONFIG_CLI_LOG_INPUT_OUTPUT_ENABLE`).
 
     SetEmittingCommandOutput(false);
-    OutputFormat("%s", sPrompt);
+    OutputFormat("%s", kPrompt);
     SetEmittingCommandOutput(true);
-#endif // OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE
+
+exit:
+    return;
+#endif
 }
 
 void Interpreter::HandleTimer(Timer &aTimer)
@@ -8537,9 +8600,6 @@
 #if (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
         CmdEntry("domainname"),
 #endif
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-        CmdEntry("dua"),
-#endif
 #if OPENTHREAD_FTD
         CmdEntry("eidcache"),
 #endif
@@ -8640,8 +8700,11 @@
         CmdEntry("ping"),
 #endif
         CmdEntry("platform"),
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+        CmdEntry("plattcp"),
+#endif
         CmdEntry("pollperiod"),
-#if OPENTHREAD_FTD
+#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
         CmdEntry("preferrouterid"),
 #endif
 #if OPENTHREAD_CONFIG_BORDER_ROUTER_ENABLE
@@ -8764,54 +8827,5 @@
     return error;
 }
 
-extern "C" void otCliInit(otInstance *aInstance, otCliOutputCallback aCallback, void *aContext)
-{
-    Interpreter::Initialize(aInstance, aCallback, aContext);
-
-#if OPENTHREAD_CONFIG_CLI_VENDOR_COMMANDS_ENABLE && OPENTHREAD_CONFIG_CLI_MAX_USER_CMD_ENTRIES > 1
-    otCliVendorSetUserCommands();
-#endif
-}
-
-extern "C" void otCliInputLine(char *aBuf) { Interpreter::GetInterpreter().ProcessLine(aBuf); }
-
-extern "C" otError otCliSetUserCommands(const otCliCommand *aUserCommands, uint8_t aLength, void *aContext)
-{
-    return Interpreter::GetInterpreter().SetUserCommands(aUserCommands, aLength, aContext);
-}
-
-extern "C" void otCliOutputBytes(const uint8_t *aBytes, uint8_t aLength)
-{
-    Interpreter::GetInterpreter().OutputBytes(aBytes, aLength);
-}
-
-extern "C" void otCliOutputFormat(const char *aFmt, ...)
-{
-    va_list aAp;
-    va_start(aAp, aFmt);
-    Interpreter::GetInterpreter().OutputFormatV(aFmt, aAp);
-    va_end(aAp);
-}
-
-extern "C" void otCliAppendResult(otError aError) { Interpreter::GetInterpreter().OutputResult(aError); }
-
-extern "C" void otCliPlatLogv(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, va_list aArgs)
-{
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-
-    VerifyOrExit(Interpreter::IsInitialized());
-
-    // CLI output is being used for logging, so we set the flag
-    // `EmittingCommandOutput` to false indicate this.
-    Interpreter::GetInterpreter().SetEmittingCommandOutput(false);
-    Interpreter::GetInterpreter().OutputFormatV(aFormat, aArgs);
-    Interpreter::GetInterpreter().OutputNewLine();
-    Interpreter::GetInterpreter().SetEmittingCommandOutput(true);
-
-exit:
-    return;
-}
-
 } // namespace Cli
 } // namespace ot
diff --git a/src/cli/cli.hpp b/src/cli/cli.hpp
index 0ec85fb..b02396a 100644
--- a/src/cli/cli.hpp
+++ b/src/cli/cli.hpp
@@ -73,6 +73,7 @@
 #include "cli/cli_mesh_diag.hpp"
 #include "cli/cli_network_data.hpp"
 #include "cli/cli_ping.hpp"
+#include "cli/cli_plat_tcp.hpp"
 #include "cli/cli_srp_client.hpp"
 #include "cli/cli_srp_server.hpp"
 #include "cli/cli_tcat.hpp"
@@ -86,10 +87,13 @@
 #include "common/type_traits.hpp"
 #include "instance/instance.hpp"
 
+typedef struct otCliInterpreter
+{
+} otCliInterpreter;
+
 namespace ot {
 
 /**
- * @namespace ot::Cli
  *
  * @brief
  *   This namespace contains definitions for the CLI interpreter.
@@ -104,7 +108,7 @@
 /**
  * Implements the CLI interpreter.
  */
-class Interpreter : public OutputImplementer, public Utils
+class Interpreter : public otCliInterpreter, public OutputImplementer, public Utils
 {
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
     friend class Ba;
@@ -122,6 +126,8 @@
     friend class SrpClient;
     friend class SrpServer;
 #endif
+    friend class Utils;
+
     friend void otCliPlatLogv(otLogLevel, otLogRegion, const char *, va_list);
     friend void otCliAppendResult(otError aError);
     friend void otCliOutputBytes(const uint8_t *aBytes, uint8_t aLength);
@@ -137,34 +143,65 @@
      */
     explicit Interpreter(Instance *aInstance, otCliOutputCallback aCallback, void *aContext);
 
+#if OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE
     /**
-     * Returns a reference to the interpreter object.
+     * Returns a reference to the static CLI interpreter.
      *
-     * @returns A reference to the interpreter object.
+     * @returns A reference to the static CLI interpreter.
      */
     static Interpreter &GetInterpreter(void)
     {
         OT_ASSERT(sInterpreter != nullptr);
-
         return *sInterpreter;
     }
 
     /**
-     * Initializes the Console interpreter.
+     * Initializes the static CLI interpreter.
      *
      * @param[in]  aInstance  The OpenThread instance structure.
      * @param[in]  aCallback  A pointer to a callback method.
      * @param[in]  aContext   A pointer to a user context.
      */
-    static void Initialize(otInstance *aInstance, otCliOutputCallback aCallback, void *aContext);
+    static void Init(otInstance *aInstance, otCliOutputCallback aCallback, void *aContext);
 
     /**
-     * Returns whether the interpreter is initialized.
+     * Returns whether the static CLI interpreter is initialized.
      *
      * @returns  Whether the interpreter is initialized.
      */
     static bool IsInitialized(void) { return sInterpreter != nullptr; }
 
+#endif // OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE
+
+    /**
+     * Gets the size of the CLI interpreter object.
+     *
+     * @returns The size of the CLI interpreter object in bytes.
+     */
+    static size_t GetSize(void);
+
+    /**
+     * Initializes a CLI interpreter.
+     *
+     * @param[in]  aBuffer    A pointer to a memory buffer for the CLI interpreter.
+     * @param[in]  aSize      The size of the memory buffer.
+     * @param[in]  aInstance  The OpenThread instance structure.
+     * @param[in]  aCallback  A callback method called to process CLI output.
+     * @param[in]  aContext   A user context pointer.
+     *
+     * @returns A pointer to the initialized CLI interpreter, or `nullptr` if @p aSize is too small.
+     */
+    static Interpreter *Init(void               *aBuffer,
+                             size_t              aSize,
+                             otInstance         *aInstance,
+                             otCliOutputCallback aCallback,
+                             void               *aContext);
+
+    /**
+     * Finalizes the CLI interpreter.
+     */
+    void Finalize(void);
+
     /**
      * Interprets a CLI command.
      *
@@ -177,15 +214,28 @@
      *
      * @param[in]  aCommands  A pointer to an array with user commands.
      * @param[in]  aLength    @p aUserCommands length.
-     * @param[in]  aContext   @p aUserCommands length.
+     * @param[in]  aContext   Context to use when invoking the command handler.
      *
-     * @retval OT_ERROR_NONE    Successfully updated command table with commands from @p aCommands.
-     * @retval OT_ERROR_FAILED  No available UserCommandsEntry to register requested user commands.
+     * @retval OT_ERROR_NONE     Successfully updated command table with commands from @p aCommands.
+     * @retval OT_ERROR_NO_BUFS  No available `UserCommandsEntry` to register the requested user commands.
      */
     otError SetUserCommands(const otCliCommand *aCommands, uint8_t aLength, void *aContext);
 
+#if OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE
+    /**
+     * Configures whether or not the CLI interpreter outputs prompt string.
+     *
+     * It is enabled by default.
+     *
+     * @param[in] aEnabled  TRUE to enable outputting prompt, FALSE to disable.
+     */
+    void SetPromptConfig(bool aEnabled);
+#endif
+
 protected:
+#if OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE
     static Interpreter *sInterpreter;
+#endif
 
 private:
     static constexpr uint8_t  kIndentSize            = 4;
@@ -260,7 +310,7 @@
     static void HandleLinkPcapReceive(const otRadioFrame *aFrame, bool aIsTx, void *aContext);
 
 #if OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE
-    void HandleDiagnosticGetResponse(otError aError, const otMessage *aMessage, const Ip6::MessageInfo *aMessageInfo);
+    void HandleDiagnosticGetResponse(otError aError, const otMessage *aMessage, const otMessageInfo *aMessageInfo);
     static void HandleDiagnosticGetResponse(otError              aError,
                                             otMessage           *aMessage,
                                             const otMessageInfo *aMessageInfo,
@@ -353,6 +403,9 @@
     UserCommandsEntry mUserCommands[kMaxUserCommandEntries];
     bool              mCommandIsPending;
     bool              mInternalDebugCommand;
+#if OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE
+    bool mPromptEnabled;
+#endif
 
     TimerMilliContext mTimer;
 
@@ -393,6 +446,10 @@
     TcpExample mTcp;
 #endif
 
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+    PlatTcp mPlatTcp;
+#endif
+
 #if OPENTHREAD_CONFIG_COAP_API_ENABLE
     Coap mCoap;
 #endif
diff --git a/src/cli/cli_api.cpp b/src/cli/cli_api.cpp
new file mode 100644
index 0000000..3075654
--- /dev/null
+++ b/src/cli/cli_api.cpp
@@ -0,0 +1,128 @@
+/*
+ *  Copyright (c) 2016-2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file implements the CLI public APIs.
+ */
+
+#include <openthread/cli.h>
+
+#include "cli.hpp"
+
+namespace ot {
+namespace Cli {
+
+extern "C" size_t otCliInterpreterGetSize(void) { return Interpreter::GetSize(); }
+
+extern "C" otCliInterpreter *otCliInterpreterInit(void               *aBuffer,
+                                                  size_t              aSize,
+                                                  otInstance         *aInstance,
+                                                  otCliOutputCallback aCallback,
+                                                  void               *aContext)
+{
+    return Interpreter::Init(aBuffer, aSize, aInstance, aCallback, aContext);
+}
+
+#if OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE
+extern "C" void otCliInterpreterSetPromptConfig(otCliInterpreter *aInterpreter, bool aEnable)
+{
+    static_cast<Interpreter *>(aInterpreter)->SetPromptConfig(aEnable);
+}
+#endif
+
+extern "C" void otCliInterpreterInputLine(otCliInterpreter *aInterpreter, char *aLine)
+{
+    static_cast<Interpreter *>(aInterpreter)->ProcessLine(aLine);
+}
+
+extern "C" void otCliInterpreterFinalize(otCliInterpreter *aInterpreter)
+{
+    static_cast<Interpreter *>(aInterpreter)->Finalize();
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+
+#if OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE
+
+extern "C" void otCliInit(otInstance *aInstance, otCliOutputCallback aCallback, void *aContext)
+{
+    Interpreter::Init(aInstance, aCallback, aContext);
+
+#if OPENTHREAD_CONFIG_CLI_VENDOR_COMMANDS_ENABLE && OPENTHREAD_CONFIG_CLI_MAX_USER_CMD_ENTRIES > 1
+    otCliVendorSetUserCommands();
+#endif
+}
+
+extern "C" otCliInterpreter *otCliGetStaticInterpreter(void) { return &Interpreter::GetInterpreter(); }
+
+extern "C" void otCliInputLine(char *aLine) { Interpreter::GetInterpreter().ProcessLine(aLine); }
+
+extern "C" otError otCliSetUserCommands(const otCliCommand *aUserCommands, uint8_t aLength, void *aContext)
+{
+    return Interpreter::GetInterpreter().SetUserCommands(aUserCommands, aLength, aContext);
+}
+
+extern "C" void otCliOutputBytes(const uint8_t *aBytes, uint8_t aLength)
+{
+    Interpreter::GetInterpreter().OutputBytes(aBytes, aLength);
+}
+
+extern "C" void otCliOutputFormat(const char *aFmt, ...)
+{
+    va_list args;
+
+    va_start(args, aFmt);
+    Interpreter::GetInterpreter().OutputFormatV(aFmt, args);
+    va_end(args);
+}
+
+extern "C" void otCliAppendResult(otError aError) { Interpreter::GetInterpreter().OutputResult(aError); }
+
+extern "C" void otCliPlatLogv(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, va_list aArgs)
+{
+    OT_UNUSED_VARIABLE(aLogLevel);
+    OT_UNUSED_VARIABLE(aLogRegion);
+
+    VerifyOrExit(Interpreter::IsInitialized());
+
+    // CLI output is being used for logging, so we set the flag
+    // `EmittingCommandOutput` to false indicate this.
+    Interpreter::GetInterpreter().SetEmittingCommandOutput(false);
+    Interpreter::GetInterpreter().OutputFormatV(aFormat, aArgs);
+    Interpreter::GetInterpreter().OutputNewLine();
+    Interpreter::GetInterpreter().SetEmittingCommandOutput(true);
+
+exit:
+    return;
+}
+
+#endif // OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE
+
+} // namespace Cli
+} // namespace ot
diff --git a/src/cli/cli_bbr.cpp b/src/cli/cli_bbr.cpp
index 0f86b24..d077c20 100644
--- a/src/cli/cli_bbr.cpp
+++ b/src/cli/cli_bbr.cpp
@@ -174,55 +174,6 @@
         ExitNow(error = OT_ERROR_INVALID_COMMAND);
     }
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE && OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    /**
-     * @cli bbr mgmt dua
-     * @code
-     * bbr mgmt dua 1 2f7c235e5025a2fd
-     * Done
-     * @endcode
-     * @code
-     * bbr mgmt dua 160
-     * Done
-     * @endcode
-     * @cparam bbr mgmt dua @ca{status|coap-code} [@ca{meshLocalIid}]
-     * For `status` or `coap-code`, use:
-     * *    0: ST_DUA_SUCCESS
-     * *    1: ST_DUA_REREGISTER
-     * *    2: ST_DUA_INVALID
-     * *    3: ST_DUA_DUPLICATE
-     * *    4: ST_DUA_NO_RESOURCES
-     * *    5: ST_DUA_BBR_NOT_PRIMARY
-     * *    6: ST_DUA_GENERAL_FAILURE
-     * *    160: COAP code 5.00
-     * @par
-     * With the `meshLocalIid` included, this command configures the response status
-     * for the next DUA registration. Without `meshLocalIid`, respond to the next
-     * DUA.req with the specified `status` or `coap-code`.
-     * @par
-     * Available when `OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE` is enabled.
-     * @sa otBackboneRouterConfigNextDuaRegistrationResponse
-     */
-    if (aArgs[0] == "dua")
-    {
-        uint8_t                   status;
-        otIp6InterfaceIdentifier *mlIid = nullptr;
-        otIp6InterfaceIdentifier  iid;
-
-        SuccessOrExit(error = aArgs[1].ParseAsUint8(status));
-
-        if (!aArgs[2].IsEmpty())
-        {
-            SuccessOrExit(error = aArgs[2].ParseAsHexString(iid.mFields.m8));
-            mlIid = &iid;
-            VerifyOrExit(aArgs[3].IsEmpty(), error = OT_ERROR_INVALID_ARGS);
-        }
-
-        otBackboneRouterConfigNextDuaRegistrationResponse(GetInstancePtr(), mlIid, status);
-        ExitNow();
-    }
-#endif // OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE && OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
     if (aArgs[0] == "mlr")
     {
diff --git a/src/cli/cli_br.cpp b/src/cli/cli_br.cpp
index 230dabc..722ebbb 100644
--- a/src/cli/cli_br.cpp
+++ b/src/cli/cli_br.cpp
@@ -670,7 +670,6 @@
      * Data entries:
      * - It has added at least one external route entry.
      * - It has added at least one prefix entry with both the default-route and on-mesh flags set.
-     * - It has added at least one domain prefix (with both the domain and on-mesh flags set).
      * The list of peer BRs specifically excludes the current device, even if its is itself acting as a BR.
      * Info per BR entry:
      * - RLOC16 of the BR
@@ -1200,7 +1199,7 @@
     otError error = OT_ERROR_NONE;
 
     VerifyOrExit(aArgs[0].IsEmpty(), error = OT_ERROR_INVALID_ARGS);
-    Interpreter::GetInterpreter().OutputBorderRouterCounters();
+    GetInterpreter().OutputBorderRouterCounters();
 
 exit:
     return error;
diff --git a/src/cli/cli_config.h b/src/cli/cli_config.h
index a02b68c..3a1525f 100644
--- a/src/cli/cli_config.h
+++ b/src/cli/cli_config.h
@@ -48,6 +48,21 @@
 #endif
 
 /**
+ * @def OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE
+ *
+ * Define as 1 to enable the static CLI interpreter.
+ *
+ * This configuration option enables the static CLI interpreter, allowing the CLI module to statically allocate and
+ * provide a single interpreter instance.
+ *
+ * This is intended to provide backward compatibility with the original `otCli*` APIs. It can be disabled to save RAM
+ * if the static CLI interpreter is not needed.
+ */
+#ifndef OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE
+#define OPENTHREAD_CONFIG_CLI_STATIC_INTERPRETER_ENABLE 1
+#endif
+
+/**
  * @def OPENTHREAD_CONFIG_CLI_MAX_LINE_LENGTH
  *
  * The maximum size of the CLI line in bytes including the null terminator.
@@ -83,6 +98,18 @@
 #endif
 
 /**
+ * @def OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+ *
+ * Indicates whether the plat-tcp CLI commands are enabled.
+ *
+ * These CLI commands are intended primarily for testing and evaluation of the `otPlatTcp` API implementations.
+ *
+ */
+#ifndef OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+#define OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE 1
+#endif
+
+/**
  * @def OPENTHREAD_CONFIG_CLI_TCP_ENABLE
  *
  * Indicates whether TCP should be enabled in the CLI tool.
diff --git a/src/cli/cli_dns.cpp b/src/cli/cli_dns.cpp
index 19de30c..8c843bc 100644
--- a/src/cli/cli_dns.cpp
+++ b/src/cli/cli_dns.cpp
@@ -437,8 +437,6 @@
 
 //----------------------------------------------------------------------------------------------------------------------
 
-void Dns::OutputResult(otError aError) { Interpreter::GetInterpreter().OutputResult(aError); }
-
 otError Dns::GetDnsConfig(Arg aArgs[], otDnsQueryConfig *&aConfig)
 {
     // This method gets the optional DNS config from `aArgs[]`.
@@ -454,7 +452,8 @@
 
     VerifyOrExit(!aArgs[0].IsEmpty(), aConfig = nullptr);
 
-    SuccessOrExit(error = ParseToIp6Address(GetInstancePtr(), aArgs[0], aConfig->mServerSockAddr.mAddress, nat64Synth));
+    SuccessOrExit(error = ParseOrSynthesizeIp6Address(aArgs[0], aConfig->mServerSockAddr.mAddress, nat64Synth));
+
     if (nat64Synth)
     {
         OutputFormat("Synthesized IPv6 DNS server address: ");
diff --git a/src/cli/cli_dns.hpp b/src/cli/cli_dns.hpp
index 04579d2..96742ed 100644
--- a/src/cli/cli_dns.hpp
+++ b/src/cli/cli_dns.hpp
@@ -101,7 +101,6 @@
     template <CommandId kCommandId> otError Process(Arg aArgs[]);
 
 #if OPENTHREAD_CONFIG_DNS_CLIENT_ENABLE
-    void        OutputResult(otError aError);
     otError     GetDnsConfig(Arg aArgs[], otDnsQueryConfig *&aConfig);
     const char *DnsConfigServiceModeToString(otDnsServiceMode aMode) const;
     otError     ParseDnsServiceMode(const Arg &aArg, otDnsServiceMode &aMode) const;
diff --git a/src/cli/cli_history.cpp b/src/cli/cli_history.cpp
index 70fedcb..936a447 100644
--- a/src/cli/cli_history.cpp
+++ b/src/cli/cli_history.cpp
@@ -1305,7 +1305,6 @@
  *     * `o`: On mesh flag.
  *     * `s`: Stable flag.
  *     * `n`: Nd Dns flag.
- *     * `D`: Domain prefix flag.
  * * Pref: Preference. Values can be either `high`, `med`, or `low`.
  * * RLOC16
  * @sa otHistoryTrackerIterateOnMeshPrefixHistory
@@ -2026,8 +2025,6 @@
     OutputResult(aError);
 }
 
-void History::OutputResult(otError aError) { Interpreter::GetInterpreter().OutputResult(aError); }
-
 #endif // #if OPENTHREAD_CONFIG_HISTORY_TRACKER_CLIENT_ENABLE
 
 otError History::Process(Arg aArgs[])
diff --git a/src/cli/cli_history.hpp b/src/cli/cli_history.hpp
index 1a17d8a..68eb888 100644
--- a/src/cli/cli_history.hpp
+++ b/src/cli/cli_history.hpp
@@ -101,7 +101,6 @@
     void OutputNetInfoEntry(bool aIsList, const otHistoryTrackerNetworkInfo &aInfo, uint32_t aEntryAge);
 
 #if OPENTHREAD_CONFIG_HISTORY_TRACKER_CLIENT_ENABLE
-    void    OutputResult(otError aError);
     otError ParseQueryArgs(Arg       aArgs[],
                            bool     &aIsList,
                            uint16_t &aRloc16,
diff --git a/src/cli/cli_link_metrics.cpp b/src/cli/cli_link_metrics.cpp
index 6e4595b..2b82264 100644
--- a/src/cli/cli_link_metrics.cpp
+++ b/src/cli/cli_link_metrics.cpp
@@ -594,8 +594,6 @@
     return str;
 }
 
-void LinkMetrics::OutputResult(otError aError) { Interpreter::GetInterpreter().OutputResult(aError); }
-
 } // namespace Cli
 } // namespace ot
 
diff --git a/src/cli/cli_link_metrics.hpp b/src/cli/cli_link_metrics.hpp
index c2ef0e7..2b49518 100644
--- a/src/cli/cli_link_metrics.hpp
+++ b/src/cli/cli_link_metrics.hpp
@@ -115,8 +115,6 @@
 
     const char *LinkMetricsStatusToStr(otLinkMetricsStatus aStatus);
 
-    void OutputResult(otError aError);
-
     bool mQuerySync : 1;
     bool mConfigForwardTrackingSeriesSync : 1;
     bool mConfigEnhAckProbingSync : 1;
diff --git a/src/cli/cli_logging.cpp b/src/cli/cli_logging.cpp
index d63f73e..ccea338 100644
--- a/src/cli/cli_logging.cpp
+++ b/src/cli/cli_logging.cpp
@@ -85,6 +85,14 @@
 
 #if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_APP
 
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+extern "C" OT_TOOL_WEAK void otPlatLogOutput(otInstance *aInstance, otLogLevel aLogLevel, const char *aLogLine)
+{
+    OT_UNUSED_VARIABLE(aInstance);
+    otPlatLog(aLogLevel, OT_LOG_REGION_CORE, "%s", aLogLine);
+}
+#endif
+
 extern "C" OT_TOOL_WEAK void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
 {
     va_list ap;
diff --git a/src/cli/cli_mdns.cpp b/src/cli/cli_mdns.cpp
index d7b96ce..4cd9e2a 100644
--- a/src/cli/cli_mdns.cpp
+++ b/src/cli/cli_mdns.cpp
@@ -456,7 +456,7 @@
     if (mWaitingForCallback && (aRequestId == mRequestId))
     {
         mWaitingForCallback = false;
-        Interpreter::GetInterpreter().OutputResult(aError);
+        GetInterpreter().OutputResult(aError);
     }
     else
     {
diff --git a/src/cli/cli_mesh_diag.cpp b/src/cli/cli_mesh_diag.cpp
index 1122f74..f555bc1 100644
--- a/src/cli/cli_mesh_diag.cpp
+++ b/src/cli/cli_mesh_diag.cpp
@@ -125,6 +125,7 @@
     otError                  error = OT_ERROR_NONE;
     otMeshDiagDiscoverConfig config;
 
+    ClearAllBytes(config);
     config.mDiscoverIp6Addresses = false;
     config.mDiscoverChildTable   = false;
 
@@ -548,8 +549,6 @@
     OutputResult(aError);
 }
 
-void MeshDiag::OutputResult(otError aError) { Interpreter::GetInterpreter().OutputResult(aError); }
-
 } // namespace Cli
 } // namespace ot
 
diff --git a/src/cli/cli_mesh_diag.hpp b/src/cli/cli_mesh_diag.hpp
index e5ef008..3f4b57d 100644
--- a/src/cli/cli_mesh_diag.hpp
+++ b/src/cli/cli_mesh_diag.hpp
@@ -104,8 +104,6 @@
     void HandleMeshDiagQueryChildIp6Addrs(otError                    aError,
                                           uint16_t                   aChildRloc16,
                                           otMeshDiagIp6AddrIterator *aIp6AddrIterator);
-
-    void OutputResult(otError aError);
 };
 
 } // namespace Cli
diff --git a/src/cli/cli_network_data.cpp b/src/cli/cli_network_data.cpp
index f5cb627..852c01f 100644
--- a/src/cli/cli_network_data.cpp
+++ b/src/cli/cli_network_data.cpp
@@ -96,11 +96,6 @@
         *flagsPtr++ = 'n';
     }
 
-    if (aConfig.mDp)
-    {
-        *flagsPtr++ = 'D';
-    }
-
     *flagsPtr = '\0';
 }
 
@@ -804,7 +799,6 @@
  *   * o: On Mesh flag
  *   * s: Stable flag
  *   * n: Nd Dns flag
- *   * D: Domain Prefix flag (only available for Thread 1.2).
  * * Preference `high`, `med`, or `low`
  * * RLOC16 of device which added the on-mesh prefix
  * @par
diff --git a/src/cli/cli_ping.cpp b/src/cli/cli_ping.cpp
index c11166c..7ee2177 100644
--- a/src/cli/cli_ping.cpp
+++ b/src/cli/cli_ping.cpp
@@ -96,7 +96,7 @@
         aArgs++;
     }
 
-    SuccessOrExit(error = ParseToIp6Address(GetInstancePtr(), aArgs[0], config.mDestination, nat64Synth));
+    SuccessOrExit(error = ParseOrSynthesizeIp6Address(aArgs[0], config.mDestination, nat64Synth));
 
     if (nat64Synth)
     {
@@ -233,8 +233,8 @@
 
     if (aStatistics->mReceivedCount != 0)
     {
-        uint32_t avgRoundTripTime =
-            1000 * static_cast<uint64_t>(aStatistics->mTotalRoundTripTime) / aStatistics->mReceivedCount;
+        uint32_t avgRoundTripTime = static_cast<uint32_t>(
+            1000 * static_cast<uint64_t>(aStatistics->mTotalRoundTripTime) / aStatistics->mReceivedCount);
 
         OutputFormat(" Round-trip min/avg/max = %u/%u.%03u/%u ms.", aStatistics->mMinRoundTripTime,
                      static_cast<uint16_t>(avgRoundTripTime / 1000), static_cast<uint16_t>(avgRoundTripTime % 1000),
@@ -249,8 +249,6 @@
     }
 }
 
-void PingSender::OutputResult(otError aError) { Interpreter::GetInterpreter().OutputResult(aError); }
-
 } // namespace Cli
 } // namespace ot
 
diff --git a/src/cli/cli_ping.hpp b/src/cli/cli_ping.hpp
index 5e36d85..626b83f 100644
--- a/src/cli/cli_ping.hpp
+++ b/src/cli/cli_ping.hpp
@@ -81,8 +81,6 @@
     static void HandlePingStatistics(const otPingSenderStatistics *aStatistics, void *aContext);
     void        HandlePingStatistics(const otPingSenderStatistics *aStatistics);
 
-    void OutputResult(otError aError);
-
     bool mPingIsAsync : 1;
 };
 
diff --git a/src/cli/cli_plat_tcp.cpp b/src/cli/cli_plat_tcp.cpp
new file mode 100644
index 0000000..6184369
--- /dev/null
+++ b/src/cli/cli_plat_tcp.cpp
@@ -0,0 +1,302 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file implements a TCP CLI tool.
+ */
+
+#include "openthread-core-config.h"
+
+#include "cli_config.h"
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+
+#include "cli_plat_tcp.hpp"
+
+#include "instance/instance.hpp"
+
+namespace ot {
+namespace Cli {
+
+//---------------------------------------------------------------------------------------------------------------------
+// PlatTcp::Listener
+
+PlatTcp::Listener::Listener(Instance &aInstance, PlatTcp &aOwner)
+    : Ip6::PlatTcp::Listener(aInstance, Accept, nullptr)
+    , mOwner(aOwner)
+{
+}
+
+Ip6::PlatTcp::Connection *PlatTcp::Listener::Accept(Ip6::PlatTcp::Listener &aListener, const SockAddr &aPeerSockAddr)
+{
+    return static_cast<Listener &>(aListener).mOwner.Accept(aPeerSockAddr);
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// PlatTcp::Connection
+
+PlatTcp::Connection::Connection(Instance &aInstance, PlatTcp &aOwner)
+    : Ip6::PlatTcp::Connection(aInstance, HandleEvent, nullptr)
+    , mOwner(aOwner)
+{
+}
+
+void PlatTcp::Connection::HandleEvent(Ip6::PlatTcp::Connection &aConnection, const Event aEvent)
+{
+    return static_cast<Connection &>(aConnection).mOwner.HandleEvent(aEvent);
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// PlatTcp
+
+PlatTcp::PlatTcp(otInstance *aInstance, OutputImplementer &aOutputImplementer)
+    : Utils(aInstance, aOutputImplementer)
+    , mListener(AsCoreType(aInstance), *this)
+    , mConnection(AsCoreType(aInstance), *this)
+    , mIsBound(false)
+{
+}
+
+PlatTcp::Connection *PlatTcp::Accept(const SockAddr &aPeerSockAddr)
+{
+    OutputLine("PlatTcp: Accept %s", aPeerSockAddr.ToString().AsCString());
+
+    return &mConnection;
+}
+
+void PlatTcp::HandleEvent(Connection::Event aEvent)
+{
+    OutputLine("PlatTcp:: HandleEvent %s", Connection::EventToString(aEvent));
+
+    if (aEvent == Connection::kEventDisconnected)
+    {
+        OutputLine("PlatTcp:: DisconnectReason: %s",
+                   Connection::DisconnectReasonToString(mConnection.GetDisconnectReason()));
+    }
+
+    if (aEvent == Connection::kEventReceive)
+    {
+        const Message *rxMessage = mConnection.GetRxMessage();
+
+        VerifyOrExit(rxMessage != nullptr);
+        OutputLine("PlatTcp: Received %u bytes", rxMessage->GetLength());
+
+        for (uint16_t offset = 0; offset < rxMessage->GetLength();)
+        {
+            uint8_t  buffer[32];
+            uint16_t readLength = rxMessage->ReadBytes(offset, buffer, sizeof(buffer));
+
+            OutputSpaces(4);
+            OutputBytesLine(buffer, readLength);
+
+            offset += readLength;
+        }
+
+        mConnection.FreeRxMessage();
+    }
+
+exit:
+    return;
+}
+
+otError PlatTcp::GetSockAddr(Arg aArgs[], SockAddr &aSockAddr)
+{
+    otError  error = OT_ERROR_NONE;
+    uint16_t port;
+    uint32_t ifIndex;
+
+    aSockAddr.Clear();
+
+    VerifyOrExit(!aArgs[0].IsEmpty());
+    SuccessOrExit(error = aArgs[0].ParseAsIp6Address(aSockAddr.GetAddress()));
+
+    VerifyOrExit(!aArgs[1].IsEmpty());
+    SuccessOrExit(error = aArgs[1].ParseAsUint16(port));
+    aSockAddr.SetPort(port);
+
+    VerifyOrExit(!aArgs[2].IsEmpty());
+    SuccessOrExit(error = aArgs[2].ParseAsUint32(ifIndex));
+    aSockAddr.SetIfIndex(ifIndex);
+
+exit:
+    return error;
+}
+
+template <> otError PlatTcp::Process<Cmd("listener")>(Arg aArgs[])
+{
+    otError error = OT_ERROR_NONE;
+
+    if (aArgs[0].IsEmpty())
+    {
+        OutputEnabledDisabledStatus(mListener.GetState() == Listener::kStateEnabled);
+    }
+    else if (aArgs[0] == "enable")
+    {
+        SockAddr sockAddr;
+
+        SuccessOrExit(error = GetSockAddr(&aArgs[1], sockAddr));
+        OutputLine("Enabling listener on %s", sockAddr.ToString().AsCString());
+        error = mListener.Enable(sockAddr);
+    }
+    else if (aArgs[0] == "disable")
+    {
+        VerifyOrExit(aArgs[1].IsEmpty(), error = OT_ERROR_INVALID_ARGS);
+        mListener.Disable();
+    }
+    else
+    {
+        error = OT_ERROR_INVALID_COMMAND;
+    }
+
+exit:
+    return error;
+}
+
+template <> otError PlatTcp::Process<Cmd("bind")>(Arg aArgs[])
+{
+    otError  error = OT_ERROR_NONE;
+    SockAddr newSockAddr;
+
+    VerifyOrExit(mConnection.GetState() == Connection::kStateUnused, error = OT_ERROR_INVALID_STATE);
+
+    if (aArgs[0].IsEmpty())
+    {
+        mIsBound = false;
+        mLocalSockAddr.Clear();
+        ExitNow();
+    }
+
+    SuccessOrExit(error = GetSockAddr(aArgs, newSockAddr));
+    mLocalSockAddr = newSockAddr;
+    mIsBound       = true;
+    OutputLine("LocalSockAddr: %s", mLocalSockAddr.ToString().AsCString());
+
+exit:
+    return error;
+}
+
+template <> otError PlatTcp::Process<Cmd("connect")>(Arg aArgs[])
+{
+    otError  error = OT_ERROR_NONE;
+    SockAddr peerSockAddr;
+
+    SuccessOrExit(error = GetSockAddr(aArgs, peerSockAddr));
+
+    if (mIsBound)
+    {
+        OutputLine("Binding to %s, connecting to %s", mLocalSockAddr.ToString().AsCString(),
+                   peerSockAddr.ToString().AsCString());
+        error = mConnection.BindAndConnect(mLocalSockAddr, peerSockAddr);
+    }
+    else
+    {
+        OutputLine("Connecting to %s", peerSockAddr.ToString().AsCString());
+        error = mConnection.Connect(peerSockAddr);
+    }
+
+exit:
+    return error;
+}
+
+template <> otError PlatTcp::Process<Cmd("send")>(Arg aArgs[])
+{
+    otError           error = OT_ERROR_NONE;
+    OwnedPtr<Message> message;
+    uint16_t          length;
+
+    SuccessOrExit(error = aArgs[0].ParseAsUint16(length));
+    VerifyOrExit(aArgs[1].IsEmpty(), error = OT_ERROR_INVALID_ARGS);
+
+    message.Reset(AsCoreType(GetInstancePtr()).Get<MessagePool>().Allocate(Message::kTypeOther));
+    VerifyOrExit(message != nullptr, error = OT_ERROR_NO_BUFS);
+
+    for (uint16_t i = 0; i < length; i++)
+    {
+        SuccessOrExit(error = message->Append<uint8_t>(static_cast<uint8_t>(i & 0xff) ^ (i >> 8)));
+    }
+
+    error = mConnection.Send(message.PassOwnership());
+
+exit:
+    return error;
+}
+
+template <> otError PlatTcp::Process<Cmd("close")>(Arg aArgs[])
+{
+    otError error = OT_ERROR_NONE;
+
+    VerifyOrExit(aArgs[0].IsEmpty(), error = OT_ERROR_INVALID_ARGS);
+    mConnection.Close();
+
+exit:
+    return error;
+}
+
+template <> otError PlatTcp::Process<Cmd("abort")>(Arg aArgs[])
+{
+    otError error = OT_ERROR_NONE;
+
+    VerifyOrExit(aArgs[0].IsEmpty(), error = OT_ERROR_INVALID_ARGS);
+    mConnection.Abort();
+
+exit:
+    return error;
+}
+
+otError PlatTcp::Process(Arg aArgs[])
+{
+#define CmdEntry(aCommandString) {aCommandString, &PlatTcp::Process<Cmd(aCommandString)>}
+
+    static constexpr Command kCommands[] = {CmdEntry("abort"),   CmdEntry("bind"),     CmdEntry("close"),
+                                            CmdEntry("connect"), CmdEntry("listener"), CmdEntry("send")};
+
+    static_assert(BinarySearch::IsSorted(kCommands), "kCommands is not sorted");
+
+    otError        error = OT_ERROR_INVALID_COMMAND;
+    const Command *command;
+
+    if (aArgs[0].IsEmpty() || (aArgs[0] == "help"))
+    {
+        OutputCommandTable(kCommands);
+        ExitNow(error = aArgs[0].IsEmpty() ? error : OT_ERROR_NONE);
+    }
+
+    command = BinarySearch::Find(aArgs[0].GetCString(), kCommands);
+    VerifyOrExit(command != nullptr);
+
+    error = (this->*command->mHandler)(aArgs + 1);
+
+exit:
+    return error;
+}
+
+} // namespace Cli
+} // namespace ot
+
+#endif // OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
diff --git a/src/cli/cli_plat_tcp.hpp b/src/cli/cli_plat_tcp.hpp
new file mode 100644
index 0000000..70a7b73
--- /dev/null
+++ b/src/cli/cli_plat_tcp.hpp
@@ -0,0 +1,122 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file contains definitions for a plat-TCP CLI tool.
+ */
+
+#ifndef OT_CLI_CLI_PLAT_TCP_HPP_
+#define OT_CLI_CLI_PLAT_TCP_HPP_
+
+#include "openthread-core-config.h"
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+
+#include "cli/cli_config.h"
+#include "cli/cli_utils.hpp"
+
+#include "net/plat_tcp.hpp"
+
+namespace ot {
+namespace Cli {
+
+/**
+ * Implements a CLI-based plat-TCP example.
+ *
+ * These CLI commands are intended primarily for testing and evaluation of the `otPlatTcp` API implementations.
+ */
+class PlatTcp : private Utils
+{
+public:
+    /**
+     * Constructor
+     *
+     * @param[in]  aInstance            The OpenThread Instance.
+     * @param[in]  aOutputImplementer   An `OutputImplementer`.
+     */
+    PlatTcp(otInstance *aInstance, OutputImplementer &aOutputImplementer);
+
+    /**
+     * Processes a CLI sub-command.
+     *
+     * @param[in]  aArgs     An array of command line arguments.
+     *
+     * @retval OT_ERROR_NONE              Successfully executed the CLI command.
+     * @retval OT_ERROR_PENDING           The CLI command was successfully started but final result is pending.
+     * @retval OT_ERROR_INVALID_COMMAND   Invalid or unknown CLI command.
+     * @retval OT_ERROR_INVALID_ARGS      Invalid arguments.
+     * @retval ...                        Error during execution of the CLI command.
+     */
+    otError Process(Arg aArgs[]);
+
+private:
+    using Command = CommandEntry<PlatTcp>;
+
+    using SockAddr = Ip6::PlatTcp::SockAddr;
+
+    class Listener : public Ip6::PlatTcp::Listener
+    {
+    public:
+        Listener(Instance &aInstance, PlatTcp &aOwner);
+
+    private:
+        static Ip6::PlatTcp::Connection *Accept(Ip6::PlatTcp::Listener &aListener, const SockAddr &aPeerSockAddr);
+
+        PlatTcp &mOwner;
+    };
+
+    class Connection : public Ip6::PlatTcp::Connection
+    {
+    public:
+        Connection(Instance &aInstance, PlatTcp &aOwner);
+
+    private:
+        static void HandleEvent(Ip6::PlatTcp::Connection &aConnection, Event aEvent);
+
+        PlatTcp &mOwner;
+    };
+
+    template <CommandId kCommandId> otError Process(Arg aArgs[]);
+
+    otError     GetSockAddr(Arg aArgs[], SockAddr &aSockAddr);
+    Connection *Accept(const SockAddr &aPeerSockAddr);
+    void        HandleEvent(Connection::Event aEvent);
+
+    Listener   mListener;
+    Connection mConnection;
+    SockAddr   mLocalSockAddr;
+    bool       mIsBound;
+};
+
+} // namespace Cli
+} // namespace ot
+
+#endif // OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE && OPENTHREAD_CONFIG_CLI_PLAT_TCP_ENABLE
+
+#endif // OT_CLI_CLI_PLAT_TCP_HPP_
diff --git a/src/cli/cli_tcp.cpp b/src/cli/cli_tcp.cpp
index 640e403..7c5dadb 100644
--- a/src/cli/cli_tcp.cpp
+++ b/src/cli/cli_tcp.cpp
@@ -48,7 +48,7 @@
 
 #if OPENTHREAD_CONFIG_TLS_ENABLE
 #include <mbedtls/debug.h>
-#if (MBEDTLS_VERSION_NUMBER >= 0x03000000) && (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER >= 0x03000000) && (MBEDTLS_VERSION_NUMBER < 0x04000000)
 #include <mbedtls/ecjpake.h>
 #include "crypto/mbedtls.hpp"
 #endif
@@ -150,7 +150,7 @@
 
             mbedtls_ssl_init(&mSslContext);
             mbedtls_ssl_config_init(&mSslConfig);
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
             mbedtls_ssl_conf_rng(&mSslConfig, Crypto::MbedTls::CryptoSecurePrng, nullptr);
 #endif
             mbedtls_ssl_conf_authmode(&mSslConfig, MBEDTLS_SSL_VERIFY_NONE);
@@ -164,7 +164,7 @@
             mbedtls_ssl_conf_max_version(&mSslConfig, MBEDTLS_SSL_MAJOR_VERSION_3, MBEDTLS_SSL_MINOR_VERSION_3);
 #endif
 
-#if (MBEDTLS_VERSION_NUMBER >= 0x03000000) && (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER >= 0x03000000) && (MBEDTLS_VERSION_NUMBER < 0x04000000)
             int rv = mbedtls_pk_parse_key(&mPKey, reinterpret_cast<const unsigned char *>(sSrvKey), sSrvKeyLength,
                                           nullptr, 0, Crypto::MbedTls::CryptoSecurePrng, nullptr);
 #else
@@ -400,7 +400,7 @@
 
     VerifyOrExit(mInitialized, error = OT_ERROR_INVALID_STATE);
 
-    SuccessOrExit(error = ParseToIp6Address(GetInstancePtr(), aArgs[0], sockaddr.mAddress, nat64Synth));
+    SuccessOrExit(error = ParseOrSynthesizeIp6Address(aArgs[0], sockaddr.mAddress, nat64Synth));
 
     if (nat64Synth)
     {
diff --git a/src/cli/cli_tcp.hpp b/src/cli/cli_tcp.hpp
index ddda6e6..5e5788a 100644
--- a/src/cli/cli_tcp.hpp
+++ b/src/cli/cli_tcp.hpp
@@ -45,7 +45,7 @@
 #include <mbedtls/version.h>
 #include <mbedtls/x509_crt.h>
 
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
 #include <mbedtls/ctr_drbg.h>
 #include <mbedtls/entropy.h>
 #endif
@@ -163,7 +163,7 @@
     mbedtls_ssl_config  mSslConfig;
     mbedtls_x509_crt    mSrvCert;
     mbedtls_pk_context  mPKey;
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     mbedtls_entropy_context mEntropy;
 #endif
 #endif // OPENTHREAD_CONFIG_TLS_ENABLE
diff --git a/src/cli/cli_udp.cpp b/src/cli/cli_udp.cpp
index b59dd67..50f17ce 100644
--- a/src/cli/cli_udp.cpp
+++ b/src/cli/cli_udp.cpp
@@ -144,7 +144,7 @@
     otSockAddr sockaddr;
     bool       nat64Synth;
 
-    SuccessOrExit(error = ParseToIp6Address(GetInstancePtr(), aArgs[0], sockaddr.mAddress, nat64Synth));
+    SuccessOrExit(error = ParseOrSynthesizeIp6Address(aArgs[0], sockaddr.mAddress, nat64Synth));
 
     if (nat64Synth)
     {
@@ -281,7 +281,7 @@
     {
         bool nat64Synth;
 
-        SuccessOrExit(error = ParseToIp6Address(GetInstancePtr(), aArgs[0], messageInfo.mPeerAddr, nat64Synth));
+        SuccessOrExit(error = ParseOrSynthesizeIp6Address(aArgs[0], messageInfo.mPeerAddr, nat64Synth));
 
         if (nat64Synth)
         {
diff --git a/src/cli/cli_utils.cpp b/src/cli/cli_utils.cpp
index d273e54..94253e7 100644
--- a/src/cli/cli_utils.cpp
+++ b/src/cli/cli_utils.cpp
@@ -60,8 +60,12 @@
 {
 }
 
+Interpreter &Utils::GetInterpreter(void) { return static_cast<Interpreter &>(mImplementer); }
+
 const char *Utils::ToYesNo(bool aBool) { return aBool ? "yes" : "no"; }
 
+void Utils::OutputResult(otError aError) { GetInterpreter().OutputResult(aError); }
+
 void Utils::OutputFormat(const char *aFormat, ...)
 {
     va_list args;
@@ -227,6 +231,20 @@
     OutputNewLine();
 }
 
+void Utils::OutputVendorOui(const otThreadVendorOui &aOui)
+{
+    char string[OT_THREAD_VENDOR_OUI_STRING_SIZE];
+
+    otThreadVendorOuiToString(&aOui, string, sizeof(string));
+    OutputFormat("%s", string);
+}
+
+void Utils::OutputVendorOuiLine(const otThreadVendorOui &aOui)
+{
+    OutputVendorOui(aOui);
+    OutputNewLine();
+}
+
 void Utils::OutputDnsTxtData(const uint8_t *aTxtData, uint16_t aTxtDataLength)
 {
     OutputDnsTxtData(/* aKeyValuePerLine */ false, 0, aTxtData, aTxtDataLength);
@@ -631,25 +649,27 @@
 }
 
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
-otError Utils::ParseToIp6Address(otInstance *aInstance, const Arg &aArg, otIp6Address &aAddress, bool &aSynthesized)
-{
-    Error error = OT_ERROR_NONE;
 
-    VerifyOrExit(!aArg.IsEmpty(), error = OT_ERROR_INVALID_ARGS);
-    error        = aArg.ParseAsIp6Address(aAddress);
+otError Utils::ParseOrSynthesizeIp6Address(const Arg &aArg, otIp6Address &aAddress, bool &aSynthesized)
+{
+    Error        error;
+    otIp4Address ip4Address;
+
     aSynthesized = false;
 
-    if (error != OT_ERROR_NONE)
-    {
-        // It might be an IPv4 address, let's have a try.
-        otIp4Address ip4Address;
+    error = aArg.ParseAsIp6Address(aAddress);
 
-        // Do not touch the error value if we failed to parse it as an IPv4 address.
-        SuccessOrExit(aArg.ParseAsIp4Address(ip4Address));
-        SuccessOrExit(error = otNat64SynthesizeIp6Address(aInstance, &ip4Address, &aAddress));
-        aSynthesized = true;
+    if (error == OT_ERROR_NONE)
+    {
+        ExitNow();
     }
 
+    // Try to parse it as an IPv4 address and synthesize.
+
+    SuccessOrExit(error = aArg.ParseAsIp4Address(ip4Address));
+    SuccessOrExit(error = otNat64SynthesizeIp6Address(GetInstancePtr(), &ip4Address, &aAddress));
+    aSynthesized = true;
+
 exit:
     return error;
 }
@@ -710,11 +730,6 @@
                     aConfig.mNdDns = true;
                     break;
 
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-                case 'D':
-                    aConfig.mDp = true;
-                    break;
-#endif
                 case '-':
                     break;
 
diff --git a/src/cli/cli_utils.hpp b/src/cli/cli_utils.hpp
index dc22c8e..6055d03 100644
--- a/src/cli/cli_utils.hpp
+++ b/src/cli/cli_utils.hpp
@@ -42,6 +42,7 @@
 #include <openthread/border_routing.h>
 #include <openthread/cli.h>
 #include <openthread/joiner.h>
+#include <openthread/netdiag.h>
 #include <openthread/thread.h>
 
 #include "cli_config.h"
@@ -73,29 +74,25 @@
 }
 
 class Utils;
+class Interpreter;
 
 /**
- * Implements the basic output functions.
+ * Implements the basic output functions acting as a base class for `Interpreter`.
  */
 class OutputImplementer
 {
     friend class Utils;
 
 public:
-    /**
-     * Initializes the `OutputImplementer` object.
-     *
-     * @param[in] aCallback           A pointer to an `otCliOutputCallback` to deliver strings to the CLI console.
-     * @param[in] aCallbackContext    An arbitrary context to pass in when invoking @p aCallback.
-     */
-    OutputImplementer(otCliOutputCallback aCallback, void *aCallbackContext);
-
 #if OPENTHREAD_CONFIG_CLI_LOG_INPUT_OUTPUT_ENABLE
     void SetEmittingCommandOutput(bool aEmittingOutput) { mEmittingCommandOutput = aEmittingOutput; }
 #else
     void SetEmittingCommandOutput(bool) {}
 #endif
 
+protected:
+    OutputImplementer(otCliOutputCallback aCallback, void *aCallbackContext);
+
 private:
     static constexpr uint16_t kInputOutputLogStringSize = OPENTHREAD_CONFIG_CLI_LOG_INPUT_OUTPUT_LOG_STRING_SIZE;
 
@@ -198,6 +195,13 @@
     otInstance *GetInstancePtr(void) { return mInstance; }
 
     /**
+     * Returns the associated CLI `Interpreter`.
+     *
+     * @returns A reference to the associated CLI `Interpreter`.
+     */
+    Interpreter &GetInterpreter(void);
+
+    /**
      * Converts a boolean to "yes" or "no" string.
      *
      * @param[in] aBool  A boolean value to convert.
@@ -227,6 +231,16 @@
     static const char *Uint64ToString(uint64_t aUint64, Uint64StringBuffer &aBuffer);
 
     /**
+     * Outputs the command result.
+     *
+     * This is called to end the current command. `OT_ERROR_PENDING` can be used to indicate command execution is
+     * continuing asynchronously.
+     *
+     * @param[in]  aError Error code value.
+     */
+    void OutputResult(otError aError);
+
+    /**
      * Delivers a formatted output string to the CLI console.
      *
      * @param[in]  aFormat  A pointer to the format string.
@@ -419,6 +433,20 @@
     void OutputSockAddrLine(const otSockAddr &aSockAddr);
 
     /**
+     * Outputs the Vendor OUI to the CLI console.
+     *
+     * @param[in] aOui  A reference to the Vendor OUI.
+     */
+    void OutputVendorOui(const otThreadVendorOui &aOui);
+
+    /**
+     * Outputs the Vendor OUI to the CLI console and appends a newline.
+     *
+     * @param[in] aOui  A reference to the Vendor OUI.
+     */
+    void OutputVendorOuiLine(const otThreadVendorOui &aOui);
+
+    /**
      * Outputs DNS TXT data to the CLI console.
      *
      * All key-value pairs are output on a single line in the format: "[key1=value1, key2=value2, ...]".
@@ -661,24 +689,20 @@
     static const char *PreferenceToString(signed int aPreference);
 
     /**
-     * Parses the argument as an IP address.
+     * Parses the argument as an IPv6 address or synthesizes it from an IPv4 address.
      *
      * If the argument string is an IPv4 address, this method will try to synthesize an IPv6 address using preferred
      * NAT64 prefix in the network data.
      *
-     * @param[in]  aInstance       A pointer to OpenThread instance.
      * @param[in]  aArg            The argument string to parse.
-     * @param[out] aAddress        A reference to an `otIp6Address` to output the parsed IPv6 address.
+     * @param[out] aAddress        A reference to an `otIp6Address` to output the parsed/synthesized IPv6 address.
      * @param[out] aSynthesized    Whether @p aAddress is synthesized from an IPv4 address.
      *
-     * @retval OT_ERROR_NONE           The argument was parsed successfully.
+     * @retval OT_ERROR_NONE           The argument was parsed/synthesized successfully.
      * @retval OT_ERROR_INVALID_ARGS   The argument is empty or does not contain a valid IP address.
      * @retval OT_ERROR_INVALID_STATE  No valid NAT64 prefix in the network data.
      */
-    static otError ParseToIp6Address(otInstance   *aInstance,
-                                     const Arg    &aArg,
-                                     otIp6Address &aAddress,
-                                     bool         &aSynthesized);
+    otError ParseOrSynthesizeIp6Address(const Arg &aArg, otIp6Address &aAddress, bool &aSynthesized);
 
     /**
      * Parses the argument as a Joiner Discerner.
diff --git a/src/cli/radio.cmake b/src/cli/radio.cmake
index 75f610e..12d38ef 100644
--- a/src/cli/radio.cmake
+++ b/src/cli/radio.cmake
@@ -46,6 +46,7 @@
 target_sources(openthread-cli-radio
     PRIVATE
         cli.cpp
+        cli_api.cpp
         cli_logging.cpp
         cli_utils.cpp
 )
diff --git a/src/core/BUILD.gn b/src/core/BUILD.gn
index 64602fe..660d162 100644
--- a/src/core/BUILD.gn
+++ b/src/core/BUILD.gn
@@ -30,7 +30,7 @@
 visibility = [ "../../*" ]
 
 if (openthread_enable_core_config_args) {
-# TODO enable later
+  # TODO (b/532686603): enable later.
   # ot_bool_flags = [
   #   {
   #     macro = "OPENTHREAD_CONFIG_ANYCAST_LOCATOR_ENABLE"
@@ -105,10 +105,6 @@
   #     value = openthread_config_dns_client_enable
   #   },
   #   {
-  #     macro = "OPENTHREAD_CONFIG_DUA_ENABLE"
-  #     value = openthread_config_dua_enable
-  #   },
-  #   {
   #     macro = "OPENTHREAD_CONFIG_ECDSA_ENABLE"
   #     value = openthread_config_ecdsa_enable
   #   },
@@ -281,7 +277,7 @@
       defines += [ "OPENTHREAD_CONFIG_LOG_PREPEND_LEVEL=1" ]
     }
 
-# TODO remove below later
+# TODO (b/532686603): remove below later
     if (!openthread_config_assert_enable) {
       defines += [ "OPENTHREAD_CONFIG_ASSERT_ENABLE=0" ]
     }
@@ -368,10 +364,6 @@
       defines += [ "OPENTHREAD_CONFIG_ECDSA_ENABLE=1" ]
     }
 
-    if (openthread_config_dua_enable) {
-      defines += [ "OPENTHREAD_CONFIG_DUA_ENABLE=1" ]
-    }
-
     if (openthread_config_mlr_enable) {
       defines += [ "OPENTHREAD_CONFIG_MLR_ENABLE=1" ]
     }
@@ -608,8 +600,6 @@
   "backbone_router/bbr_manager.hpp",
   "backbone_router/multicast_listeners_table.cpp",
   "backbone_router/multicast_listeners_table.hpp",
-  "backbone_router/ndproxy_table.cpp",
-  "backbone_router/ndproxy_table.hpp",
   "border_router/br_log.cpp",
   "border_router/br_log.hpp",
   "border_router/br_tracker.cpp",
@@ -638,6 +628,7 @@
   "common/as_core_type.hpp",
   "common/binary_search.cpp",
   "common/binary_search.hpp",
+  "common/bit_set.cpp",
   "common/bit_set.hpp",
   "common/bit_utils.cpp",
   "common/bit_utils.hpp",
@@ -853,6 +844,8 @@
   "net/nd_agent.hpp",
   "net/netif.cpp",
   "net/netif.hpp",
+  "net/plat_tcp.cpp",
+  "net/plat_tcp.hpp",
   "net/slaac_address.cpp",
   "net/slaac_address.hpp",
   "net/sntp_client.cpp",
@@ -878,6 +871,8 @@
   "radio/radio.hpp",
   "radio/radio_callbacks.cpp",
   "radio/radio_platform.cpp",
+  "radio/radio_types.cpp",
+  "radio/radio_types.hpp",
   "radio/trel_interface.cpp",
   "radio/trel_interface.hpp",
   "radio/trel_link.cpp",
@@ -907,8 +902,6 @@
   "thread/csl_tx_scheduler.hpp",
   "thread/discover_scanner.cpp",
   "thread/discover_scanner.hpp",
-  "thread/dua_manager.cpp",
-  "thread/dua_manager.hpp",
   "thread/energy_scan_server.cpp",
   "thread/energy_scan_server.hpp",
   "thread/indirect_sender.cpp",
@@ -941,11 +934,18 @@
   "thread/mle_types.hpp",
   "thread/mlr_manager.cpp",
   "thread/mlr_manager.hpp",
+  "thread/mlr_types.cpp",
   "thread/mlr_types.hpp",
   "thread/neighbor.cpp",
   "thread/neighbor.hpp",
   "thread/neighbor_table.cpp",
   "thread/neighbor_table.hpp",
+  "thread/net_diag.cpp",
+  "thread/net_diag.hpp",
+  "thread/net_diag_tlvs.cpp",
+  "thread/net_diag_tlvs.hpp",
+  "thread/net_diag_types.cpp",
+  "thread/net_diag_types.hpp",
   "thread/network_data.cpp",
   "thread/network_data.hpp",
   "thread/network_data_leader.cpp",
@@ -963,10 +963,6 @@
   "thread/network_data_tlvs.hpp",
   "thread/network_data_types.cpp",
   "thread/network_data_types.hpp",
-  "thread/network_diagnostic.cpp",
-  "thread/network_diagnostic.hpp",
-  "thread/network_diagnostic_tlvs.cpp",
-  "thread/network_diagnostic_tlvs.hpp",
   "thread/panid_query_server.cpp",
   "thread/panid_query_server.hpp",
   "thread/peer.cpp",
@@ -985,6 +981,7 @@
   "thread/thread_link_info.hpp",
   "thread/thread_netif.cpp",
   "thread/thread_netif.hpp",
+  "thread/thread_tlvs.cpp",
   "thread/thread_tlvs.hpp",
   "thread/time_sync_service.cpp",
   "thread/time_sync_service.hpp",
@@ -1070,6 +1067,7 @@
   "radio/radio.cpp",
   "radio/radio_callbacks.cpp",
   "radio/radio_platform.cpp",
+  "radio/radio_types.cpp",
   "thread/link_quality.cpp",
   "utils/parse_cmdline.cpp",
   "utils/power_calibration.cpp",
@@ -1118,8 +1116,8 @@
     "config/misc.h",
     "config/mle.h",
     "config/nat64.h",
+    "config/net_diag.h",
     "config/netdata_publisher.h",
-    "config/network_diagnostic.h",
     "config/openthread-core-config-check.h",
     "config/p2p.h",
     "config/parent_search.h",
diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index 60503c9..277d4f3 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -101,7 +101,6 @@
     backbone_router/bbr_local.cpp
     backbone_router/bbr_manager.cpp
     backbone_router/multicast_listeners_table.cpp
-    backbone_router/ndproxy_table.cpp
     border_router/br_log.cpp
     border_router/br_tracker.cpp
     border_router/br_types.cpp
@@ -115,6 +114,7 @@
     coap/coap_secure.cpp
     common/appender.cpp
     common/binary_search.cpp
+    common/bit_set.cpp
     common/bit_utils.cpp
     common/crc.cpp
     common/data.cpp
@@ -210,6 +210,7 @@
     net/nd6.cpp
     net/nd_agent.cpp
     net/netif.cpp
+    net/plat_tcp.cpp
     net/slaac_address.cpp
     net/sntp_client.cpp
     net/socket.cpp
@@ -223,6 +224,7 @@
     radio/radio.cpp
     radio/radio_callbacks.cpp
     radio/radio_platform.cpp
+    radio/radio_types.cpp
     radio/trel_interface.cpp
     radio/trel_link.cpp
     radio/trel_packet.cpp
@@ -237,7 +239,6 @@
     thread/child_table.cpp
     thread/csl_tx_scheduler.cpp
     thread/discover_scanner.cpp
-    thread/dua_manager.cpp
     thread/energy_scan_server.cpp
     thread/indirect_sender.cpp
     thread/key_manager.cpp
@@ -255,8 +256,12 @@
     thread/mle_tlvs.cpp
     thread/mle_types.cpp
     thread/mlr_manager.cpp
+    thread/mlr_types.cpp
     thread/neighbor.cpp
     thread/neighbor_table.cpp
+    thread/net_diag.cpp
+    thread/net_diag_tlvs.cpp
+    thread/net_diag_types.cpp
     thread/network_data.cpp
     thread/network_data_leader.cpp
     thread/network_data_leader_ftd.cpp
@@ -266,8 +271,6 @@
     thread/network_data_service.cpp
     thread/network_data_tlvs.cpp
     thread/network_data_types.cpp
-    thread/network_diagnostic.cpp
-    thread/network_diagnostic_tlvs.cpp
     thread/panid_query_server.cpp
     thread/peer.cpp
     thread/peer_table.cpp
@@ -277,6 +280,7 @@
     thread/src_match_controller.cpp
     thread/thread_link_info.cpp
     thread/thread_netif.cpp
+    thread/thread_tlvs.cpp
     thread/time_sync_service.cpp
     thread/tmf.cpp
     thread/uri_paths.cpp
@@ -336,6 +340,7 @@
     radio/radio.cpp
     radio/radio_callbacks.cpp
     radio/radio_platform.cpp
+    radio/radio_types.cpp
     thread/link_quality.cpp
     utils/otns.cpp
     utils/parse_cmdline.cpp
diff --git a/src/core/api/backbone_router_api.cpp b/src/core/api/backbone_router_api.cpp
index e312a90..1234126 100644
--- a/src/core/api/backbone_router_api.cpp
+++ b/src/core/api/backbone_router_api.cpp
@@ -46,7 +46,7 @@
 {
     AssertPointerIsNotNull(aConfig);
 
-    return AsCoreType(aInstance).Get<BackboneRouter::Leader>().GetConfig(*aConfig);
+    return AsCoreType(aInstance).Get<BackboneRouter::Leader>().ReadConfig(AsCoreType(aConfig));
 }
 
 #endif // (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
diff --git a/src/core/api/backbone_router_ftd_api.cpp b/src/core/api/backbone_router_ftd_api.cpp
index 0ee8bc7..eb04c02 100644
--- a/src/core/api/backbone_router_ftd_api.cpp
+++ b/src/core/api/backbone_router_ftd_api.cpp
@@ -57,14 +57,14 @@
 {
     AssertPointerIsNotNull(aConfig);
 
-    AsCoreType(aInstance).Get<BackboneRouter::Local>().GetConfig(*aConfig);
+    AsCoreType(aInstance).Get<BackboneRouter::Local>().GetConfig(AsCoreType(aConfig));
 }
 
 otError otBackboneRouterSetConfig(otInstance *aInstance, const otBackboneRouterConfig *aConfig)
 {
     AssertPointerIsNotNull(aConfig);
 
-    return AsCoreType(aInstance).Get<BackboneRouter::Local>().SetConfig(*aConfig);
+    return AsCoreType(aInstance).Get<BackboneRouter::Local>().SetConfig(AsCoreType(aConfig));
 }
 
 otError otBackboneRouterRegister(otInstance *aInstance)
@@ -82,37 +82,6 @@
     return AsCoreType(aInstance).Get<BackboneRouter::Local>().SetRegistrationJitter(aJitter);
 }
 
-otError otBackboneRouterGetDomainPrefix(otInstance *aInstance, otBorderRouterConfig *aConfig)
-{
-    return AsCoreType(aInstance).Get<BackboneRouter::Local>().GetDomainPrefix(AsCoreType(aConfig));
-}
-
-void otBackboneRouterSetDomainPrefixCallback(otInstance                          *aInstance,
-                                             otBackboneRouterDomainPrefixCallback aCallback,
-                                             void                                *aContext)
-{
-    return AsCoreType(aInstance).Get<BackboneRouter::Local>().SetDomainPrefixCallback(aCallback, aContext);
-}
-
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-void otBackboneRouterSetNdProxyCallback(otInstance                     *aInstance,
-                                        otBackboneRouterNdProxyCallback aCallback,
-                                        void                           *aContext)
-{
-    AsCoreType(aInstance).Get<BackboneRouter::NdProxyTable>().SetCallback(aCallback, aContext);
-}
-
-otError otBackboneRouterGetNdProxyInfo(otInstance                  *aInstance,
-                                       const otIp6Address          *aDua,
-                                       otBackboneRouterNdProxyInfo *aNdProxyInfo)
-{
-    AssertPointerIsNotNull(aNdProxyInfo);
-
-    return AsCoreType(aInstance).Get<BackboneRouter::NdProxyTable>().GetInfo(
-        reinterpret_cast<const Ip6::Address &>(*aDua), *aNdProxyInfo);
-}
-#endif // OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
 void otBackboneRouterSetMulticastListenerCallback(otInstance                               *aInstance,
                                                   otBackboneRouterMulticastListenerCallback aCallback,
@@ -133,23 +102,14 @@
 #endif
 
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-void otBackboneRouterConfigNextDuaRegistrationResponse(otInstance                     *aInstance,
-                                                       const otIp6InterfaceIdentifier *aMlIid,
-                                                       uint8_t                         aStatus)
-{
-    AsCoreType(aInstance).Get<BackboneRouter::Manager>().ConfigNextDuaRegistrationResponse(AsCoreTypePtr(aMlIid),
-                                                                                           aStatus);
-}
-#endif
 
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
 void otBackboneRouterConfigNextMulticastListenerRegistrationResponse(otInstance *aInstance, uint8_t aStatus)
 {
-    OT_ASSERT(aStatus <= kMlrStatusMax);
+    OT_ASSERT(aStatus <= Mlr::kMaxStatusValue);
 
     AsCoreType(aInstance).Get<BackboneRouter::Manager>().ConfigNextMulticastListenerRegistrationResponse(
-        static_cast<MlrStatus>(aStatus));
+        static_cast<Mlr::Status>(aStatus));
 }
 
 void otBackboneRouterMulticastListenerClear(otInstance *aInstance)
diff --git a/src/core/api/border_router_api.cpp b/src/core/api/border_router_api.cpp
index d000b1c..b52d436 100644
--- a/src/core/api/border_router_api.cpp
+++ b/src/core/api/border_router_api.cpp
@@ -49,36 +49,12 @@
 
 otError otBorderRouterAddOnMeshPrefix(otInstance *aInstance, const otBorderRouterConfig *aConfig)
 {
-    Error error = kErrorNone;
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-    if (aConfig->mDp)
-    {
-        error = AsCoreType(aInstance).Get<BackboneRouter::Local>().SetDomainPrefix(AsCoreType(aConfig));
-    }
-    else
-#endif
-    {
-        error = AsCoreType(aInstance).Get<NetworkData::Local>().AddOnMeshPrefix(AsCoreType(aConfig));
-    }
-
-    return error;
+    return AsCoreType(aInstance).Get<NetworkData::Local>().AddOnMeshPrefix(AsCoreType(aConfig));
 }
 
 otError otBorderRouterRemoveOnMeshPrefix(otInstance *aInstance, const otIp6Prefix *aPrefix)
 {
-    Error error = kErrorNone;
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-    error = AsCoreType(aInstance).Get<BackboneRouter::Local>().RemoveDomainPrefix(AsCoreType(aPrefix));
-
-    if (error == kErrorNotFound)
-#endif
-    {
-        error = AsCoreType(aInstance).Get<NetworkData::Local>().RemoveOnMeshPrefix(AsCoreType(aPrefix));
-    }
-
-    return error;
+    return AsCoreType(aInstance).Get<NetworkData::Local>().RemoveOnMeshPrefix(AsCoreType(aPrefix));
 }
 
 otError otBorderRouterGetNextOnMeshPrefix(otInstance            *aInstance,
diff --git a/src/core/api/coap_secure_api.cpp b/src/core/api/coap_secure_api.cpp
index 0f0fc6b..738a572 100644
--- a/src/core/api/coap_secure_api.cpp
+++ b/src/core/api/coap_secure_api.cpp
@@ -41,13 +41,7 @@
 
 otError otCoapSecureStart(otInstance *aInstance, uint16_t aPort)
 {
-    otError error;
-
-    SuccessOrExit(error = AsCoreType(aInstance).Get<Coap::ApplicationCoapSecure>().Open());
-    error = AsCoreType(aInstance).Get<Coap::ApplicationCoapSecure>().Bind(aPort);
-
-exit:
-    return error;
+    return AsCoreType(aInstance).Get<Coap::ApplicationCoapSecure>().Open(aPort);
 }
 
 otError otCoapSecureStartWithMaxConnAttempts(otInstance                  *aInstance,
diff --git a/src/core/api/crypto_api.cpp b/src/core/api/crypto_api.cpp
index fa70274..96e7523 100644
--- a/src/core/api/crypto_api.cpp
+++ b/src/core/api/crypto_api.cpp
@@ -64,22 +64,11 @@
                     bool               aEncrypt,
                     void              *aTag)
 {
-    AesCcm aesCcm;
-
     AssertPointerIsNotNull(aNonce);
     AssertPointerIsNotNull(aPlainText);
     AssertPointerIsNotNull(aCipherText);
     AssertPointerIsNotNull(aTag);
 
-    aesCcm.SetKey(AsCoreType(aKey));
-    aesCcm.Init(aHeaderLength, aLength, aTagLength, aNonce, aNonceLength);
-
-    if (aHeaderLength != 0)
-    {
-        OT_ASSERT(aHeader != nullptr);
-        aesCcm.Header(aHeader, aHeaderLength);
-    }
-
-    aesCcm.Payload(aPlainText, aCipherText, aLength, aEncrypt ? AesCcm::kEncrypt : AesCcm::kDecrypt);
-    aesCcm.Finalize(aTag);
+    AesCcm::Perform(aEncrypt ? AesCcm::kEncrypt : AesCcm::kDecrypt, AsCoreType(aKey), aTagLength, aNonce, aNonceLength,
+                    aHeader, aHeaderLength, aPlainText, aCipherText, aLength, aTag);
 }
diff --git a/src/core/api/dataset_api.cpp b/src/core/api/dataset_api.cpp
index c8f902e..99be958 100644
--- a/src/core/api/dataset_api.cpp
+++ b/src/core/api/dataset_api.cpp
@@ -153,6 +153,21 @@
     return (error == OT_ERROR_ALREADY) ? OT_ERROR_NONE : error;
 }
 
+bool otDatasetIsValid(const otOperationalDatasetTlvs *aDatasetTlvs, bool aActive)
+{
+    bool             isValid = false;
+    MeshCoP::Dataset dataset;
+
+    AssertPointerIsNotNull(aDatasetTlvs);
+
+    SuccessOrExit(dataset.SetFrom(*aDatasetTlvs));
+    SuccessOrExit(dataset.ValidateTlvs());
+    isValid = dataset.ContainsAllRequiredTlvsFor(aActive ? MeshCoP::Dataset::kActive : MeshCoP::Dataset::kPending);
+
+exit:
+    return isValid;
+}
+
 otError otDatasetParseTlvs(const otOperationalDatasetTlvs *aDatasetTlvs, otOperationalDataset *aDataset)
 {
     Error            error = kErrorNone;
@@ -213,3 +228,17 @@
 exit:
     return error;
 }
+
+bool otDatasetAffectsConnectivity(otInstance *aInstance, const otOperationalDatasetTlvs *aDatasetTlvs)
+{
+    bool             affects = false;
+    MeshCoP::Dataset dataset;
+
+    AssertPointerIsNotNull(aDatasetTlvs);
+
+    SuccessOrExit(dataset.SetFrom(*aDatasetTlvs));
+    affects = dataset.AffectsConnectivity(AsCoreType(aInstance));
+
+exit:
+    return affects;
+}
diff --git a/src/core/api/dns_api.cpp b/src/core/api/dns_api.cpp
index 71d6fc2..aeff4bf 100644
--- a/src/core/api/dns_api.cpp
+++ b/src/core/api/dns_api.cpp
@@ -49,7 +49,7 @@
 }
 
 otError otDnsEncodeTxtData(const otDnsTxtEntry *aTxtEntries,
-                           uint8_t              aNumTxtEntries,
+                           uint16_t             aNumTxtEntries,
                            uint8_t             *aTxtData,
                            uint16_t            *aTxtDataLength)
 {
diff --git a/src/core/api/instance_api.cpp b/src/core/api/instance_api.cpp
index 96943d6..73e39f3 100644
--- a/src/core/api/instance_api.cpp
+++ b/src/core/api/instance_api.cpp
@@ -116,7 +116,7 @@
     {
         StringWriter writer(aBuffer, aSize);
 
-        UptimeToString(AsCoreType(aInstance).Get<UptimeTracker>().GetUptime(), writer, /* aIncludeMsec */ true);
+        UptimeToString(AsCoreType(aInstance).Get<UptimeTracker>().GetUptime(), writer, kUptimeStringIncludeMsec);
     }
 }
 #endif
diff --git a/src/core/api/ip6_api.cpp b/src/core/api/ip6_api.cpp
index 1e9adcc..bddb36c 100644
--- a/src/core/api/ip6_api.cpp
+++ b/src/core/api/ip6_api.cpp
@@ -191,7 +191,7 @@
 
 void otIp6FormLinkLocalAddressFromExtAddress(const otExtAddress *aExtAddress, otIp6Address *aAddress)
 {
-    AsCoreType(aAddress).SetToLinkLocalAddress(AsCoreType(aExtAddress));
+    AsCoreType(aAddress).InitAsLinkLocalAddress(AsCoreType(aExtAddress));
 }
 
 void otIp6ExtractExtAddressFromIp6AddressIid(const otIp6Address *aAddress, otExtAddress *aExtAddress)
@@ -260,8 +260,8 @@
                                         otIp6RegisterMulticastListenersCallback aCallback,
                                         void                                   *aContext)
 {
-    return AsCoreType(aInstance).Get<MlrManager>().RegisterMulticastListeners(AsCoreTypePtr(aAddresses), aAddressNum,
-                                                                              aTimeout, aCallback, aContext);
+    return AsCoreType(aInstance).Get<Mlr::Manager>().RegisterMulticastListeners(AsCoreTypePtr(aAddresses), aAddressNum,
+                                                                                aTimeout, aCallback, aContext);
 }
 #endif
 
@@ -312,3 +312,15 @@
     AsCoreType(aInstance).Get<Ip6::Ip6>().ResetBorderRoutingCounters();
 }
 #endif
+
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+void otIp6SetAllowUnsecureWhenDisabled(otInstance *aInstance, bool aAllow)
+{
+    AsCoreType(aInstance).Get<Ip6::Filter>().SetAllowUnsecureWhenDisabled(aAllow);
+}
+
+bool otIp6IsUnsecureAllowedWhenDisabled(otInstance *aInstance)
+{
+    return AsCoreType(aInstance).Get<Ip6::Filter>().IsUnsecureAllowedWhenDisabled();
+}
+#endif
diff --git a/src/core/api/logging_api.cpp b/src/core/api/logging_api.cpp
index 406ea96..d339330 100644
--- a/src/core/api/logging_api.cpp
+++ b/src/core/api/logging_api.cpp
@@ -37,20 +37,43 @@
 
 using namespace ot;
 
-otLogLevel otLoggingGetLevel(void) { return static_cast<otLogLevel>(Instance::GetLogLevel()); }
+otLogLevel otGetLogLevel(otInstance *aInstance) { return MapEnum(AsCoreType(aInstance).GetLogLevel()); }
+
+otLogLevel otLoggingGetLevel(void)
+{
+    LogLevel level;
+
+#if !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    level = Instance::Get().GetLogLevel();
+#else
+    level = Instance::GetGlobalLogLevel();
+#endif
+
+    return MapEnum(level);
+}
 
 #if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
+
+otError otSetLogLevel(otInstance *aInstance, otLogLevel aLogLevel)
+{
+    return AsCoreType(aInstance).SetLogLevel(MapEnum(aLogLevel));
+}
+
 otError otLoggingSetLevel(otLogLevel aLogLevel)
 {
-    Error error = kErrorNone;
+    Error    error;
+    LogLevel level = MapEnum(aLogLevel);
 
-    VerifyOrExit(aLogLevel <= kLogLevelDebg && aLogLevel >= kLogLevelNone, error = kErrorInvalidArgs);
-    Instance::SetLogLevel(static_cast<LogLevel>(aLogLevel));
+#if !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    error = Instance::Get().SetLogLevel(level);
+#else
+    error = Instance::SetGlobalLogLevel(level);
+#endif
 
-exit:
     return error;
 }
-#endif
+
+#endif // OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
 
 static const char kPlatformModuleName[] = "Platform";
 
diff --git a/src/core/api/mesh_diag_api.cpp b/src/core/api/mesh_diag_api.cpp
index 5e1d4f7..18905ef 100644
--- a/src/core/api/mesh_diag_api.cpp
+++ b/src/core/api/mesh_diag_api.cpp
@@ -60,6 +60,11 @@
     return AsCoreType(aIterator).GetNextChildInfo(AsCoreType(aChildInfo));
 }
 
+otError otMeshDiagGetNextTlvInfo(otMeshDiagTlvIterator *aIterator, otMeshDiagTlvInfo *aTlvInfo)
+{
+    return AsCoreType(aIterator).GetNextTlvInfo(*aTlvInfo);
+}
+
 otError otMeshDiagQueryChildTable(otInstance                       *aInstance,
                                   uint16_t                          aRloc16,
                                   otMeshDiagQueryChildTableCallback aCallback,
diff --git a/src/core/api/nat64_api.cpp b/src/core/api/nat64_api.cpp
index c243aa9..3807995 100644
--- a/src/core/api/nat64_api.cpp
+++ b/src/core/api/nat64_api.cpp
@@ -142,7 +142,7 @@
 
 void otIp4ToIp4MappedIp6Address(const otIp4Address *aIp4Address, otIp6Address *aIp6Address)
 {
-    AsCoreType(aIp6Address).SetToIp4Mapped(AsCoreType(aIp4Address));
+    AsCoreType(aIp6Address).InitAsIp4Mapped(AsCoreType(aIp4Address));
 }
 
 otError otIp4AddressFromString(const char *aString, otIp4Address *aAddress)
diff --git a/src/core/api/netdiag_api.cpp b/src/core/api/netdiag_api.cpp
index c18922c..0b607ff 100644
--- a/src/core/api/netdiag_api.cpp
+++ b/src/core/api/netdiag_api.cpp
@@ -46,7 +46,7 @@
     AssertPointerIsNotNull(aIterator);
     AssertPointerIsNotNull(aNetworkDiagTlv);
 
-    return NetworkDiagnostic::Client::GetNextDiagTlv(AsCoapMessage(aMessage), *aIterator, *aNetworkDiagTlv);
+    return NetDiag::Client::GetNextDiagTlv(AsCoapMessage(aMessage), *aIterator, *aNetworkDiagTlv);
 }
 
 otError otThreadSendDiagnosticGet(otInstance                    *aInstance,
@@ -56,8 +56,8 @@
                                   otReceiveDiagnosticGetCallback aCallback,
                                   void                          *aCallbackContext)
 {
-    return AsCoreType(aInstance).Get<NetworkDiagnostic::Client>().SendDiagnosticGet(
-        AsCoreType(aDestination), aTlvTypes, aCount, aCallback, aCallbackContext);
+    return AsCoreType(aInstance).Get<NetDiag::Client>().SendDiagnosticGet(AsCoreType(aDestination), aTlvTypes, aCount,
+                                                                          aCallback, aCallbackContext);
 }
 
 otError otThreadSendDiagnosticReset(otInstance         *aInstance,
@@ -65,8 +65,8 @@
                                     const uint8_t       aTlvTypes[],
                                     uint8_t             aCount)
 {
-    return AsCoreType(aInstance).Get<NetworkDiagnostic::Client>().SendDiagnosticReset(AsCoreType(aDestination),
-                                                                                      aTlvTypes, aCount);
+    return AsCoreType(aInstance).Get<NetDiag::Client>().SendDiagnosticReset(AsCoreType(aDestination), aTlvTypes,
+                                                                            aCount);
 }
 
 #endif // OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE
@@ -85,7 +85,27 @@
     return AsCoreType(aInstance).Get<VendorInfo>().GetAppUrl();
 }
 
+void otThreadGetVendorOuiInfo(otInstance *aInstance, otThreadVendorOui *aOui)
+{
+    AssertPointerIsNotNull(aOui);
+
+    *aOui = AsCoreType(aInstance).Get<VendorInfo>().GetOui();
+}
+
+void otThreadVendorOuiToString(const otThreadVendorOui *aOui, char *aBuffer, uint16_t aSize)
+{
+    AsCoreType(aOui).ToString(aBuffer, aSize);
+}
+
+uint32_t otThreadGetVendorOui(otInstance *aInstance)
+{
+    // This API is deprecated. Use `otThreadGetVendorOuiInfo()` instead
+
+    return AsCoreType(aInstance).Get<VendorInfo>().GetOui().GetAsOui24();
+}
+
 #if OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE
+
 otError otThreadSetVendorName(otInstance *aInstance, const char *aVendorName)
 {
     return AsCoreType(aInstance).Get<VendorInfo>().SetName(aVendorName);
@@ -105,21 +125,46 @@
 {
     return AsCoreType(aInstance).Get<VendorInfo>().SetAppUrl(aVendorAppUrl);
 }
-#endif
+
+otError otThreadSetVendorOuiInfo(otInstance *aInstance, const otThreadVendorOui *aOui)
+{
+    return AsCoreType(aInstance).Get<VendorInfo>().SetOui(AsCoreType(aOui));
+}
+
+otError otThreadSetVendorOui(otInstance *aInstance, uint32_t aVendorOui)
+{
+    // This API is deprecated, use `otThreadSetVendorOuiInfo()` instead
+
+    Error           error;
+    VendorInfo::Oui oui;
+
+    VerifyOrExit(aVendorOui <= 0xffffff, error = kErrorInvalidArgs);
+
+    oui.Clear();
+    oui.mBitLength = 24;
+    BigEndian::WriteUint24(aVendorOui, oui.mBytes);
+
+    error = AsCoreType(aInstance).Get<VendorInfo>().SetOui(oui);
+
+exit:
+    return error;
+}
+
+#endif // OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE
 
 void otThreadSetNonPreferredChannels(otInstance *aInstance, otChannelMask aChannelMask)
 {
-    return AsCoreType(aInstance).Get<NetworkDiagnostic::Server>().SetNonPreferredChannels(aChannelMask);
+    return AsCoreType(aInstance).Get<NetDiag::Server>().SetNonPreferredChannels(aChannelMask);
 }
 
 otChannelMask otThreadGetNonPreferredChannels(otInstance *aInstance)
 {
-    return AsCoreType(aInstance).Get<NetworkDiagnostic::Server>().GetNonPreferredChannels();
+    return AsCoreType(aInstance).Get<NetDiag::Server>().GetNonPreferredChannels();
 }
 
 void otThreadSetNonPreferredChannelsResetCallback(otInstance                               *aInstance,
                                                   otThreadNonPreferredChannelsResetCallback aCallback,
                                                   void                                     *aContext)
 {
-    AsCoreType(aInstance).Get<NetworkDiagnostic::Server>().SetNonPreferredChannelsResetCallback(aCallback, aContext);
+    AsCoreType(aInstance).Get<NetDiag::Server>().SetNonPreferredChannelsResetCallback(aCallback, aContext);
 }
diff --git a/src/core/api/random_crypto_api.cpp b/src/core/api/random_crypto_api.cpp
index ac9ff48..50842c8 100644
--- a/src/core/api/random_crypto_api.cpp
+++ b/src/core/api/random_crypto_api.cpp
@@ -34,7 +34,7 @@
 #include <openthread/random_crypto.h>
 
 #include <mbedtls/version.h>
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
 #include <mbedtls/ctr_drbg.h>
 #endif
 
diff --git a/src/core/api/random_noncrypto_api.cpp b/src/core/api/random_noncrypto_api.cpp
index 6a2d18b..1813ad3 100644
--- a/src/core/api/random_noncrypto_api.cpp
+++ b/src/core/api/random_noncrypto_api.cpp
@@ -37,25 +37,25 @@
 
 using namespace ot;
 
-uint32_t otRandomNonCryptoGetUint32(void) { return Random::NonCrypto::GetUint32(); }
+uint32_t otRandomNonCryptoGetUint32(void) { return Random::NonCrypto::Generate<uint32_t>(); }
 
-uint8_t otRandomNonCryptoGetUint8(void) { return Random::NonCrypto::GetUint8(); }
+uint8_t otRandomNonCryptoGetUint8(void) { return Random::NonCrypto::Generate<uint8_t>(); }
 
-uint16_t otRandomNonCryptoGetUint16(void) { return Random::NonCrypto::GetUint16(); }
+uint16_t otRandomNonCryptoGetUint16(void) { return Random::NonCrypto::Generate<uint16_t>(); }
 
 uint8_t otRandomNonCryptoGetUint8InRange(uint8_t aMin, uint8_t aMax)
 {
-    return Random::NonCrypto::GetUint8InRange(aMin, aMax);
+    return Random::NonCrypto::GenerateFromMinUpToExcluding(aMin, aMax);
 }
 
 uint16_t otRandomNonCryptoGetUint16InRange(uint16_t aMin, uint16_t aMax)
 {
-    return Random::NonCrypto::GetUint16InRange(aMin, aMax);
+    return Random::NonCrypto::GenerateFromMinUpToExcluding(aMin, aMax);
 }
 
 uint32_t otRandomNonCryptoGetUint32InRange(uint32_t aMin, uint32_t aMax)
 {
-    return Random::NonCrypto::GetUint32InRange(aMin, aMax);
+    return Random::NonCrypto::GenerateFromMinUpToExcluding(aMin, aMax);
 }
 
 void otRandomNonCryptoFillBuffer(uint8_t *aBuffer, uint16_t aSize) { Random::NonCrypto::FillBuffer(aBuffer, aSize); }
diff --git a/src/core/api/sntp_api.cpp b/src/core/api/sntp_api.cpp
index 2b316b2..64568d8 100644
--- a/src/core/api/sntp_api.cpp
+++ b/src/core/api/sntp_api.cpp
@@ -46,7 +46,7 @@
                           otSntpResponseHandler aHandler,
                           void                 *aContext)
 {
-    return AsCoreType(aInstance).Get<Sntp::Client>().Query(aQuery, aHandler, aContext);
+    return AsCoreType(aInstance).Get<Sntp::Client>().Query(AsCoreType(aQuery), aHandler, aContext);
 }
 
 void otSntpClientSetUnixEra(otInstance *aInstance, uint32_t aUnixEra)
diff --git a/src/core/api/thread_api.cpp b/src/core/api/thread_api.cpp
index 8291189..6c1a910 100644
--- a/src/core/api/thread_api.cpp
+++ b/src/core/api/thread_api.cpp
@@ -73,7 +73,7 @@
     Error error = kErrorNone;
 
     VerifyOrExit(!AsCoreType(aInstance).Get<Mle::Mle>().HasRloc16(Mle::kInvalidRloc16), error = kErrorDetached);
-    AsCoreType(aInstance).Get<Mle::Mle>().GetLeaderRloc(AsCoreType(aLeaderRloc));
+    AsCoreType(aInstance).Get<Mle::Mle>().ComposeLeaderRloc(AsCoreType(aLeaderRloc));
 
 exit:
     return error;
@@ -189,7 +189,7 @@
     Error error = kErrorNone;
 
     VerifyOrExit(!AsCoreType(aInstance).Get<Mle::Mle>().HasRloc16(Mle::kInvalidRloc16), error = kErrorDetached);
-    AsCoreType(aInstance).Get<Mle::Mle>().GetServiceAloc(aServiceId, AsCoreType(aServiceAloc));
+    AsCoreType(aInstance).Get<Mle::Mle>().ComposeServiceAloc(aServiceId, AsCoreType(aServiceAloc));
 
 exit:
     return error;
@@ -238,37 +238,6 @@
     return error;
 }
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-otError otThreadSetFixedDuaInterfaceIdentifier(otInstance *aInstance, const otIp6InterfaceIdentifier *aIid)
-{
-    Error error = kErrorNone;
-
-    if (aIid)
-    {
-        error = AsCoreType(aInstance).Get<DuaManager>().SetFixedDuaInterfaceIdentifier(AsCoreType(aIid));
-    }
-    else
-    {
-        AsCoreType(aInstance).Get<DuaManager>().ClearFixedDuaInterfaceIdentifier();
-    }
-
-    return error;
-}
-
-const otIp6InterfaceIdentifier *otThreadGetFixedDuaInterfaceIdentifier(otInstance *aInstance)
-{
-    Instance                       &instance = AsCoreType(aInstance);
-    const otIp6InterfaceIdentifier *iid      = nullptr;
-
-    if (instance.Get<DuaManager>().IsFixedDuaInterfaceIdentifierSet())
-    {
-        iid = &instance.Get<DuaManager>().GetFixedDuaInterfaceIdentifier();
-    }
-
-    return iid;
-}
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE
-
 #endif // (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
 
 uint32_t otThreadGetKeySequenceCounter(otInstance *aInstance)
@@ -533,7 +502,7 @@
     StringWriter writer(aBuffer, aSize);
     UptimeMsec   uptime = static_cast<UptimeMsec>(aDuration) * Time::kOneSecondInMsec;
 
-    UptimeToString(uptime, writer, /* aIncludeMsec */ false);
+    UptimeToString(uptime, writer, /* aFlags */ 0);
 }
 #endif
 
diff --git a/src/core/api/thread_ftd_api.cpp b/src/core/api/thread_ftd_api.cpp
index d838f17..2c09a86 100644
--- a/src/core/api/thread_ftd_api.cpp
+++ b/src/core/api/thread_ftd_api.cpp
@@ -51,19 +51,19 @@
 
 uint8_t otThreadGetMaxChildIpAddresses(otInstance *aInstance)
 {
-    return AsCoreType(aInstance).Get<Mle::Mle>().GetMaxChildIpAddresses();
+    return AsCoreType(aInstance).Get<ChildTable>().GetMaxChildIpAddresses();
 }
 
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
 otError otThreadSetMaxChildIpAddresses(otInstance *aInstance, uint8_t aMaxIpAddresses)
 {
-    return AsCoreType(aInstance).Get<Mle::Mle>().SetMaxChildIpAddresses(aMaxIpAddresses);
+    return AsCoreType(aInstance).Get<ChildTable>().OverrideMaxChildIpAddresses(aMaxIpAddresses);
 }
 #endif
 
 bool otThreadIsRouterEligible(otInstance *aInstance)
 {
-    return AsCoreType(aInstance).Get<Mle::Mle>().IsRouterEligible();
+    return AsCoreType(aInstance).Get<Mle::Mle>().IsRouterRoleAllowed();
 }
 
 otError otThreadSetRouterEligible(otInstance *aInstance, bool aEligible)
@@ -71,10 +71,12 @@
     return AsCoreType(aInstance).Get<Mle::Mle>().SetRouterEligible(aEligible);
 }
 
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
 otError otThreadSetPreferredRouterId(otInstance *aInstance, uint8_t aRouterId)
 {
     return AsCoreType(aInstance).Get<Mle::Mle>().SetPreferredRouterId(aRouterId);
 }
+#endif
 
 #if OPENTHREAD_CONFIG_MLE_DEVICE_PROPERTY_LEADER_WEIGHT_ENABLE
 const otDeviceProperties *otThreadGetDeviceProperties(otInstance *aInstance)
@@ -150,6 +152,7 @@
 void otThreadSetRouterUpgradeThreshold(otInstance *aInstance, uint8_t aThreshold)
 {
     AsCoreType(aInstance).Get<Mle::Mle>().SetRouterUpgradeThreshold(aThreshold);
+    AsCoreType(aInstance).Get<Mle::Mle>().SetLeaderUpgradeThreshold(aThreshold);
 }
 
 uint8_t otThreadGetChildRouterLinks(otInstance *aInstance)
@@ -257,6 +260,8 @@
                                                                           AsCoreType(aIterator));
 }
 
+void otThreadClearEidCache(otInstance *aInstance) { AsCoreType(aInstance).Get<AddressResolver>().Clear(); }
+
 #if OPENTHREAD_CONFIG_MLE_STEERING_DATA_SET_OOB_ENABLE
 void otThreadSetSteeringData(otInstance *aInstance, const otExtAddress *aExtAddress)
 {
@@ -340,17 +345,6 @@
                                                                           nullptr, AsCoreType(aDestination));
 }
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-otError otThreadSendProactiveBackboneNotification(otInstance               *aInstance,
-                                                  otIp6Address             *aTarget,
-                                                  otIp6InterfaceIdentifier *aMlIid,
-                                                  uint32_t                  aTimeSinceLastTransaction)
-{
-    return AsCoreType(aInstance).Get<BackboneRouter::Manager>().SendProactiveBackboneNotification(
-        AsCoreType(aTarget), AsCoreType(aMlIid), aTimeSinceLastTransaction);
-}
-#endif
-
 void otThreadSetCcmEnabled(otInstance *aInstance, bool aEnabled)
 {
     AsCoreType(aInstance).Get<Mle::Mle>().SetCcmEnabled(aEnabled);
diff --git a/src/core/api/udp_api.cpp b/src/core/api/udp_api.cpp
index dcca993..ed42d9a 100644
--- a/src/core/api/udp_api.cpp
+++ b/src/core/api/udp_api.cpp
@@ -81,7 +81,10 @@
     return error;
 }
 
-otUdpSocket *otUdpGetSockets(otInstance *aInstance) { return AsCoreType(aInstance).Get<Ip6::Udp>().GetUdpSockets(); }
+otUdpSocket *otUdpGetSockets(otInstance *aInstance)
+{
+    return AsCoreType(aInstance).Get<Ip6::Udp>().GetUdpSockets().GetHead();
+}
 
 #if OPENTHREAD_CONFIG_UDP_FORWARD_ENABLE
 void otUdpForwardSetForwarder(otInstance *aInstance, otUdpForwarder aForwarder, void *aContext)
diff --git a/src/core/backbone_router/backbone_tmf.cpp b/src/core/backbone_router/backbone_tmf.cpp
index 38cb328..a68f4b0 100644
--- a/src/core/backbone_router/backbone_tmf.cpp
+++ b/src/core/backbone_router/backbone_tmf.cpp
@@ -87,11 +87,6 @@
 
     switch (uri)
     {
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-        Case(kUriBackboneQuery, Manager);
-        Case(kUriBackboneAnswer, Manager);
-#endif
-
     default:
         didHandle = false;
         break;
@@ -121,12 +116,10 @@
     // A Backbone TMF message must comply with following rules:
     // The destination must be one of:
     //     1. All Network BBRs (Link-Local scope)
-    //     2. All Domain BBRs (Link-Local scope)
-    //     3. A Backbone Link-Local address
+    //     2. A Backbone Link-Local address
     // The source must be a Backbone Link-local address.
     return (Get<BackboneRouter::Local>().IsEnabled() && src.IsLinkLocalUnicast() &&
-            (dst.IsLinkLocalUnicast() || dst == Get<BackboneRouter::Local>().GetAllNetworkBackboneRoutersAddress() ||
-             dst == Get<BackboneRouter::Local>().GetAllDomainBackboneRoutersAddress()));
+            (dst.IsLinkLocalUnicast() || dst == Get<BackboneRouter::Local>().GetAllNetworkBackboneRoutersAddress()));
 }
 
 void BackboneTmfAgent::SubscribeMulticast(const Ip6::Address &aAddress)
diff --git a/src/core/backbone_router/bbr_leader.cpp b/src/core/backbone_router/bbr_leader.cpp
index 3dfa4fb..51d2d37 100644
--- a/src/core/backbone_router/bbr_leader.cpp
+++ b/src/core/backbone_router/bbr_leader.cpp
@@ -42,22 +42,57 @@
 
 RegisterLogModule("BbrLeader");
 
+//---------------------------------------------------------------------------------------------------------------------
+// Config
+
+void Config::AdjustMlrTimeout(void)
+{
+    uint32_t origTimeout;
+
+    VerifyOrExit(IsPresent());
+
+    origTimeout = GetMlrTimeout();
+    mMlrTimeout = Clamp(mMlrTimeout, kMinMlrTimeout, kMaxMlrTimeout);
+
+    VerifyOrExit(mMlrTimeout != origTimeout);
+    LogNote("MLR timeout adjusted: %lu -> %lu", ToUlong(origTimeout), ToUlong(mMlrTimeout));
+
+exit:
+    return;
+}
+
+uint16_t Config::SelectRandomReregistrationDelay(void) const
+{
+    return Random::NonCrypto::GenerateInClosedRange<uint16_t>(1, mReregistrationDelay);
+}
+
+#if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
+void Config::Log(const char *aTitle) const
+{
+    if (IsPresent())
+    {
+        LogInfo("  %s: 0x%04x seqno:%u delay:%u timeout:%lu", aTitle, mServer16, mSequenceNumber, mReregistrationDelay,
+                ToUlong(mMlrTimeout));
+    }
+    else
+    {
+        LogInfo("  %s: none", aTitle);
+    }
+}
+#endif
+
+//---------------------------------------------------------------------------------------------------------------------
+// Leader
+
 Leader::Leader(Instance &aInstance)
     : InstanceLocator(aInstance)
 {
     Reset();
 }
 
-void Leader::Reset(void)
-{
-    // Invalid server short address indicates no available Backbone Router service in the Thread Network.
-    mConfig.mServer16 = Mle::kInvalidRloc16;
+void Leader::Reset(void) { mConfig.MarkAsAbsent(); }
 
-    // Domain Prefix Length 0 indicates no available Domain Prefix in the Thread network.
-    mDomainPrefix.SetLength(0);
-}
-
-Error Leader::GetConfig(Config &aConfig) const
+Error Leader::ReadConfig(Config &aConfig) const
 {
     Error error = kErrorNone;
 
@@ -82,43 +117,15 @@
 
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
 
-void Leader::LogBackboneRouterPrimary(State aState, const Config &aConfig) const
+const char *Leader::PrimaryEventToString(PrimaryEvent aEvent)
 {
-    OT_UNUSED_VARIABLE(aConfig);
+#define PrimaryEventMapList(_)              \
+    _(kPrimaryAdded, "Added")               \
+    _(kPrimaryRemoved, "Removed")           \
+    _(kPrimaryUpdatedReregister, "Updated") \
+    _(kPrimaryConfigParameterChanged, "ConfigChanged")
 
-    LogInfo("PBBR state: %s", StateToString(aState));
-
-    if (aState != kStateRemoved && aState != kStateNone)
-    {
-        LogInfo("Rloc16:0x%4x, seqno:%u, delay:%u, timeout:%lu", aConfig.mServer16, aConfig.mSequenceNumber,
-                aConfig.mReregistrationDelay, ToUlong(aConfig.mMlrTimeout));
-    }
-}
-
-const char *Leader::StateToString(State aState)
-{
-#define StateMapList(_)                        \
-    _(kStateNone, "None")                      \
-    _(kStateAdded, "Added")                    \
-    _(kStateRemoved, "Removed")                \
-    _(kStateToTriggerRereg, "Rereg triggered") \
-    _(kStateRefreshed, "Refreshed")            \
-    _(kStateUnchanged, "Unchanged")
-
-    DefineEnumStringArray(StateMapList);
-
-    return kStrings[aState];
-}
-
-const char *Leader::DomainPrefixEventToString(DomainPrefixEvent aEvent)
-{
-#define DomainPrefixEventMapList(_)        \
-    _(kDomainPrefixAdded, "Added")         \
-    _(kDomainPrefixRemoved, "Removed")     \
-    _(kDomainPrefixRefreshed, "Refreshed") \
-    _(kDomainPrefixUnchanged, "Unchanged")
-
-    DefineEnumStringArray(DomainPrefixEventMapList);
+    DefineEnumStringArray(PrimaryEventMapList);
 
     return kStrings[aEvent];
 }
@@ -127,140 +134,63 @@
 
 void Leader::HandleNotifierEvents(Events aEvents)
 {
-    if (aEvents.Contains(kEventThreadNetdataChanged))
+    if (aEvents.ContainsAny(kEventThreadNetdataChanged | kEventThreadRoleChanged))
     {
         UpdateBackboneRouterPrimary();
-        UpdateDomainPrefixConfig();
     }
 }
 
 void Leader::UpdateBackboneRouterPrimary(void)
 {
-    Config config;
-    State  state;
+    Config       newConfig;
+    PrimaryEvent event;
 
-    Get<NetworkData::Service::Manager>().GetBackboneRouterPrimary(config);
+    Get<NetworkData::Service::Manager>().GetBackboneRouterPrimary(newConfig);
 
-    if (config.mServer16 != mConfig.mServer16)
+    newConfig.AdjustMlrTimeout();
+
+    if (!mConfig.IsPresent())
     {
-        if (config.mServer16 == Mle::kInvalidRloc16)
-        {
-            state = kStateRemoved;
-        }
-        else if (mConfig.mServer16 == Mle::kInvalidRloc16)
-        {
-            state = kStateAdded;
-        }
-        else
-        {
-            // Short Address of PBBR changes.
-            state = kStateToTriggerRereg;
-        }
+        VerifyOrExit(newConfig.IsPresent());
+        event = kPrimaryAdded;
     }
-    else if (config.mServer16 == Mle::kInvalidRloc16)
+    else if (!newConfig.IsPresent())
     {
-        // If no Primary all the time.
-        state = kStateNone;
+        event = kPrimaryRemoved;
     }
-    else if (config.mSequenceNumber != mConfig.mSequenceNumber)
+    else if (newConfig.GetServer16() != mConfig.GetServer16() ||
+             newConfig.GetSequenceNumber() != mConfig.GetSequenceNumber())
     {
-        state = kStateToTriggerRereg;
+        event = kPrimaryUpdatedReregister;
     }
-    else if (config.mReregistrationDelay != mConfig.mReregistrationDelay || config.mMlrTimeout != mConfig.mMlrTimeout)
+    else if (newConfig.GetReregistrationDelay() != mConfig.GetReregistrationDelay() ||
+             newConfig.GetMlrTimeout() != mConfig.GetMlrTimeout())
     {
-        state = kStateRefreshed;
+        event = kPrimaryConfigParameterChanged;
     }
     else
     {
-        state = kStateUnchanged;
+        ExitNow(); // No changes
     }
 
-    // Restrain the range of MLR timeout to be always valid
-    if (config.mServer16 != Mle::kInvalidRloc16)
-    {
-        uint32_t origTimeout = config.mMlrTimeout;
+#if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
+    LogInfo("PrimaryEvent: %s", PrimaryEventToString(event));
+    mConfig.Log("Old");
+    newConfig.Log("New");
+#endif
 
-        config.mMlrTimeout = Clamp(config.mMlrTimeout, kMinMlrTimeout, kMaxMlrTimeout);
-
-        if (config.mMlrTimeout != origTimeout)
-        {
-            LogNote("Leader MLR Timeout is normalized from %lu to %lu", ToUlong(origTimeout),
-                    ToUlong(config.mMlrTimeout));
-        }
-    }
-
-    mConfig = config;
-    LogBackboneRouterPrimary(state, mConfig);
+    mConfig = newConfig;
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-    Get<BackboneRouter::Local>().HandleBackboneRouterPrimaryUpdate(state, mConfig);
+    Get<BackboneRouter::Local>().HandleBackboneRouterPrimaryUpdate(event);
 #endif
 
 #if OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
-    Get<MlrManager>().HandleBackboneRouterPrimaryUpdate(state, mConfig);
-#endif
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-    Get<DuaManager>().HandleBackboneRouterPrimaryUpdate(state, mConfig);
-#endif
-}
-
-void Leader::UpdateDomainPrefixConfig(void)
-{
-    NetworkData::Iterator           iterator = NetworkData::kIteratorInit;
-    NetworkData::OnMeshPrefixConfig prefixConfig;
-    DomainPrefixEvent               event;
-    bool                            found = false;
-
-    while (Get<NetworkData::Leader>().GetNext(iterator, prefixConfig) == kErrorNone)
-    {
-        if (prefixConfig.mDp)
-        {
-            found = true;
-            break;
-        }
-    }
-
-    if (!found)
-    {
-        VerifyOrExit(HasDomainPrefix());
-
-        // Domain Prefix does not exist any more.
-        mDomainPrefix.Clear();
-        event = kDomainPrefixRemoved;
-    }
-    else if (prefixConfig.GetPrefix() == mDomainPrefix)
-    {
-        event = kDomainPrefixUnchanged;
-    }
-    else
-    {
-        event         = HasDomainPrefix() ? kDomainPrefixRefreshed : kDomainPrefixAdded;
-        mDomainPrefix = prefixConfig.GetPrefix();
-    }
-
-    LogInfo("%s domain Prefix: %s", DomainPrefixEventToString(event), mDomainPrefix.ToString().AsCString());
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-    Get<Local>().HandleDomainPrefixUpdate(event);
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    Get<NdProxyTable>().HandleDomainPrefixUpdate(event);
-#endif
-#endif
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-    Get<DuaManager>().HandleDomainPrefixUpdate(event);
-#else
-    OT_UNUSED_VARIABLE(event);
+    Get<Mlr::Manager>().HandleBackboneRouterPrimaryUpdate(event);
 #endif
 
 exit:
-    return;
-}
-
-bool Leader::IsDomainUnicast(const Ip6::Address &aAddress) const
-{
-    return HasDomainPrefix() && aAddress.MatchesPrefix(mDomainPrefix);
+    OT_UNUSED_VARIABLE(event);
 }
 
 } // namespace BackboneRouter
diff --git a/src/core/backbone_router/bbr_leader.hpp b/src/core/backbone_router/bbr_leader.hpp
index f00d94f..fc0b59d 100644
--- a/src/core/backbone_router/bbr_leader.hpp
+++ b/src/core/backbone_router/bbr_leader.hpp
@@ -48,14 +48,13 @@
 #include "common/log.hpp"
 #include "common/non_copyable.hpp"
 #include "common/notifier.hpp"
+#include "common/string.hpp"
 #include "net/ip6_address.hpp"
 
 namespace ot {
 
 namespace BackboneRouter {
 
-typedef otBackboneRouterConfig Config;
-
 constexpr uint16_t kDefaultRegistrationDelay  = 5;                 ///< Default registration delay (in sec).
 constexpr uint32_t kDefaultMlrTimeout         = 3600;              ///< Default MLR Timeout (in sec).
 constexpr uint32_t kMinMlrTimeout             = 300;               ///< Minimum MLR Timeout (in sec).
@@ -69,14 +68,84 @@
 static_assert(kParentAggregateDelay > 1, "kParentAggregateDelay should be larger than 1 second");
 
 /**
- * Represents Domain Prefix changes.
+ * Represents Primary Backbone Router events.
  */
-enum DomainPrefixEvent : uint8_t
+enum PrimaryEvent : uint8_t
 {
-    kDomainPrefixAdded     = OT_BACKBONE_ROUTER_DOMAIN_PREFIX_ADDED,   ///< Domain Prefix Added.
-    kDomainPrefixRemoved   = OT_BACKBONE_ROUTER_DOMAIN_PREFIX_REMOVED, ///< Domain Prefix Removed.
-    kDomainPrefixRefreshed = OT_BACKBONE_ROUTER_DOMAIN_PREFIX_CHANGED, ///< Domain Prefix Changed.
-    kDomainPrefixUnchanged,                                            ///< Domain Prefix did not change.
+    kPrimaryAdded,                  ///< A new Primary Backbone Router is added.
+    kPrimaryRemoved,                ///< The Primary Backbone Router is removed.
+    kPrimaryUpdatedReregister,      ///< The Primary BBR is updated, need re-registration (server16 or seqno change).
+    kPrimaryConfigParameterChanged, ///< Config parameter changed: Re-registration Delay or MLR Timeout value.
+};
+
+class Leader;
+
+/**
+ * Represents a Backbone Router configuration.
+ */
+class Config : public otBackboneRouterConfig
+{
+    friend class Leader;
+
+public:
+    /**
+     * Marks the configuration as absent.
+     *
+     * This is done by setting the Primary Backbone Router short address (`GetServer16()`) to `Mle::kInvalidRloc16`.
+     */
+    void MarkAsAbsent(void) { mServer16 = Mle::kInvalidRloc16; }
+
+    /**
+     * Indicates whether the configuration is present (i.e., it is derived from a Primary Backbone Router).
+     *
+     * The presence state is tracked using the Primary Backbone Router short address (`GetServer16()`).
+     *
+     * @retval TRUE   The configuration is present.
+     * @retval FALSE  The configuration is not present.
+     */
+    bool IsPresent(void) const { return mServer16 != Mle::kInvalidRloc16; }
+
+    /**
+     * Gets the Primary Backbone Router short address.
+     *
+     * @returns The Primary Backbone Router short address, or `Mle::kInvalidRloc16` if not present.
+     */
+    uint16_t GetServer16(void) const { return mServer16; }
+
+    /**
+     * Gets the Reregistration Delay value.
+     *
+     * @returns The Reregistration Delay value (in seconds).
+     */
+    uint16_t GetReregistrationDelay(void) const { return mReregistrationDelay; }
+
+    /**
+     * Gets the Multicast Listener Registration (MLR) Timeout value.
+     *
+     * @returns The MLR Timeout value (in seconds).
+     */
+    uint32_t GetMlrTimeout(void) const { return mMlrTimeout; }
+
+    /**
+     * Gets the Sequence Number.
+     *
+     * @returns The Sequence Number.
+     */
+    uint8_t GetSequenceNumber(void) const { return mSequenceNumber; }
+
+    /**
+     * Selects a random reregistration delay.
+     *
+     * @returns A random reregistration delay in seconds.
+     */
+    uint16_t SelectRandomReregistrationDelay(void) const;
+
+private:
+    void AdjustMlrTimeout(void);
+
+#if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
+    void Log(const char *aTitle) const;
+#endif
 };
 
 /**
@@ -87,18 +156,6 @@
     friend class ot::Notifier;
 
 public:
-    // Primary Backbone Router Service state or state change.
-    enum State : uint8_t
-    {
-        kStateNone = 0,       ///< Not exist (trigger Backbone Router register its service).
-        kStateAdded,          ///< Newly added.
-        kStateRemoved,        ///< Newly removed (trigger Backbone Router register its service).
-        kStateToTriggerRereg, ///< Short address or sequence number changes (trigger re-registration).
-                              ///< May also have ReregistrationDelay or MlrTimeout update.
-        kStateRefreshed,      ///< Only ReregistrationDelay or MlrTimeout changes.
-        kStateUnchanged,      ///< No change on Primary Backbone Router information (only for logging).
-    };
-
     /**
      * Initializes the `Leader`.
      *
@@ -112,14 +169,21 @@
     void Reset(void);
 
     /**
-     * Gets the Primary Backbone Router in the Thread Network.
+     * Gets the Primary Backbone Router configuration.
      *
-     * @param[out]  aConfig        The Primary Backbone Router information.
+     * @returns The Primary Backbone Router configuration.
+     */
+    const Config &GetConfig(void) const { return mConfig; }
+
+    /**
+     * Reads the Primary Backbone Router configuration in the Thread Network.
      *
-     * @retval kErrorNone          Successfully got the Primary Backbone Router information.
+     * @param[out]  aConfig        A reference to a `Config` to populate.
+     *
+     * @retval kErrorNone          Successfully read the Primary BBR config. @p aConfig is updated.
      * @retval kErrorNotFound      No Backbone Router in the Thread Network.
      */
-    Error GetConfig(Config &aConfig) const;
+    Error ReadConfig(Config &aConfig) const;
 
     /**
      * Gets the Backbone Router Service ID.
@@ -144,57 +208,21 @@
      * @retval TRUE   If there is Primary Backbone Router.
      * @retval FALSE  If there is no Primary Backbone Router.
      */
-    bool HasPrimary(void) const { return mConfig.mServer16 != Mle::kInvalidRloc16; }
-
-    /**
-     * Gets the Domain Prefix in the Thread Network.
-     *
-     * @retval A pointer to the Domain Prefix or nullptr if there is no Domain Prefix.
-     */
-    const Ip6::Prefix *GetDomainPrefix(void) const
-    {
-        return (mDomainPrefix.GetLength() == 0) ? nullptr : &mDomainPrefix;
-    }
-
-    /**
-     * Indicates whether or not the Domain Prefix is available in the Thread Network.
-     *
-     * @retval TRUE   If there is Domain Prefix.
-     * @retval FALSE  If there is no Domain Prefix.
-     */
-    bool HasDomainPrefix(void) const { return (mDomainPrefix.GetLength() > 0); }
-
-    /**
-     * Indicates whether or not the address is a Domain Unicast Address.
-     *
-     * @param[in]  aAddress A reference to the address.
-     *
-     * @retval true  @p aAddress is a Domain Unicast Address.
-     * @retval false @p aAddress is not a Domain Unicast Address.
-     */
-    bool IsDomainUnicast(const Ip6::Address &aAddress) const;
+    bool HasPrimary(void) const { return mConfig.IsPresent(); }
 
 private:
     void HandleNotifierEvents(Events aEvents);
     void UpdateBackboneRouterPrimary(void);
-    void UpdateDomainPrefixConfig(void);
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
-    void               LogBackboneRouterPrimary(State aState, const Config &aConfig) const;
-    static const char *StateToString(State aState);
-    static const char *DomainPrefixEventToString(DomainPrefixEvent aEvent);
-#else
-    void LogBackboneRouterPrimary(State, const Config &) const {}
+    static const char *PrimaryEventToString(PrimaryEvent aEvent);
 #endif
 
-    Config      mConfig;       ///< Primary Backbone Router information.
-    Ip6::Prefix mDomainPrefix; ///< Domain Prefix in the Thread network.
+    Config mConfig;
 };
 
 } // namespace BackboneRouter
 
-/**
- * @}
- */
+DefineCoreType(otBackboneRouterConfig, BackboneRouter::Config);
 
 } // namespace ot
 
diff --git a/src/core/backbone_router/bbr_local.cpp b/src/core/backbone_router/bbr_local.cpp
index bb21ede..60c7968 100644
--- a/src/core/backbone_router/bbr_local.cpp
+++ b/src/core/backbone_router/bbr_local.cpp
@@ -47,17 +47,14 @@
     : InstanceLocator(aInstance)
     , mIsServiceAdded(false)
     , mState(kStateDisabled)
-    , mSequenceNumber(Random::NonCrypto::GetUint8() % 127)
+    , mSequenceNumber(Random::NonCrypto::GenerateUpToExcluding<uint8_t>(127))
     , mRegistrationJitter(kDefaultRegistrationJitter)
     , mReregistrationDelay(kDefaultRegistrationDelay)
     , mRegistrationTimeout(0)
     , mMlrTimeout(kDefaultMlrTimeout)
 {
-    mDomainPrefixConfig.GetPrefix().SetLength(0);
-
     // Primary Backbone Router Aloc
     mBbrPrimaryAloc.InitAsThreadOriginMeshLocal();
-    mBbrPrimaryAloc.GetAddress().GetIid().SetToLocator(Mle::Aloc16::ForPrimaryBackboneRouter());
 
     // All Network Backbone Routers Multicast Address.
     mAllNetworkBackboneRouters.Clear();
@@ -65,13 +62,6 @@
     mAllNetworkBackboneRouters.mFields.m8[0]  = 0xff; // Multicast
     mAllNetworkBackboneRouters.mFields.m8[1]  = 0x32; // Flags = 3, Scope = 2
     mAllNetworkBackboneRouters.mFields.m8[15] = 3;    // Group ID = 3
-
-    // All Domain Backbone Routers Multicast Address.
-    mAllDomainBackboneRouters.Clear();
-
-    mAllDomainBackboneRouters.mFields.m8[0]  = 0xff; // Multicast
-    mAllDomainBackboneRouters.mFields.m8[1]  = 0x32; // Flags = 3, Scope = 2
-    mAllDomainBackboneRouters.mFields.m8[15] = 3;    // Group ID = 3
 }
 
 void Local::SetEnabled(bool aEnable)
@@ -81,12 +71,10 @@
     if (aEnable)
     {
         SetState(kStateSecondary);
-        AddDomainPrefixToNetworkData();
         IgnoreError(AddService(kDecideBasedOnState));
     }
     else
     {
-        RemoveDomainPrefixFromNetworkData();
         RemoveService();
         SetState(kStateDisabled);
     }
@@ -225,7 +213,7 @@
     if (aState == kStatePrimary)
     {
         // Add Primary Backbone Router ALOC for Primary Backbone Router.
-        mBbrPrimaryAloc.GetAddress().SetPrefix(Get<Mle::Mle>().GetMeshLocalPrefix());
+        Get<Mle::Mle>().ComposeAloc(Mle::Aloc16::ForPrimaryBackboneRouter(), mBbrPrimaryAloc.GetAddress());
         Get<ThreadNetif>().AddUnicastAddress(mBbrPrimaryAloc);
     }
 
@@ -237,26 +225,40 @@
     return;
 }
 
-void Local::HandleBackboneRouterPrimaryUpdate(Leader::State aState, const Config &aConfig)
+void Local::HandleBackboneRouterPrimaryUpdate(PrimaryEvent aEvent)
 {
-    OT_UNUSED_VARIABLE(aState);
+    OT_UNUSED_VARIABLE(aEvent);
+    UpdateState();
+}
 
+void Local::HandleNotifierEvents(Events aEvents)
+{
+    if (aEvents.Contains(kEventThreadRoleChanged))
+    {
+        UpdateState();
+    }
+}
+
+void Local::UpdateState(void)
+{
     VerifyOrExit(IsEnabled() && Get<Mle::Mle>().IsAttached());
 
     // Wait some jitter before trying to Register.
-    if (aConfig.mServer16 == Mle::kInvalidRloc16)
+    if (!Get<Leader>().HasPrimary())
     {
-        mRegistrationTimeout = 1;
-
-        if (!Get<Mle::Mle>().IsLeader())
+        if (Get<Mle::Mle>().IsLeader())
         {
-            mRegistrationTimeout +=
-                Random::NonCrypto::GetUint16InRange(0, static_cast<uint16_t>(mRegistrationJitter) + 1);
+            mRegistrationTimeout = 0;
+            Get<TimeTicker>().UnregisterReceiver(TimeTicker::kBbrLocal);
+            IgnoreError(AddService(kDecideBasedOnState));
         }
-
-        Get<TimeTicker>().RegisterReceiver(TimeTicker::kBbrLocal);
+        else
+        {
+            mRegistrationTimeout = Random::NonCrypto::GenerateInClosedRange<uint16_t>(1, mRegistrationJitter);
+            Get<TimeTicker>().RegisterReceiver(TimeTicker::kBbrLocal);
+        }
     }
-    else if (aConfig.mServer16 != Get<Mle::Mle>().GetRloc16())
+    else if (!Get<Mle::Mle>().HasRloc16(Get<Leader>().GetConfig().GetServer16()))
     {
         Reset();
     }
@@ -264,9 +266,9 @@
     {
         // Here original PBBR restores its Backbone Router Service from Thread Network,
         // Intentionally skips the state update as PBBR will refresh its service.
-        mSequenceNumber      = aConfig.mSequenceNumber;
-        mReregistrationDelay = aConfig.mReregistrationDelay;
-        mMlrTimeout          = aConfig.mMlrTimeout;
+        mSequenceNumber      = Get<Leader>().GetConfig().mSequenceNumber;
+        mReregistrationDelay = Get<Leader>().GetConfig().mReregistrationDelay;
+        mMlrTimeout          = Get<Leader>().GetConfig().mMlrTimeout;
         IncrementSequenceNumber();
         Get<Notifier>().Signal(kEventThreadBackboneRouterLocalChanged);
         IgnoreError(AddService(kForceRegistration));
@@ -304,59 +306,6 @@
     }
 }
 
-Error Local::GetDomainPrefix(NetworkData::OnMeshPrefixConfig &aConfig)
-{
-    Error error = kErrorNone;
-
-    VerifyOrExit(mDomainPrefixConfig.GetPrefix().GetLength() > 0, error = kErrorNotFound);
-
-    aConfig = mDomainPrefixConfig;
-
-exit:
-    return error;
-}
-
-Error Local::RemoveDomainPrefix(const Ip6::Prefix &aPrefix)
-{
-    Error error = kErrorNone;
-
-    VerifyOrExit(aPrefix.GetLength() > 0, error = kErrorInvalidArgs);
-    VerifyOrExit(mDomainPrefixConfig.GetPrefix() == aPrefix, error = kErrorNotFound);
-
-    if (IsEnabled())
-    {
-        RemoveDomainPrefixFromNetworkData();
-    }
-
-    mDomainPrefixConfig.GetPrefix().SetLength(0);
-
-exit:
-    return error;
-}
-
-Error Local::SetDomainPrefix(const NetworkData::OnMeshPrefixConfig &aConfig)
-{
-    Error error = kErrorNone;
-
-    VerifyOrExit(aConfig.IsValid(GetInstance()), error = kErrorInvalidArgs);
-
-    if (IsEnabled())
-    {
-        RemoveDomainPrefixFromNetworkData();
-    }
-
-    mDomainPrefixConfig = aConfig;
-    LogDomainPrefix(kActionSet, kErrorNone);
-
-    if (IsEnabled())
-    {
-        AddDomainPrefixToNetworkData();
-    }
-
-exit:
-    return error;
-}
-
 void Local::ApplyNewMeshLocalPrefix(void)
 {
     VerifyOrExit(IsEnabled());
@@ -369,46 +318,6 @@
     return;
 }
 
-void Local::HandleDomainPrefixUpdate(DomainPrefixEvent aEvent)
-{
-    if (!IsEnabled())
-    {
-        ExitNow();
-    }
-
-    if (aEvent == kDomainPrefixRemoved || aEvent == kDomainPrefixRefreshed)
-    {
-        Get<BackboneTmfAgent>().UnsubscribeMulticast(mAllDomainBackboneRouters);
-    }
-
-    if (aEvent == kDomainPrefixAdded || aEvent == kDomainPrefixRefreshed)
-    {
-        mAllDomainBackboneRouters.SetMulticastNetworkPrefix(*Get<Leader>().GetDomainPrefix());
-        Get<BackboneTmfAgent>().SubscribeMulticast(mAllDomainBackboneRouters);
-    }
-
-    if (aEvent != kDomainPrefixUnchanged)
-    {
-        mDomainPrefixCallback.InvokeIfSet(static_cast<otBackboneRouterDomainPrefixEvent>(aEvent),
-                                          Get<Leader>().GetDomainPrefix());
-    }
-
-exit:
-    return;
-}
-
-void Local::RemoveDomainPrefixFromNetworkData(void)
-{
-    Error error = kErrorNotFound; // only used for logging.
-
-    if (mDomainPrefixConfig.mPrefix.mLength > 0)
-    {
-        error = Get<NetworkData::Local>().RemoveOnMeshPrefix(mDomainPrefixConfig.GetPrefix());
-    }
-
-    LogDomainPrefix(kActionRemove, error);
-}
-
 void Local::IncrementSequenceNumber(void)
 {
     switch (mSequenceNumber)
@@ -427,18 +336,6 @@
     }
 }
 
-void Local::AddDomainPrefixToNetworkData(void)
-{
-    Error error = kErrorNotFound; // only used for logging.
-
-    if (mDomainPrefixConfig.GetPrefix().GetLength() > 0)
-    {
-        error = Get<NetworkData::Local>().AddOnMeshPrefix(mDomainPrefixConfig);
-    }
-
-    LogDomainPrefix(kActionAdd, error);
-}
-
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
 
 const char *Local::ActionToString(Action aAction)
@@ -453,12 +350,6 @@
     return kStrings[aAction];
 }
 
-void Local::LogDomainPrefix(Action aAction, Error aError)
-{
-    LogInfo("%s Domain Prefix: %s, %s", ActionToString(aAction), mDomainPrefixConfig.GetPrefix().ToString().AsCString(),
-            ErrorToString(aError));
-}
-
 void Local::LogService(Action aAction, Error aError)
 {
     LogInfo("%s BBR Service: seqno (%u), delay (%us), timeout (%lus), %s", ActionToString(aAction), mSequenceNumber,
diff --git a/src/core/backbone_router/bbr_local.hpp b/src/core/backbone_router/bbr_local.hpp
index 6079343..889d400 100644
--- a/src/core/backbone_router/bbr_local.hpp
+++ b/src/core/backbone_router/bbr_local.hpp
@@ -59,6 +59,7 @@
 #include "common/locator.hpp"
 #include "common/log.hpp"
 #include "common/non_copyable.hpp"
+#include "common/notifier.hpp"
 #include "common/time_ticker.hpp"
 #include "net/netif.hpp"
 #include "thread/network_data.hpp"
@@ -73,10 +74,9 @@
 class Local : public InstanceLocator, private NonCopyable
 {
     friend class ot::TimeTicker;
+    friend class ot::Notifier;
 
 public:
-    typedef otBackboneRouterDomainPrefixCallback DomainPrefixCallback; ///< Domain Prefix callback.
-
     /**
      * Represents Backbone Router state.
      */
@@ -182,43 +182,11 @@
     uint8_t GetRegistrationJitter(void) const { return mRegistrationJitter; }
 
     /**
-     * Notifies Primary Backbone Router status.
+     * Notifies the `Local` of a Primary Backbone Router event.
      *
-     * @param[in]  aState   The state or state change of Primary Backbone Router.
-     * @param[in]  aConfig  The Primary Backbone Router service.
+     * @param[in]  aEvent   The Primary Backbone Router event.
      */
-    void HandleBackboneRouterPrimaryUpdate(Leader::State aState, const Config &aConfig);
-
-    /**
-     * Gets the Domain Prefix configuration.
-     *
-     * @param[out]  aConfig  A reference to the Domain Prefix configuration.
-     *
-     * @retval kErrorNone      Successfully got the Domain Prefix configuration.
-     * @retval kErrorNotFound  No Domain Prefix was configured.
-     */
-    Error GetDomainPrefix(NetworkData::OnMeshPrefixConfig &aConfig);
-
-    /**
-     * Removes the local Domain Prefix configuration.
-     *
-     * @param[in]  aPrefix A reference to the IPv6 Domain Prefix.
-     *
-     * @retval kErrorNone         Successfully removed the Domain Prefix.
-     * @retval kErrorInvalidArgs  @p aPrefix is invalid.
-     * @retval kErrorNotFound     No Domain Prefix was configured or @p aPrefix doesn't match.
-     */
-    Error RemoveDomainPrefix(const Ip6::Prefix &aPrefix);
-
-    /**
-     * Sets the local Domain Prefix configuration.
-     *
-     * @param[in]  aConfig A reference to the Domain Prefix configuration.
-     *
-     * @returns kErrorNone          Successfully set the local Domain Prefix.
-     * @returns kErrorInvalidArgs   @p aConfig is invalid.
-     */
-    Error SetDomainPrefix(const NetworkData::OnMeshPrefixConfig &aConfig);
+    void HandleBackboneRouterPrimaryUpdate(PrimaryEvent aEvent);
 
     /**
      * Returns a reference to the All Network Backbone Routers Multicast Address.
@@ -228,35 +196,10 @@
     const Ip6::Address &GetAllNetworkBackboneRoutersAddress(void) const { return mAllNetworkBackboneRouters; }
 
     /**
-     * Returns a reference to the All Domain Backbone Routers Multicast Address.
-     *
-     * @returns A reference to the All Domain Backbone Routers Multicast Address.
-     */
-    const Ip6::Address &GetAllDomainBackboneRoutersAddress(void) const { return mAllDomainBackboneRouters; }
-
-    /**
      * Applies the Mesh Local Prefix.
      */
     void ApplyNewMeshLocalPrefix(void);
 
-    /**
-     * Updates the subscription of All Domain Backbone Routers Multicast Address.
-     *
-     * @param[in]  aEvent  The Domain Prefix event.
-     */
-    void HandleDomainPrefixUpdate(DomainPrefixEvent aEvent);
-
-    /**
-     * Sets the Domain Prefix callback.
-     *
-     * @param[in] aCallback  The callback function.
-     * @param[in] aContext   A user context pointer.
-     */
-    void SetDomainPrefixCallback(DomainPrefixCallback aCallback, void *aContext)
-    {
-        mDomainPrefixCallback.Set(aCallback, aContext);
-    }
-
 private:
     enum Action : uint8_t
     {
@@ -266,35 +209,30 @@
     };
 
     void SetState(State aState);
+    void HandleNotifierEvents(Events aEvents);
+    void UpdateState(void);
     void RemoveService(void);
     void HandleTimeTick(void);
-    void AddDomainPrefixToNetworkData(void);
-    void RemoveDomainPrefixFromNetworkData(void);
     void IncrementSequenceNumber(void);
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
     static const char *ActionToString(Action aAction);
     void               LogService(Action aAction, Error aError);
-    void               LogDomainPrefix(Action aAction, Error aError);
 #else
     void LogService(Action, Error) {}
-    void LogDomainPrefix(Action, Error) {}
 #endif
 
     // Indicates whether or not already add Backbone Router Service to local server data.
     // Used to check whether or not in restore stage after reset or whether to remove
     // Backbone Router service for Secondary Backbone Router if it was added by force.
-    bool                            mIsServiceAdded;
-    State                           mState;
-    uint8_t                         mSequenceNumber;
-    uint8_t                         mRegistrationJitter;
-    uint16_t                        mReregistrationDelay;
-    uint16_t                        mRegistrationTimeout;
-    uint32_t                        mMlrTimeout;
-    NetworkData::OnMeshPrefixConfig mDomainPrefixConfig;
-    Ip6::Netif::UnicastAddress      mBbrPrimaryAloc;
-    Ip6::Address                    mAllNetworkBackboneRouters;
-    Ip6::Address                    mAllDomainBackboneRouters;
-    Callback<DomainPrefixCallback>  mDomainPrefixCallback;
+    bool                       mIsServiceAdded;
+    State                      mState;
+    uint8_t                    mSequenceNumber;
+    uint8_t                    mRegistrationJitter;
+    uint16_t                   mReregistrationDelay;
+    uint16_t                   mRegistrationTimeout;
+    uint32_t                   mMlrTimeout;
+    Ip6::Netif::UnicastAddress mBbrPrimaryAloc;
+    Ip6::Address               mAllNetworkBackboneRouters;
 };
 
 } // namespace BackboneRouter
diff --git a/src/core/backbone_router/bbr_manager.cpp b/src/core/backbone_router/bbr_manager.cpp
index 515b392..241197b 100644
--- a/src/core/backbone_router/bbr_manager.cpp
+++ b/src/core/backbone_router/bbr_manager.cpp
@@ -45,28 +45,15 @@
 
 Manager::Manager(Instance &aInstance)
     : InstanceLocator(aInstance)
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    , mNdProxyTable(aInstance)
-#endif
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
     , mMulticastListenersTable(aInstance)
 #endif
     , mTimer(aInstance)
     , mBackboneTmfAgent(aInstance)
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    , mDuaResponseStatus(kDuaSuccess)
-#endif
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
-    , mMlrResponseStatus(kMlrSuccess)
-#endif
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    , mDuaResponseIsSpecified(false)
-#endif
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE && OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
+    , mMlrResponseStatus(Mlr::kStatusSuccess)
     , mMlrResponseIsSpecified(false)
 #endif
-#endif
 {
 }
 
@@ -108,18 +95,7 @@
     }
 }
 
-void Manager::HandleTimer(void)
-{
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
-    mMulticastListenersTable.Expire();
-#endif
-
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    mNdProxyTable.HandleTimer();
-#endif
-
-    mTimer.Start(kTimerInterval);
-}
+void Manager::HandleTimer(void) { mTimer.Start(kTimerInterval); }
 
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
 template <> void Manager::HandleTmf<kUriMlr>(Coap::Msg &aMsg)
@@ -133,14 +109,12 @@
 
 void Manager::HandleMulticastListenerRegistration(const Coap::Msg &aMsg)
 {
-    Error     error     = kErrorNone;
-    bool      isPrimary = Get<Local>().IsPrimary();
-    MlrStatus status    = kMlrSuccess;
-    Config    config;
-
+    Error        error     = kErrorNone;
+    bool         isPrimary = Get<Local>().IsPrimary();
+    Mlr::Status  status    = Mlr::kStatusSuccess;
     OffsetRange  offsetRange;
     Ip6::Address address;
-    Ip6::Address addresses[Ip6AddressesTlv::kMaxAddresses];
+    Ip6::Address addresses[Mlr::kMaxIp6Addresses];
     uint8_t      failedAddressNum  = 0;
     uint8_t      successAddressNum = 0;
     TimeMilli    expireTime;
@@ -151,13 +125,13 @@
 
     VerifyOrExit(aMsg.IsConfirmable(), error = kErrorParse);
 
-    VerifyOrExit(isPrimary, status = kMlrBbrNotPrimary);
+    VerifyOrExit(isPrimary, status = Mlr::kStatusBbrNotPrimary);
 
-    VerifyOrExit(Tlv::FindTlvValueOffsetRange(aMsg.mMessage, Ip6AddressesTlv::kIp6Addresses, offsetRange) == kErrorNone,
+    VerifyOrExit(Tlv::FindTlvValueOffsetRange(aMsg.mMessage, Ip6AddressesTlv::kType, offsetRange) == kErrorNone,
                  error = kErrorParse);
-    VerifyOrExit(offsetRange.GetLength() % sizeof(Ip6::Address) == 0, status = kMlrGeneralFailure);
-    VerifyOrExit(offsetRange.GetLength() / sizeof(Ip6::Address) <= Ip6AddressesTlv::kMaxAddresses,
-                 status = kMlrGeneralFailure);
+    VerifyOrExit(offsetRange.GetLength() % sizeof(Ip6::Address) == 0, status = Mlr::kStatusGeneralFailure);
+    VerifyOrExit(offsetRange.GetLength() / sizeof(Ip6::Address) <= Mlr::kMaxIp6Addresses,
+                 status = Mlr::kStatusGeneralFailure);
 
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     // Required by Test Specification 5.10.22 MATN-TC-26, only for certification purpose
@@ -166,12 +140,11 @@
         mMlrResponseIsSpecified = false;
         status                  = mMlrResponseStatus;
 
-        if (status != kMlrSuccess)
+        if (status != Mlr::kStatusSuccess)
         {
             while (!offsetRange.IsEmpty())
             {
-                IgnoreError(aMsg.mMessage.Read(offsetRange, address));
-                offsetRange.AdvanceOffset(sizeof(Ip6::Address));
+                IgnoreError(aMsg.mMessage.ReadAndAdvance(offsetRange, address));
                 addresses[failedAddressNum++] = address;
             }
         }
@@ -186,7 +159,7 @@
 
         VerifyOrExit((Get<NetworkData::Leader>().FindCommissioningSessionId(localSessionId) == kErrorNone) &&
                          (localSessionId == commissionerSessionId),
-                     status = kMlrGeneralFailure);
+                     status = Mlr::kStatusGeneralFailure);
 
         hasCommissionerSessionIdTlv = true;
     }
@@ -196,13 +169,11 @@
 
     if (!processTimeoutTlv)
     {
-        IgnoreError(Get<Leader>().GetConfig(config));
-
-        timeout = config.mMlrTimeout;
+        timeout = Get<Leader>().GetConfig().GetMlrTimeout();
     }
     else
     {
-        VerifyOrExit(timeout < NumericLimits<uint32_t>::kMax, status = kMlrNoPersistent);
+        VerifyOrExit(timeout < NumericLimits<uint32_t>::kMax, status = Mlr::kStatusNoPersistent);
 
         if (timeout != 0)
         {
@@ -221,15 +192,14 @@
 
     while (!offsetRange.IsEmpty())
     {
-        IgnoreError(aMsg.mMessage.Read(offsetRange, address));
-        offsetRange.AdvanceOffset(sizeof(Ip6::Address));
+        IgnoreError(aMsg.mMessage.ReadAndAdvance(offsetRange, address));
 
         if (timeout == 0)
         {
             mMulticastListenersTable.Remove(address);
 
             // Put successfully de-registered addresses at the end of `addresses`.
-            addresses[Ip6AddressesTlv::kMaxAddresses - (++successAddressNum)] = address;
+            addresses[Mlr::kMaxIp6Addresses - (++successAddressNum)] = address;
         }
         else
         {
@@ -241,15 +211,15 @@
                 failed = false;
                 break;
             case kErrorInvalidArgs:
-                if (status == kMlrSuccess)
+                if (status == Mlr::kStatusSuccess)
                 {
-                    status = kMlrInvalid;
+                    status = Mlr::kStatusInvalid;
                 }
                 break;
             case kErrorNoBufs:
-                if (status == kMlrSuccess)
+                if (status == Mlr::kStatusSuccess)
                 {
-                    status = kMlrNoResources;
+                    status = Mlr::kStatusNoResources;
                 }
                 break;
             default:
@@ -263,7 +233,7 @@
             else
             {
                 // Put successfully registered addresses at the end of `addresses`.
-                addresses[Ip6AddressesTlv::kMaxAddresses - (++successAddressNum)] = address;
+                addresses[Mlr::kMaxIp6Addresses - (++successAddressNum)] = address;
             }
         }
     }
@@ -276,13 +246,13 @@
 
     if (successAddressNum > 0)
     {
-        SendBackboneMulticastListenerRegistration(&addresses[Ip6AddressesTlv::kMaxAddresses - successAddressNum],
+        SendBackboneMulticastListenerRegistration(&addresses[Mlr::kMaxIp6Addresses - successAddressNum],
                                                   successAddressNum, timeout);
     }
 }
 
 void Manager::SendMulticastListenerRegistrationResponse(const Coap::Msg &aMsg,
-                                                        MlrStatus        aStatus,
+                                                        Mlr::Status      aStatus,
                                                         Ip6::Address    *aFailedAddresses,
                                                         uint8_t          aFailedAddressNum)
 {
@@ -296,16 +266,7 @@
 
     if (aFailedAddressNum > 0)
     {
-        Ip6AddressesTlv addressesTlv;
-
-        addressesTlv.Init();
-        addressesTlv.SetLength(sizeof(Ip6::Address) * aFailedAddressNum);
-        SuccessOrExit(error = message->Append(addressesTlv));
-
-        for (uint8_t i = 0; i < aFailedAddressNum; i++)
-        {
-            SuccessOrExit(error = message->Append(aFailedAddresses[i]));
-        }
+        SuccessOrExit(error = Ip6AddressesTlv::AppendTo(*message, aFailedAddresses, aFailedAddressNum));
     }
 
     SuccessOrExit(error = Get<Tmf::Agent>().SendMessage(*message, aMsg.mMessageInfo));
@@ -322,18 +283,14 @@
     Error             error   = kErrorNone;
     Coap::Message    *message = nullptr;
     Ip6::MessageInfo  messageInfo;
-    Ip6AddressesTlv   addressesTlv;
     BackboneTmfAgent &backboneTmf = Get<BackboneRouter::BackboneTmfAgent>();
 
-    OT_ASSERT(aAddressNum >= Ip6AddressesTlv::kMinAddresses && aAddressNum <= Ip6AddressesTlv::kMaxAddresses);
+    OT_ASSERT(aAddressNum >= Mlr::kMinIp6Addresses && aAddressNum <= Mlr::kMaxIp6Addresses);
 
     message = backboneTmf.AllocateAndInitNonConfirmablePostMessage(kUriBackboneMlr);
     VerifyOrExit(message != nullptr, error = kErrorNoBufs);
 
-    addressesTlv.Init();
-    addressesTlv.SetLength(sizeof(Ip6::Address) * aAddressNum);
-    SuccessOrExit(error = message->Append(addressesTlv));
-    SuccessOrExit(error = message->AppendBytes(aAddresses, sizeof(Ip6::Address) * aAddressNum));
+    SuccessOrExit(error = Ip6AddressesTlv::AppendTo(*message, aAddresses, aAddressNum));
 
     SuccessOrExit(error = Tlv::Append<ThreadTimeoutTlv>(*message, aTimeout));
 
@@ -351,135 +308,10 @@
 }
 #endif // OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-
-template <> void Manager::HandleTmf<kUriDuaRegistrationRequest>(Coap::Msg &aMsg)
-{
-    VerifyOrExit(Get<Local>().IsEnabled());
-    HandleDuaRegistration(aMsg);
-
-exit:
-    return;
-}
-
-void Manager::HandleDuaRegistration(const Coap::Msg &aMsg)
-{
-    Error                    error     = kErrorNone;
-    DuaStatus                status    = kDuaSuccess;
-    bool                     isPrimary = Get<Local>().IsPrimary();
-    uint32_t                 lastTransactionTime;
-    bool                     hasLastTransactionTime;
-    Ip6::Address             target;
-    Ip6::InterfaceIdentifier meshLocalIid;
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    Coap::Code duaRespCoapCode = Coap::kCodeEmpty;
-#endif
-
-    VerifyOrExit(aMsg.mMessageInfo.GetPeerAddr().GetIid().IsRoutingLocator(), error = kErrorDrop);
-    VerifyOrExit(aMsg.IsConfirmable(), error = kErrorParse);
-
-    SuccessOrExit(error = Tlv::Find<ThreadTargetTlv>(aMsg.mMessage, target));
-    SuccessOrExit(error = Tlv::Find<ThreadMeshLocalEidTlv>(aMsg.mMessage, meshLocalIid));
-
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    if (mDuaResponseIsSpecified && (mDuaResponseTargetMlIid.IsUnspecified() || mDuaResponseTargetMlIid == meshLocalIid))
-    {
-        mDuaResponseIsSpecified = false;
-        if (mDuaResponseStatus >= Coap::kCodeResponseMin)
-        {
-            duaRespCoapCode = static_cast<Coap::Code>(mDuaResponseStatus);
-        }
-        else
-        {
-            status = static_cast<DuaStatus>(mDuaResponseStatus);
-        }
-        ExitNow();
-    }
-#endif
-
-    VerifyOrExit(isPrimary, status = kDuaNotPrimary);
-    VerifyOrExit(Get<Leader>().HasDomainPrefix(), status = kDuaGeneralFailure);
-    VerifyOrExit(Get<Leader>().IsDomainUnicast(target), status = kDuaInvalid);
-
-    hasLastTransactionTime =
-        (Tlv::Find<ThreadLastTransactionTimeTlv>(aMsg.mMessage, lastTransactionTime) == kErrorNone);
-
-    switch (mNdProxyTable.Register(target.GetIid(), meshLocalIid, aMsg.mMessageInfo.GetPeerAddr().GetIid().GetLocator(),
-                                   hasLastTransactionTime ? &lastTransactionTime : nullptr))
-    {
-    case kErrorNone:
-        // TODO: update its EID-to-RLOC Map Cache based on the pair {DUA, RLOC16-source} which is gleaned from the
-        // DUA.req packet according to Thread Spec. 5.23.3.6.2
-        break;
-    case kErrorDuplicated:
-        status = kDuaDuplicate;
-        break;
-    case kErrorNoBufs:
-        status = kDuaNoResources;
-        break;
-    default:
-        status = kDuaGeneralFailure;
-        break;
-    }
-
-exit:
-    LogInfo("Received DUA.req on %s: %s", (isPrimary ? "PBBR" : "SBBR"), ErrorToString(error));
-
-    if (error == kErrorNone)
-    {
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-        if (duaRespCoapCode != Coap::kCodeEmpty)
-        {
-            IgnoreError(Get<Tmf::Agent>().SendAckResponse(aMsg, duaRespCoapCode));
-        }
-        else
-#endif
-        {
-            SendDuaRegistrationResponse(aMsg, target, status);
-        }
-    }
-}
-
-void Manager::SendDuaRegistrationResponse(const Coap::Msg &aMsg, const Ip6::Address &aTarget, DuaStatus aStatus)
-{
-    Error          error = kErrorNone;
-    Coap::Message *message;
-
-    message = Get<Tmf::Agent>().AllocateAndInitResponseFor(aMsg.mMessage);
-    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
-
-    SuccessOrExit(Tlv::Append<ThreadStatusTlv>(*message, aStatus));
-    SuccessOrExit(Tlv::Append<ThreadTargetTlv>(*message, aTarget));
-
-    SuccessOrExit(error = Get<Tmf::Agent>().SendMessage(*message, aMsg.mMessageInfo));
-
-exit:
-    FreeMessageOnError(message, error);
-    LogInfo("Sent DUA.rsp for DUA %s, status %d %s", aTarget.ToString().AsCString(), aStatus, ErrorToString(error));
-}
-#endif // OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-void Manager::ConfigNextDuaRegistrationResponse(const Ip6::InterfaceIdentifier *aMlIid, uint8_t aStatus)
-{
-    mDuaResponseIsSpecified = true;
-
-    if (aMlIid)
-    {
-        mDuaResponseTargetMlIid = *aMlIid;
-    }
-    else
-    {
-        mDuaResponseTargetMlIid.Clear();
-    }
-
-    mDuaResponseStatus = aStatus;
-}
-#endif
 
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
-void Manager::ConfigNextMulticastListenerRegistrationResponse(MlrStatus aStatus)
+void Manager::ConfigNextMulticastListenerRegistrationResponse(Mlr::Status aStatus)
 {
     mMlrResponseIsSpecified = true;
     mMlrResponseStatus      = aStatus;
@@ -487,272 +319,6 @@
 #endif
 #endif
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-NdProxyTable &Manager::GetNdProxyTable(void) { return mNdProxyTable; }
-
-bool Manager::ShouldForwardDuaToBackbone(const Ip6::Address &aAddress)
-{
-    bool forwardToBackbone = false;
-
-    VerifyOrExit(Get<Local>().IsPrimary());
-    VerifyOrExit(Get<Leader>().IsDomainUnicast(aAddress));
-
-    // Do not forward to Backbone if the DUA is registered on PBBR
-    VerifyOrExit(!mNdProxyTable.IsRegistered(aAddress.GetIid()));
-    // Do not forward to Backbone if the DUA belongs to a MTD Child (which may have failed in DUA registration)
-    VerifyOrExit(Get<NeighborTable>().FindNeighbor(aAddress) == nullptr);
-    // Forward to Backbone only if the DUA is resolved to the PBBR's RLOC16
-    VerifyOrExit(Get<AddressResolver>().LookUp(aAddress) == Get<Mle::Mle>().GetRloc16());
-
-    forwardToBackbone = true;
-
-exit:
-    return forwardToBackbone;
-}
-
-Error Manager::SendBackboneQuery(const Ip6::Address &aDua, uint16_t aRloc16)
-{
-    Error            error   = kErrorNone;
-    Coap::Message   *message = nullptr;
-    Ip6::MessageInfo messageInfo;
-
-    VerifyOrExit(Get<Local>().IsPrimary(), error = kErrorInvalidState);
-
-    message = mBackboneTmfAgent.AllocateAndInitPriorityNonConfirmablePostMessage(kUriBackboneQuery);
-    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
-
-    SuccessOrExit(error = Tlv::Append<ThreadTargetTlv>(*message, aDua));
-
-    if (aRloc16 != Mle::kInvalidRloc16)
-    {
-        SuccessOrExit(error = Tlv::Append<ThreadRloc16Tlv>(*message, aRloc16));
-    }
-
-    messageInfo.SetPeerAddr(Get<Local>().GetAllDomainBackboneRoutersAddress());
-    messageInfo.SetPeerPort(BackboneRouter::kBackboneUdpPort);
-
-    messageInfo.SetHopLimit(kDefaultHoplimit);
-    messageInfo.SetIsHostInterface(true);
-
-    error = mBackboneTmfAgent.SendMessage(*message, messageInfo);
-
-exit:
-    LogInfo("SendBackboneQuery for %s (rloc16=%04x): %s", aDua.ToString().AsCString(), aRloc16, ErrorToString(error));
-    FreeMessageOnError(message, error);
-    return error;
-}
-
-template <> void Manager::HandleTmf<kUriBackboneQuery>(Coap::Msg &aMsg)
-{
-    Error                  error = kErrorNone;
-    Ip6::Address           dua;
-    uint16_t               rloc16 = Mle::kInvalidRloc16;
-    NdProxyTable::NdProxy *ndProxy;
-
-    VerifyOrExit(aMsg.mMessageInfo.IsHostInterface(), error = kErrorDrop);
-
-    VerifyOrExit(Get<Local>().IsPrimary(), error = kErrorInvalidState);
-    VerifyOrExit(aMsg.IsNonConfirmable(), error = kErrorParse);
-
-    SuccessOrExit(error = Tlv::Find<ThreadTargetTlv>(aMsg.mMessage, dua));
-
-    error = Tlv::Find<ThreadRloc16Tlv>(aMsg.mMessage, rloc16);
-    VerifyOrExit(error == kErrorNone || error == kErrorNotFound);
-
-    LogInfo("Received BB.qry from %s for %s (rloc16=%04x)", aMsg.mMessageInfo.GetPeerAddr().ToString().AsCString(),
-            dua.ToString().AsCString(), rloc16);
-
-    ndProxy = mNdProxyTable.ResolveDua(dua);
-    VerifyOrExit(ndProxy != nullptr && !ndProxy->GetDadFlag(), error = kErrorNotFound);
-
-    error = SendBackboneAnswer(aMsg.mMessageInfo, dua, rloc16, *ndProxy);
-
-exit:
-    LogInfo("HandleBackboneQuery: %s", ErrorToString(error));
-}
-
-template <> void Manager::HandleTmf<kUriBackboneAnswer>(Coap::Msg &aMsg)
-{
-    Error                    error = kErrorNone;
-    bool                     proactive;
-    Ip6::Address             dua;
-    Ip6::InterfaceIdentifier meshLocalIid;
-    OffsetRange              offsetRange;
-    uint32_t                 timeSinceLastTransaction;
-    uint16_t                 srcRloc16 = Mle::kInvalidRloc16;
-
-    VerifyOrExit(aMsg.mMessageInfo.IsHostInterface(), error = kErrorDrop);
-
-    VerifyOrExit(Get<Local>().IsPrimary(), error = kErrorInvalidState);
-
-    proactive = !aMsg.IsConfirmable();
-
-    SuccessOrExit(error = Tlv::Find<ThreadTargetTlv>(aMsg.mMessage, dua));
-    SuccessOrExit(error = Tlv::Find<ThreadMeshLocalEidTlv>(aMsg.mMessage, meshLocalIid));
-    SuccessOrExit(error = Tlv::Find<ThreadLastTransactionTimeTlv>(aMsg.mMessage, timeSinceLastTransaction));
-    SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(aMsg.mMessage, ThreadTlv::kNetworkName, offsetRange));
-
-    error = Tlv::Find<ThreadRloc16Tlv>(aMsg.mMessage, srcRloc16);
-    VerifyOrExit(error == kErrorNone || error == kErrorNotFound);
-
-    if (proactive)
-    {
-        HandleProactiveBackboneNotification(dua, meshLocalIid, timeSinceLastTransaction);
-    }
-    else if (srcRloc16 == Mle::kInvalidRloc16)
-    {
-        HandleDadBackboneAnswer(dua, meshLocalIid);
-    }
-    else
-    {
-        HandleExtendedBackboneAnswer(dua, meshLocalIid, timeSinceLastTransaction, srcRloc16);
-    }
-
-    SuccessOrExit(error = mBackboneTmfAgent.SendAckResponse(aMsg));
-
-exit:
-    LogInfo("HandleBackboneAnswer: %s", ErrorToString(error));
-}
-
-Error Manager::SendProactiveBackboneNotification(const Ip6::Address             &aDua,
-                                                 const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                                                 uint32_t                        aTimeSinceLastTransaction)
-{
-    return SendBackboneAnswer(Get<Local>().GetAllDomainBackboneRoutersAddress(), aDua, aMeshLocalIid,
-                              aTimeSinceLastTransaction, Mle::kInvalidRloc16);
-}
-
-Error Manager::SendBackboneAnswer(const Ip6::MessageInfo      &aQueryMessageInfo,
-                                  const Ip6::Address          &aDua,
-                                  uint16_t                     aSrcRloc16,
-                                  const NdProxyTable::NdProxy &aNdProxy)
-{
-    return SendBackboneAnswer(aQueryMessageInfo.GetPeerAddr(), aDua, aNdProxy.GetMeshLocalIid(),
-                              aNdProxy.GetTimeSinceLastTransaction(), aSrcRloc16);
-}
-
-Error Manager::SendBackboneAnswer(const Ip6::Address             &aDstAddr,
-                                  const Ip6::Address             &aDua,
-                                  const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                                  uint32_t                        aTimeSinceLastTransaction,
-                                  uint16_t                        aSrcRloc16)
-{
-    Error            error   = kErrorNone;
-    Coap::Message   *message = nullptr;
-    Ip6::MessageInfo messageInfo;
-
-    message = mBackboneTmfAgent.AllocateAndInitPriorityPostMessageTo(kUriBackboneAnswer, aDstAddr);
-    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
-
-    SuccessOrExit(error = Tlv::Append<ThreadTargetTlv>(*message, aDua));
-
-    SuccessOrExit(error = Tlv::Append<ThreadMeshLocalEidTlv>(*message, aMeshLocalIid));
-
-    SuccessOrExit(error = Tlv::Append<ThreadLastTransactionTimeTlv>(*message, aTimeSinceLastTransaction));
-
-    SuccessOrExit(error = Tlv::Append<ThreadNetworkNameTlv>(
-                      *message, Get<MeshCoP::NetworkIdentity>().GetNetworkName().GetAsCString()));
-
-    if (aSrcRloc16 != Mle::kInvalidRloc16)
-    {
-        SuccessOrExit(Tlv::Append<ThreadRloc16Tlv>(*message, aSrcRloc16));
-    }
-
-    messageInfo.SetPeerAddr(aDstAddr);
-    messageInfo.SetPeerPort(BackboneRouter::kBackboneUdpPort);
-
-    messageInfo.SetHopLimit(kDefaultHoplimit);
-    messageInfo.SetIsHostInterface(true);
-
-    error = mBackboneTmfAgent.SendMessage(*message, messageInfo);
-
-exit:
-    LogInfo("Send %s for %s (rloc16=%04x): %s", aDstAddr.IsMulticast() ? "PRO_BB.ntf" : "BB.ans",
-            aDua.ToString().AsCString(), aSrcRloc16, ErrorToString(error));
-
-    FreeMessageOnError(message, error);
-    return error;
-}
-
-void Manager::HandleDadBackboneAnswer(const Ip6::Address &aDua, const Ip6::InterfaceIdentifier &aMeshLocalIid)
-{
-    Error                  error     = kErrorNone;
-    NdProxyTable::NdProxy *ndProxy   = mNdProxyTable.ResolveDua(aDua);
-    bool                   duplicate = false;
-
-    OT_UNUSED_VARIABLE(error);
-
-    VerifyOrExit(ndProxy != nullptr, error = kErrorNotFound);
-
-    duplicate = ndProxy->GetMeshLocalIid() != aMeshLocalIid;
-
-    if (duplicate)
-    {
-        Ip6::Address dest;
-
-        dest.SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), ndProxy->GetRloc16());
-        Get<AddressResolver>().SendAddressError(aDua, aMeshLocalIid, dest);
-    }
-
-    ot::BackboneRouter::NdProxyTable::NotifyDadComplete(*ndProxy, duplicate);
-
-exit:
-    LogInfo("HandleDadBackboneAnswer: %s, target=%s, mliid=%s, duplicate=%s", ErrorToString(error),
-            aDua.ToString().AsCString(), aMeshLocalIid.ToString().AsCString(), duplicate ? "Y" : "N");
-}
-
-void Manager::HandleExtendedBackboneAnswer(const Ip6::Address             &aDua,
-                                           const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                                           uint32_t                        aTimeSinceLastTransaction,
-                                           uint16_t                        aSrcRloc16)
-{
-    Ip6::Address dest;
-
-    dest.SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), aSrcRloc16);
-    Get<AddressResolver>().SendAddressQueryResponse(aDua, aMeshLocalIid, &aTimeSinceLastTransaction, dest);
-
-    LogInfo("HandleExtendedBackboneAnswer: target=%s, mliid=%s, LTT=%lus, rloc16=%04x", aDua.ToString().AsCString(),
-            aMeshLocalIid.ToString().AsCString(), ToUlong(aTimeSinceLastTransaction), aSrcRloc16);
-}
-
-void Manager::HandleProactiveBackboneNotification(const Ip6::Address             &aDua,
-                                                  const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                                                  uint32_t                        aTimeSinceLastTransaction)
-{
-    Error                  error   = kErrorNone;
-    NdProxyTable::NdProxy *ndProxy = mNdProxyTable.ResolveDua(aDua);
-
-    OT_UNUSED_VARIABLE(error);
-
-    VerifyOrExit(ndProxy != nullptr, error = kErrorNotFound);
-
-    if (ndProxy->GetMeshLocalIid() == aMeshLocalIid)
-    {
-        uint32_t localTimeSinceLastTransaction = ndProxy->GetTimeSinceLastTransaction();
-
-        if (aTimeSinceLastTransaction <= localTimeSinceLastTransaction)
-        {
-            BackboneRouter::NdProxyTable::Erase(*ndProxy);
-        }
-        else
-        {
-            IgnoreError(SendProactiveBackboneNotification(aDua, ndProxy->GetMeshLocalIid(),
-                                                          ndProxy->GetTimeSinceLastTransaction()));
-        }
-    }
-    else
-    {
-        // Duplicated address detected, send ADDR_ERR.ntf to ff03::2 in the Thread network
-        BackboneRouter::NdProxyTable::Erase(*ndProxy);
-        Get<AddressResolver>().SendAddressError(aDua, aMeshLocalIid, Ip6::Address::GetRealmLocalAllRoutersMulticast());
-    }
-
-exit:
-    LogInfo("HandleProactiveBackboneNotification: %s, target=%s, mliid=%s, LTT=%lus", ErrorToString(error),
-            aDua.ToString().AsCString(), aMeshLocalIid.ToString().AsCString(), ToUlong(aTimeSinceLastTransaction));
-}
-#endif // OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-
 void Manager::LogError(const char *aText, Error aError) const
 {
     OT_UNUSED_VARIABLE(aText);
diff --git a/src/core/backbone_router/bbr_manager.hpp b/src/core/backbone_router/bbr_manager.hpp
index 70252a0..5d7fa9c 100644
--- a/src/core/backbone_router/bbr_manager.hpp
+++ b/src/core/backbone_router/bbr_manager.hpp
@@ -44,11 +44,10 @@
 #include "backbone_router/backbone_tmf.hpp"
 #include "backbone_router/bbr_leader.hpp"
 #include "backbone_router/multicast_listeners_table.hpp"
-#include "backbone_router/ndproxy_table.hpp"
 #include "common/locator.hpp"
 #include "common/non_copyable.hpp"
 #include "net/netif.hpp"
-#include "thread/dua_manager.hpp"
+#include "thread/mlr_types.hpp"
 #include "thread/network_data.hpp"
 #include "thread/tmf.hpp"
 
@@ -73,28 +72,7 @@
      */
     explicit Manager(Instance &aInstance);
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    /**
-     * Returns the NdProxy Table.
-     *
-     * @returns The NdProxy Table.
-     */
-    NdProxyTable &GetNdProxyTable(void);
-#endif
-
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    /**
-     * Configures response status for next DUA registration.
-     *
-     * Note: available only when `OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE` is enabled.
-     *       Only used for test and certification.
-     *
-     * @param[in] aMlIid    A pointer to the Mesh Local IID. If `nullptr`, respond with @p aStatus for any
-     *                      coming DUA.req, otherwise only respond the one with matching @p aMlIid.
-     * @param[in] aStatus   The status to respond.
-     */
-    void ConfigNextDuaRegistrationResponse(const Ip6::InterfaceIdentifier *aMlIid, uint8_t aStatus);
-
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
     /**
      * Configures response status for next Multicast Listener Registration.
@@ -104,7 +82,7 @@
      *
      * @param[in] aStatus  The status to respond.
      */
-    void ConfigNextMulticastListenerRegistrationResponse(MlrStatus aStatus);
+    void ConfigNextMulticastListenerRegistrationResponse(Mlr::Status aStatus);
 #endif
 #endif
 
@@ -118,50 +96,12 @@
 #endif
 
     /**
-     * Returns if messages destined to a given Domain Unicast Address should be forwarded to the Backbone
-     * link.
-     *
-     * @param aAddress The Domain Unicast Address.
-     *
-     * @retval TRUE   If messages destined to the Domain Unicast Address should be forwarded to the Backbone link.
-     * @retval FALSE  If messages destined to the Domain Unicast Address should not be forwarded to the Backbone link.
-     */
-    bool ShouldForwardDuaToBackbone(const Ip6::Address &aAddress);
-
-    /**
      * Returns a reference to the Backbone TMF agent.
      *
      * @returns A reference to the Backbone TMF agent.
      */
     BackboneTmfAgent &GetBackboneTmfAgent(void) { return mBackboneTmfAgent; }
 
-    /**
-     * Sends BB.qry on the Backbone link.
-     *
-     * @param[in]  aDua     The Domain Unicast Address to query.
-     * @param[in]  aRloc16  The short address of the address resolution initiator or `Mle::kInvalidRloc16` for
-     *                      DUA DAD.
-     *
-     * @retval kErrorNone          Successfully sent BB.qry on backbone link.
-     * @retval kErrorInvalidState  If the Backbone Router is not primary, or not enabled.
-     * @retval kErrorNoBufs        If insufficient message buffers available.
-     */
-    Error SendBackboneQuery(const Ip6::Address &aDua, uint16_t aRloc16 = Mle::kInvalidRloc16);
-
-    /**
-     * Send a Proactive Backbone Notification (PRO_BB.ntf) on the Backbone link.
-     *
-     * @param[in] aDua                          The Domain Unicast Address to notify.
-     * @param[in] aMeshLocalIid                 The Mesh-Local IID to notify.
-     * @param[in] aTimeSinceLastTransaction     Time since last transaction (in seconds).
-     *
-     * @retval kErrorNone          Successfully sent PRO_BB.ntf on backbone link.
-     * @retval kErrorNoBufs        If insufficient message buffers available.
-     */
-    Error SendProactiveBackboneNotification(const Ip6::Address             &aDua,
-                                            const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                                            uint32_t                        aTimeSinceLastTransaction);
-
 private:
     static constexpr uint8_t  kDefaultHoplimit = 1;
     static constexpr uint32_t kTimerInterval   = 1000;
@@ -172,7 +112,7 @@
     void HandleMulticastListenerRegistration(const Coap::Msg &aMsg);
 
     void SendMulticastListenerRegistrationResponse(const Coap::Msg &aMsg,
-                                                   MlrStatus        aStatus,
+                                                   Mlr::Status      aStatus,
                                                    Ip6::Address    *aFailedAddresses,
                                                    uint8_t          aFailedAddressNum);
     void SendBackboneMulticastListenerRegistration(const Ip6::Address *aAddresses,
@@ -180,27 +120,6 @@
                                                    uint32_t            aTimeout);
 #endif
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    void  HandleDuaRegistration(const Coap::Msg &aMsg);
-    Error SendBackboneAnswer(const Ip6::MessageInfo      &aQueryMessageInfo,
-                             const Ip6::Address          &aDua,
-                             uint16_t                     aSrcRloc16,
-                             const NdProxyTable::NdProxy &aNdProxy);
-    Error SendBackboneAnswer(const Ip6::Address             &aDstAddr,
-                             const Ip6::Address             &aDua,
-                             const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                             uint32_t                        aTimeSinceLastTransaction,
-                             uint16_t                        aSrcRloc16);
-    void  HandleDadBackboneAnswer(const Ip6::Address &aDua, const Ip6::InterfaceIdentifier &aMeshLocalIid);
-    void  HandleExtendedBackboneAnswer(const Ip6::Address             &aDua,
-                                       const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                                       uint32_t                        aTimeSinceLastTransaction,
-                                       uint16_t                        aSrcRloc16);
-    void  HandleProactiveBackboneNotification(const Ip6::Address             &aDua,
-                                              const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                                              uint32_t                        aTimeSinceLastTransaction);
-    void  SendDuaRegistrationResponse(const Coap::Msg &aMsg, const Ip6::Address &aTarget, DuaStatus aStatus);
-#endif
     void HandleNotifierEvents(Events aEvents);
 
     void HandleTimer(void);
@@ -209,10 +128,6 @@
 
     using BbrTimer = TimerMilliIn<Manager, &Manager::HandleTimer>;
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    NdProxyTable mNdProxyTable;
-#endif
-
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
     MulticastListenersTable mMulticastListenersTable;
 #endif
@@ -220,31 +135,15 @@
 
     BackboneTmfAgent mBackboneTmfAgent;
 
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    Ip6::InterfaceIdentifier mDuaResponseTargetMlIid;
-    uint8_t                  mDuaResponseStatus;
-#endif
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
-    MlrStatus mMlrResponseStatus;
-#endif
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    bool mDuaResponseIsSpecified : 1;
-#endif
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
-    bool mMlrResponseIsSpecified : 1;
-#endif
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE && OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
+    Mlr::Status mMlrResponseStatus;
+    bool        mMlrResponseIsSpecified : 1;
 #endif
 };
 
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
 DeclareTmfHandler(Manager, kUriMlr);
 #endif
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-DeclareTmfHandler(Manager, kUriDuaRegistrationRequest);
-DeclareTmfHandler(Manager, kUriBackboneQuery);
-DeclareTmfHandler(Manager, kUriBackboneAnswer);
-#endif
 
 } // namespace BackboneRouter
 
diff --git a/src/core/backbone_router/multicast_listeners_table.cpp b/src/core/backbone_router/multicast_listeners_table.cpp
index a1ea503..b6ec674 100644
--- a/src/core/backbone_router/multicast_listeners_table.cpp
+++ b/src/core/backbone_router/multicast_listeners_table.cpp
@@ -43,108 +43,95 @@
 
 RegisterLogModule("BbrMlt");
 
+MulticastListenersTable::MulticastListenersTable(Instance &aInstance)
+    : InstanceLocator(aInstance)
+    , mTimer(aInstance)
+{
+}
+
 Error MulticastListenersTable::Add(const Ip6::Address &aAddress, Time aExpireTime)
 {
-    Error error = kErrorNone;
+    Error     error = kErrorNone;
+    bool      isNew = false;
+    Listener *entry;
 
     VerifyOrExit(aAddress.IsMulticastLargerThanRealmLocal(), error = kErrorInvalidArgs);
 
-    for (uint16_t i = 0; i < mNumValidListeners; i++)
-    {
-        Listener &listener = mListeners[i];
+    entry = mListeners.FindMatching(aAddress);
 
-        if (listener.GetAddress() == aAddress)
-        {
-            listener.SetExpireTime(aExpireTime);
-            FixHeap(i);
-            ExitNow();
-        }
+    if (entry == nullptr)
+    {
+        entry = mListeners.PushBack();
+        isNew = true;
     }
 
-    VerifyOrExit(mNumValidListeners < GetArrayLength(mListeners), error = kErrorNoBufs);
+    VerifyOrExit(entry != nullptr, error = kErrorNoBufs);
 
-    mListeners[mNumValidListeners].SetAddress(aAddress);
-    mListeners[mNumValidListeners].SetExpireTime(aExpireTime);
-    mNumValidListeners++;
+    entry->mAddress    = aAddress;
+    entry->mExpireTime = aExpireTime;
 
-    FixHeap(mNumValidListeners - 1);
+    mTimer.FireAtIfEarlier(aExpireTime);
 
-    mCallback.InvokeIfSet(MapEnum(Listener::kEventAdded), &aAddress);
+    if (isNew)
+    {
+        InvokeCallback(kEventAdded, aAddress);
+    }
 
 exit:
     Log(kAdd, aAddress, aExpireTime, error);
-    CheckInvariants();
     return error;
 }
 
 void MulticastListenersTable::Remove(const Ip6::Address &aAddress)
 {
-    Error error = kErrorNotFound;
+    Error     error = kErrorNone;
+    Listener *entry;
 
-    for (uint16_t i = 0; i < mNumValidListeners; i++)
-    {
-        Listener &listener = mListeners[i];
+    entry = mListeners.FindMatching(aAddress);
+    VerifyOrExit(entry != nullptr, error = kErrorNotFound);
 
-        if (listener.GetAddress() == aAddress)
-        {
-            mNumValidListeners--;
+    mListeners.Remove(*entry);
 
-            if (i != mNumValidListeners)
-            {
-                listener = mListeners[mNumValidListeners];
-                FixHeap(i);
-            }
-
-            mCallback.InvokeIfSet(MapEnum(Listener::kEventRemoved), &aAddress);
-
-            ExitNow(error = kErrorNone);
-        }
-    }
+    InvokeCallback(kEventRemoved, aAddress);
 
 exit:
     Log(kRemove, aAddress, TimeMilli(0), error);
-    CheckInvariants();
 }
 
-void MulticastListenersTable::Expire(void)
+void MulticastListenersTable::HandleTimer(void)
 {
     TimeMilli    now = TimerMilli::GetNow();
-    Ip6::Address address;
+    NextFireTime nextTime(now);
 
-    while (mNumValidListeners > 0 && now >= mListeners[0].GetExpireTime())
+    for (uint16_t i = 0; i < mListeners.GetLength();)
     {
-        Log(kExpire, mListeners[0].GetAddress(), mListeners[0].GetExpireTime(), kErrorNone);
-        address = mListeners[0].GetAddress();
-
-        mNumValidListeners--;
-
-        if (mNumValidListeners > 0)
+        if (mListeners[i].mExpireTime <= now)
         {
-            mListeners[0] = mListeners[mNumValidListeners];
-            FixHeap(0);
-        }
+            Ip6::Address address = mListeners[i].mAddress;
 
-        mCallback.InvokeIfSet(MapEnum(Listener::kEventRemoved), &address);
+            Log(kExpire, address, mListeners[i].mExpireTime, kErrorNone);
+            mListeners.Remove(mListeners[i]);
+            InvokeCallback(kEventRemoved, address);
+
+            // When the entry is removed from the array it is replaced
+            // with the last element. In this case, we do not
+            // increment `i`.
+        }
+        else
+        {
+            nextTime.UpdateIfEarlier(mListeners[i].mExpireTime);
+            i++;
+        }
     }
 
-    CheckInvariants();
+    mTimer.FireAtIfEarlier(nextTime);
 }
 
-bool MulticastListenersTable::Has(const Ip6::Address &aAddress) const
+bool MulticastListenersTable::Has(const Ip6::Address &aAddress) const { return mListeners.ContainsMatching(aAddress); }
+
+void MulticastListenersTable::InvokeCallback(Event aEvent, const Ip6::Address &aAddress) const
 {
-    bool has = false;
-
-    for (uint16_t i = 0; i < mNumValidListeners; i++)
-    {
-        if (mListeners[i].GetAddress() == aAddress)
-        {
-            has = true;
-            ExitNow();
-        }
-    }
-
-exit:
-    return has;
+    mCallback.InvokeIfSet(aEvent, &aAddress);
 }
 
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_DEBG)
@@ -167,146 +154,43 @@
 void MulticastListenersTable::Log(Action, const Ip6::Address &, TimeMilli, Error) const {}
 #endif
 
-void MulticastListenersTable::FixHeap(uint16_t aIndex)
-{
-    if (!SiftHeapElemDown(aIndex))
-    {
-        SiftHeapElemUp(aIndex);
-    }
-}
-
-void MulticastListenersTable::CheckInvariants(void) const
-{
-#if OPENTHREAD_EXAMPLES_SIMULATION && OPENTHREAD_CONFIG_ASSERT_ENABLE
-    for (uint16_t child = 1; child < mNumValidListeners; ++child)
-    {
-        uint16_t parent = (child - 1) / 2;
-
-        OT_ASSERT(!(mListeners[child] < mListeners[parent]));
-    }
-#endif
-}
-
-bool MulticastListenersTable::SiftHeapElemDown(uint16_t aIndex)
-{
-    uint16_t index = aIndex;
-    Listener saveElem;
-
-    OT_ASSERT(aIndex < mNumValidListeners);
-
-    saveElem = mListeners[aIndex];
-
-    for (;;)
-    {
-        uint16_t child = 2 * index + 1;
-
-        if (child >= mNumValidListeners || child <= index) // child <= index after int overflow
-        {
-            break;
-        }
-
-        if (child + 1 < mNumValidListeners && mListeners[child + 1] < mListeners[child])
-        {
-            child++;
-        }
-
-        if (!(mListeners[child] < saveElem))
-        {
-            break;
-        }
-
-        mListeners[index] = mListeners[child];
-
-        index = child;
-    }
-
-    if (index > aIndex)
-    {
-        mListeners[index] = saveElem;
-    }
-
-    return index > aIndex;
-}
-
-void MulticastListenersTable::SiftHeapElemUp(uint16_t aIndex)
-{
-    uint16_t index = aIndex;
-    Listener saveElem;
-
-    OT_ASSERT(aIndex < mNumValidListeners);
-
-    saveElem = mListeners[aIndex];
-
-    for (;;)
-    {
-        uint16_t parent = (index - 1) / 2;
-
-        if (index == 0 || !(saveElem < mListeners[parent]))
-        {
-            break;
-        }
-
-        mListeners[index] = mListeners[parent];
-
-        index = parent;
-    }
-
-    if (index < aIndex)
-    {
-        mListeners[index] = saveElem;
-    }
-}
-
-MulticastListenersTable::Listener *MulticastListenersTable::IteratorBuilder::begin(void)
-{
-    return &Get<MulticastListenersTable>().mListeners[0];
-}
-
-MulticastListenersTable::Listener *MulticastListenersTable::IteratorBuilder::end(void)
-{
-    return &Get<MulticastListenersTable>().mListeners[Get<MulticastListenersTable>().mNumValidListeners];
-}
-
 void MulticastListenersTable::Clear(void)
 {
-    if (mCallback.IsSet())
+    while (!mListeners.IsEmpty())
     {
-        for (uint16_t i = 0; i < mNumValidListeners; i++)
-        {
-            mCallback.Invoke(MapEnum(Listener::kEventRemoved), &mListeners[i].GetAddress());
-        }
+        Ip6::Address address = mListeners.Back()->mAddress;
+
+        mListeners.PopBack();
+        InvokeCallback(kEventRemoved, address);
     }
 
-    mNumValidListeners = 0;
-
-    CheckInvariants();
+    mTimer.Stop();
 }
 
-void MulticastListenersTable::SetCallback(Listener::Callback aCallback, void *aContext)
+void MulticastListenersTable::SetCallback(ListenerCallback aCallback, void *aContext)
 {
     mCallback.Set(aCallback, aContext);
 
     if (mCallback.IsSet())
     {
-        for (uint16_t i = 0; i < mNumValidListeners; i++)
+        for (const Listener &listener : mListeners)
         {
-            mCallback.Invoke(MapEnum(Listener::kEventAdded), &mListeners[i].GetAddress());
+            InvokeCallback(kEventAdded, listener.mAddress);
         }
     }
 }
 
-Error MulticastListenersTable::GetNext(Listener::Iterator &aIterator, Listener::Info &aInfo)
+Error MulticastListenersTable::GetNext(ListenerIterator &aIterator, ListenerInfo &aInfo)
 {
     Error     error = kErrorNone;
     TimeMilli now;
 
-    VerifyOrExit(aIterator < mNumValidListeners, error = kErrorNotFound);
+    VerifyOrExit(aIterator < mListeners.GetLength(), error = kErrorNotFound);
 
     now = TimerMilli::GetNow();
 
     aInfo.mAddress = mListeners[aIterator].mAddress;
-    aInfo.mTimeout =
-        Time::MsecToSec(mListeners[aIterator].mExpireTime > now ? mListeners[aIterator].mExpireTime - now : 0);
+    aInfo.mTimeout = Time::MsecToSec(mListeners[aIterator].mExpireTime.DetermineRemainingDurationFrom(now));
 
     aIterator++;
 
diff --git a/src/core/backbone_router/multicast_listeners_table.hpp b/src/core/backbone_router/multicast_listeners_table.hpp
index fd0c727..a538f4e 100644
--- a/src/core/backbone_router/multicast_listeners_table.hpp
+++ b/src/core/backbone_router/multicast_listeners_table.hpp
@@ -40,11 +40,13 @@
 
 #include <openthread/backbone_router_ftd.h>
 
+#include "common/array.hpp"
 #include "common/as_core_type.hpp"
 #include "common/callback.hpp"
 #include "common/non_copyable.hpp"
 #include "common/notifier.hpp"
 #include "common/time.hpp"
+#include "common/timer.hpp"
 #include "net/ip6_address.hpp"
 
 namespace ot {
@@ -56,66 +58,17 @@
  */
 class MulticastListenersTable : public InstanceLocator, private NonCopyable
 {
-    class IteratorBuilder;
-
 public:
-    /**
-     * Represents a Multicast Listener entry.
-     */
-    class Listener : public Clearable<Listener>
-    {
-        friend class MulticastListenersTable;
-
-    public:
-        typedef otBackboneRouterMulticastListenerCallback Callback; ///< Listener callback.
-        typedef otBackboneRouterMulticastListenerIterator Iterator; ///< Iterator to go over Listener entries.
-        typedef otBackboneRouterMulticastListenerInfo     Info;     ///< Listener info.
-
-        enum Event : uint8_t ///< Listener Event
-        {
-            kEventAdded   = OT_BACKBONE_ROUTER_MULTICAST_LISTENER_ADDED,   ///< Listener was added.
-            kEventRemoved = OT_BACKBONE_ROUTER_MULTICAST_LISTENER_REMOVED, ///< Listener was removed.
-        };
-
-        /**
-         * Initializes the `Listener` object.
-         */
-        Listener(void) { Clear(); }
-
-        /**
-         * Returns the Multicast Listener address.
-         *
-         * @returns The Multicast Listener address.
-         */
-        const Ip6::Address &GetAddress(void) const { return mAddress; }
-
-        /**
-         * Returns the expire time of the Multicast Listener.
-         *
-         * @returns The Multicast Listener expire time.
-         */
-        const TimeMilli GetExpireTime(void) const { return mExpireTime; }
-
-    private:
-        void SetAddress(const Ip6::Address &aAddress) { mAddress = aAddress; }
-        void SetExpireTime(TimeMilli aExpireTime) { mExpireTime = aExpireTime; }
-
-        bool operator<(const Listener &aOther) const { return GetExpireTime() < aOther.GetExpireTime(); }
-
-        Ip6::Address mAddress;
-        TimeMilli    mExpireTime;
-    };
+    typedef otBackboneRouterMulticastListenerCallback ListenerCallback; ///< Listener Callback.
+    typedef otBackboneRouterMulticastListenerInfo     ListenerInfo;     ///< Listener info.
+    typedef otBackboneRouterMulticastListenerIterator ListenerIterator; ///< Iterator to go over entries.
 
     /**
      * Initializes the Multicast Listeners Table.
      *
      * @param[in] aInstance  A reference to the OpenThread instance.
      */
-    explicit MulticastListenersTable(Instance &aInstance)
-        : InstanceLocator(aInstance)
-        , mNumValidListeners(0)
-    {
-    }
+    explicit MulticastListenersTable(Instance &aInstance);
 
     /**
      * Adds a Multicast Listener with given address and expire time.
@@ -137,16 +90,11 @@
     void Remove(const Ip6::Address &aAddress);
 
     /**
-     * Removes expired Multicast Listeners.
-     */
-    void Expire(void);
-
-    /**
      * Counts the number of valid Multicast Listeners.
      *
      * @returns The number of valid Multicast Listeners.
      */
-    uint16_t Count(void) const { return mNumValidListeners; }
+    uint16_t Count(void) const { return mListeners.GetLength(); }
 
     /**
      * Indicates whether or not the Multicast Listeners Table contains the given address.
@@ -159,17 +107,6 @@
     bool Has(const Ip6::Address &aAddress) const;
 
     /**
-     * Enables range-based `for` loop iteration over all Multicast Listeners.
-     *
-     * Should be used as follows:
-     *
-     *     for (MulticastListenersTable::Listener &listener : Get<MulticastListenersTable>().Iterate())
-     *
-     * @returns An IteratorBuilder instance.
-     */
-    IteratorBuilder Iterate(void) { return IteratorBuilder(GetInstance()); }
-
-    /**
      * Removes all the Multicast Listeners.
      */
     void Clear(void);
@@ -180,7 +117,7 @@
      * @param[in] aCallback  The callback function.
      * @param[in] aContext   A user context pointer.
      */
-    void SetCallback(Listener::Callback aCallback, void *aContext);
+    void SetCallback(ListenerCallback aCallback, void *aContext);
 
     /**
      * Gets the next Multicast Listener.
@@ -191,24 +128,17 @@
      * @retval kErrorNone         Successfully found the next Multicast Listener info.
      * @retval kErrorNotFound     No subsequent Multicast Listener was found.
      */
-    Error GetNext(Listener::Iterator &aIterator, Listener::Info &aInfo);
+    Error GetNext(ListenerIterator &aIterator, ListenerInfo &aInfo);
 
 private:
     static constexpr uint16_t kTableSize = OPENTHREAD_CONFIG_MAX_MULTICAST_LISTENERS;
 
     static_assert(kTableSize >= 75, "Thread 1.2 Conformance requires table size of at least 75 listeners.");
 
-    class IteratorBuilder : InstanceLocator
-    {
-    public:
-        explicit IteratorBuilder(Instance &aInstance)
-            : InstanceLocator(aInstance)
-        {
-        }
+    typedef otBackboneRouterMulticastListenerEvent Event;
 
-        Listener *begin(void);
-        Listener *end(void);
-    };
+    static constexpr Event kEventAdded   = OT_BACKBONE_ROUTER_MULTICAST_LISTENER_ADDED;
+    static constexpr Event kEventRemoved = OT_BACKBONE_ROUTER_MULTICAST_LISTENER_REMOVED;
 
     enum Action : uint8_t
     {
@@ -217,22 +147,28 @@
         kExpire,
     };
 
+    struct Listener
+    {
+        bool Matches(const Ip6::Address &aAddress) const { return mAddress == aAddress; }
+
+        Ip6::Address mAddress;
+        TimeMilli    mExpireTime;
+    };
+
+    void InvokeCallback(Event aEvent, const Ip6::Address &aAddress) const;
+    void HandleTimer(void);
     void Log(Action aAction, const Ip6::Address &aAddress, TimeMilli aExpireTime, Error aError) const;
 
-    void FixHeap(uint16_t aIndex);
-    bool SiftHeapElemDown(uint16_t aIndex);
-    void SiftHeapElemUp(uint16_t aIndex);
-    void CheckInvariants(void) const;
+    using ListenerArray = Array<Listener, kTableSize, uint16_t>;
+    using ExpireTimer   = TimerMilliIn<MulticastListenersTable, &MulticastListenersTable::HandleTimer>;
 
-    Listener                     mListeners[kTableSize];
-    uint16_t                     mNumValidListeners;
-    Callback<Listener::Callback> mCallback;
+    ListenerArray              mListeners;
+    ExpireTimer                mTimer;
+    Callback<ListenerCallback> mCallback;
 };
 
 } // namespace BackboneRouter
 
-DefineMapEnum(otBackboneRouterMulticastListenerEvent, BackboneRouter::MulticastListenersTable::Listener::Event);
-
 /**
  * @}
  */
diff --git a/src/core/backbone_router/ndproxy_table.cpp b/src/core/backbone_router/ndproxy_table.cpp
deleted file mode 100644
index 3e2c494..0000000
--- a/src/core/backbone_router/ndproxy_table.cpp
+++ /dev/null
@@ -1,352 +0,0 @@
-/*
- *  Copyright (c) 2020, The OpenThread Authors.
- *  All rights reserved.
- *
- *  Redistribution and use in source and binary forms, with or without
- *  modification, are permitted provided that the following conditions are met:
- *  1. Redistributions of source code must retain the above copyright
- *     notice, this list of conditions and the following disclaimer.
- *  2. Redistributions in binary form must reproduce the above copyright
- *     notice, this list of conditions and the following disclaimer in the
- *     documentation and/or other materials provided with the distribution.
- *  3. Neither the name of the copyright holder nor the
- *     names of its contributors may be used to endorse or promote products
- *     derived from this software without specific prior written permission.
- *
- *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- *  POSSIBILITY OF SUCH DAMAGE.
- */
-
-/**
- * @file
- *   This file implements Thread NdProxy Table management.
- */
-
-#include "ndproxy_table.hpp"
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-
-#include "common/array.hpp"
-#include "common/log.hpp"
-#include "common/num_utils.hpp"
-#include "instance/instance.hpp"
-
-namespace ot {
-
-namespace BackboneRouter {
-
-RegisterLogModule("BbrNdProxy");
-
-void NdProxyTable::NdProxy::Init(const Ip6::InterfaceIdentifier &aAddressIid,
-                                 const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                                 uint16_t                        aRloc16,
-                                 uint32_t                        aTimeSinceLastTransaction)
-{
-    OT_ASSERT(!mValid);
-
-    Clear();
-
-    mValid        = true;
-    mAddressIid   = aAddressIid;
-    mMeshLocalIid = aMeshLocalIid;
-    mDadFlag      = true;
-
-    Update(aRloc16, aTimeSinceLastTransaction);
-}
-
-void NdProxyTable::NdProxy::Update(uint16_t aRloc16, uint32_t aTimeSinceLastTransaction)
-{
-    OT_ASSERT(mValid);
-
-    mRloc16                   = aRloc16;
-    aTimeSinceLastTransaction = Min(aTimeSinceLastTransaction, kMaxTimeSinceLastTransaction);
-    mLastRegistrationTime     = TimerMilli::GetNow() - TimeMilli::SecToMsec(aTimeSinceLastTransaction);
-}
-
-bool NdProxyTable::MatchesFilter(const NdProxy &aProxy, Filter aFilter)
-{
-    bool rval = false;
-
-    switch (aFilter)
-    {
-    case kFilterInvalid:
-        rval = !aProxy.mValid;
-        break;
-    case kFilterValid:
-        rval = aProxy.mValid;
-        break;
-    case kFilterDadInProcess:
-        rval = aProxy.mValid && aProxy.mDadFlag;
-        break;
-    }
-
-    return rval;
-}
-
-NdProxyTable::Iterator::Iterator(Instance &aInstance, Filter aFilter)
-    : InstanceLocator(aInstance)
-    , mFilter(aFilter)
-{
-    NdProxyTable &table = GetInstance().Get<BackboneRouter::NdProxyTable>();
-
-    mItem = &table.mProxies[0];
-
-    if (!MatchesFilter(*mItem, mFilter))
-    {
-        Advance();
-    }
-}
-
-NdProxyTable::Iterator::Iterator(Instance &aInstance, NdProxyTable::Iterator::IteratorType)
-    : InstanceLocator(aInstance)
-{
-    NdProxyTable &table = GetInstance().Get<BackboneRouter::NdProxyTable>();
-    mItem               = GetArrayEnd(table.mProxies);
-}
-
-void NdProxyTable::Iterator::Advance(void)
-{
-    NdProxyTable &table = GetInstance().Get<BackboneRouter::NdProxyTable>();
-
-    do
-    {
-        mItem++;
-    } while (mItem < GetArrayEnd(table.mProxies) && !MatchesFilter(*mItem, mFilter));
-}
-
-void NdProxyTable::Erase(NdProxy &aNdProxy) { aNdProxy.mValid = false; }
-
-void NdProxyTable::HandleDomainPrefixUpdate(DomainPrefixEvent aEvent)
-{
-    if (aEvent == kDomainPrefixAdded || aEvent == kDomainPrefixRemoved || aEvent == kDomainPrefixRefreshed)
-    {
-        Clear();
-    }
-}
-
-void NdProxyTable::Clear(void)
-{
-    for (NdProxy &proxy : mProxies)
-    {
-        proxy.Clear();
-    }
-
-    mCallback.InvokeIfSet(MapEnum(NdProxy::kCleared), nullptr);
-
-    LogInfo("NdProxyTable::Clear!");
-}
-
-Error NdProxyTable::Register(const Ip6::InterfaceIdentifier &aAddressIid,
-                             const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                             uint16_t                        aRloc16,
-                             const uint32_t                 *aTimeSinceLastTransaction)
-{
-    Error    error                    = kErrorNone;
-    NdProxy *proxy                    = FindByAddressIid(aAddressIid);
-    uint32_t timeSinceLastTransaction = aTimeSinceLastTransaction == nullptr ? 0 : *aTimeSinceLastTransaction;
-
-    if (proxy != nullptr)
-    {
-        VerifyOrExit(proxy->mMeshLocalIid == aMeshLocalIid, error = kErrorDuplicated);
-
-        proxy->Update(aRloc16, timeSinceLastTransaction);
-        NotifyDuaRegistrationOnBackboneLink(*proxy, /* aIsRenew */ true);
-        ExitNow();
-    }
-
-    proxy = FindByMeshLocalIid(aMeshLocalIid);
-    if (proxy != nullptr)
-    {
-        TriggerCallback(NdProxy::kRemoved, proxy->mAddressIid);
-        Erase(*proxy);
-    }
-    else
-    {
-        proxy = FindInvalid();
-
-        // TODO: evict stale DUA entries to have room for this new DUA.
-        VerifyOrExit(proxy != nullptr, error = kErrorNoBufs);
-    }
-
-    proxy->Init(aAddressIid, aMeshLocalIid, aRloc16, timeSinceLastTransaction);
-    mIsAnyDadInProcess = true;
-
-exit:
-    LogInfo("NdProxyTable::Register %s MLIID %s RLOC16 %04x LTT %lu => %s", aAddressIid.ToString().AsCString(),
-            aMeshLocalIid.ToString().AsCString(), aRloc16, ToUlong(timeSinceLastTransaction), ErrorToString(error));
-    return error;
-}
-
-NdProxyTable::NdProxy *NdProxyTable::FindByAddressIid(const Ip6::InterfaceIdentifier &aAddressIid)
-{
-    NdProxy *found = nullptr;
-
-    for (NdProxy &proxy : Iterate(kFilterValid))
-    {
-        if (proxy.mAddressIid == aAddressIid)
-        {
-            ExitNow(found = &proxy);
-        }
-    }
-
-exit:
-    LogDebg("NdProxyTable::FindByAddressIid(%s) => %s", aAddressIid.ToString().AsCString(),
-            found ? found->mMeshLocalIid.ToString().AsCString() : "NOT_FOUND");
-    return found;
-}
-
-NdProxyTable::NdProxy *NdProxyTable::FindByMeshLocalIid(const Ip6::InterfaceIdentifier &aMeshLocalIid)
-{
-    NdProxy *found = nullptr;
-
-    for (NdProxy &proxy : Iterate(kFilterValid))
-    {
-        if (proxy.mMeshLocalIid == aMeshLocalIid)
-        {
-            ExitNow(found = &proxy);
-        }
-    }
-
-exit:
-    LogDebg("NdProxyTable::FindByMeshLocalIid(%s) => %s", aMeshLocalIid.ToString().AsCString(),
-            found ? found->mAddressIid.ToString().AsCString() : "NOT_FOUND");
-    return found;
-}
-
-NdProxyTable::NdProxy *NdProxyTable::FindInvalid(void)
-{
-    NdProxy *found = nullptr;
-
-    for (NdProxy &proxy : Iterate(kFilterInvalid))
-    {
-        ExitNow(found = &proxy);
-    }
-
-exit:
-    LogDebg("NdProxyTable::FindInvalid() => %s", found ? "OK" : "NOT_FOUND");
-    return found;
-}
-
-void NdProxyTable::HandleTimer(void)
-{
-    VerifyOrExit(mIsAnyDadInProcess);
-
-    mIsAnyDadInProcess = false;
-
-    for (NdProxy &proxy : Iterate(kFilterDadInProcess))
-    {
-        if (proxy.IsDadAttemptsComplete())
-        {
-            proxy.mDadFlag = false;
-            NotifyDuaRegistrationOnBackboneLink(proxy, /* aIsRenew */ false);
-        }
-        else
-        {
-            mIsAnyDadInProcess = true;
-
-            if (Get<BackboneRouter::Manager>().SendBackboneQuery(GetDua(proxy)) == kErrorNone)
-            {
-                proxy.IncreaseDadAttempts();
-            }
-        }
-    }
-
-exit:
-    return;
-}
-
-void NdProxyTable::TriggerCallback(NdProxy::Event aEvent, const Ip6::InterfaceIdentifier &aAddressIid) const
-{
-    Ip6::Address       dua;
-    const Ip6::Prefix *prefix = Get<BackboneRouter::Leader>().GetDomainPrefix();
-
-    VerifyOrExit(mCallback.IsSet());
-
-    OT_ASSERT(prefix != nullptr);
-
-    dua.SetPrefix(*prefix);
-    dua.SetIid(aAddressIid);
-
-    mCallback.Invoke(MapEnum(aEvent), &dua);
-
-exit:
-    return;
-}
-
-void NdProxyTable::NotifyDadComplete(NdProxyTable::NdProxy &aNdProxy, bool aDuplicated)
-{
-    if (aDuplicated)
-    {
-        Erase(aNdProxy);
-    }
-    else
-    {
-        aNdProxy.mDadAttempts = kDuaDadRepeats;
-    }
-}
-
-Ip6::Address NdProxyTable::GetDua(NdProxy &aNdProxy)
-{
-    Ip6::Address       dua;
-    const Ip6::Prefix *domainPrefix = Get<BackboneRouter::Leader>().GetDomainPrefix();
-
-    OT_ASSERT(domainPrefix != nullptr);
-
-    dua.SetPrefix(*domainPrefix);
-    dua.SetIid(aNdProxy.mAddressIid);
-
-    return dua;
-}
-
-NdProxyTable::NdProxy *NdProxyTable::ResolveDua(const Ip6::Address &aDua)
-{
-    return Get<Leader>().IsDomainUnicast(aDua) ? FindByAddressIid(aDua.GetIid()) : nullptr;
-}
-
-void NdProxyTable::NotifyDuaRegistrationOnBackboneLink(NdProxyTable::NdProxy &aNdProxy, bool aIsRenew)
-{
-    if (!aNdProxy.mDadFlag)
-    {
-        TriggerCallback(aIsRenew ? NdProxy::kRenewed : NdProxy::kAdded, aNdProxy.mAddressIid);
-
-        IgnoreError(Get<BackboneRouter::Manager>().SendProactiveBackboneNotification(
-            GetDua(aNdProxy), aNdProxy.GetMeshLocalIid(), aNdProxy.GetTimeSinceLastTransaction()));
-    }
-}
-
-Error NdProxyTable::GetInfo(const Ip6::Address &aDua, otBackboneRouterNdProxyInfo &aNdProxyInfo)
-{
-    Error error = kErrorNotFound;
-
-    VerifyOrExit(Get<Leader>().IsDomainUnicast(aDua), error = kErrorInvalidArgs);
-
-    for (NdProxy &proxy : Iterate(kFilterValid))
-    {
-        if (proxy.mAddressIid == aDua.GetIid())
-        {
-            aNdProxyInfo.mMeshLocalIid             = &proxy.mMeshLocalIid;
-            aNdProxyInfo.mTimeSinceLastTransaction = proxy.GetTimeSinceLastTransaction();
-            aNdProxyInfo.mRloc16                   = proxy.mRloc16;
-
-            ExitNow(error = kErrorNone);
-        }
-    }
-
-exit:
-    return error;
-}
-
-} // namespace BackboneRouter
-
-} // namespace ot
-
-#endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
diff --git a/src/core/backbone_router/ndproxy_table.hpp b/src/core/backbone_router/ndproxy_table.hpp
deleted file mode 100644
index 29b5cf4..0000000
--- a/src/core/backbone_router/ndproxy_table.hpp
+++ /dev/null
@@ -1,308 +0,0 @@
-/*
- *  Copyright (c) 2020, The OpenThread Authors.
- *  All rights reserved.
- *
- *  Redistribution and use in source and binary forms, with or without
- *  modification, are permitted provided that the following conditions are met:
- *  1. Redistributions of source code must retain the above copyright
- *     notice, this list of conditions and the following disclaimer.
- *  2. Redistributions in binary form must reproduce the above copyright
- *     notice, this list of conditions and the following disclaimer in the
- *     documentation and/or other materials provided with the distribution.
- *  3. Neither the name of the copyright holder nor the
- *     names of its contributors may be used to endorse or promote products
- *     derived from this software without specific prior written permission.
- *
- *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- *  POSSIBILITY OF SUCH DAMAGE.
- */
-/**
- * @file
- *   This file includes definitions for NdProxy Table on Thread Backbone Border Router.
- */
-
-#ifndef OT_CORE_BACKBONE_ROUTER_NDPROXY_TABLE_HPP_
-#define OT_CORE_BACKBONE_ROUTER_NDPROXY_TABLE_HPP_
-
-#include "openthread-core-config.h"
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-
-#include <openthread/backbone_router_ftd.h>
-
-#include "backbone_router/bbr_leader.hpp"
-#include "common/as_core_type.hpp"
-#include "common/callback.hpp"
-#include "common/iterator_utils.hpp"
-#include "common/locator.hpp"
-#include "common/non_copyable.hpp"
-#include "common/time.hpp"
-#include "net/ip6_address.hpp"
-#include "thread/mle_types.hpp"
-
-namespace ot {
-
-namespace BackboneRouter {
-
-/**
- * Implements NdProxy Table maintenance on Primary Backbone Router.
- */
-class NdProxyTable : public InstanceLocator, private NonCopyable
-{
-public:
-    static constexpr uint8_t kDuaDadRepeats = 3; ///< Number multicast DAD queries by BBR
-
-    /**
-     * Represents a ND Proxy instance.
-     */
-    class NdProxy : private Clearable<NdProxy>
-    {
-        friend class NdProxyTable;
-        friend class Clearable<NdProxy>;
-
-    public:
-        typedef otBackboneRouterNdProxyCallback Callback; ///< ND Proxy callback.
-
-        /**
-         * Represents the ND Proxy events.
-         */
-        enum Event
-        {
-            kAdded   = OT_BACKBONE_ROUTER_NDPROXY_ADDED,   ///< ND Proxy was added.
-            kRemoved = OT_BACKBONE_ROUTER_NDPROXY_REMOVED, ///< ND Proxy was removed.
-            kRenewed = OT_BACKBONE_ROUTER_NDPROXY_RENEWED, ///< ND Proxy was renewed.
-            kCleared = OT_BACKBONE_ROUTER_NDPROXY_CLEARED, ///< All ND Proxies were cleared.
-        };
-
-        /**
-         * Gets the Mesh-Local IID of the ND Proxy.
-         *
-         * @returns  The Mesh-Local IID.
-         */
-        const Ip6::InterfaceIdentifier &GetMeshLocalIid(void) const { return mMeshLocalIid; }
-
-        /**
-         * Gets the time since last transaction of the ND Proxy.
-         *
-         * @returns  The time since last transaction in seconds.
-         */
-        uint32_t GetTimeSinceLastTransaction(void) const
-        {
-            return TimeMilli::MsecToSec(TimerMilli::GetNow() - mLastRegistrationTime);
-        }
-
-        /**
-         * Gets the short address of the device who sends the DUA registration.
-         *
-         * @returns  The RLOC16 value.
-         */
-        uint16_t GetRloc16(void) const { return mRloc16; }
-
-        /**
-         * Gets the DAD flag of the ND Proxy.
-         *
-         * @returns  The DAD flag.
-         */
-        bool GetDadFlag(void) const { return mDadFlag; }
-
-    private:
-        static constexpr uint32_t kMaxTimeSinceLastTransaction = 10 * 86400; // In seconds (10 days).
-
-        NdProxy(void) { Clear(); }
-
-        void Init(const Ip6::InterfaceIdentifier &aAddressIid,
-                  const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                  uint16_t                        aRloc16,
-                  uint32_t                        aTimeSinceLastTransaction);
-
-        void Update(uint16_t aRloc16, uint32_t aTimeSinceLastTransaction);
-        void IncreaseDadAttempts(void) { mDadAttempts++; }
-        bool IsDadAttemptsComplete(void) const { return mDadAttempts == kDuaDadRepeats; }
-
-        Ip6::InterfaceIdentifier mAddressIid;
-        Ip6::InterfaceIdentifier mMeshLocalIid;
-        TimeMilli                mLastRegistrationTime; ///< in milliseconds
-        uint16_t                 mRloc16;
-        uint8_t                  mDadAttempts : 2;
-        bool                     mDadFlag : 1;
-        bool                     mValid : 1;
-
-        static_assert(kDuaDadRepeats < 4, "kDuaDadRepeats does not fit in mDadAttempts field as 2-bit value");
-    };
-
-    /**
-     * Initializes the `NdProxyTable` object.
-     *
-     * @param[in]  aInstance     A reference to the OpenThread instance.
-     */
-    explicit NdProxyTable(Instance &aInstance)
-        : InstanceLocator(aInstance)
-        , mIsAnyDadInProcess(false)
-    {
-    }
-
-    /**
-     * Registers a given IPv6 address IID with related information to the NdProxy table.
-     *
-     * @param[in] aAddressIid                The IPv6 address IID.
-     * @param[in] aMeshLocalIid              The Mesh-Local IID.
-     * @param[in] aRloc16                    The RLOC16.
-     * @param[in] aTimeSinceLastTransaction  Time since last transaction (in seconds).
-     *
-     * @retval kErrorNone        If registered successfully.
-     * @retval kErrorDuplicated  If the IPv6 address IID is a duplicate.
-     * @retval kErrorNoBufs      Insufficient buffer space available to register.
-     */
-    Error Register(const Ip6::InterfaceIdentifier &aAddressIid,
-                   const Ip6::InterfaceIdentifier &aMeshLocalIid,
-                   uint16_t                        aRloc16,
-                   const uint32_t                 *aTimeSinceLastTransaction);
-
-    /**
-     * Checks if a given IPv6 address IID was registered.
-     *
-     * @param[in] aAddressIid  The IPv6 address IID.
-     *
-     * @retval TRUE   If the IPv6 address IID was registered.
-     * @retval FALSE  If the IPv6 address IID was not registered.
-     */
-    bool IsRegistered(const Ip6::InterfaceIdentifier &aAddressIid) { return FindByAddressIid(aAddressIid) != nullptr; }
-
-    /**
-     * Notifies Domain Prefix event.
-     *
-     * @param[in]  aEvent  The Domain Prefix event.
-     */
-    void HandleDomainPrefixUpdate(DomainPrefixEvent aEvent);
-
-    /**
-     * Notifies ND Proxy table of the timer tick.
-     */
-    void HandleTimer(void);
-
-    /**
-     * Gets the ND Proxy info for a given Domain Unicast Address.
-     *
-     * @param[in] aDua  The Domain Unicast Address.
-     *
-     * @returns The `NdProxy` instance matching the specified @p aDua, or nullptr if not found.
-     */
-    NdProxy *ResolveDua(const Ip6::Address &aDua);
-
-    /**
-     * Notifies DAD completed for a given ND Proxy.
-     *
-     * @param[in] aNdProxy      The ND Proxy to notify of.
-     * @param[in] aDuplicated   Whether duplicate was detected.
-     */
-    static void NotifyDadComplete(NdProxy &aNdProxy, bool aDuplicated);
-
-    /**
-     * Removes the ND Proxy.
-     *
-     * @param[in] aNdProxy      The ND Proxy to remove.
-     */
-    static void Erase(NdProxy &aNdProxy);
-
-    /*
-     * Sets the ND Proxy callback.
-     *
-     * @param[in] aCallback  The callback function.
-     * @param[in] aContext   A user context pointer.
-     */
-    void SetCallback(NdProxy::Callback aCallback, void *aContext) { mCallback.Set(aCallback, aContext); }
-
-    /**
-     * Retrieves the ND Proxy info of the Domain Unicast Address.
-     *
-     * @param[in] aDua          The Domain Unicast Address to get info.
-     * @param[in] aNdProxyInfo  A pointer to the ND Proxy info.
-     *
-     * @retval kErrorNone       Successfully retrieve the ND Proxy info.
-     * @retval kErrorNotFound   Failed to find the Domain Unicast Address in the ND Proxy table.
-     */
-    Error GetInfo(const Ip6::Address &aDua, otBackboneRouterNdProxyInfo &aNdProxyInfo);
-
-private:
-    static constexpr uint16_t kMaxNdProxyNum = OPENTHREAD_CONFIG_NDPROXY_TABLE_ENTRY_NUM;
-
-    enum Filter : uint8_t
-    {
-        kFilterInvalid,
-        kFilterValid,
-        kFilterDadInProcess,
-    };
-
-    /**
-     * Represents an iterator for iterating through the NdProxy Table.
-     */
-    class Iterator : public InstanceLocator, public ItemPtrIterator<NdProxy, Iterator>
-    {
-        friend class ItemPtrIterator<NdProxy, Iterator>;
-        friend class NdProxyTable;
-        friend class IteratorBuilder;
-
-    private:
-        enum IteratorType : uint8_t
-        {
-            kEndIterator,
-        };
-
-        Iterator(Instance &aInstance, Filter aFilter);
-        Iterator(Instance &aInstance, IteratorType);
-
-        void Advance(void);
-
-        Filter mFilter;
-    };
-
-    class IteratorBuilder : public InstanceLocator
-    {
-        friend class NdProxyTable;
-
-    private:
-        IteratorBuilder(Instance &aInstance, Filter aFilter)
-            : InstanceLocator(aInstance)
-            , mFilter(aFilter)
-        {
-        }
-
-        Iterator begin(void) { return Iterator(GetInstance(), mFilter); }
-        Iterator end(void) { return Iterator(GetInstance(), Iterator::kEndIterator); }
-
-        Filter mFilter;
-    };
-
-    IteratorBuilder Iterate(Filter aFilter) { return IteratorBuilder(GetInstance(), aFilter); }
-    void            Clear(void);
-    static bool     MatchesFilter(const NdProxy &aProxy, Filter aFilter);
-    NdProxy        *FindByAddressIid(const Ip6::InterfaceIdentifier &aAddressIid);
-    NdProxy        *FindByMeshLocalIid(const Ip6::InterfaceIdentifier &aMeshLocalIid);
-    NdProxy        *FindInvalid(void);
-    Ip6::Address    GetDua(NdProxy &aNdProxy);
-    void            NotifyDuaRegistrationOnBackboneLink(NdProxy &aNdProxy, bool aIsRenew);
-    void            TriggerCallback(NdProxy::Event aEvent, const Ip6::InterfaceIdentifier &aAddressIid) const;
-
-    NdProxy                     mProxies[kMaxNdProxyNum];
-    Callback<NdProxy::Callback> mCallback;
-    bool                        mIsAnyDadInProcess : 1;
-};
-
-} // namespace BackboneRouter
-
-DefineMapEnum(otBackboneRouterNdProxyEvent, BackboneRouter::NdProxyTable::NdProxy::Event);
-
-} // namespace ot
-
-#endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-
-#endif // OT_CORE_BACKBONE_ROUTER_NDPROXY_TABLE_HPP_
diff --git a/src/core/border_router/br_types.cpp b/src/core/border_router/br_types.cpp
index 781f85e..b9471e1 100644
--- a/src/core/border_router/br_types.cpp
+++ b/src/core/border_router/br_types.cpp
@@ -72,6 +72,18 @@
     mLastUpdateTime    = TimerMilli::GetNow();
 }
 
+void OnLinkPrefix::Deprecate(void)
+{
+    TimeMilli twoHoursFromNow = TimerMilli::GetNow() + Time::SecToMsec(kTwoHoursLifetime);
+
+    mPreferredLifetime = 0;
+
+    if (GetExpireTime() > twoHoursFromNow)
+    {
+        mValidLifetime = Time::MsecToSec(twoHoursFromNow.DetermineRemainingDurationFrom(mLastUpdateTime));
+    }
+}
+
 bool OnLinkPrefix::IsDeprecated(void) const { return GetDeprecationTime() <= TimerMilli::GetNow(); }
 
 TimeMilli OnLinkPrefix::GetDeprecationTime(void) const { return CalculateExpirationTime(mPreferredLifetime); }
@@ -83,8 +95,6 @@
 
 void OnLinkPrefix::AdoptFlagsAndValidAndPreferredLifetimesFrom(const OnLinkPrefix &aPrefix)
 {
-    constexpr uint32_t kTwoHoursInSeconds = 2 * 3600;
-
     // Per RFC 4862 section 5.5.3.e:
     //
     // 1.  If the received Valid Lifetime is greater than 2 hours or
@@ -96,13 +106,13 @@
     // 3.  Otherwise, reset the valid lifetime of the corresponding
     //     address to 2 hours.
 
-    if (aPrefix.mValidLifetime > kTwoHoursInSeconds || aPrefix.GetExpireTime() > GetExpireTime())
+    if (aPrefix.mValidLifetime > kTwoHoursLifetime || aPrefix.GetExpireTime() > GetExpireTime())
     {
         mValidLifetime = aPrefix.mValidLifetime;
     }
-    else if (GetExpireTime() > TimerMilli::GetNow() + TimeMilli::SecToMsec(kTwoHoursInSeconds))
+    else if (GetExpireTime() > TimerMilli::GetNow() + Time::SecToMsec(kTwoHoursLifetime))
     {
-        mValidLifetime = kTwoHoursInSeconds;
+        mValidLifetime = kTwoHoursLifetime;
     }
 
     mPreferredLifetime    = aPrefix.GetPreferredLifetime();
@@ -255,6 +265,11 @@
     mPreference = aPreference;
 }
 
+bool OmrPrefix::Matches(const Ip6::Prefix &aPrefix, RoutePreference aPreference) const
+{
+    return (mPreference == aPreference) && (mPrefix == aPrefix);
+}
+
 //---------------------------------------------------------------------------------------------------------------------
 // FavoredOmrPrefix
 
@@ -269,16 +284,14 @@
 
 void FavoredOmrPrefix::SetFrom(const NetworkData::OnMeshPrefixConfig &aOnMeshPrefixConfig)
 {
-    mPrefix         = aOnMeshPrefixConfig.GetPrefix();
-    mPreference     = aOnMeshPrefixConfig.GetPreference();
-    mIsDomainPrefix = aOnMeshPrefixConfig.mDp;
+    mPrefix     = aOnMeshPrefixConfig.GetPrefix();
+    mPreference = aOnMeshPrefixConfig.GetPreference();
 }
 
 void FavoredOmrPrefix::SetFrom(const OmrPrefix &aOmrPrefix)
 {
-    mPrefix         = aOmrPrefix.GetPrefix();
-    mPreference     = aOmrPrefix.GetPreference();
-    mIsDomainPrefix = aOmrPrefix.IsDomainPrefix();
+    mPrefix     = aOmrPrefix.GetPrefix();
+    mPreference = aOmrPrefix.GetPreference();
 }
 
 bool FavoredOmrPrefix::IsFavoredOver(const NetworkData::OnMeshPrefixConfig &aOmrPrefixConfig) const
diff --git a/src/core/border_router/br_types.hpp b/src/core/border_router/br_types.hpp
index 10a0481..45a9921 100644
--- a/src/core/border_router/br_types.hpp
+++ b/src/core/border_router/br_types.hpp
@@ -227,9 +227,11 @@
     uint32_t GetPreferredLifetime(void) const { return mPreferredLifetime; }
 
     /**
-     * Clears (sets to zero) the preferred lifetime of the prefix.
+     * Deprecates the prefix.
+     *
+     * Sets the preferred lifetime to zero and bounds the remaining valid lifetime to at most two hours from now.
      */
-    void ClearPreferredLifetime(void) { mPreferredLifetime = 0; }
+    void Deprecate(void);
 
     /**
      * Indicates whether the on-link prefix is deprecated.
@@ -279,7 +281,8 @@
     bool IsFavoredOver(const Ip6::Prefix &aPrefix) const;
 
 private:
-    static constexpr uint32_t kFavoredMinPreferredLifetime = 1800; // In sec.
+    static constexpr uint32_t kTwoHoursLifetime            = 2 * Time::kOneHourInSec;
+    static constexpr uint32_t kFavoredMinPreferredLifetime = 30 * Time::kOneMinuteInSec;
     static constexpr uint8_t  kExpectedFavoredPrefixLength = 64;
 
     uint32_t mPreferredLifetime;
@@ -566,26 +569,22 @@
      */
     RoutePreference GetPreference(void) const { return mPreference; }
 
-    /**
-     * Indicates whether the OMR prefix is a domain prefix.
-     *
-     * @retval TRUE   If the OMR prefix is a domain prefix.
-     * @retval FALSE  If the OMR prefix is not a domain prefix.
-     */
-    bool IsDomainPrefix(void) const { return mIsDomainPrefix; }
+    void SetPrefix(const Ip6::Prefix &aPrefix, RoutePreference aPreference);
 
     /**
-     * Sets the OMR prefix and its preference.
+     * Indicates whether the OMR prefix matches a given prefix and preference.
      *
-     * @param[in] aPrefix      The IPv6 prefix to set.
-     * @param[in] aPreference  The preference to set.
+     * @param[in] aPrefix      The prefix to compare with.
+     * @param[in] aPreference  The preference to compare with.
+     *
+     * @retval TRUE   The OMR prefix matches the given prefix and preference.
+     * @retval FALSE  The OMR prefix does not match the given prefix and preference.
      */
-    void SetPrefix(const Ip6::Prefix &aPrefix, RoutePreference aPreference);
+    bool Matches(const Ip6::Prefix &aPrefix, RoutePreference aPreference) const;
 
 protected:
     Ip6::Prefix     mPrefix;
     RoutePreference mPreference;
-    bool            mIsDomainPrefix;
 };
 
 //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/src/core/border_router/dhcp6_pd_client.cpp b/src/core/border_router/dhcp6_pd_client.cpp
index 9cf2fc5..9121de6 100644
--- a/src/core/border_router/dhcp6_pd_client.cpp
+++ b/src/core/border_router/dhcp6_pd_client.cpp
@@ -51,7 +51,6 @@
     , mMaxSolicitTimeout(kMaxSolicitTimeout)
     , mTimer(aInstance)
 {
-    mServerAddress.Clear();
 }
 
 void Dhcp6PdClient::Start(void)
@@ -120,7 +119,7 @@
     case kStateToSolicit:
         ClearServerDuid();
         ClearPdPrefix();
-        mTimer.Start(Random::NonCrypto::GetUint32InRange(0, kMaxDelayFirstSolicit));
+        mTimer.Start(Random::NonCrypto::GenerateUpToExcluding(kMaxDelayFirstSolicit));
         break;
 
     case kStateSoliciting:
@@ -268,18 +267,9 @@
 
     SuccessOrExit(AppendIaPdOption(*message));
 
-    LogInfo("Sending %s %s%s", MsgTypeToString(msgType),
-            mServerAddress.IsUnspecified() ? "(multicast)" : "(unicast) to:",
-            mServerAddress.IsUnspecified() ? "" : mServerAddress.ToString().AsCString());
+    LogInfo("Sending %s (multicast)", MsgTypeToString(msgType));
 
-    if (!mServerAddress.IsUnspecified())
-    {
-        dstAddr = mServerAddress;
-    }
-    else
-    {
-        GetAllRelayAgentsAndServersMulticastAddress(dstAddr);
-    }
+    GetAllRelayAgentsAndServersMulticastAddress(dstAddr);
 
     Get<InfraIf>().SendDhcp6(*message.Release(), dstAddr);
 
@@ -475,8 +465,7 @@
 
     offsetRange.InitFromMessageOffsetToEnd(aMessage);
 
-    SuccessOrExit(error = aMessage.Read(offsetRange, aHeader));
-    offsetRange.AdvanceOffset(sizeof(Header));
+    SuccessOrExit(error = aMessage.ReadAndAdvance(offsetRange, aHeader));
 
     aMessage.SetOffset(offsetRange.GetOffset());
 
@@ -536,7 +525,7 @@
 
     mPdPrefix = *favoredPdPrefix;
 
-    SaveServerDuidAndAddress(aMessage);
+    SaveServerDuid(aMessage);
 
     if (!mRetxTracker.IsFirstAttempt() || (preference == 255))
     {
@@ -563,20 +552,6 @@
         ExitNow();
     }
 
-    if (status == StatusCodeOption::kUseMulticast)
-    {
-        // Per RFC 8514 Section 18.2.10, if the client receives a Reply
-        // message with a status code of UseMulticast, the client
-        // records the receipt of the message and sends subsequent
-        // messages to the server using multicast. The client re-sends the
-        // original message using multicast.
-
-        VerifyOrExit(!mServerAddress.IsUnspecified());
-        mServerAddress.Clear();
-        SendMessage();
-        ExitNow();
-    }
-
     if (mState == kStateReleasing)
     {
         // Per RFC 8415 Section 18.2.10.2: When the client receives a
@@ -600,7 +575,7 @@
             ExitNow();
         }
 
-        SaveServerDuidAndAddress(aMessage);
+        SaveServerDuid(aMessage);
         CommitPdPrefix(*favoredPdPrefix);
 
         ExitNow();
@@ -614,7 +589,7 @@
 
         if (matchedPdPrefix != nullptr)
         {
-            SaveServerDuidAndAddress(aMessage);
+            SaveServerDuid(aMessage);
             CommitPdPrefix(*matchedPdPrefix);
 
             if (mPdPrefix.mPreferredLifetime >= kMinPreferredLifetime)
@@ -632,7 +607,7 @@
 
         if (favoredPdPrefix != nullptr)
         {
-            SaveServerDuidAndAddress(aMessage);
+            SaveServerDuid(aMessage);
             CommitPdPrefix(*favoredPdPrefix);
             ExitNow();
         }
@@ -686,32 +661,20 @@
     return favoredPdPrefix;
 }
 
-void Dhcp6PdClient::SaveServerDuidAndAddress(const Message &aMessage)
+void Dhcp6PdClient::SaveServerDuid(const Message &aMessage)
 {
-    // Reads the server DUID and Server Unicast option from the given
-    // message and saves them. The message is assumed to have already
-    // been validated to contain a Server ID option.
+    // Reads the server DUID from the given message and saves it.
+    // The message is assumed to have already been validated to contain
+    // a Server ID option.
 
-    OffsetRange  serverDuidOffsetRange;
-    Ip6::Address serverAddress;
+    OffsetRange serverDuidOffsetRange;
 
     SuccessOrAssert(ServerIdOption::ReadDuid(aMessage, serverDuidOffsetRange));
     mServerDuid.SetLength(static_cast<uint8_t>(serverDuidOffsetRange.GetLength()));
     aMessage.ReadBytes(serverDuidOffsetRange, mServerDuid.GetArrayBuffer());
-
-    ProcessServerUnicastOption(aMessage, serverAddress);
-
-    if (!serverAddress.IsUnspecified())
-    {
-        mServerAddress = serverAddress;
-    }
 }
 
-void Dhcp6PdClient::ClearServerDuid(void)
-{
-    mServerDuid.Clear();
-    mServerAddress.Clear();
-}
+void Dhcp6PdClient::ClearServerDuid(void) { mServerDuid.Clear(); }
 
 void Dhcp6PdClient::ClearPdPrefix(void)
 {
@@ -971,28 +934,6 @@
     return shouldSkip;
 }
 
-void Dhcp6PdClient::ProcessServerUnicastOption(const Message &aMessage, Ip6::Address &aServerAddress) const
-{
-    // Searches the message for a `ServerUnicastOption`. If found, the
-    // server address is retrieved from it. Otherwise, `aServerAddress`
-    // is set to `::` (unspecified address).
-
-    OffsetRange         offsetRange;
-    ServerUnicastOption serverUnicastOption;
-
-    aServerAddress.Clear();
-
-    SuccessOrExit(Option::FindOption(aMessage, Option::kServerUnicast, offsetRange));
-    SuccessOrExit(aMessage.Read(offsetRange, serverUnicastOption));
-
-    aServerAddress = serverUnicastOption.GetServerAddress();
-
-    LogInfo("Processed Sever Unicast Option, serverAddr:%s", aServerAddress.ToString().AsCString());
-
-exit:
-    return;
-}
-
 void Dhcp6PdClient::ProcessPreferenceOption(const Message &aMessage, uint8_t &aPreference) const
 {
     // Searches for `PreferenceOption` in the message. If it is not
@@ -1184,11 +1125,11 @@
     switch (aJitterMode)
     {
     case kPositiveJitter:
-        randomizedValue += Random::NonCrypto::GetUint32InRange(0, jitter);
+        randomizedValue += Random::NonCrypto::GenerateUpToExcluding<uint32_t>(jitter);
         break;
 
     case kFullJitter:
-        randomizedValue += Random::NonCrypto::GetUint32InRange(0, 2 * jitter) - jitter;
+        randomizedValue += Random::NonCrypto::GenerateUpToExcluding<uint32_t>(2 * jitter) - jitter;
         break;
     }
 
diff --git a/src/core/border_router/dhcp6_pd_client.hpp b/src/core/border_router/dhcp6_pd_client.hpp
index 5ba898e..3d9ce65 100644
--- a/src/core/border_router/dhcp6_pd_client.hpp
+++ b/src/core/border_router/dhcp6_pd_client.hpp
@@ -246,9 +246,8 @@
                                   PdPrefixArray                   &aPdPrefixes,
                                   Dhcp6::StatusCodeOption::Status &aStatus) const;
     bool      ShouldSkipPrefixOption(const Dhcp6::IaPrefixOption &aPrefixOption) const;
-    void      ProcessServerUnicastOption(const Message &aMessage, Ip6::Address &aServerAddress) const;
     void      ProcessPreferenceOption(const Message &aMessage, uint8_t &aPreference) const;
-    void      SaveServerDuidAndAddress(const Message &aMessage);
+    void      SaveServerDuid(const Message &aMessage);
     void      ClearServerDuid(void);
     void      ClearPdPrefix(void);
     void      CommitPdPrefix(const PdPrefix &aPdPrefix);
@@ -259,14 +258,13 @@
 
     using DelayTimer = TimerMilliIn<Dhcp6PdClient, &Dhcp6PdClient::HandleTimer>;
 
-    State        mState;
-    bool         mPdPrefixCommited;
-    RetxTracker  mRetxTracker;
-    uint32_t     mMaxSolicitTimeout;
-    PdPrefix     mPdPrefix;
-    ServerDuid   mServerDuid;
-    Ip6::Address mServerAddress;
-    DelayTimer   mTimer;
+    State       mState;
+    bool        mPdPrefixCommited;
+    RetxTracker mRetxTracker;
+    uint32_t    mMaxSolicitTimeout;
+    PdPrefix    mPdPrefix;
+    ServerDuid  mServerDuid;
+    DelayTimer  mTimer;
 };
 
 } // namespace BorderRouter
diff --git a/src/core/border_router/infra_if.cpp b/src/core/border_router/infra_if.cpp
index ae7d717..a0b9984 100644
--- a/src/core/border_router/infra_if.cpp
+++ b/src/core/border_router/infra_if.cpp
@@ -107,25 +107,25 @@
 
 void InfraIf::HandledReceived(uint32_t aIfIndex, const Ip6::Address &aSource, const Icmp6Packet &aPacket)
 {
-    Error                    error = kErrorNone;
-    const Ip6::Icmp::Header *icmp6Header;
+    Error                   error = kErrorNone;
+    const Ip6::Icmp6Header *icmp6Header;
 
     VerifyOrExit(mInitialized && mIsRunning, error = kErrorInvalidState);
     VerifyOrExit(aIfIndex == mIfIndex, error = kErrorDrop);
     VerifyOrExit(aPacket.GetBytes() != nullptr, error = kErrorInvalidArgs);
-    VerifyOrExit(aPacket.GetLength() >= sizeof(Ip6::Icmp::Header), error = kErrorParse);
+    VerifyOrExit(aPacket.GetLength() >= sizeof(Ip6::Icmp6Header), error = kErrorParse);
 
-    icmp6Header = reinterpret_cast<const Ip6::Icmp::Header *>(aPacket.GetBytes());
+    icmp6Header = reinterpret_cast<const Ip6::Icmp6Header *>(aPacket.GetBytes());
 
     switch (icmp6Header->GetType())
     {
-    case Ip6::Icmp::Header::kTypeRouterAdvert:
+    case Ip6::Icmp6Header::kTypeRouterAdvert:
         Get<RxRaTracker>().HandleRouterAdvertisement(aPacket, aSource);
         break;
-    case Ip6::Icmp::Header::kTypeNeighborAdvert:
+    case Ip6::Icmp6Header::kTypeNeighborAdvert:
         Get<RxRaTracker>().HandleNeighborAdvertisement(aPacket);
         break;
-    case Ip6::Icmp::Header::kTypeRouterSolicit:
+    case Ip6::Icmp6Header::kTypeRouterSolicit:
         Get<RoutingManager>().HandleRouterSolicit(aPacket, aSource);
         break;
     default:
@@ -237,6 +237,71 @@
 }
 
 //---------------------------------------------------------------------------------------------------------------------
+// InfraIf::LinkLayerAddress
+
+Error InfraIf::LinkLayerAddress::ConvertToIid(Ip6::InterfaceIdentifier &aIid) const
+{
+    Error           error = kErrorNone;
+    Mac::ExtAddress extAddress;
+
+    switch (mLength)
+    {
+    case 5:
+        // EUI-40 - [AA BB] + [FF FF FE] + [CC DD EE]
+        extAddress.m8[0] = mAddress[0];
+        extAddress.m8[1] = mAddress[1];
+        extAddress.m8[2] = 0xff;
+        extAddress.m8[3] = 0xff;
+        extAddress.m8[4] = 0xfe;
+        extAddress.m8[5] = mAddress[2];
+        extAddress.m8[6] = mAddress[3];
+        extAddress.m8[7] = mAddress[4];
+        break;
+
+    case 6:
+        // EUI-48 - [AA BB CC] + [FF FE] + [DD EE FF]
+        extAddress.m8[0] = mAddress[0];
+        extAddress.m8[1] = mAddress[1];
+        extAddress.m8[2] = mAddress[2];
+        extAddress.m8[3] = 0xff;
+        extAddress.m8[4] = 0xfe;
+        extAddress.m8[5] = mAddress[3];
+        extAddress.m8[6] = mAddress[4];
+        extAddress.m8[7] = mAddress[5];
+        break;
+
+    case 8:
+        extAddress.Set(mAddress);
+        break;
+
+    default:
+        ExitNow(error = kErrorNotCapable);
+    }
+
+    aIid.InitFromExtAddress(extAddress);
+
+exit:
+    return error;
+}
+
+InfraIf::LinkLayerAddress::InfoString InfraIf::LinkLayerAddress::ToString(void) const
+{
+    InfoString string;
+
+    for (uint8_t i = 0; i < GetLength(); i++)
+    {
+        if (i > 0)
+        {
+            string.Append(":");
+        }
+
+        string.Append("%02x", mAddress[i]);
+    }
+
+    return string;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
 
 extern "C" void otPlatInfraIfRecvIcmp6Nd(otInstance         *aInstance,
                                          uint32_t            aInfraIfIndex,
diff --git a/src/core/border_router/infra_if.hpp b/src/core/border_router/infra_if.hpp
index 0b4dd71..6bebd1c 100644
--- a/src/core/border_router/infra_if.hpp
+++ b/src/core/border_router/infra_if.hpp
@@ -40,6 +40,7 @@
 
 #include <openthread/platform/infra_if.h>
 
+#include "common/as_core_type.hpp"
 #include "common/data.hpp"
 #include "common/error.hpp"
 #include "common/locator.hpp"
@@ -87,9 +88,56 @@
 public:
     static constexpr uint16_t kInfoStringSize = 20; ///< Max chars for the info string (`ToString()`).
 
-    typedef String<kInfoStringSize>       InfoString;       ///< String type returned from `ToString()`.
-    typedef Data<kWithUint16Length>       Icmp6Packet;      ///< An IMCPv6 packet (data containing the IP payload)
-    typedef otPlatInfraIfLinkLayerAddress LinkLayerAddress; ///< A link-layer address
+    typedef String<kInfoStringSize> InfoString;  ///< String type returned from `ToString()`.
+    typedef Data<kWithUint16Length> Icmp6Packet; ///< An IMCPv6 packet (data containing the IP payload)
+
+    /**
+     * Represents a link-layer address.
+     */
+    class LinkLayerAddress : public otPlatInfraIfLinkLayerAddress, public Clearable<LinkLayerAddress>
+    {
+    public:
+        static constexpr uint8_t kMaxLength = OT_PLAT_INFRA_IF_MAX_LINK_LAYER_ADDR_LENGTH; ///< Max length
+
+        static constexpr uint16_t kInfoStringSize = 50; ///< `InfoString` size
+
+        typedef String<kInfoStringSize> InfoString; //< String type returned from `ToString()`.
+
+        /**
+         * Gets the link-layer address's length (number of bytes).
+         *
+         * @return The link-layer address's length (in bytes).
+         */
+        uint8_t GetLength(void) const { return mLength; }
+
+        /**
+         * Gets the address bytes (as a pointer to a byte array).
+         *
+         * @returns A pointer to a byte array containing the link layer address
+         */
+        const uint8_t *GetBytes(void) const { return mAddress; };
+
+        /**
+         * Converts the link-layer address to an IPv6 Interface Identifier (IID).
+         *
+         * Currently supports link-layer addresses of length 5 (40-bit), 6 (48-bit), and 8 (64-bit) bytes.
+         *
+         * @param[out] aIid  A reference to an `InterfaceIdentifier` to output the converted IID.
+         *
+         * @retval kErrorNone        Successfully converted to an IID and updated @p aIid.
+         * @retval kErrorNotCapable  The link-layer address length is not supported for conversion.
+         */
+        Error ConvertToIid(Ip6::InterfaceIdentifier &aIid) const;
+
+        /**
+         * Converts the link-layer address to a human-readable string.
+         *
+         * The address is represented as a sequence of hex digits (lower case) separated by `:`, e.g., `01:ab:7c:d2:38`.
+         *
+         * @returns The string representation of the link-layer address.
+         */
+        InfoString ToString(void) const;
+    };
 
     /**
      * Initializes the `InfraIf`.
@@ -238,6 +286,9 @@
 };
 
 } // namespace BorderRouter
+
+DefineCoreType(otPlatInfraIfLinkLayerAddress, BorderRouter::InfraIf::LinkLayerAddress);
+
 } // namespace ot
 
 #endif // OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE
diff --git a/src/core/border_router/routing_manager.cpp b/src/core/border_router/routing_manager.cpp
index ac63568..aa45113 100644
--- a/src/core/border_router/routing_manager.cpp
+++ b/src/core/border_router/routing_manager.cpp
@@ -224,7 +224,7 @@
 
         SuccessOrAssert(randomUlaPrefix.GenerateRandomUla());
 
-        mBrUlaPrefix.Set(randomUlaPrefix);
+        mBrUlaPrefix.InitFrom(randomUlaPrefix);
         mBrUlaPrefix.SetSubnetId(0);
         mBrUlaPrefix.SetLength(kBrUlaPrefixLength);
 
@@ -360,6 +360,7 @@
 
     if (mIsRunning && aEvents.Contains(kEventThreadNetdataChanged))
     {
+        mOmrPrefixManager.HandleNetDataChange();
         mOnLinkPrefixManager.HandleNetDataChange();
         ScheduleRoutingPolicyEvaluation(kAfterRandomDelay);
     }
@@ -477,10 +478,9 @@
         }
         else
         {
-            String<kUptimeStringSize> string;
-
-            UptimeToString(duration, string, /* aIncludeMsec */ true);
-            LogInfo("Will evaluate routing policy in %s (%lu msec)", string.AsCString() + 3, ToUlong(duration));
+            LogInfo("Will evaluate routing policy in %s (%lu msec)",
+                    UptimeToString(duration, kUptimeStringIncludeMsec | kUptimeStringSkipHoursIfZero).AsCString(),
+                    ToUlong(duration));
         }
     }
 #endif
@@ -706,8 +706,8 @@
     messageInfo.Clear();
     messageInfo.SetPeerAddr(aIp6Header.GetSource());
 
-    IgnoreError(Get<Ip6::Icmp>().SendError(Ip6::Icmp::Header::kTypeDstUnreach,
-                                           Ip6::Icmp::Header::kCodeDstUnreachProhibited, messageInfo, aMessage));
+    IgnoreError(Get<Ip6::Icmp>().SendError(Ip6::Icmp6Header::kTypeDstUnreach,
+                                           Ip6::Icmp6Header::kCodeDstUnreachProhibited, messageInfo, aMessage));
 
 exit:
     return;
@@ -721,8 +721,11 @@
 RoutingManager::OmrPrefixManager::OmrPrefixManager(Instance &aInstance)
     : InstanceLocator(aInstance)
     , mConfig(kOmrConfigAuto)
-    , mIsLocalAddedInNetData(false)
+    , mTimer(aInstance)
+    , mLocalInNetDataState(kNotAdded)
     , mDefaultRoute(false)
+    , mIsInitialized(false)
+    , mIsRunning(false)
 {
 }
 
@@ -733,22 +736,44 @@
     mGeneratedPrefix.SetLength(kOmrPrefixLength);
 
     LogInfo("Generated local OMR prefix: %s", mGeneratedPrefix.ToString().AsCString());
+
+    mIsInitialized = true;
 }
 
 void RoutingManager::OmrPrefixManager::Start(void)
 {
-    FavoredOmrPrefix favoredPrefix;
+    VerifyOrExit(mIsInitialized && !mIsRunning);
 
-    DetermineFavoredPrefixInNetData(favoredPrefix);
-    SetFavoredPrefix(favoredPrefix);
+    mIsRunning = true;
+    Evaluate();
+
+exit:
+    return;
 }
 
 void RoutingManager::OmrPrefixManager::Stop(void)
 {
+    VerifyOrExit(mIsRunning);
+
     RemoveLocalFromNetData();
     ClearFavoredPrefix();
+
+    mIsRunning = false;
+
+exit:
+    return;
 }
 
+void RoutingManager::OmrPrefixManager::HandleNetDataChange(void) { Evaluate(); }
+
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_ENABLE
+void RoutingManager::OmrPrefixManager::HandlePdPrefixManagerEvent(void)
+{
+    // Callback from `PdPrefixManager`.
+    Evaluate();
+}
+#endif
+
 bool RoutingManager::OmrPrefixManager::IsInitialEvaluationDone(void) const
 {
     // This method indicates whether or not we are done with the
@@ -756,7 +781,7 @@
     // we have discovered a favored OMR  prefix (added by us or another BR)
     // or if `OmrConfig` is set to disable OMR prefix management.
 
-    return !mFavoredPrefix.IsEmpty() || (mConfig == kOmrConfigDisabled);
+    return mIsRunning && (!mFavoredPrefix.IsEmpty() || (mConfig == kOmrConfigDisabled));
 }
 
 RoutingManager::OmrConfig RoutingManager::OmrPrefixManager::GetConfig(Ip6::Prefix     *aPrefix,
@@ -805,7 +830,7 @@
     mConfig       = aConfig;
     mCustomPrefix = customPrefix;
 
-    Get<RoutingManager>().ScheduleRoutingPolicyEvaluation(kImmediately);
+    Evaluate();
 
 exit:
     return error;
@@ -857,8 +882,13 @@
 
 void RoutingManager::OmrPrefixManager::UpdateLocalPrefix(void)
 {
-    // Determine the local prefix and remove any outdated previous
-    // local prefix which may have been added in the Network Data.
+    // Determine the local prefix, its origin and remove any outdated
+    // previous local prefix which may have been added in the Network
+    // Data.
+
+    const Ip6::Prefix *prefix = nullptr;
+    RoutePreference    preference;
+    PrefixOrigin       origin;
 
     switch (mConfig)
     {
@@ -866,58 +896,60 @@
 #if OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_ENABLE
         if (Get<RoutingManager>().mPdPrefixManager.HasPrefix() && !Get<RoutingManager>().mPdPrefixManager.HasConflict())
         {
-            if (mLocalPrefix.GetPrefix() != Get<RoutingManager>().mPdPrefixManager.GetPrefix())
-            {
-                RemoveLocalFromNetData();
-                mLocalPrefix.SetPrefix(Get<RoutingManager>().mPdPrefixManager.GetPrefix(),
-                                       PdPrefixManager::kPdRoutePreference);
-                LogInfo("Setting local OMR prefix to PD prefix: %s", mLocalPrefix.GetPrefix().ToString().AsCString());
-            }
+            prefix     = &Get<RoutingManager>().mPdPrefixManager.GetPrefix();
+            preference = PdPrefixManager::kPdRoutePreference;
+            origin     = kDhcp6Pd;
+            break;
         }
-        else
 #endif
-
-            if (mLocalPrefix.GetPrefix() != mGeneratedPrefix)
-        {
-            RemoveLocalFromNetData();
-            mLocalPrefix.SetPrefix(mGeneratedPrefix, RoutePreference::kRoutePreferenceLow);
-            LogInfo("Setting local OMR prefix to generated prefix: %s",
-                    mLocalPrefix.GetPrefix().ToString().AsCString());
-        }
-
+        prefix     = &mGeneratedPrefix;
+        preference = RoutePreference::kRoutePreferenceLow;
+        origin     = kSelfGenerated;
         break;
 
     case kOmrConfigCustom:
-        if (mLocalPrefix != mCustomPrefix)
-        {
-            RemoveLocalFromNetData();
-            mLocalPrefix = mCustomPrefix;
-            LogInfo("Setting local OMR prefix to custom prefix: %s", mLocalPrefix.GetPrefix().ToString().AsCString());
-        }
-
+        prefix     = &mCustomPrefix.GetPrefix();
+        preference = mCustomPrefix.GetPreference();
+        origin     = kCustom;
         break;
 
     case kOmrConfigDisabled:
-        if (!mLocalPrefix.IsEmpty())
-        {
-            RemoveLocalFromNetData();
-            mLocalPrefix.Clear();
-        }
         break;
     }
+
+    if (prefix == nullptr)
+    {
+        VerifyOrExit(!mLocalPrefix.IsEmpty());
+        RemoveLocalFromNetData();
+        mLocalPrefix.Clear();
+        LogInfo("Cleared local OMR prefix");
+        ExitNow();
+    }
+
+    VerifyOrExit(!mLocalPrefix.Matches(*prefix, preference) || (origin != mLocalPrefixOrigin));
+
+    RemoveLocalFromNetData();
+    mLocalPrefix.SetPrefix(*prefix, preference);
+    mLocalPrefixOrigin = origin;
+
+    LogInfo("Set %s", LocalToString().AsCString());
+
+exit:
+    return;
 }
 
 void RoutingManager::OmrPrefixManager::Evaluate(void)
 {
     FavoredOmrPrefix favoredPrefix;
 
-    OT_ASSERT(Get<RoutingManager>().IsRunning());
+    VerifyOrExit(mIsRunning);
 
     DetermineFavoredPrefixInNetData(favoredPrefix);
 
     UpdateLocalPrefix();
 
-    // Decide if we need to add or remove our local OMR prefix.
+    // Determine whether to add or remove our local OMR prefix in the
+    // Network Data.
 
     if (mLocalPrefix.IsEmpty())
     {
@@ -925,24 +957,73 @@
         ExitNow();
     }
 
-    if (favoredPrefix.IsEmpty() || favoredPrefix.GetPreference() < mLocalPrefix.GetPreference())
+    if (favoredPrefix.GetPrefix() == mLocalPrefix.GetPrefix())
     {
-        SuccessOrExit(AddLocalToNetData());
-        SetFavoredPrefix(mLocalPrefix);
+        // If `favoredPrefix` matches our local prefix, add it to the
+        // Network Data (if not already added). This also handles the
+        // case where a restarting BR, which was previously publishing
+        // the OMR prefix, sees its own local prefix in the restored
+        // Network Data. In such cases, we want to re-add it quickly.
+
+        AddLocalToNetData();
         ExitNow();
     }
 
-    SetFavoredPrefix(favoredPrefix);
+    if (favoredPrefix.IsEmpty())
+    {
+        if (mLocalPrefixOrigin != kSelfGenerated)
+        {
+            AddLocalToNetData();
+            ExitNow();
+        }
 
-    if (favoredPrefix.GetPrefix() == mLocalPrefix.GetPrefix())
-    {
-        IgnoreError(AddLocalToNetData());
+        // Apply a random delay before adding a self-generated ULA OMR
+        // prefix. This allows other BRs time to publish their prefixes,
+        // or provides time for the `PdPrefixManager` to be delegated a
+        // prefix.
+
+        switch (mLocalInNetDataState)
+        {
+        case kNotAdded:
+        {
+            uint32_t delay = Random::NonCrypto::GenerateInClosedRange(kMinDelayToAdd, kMaxDelayToAdd);
+
+            mLocalInNetDataState = kToAdd;
+            mTimer.Start(delay);
+
+            LogInfo("Will add %s in %lu msec to NetData", LocalToString().AsCString(), ToUlong(delay));
+            break;
+        }
+
+        case kAdded:
+        case kToAdd:
+            break;
+        }
+
+        ExitNow();
     }
-    else if (mIsLocalAddedInNetData)
+
+    if (favoredPrefix.GetPreference() < mLocalPrefix.GetPreference())
     {
-        RemoveLocalFromNetData();
+        AddLocalToNetData();
+        ExitNow();
     }
 
+    // Since there is a `favoredPrefix` different from our local prefix,
+    // we remove our local prefix if it is added or scheduled to be added.
+
+    SetFavoredPrefix(favoredPrefix);
+    RemoveLocalFromNetData();
+
+exit:
+    return;
+}
+
+void RoutingManager::OmrPrefixManager::HandleTimer(void)
+{
+    VerifyOrExit(mLocalInNetDataState == kToAdd);
+    AddLocalToNetData();
+
 exit:
     return;
 }
@@ -962,19 +1043,42 @@
     // may still be present in Network Data for a short interval due
     // to delays in registering changes with the leader.
 
-    return mIsLocalAddedInNetData && Get<NetworkData::Leader>().ContainsOmrPrefix(mLocalPrefix.GetPrefix());
+    bool shouldAdv = false;
+
+    switch (mLocalInNetDataState)
+    {
+    case kAdded:
+    case kToAdd:
+        shouldAdv = Get<NetworkData::Leader>().ContainsOmrPrefix(mLocalPrefix.GetPrefix());
+        break;
+    case kNotAdded:
+        break;
+    }
+
+    return shouldAdv;
 }
 
-Error RoutingManager::OmrPrefixManager::AddLocalToNetData(void)
+void RoutingManager::OmrPrefixManager::AddLocalToNetData(void)
 {
-    Error error = kErrorNone;
+    VerifyOrExit(mLocalInNetDataState != kAdded);
 
-    VerifyOrExit(!mIsLocalAddedInNetData);
-    SuccessOrExit(error = AddOrUpdateLocalInNetData());
-    mIsLocalAddedInNetData = true;
+    if (AddOrUpdateLocalInNetData() != kErrorNone)
+    {
+        uint32_t delay = Random::NonCrypto::AddJitter(kRetryDelay, kRetryJitter);
+
+        LogInfo("Will retry adding %s in %lu msec to NetData", LocalToString().AsCString(), ToUlong(delay));
+        mLocalInNetDataState = kToAdd;
+        mTimer.Start(delay);
+        ExitNow();
+    }
+
+    mLocalInNetDataState = kAdded;
+    mTimer.Stop();
+
+    SetFavoredPrefix(mLocalPrefix);
 
 exit:
-    return error;
+    return;
 }
 
 Error RoutingManager::OmrPrefixManager::AddOrUpdateLocalInNetData(void)
@@ -997,30 +1101,33 @@
     SuccessOrExit(error = Get<NetworkData::Local>().AddOnMeshPrefix(config));
     Get<NetworkData::Notifier>().HandleServerDataUpdated();
 
-    LogInfo("%s %s in Thread Network Data", !mIsLocalAddedInNetData ? "Added" : "Updated", LocalToString().AsCString());
+    LogInfo("%s %s in NetData", !IsLocalAddedInNetData() ? "Added" : "Updated", LocalToString().AsCString());
 
 exit:
-    LogWarnOnError(error, "%s %s in Thread Network Data", !mIsLocalAddedInNetData ? "add" : "update",
-                   LocalToString().AsCString());
+    LogWarnOnError(error, "%s %s in NetData", !IsLocalAddedInNetData() ? "add" : "update", LocalToString().AsCString());
     return error;
 }
 
 void RoutingManager::OmrPrefixManager::RemoveLocalFromNetData(void)
 {
-    Error error = kErrorNone;
+    switch (mLocalInNetDataState)
+    {
+    case kAdded:
+        IgnoreError(Get<NetworkData::Local>().RemoveOnMeshPrefix(mLocalPrefix.GetPrefix()));
+        Get<NetworkData::Notifier>().HandleServerDataUpdated();
+        LogInfo("Removed %s from NetData", LocalToString().AsCString());
+        mLocalInNetDataState = kNotAdded;
+        break;
 
-    VerifyOrExit(mIsLocalAddedInNetData);
+    case kToAdd:
+        LogInfo("Canceled adding %s to NetData", LocalToString().AsCString());
+        mTimer.Stop();
+        mLocalInNetDataState = kNotAdded;
+        break;
 
-    error = Get<NetworkData::Local>().RemoveOnMeshPrefix(mLocalPrefix.GetPrefix());
-
-    LogWarnOnError(error, "remove %s from Thread Network Data", LocalToString().AsCString());
-
-    mIsLocalAddedInNetData = false;
-    Get<NetworkData::Notifier>().HandleServerDataUpdated();
-    LogInfo("Removed %s from Thread Network Data", LocalToString().AsCString());
-
-exit:
-    return;
+    case kNotAdded:
+        break;
+    }
 }
 
 void RoutingManager::OmrPrefixManager::UpdateDefaultRouteFlag(bool aDefaultRoute)
@@ -1029,7 +1136,7 @@
 
     mDefaultRoute = aDefaultRoute;
 
-    VerifyOrExit(mIsLocalAddedInNetData);
+    VerifyOrExit(IsLocalAddedInNetData());
     IgnoreError(AddOrUpdateLocalInNetData());
 
 exit:
@@ -1040,8 +1147,11 @@
 {
     InfoString string;
 
-    string.Append("local OMR prefix %s (def-route:%s)", mLocalPrefix.GetPrefix().ToString().AsCString(),
-                  ToYesNo(mDefaultRoute));
+    string.Append("local OMR prefix %s (prf:%s, def-route:%s, origin:%s)",
+                  mLocalPrefix.GetPrefix().ToString().AsCString(),
+                  RoutePreferenceToString(mLocalPrefix.GetPreference()), ToYesNo(mDefaultRoute),
+                  PrefixOriginToString(mLocalPrefixOrigin));
+
     return string;
 }
 
@@ -1059,11 +1169,6 @@
         string.Append("%s (prf:%s", aFavoredPrefix.GetPrefix().ToString().AsCString(),
                       RoutePreferenceToString(aFavoredPrefix.GetPreference()));
 
-        if (aFavoredPrefix.IsDomainPrefix())
-        {
-            string.Append(", domain");
-        }
-
         if (aFavoredPrefix.GetPrefix() == mLocalPrefix.GetPrefix())
         {
             string.Append(", local");
@@ -1087,6 +1192,18 @@
     return kStrings[aConfig];
 }
 
+const char *RoutingManager::OmrPrefixManager::PrefixOriginToString(PrefixOrigin aOrigin)
+{
+#define PrefixOriginMapList(_)    \
+    _(kSelfGenerated, "self-gen") \
+    _(kCustom, "custom")          \
+    _(kDhcp6Pd, "dhcp6-pd")
+
+    DefineEnumStringArray(PrefixOriginMapList);
+
+    return kStrings[aOrigin];
+}
+
 //---------------------------------------------------------------------------------------------------------------------
 // OnLinkPrefixManager
 
@@ -1787,7 +1904,7 @@
 
     // (2) Favored OMR prefix.
 
-    if (!omrPrefixManager.GetFavoredPrefix().IsEmpty() && !omrPrefixManager.GetFavoredPrefix().IsDomainPrefix())
+    if (!omrPrefixManager.GetFavoredPrefix().IsEmpty())
     {
         mPrefixes.Add(omrPrefixManager.GetFavoredPrefix().GetPrefix());
     }
@@ -1806,11 +1923,6 @@
         // it, while it might still be present in the Network Data due to
         // delays in registering changes with the leader.
 
-        if (prefixConfig.mDp)
-        {
-            continue;
-        }
-
         if (IsValidOmrPrefix(prefixConfig) &&
             (prefixConfig.GetPrefix() != omrPrefixManager.GetLocalPrefix().GetPrefix()))
         {
@@ -1818,13 +1930,13 @@
         }
     }
 
-    // (4) All other on-mesh prefixes (excluding Domain Prefix).
+    // (4) All other on-mesh prefixes.
 
     iterator = NetworkData::kIteratorInit;
 
     while (Get<NetworkData::Leader>().GetNext(iterator, prefixConfig) == kErrorNone)
     {
-        if (prefixConfig.mOnMesh && !prefixConfig.mDp && !IsValidOmrPrefix(prefixConfig))
+        if (prefixConfig.mOnMesh && !IsValidOmrPrefix(prefixConfig))
         {
             mPrefixes.Add(prefixConfig.GetPrefix());
         }
@@ -2149,9 +2261,9 @@
     _(kPublishDefault, "def-route")   \
     _(kPublishUla, "ula")
 
-    DefineEnumStringArray(RoutePublisherStateMapList)
+    DefineEnumStringArray(RoutePublisherStateMapList);
 
-        return kStrings[aState];
+    return kStrings[aState];
 }
 
 #if OPENTHREAD_CONFIG_NAT64_BORDER_ROUTING_ENABLE
@@ -2496,15 +2608,14 @@
 RoutingManager::PdPrefixManager::PdPrefixManager(Instance &aInstance)
     : InstanceLocator(aInstance)
     , mState(kDhcp6PdStateDisabled)
+    , mEvents(0)
     , mOnLinkPrefixConflict(false)
     , mRoutePrefixConflict(false)
     , mNumPlatformPioProcessed(0)
     , mNumPlatformRaReceived(0)
     , mLastPlatformRaTime(0)
     , mTimer(aInstance)
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-    , mRecordHistoryTask(aInstance)
-#endif
+    , mEventTask(aInstance)
 {
 }
 
@@ -2593,11 +2704,7 @@
         break;
     }
 
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-    mRecordHistoryTask.Post();
-#endif
-
-    mStateCallback.InvokeIfSet(MapEnum(mState));
+    SignalEvent(kEventStateChanged);
 
 exit:
     return;
@@ -2644,11 +2751,7 @@
 
     mTimer.Stop();
 
-    Get<RoutingManager>().ScheduleRoutingPolicyEvaluation(kImmediately);
-
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-    mRecordHistoryTask.Post();
-#endif
+    SignalEvent(kEventPdPrefixChanged);
 
 exit:
     return;
@@ -2737,11 +2840,7 @@
         LogInfo("DHCPv6 PD prefix set to %s", mPrefix.GetPrefix().ToString().AsCString());
         CheckConflict(kPdPrefixChanged);
 
-        Get<RoutingManager>().ScheduleRoutingPolicyEvaluation(kImmediately);
-
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-        mRecordHistoryTask.Post();
-#endif
+        SignalEvent(kEventPdPrefixChanged);
     }
 
     if (HasPrefix())
@@ -2814,7 +2913,7 @@
 
     if (hadConflict != HasConflict())
     {
-        Get<RoutingManager>().ScheduleRoutingPolicyEvaluation(kImmediately);
+        SignalEvent(kEventConflictStateChanged);
     }
 
 exit:
@@ -2873,15 +2972,33 @@
     return;
 }
 
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-
-void RoutingManager::PdPrefixManager::HandleRecordHistoryTask(void)
+void RoutingManager::PdPrefixManager::SignalEvent(Event aEvent)
 {
-    Get<HistoryTracker::Local>().RecordDhcp6Pd(mState, mPrefix.GetPrefix());
+    mEvents |= aEvent;
+    mEventTask.Post();
 }
 
+void RoutingManager::PdPrefixManager::HandleEventTask(void)
+{
+    Events events = mEvents;
+
+    mEvents = 0;
+
+    Get<RoutingManager>().mOmrPrefixManager.HandlePdPrefixManagerEvent();
+
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
+    if (events & (kEventStateChanged | kEventPdPrefixChanged))
+    {
+        Get<HistoryTracker::Local>().RecordDhcp6Pd(mState, mPrefix.GetPrefix());
+    }
 #endif
 
+    if (events & kEventStateChanged)
+    {
+        mStateCallback.InvokeIfSet(MapEnum(mState));
+    }
+}
+
 bool RoutingManager::PdPrefixManager::PdPrefix::IsValidPdPrefix(void) const
 {
     // We should accept ULA prefix since it could be used by the internet infrastructure like NAT64.
diff --git a/src/core/border_router/routing_manager.hpp b/src/core/border_router/routing_manager.hpp
index b802f24..551491f 100644
--- a/src/core/border_router/routing_manager.hpp
+++ b/src/core/border_router/routing_manager.hpp
@@ -617,6 +617,8 @@
     //------------------------------------------------------------------------------------------------------------------
     // Nested types
 
+    void HandleOmrPrefixManagerTimer(void) { mOmrPrefixManager.HandleTimer(); }
+
     class OmrPrefixManager : public InstanceLocator
     {
     public:
@@ -634,31 +636,63 @@
         const Ip6::Prefix      &GetGeneratedPrefix(void) const { return mGeneratedPrefix; }
         const OmrPrefix        &GetLocalPrefix(void) const { return mLocalPrefix; }
         const FavoredOmrPrefix &GetFavoredPrefix(void) const { return mFavoredPrefix; }
+        void                    HandleTimer(void);
+        void                    HandleNetDataChange(void);
+#if OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_ENABLE
+        void HandlePdPrefixManagerEvent(void);
+#endif
 
     private:
+        // All times are in msec
+        static constexpr uint32_t kMinDelayToAdd  = 250;
+        static constexpr uint32_t kMaxDelayToAdd  = kMinDelayToAdd + 3500;
+        static constexpr uint32_t kRetryDelay     = 1500;
+        static constexpr uint16_t kRetryJitter    = 150;
         static constexpr uint16_t kInfoStringSize = 85;
 
         typedef String<kInfoStringSize> InfoString;
 
+        enum PrefixOrigin : uint8_t // of `mLocalPrefix`
+        {
+            kSelfGenerated,
+            kCustom,
+            kDhcp6Pd,
+        };
+
+        enum LocalPrefixState : uint8_t // State of `mLocalPrefix` in Network Data
+        {
+            kNotAdded,
+            kToAdd,
+            kAdded,
+        };
+
         void       SetFavoredPrefix(const OmrPrefix &aOmrPrefix);
         void       ClearFavoredPrefix(void) { SetFavoredPrefix(OmrPrefix()); }
         void       DetermineFavoredPrefixInNetData(FavoredOmrPrefix &aFavoredPrefix);
         void       UpdateLocalPrefix(void);
-        Error      AddLocalToNetData(void);
+        bool       IsLocalAddedInNetData(void) const { return (mLocalInNetDataState == kAdded); }
+        void       AddLocalToNetData(void);
         Error      AddOrUpdateLocalInNetData(void);
         void       RemoveLocalFromNetData(void);
         InfoString LocalToString(void) const;
         InfoString FavoredToString(const FavoredOmrPrefix &aFavoredPrefix) const;
 
         static const char *OmrConfigToString(OmrConfig aConfig);
+        static const char *PrefixOriginToString(PrefixOrigin aOrigin);
+
+        using DelayTimer = TimerMilliIn<RoutingManager, &RoutingManager::HandleOmrPrefixManagerTimer>;
 
         OmrConfig        mConfig;
         OmrPrefix        mLocalPrefix;
         OmrPrefix        mCustomPrefix;
         Ip6::Prefix      mGeneratedPrefix;
         FavoredOmrPrefix mFavoredPrefix;
-        bool             mIsLocalAddedInNetData;
-        bool             mDefaultRoute;
+        DelayTimer       mTimer;
+        PrefixOrigin     mLocalPrefixOrigin;
+        LocalPrefixState mLocalInNetDataState;
+        bool             mDefaultRoute : 1;
+        bool             mIsInitialized : 1;
+        bool             mIsRunning : 1;
     };
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -940,9 +974,7 @@
 #if OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_ENABLE
 
     void HandlePdPrefixManagerTimer(void) { mPdPrefixManager.HandleTimer(); }
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-    void HandlePdPrefixManagerTask(void) { mPdPrefixManager.HandleRecordHistoryTask(); }
-#endif
+    void HandlePdPrefixManagerEventTask(void) { mPdPrefixManager.HandleEventTask(); }
 
     class PdPrefixManager : public InstanceLocator
     {
@@ -961,6 +993,15 @@
             kRxRaPrefixTableChanged,
         };
 
+        enum Event : uint8_t
+        {
+            kEventStateChanged         = 1 << 0,
+            kEventPdPrefixChanged      = 1 << 1,
+            kEventConflictStateChanged = 1 << 2,
+        };
+
+        typedef uint8_t Events;
+
         explicit PdPrefixManager(Instance &aInstance);
 
         void               SetEnabled(bool aEnabled);
@@ -979,9 +1020,7 @@
         void  HandleTimer(void) { WithdrawPrefix(); }
         void  SetStateCallback(Dhcp6PdCallback aCallback, void *aContext) { mStateCallback.Set(aCallback, aContext); }
         void  Evaluate(void);
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-        void HandleRecordHistoryTask(void);
-#endif
+        void  HandleEventTask(void);
 
     private:
         class PdPrefix : public OnLinkPrefix
@@ -995,6 +1034,7 @@
 
         void UpdateState(void);
         void SetState(State aState);
+        void SignalEvent(Event aEvent);
         void EvaluateCandidatePrefix(PdPrefix &aPrefix, PdPrefix &aFavoredPrefix);
         void ApplyFavoredPrefix(const PdPrefix &aFavoredPrefix);
         void WithdrawPrefix(void);
@@ -1006,11 +1046,10 @@
 
         using PrefixTimer   = TimerMilliIn<RoutingManager, &RoutingManager::HandlePdPrefixManagerTimer>;
         using StateCallback = Callback<Dhcp6PdCallback>;
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-        using RecordHistoryTask = TaskletIn<RoutingManager, &RoutingManager::HandlePdPrefixManagerTask>;
-#endif
+        using EventTask     = TaskletIn<RoutingManager, &RoutingManager::HandlePdPrefixManagerEventTask>;
 
         State         mState;
+        Events        mEvents;
         bool          mOnLinkPrefixConflict;
         bool          mRoutePrefixConflict;
         uint32_t      mNumPlatformPioProcessed;
@@ -1018,10 +1057,8 @@
         TimeMilli     mLastPlatformRaTime;
         StateCallback mStateCallback;
         PrefixTimer   mTimer;
+        EventTask     mEventTask;
         PdPrefix      mPrefix;
-#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE
-        RecordHistoryTask mRecordHistoryTask;
-#endif
     };
 
 #endif // OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_ENABLE
diff --git a/src/core/border_router/rx_ra_tracker.cpp b/src/core/border_router/rx_ra_tracker.cpp
index cb88eeb..6a63d7a 100644
--- a/src/core/border_router/rx_ra_tracker.cpp
+++ b/src/core/border_router/rx_ra_tracker.cpp
@@ -727,7 +727,7 @@
         {
             if (entry.GetLastUpdateTime() <= aTimeThreshold)
             {
-                entry.ClearPreferredLifetime();
+                entry.Deprecate();
             }
         }
 
@@ -1096,10 +1096,7 @@
 
             for (OnLinkPrefix &entry : router.mOnLinkPrefixes)
             {
-                if (!entry.IsDeprecated())
-                {
-                    entry.ClearPreferredLifetime();
-                }
+                entry.Deprecate();
             }
 
             for (RoutePrefix &entry : router.mRoutePrefixes)
@@ -1809,7 +1806,7 @@
 
     VerifyOrExit(!IsInProgress());
 
-    delay = Random::NonCrypto::GetUint32InRange(0, kMaxStartDelay);
+    delay = Random::NonCrypto::GenerateUpToExcluding(kMaxStartDelay);
 
     LogInfo("RsSender: Starting - will send first RS in %lu msec", ToUlong(delay));
 
diff --git a/src/core/coap/coap.cpp b/src/core/coap/coap.cpp
index 737a510..e2395d8 100644
--- a/src/core/coap/coap.cpp
+++ b/src/core/coap/coap.cpp
@@ -124,7 +124,7 @@
     , mResponseCache(aInstance)
     , mResourceHandler(nullptr)
     , mTransmitter(aTransmitter)
-    , mMessageId(Random::NonCrypto::GetUint16())
+    , mMessageId(Random::NonCrypto::Generate<uint16_t>())
 #if OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE
     , mLastResponse(nullptr)
 #endif
@@ -618,7 +618,7 @@
             if (shouldObserve)
             {
                 // This is a RFC7641 notification.  The request is *not* done!
-                request.InvokeResponseHandler(&aRxMsg, kErrorNone);
+                mPendingRequests.DispatchResponse(request, kErrorNone, &aRxMsg);
 
                 request.MarkAsAcknowledged();
                 ExitNow();
@@ -648,7 +648,7 @@
 #if OPENTHREAD_CONFIG_COAP_OBSERVE_API_ENABLE
         if (shouldObserve)
         {
-            request.InvokeResponseHandler(&aRxMsg, kErrorNone);
+            mPendingRequests.DispatchResponse(request, kErrorNone, &aRxMsg);
 
             // When any Observe response is seen, consider a NON observe
             // request "acknowledged" at this point. This will keep the
@@ -669,7 +669,7 @@
 
         if (request.HasResponseHandler() && request.GetDestinationAddress().IsMulticast())
         {
-            request.InvokeResponseHandler(&aRxMsg, kErrorNone);
+            mPendingRequests.DispatchResponse(request, kErrorNone, &aRxMsg);
         }
         else
         {
@@ -978,10 +978,18 @@
             case 2:
                 if (resource.mTransmitHook != nullptr)
                 {
-                    if ((error = ProcessBlock2Request(aRxMsg, resource)) != kErrorNone)
+                    switch (ProcessBlock2Request(aRxMsg, resource))
                     {
+                    case kErrorNone:
+                        break;
+                    case kErrorNoFrameReceived:
+                        IgnoreError(SendResponse(kCodeRequestIncomplete, aRxMsg));
+                        error = kErrorDrop;
+                        break;
+                    default:
                         IgnoreError(SendResponse(kCodeInternalError, aRxMsg));
                         error = kErrorDrop;
+                        break;
                     }
                 }
                 break;
@@ -1268,6 +1276,8 @@
         ExitNow();
     }
 
+    VerifyOrExit(mLastResponse != nullptr, error = kErrorNoFrameReceived);
+
     VerifyOrExit((response = NewMessage()) != nullptr, error = kErrorNoBufs);
 
     SuccessOrExit(error = response->Init(kTypeAck, kCodeContent, aRxMsg.GetMessageId()));
@@ -1525,6 +1535,7 @@
 
 CoapBase::PendingRequests::PendingRequests(Instance &aInstance, CoapBase &aCoapBase)
     : mCoapBase(aCoapBase)
+    , mDispatchingRequest(nullptr)
     , mTimer(aInstance, HandleTimer, this)
 {
 }
@@ -1622,13 +1633,48 @@
 {
     VerifyOrExit(aRequest.HasMessage());
 
-    Remove(aRequest);
-    aRequest.InvokeResponseHandler(aResponse, aResult);
+    mRequestMessages.Dequeue(*aRequest.mMessage);
+
+    DispatchResponse(aRequest, aResult, aResponse);
+
+    aRequest.mMessage->Free();
+    aRequest.Clear();
 
 exit:
     return;
 }
 
+void CoapBase::PendingRequests::DispatchResponse(Request &aRequest, Error aResult)
+{
+    DispatchResponse(aRequest, aResult, /* aResponse */ nullptr);
+}
+
+void CoapBase::PendingRequests::DispatchResponse(Request &aRequest, Error aResult, Msg *aResponse)
+{
+    const Request *prev = mDispatchingRequest;
+
+    mDispatchingRequest = &aRequest;
+    aRequest.GetCallbacks().InvokeResponseHandler(aResponse, aResult);
+    mDispatchingRequest = prev;
+}
+
+Error CoapBase::PendingRequests::GetDispatchingRequest(OwnedPtr<Message> &aMessage) const
+{
+    Error error = kErrorNotFound;
+
+    VerifyOrExit(mDispatchingRequest != nullptr);
+    VerifyOrExit(mDispatchingRequest->IsConfirmable());
+    VerifyOrExit(mDispatchingRequest->HasMessage());
+
+    aMessage.Reset(mCoapBase.CloneMessageWithout<Request::Metadata>(mDispatchingRequest->GetMessage()));
+    VerifyOrExit(aMessage != nullptr, error = kErrorNoBufs);
+
+    error = kErrorNone;
+
+exit:
+    return error;
+}
+
 void CoapBase::PendingRequests::AbortAllRequests(void)
 {
     IgnoreError(AbortAllMatching(Matcher()));
@@ -1676,7 +1722,7 @@
         Request request;
 
         request.InitFrom(message);
-        request.InvokeResponseHandler(/* aResponse */ nullptr, aResult);
+        DispatchResponse(request, aResult);
     }
 
     aQueue.DequeueAndFreeAll();
@@ -1999,8 +2045,8 @@
 
 uint32_t TxParameters::CalculateInitialRetransmissionTimeout(void) const
 {
-    return Random::NonCrypto::GetUint32InRange(
-        mAckTimeout, mAckTimeout * mAckRandomFactorNumerator / mAckRandomFactorDenominator + 1);
+    return Random::NonCrypto::GenerateInClosedRange<uint32_t>(mAckTimeout, mAckTimeout * mAckRandomFactorNumerator /
+                                                                               mAckRandomFactorDenominator);
 }
 
 uint32_t TxParameters::CalculateExchangeLifetime(void) const
diff --git a/src/core/coap/coap.hpp b/src/core/coap/coap.hpp
index dedf5c5..5424398 100644
--- a/src/core/coap/coap.hpp
+++ b/src/core/coap/coap.hpp
@@ -572,6 +572,23 @@
     Error SendMessage(OwnedPtr<Message> aMessage, const Ip6::MessageInfo &aMessageInfo);
 
     /**
+     * Gets a copy of the request message currently being dispatched (`ResponseHandler` callback is invoked).
+     *
+     * This method can only be used from a `ResponseHandler` callback. It returns a copy of the original request
+     * message if the request was confirmable.
+     *
+     * @param[out] aMessage    A reference to an `OwnedPtr` to return a copy of original request message.
+     *
+     * @retval kErrorNone      Successfully retrieved and cloned the request message.
+     * @retval kErrorNotFound  Not currently dispatching a confirmable request.
+     * @retval kErrorNoBufs    Failed to allocate a message for the clone.
+     */
+    Error GetDispatchingRequest(OwnedPtr<Message> &aMessage) const
+    {
+        return mPendingRequests.GetDispatchingRequest(aMessage);
+    }
+
+    /**
      * Sends an empty CoAP message (using `kCodeEmpty` Code 0.00).
      *
      * An empty CoAP message has no options and no payload (only a 4-byte header). This is typically used for sending
@@ -824,10 +841,6 @@
         bool ShouldRetransmit(void) const;
         void UpdateRetxCounterAndTimeout(TimeMilli aNow);
         bool HasResponseHandler(void) const { return GetCallbacks().HasResponseHandler(); }
-        void InvokeResponseHandler(Msg *aMsg, Error aResult) const
-        {
-            GetCallbacks().InvokeResponseHandler(aMsg, aResult);
-        }
 
         const Message       &GetMessage(void) const { return *mMessage; }
         const Ip6::Address  &GetSourceAddress(void) const { return mMetadata.mSourceAddress; }
@@ -896,6 +909,9 @@
         void  AbortAllRequests(void);
         void  AbortRequestsMatching(const Ip6::Address &aAddress);
         Error AbortRequestsMatching(ResponseHandler aHandler, void *aContext);
+        void  DispatchResponse(Request &aRequest, Error aResult, Msg *aResponse);
+        void  DispatchResponse(Request &aRequest, Error aResult);
+        Error GetDispatchingRequest(OwnedPtr<Message> &aMessage) const;
         void  GetInfo(MessageQueue::Info &aInfo) const { mRequestMessages.GetInfo(aInfo); }
 
     private:
@@ -935,6 +951,7 @@
 
         CoapBase         &mCoapBase;
         MessageQueue      mRequestMessages;
+        const Request    *mDispatchingRequest;
         TimerMilliContext mTimer;
     };
 
diff --git a/src/core/common/array.hpp b/src/core/common/array.hpp
index d307a60..eb6da87 100644
--- a/src/core/common/array.hpp
+++ b/src/core/common/array.hpp
@@ -98,6 +98,38 @@
 }
 
 /**
+ * Indicates whether a given array contains a match to a given item.
+ *
+ * The template arguments are expected to be deduced by the compiler allowing  callers to simply use
+ * `DoesArrayContain(aArray, aItem)`.
+ *
+ * @tparam  Type          The array element type.
+ * @tparam  kArrayLength  The array length.
+ *
+ * @param[in] aArray  A reference to the array to search in.
+ * @param[in] aItem   The item to search for.
+ *
+ * @retval TRUE   The array contains @p aItem.
+ * @retval FALSE  The array does not contain @p aItem.
+ */
+template <typename Type, uint16_t kArrayLength>
+inline bool DoesArrayContain(const Type (&aArray)[kArrayLength], const Type &aItem)
+{
+    bool contains = false;
+
+    for (const Type &entry : aArray)
+    {
+        if (entry == aItem)
+        {
+            contains = true;
+            break;
+        }
+    }
+
+    return contains;
+}
+
+/**
  * Represents an array of elements with a fixed max size.
  *
  * @tparam Type        The array element type.
@@ -460,6 +492,34 @@
     }
 
     /**
+     * Counts the number of elements in the array matching a given indicator.
+     *
+     * The template type `Indicator` specifies the type of @p aIndicator object which is used to match against elements
+     * in the array. To check that an element matches the given indicator, the `Matches()` method is invoked on each
+     * `Type` element in the array. The `Matches()` method should be provided by `Type` class accordingly:
+     *
+     *     bool Type::Matches(const Indicator &aIndicator) const
+     *
+     * @param[in]  aIndicator  An indicator to match with elements in the array.
+     *
+     * @returns The number of elements in the array matching @p aIndicator.
+     */
+    template <typename Indicator> SizeType CountMatching(const Indicator &aIndicator) const
+    {
+        SizeType count = 0;
+
+        for (const Type &element : *this)
+        {
+            if (element.Matches(aIndicator))
+            {
+                count++;
+            }
+        }
+
+        return count;
+    }
+
+    /**
      * Removes the first element in the array matching a given indicator.
      *
      * Behaves similar to `Remove()`, i.e., the matched element (if found) is replaced with the last element
@@ -540,17 +600,17 @@
     }
 
     /**
-     * Indicates whether a given entry pointer is from the array buffer.
+     * Indicates whether a given pointer is from the array buffer.
      *
      * Does not check the current length of array and only checks that @p aEntry is pointing to an address
      * contained within underlying C array buffer.
      *
-     * @param[in] aEntry   A pointer to an entry to check.
+     * @param[in] aEntry   A pointer to  check.
      *
      * @retval TRUE  The @p aEntry is from the array.
      * @retval FALSE The @p aEntry is not from the array.
      */
-    bool IsInArrayBuffer(const Type *aEntry) const
+    bool IsInArrayBuffer(const void *aEntry) const
     {
         return (&mElements[0] <= aEntry) && (aEntry < GetArrayEnd(mElements));
     }
diff --git a/src/core/common/bit_set.cpp b/src/core/common/bit_set.cpp
new file mode 100644
index 0000000..06f244f
--- /dev/null
+++ b/src/core/common/bit_set.cpp
@@ -0,0 +1,155 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file implements utility function for a bit-set.
+ */
+
+#include "bit_set.hpp"
+
+#include "common/code_utils.hpp"
+#include "common/message.hpp"
+
+namespace ot {
+
+bool BitSetUtils::IsAllZero(const uint8_t *aMask, uint16_t aSize)
+{
+    bool allZero = true;
+
+    for (uint16_t i = 0; i < aSize; i++)
+    {
+        VerifyOrExit(aMask[i] == 0, allZero = false);
+    }
+
+exit:
+    return allZero;
+}
+
+uint16_t BitSetUtils::CountBits(const uint8_t *aMask, uint16_t aSize)
+{
+    uint16_t count = 0;
+
+    for (uint16_t i = 0; i < aSize; i++)
+    {
+        count += CountBitsInMask(aMask[i]);
+    }
+
+    return count;
+}
+
+void BitSetUtils::FlipBits(uint8_t *aTargetMask, uint16_t aSize, uint16_t aNumBits)
+{
+    for (uint16_t i = 0; i < aSize; i++)
+    {
+        aTargetMask[i] = static_cast<uint8_t>(~aTargetMask[i]);
+    }
+
+    Tidy(aTargetMask, aSize, aNumBits);
+}
+
+bool BitSetUtils::IsSubset(const uint8_t *aMask, const uint8_t *aSuperMask, uint16_t aSize)
+{
+    bool isSubset = true;
+
+    for (uint16_t i = 0; i < aSize; i++)
+    {
+        VerifyOrExit((aMask[i] & aSuperMask[i]) == aMask[i], isSubset = false);
+    }
+
+exit:
+    return isSubset;
+}
+
+void BitSetUtils::Union(uint8_t *aTargetMask, const uint8_t *aMask, uint16_t aSize)
+{
+    for (uint16_t i = 0; i < aSize; i++)
+    {
+        aTargetMask[i] |= aMask[i];
+    }
+}
+
+void BitSetUtils::Intersect(uint8_t *aTargetMask, const uint8_t *aMask, uint16_t aSize)
+{
+    for (uint16_t i = 0; i < aSize; i++)
+    {
+        aTargetMask[i] &= aMask[i];
+    }
+}
+
+void BitSetUtils::Subtract(uint8_t *aTargetMask, const uint8_t *aMask, uint16_t aSize)
+{
+    for (uint16_t i = 0; i < aSize; i++)
+    {
+        aTargetMask[i] &= static_cast<uint8_t>(~aMask[i]);
+    }
+}
+
+void BitSetUtils::Copy(uint8_t *aTargetMask, const uint8_t *aMask, uint16_t aSize, uint16_t aNumBits)
+{
+    for (uint16_t i = 0; i < aSize; i++)
+    {
+        aTargetMask[i] = aMask[i];
+    }
+
+    Tidy(aTargetMask, aSize, aNumBits);
+}
+
+void BitSetUtils::Tidy(uint8_t *aTargetMask, uint16_t aSize, uint16_t aNumBits)
+{
+    // `Tidy` clears any unused bits in the last byte of the mask.
+    //
+    // Since bits are allocated starting from the MSB of the first byte
+    // (byte 0), the unused bits will be the lowest bits of the last
+    // byte (byte `aSize - 1`). The number of unused bits is calculated
+    // as `(aSize * kBitsPerByte) - aNumBits`.
+
+    aTargetMask[aSize - 1] &= static_cast<uint8_t>(~(static_cast<uint8_t>(1 << (aSize * kBitsPerByte - aNumBits)) - 1));
+}
+
+Error BitSetUtils::AppendMask(Message &aMessage, const uint8_t *aMask, uint16_t aSize)
+{
+    return aMessage.AppendBytes(aMask, aSize);
+}
+
+Error BitSetUtils::ReadMask(const Message     &aMessage,
+                            const OffsetRange &aOffsetRange,
+                            uint8_t           *aTargetMask,
+                            uint16_t           aSize,
+                            uint16_t           aNumBits)
+{
+    Error error;
+
+    SuccessOrExit(error = aMessage.Read(aOffsetRange, aTargetMask, aSize));
+    Tidy(aTargetMask, aSize, aNumBits);
+
+exit:
+    return error;
+}
+
+} // namespace ot
diff --git a/src/core/common/bit_set.hpp b/src/core/common/bit_set.hpp
index 05472da..baf907e 100644
--- a/src/core/common/bit_set.hpp
+++ b/src/core/common/bit_set.hpp
@@ -39,6 +39,7 @@
 #include "common/bit_utils.hpp"
 #include "common/clearable.hpp"
 #include "common/equatable.hpp"
+#include "common/error.hpp"
 
 namespace ot {
 
@@ -51,13 +52,42 @@
  * @{
  */
 
+class Message;
+class OffsetRange;
+
+class BitSetUtils
+{
+protected:
+    // Common helpers used by `BitSet<>` templates
+
+    static uint8_t  BitMaskFor(uint16_t aIndex) { return (0x80 >> (aIndex & 7)); }
+    static bool     IsAllZero(const uint8_t *aMask, uint16_t aSize);
+    static uint16_t CountBits(const uint8_t *aMask, uint16_t aSize);
+    static void     FlipBits(uint8_t *aTargetMask, uint16_t aSize, uint16_t aNumBits);
+    static bool     IsSubset(const uint8_t *aMask, const uint8_t *aSuperMask, uint16_t aSize);
+    static void     Union(uint8_t *aTargetMask, const uint8_t *aMask, uint16_t aSize);
+    static void     Subtract(uint8_t *aTargetMask, const uint8_t *aMask, uint16_t aSize);
+    static void     Intersect(uint8_t *aTargetMask, const uint8_t *aMask, uint16_t aSize);
+    static void     Copy(uint8_t *aTargetMask, const uint8_t *aMask, uint16_t aSize, uint16_t aNumBits);
+    static void     Tidy(uint8_t *aTargetMask, uint16_t aSize, uint16_t aNumBits);
+    static Error    AppendMask(Message &aMessage, const uint8_t *aMask, uint16_t aSize);
+    static Error    ReadMask(const Message     &aMessage,
+                             const OffsetRange &aOffsetRange,
+                             uint8_t           *aTargetMask,
+                             uint16_t           aSize,
+                             uint16_t           aNumBits);
+};
+
 /**
  * Represents a bit-set.
  *
  * @tparam kNumBits  Specifies the number of bits.
  */
-template <uint16_t kNumBits> class BitSet : public Equatable<BitSet<kNumBits>>, public Clearable<BitSet<kNumBits>>
+template <uint16_t kNumBits>
+class BitSet : public Equatable<BitSet<kNumBits>>, public Clearable<BitSet<kNumBits>>, private BitSetUtils
 {
+    static_assert(kNumBits > 0, "kNumBits cannot be zero");
+
 public:
     /**
      * Indicates whether a given bit index is contained in the set.
@@ -109,26 +139,117 @@
      * @retval TRUE   If the set is empty.
      * @retval FALSE  If the set is not empty.
      */
-    bool IsEmpty(void) const
+    bool IsEmpty(void) const { return IsAllZero(mMask, kMaskSize); }
+
+    /**
+     * Returns the number of elements (bits set to TRUE) in the set.
+     *
+     * @returns The number of elements in the set.
+     */
+    uint16_t CountElements(void) const { return CountBits(mMask, kMaskSize); }
+
+    /**
+     * Indicates whether the set is a subset of another set.
+     *
+     * @param[in] aOther  The other set to check against.
+     *
+     * @retval TRUE   If the set is a subset of @p aOther.
+     * @retval FALSE  If the set is not a subset of @p aOther.
+     */
+    bool IsSubsetOf(const BitSet<kNumBits> &aOther) const { return IsSubset(mMask, aOther.mMask, kMaskSize); }
+
+    /**
+     * Indicates whether the set is a superset of another set.
+     *
+     * @param[in] aOther  The other set to check against.
+     *
+     * @retval TRUE   If the set is a superset of @p aOther.
+     * @retval FALSE  If the set is not a superset of @p aOther.
+     */
+    bool IsSupersetOf(const BitSet<kNumBits> &aOther) const { return aOther.IsSubsetOf(*this); }
+
+    /**
+     * Complements the set (flips all bits in the mask).
+     */
+    void Complement(void) { FlipBits(mMask, kMaskSize, kNumBits); }
+
+    /**
+     * Performs a union operation with another set.
+     *
+     * @param[in] aOther  The other set.
+     */
+    void UnionWith(const BitSet<kNumBits> &aOther) { Union(mMask, aOther.mMask, kMaskSize); }
+
+    /**
+     * Performs an intersection operation with another set.
+     *
+     * @param[in] aOther  The other set.
+     */
+    void IntersectWith(const BitSet<kNumBits> &aOther) { Intersect(mMask, aOther.mMask, kMaskSize); }
+
+    /**
+     * Performs a subtraction operation with another set.
+     *
+     * @param[in] aOther  The other set.
+     */
+    void SubtractWith(const BitSet<kNumBits> &aOther) { Subtract(mMask, aOther.mMask, kMaskSize); }
+
+    /**
+     * Gets a pointer to the byte array representing the bit mask.
+     *
+     * @returns A pointer to the byte array representing the bit mask.
+     */
+    const uint8_t *GetMaskBytes(void) const { return mMask; }
+
+    /**
+     * Gets the size of the bit mask in bytes.
+     *
+     * @returns The size of the bit mask in bytes.
+     */
+    uint16_t GetMaskSize(void) const { return kMaskSize; }
+
+    /**
+     * Gets the maximum number of bits in the set (`kNumBits` template parameter).
+     *
+     * @returns The maximum number of bits in the set.
+     */
+    uint16_t GetNumBits(void) const { return kNumBits; }
+
+    /**
+     * Sets the bit mask from a given byte array.
+     *
+     * @param[in] aMask  A pointer to the byte array to copy from. MUST contain proper size
+     */
+    void SetMask(const uint8_t *aMask) { Copy(mMask, aMask, kMaskSize, kNumBits); }
+
+    /**
+     * Appends the bit mask bytes to a message.
+     *
+     * @param[in] aMessage  The message to append to.
+     *
+     * @retval kErrorNone    Successfully appended the bit mask.
+     * @retval kErrorNoBufs  Insufficient available buffers to grow the message.
+     */
+    Error AppendTo(Message &aMessage) const { return AppendMask(aMessage, mMask, kMaskSize); }
+
+    /**
+     * Reads the bit mask bytes from a message.
+     *
+     * @param[in]  aMessage      The message to read from.
+     * @param[in]  aOffsetRange  The offset range in the message to read from.
+     *
+     * @retval kErrorNone   Successfully read the bit mask.
+     * @retval kErrorParse  Failed to read the bit mask.
+     */
+    Error ReadFrom(const Message &aMessage, const OffsetRange &aOffsetRange)
     {
-        bool isEmpty = true;
-
-        for (uint8_t byte : mMask)
-        {
-            if (byte != 0)
-            {
-                isEmpty = false;
-                break;
-            }
-        }
-
-        return isEmpty;
+        return ReadMask(aMessage, aOffsetRange, mMask, kMaskSize, kNumBits);
     }
 
 private:
-    static uint8_t BitMaskFor(uint16_t aIndex) { return (0x80 >> (aIndex & 7)); }
+    static constexpr uint16_t kMaskSize = BytesForBitSize(kNumBits);
 
-    uint8_t mMask[BytesForBitSize(kNumBits)];
+    uint8_t mMask[kMaskSize];
 };
 
 /**
diff --git a/src/core/common/bit_utils.cpp b/src/core/common/bit_utils.cpp
index 508d5a8..46d678b 100644
--- a/src/core/common/bit_utils.cpp
+++ b/src/core/common/bit_utils.cpp
@@ -38,6 +38,19 @@
 
 namespace ot {
 
+uint8_t CountBitsInMask(uint32_t aMask)
+{
+    uint8_t count = 0;
+
+    while (aMask != 0)
+    {
+        aMask &= aMask - 1;
+        count++;
+    }
+
+    return count;
+}
+
 uint16_t CountMatchingBits(const uint8_t *aFirst, const uint8_t *aSecond, uint16_t aMaxBitLength)
 {
     uint16_t remainingLen = aMaxBitLength;
@@ -79,4 +92,17 @@
     return matchedLen;
 }
 
+uint8_t DetermineMinBitSizeFor(uint32_t aValue)
+{
+    uint8_t bitSize = 0;
+
+    do
+    {
+        bitSize++;
+        aValue >>= 1;
+    } while (aValue != 0);
+
+    return bitSize;
+}
+
 } // namespace ot
diff --git a/src/core/common/bit_utils.hpp b/src/core/common/bit_utils.hpp
index 9e00808..c7323af 100644
--- a/src/core/common/bit_utils.hpp
+++ b/src/core/common/bit_utils.hpp
@@ -61,28 +61,13 @@
 #define BytesForBitSize(aBitSize) static_cast<uint8_t>(((aBitSize) + (kBitsPerByte - 1)) / kBitsPerByte)
 
 /**
- * Counts the number of `1` bits in the binary representation of a given unsigned int bit-mask value.
- *
- * @tparam UintType   The unsigned int type (MUST be `uint8_t`, `uint16_t`, `uint32_t`, or `uint64_t`).
+ * Counts the number of `1` bits in the binary representation of a given `uint32_t` bit-mask value.
  *
  * @param[in] aMask   A bit mask.
  *
  * @returns The number of `1` bits in @p aMask.
  */
-template <typename UintType> uint8_t CountBitsInMask(UintType aMask)
-{
-    static_assert(TypeTraits::IsUint<UintType>::kValue, "UintType must be an unsigned int (8, 16, 32, or 64 bit len)");
-
-    uint8_t count = 0;
-
-    while (aMask != 0)
-    {
-        aMask &= aMask - 1;
-        count++;
-    }
-
-    return count;
-}
+uint8_t CountBitsInMask(uint32_t aMask);
 
 /**
  * Counts the number of consecutive matching bits between two byte arrays.
@@ -103,6 +88,34 @@
 uint16_t CountMatchingBits(const uint8_t *aFirst, const uint8_t *aSecond, uint16_t aMaxBitLength);
 
 /**
+ * Determines the minimum number of bits required to represent a given integer value.
+ *
+ * @note For a value of `0`, this function returns `1`.
+ *
+ * @param[in] aValue  The 32-bit unsigned integer value.
+ *
+ * @returns The minimum number of bits required to represent @p aValue.
+ */
+uint8_t DetermineMinBitSizeFor(uint32_t aValue);
+
+/**
+ * Generates an unsigned integer bit-mask with a specified number of lowest bits set to 1.
+ *
+ * @tparam UintType    The value type (MUST be `uint8_t`, `uint16_t`, `uint32_t`, or `uint64_t`).
+ *
+ * @param[in] aBitSize  The number of lowest bits to set to 1.
+ *
+ * @returns The generated bit-mask.
+ */
+template <typename UintType> constexpr inline UintType MaskForBitSize(uint8_t aBitSize)
+{
+    static_assert(TypeTraits::IsUint<UintType>::kValue, "UintType must be an unsigned int (8, 16, 32, or 64 bit len)");
+
+    return (aBitSize >= BitSizeOf(UintType)) ? NumericLimits<UintType>::kMax
+                                             : static_cast<UintType>((static_cast<UintType>(1) << aBitSize) - 1);
+}
+
+/**
  * Sets the specified bit in a given integer to 1.
  *
  * @tparam UintType   The value type (MUST be `uint8_t`, `uint16_t`, `uint32_t`, or `uint64_t`).
diff --git a/src/core/common/encoding.hpp b/src/core/common/encoding.hpp
index d0e6b31..1b26b12 100644
--- a/src/core/common/encoding.hpp
+++ b/src/core/common/encoding.hpp
@@ -427,6 +427,38 @@
 
 } // namespace LittleEndian
 
+/**
+ * Represents the byte ordering (endianness) encoding.
+ */
+enum Encoding : uint8_t
+{
+    kBigEndian,    ///< Big-endian.
+    kLittleEndian, ///< Little-endian.
+};
+
+/**
+ * Swaps the byte order of a given integer value from the host representation to the specified encoding,
+ * or vice-versa.
+ *
+ * @tparam  kEncoding  The target encoding (big or little endian).
+ * @tparam  UintType   The unsigned integer type.
+ *
+ * @param[in]  aValue  The value to swap.
+ *
+ * @returns The swapped value.
+ */
+template <Encoding kEncoding, typename UintType> UintType HostSwap(UintType aValue);
+
+template <> inline uint8_t  HostSwap<kBigEndian>(uint8_t aValue) { return aValue; }
+template <> inline uint16_t HostSwap<kBigEndian>(uint16_t aValue) { return BigEndian::HostSwap16(aValue); }
+template <> inline uint32_t HostSwap<kBigEndian>(uint32_t aValue) { return BigEndian::HostSwap32(aValue); }
+template <> inline uint64_t HostSwap<kBigEndian>(uint64_t aValue) { return BigEndian::HostSwap64(aValue); }
+
+template <> inline uint8_t  HostSwap<kLittleEndian>(uint8_t aValue) { return aValue; }
+template <> inline uint16_t HostSwap<kLittleEndian>(uint16_t aValue) { return LittleEndian::HostSwap16(aValue); }
+template <> inline uint32_t HostSwap<kLittleEndian>(uint32_t aValue) { return LittleEndian::HostSwap32(aValue); }
+template <> inline uint64_t HostSwap<kLittleEndian>(uint64_t aValue) { return LittleEndian::HostSwap64(aValue); }
+
 } // namespace ot
 
 #endif // OT_CORE_COMMON_ENCODING_HPP_
diff --git a/src/core/common/frame_builder.cpp b/src/core/common/frame_builder.cpp
index 4eba998..563bc7e 100644
--- a/src/core/common/frame_builder.cpp
+++ b/src/core/common/frame_builder.cpp
@@ -38,6 +38,7 @@
 #include "common/code_utils.hpp"
 #include "common/debug.hpp"
 #include "common/encoding.hpp"
+#include "common/type_traits.hpp"
 
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
 #include "common/message.hpp"
@@ -54,19 +55,19 @@
 
 Error FrameBuilder::AppendUint8(uint8_t aUint8) { return Append<uint8_t>(aUint8); }
 
-Error FrameBuilder::AppendBigEndianUint16(uint16_t aUint16) { return Append<uint16_t>(BigEndian::HostSwap16(aUint16)); }
-
-Error FrameBuilder::AppendBigEndianUint32(uint32_t aUint32) { return Append<uint32_t>(BigEndian::HostSwap32(aUint32)); }
-
-Error FrameBuilder::AppendLittleEndianUint16(uint16_t aUint16)
+template <Encoding kEncoding, typename UintType> Error FrameBuilder::AppendUint(UintType aUint)
 {
-    return Append<uint16_t>(LittleEndian::HostSwap16(aUint16));
+    static_assert(TypeTraits::IsUint<UintType>::kValue, "UintType is not valid, it must be an unsigned int");
+
+    return Append<UintType>(HostSwap<kEncoding>(aUint));
 }
 
-Error FrameBuilder::AppendLittleEndianUint32(uint32_t aUint32)
-{
-    return Append<uint32_t>(LittleEndian::HostSwap32(aUint32));
-}
+template Error FrameBuilder::AppendUint<kBigEndian, uint16_t>(uint16_t aUint);
+template Error FrameBuilder::AppendUint<kBigEndian, uint32_t>(uint32_t aUint);
+template Error FrameBuilder::AppendUint<kBigEndian, uint64_t>(uint64_t aUint);
+template Error FrameBuilder::AppendUint<kLittleEndian, uint16_t>(uint16_t aUint);
+template Error FrameBuilder::AppendUint<kLittleEndian, uint32_t>(uint32_t aUint);
+template Error FrameBuilder::AppendUint<kLittleEndian, uint64_t>(uint64_t aUint);
 
 Error FrameBuilder::AppendBytes(const void *aBuffer, uint16_t aLength)
 {
@@ -90,7 +91,7 @@
         break;
 
     case Mac::Address::kTypeShort:
-        error = AppendLittleEndianUint16(aMacAddress.GetShort());
+        error = AppendUint<kLittleEndian>(aMacAddress.GetShort());
         break;
 
     case Mac::Address::kTypeExtended:
diff --git a/src/core/common/frame_builder.hpp b/src/core/common/frame_builder.hpp
index 2fb733c..528b6fe 100644
--- a/src/core/common/frame_builder.hpp
+++ b/src/core/common/frame_builder.hpp
@@ -36,6 +36,7 @@
 
 #include "openthread-core-config.h"
 
+#include "common/encoding.hpp"
 #include "common/error.hpp"
 #include "common/type_traits.hpp"
 #include "mac/mac_types.hpp"
@@ -119,44 +120,19 @@
     Error AppendUint8(uint8_t aUint8);
 
     /**
-     * Appends an `uint16_t` value assuming big endian encoding to the `FrameBuilder`.
+     * Appends an integer value with a specified encoding to the `FrameBuilder`.
      *
-     * @param[in] aUint16    The `uint16_t` value to append.
+     * The value is converted to the specified @p kEncoding byte order before being appended.
+     *
+     * @tparam  kEncoding  The encoding to use (big or little endian).
+     * @tparam  UintType   The unsigned integer type.
+     *
+     * @param[in] aUint      The integer value to append.
      *
      * @retval kErrorNone    Successfully appended the value.
      * @retval kErrorNoBufs  Insufficient available buffers.
      */
-    Error AppendBigEndianUint16(uint16_t aUint16);
-
-    /**
-     * Appends an `uint32_t` value assuming big endian encoding to the `FrameBuilder`.
-     *
-     * @param[in] aUint32    The `uint32_t` value to append.
-     *
-     * @retval kErrorNone    Successfully appended the value.
-     * @retval kErrorNoBufs  Insufficient available buffers.
-     */
-    Error AppendBigEndianUint32(uint32_t aUint32);
-
-    /**
-     * Appends an `uint16_t` value assuming little endian encoding to the `FrameBuilder`.
-     *
-     * @param[in] aUint16    The `uint16_t` value to append.
-     *
-     * @retval kErrorNone    Successfully appended the value.
-     * @retval kErrorNoBufs  Insufficient available buffers.
-     */
-    Error AppendLittleEndianUint16(uint16_t aUint16);
-
-    /**
-     * Appends an `uint32_t` value assuming little endian encoding to the `FrameBuilder`.
-     *
-     * @param[in] aUint32    The `uint32_t` value to append.
-     *
-     * @retval kErrorNone    Successfully appended the value.
-     * @retval kErrorNoBufs  Insufficient available buffers.
-     */
-    Error AppendLittleEndianUint32(uint32_t aUint32);
+    template <Encoding kEncoding, typename UintType> Error AppendUint(UintType aUint);
 
     /**
      * Appends bytes from a given buffer to the `FrameBuilder`.
@@ -321,6 +297,25 @@
      */
     void RemoveBytes(uint16_t aOffset, uint16_t aLength);
 
+    /**
+     * Reads a pointer to a previously appended object in the `FrameBuilder` at a given byte offset.
+     *
+     * This method does not perform any bounds checking. The caller MUST ensure the object of type `ObjectType`
+     * fits within the previously appended content.
+     *
+     * @tparam ObjectType  The object type to read.
+     *
+     * @param[in] aOffset  The byte offset where the object starts.
+     *
+     * @returns A pointer to the `ObjectType` at @p aOffset.
+     */
+    template <typename ObjectType> ObjectType *Read(uint16_t aOffset)
+    {
+        static_assert(!TypeTraits::IsPointer<ObjectType>::kValue, "ObjectType must not be a pointer");
+
+        return reinterpret_cast<ObjectType *>(mBuffer + aOffset);
+    }
+
 private:
     uint8_t *mBuffer;
     uint16_t mLength;
diff --git a/src/core/common/frame_data.cpp b/src/core/common/frame_data.cpp
index a9c3900..890d478 100644
--- a/src/core/common/frame_data.cpp
+++ b/src/core/common/frame_data.cpp
@@ -40,49 +40,25 @@
 
 Error FrameData::ReadUint8(uint8_t &aUint8) { return ReadBytes(&aUint8, sizeof(uint8_t)); }
 
-Error FrameData::ReadBigEndianUint16(uint16_t &aUint16)
+template <Encoding kEncoding, typename UintType> Error FrameData::ReadUint(UintType &aUint)
 {
     Error error;
 
-    SuccessOrExit(error = ReadBytes(&aUint16, sizeof(uint16_t)));
-    aUint16 = BigEndian::HostSwap16(aUint16);
+    static_assert(TypeTraits::IsUint<UintType>::kValue, "UintType is not valid, it must be an unsigned int");
+
+    SuccessOrExit(error = ReadBytes(&aUint, sizeof(UintType)));
+    aUint = HostSwap<kEncoding>(aUint);
 
 exit:
     return error;
 }
 
-Error FrameData::ReadBigEndianUint32(uint32_t &aUint32)
-{
-    Error error;
-
-    SuccessOrExit(error = ReadBytes(&aUint32, sizeof(uint32_t)));
-    aUint32 = BigEndian::HostSwap32(aUint32);
-
-exit:
-    return error;
-}
-
-Error FrameData::ReadLittleEndianUint16(uint16_t &aUint16)
-{
-    Error error;
-
-    SuccessOrExit(error = ReadBytes(&aUint16, sizeof(uint16_t)));
-    aUint16 = LittleEndian::HostSwap16(aUint16);
-
-exit:
-    return error;
-}
-
-Error FrameData::ReadLittleEndianUint32(uint32_t &aUint32)
-{
-    Error error;
-
-    SuccessOrExit(error = ReadBytes(&aUint32, sizeof(uint32_t)));
-    aUint32 = LittleEndian::HostSwap32(aUint32);
-
-exit:
-    return error;
-}
+template Error FrameData::ReadUint<kBigEndian, uint16_t>(uint16_t &aUint);
+template Error FrameData::ReadUint<kBigEndian, uint32_t>(uint32_t &aUint);
+template Error FrameData::ReadUint<kBigEndian, uint64_t>(uint64_t &aUint);
+template Error FrameData::ReadUint<kLittleEndian, uint16_t>(uint16_t &aUint);
+template Error FrameData::ReadUint<kLittleEndian, uint32_t>(uint32_t &aUint);
+template Error FrameData::ReadUint<kLittleEndian, uint64_t>(uint64_t &aUint);
 
 Error FrameData::ReadBytes(void *aBuffer, uint16_t aLength)
 {
diff --git a/src/core/common/frame_data.hpp b/src/core/common/frame_data.hpp
index 419ca80..bba8059 100644
--- a/src/core/common/frame_data.hpp
+++ b/src/core/common/frame_data.hpp
@@ -37,6 +37,7 @@
 #include "openthread-core-config.h"
 
 #include "common/data.hpp"
+#include "common/encoding.hpp"
 #include "common/type_traits.hpp"
 
 namespace ot {
@@ -74,52 +75,22 @@
     Error ReadUint8(uint8_t &aUint8);
 
     /**
-     * Reads an `uint16_t` value assuming big endian encoding from the `FrameData`.
+     * Reads an integer value with a specified encoding from the `FrameData`.
+     *
+     * The value is read from the frame data, converted from the specified @p kEncoding byte order to
+     * host byte order, and returned in @p aUint.
      *
      * If read successfully, the `FrameData` is updated to skip over the read content.
      *
-     * @param[out] aUint16   A reference to an `uint16_t` to return the read value.
+     * @tparam  kEncoding  The encoding of the integer in the frame data (big or little endian).
+     * @tparam  UintType   The unsigned integer type.
      *
-     * @retval kErrorNone   Successfully read `uint16_t` value and skipped over it.
-     * @retval kErrorParse  Not enough bytes remaining to read.
+     * @param[out] aUint     A reference to the integer to return the read value.
+     *
+     * @retval kErrorNone    Successfully read the value and skipped over it.
+     * @retval kErrorParse   Not enough bytes remaining to read.
      */
-    Error ReadBigEndianUint16(uint16_t &aUint16);
-
-    /**
-     * Reads an `uint32_t` value assuming big endian encoding from the `FrameData`.
-     *
-     * If read successfully, the `FrameData` is updated to skip over the read content.
-     *
-     * @param[out] aUint32   A reference to an `uint32_t` to return the read value.
-     *
-     * @retval kErrorNone   Successfully read `uint32_t` value and skipped over it.
-     * @retval kErrorParse  Not enough bytes remaining to read.
-     */
-    Error ReadBigEndianUint32(uint32_t &aUint32);
-
-    /**
-     * Reads an `uint16_t` value assuming little endian encoding from the `FrameData`.
-     *
-     * If read successfully, the `FrameData` is updated to skip over the read content.
-     *
-     * @param[out] aUint16   A reference to an `uint16_t` to return the read value.
-     *
-     * @retval kErrorNone   Successfully read `uint16_t` value and skipped over it.
-     * @retval kErrorParse  Not enough bytes remaining to read.
-     */
-    Error ReadLittleEndianUint16(uint16_t &aUint16);
-
-    /**
-     * Reads an `uint32_t` value assuming little endian encoding from the `FrameData`.
-     *
-     * If read successfully, the `FrameData` is updated to skip over the read content.
-     *
-     * @param[out] aUint32   A reference to an `uint32_t` to return the read value.
-     *
-     * @retval kErrorNone   Successfully read `uint32_t` value and skipped over it.
-     * @retval kErrorParse  Not enough bytes remaining to read.
-     */
-    Error ReadLittleEndianUint32(uint32_t &aUint32);
+    template <Encoding kEncoding, typename UintType> Error ReadUint(UintType &aUint);
 
     /**
      * Reads a given number of bytes from the `FrameData`.
diff --git a/src/core/common/heap_data.cpp b/src/core/common/heap_data.cpp
index 5060fde..28e1b2f 100644
--- a/src/core/common/heap_data.cpp
+++ b/src/core/common/heap_data.cpp
@@ -120,10 +120,9 @@
 
     VerifyOrExit(aNewLength != mData.GetLength());
 
-    Heap::Free(mData.GetBytes());
-
     if (aNewLength == 0)
     {
+        Heap::Free(mData.GetBytes());
         mData.Init(nullptr, 0);
     }
     else
@@ -131,6 +130,7 @@
         uint8_t *newBuffer = static_cast<uint8_t *>(Heap::CAlloc(aNewLength, sizeof(uint8_t)));
 
         VerifyOrExit(newBuffer != nullptr, error = kErrorNoBufs);
+        Heap::Free(mData.GetBytes());
         mData.Init(newBuffer, aNewLength);
     }
 
diff --git a/src/core/common/locator.hpp b/src/core/common/locator.hpp
index efa18b3..1ae65f7 100644
--- a/src/core/common/locator.hpp
+++ b/src/core/common/locator.hpp
@@ -48,6 +48,13 @@
 extern uint64_t gInstanceRaw[];
 #endif
 
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+extern Instance *gActiveInstance;
+inline Instance *UpdateActiveInstance(Instance *aInstance) { return gActiveInstance = aInstance; }
+#else
+inline Instance *UpdateActiveInstance(Instance *aInstance) { return aInstance; }
+#endif
+
 /**
  * @addtogroup core-locator
  *
@@ -117,7 +124,7 @@
      * @returns A reference to the parent otInstance.
      */
 #if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
-    Instance &GetInstance(void) const { return *mInstance; }
+    Instance &GetInstance(void) const { return *UpdateActiveInstance(mInstance); }
 #else
     Instance &GetInstance(void) const { return GetSingleInstance(); }
 #endif
diff --git a/src/core/common/log.cpp b/src/core/common/log.cpp
index 203a02b..09ebc57 100644
--- a/src/core/common/log.cpp
+++ b/src/core/common/log.cpp
@@ -56,10 +56,6 @@
 #error "OPENTHREAD_CONFIG_LOG_PREPEND_UPTIME requires OPENTHREAD_CONFIG_UPTIME_ENABLE"
 #endif
 
-#if OPENTHREAD_CONFIG_LOG_PREPEND_UPTIME && OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
-#error "OPENTHREAD_CONFIG_LOG_PREPEND_UPTIME is not supported under OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE"
-#endif
-
 namespace ot {
 
 #if OT_SHOULD_LOG
@@ -126,15 +122,60 @@
     static_assert(sizeof(kModuleNamePadding) == kMaxLogModuleNameLength + 1, "Padding string is not correct");
 
 #if OPENTHREAD_CONFIG_LOG_PREPEND_UPTIME
-    ot::UptimeToString(ot::Instance::Get().Get<ot::UptimeTracker>().GetUptime(), logString,
-                       /* aInlcudeMsec */ true);
-    logString.Append(" ");
+    {
+        Instance *instance;
+
+#if !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+        instance = &ot::Instance::Get();
+#elif OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+        instance = Instance::GetActiveInstance();
+        VerifyOrExit(instance != nullptr);
+#else
+#error "OPENTHREAD_CONFIG_LOG_PREPEND_UPTIME requires LOG_INSTANCE_AWARE_API_ENABLE under multi-instance"
+#endif
+        if (instance->IsInitialized())
+        {
+            ot::UptimeToString(instance->Get<ot::UptimeTracker>().GetUptime(), logString, kUptimeStringIncludeMsec);
+        }
+        else
+        {
+            ot::UptimeToString(0, logString, kUptimeStringIncludeMsec);
+        }
+        logString.Append(" ");
+    }
 #endif
 
 #if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
-    VerifyOrExit(Instance::GetLogLevel() >= aLogLevel);
+
+#if !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    if (Instance::Get().IsInitialized())
+    {
+        VerifyOrExit(Instance::Get().GetLogLevel() >= aLogLevel);
+    }
+    else
+    {
+        VerifyOrExit(OPENTHREAD_CONFIG_LOG_LEVEL_INIT >= aLogLevel);
+    }
+#elif !OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+    VerifyOrExit(Instance::GetGlobalLogLevel() >= aLogLevel);
+#else
+    {
+        Instance *instance = Instance::GetActiveInstance();
+
+        VerifyOrExit(instance != nullptr);
+        if (instance->IsInitialized())
+        {
+            VerifyOrExit(instance->GetLogLevel() >= aLogLevel);
+        }
+        else
+        {
+            VerifyOrExit(OPENTHREAD_CONFIG_LOG_LEVEL_INIT >= aLogLevel);
+        }
+    }
 #endif
 
+#endif // OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
+
 #if OPENTHREAD_CONFIG_LOG_PREPEND_LEVEL
     {
         static const char kLevelChars[] = {
@@ -166,7 +207,23 @@
     }
 
     logString.Append("%s", OPENTHREAD_CONFIG_LOG_SUFFIX);
+
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+    {
+        Instance *instance;
+
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+        instance = Instance::GetActiveInstance();
+        VerifyOrExit(instance != nullptr);
+#else
+        instance = &Instance::Get();
+#endif
+
+        otPlatLogOutput(instance, aLogLevel, logString.AsCString());
+    }
+#else
     otPlatLog(aLogLevel, OT_LOG_REGION_CORE, "%s", logString.AsCString());
+#endif
 
     ExitNow();
 
diff --git a/src/core/common/log.hpp b/src/core/common/log.hpp
index a099e67..319326a 100644
--- a/src/core/common/log.hpp
+++ b/src/core/common/log.hpp
@@ -40,6 +40,7 @@
 #include <openthread/platform/logging.h>
 #include <openthread/platform/toolchain.h>
 
+#include "common/as_core_type.hpp"
 #include "common/error.hpp"
 
 namespace ot {
@@ -111,7 +112,7 @@
 /**
  * Emits an error log message at critical log level if there is an error.
  *
- * The emitted log will use the the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
+ * The emitted log will use the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
  * be emitted only if there is an error, i.e., @p aError is not `kErrorNone`.
  *
  * @param[in] aError       The error to check and log.
@@ -134,7 +135,7 @@
 /**
  * Emits an error log message at warning log level if there is an error.
  *
- * The emitted log will use the the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
+ * The emitted log will use the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
  * be emitted only if there is an error, i.e., @p aError is not `kErrorNone`.
  *
  * @param[in] aError       The error to check and log.
@@ -158,7 +159,7 @@
 /**
  * Emits an error log message at note log level if there is an error.
  *
- * The emitted log will use the the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
+ * The emitted log will use the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
  * be emitted only if there is an error, i.e., @p aError is not `kErrorNone`.
  *
  * @param[in] aError       The error to check and log.
@@ -182,7 +183,7 @@
 /**
  * Emits an error log message at info log level if there is an error.
  *
- * The emitted log will use the the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
+ * The emitted log will use the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
  * be emitted only if there is an error, i.e., @p aError is not `kErrorNone`.
  *
  * @param[in] aError       The error to check and log.
@@ -206,7 +207,7 @@
 /**
  * Emits an error log message at debug log level if there is an error.
  *
- * The emitted log will use the the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
+ * The emitted log will use the following format "Failed to {aFormattedText} - {ErrorToString(aError)}", and will
  * be emitted only if there is an error, i.e., @p aError is not `kErrorNone`.
  *
  * @param[in] aError       The error to check and log.
@@ -460,6 +461,8 @@
  */
 Error GenerateNextHexDumpLine(HexDumpInfo &aInfo);
 
+DefineMapEnum(otLogLevel, LogLevel);
+
 } // namespace ot
 
 #endif // OT_CORE_COMMON_LOG_HPP_
diff --git a/src/core/common/message.cpp b/src/core/common/message.cpp
index 03deb47..c97e025 100644
--- a/src/core/common/message.cpp
+++ b/src/core/common/message.cpp
@@ -663,6 +663,18 @@
     return error;
 }
 
+Error Message::ReadAndAdvance(OffsetRange &aOffsetRange, void *aBuf, uint16_t aLength) const
+{
+    Error error = Read(aOffsetRange, aBuf, aLength);
+
+    if (error == kErrorNone)
+    {
+        aOffsetRange.AdvanceOffset(aLength);
+    }
+
+    return error;
+}
+
 Error Message::ReadAtAndAdvanceOffset(void *aBuf, uint16_t aLength)
 {
     Error error;
@@ -980,6 +992,30 @@
     }
 }
 
+void MessageQueue::EnqueueAllFrom(MessageQueue &aOtherQueue)
+{
+    VerifyOrExit(&aOtherQueue != this);
+
+    VerifyOrExit(aOtherQueue.GetHead() != nullptr);
+
+    if (GetHead() == nullptr)
+    {
+        SetHead(aOtherQueue.GetHead());
+    }
+    else
+    {
+        GetTail()->Next()             = aOtherQueue.GetHead();
+        aOtherQueue.GetHead()->Prev() = GetTail();
+    }
+
+    SetTail(aOtherQueue.GetTail());
+
+    aOtherQueue.Clear();
+
+exit:
+    return;
+}
+
 Message::Iterator MessageQueue::begin(void) { return Message::Iterator(GetHead()); }
 
 Message::ConstIterator MessageQueue::begin(void) const { return Message::ConstIterator(GetHead()); }
diff --git a/src/core/common/message.hpp b/src/core/common/message.hpp
index 3a729a6..67e8f00 100644
--- a/src/core/common/message.hpp
+++ b/src/core/common/message.hpp
@@ -81,6 +81,10 @@
 
 } // namespace Crypto
 
+namespace Ip6 {
+class PlatTcp;
+} // namespace Ip6
+
 /**
  * @addtogroup core-message
  *
@@ -293,6 +297,7 @@
     friend class Crypto::HmacSha256;
     friend class Crypto::Sha256;
     friend class Crypto::AesCcm;
+    friend class Ip6::PlatTcp;
     friend class MessagePool;
     friend class MessageQueue;
     friend class PriorityQueue;
@@ -476,7 +481,7 @@
      * @returns A reference to the `Instance`.
      */
 #if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
-    Instance &GetInstance(void) const { return *GetMetadata().mInstance; }
+    Instance &GetInstance(void) const { return *UpdateActiveInstance(GetMetadata().mInstance); }
 #else
     Instance &GetInstance(void) const { return GetSingleInstance(); }
 #endif
@@ -944,6 +949,37 @@
     }
 
     /**
+     * Reads a given number of bytes from the message at a given offset range and advances the offset range.
+     *
+     * @param[in,out] aOffsetRange  The offset range in the message to read from. On success, it is advanced.
+     * @param[out]    aBuf          A pointer to a data buffer to copy the read bytes into.
+     * @param[in]     aLength       Number of bytes to read.
+     *
+     * @retval kErrorNone     Requested bytes were successfully read from message. @p aOffsetRange is advanced.
+     * @retval kErrorParse    Not enough bytes remaining to read the requested @p aLength. @p aOffsetRange is unchanged.
+     */
+    Error ReadAndAdvance(OffsetRange &aOffsetRange, void *aBuf, uint16_t aLength) const;
+
+    /**
+     * Reads an object from the message at a given offset range and advances the offset range.
+     *
+     * @tparam     ObjectType   The object type to read from the message.
+     *
+     * @param[in,out] aOffsetRange  The offset range in the message to read from. On success, it is advanced.
+     * @param[out]    aObject       A reference to the object to read into.
+     *
+     * @retval kErrorNone     Object @p aObject was successfully read from message. @p aOffsetRange is advanced.
+     * @retval kErrorParse    Not enough bytes remaining in message to read the entire object. @p aOffsetRange is
+     * unchanged.
+     */
+    template <typename ObjectType> Error ReadAndAdvance(OffsetRange &aOffsetRange, ObjectType &aObject) const
+    {
+        static_assert(!TypeTraits::IsPointer<ObjectType>::kValue, "ObjectType must not be a pointer");
+
+        return ReadAndAdvance(aOffsetRange, &aObject, sizeof(ObjectType));
+    }
+
+    /**
      * Reads a given number of bytes from the message at the current message offset and advances the message offset.
      *
      * @param[out] aBuf     A pointer to a data buffer to copy the read bytes into.
@@ -1684,6 +1720,14 @@
 #endif
 
     /**
+     * Indicates whether the message queue is empty.
+     *
+     * @retval TRUE   The message queue is empty.
+     * @retval FALSE  The message queue is not empty.
+     */
+    bool IsEmpty(void) const { return GetHead() == nullptr; }
+
+    /**
      * Returns a pointer to the first message.
      *
      * @returns A pointer to the first message.
@@ -1732,6 +1776,15 @@
     void DequeueAndFreeAll(void);
 
     /**
+     * Enqueues all messages from another message queue at the end of this queue.
+     *
+     * Upon return, @p aOtherQueue will be empty.
+     *
+     * @param[in,out] aOtherQueue  The other message queue to enqueue from.
+     */
+    void EnqueueAllFrom(MessageQueue &aOtherQueue);
+
+    /**
      * Gets the information about number of messages and buffers in the queue.
      *
      * @param[out] aInfo  A reference to `Info` structure to update.
diff --git a/src/core/common/notifier.cpp b/src/core/common/notifier.cpp
index 682199e..dc74cd9 100644
--- a/src/core/common/notifier.cpp
+++ b/src/core/common/notifier.cpp
@@ -104,6 +104,9 @@
 #if (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
     Get<BackboneRouter::Leader>().HandleNotifierEvents(events);
 #endif
+#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
+    Get<BackboneRouter::Local>().HandleNotifierEvents(events);
+#endif
 #if OPENTHREAD_CONFIG_DHCP6_SERVER_ENABLE
     Get<Dhcp6::Server>().HandleNotifierEvents(events);
 #endif
@@ -137,11 +140,12 @@
 #if OPENTHREAD_CONFIG_BORDER_AGENT_ENABLE && OPENTHREAD_CONFIG_BORDER_AGENT_ADMITTER_ENABLE
     Get<MeshCoP::BorderAgent::Admitter>().HandleNotifierEvents(events);
 #endif
-#if OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
-    Get<MlrManager>().HandleNotifierEvents(events);
+#if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
+    Get<Ble::BleSecure>().HandleNotifierEvents(events);
+    Get<MeshCoP::TcatAgent>().HandleNotifierEvents(events);
 #endif
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-    Get<DuaManager>().HandleNotifierEvents(events);
+#if OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
+    Get<Mlr::Manager>().HandleNotifierEvents(events);
 #endif
 #if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
     Get<Trel::Link>().HandleNotifierEvents(events);
diff --git a/src/core/common/num_utils.hpp b/src/core/common/num_utils.hpp
index 61fad42..b40b0d3 100644
--- a/src/core/common/num_utils.hpp
+++ b/src/core/common/num_utils.hpp
@@ -272,7 +272,7 @@
  *
  * @return The result of division and rounding to the closest integer.
  */
-template <typename IntType> inline IntType DivideAndRoundToClosest(IntType aDividend, IntType aDivisor)
+template <typename IntType> inline constexpr IntType DivideAndRoundToClosest(IntType aDividend, IntType aDivisor)
 {
     return (aDividend + (aDivisor / 2)) / aDivisor;
 }
@@ -287,7 +287,7 @@
  *
  * @return The result of division and rounding up.
  */
-template <typename IntType> inline IntType DivideAndRoundUp(IntType aDividend, IntType aDivisor)
+template <typename IntType> inline constexpr IntType DivideAndRoundUp(IntType aDividend, IntType aDivisor)
 {
     return (aDividend + (aDivisor - 1)) / aDivisor;
 }
diff --git a/src/core/common/random.cpp b/src/core/common/random.cpp
index d39a06b..da77450 100644
--- a/src/core/common/random.cpp
+++ b/src/core/common/random.cpp
@@ -37,6 +37,8 @@
 
 #include "common/code_utils.hpp"
 #include "common/debug.hpp"
+#include "common/num_utils.hpp"
+#include "common/numeric_limits.hpp"
 
 namespace ot {
 namespace Random {
@@ -129,30 +131,64 @@
 
 namespace NonCrypto {
 
-uint8_t GetUint8InRange(uint8_t aMin, uint8_t aMax)
+static uint32_t GenerateInRange(uint32_t aMin, uint32_t aMax)
 {
-    OT_ASSERT(aMax > aMin);
+    uint32_t value;
+    uint32_t range;
 
-    return (aMin + (GetUint8() % (aMax - aMin)));
+    VerifyOrExit(aMin < aMax, value = aMin);
+
+    value = Generate<uint32_t>();
+
+    range = aMax - aMin;
+
+    VerifyOrExit(range < NumericLimits<uint32_t>::kMax);
+
+    value = aMin + (value % (range + 1));
+
+exit:
+    return value;
 }
 
-uint16_t GetUint16InRange(uint16_t aMin, uint16_t aMax)
+template <typename UintType> UintType GenerateUpToExcluding(UintType aMax)
 {
-    OT_ASSERT(aMax > aMin);
-    return (aMin + (GetUint16() % (aMax - aMin)));
+    return GenerateFromMinUpToExcluding<UintType>(0, aMax);
 }
 
-uint32_t GetUint32InRange(uint32_t aMin, uint32_t aMax)
+template <typename UintType> UintType GenerateFromMinUpToExcluding(UintType aMin, UintType aMax)
 {
-    OT_ASSERT(aMax > aMin);
-    return (aMin + (GetUint32() % (aMax - aMin)));
+    if (aMax > 0)
+    {
+        aMax--;
+    }
+
+    return GenerateInClosedRange<UintType>(aMin, aMax);
 }
 
+template <typename UintType> UintType GenerateInClosedRange(UintType aMin, UintType aMax)
+{
+    return static_cast<UintType>(GenerateInRange(aMin, aMax));
+}
+
+// Explicit instantiations
+
+template uint8_t  GenerateUpToExcluding<uint8_t>(uint8_t aMax);
+template uint16_t GenerateUpToExcluding<uint16_t>(uint16_t aMax);
+template uint32_t GenerateUpToExcluding<uint32_t>(uint32_t aMax);
+
+template uint8_t  GenerateFromMinUpToExcluding<uint8_t>(uint8_t aMin, uint8_t aMax);
+template uint16_t GenerateFromMinUpToExcluding<uint16_t>(uint16_t aMin, uint16_t aMax);
+template uint32_t GenerateFromMinUpToExcluding<uint32_t>(uint32_t aMin, uint32_t aMax);
+
+template uint8_t  GenerateInClosedRange<uint8_t>(uint8_t aMin, uint8_t aMax);
+template uint16_t GenerateInClosedRange<uint16_t>(uint16_t aMin, uint16_t aMax);
+template uint32_t GenerateInClosedRange<uint32_t>(uint32_t aMin, uint32_t aMax);
+
 void FillBuffer(uint8_t *aBuffer, uint16_t aSize)
 {
     while (aSize-- != 0)
     {
-        *aBuffer++ = GetUint8();
+        *aBuffer++ = Generate<uint8_t>();
     }
 }
 
@@ -163,7 +199,7 @@
     VerifyOrExit(aValue != 0);
 
     aJitter = (aJitter <= aValue) ? aJitter : static_cast<uint16_t>(aValue);
-    delay   = aValue + GetUint32InRange(0, 2 * aJitter + 1) - aJitter;
+    delay   = aValue + GenerateUpToExcluding<uint32_t>(2 * aJitter + 1) - aJitter;
 
 exit:
     return delay;
diff --git a/src/core/common/random.hpp b/src/core/common/random.hpp
index e42f892..7e27a9a 100644
--- a/src/core/common/random.hpp
+++ b/src/core/common/random.hpp
@@ -101,60 +101,53 @@
 namespace NonCrypto {
 
 /**
- * Generates and returns a random `uint32_t` value.
+ * Generates and returns a random value of a given unsigned integer type.
  *
- * @returns    A random `uint32_t` value.
+ * @tparam UintType  The unsigned integer type to generate (must be `uint8_t`, `uint16_t`, or `uint32_t`).
+ *
+ * @returns A random `UintType` value.
  */
-inline uint32_t GetUint32(void) { return Manager::NonCryptoGetUint32(); }
+template <typename UintType> inline UintType Generate(void)
+{
+    static_assert(TypeTraits::IsUint<UintType>::kValue, "UintType must be an unsigned int");
+    static_assert(!TypeTraits::IsSame<UintType, uint64_t>::kValue, "UintType cannot be `uint64_t`");
+
+    return static_cast<UintType>(Manager::NonCryptoGetUint32());
+}
 
 /**
- * Generates and returns a random byte.
+ * Generates and returns a random value of a given unsigned integer type within range `[0, aMax)`.
  *
- * @returns A random `uint8_t` value.
+ * @tparam UintType  The unsigned integer type to generate (must be `uint8_t`, `uint16_t`, or `uint32_t`).
+ *
+ * @param[in] aMax  The upper bound (exclusive). If zero, zero is returned.
+ *
+ * @returns A random `UintType` value in the range `[0, aMax)`.
  */
-inline uint8_t GetUint8(void) { return static_cast<uint8_t>(GetUint32() & 0xff); }
+template <typename UintType> UintType GenerateUpToExcluding(UintType aMax);
+/**
+ * Generates and returns a random value of a given unsigned integer type within range `[aMin, aMax)`.
+ *
+ * @tparam UintType  The unsigned integer type to generate (must be `uint8_t`, `uint16_t`, or `uint32_t`).
+ *
+ * @param[in] aMin  The lower bound (inclusive).
+ * @param[in] aMax  The upper bound (exclusive). If @p aMax <= @p aMin, @p aMin is returned.
+ *
+ * @returns A random `UintType` value in the range `[aMin, aMax)`.
+ */
+template <typename UintType> UintType GenerateFromMinUpToExcluding(UintType aMin, UintType aMax);
 
 /**
- * Generates and returns a random `uint16_t` value.
+ * Generates and returns a random value of a given unsigned integer type within a closed range `[aMin, aMax]`.
  *
- * @returns A random `uint16_t` value.
+ * @tparam UintType  The unsigned integer type to generate (must be `uint8_t`, `uint16_t`, or `uint32_t`).
+ *
+ * @param[in] aMin  The lower bound (inclusive).
+ * @param[in] aMax  The upper bound (inclusive). If @p aMax < @p aMin, @p aMin is returned.
+ *
+ * @returns A random `UintType` value in the range `[aMin, aMax]`.
  */
-inline uint16_t GetUint16(void) { return static_cast<uint16_t>(GetUint32() & 0xffff); }
-
-/**
- * Generates and returns a random `uint8_t` value within a given range `[aMin, aMax)`.
- *
- * @param[in]  aMin  A minimum value (this value can be included in returned random result).
- * @param[in]  aMax  A maximum value (this value is excluded from returned random result).
- *
- * @returns    A random `uint8_t` value in the given range (i.e., aMin <= random value < aMax).
- */
-uint8_t GetUint8InRange(uint8_t aMin, uint8_t aMax);
-
-/**
- * Generates and returns a random `uint16_t` value within a given range `[aMin, aMax)`.
- *
- * @note The returned random value can include the @p aMin value but excludes the @p aMax.
- *
- * @param[in]  aMin  A minimum value (this value can be included in returned random result).
- * @param[in]  aMax  A maximum value (this value is excluded from returned random result).
- *
- * @returns    A random `uint16_t` value in the given range (i.e., aMin <= random value < aMax).
- */
-uint16_t GetUint16InRange(uint16_t aMin, uint16_t aMax);
-
-/**
- * Generates and returns a random `uint32_t` value within a given range `[aMin, aMax)`.
- *
- * @note The returned random value can include the @p aMin value but excludes the @p aMax.
- *
- * @param[in]  aMin  A minimum value (this value can be included in returned random result).
- * @param[in]  aMax  A maximum value (this value is excluded from returned random result).
- *
- * @returns    A random `uint32_t` value in the given range (i.e., aMin <= random value < aMax).
- */
-uint32_t GetUint32InRange(uint32_t aMin, uint32_t aMax);
-
+template <typename UintType> UintType GenerateInClosedRange(UintType aMin, UintType aMax);
 /**
  * Fills a given buffer with random bytes.
  *
diff --git a/src/core/common/settings.cpp b/src/core/common/settings.cpp
index 45764fc..e072037 100644
--- a/src/core/common/settings.cpp
+++ b/src/core/common/settings.cpp
@@ -71,13 +71,6 @@
 }
 #endif
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-void SettingsBase::DadInfo::Log(Action aAction) const
-{
-    LogInfo("%s DadInfo {DadCounter:%2d}", ActionToString(aAction), GetDadCounter());
-}
-#endif
-
 #if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE
 void SettingsBase::LogPrefix(Action aAction, Key aKey, const Ip6::Prefix &aPrefix)
 {
@@ -151,7 +144,7 @@
     _(kKeyChildInfo, "ChildInfo")                 \
     _(6, "")                                      \
     _(kKeySlaacIidSecretKey, "SlaacIidSecretKey") \
-    _(kKeyDadInfo, "DadInfo")                     \
+    _(8, "")                                      \
     _(9, "")                                      \
     _(10, "")                                     \
     _(kKeySrpEcdsaKey, "SrpEcdsaKey")             \
@@ -487,12 +480,6 @@
             break;
 #endif
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-        case kKeyDadInfo:
-            reinterpret_cast<const DadInfo *>(aValue)->Log(aAction);
-            break;
-#endif
-
 #if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE
         case kKeyBrUlaPrefix:
             LogPrefix(aAction, aKey, *reinterpret_cast<const Ip6::Prefix *>(aValue));
diff --git a/src/core/common/settings.hpp b/src/core/common/settings.hpp
index 0099432..7ce35cb 100644
--- a/src/core/common/settings.hpp
+++ b/src/core/common/settings.hpp
@@ -112,7 +112,6 @@
         kKeyParentInfo        = OT_SETTINGS_KEY_PARENT_INFO,
         kKeyChildInfo         = OT_SETTINGS_KEY_CHILD_INFO,
         kKeySlaacIidSecretKey = OT_SETTINGS_KEY_SLAAC_IID_SECRET_KEY,
-        kKeyDadInfo           = OT_SETTINGS_KEY_DAD_INFO,
         kKeySrpEcdsaKey       = OT_SETTINGS_KEY_SRP_ECDSA_KEY,
         kKeySrpClientInfo     = OT_SETTINGS_KEY_SRP_CLIENT_INFO,
         kKeySrpServerInfo     = OT_SETTINGS_KEY_SRP_SERVER_INFO,
@@ -486,45 +485,6 @@
     };
 #endif
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    /**
-     * Represents the duplicate address detection information for settings storage.
-     */
-    OT_TOOL_PACKED_BEGIN
-    class DadInfo : private Clearable<DadInfo>
-    {
-        friend class Settings;
-        friend class Clearable<DadInfo>;
-
-    public:
-        static constexpr Key kKey = kKeyDadInfo; ///< The associated key.
-
-        /**
-         * Initializes the `DadInfo` object.
-         */
-        void Init(void) { Clear(); }
-
-        /**
-         * Returns the Dad Counter.
-         *
-         * @returns The Dad Counter value.
-         */
-        uint8_t GetDadCounter(void) const { return mDadCounter; }
-
-        /**
-         * Sets the Dad Counter.
-         *
-         * @param[in] aDadCounter The Dad Counter value.
-         */
-        void SetDadCounter(uint8_t aDadCounter) { mDadCounter = aDadCounter; }
-
-    private:
-        void Log(Action aAction) const;
-
-        uint8_t mDadCounter; ///< Dad Counter used to resolve address conflict in Thread 1.2 DUA feature.
-    } OT_TOOL_PACKED_END;
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE
-
 #if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE
     /**
      * Defines constants and types for BR ULA prefix settings.
@@ -578,7 +538,7 @@
         uint32_t GetLifetime(void) const { return mLifetime; }
 
         /**
-         * Sets the the prefix lifetime.
+         * Sets the prefix lifetime.
          *
          * @param[in] aLifetime  The prefix lifetime in seconds.
          */
diff --git a/src/core/common/string.cpp b/src/core/common/string.cpp
index b7f11de..ce6175d 100644
--- a/src/core/common/string.cpp
+++ b/src/core/common/string.cpp
@@ -357,6 +357,16 @@
     return *this;
 }
 
+StringWriter &StringWriter::AppendHexBytesUppercase(const uint8_t *aBytes, uint16_t aLength)
+{
+    while (aLength--)
+    {
+        Append("%02X", *aBytes++);
+    }
+
+    return *this;
+}
+
 StringWriter &StringWriter::AppendCharMultipleTimes(char aChar, uint16_t aCount)
 {
     while (aCount--)
diff --git a/src/core/common/string.hpp b/src/core/common/string.hpp
index fe919b3..9740a31 100644
--- a/src/core/common/string.hpp
+++ b/src/core/common/string.hpp
@@ -170,6 +170,8 @@
 /**
  * Copies a string into a given target buffer with a given size if it fits.
  *
+ * Guarantees that @p aTargetBuffer remains unmodified if an error is returned.
+ *
  * @param[out] aTargetBuffer  A pointer to the target buffer to copy into.
  * @param[out] aTargetSize    The size (number of characters) in @p aTargetBuffer array.
  * @param[in]  aSource        A pointer to null-terminated string to copy from. Can be `nullptr` which treated as "".
@@ -179,13 +181,15 @@
  * @retval kErrorInvalidArgs  The @p aSource does not fit in the given buffer.
  * @retval kErrorParse        The @p aSource does not follow the encoding format specified by @p aEncodingCheck.
  */
-Error StringCopy(char *TargetBuffer, uint16_t aTargetSize, const char *aSource, StringEncodingCheck aEncodingCheck);
+Error StringCopy(char *aTargetBuffer, uint16_t aTargetSize, const char *aSource, StringEncodingCheck aEncodingCheck);
 
 /**
  * Copies a string into a given target buffer with a given size if it fits.
  *
  * @tparam kSize  The size of buffer.
  *
+ * Guarantees that @p aTargetBuffer remains unmodified if an error is returned.
+ *
  * @param[out] aTargetBuffer  A reference to the target buffer array to copy into.
  * @param[in]  aSource        A pointer to null-terminated string to copy from. Can be `nullptr` which treated as "".
  * @param[in]  aEncodingCheck Specifies the encoding format check (e.g., UTF-8) to perform.
@@ -483,16 +487,26 @@
     StringWriter &AppendVarArgs(const char *aFormat, va_list aArgs) OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(2, 0);
 
     /**
-     * Appends an array of bytes in hex representation (using "%02x" style) to the buffer.
+     * Appends an array of bytes in hex representation (using lowercase "%02x" style) to the buffer.
      *
      * @param[in] aBytes    A pointer to buffer containing the bytes to append.
      * @param[in] aLength   The length of @p aBytes buffer (in bytes).
      *
-     * @returns The string writer.
+     * @returns A reference to this string writer.
      */
     StringWriter &AppendHexBytes(const uint8_t *aBytes, uint16_t aLength);
 
     /**
+     * Appends an array of bytes in hex representation (using uppercase "%02X" style) to the buffer.
+     *
+     * @param[in] aBytes    A pointer to buffer containing the bytes to append.
+     * @param[in] aLength   The length of @p aBytes buffer (in bytes).
+     *
+     * @returns A reference to this string writer.
+     */
+    StringWriter &AppendHexBytesUppercase(const uint8_t *aBytes, uint16_t aLength);
+
+    /**
      * Appends a given character a given number of times.
      *
      * @param[in] aChar    The character to append.
diff --git a/src/core/common/tasklet.cpp b/src/core/common/tasklet.cpp
index d45fa9a..304f172 100644
--- a/src/core/common/tasklet.cpp
+++ b/src/core/common/tasklet.cpp
@@ -42,19 +42,17 @@
 {
     if (!IsPosted())
     {
-        Get<Scheduler>().PostTasklet(*this);
+        Get<Scheduler>().mPostedQueue.PostTasklet(*this);
     }
 }
 
 void Tasklet::Unpost(void)
 {
-    if (IsPosted())
-    {
-        Get<Scheduler>().RemoveTasklet(*this);
-    }
+    Get<Scheduler>().mPostedQueue.RemoveTasklet(*this);
+    Get<Scheduler>().mRunningQueue.RemoveTasklet(*this);
 }
 
-void Tasklet::Scheduler::PostTasklet(Tasklet &aTasklet)
+void Tasklet::Scheduler::Queue::PostTasklet(Tasklet &aTasklet)
 {
     // Tasklets are saved in a circular singly linked list.
 
@@ -72,13 +70,24 @@
     }
 }
 
-void Tasklet::Scheduler::RemoveTasklet(Tasklet &aTasklet)
+void Tasklet::Scheduler::Queue::RemoveTasklet(Tasklet &aTasklet)
 {
-    Tasklet *prev = mTail;
+    Tasklet *prev;
+
+    VerifyOrExit(aTasklet.IsPosted());
+
+    VerifyOrExit(!IsEmpty());
+
+    prev = mTail;
 
     while (prev->mNext != &aTasklet)
     {
         prev = prev->mNext;
+
+        if (prev == mTail)
+        {
+            ExitNow();
+        }
     }
 
     prev->mNext    = aTasklet.mNext;
@@ -88,34 +97,42 @@
     {
         mTail = (prev != &aTasklet) ? prev : nullptr;
     }
+
+exit:
+    return;
+}
+
+Tasklet *Tasklet::Scheduler::Queue::PopTasklet(void)
+{
+    Tasklet *tasklet;
+
+    if (IsEmpty())
+    {
+        tasklet = nullptr;
+    }
+    else
+    {
+        tasklet = mTail->mNext;
+        RemoveTasklet(*tasklet);
+    }
+
+    return tasklet;
 }
 
 void Tasklet::Scheduler::ProcessQueuedTasklets(void)
 {
-    Tasklet *tail = mTail;
+    Tasklet *tasklet;
 
-    // This method processes all tasklets queued when this is called. We
-    // keep a copy the current list and then clear the main list by
-    // setting `mTail` to `nullptr`. A newly posted tasklet while
-    // processing the currently queued tasklets will then trigger a call
-    // to `otTaskletsSignalPending()`.
+    // We transfer all currently posted tasklets to the `mRunningQueue` and
+    // clear the `mPostedQueue`. This ensures that any new tasklet posted
+    // while we are processing `mRunningQueue` will be added to `mPostedQueue`
+    // and will trigger a call to `otTaskletsSignalPending()`.
 
-    mTail = nullptr;
+    mRunningQueue = mPostedQueue;
+    mPostedQueue.Clear();
 
-    while (tail != nullptr)
+    while ((tasklet = mRunningQueue.PopTasklet()) != nullptr)
     {
-        Tasklet *tasklet = tail->mNext;
-
-        if (tasklet == tail)
-        {
-            tail = nullptr;
-        }
-        else
-        {
-            tail->mNext = tasklet->mNext;
-        }
-
-        tasklet->mNext = nullptr;
         tasklet->RunTask();
     }
 }
diff --git a/src/core/common/tasklet.hpp b/src/core/common/tasklet.hpp
index 5370fe8..5d3ee46 100644
--- a/src/core/common/tasklet.hpp
+++ b/src/core/common/tasklet.hpp
@@ -69,20 +69,12 @@
 
     public:
         /**
-         * Initializes the object.
-         */
-        Scheduler(void)
-            : mTail(nullptr)
-        {
-        }
-
-        /**
          * Indicates whether or not there are tasklets pending.
          *
          * @retval TRUE   If there are tasklets pending.
          * @retval FALSE  If there are no tasklets pending.
          */
-        bool AreTaskletsPending(void) const { return mTail != nullptr; }
+        bool AreTaskletsPending(void) const { return !mPostedQueue.IsEmpty(); }
 
         /**
          * Processes all tasklets queued when this is called.
@@ -90,10 +82,26 @@
         void ProcessQueuedTasklets(void);
 
     private:
-        void PostTasklet(Tasklet &aTasklet);
-        void RemoveTasklet(Tasklet &aTasklet);
+        class Queue // A circular singly linked-list
+        {
+        public:
+            Queue(void)
+                : mTail(nullptr)
+            {
+            }
 
-        Tasklet *mTail; // A circular singly linked-list
+            void     Clear(void) { mTail = nullptr; }
+            bool     IsEmpty(void) const { return (mTail == nullptr); }
+            void     PostTasklet(Tasklet &aTasklet);
+            void     RemoveTasklet(Tasklet &aTasklet);
+            Tasklet *PopTasklet(void);
+
+        private:
+            Tasklet *mTail;
+        };
+
+        Queue mPostedQueue;
+        Queue mRunningQueue;
     };
 
     /**
diff --git a/src/core/common/time_ticker.cpp b/src/core/common/time_ticker.cpp
index eeaba6d..0ec844d 100644
--- a/src/core/common/time_ticker.cpp
+++ b/src/core/common/time_ticker.cpp
@@ -50,7 +50,7 @@
 
     if (!mTimer.IsRunning())
     {
-        mTimer.Start(Random::NonCrypto::GetUint32InRange(0, kTickInterval + 1));
+        mTimer.Start(Random::NonCrypto::GenerateInClosedRange<uint32_t>(0, kTickInterval));
     }
 }
 
@@ -104,20 +104,6 @@
     }
 #endif
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-    if (mReceivers & Mask(kDuaManager))
-    {
-        Get<DuaManager>().HandleTimeTick();
-    }
-#endif
-
-#if OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
-    if (mReceivers & Mask(kMlrManager))
-    {
-        Get<MlrManager>().HandleTimeTick();
-    }
-#endif
-
     if (mReceivers & Mask(kIp6Mpl))
     {
         Get<Ip6::Mpl>().HandleTimeTick();
diff --git a/src/core/common/time_ticker.hpp b/src/core/common/time_ticker.hpp
index 5b70fc5..f8b181c 100644
--- a/src/core/common/time_ticker.hpp
+++ b/src/core/common/time_ticker.hpp
@@ -66,8 +66,6 @@
         kAddressResolver,        ///< `AddressResolver`
         kChildSupervisor,        ///< `ChildSupervisor`
         kIp6FragmentReassembler, ///< `Ip6::Ip6` (handling of fragmented messages)
-        kDuaManager,             ///< `DuaManager`
-        kMlrManager,             ///< `MlrManager`
         kNetworkDataNotifier,    ///< `NetworkData::Notifier`
         kIp6Mpl,                 ///< `Ip6::Mpl`
         kBbrLocal,               ///< `BackboneRouter::Local`
diff --git a/src/core/common/tlvs.cpp b/src/core/common/tlvs.cpp
index ddefbf6..6844024 100644
--- a/src/core/common/tlvs.cpp
+++ b/src/core/common/tlvs.cpp
@@ -57,28 +57,6 @@
 
 Error Tlv::AppendTo(Message &aMessage) const { return aMessage.AppendBytes(this, static_cast<uint16_t>(GetSize())); }
 
-Error Tlv::FindTlv(const Message &aMessage, uint8_t aType, uint16_t aMaxSize, Tlv &aTlv)
-{
-    uint16_t offset;
-
-    return FindTlv(aMessage, aType, aMaxSize, aTlv, offset);
-}
-
-Error Tlv::FindTlv(const Message &aMessage, uint8_t aType, uint16_t aMaxSize, Tlv &aTlv, uint16_t &aOffset)
-{
-    Error error;
-    Info  info;
-
-    SuccessOrExit(error = info.FindIn(aMessage, aType));
-
-    info.mTlvOffsetRange.ShrinkLength(aMaxSize);
-    aMessage.ReadBytes(info.mTlvOffsetRange, &aTlv);
-    aOffset = info.GetTlvOffset();
-
-exit:
-    return error;
-}
-
 Error Tlv::FindTlvValueOffsetRange(const Message &aMessage, uint8_t aType, OffsetRange &aOffsetRange)
 {
     Error error;
@@ -200,18 +178,6 @@
 template Error Tlv::Info::ReadUintValue<uint16_t>(const Message &aMessage, uint16_t &aValue) const;
 template Error Tlv::Info::ReadUintValue<uint32_t>(const Message &aMessage, uint32_t &aValue) const;
 
-Error Tlv::ReadTlvValue(const Message &aMessage, uint16_t aOffset, void *aValue, uint8_t aMinLength)
-{
-    Error error;
-    Info  info;
-
-    SuccessOrExit(error = info.ParseFrom(aMessage, aOffset));
-    error = info.ReadValue(aMessage, aValue, aMinLength);
-
-exit:
-    return error;
-}
-
 Error Tlv::FindStringTlv(const Message &aMessage, uint8_t aType, uint8_t aMaxStringLength, char *aValue)
 {
     Error error;
@@ -287,25 +253,38 @@
 
 Error Tlv::AppendEmptyTlv(Message &aMessage, uint8_t aType) { return AppendTlv(aMessage, aType, nullptr, 0); }
 
-Error Tlv::AppendTlv(Message &aMessage, uint8_t aType, const void *aValue, uint16_t aLength)
+Error Tlv::AppendTlvHeader(Message &aMessage, uint8_t aType, uint16_t aLength)
 {
-    Error       error = kErrorNone;
-    ExtendedTlv extTlv;
-    Tlv         tlv;
+    uint16_t size;
+
+    union
+    {
+        Tlv         tlv;
+        ExtendedTlv extTlv;
+    };
+
+    tlv.SetType(aType);
 
     if (aLength > kBaseTlvMaxLength)
     {
-        extTlv.SetType(aType);
         extTlv.SetLength(aLength);
-        SuccessOrExit(error = aMessage.Append(extTlv));
+        size = sizeof(ExtendedTlv);
     }
     else
     {
-        tlv.SetType(aType);
         tlv.SetLength(static_cast<uint8_t>(aLength));
-        SuccessOrExit(error = aMessage.Append(tlv));
+        size = sizeof(Tlv);
     }
 
+    return aMessage.AppendBytes(&tlv, size);
+}
+
+Error Tlv::AppendTlv(Message &aMessage, uint8_t aType, const void *aValue, uint16_t aLength)
+{
+    Error error;
+
+    SuccessOrExit(error = AppendTlvHeader(aMessage, aType, aLength));
+
     VerifyOrExit(aLength > 0);
     error = aMessage.AppendBytes(aValue, aLength);
 
@@ -313,16 +292,25 @@
     return error;
 }
 
+Error Tlv::AppendTlvWithValueFromMessage(Message           &aMessage,
+                                         uint8_t            aType,
+                                         const Message     &aValueMsg,
+                                         const OffsetRange &aValueMsgOffsetRange)
+{
+    Error error;
+
+    SuccessOrExit(error = AppendTlvHeader(aMessage, aType, aValueMsgOffsetRange.GetLength()));
+    error = aMessage.AppendBytesFromMessage(aValueMsg, aValueMsgOffsetRange);
+
+exit:
+    return error;
+}
+
 Error Tlv::StartTlv(Message &aMessage, uint8_t aType, Bookmark &aBookmark)
 {
-    Tlv tlv;
-
-    tlv.SetType(aType);
-    tlv.SetLength(0);
-
     aBookmark = aMessage.GetLength();
 
-    return aMessage.Append(tlv);
+    return AppendTlvHeader(aMessage, aType, 0);
 }
 
 Error Tlv::AdjustTlv(Message &aMessage, Bookmark aBookmark)
diff --git a/src/core/common/tlvs.hpp b/src/core/common/tlvs.hpp
index 6a7ab2e..833d43b 100644
--- a/src/core/common/tlvs.hpp
+++ b/src/core/common/tlvs.hpp
@@ -440,89 +440,6 @@
     };
 
     /**
-     * Reads a TLV's value in a message at a given offset expecting a minimum length for the value.
-     *
-     * Can be used independent of whether the read TLV (from the message) is an Extended TLV or not.
-     *
-     * @param[in]   aMessage    The message to read from.
-     * @param[in]   aOffset     The offset into the message pointing to the start of the TLV.
-     * @param[out]  aValue      A buffer to output the TLV's value, must contain (at least) @p aMinLength bytes.
-     * @param[in]   aMinLength  The minimum expected length of TLV and number of bytes to copy into @p aValue buffer.
-     *
-     * @retval kErrorNone        Successfully read the TLV and copied @p aMinLength into @p aValue.
-     * @retval kErrorParse       The TLV was not well-formed and could not be parsed.
-     */
-    static Error ReadTlvValue(const Message &aMessage, uint16_t aOffset, void *aValue, uint8_t aMinLength);
-
-    /**
-     * Searches for and reads a requested TLV out of a given message.
-     *
-     * Can be used independent of whether the read TLV (from message) is an Extended TLV or not.
-     *
-     * @param[in]   aMessage    A reference to the message.
-     * @param[in]   aType       The Type value to search for.
-     * @param[in]   aMaxSize    Maximum number of bytes to read.
-     * @param[out]  aTlv        A reference to the TLV that will be copied to.
-     *
-     * @retval kErrorNone       Successfully copied the TLV.
-     * @retval kErrorNotFound   Could not find the TLV with Type @p aType.
-     */
-    static Error FindTlv(const Message &aMessage, uint8_t aType, uint16_t aMaxSize, Tlv &aTlv);
-
-    /**
-     * Searches for and reads a requested TLV out of a given message.
-     *
-     * Can be used independent of whether the read TLV (from message) is an Extended TLV or not.
-     *
-     * @param[in]   aMessage    A reference to the message.
-     * @param[in]   aType       The Type value to search for.
-     * @param[in]   aMaxSize    Maximum number of bytes to read.
-     * @param[out]  aTlv        A reference to the TLV that will be copied to.
-     * @param[out]  aOffset     A reference to return the offset to start of the TLV in @p aMessage.
-     *
-     * @retval kErrorNone       Successfully copied the TLV.
-     * @retval kErrorNotFound   Could not find the TLV with Type @p aType.
-     */
-    static Error FindTlv(const Message &aMessage, uint8_t aType, uint16_t aMaxSize, Tlv &aTlv, uint16_t &aOffset);
-
-    /**
-     * Searches for and reads a requested TLV out of a given message.
-     *
-     * Can be used independent of whether the read TLV (from message) is an Extended TLV or not.
-     *
-     * @tparam      TlvType     The TlvType to search for (must be a sub-class of `Tlv`).
-     *
-     * @param[in]   aMessage    A reference to the message.
-     * @param[out]  aTlv        A reference to the TLV that will be copied to.
-     *
-     * @retval kErrorNone       Successfully copied the TLV.
-     * @retval kErrorNotFound   Could not find the TLV with Type @p aType.
-     */
-    template <typename TlvType> static Error FindTlv(const Message &aMessage, TlvType &aTlv)
-    {
-        return FindTlv(aMessage, TlvType::kType, sizeof(TlvType), aTlv);
-    }
-
-    /**
-     * Searches for and reads a requested TLV out of a given message.
-     *
-     * Can be used independent of whether the read TLV (from message) is an Extended TLV or not.
-     *
-     * @tparam      TlvType     The TlvType to search for (must be a sub-class of `Tlv`).
-     *
-     * @param[in]   aMessage    A reference to the message.
-     * @param[out]  aTlv        A reference to the TLV that will be copied to.
-     * @param[out]  aOffset     A reference to return the offset to start of the TLV in @p aMessage.
-     *
-     * @retval kErrorNone       Successfully copied the TLV.
-     * @retval kErrorNotFound   Could not find the TLV with Type @p aType.
-     */
-    template <typename TlvType> static Error FindTlv(const Message &aMessage, TlvType &aTlv, uint16_t &aOffset)
-    {
-        return FindTlv(aMessage, TlvType::kType, sizeof(TlvType), aTlv, aOffset);
-    }
-
-    /**
      * Finds the offset range of the TLV value for a given TLV type within @p aMessage.
      *
      * Can be used independent of whether the read TLV (from message) is an Extended TLV or not.
@@ -634,6 +551,21 @@
     }
 
     /**
+     * Appends a TLV header to a message.
+     *
+     * This method automatically formats the header as a standard TLV header or an extended one based on the
+     * @p aLength value.
+     *
+     * @param[in] aMessage  The message to append the TLV header to.
+     * @param[in] aType     The TLV type to append.
+     * @param[in] aLength   The length of the TLV value.
+     *
+     * @retval kErrorNone    Successfully appended the TLV header.
+     * @retval kErrorNoBufs  Could not add the TLV header due to insufficient buffer space.
+     */
+    static Error AppendTlvHeader(Message &aMessage, uint8_t aType, uint16_t aLength);
+
+    /**
      * Appends an empty TLV (no value) with a given type to a message.
      *
      * On success this method grows the message by the size of the TLV.
@@ -682,6 +614,25 @@
     static Error AppendTlv(Message &aMessage, uint8_t aType, const void *aValue, uint16_t aLength);
 
     /**
+     * Appends a TLV with a given type and value read from another message.
+     *
+     * This method automatically formats the TLV as an Extended TLV if the length exceeds `kBaseTlvMaxLength` (254).
+     *
+     * @param[in] aMessage                The message to append the TLV to.
+     * @param[in] aType                   The TLV type to append.
+     * @param[in] aValueMsg               The message to read the TLV value from.
+     * @param[in] aValueMsgOffsetRange    The offset range in @p aValueMsg to read the value from.
+     *
+     * @retval kErrorNone     Successfully appended the TLV.
+     * @retval kErrorNoBufs   Insufficient available buffers to grow the message.
+     * @retval kErrorParse    Not enough bytes in @p aValueMsg to read the @p aValueMsgOffsetRange.
+     */
+    static Error AppendTlvWithValueFromMessage(Message           &aMessage,
+                                               uint8_t            aType,
+                                               const Message     &aValueMsg,
+                                               const OffsetRange &aValueMsgOffsetRange);
+
+    /**
      * Appends a TLV with a given type and value to a message.
      *
      * The TLV is appended as either a regular or an extended TLV based on the given @p aLength. If the length
diff --git a/src/core/common/trickle_timer.cpp b/src/core/common/trickle_timer.cpp
index 2f86f80..edd0f06 100644
--- a/src/core/common/trickle_timer.cpp
+++ b/src/core/common/trickle_timer.cpp
@@ -216,7 +216,7 @@
     mMode               = aMode;
 
     // Select interval randomly from range [Imin, Imax].
-    mInterval = Random::NonCrypto::GetUint32InRange(mIntervalMin, mIntervalMax + 1);
+    mInterval = Random::NonCrypto::GenerateInClosedRange(mIntervalMin, mIntervalMax);
 
     StartNewInterval();
 }
@@ -257,11 +257,12 @@
 
     case kModeTrickle:
         // Select a random point in the interval taken from the range [I/2, I).
-        halfInterval = mInterval / 2;
-        mTimeInInterval =
-            (halfInterval < mInterval) ? Random::NonCrypto::GetUint32InRange(halfInterval, mInterval) : halfInterval;
-        mCounter = 0;
-        mPhase   = kBeforeRandomTime;
+        halfInterval    = mInterval / 2;
+        mTimeInInterval = (halfInterval < mInterval)
+                              ? Random::NonCrypto::GenerateFromMinUpToExcluding(halfInterval, mInterval)
+                              : halfInterval;
+        mCounter        = 0;
+        mPhase          = kBeforeRandomTime;
         break;
     }
 
@@ -275,7 +276,7 @@
     switch (mMode)
     {
     case kModePlainTimer:
-        mInterval = Random::NonCrypto::GetUint32InRange(mIntervalMin, mIntervalMax + 1);
+        mInterval = Random::NonCrypto::GenerateInClosedRange(mIntervalMin, mIntervalMax);
         StartNewInterval();
         break;
 
diff --git a/src/core/common/uptime.cpp b/src/core/common/uptime.cpp
index c6655ab..a508249 100644
--- a/src/core/common/uptime.cpp
+++ b/src/core/common/uptime.cpp
@@ -96,7 +96,7 @@
 static uint16_t DivideAndGetRemainder(uint32_t &aDividend, uint32_t aDivisor)
 {
     // Returns the quotient of division `aDividend / aDivisor` and updates
-    // `aDividend` to returns the remainder
+    // `aDividend` to the remainder
 
     uint32_t quotient = aDividend / aDivisor;
 
@@ -105,7 +105,15 @@
     return static_cast<uint16_t>(quotient);
 }
 
-void UptimeToString(UptimeMsec aUptime, StringWriter &aWriter, bool aIncludeMsec)
+UptimeString UptimeToString(UptimeMsec aUptime, UptimeStringFlags aFlags)
+{
+    UptimeString string;
+
+    UptimeToString(aUptime, string, aFlags);
+    return string;
+}
+
+void UptimeToString(UptimeMsec aUptime, StringWriter &aWriter, UptimeStringFlags aFlags)
 {
     uint64_t days = aUptime / Time::kOneDayInMsec;
     uint32_t remainder;
@@ -124,9 +132,14 @@
     minutes   = DivideAndGetRemainder(remainder, Time::kOneMinuteInMsec);
     seconds   = DivideAndGetRemainder(remainder, Time::kOneSecondInMsec);
 
-    aWriter.Append("%02u:%02u:%02u", hours, minutes, seconds);
+    if ((days > 0) || (hours > 0) || !(aFlags & kUptimeStringSkipHoursIfZero))
+    {
+        aWriter.Append("%02u:", hours);
+    }
 
-    if (aIncludeMsec)
+    aWriter.Append("%02u:%02u", minutes, seconds);
+
+    if (aFlags & kUptimeStringIncludeMsec)
     {
         aWriter.Append(".%03u", static_cast<uint16_t>(remainder));
     }
diff --git a/src/core/common/uptime.hpp b/src/core/common/uptime.hpp
index ea87870..7508b99 100644
--- a/src/core/common/uptime.hpp
+++ b/src/core/common/uptime.hpp
@@ -50,11 +50,52 @@
 
 namespace ot {
 
+constexpr uint16_t kUptimeStringSize = OT_UPTIME_STRING_SIZE; ///< Recommended string size to represent uptime.
+
 typedef uint64_t UptimeMsec; ///< Uptime in milliseconds.
 
 typedef uint32_t UptimeSec; ///< Uptime in seconds.
 
-constexpr uint16_t kUptimeStringSize = OT_UPTIME_STRING_SIZE; ///< Recommended string size to represent uptime.
+typedef String<kUptimeStringSize> UptimeString; ///< A string representation of `UptimeMsec`
+
+/**
+ * Represents the flags used by `UptimeToString` to customize the string representation of uptime.
+ */
+enum UptimeStringFlag : uint8_t
+{
+    kUptimeStringIncludeMsec     = 1 << 0, ///< Include `.<mmm>` milliseconds in the string.
+    kUptimeStringSkipHoursIfZero = 1 << 1, ///< Skip the `<hh>:` part if hours (and days) are zero.
+};
+
+/**
+ * Represents a set of `UptimeStringFlag` values.
+ */
+typedef uint8_t UptimeStringFlags;
+
+/**
+ * Converts an uptime value (number of milliseconds) to a human-readable string.
+ *
+ * The string follows the format "<hh>:<mm>:<ss>" or "<dd>d.<hh>:<mm>:<ss>" (if uptime is longer than a day).
+ * @p aFlags can be used to include milliseconds and/or skip the `<hh>:` part if hours/days are zero.
+ *
+ * @param[in]     aUptime  The uptime to convert.
+ * @param[in,out] aWriter  A `StringWriter` to append the converted string to.
+ * @param[in]     aFlags   Flags to customize the string representation.
+ */
+void UptimeToString(UptimeMsec aUptime, StringWriter &aWriter, UptimeStringFlags aFlags);
+
+/**
+ * Converts an uptime value (number of milliseconds) to a human-readable string.
+ *
+ * The string follows the format "<hh>:<mm>:<ss>" or "<dd>d.<hh>:<mm>:<ss>" (if uptime is longer than a day).
+ * @p aFlags can be used to include milliseconds and/or skip the `<hh>:` part if hours/days are zero.
+ *
+ * @param[in]     aUptime  The uptime to convert.
+ * @param[in]     aFlags   Flags to customize the string representation.
+ *
+ * @returns An `UptimeString` containing the human-readable string.
+ */
+UptimeString UptimeToString(UptimeMsec aUptime, UptimeStringFlags aFlags);
 
 /**
  * Implements tracking of device uptime.
@@ -99,19 +140,6 @@
     UptimeTimer mTimer;
 };
 
-/**
- * Converts an uptime value (number of milliseconds) to a human-readable string.
- *
- * The string follows the format "<hh>:<mm>:<ss>.<mmmm>" for hours, minutes, seconds and millisecond (if uptime is
- * shorter than one day) or "<dd>d.<hh>:<mm>:<ss>.<mmmm>" (if longer than a day). @p aIncludeMsec can be used
- * to determine whether `.<mmm>` milliseconds is included or omitted in the resulting string.
- *
- * @param[in]     aUptime        The uptime to convert.
- * @param[in,out] aWriter        A `StringWriter` to append the converted string to.
- * @param[in]     aIncludeMsec   Whether to include `.<mmm>` milliseconds in the string.
- */
-void UptimeToString(UptimeMsec aUptime, StringWriter &aWriter, bool aIncludeMsec);
-
 } // namespace ot
 
 #endif // OPENTHREAD_CONFIG_UPTIME_ENABLE
diff --git a/src/core/config/backbone_router.h b/src/core/config/backbone_router.h
index 2e52797..eb88ce7 100644
--- a/src/core/config/backbone_router.h
+++ b/src/core/config/backbone_router.h
@@ -53,21 +53,6 @@
 #endif
 
 /**
- * @def OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
- *
- * Define to 1 to enable Backbone Router ND Proxying for Thread 1.2 DUA feature.
- * It's turned on by default for Thread 1.2 Backbone Router.
- */
-#ifndef OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-#define OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-#endif
-
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE && !OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-#error \
-    "OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE is required for OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE"
-#endif
-
-/**
  * @def OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
  *
  * Define to 1 to enable Backbone Router Multicast Routing for Thread 1.2 MLR feature.
@@ -83,10 +68,9 @@
 #endif
 
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-#if !OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE && \
-    !OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
+#if !OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
 #error \
-    "At least one of OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE and OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE must be enabled for OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE"
+    "OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE must be enabled for OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE"
 #endif
 #endif
 
@@ -105,18 +89,6 @@
 #endif
 
 /**
- * @def OPENTHREAD_CONFIG_NDPROXY_TABLE_ENTRY_NUM
- *
- * The maximum number of supported DUA that Backbone Router could proxy.
- *
- * Note: According to Thread Conformance v1.2.0, a Thread Border Router MUST be able to hold a DUA Devices Table in
- * memory with at least two hundred and fifty (250) entries.
- */
-#ifndef OPENTHREAD_CONFIG_NDPROXY_TABLE_ENTRY_NUM
-#define OPENTHREAD_CONFIG_NDPROXY_TABLE_ENTRY_NUM 250
-#endif
-
-/**
  * @}
  */
 
diff --git a/src/core/config/border_agent.h b/src/core/config/border_agent.h
index bbeffc9..6a60db0 100644
--- a/src/core/config/border_agent.h
+++ b/src/core/config/border_agent.h
@@ -116,10 +116,10 @@
  * Per the Thread specification, the service instance should be a user-friendly name identifying the device model or
  * product. A recommended format is "VendorName ProductName".
  *
- * The name MUST have a length less than or equal to `OT_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME_MAX_LENGTH` (47 chars).
+ * The name MUST have a length less than or equal to `OT_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME_MAX_LENGTH`.
  */
 #ifndef OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME
-#define OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME "OpenThread BR (unspecified vendor) "
+#define OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME "OpenThread BR (unspecified vendor)"
 #endif
 
 /**
diff --git a/src/core/config/border_router.h b/src/core/config/border_router.h
index 5235ea8..dd6d901 100644
--- a/src/core/config/border_router.h
+++ b/src/core/config/border_router.h
@@ -66,7 +66,6 @@
  *
  * - It has added at least one external route entry.
  * - It has added at least one prefix entry with default-route and on-mesh flags set.
- * - It has added at least one domain prefix (domain and on-mesh flags set).
  *
  * A Border Router which provides IP connectivity and is acting as a REED is eligible to request a router role upgrade
  * by sending an "Address Solicit" request to leader with status reason `BorderRouterRequest`. This reason is used when
diff --git a/src/core/config/crypto.h b/src/core/config/crypto.h
index a3607b6..4a82083 100644
--- a/src/core/config/crypto.h
+++ b/src/core/config/crypto.h
@@ -59,6 +59,28 @@
 /** Use platform provided crypto library */
 #define OPENTHREAD_CONFIG_CRYPTO_LIB_PLATFORM 2
 
+/**
+ * @def OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT
+ *
+ * Define to 1 to enable the platform to allocate crypto operation context.
+ */
+#ifndef OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT
+#define OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT 0
+#endif
+
+/**
+ * @def OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+ *
+ * Define to 1 to enable platform one-shot AES-CCM* acceleration.
+ *
+ * When enabled, `AesCcm::Engine::ProcessOneShot()` calls
+ * `otPlatCryptoAesCcmProcessOneShot()` instead of the built-in
+ * software CCM engine.
+ */
+#ifndef OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+#define OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE 0
+#endif
+
 #if OPENTHREAD_CONFIG_CRYPTO_LIB == OPENTHREAD_CONFIG_CRYPTO_LIB_PLATFORM
 
 /**
diff --git a/src/core/config/ip6.h b/src/core/config/ip6.h
index eb8a54d..ed6e4e8 100644
--- a/src/core/config/ip6.h
+++ b/src/core/config/ip6.h
@@ -199,6 +199,15 @@
 #endif
 
 /**
+ * @def OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+ *
+ * Define as 1 to enable TCP support via platform `otPlatTcp*` APIs and `Ip6::PlatTcp`.
+ */
+#ifndef OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+#define OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE 0
+#endif
+
+/**
  * @def OPENTHREAD_CONFIG_TCP_ENABLE
  *
  * Define as 1 to enable TCP.
diff --git a/src/core/config/logging.h b/src/core/config/logging.h
index 4c278ee..2e090df 100644
--- a/src/core/config/logging.h
+++ b/src/core/config/logging.h
@@ -78,6 +78,20 @@
 #define OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED 3
 
 /**
+ * @def OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+ *
+ * Define to 1 to enable the instance-aware platform logging API.
+ *
+ * When this configuration is enabled, OpenThread logging will track the OpenThread instance (`otInstance`) from which
+ * a log is generated. The core will use the `otPlatLogOutput()` platform API instead of `otPlatLog()`. The new
+ * platform API provides the `otInstance` pointer along with the log as a fully formatted string, which is particularly
+ * useful in multi-instance setups to distinguish logs from different OpenThread instances.
+ */
+#ifndef OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+#define OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE 0
+#endif
+
+/**
  * @def OPENTHREAD_CONFIG_LOG_LEVEL
  *
  * The log level (used at compile time). If `OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE` is set, this defines the most
@@ -184,6 +198,20 @@
 #endif
 
 /**
+ * @def OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+ *
+ * Define to 1 to enable the log level override feature and its associated APIs.
+ *
+ * This feature is used when `OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE` is also enabled.
+ *
+ * When enabled, new mechanism is added to allow temporary override of the current log level (e.g., to increase the
+ * level to capture more detailed information) and subsequently restore it to the original user-specified level.
+ */
+#ifndef OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+#define OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE 0
+#endif
+
+/**
  * @}
  */
 
diff --git a/src/core/config/mac.h b/src/core/config/mac.h
index 896455c..74c937e 100644
--- a/src/core/config/mac.h
+++ b/src/core/config/mac.h
@@ -383,6 +383,15 @@
 #endif
 
 /**
+ * @def OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE
+ *
+ * Define to 1 to enable software retransmission security logic.
+ */
+#ifndef OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE
+#define OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE 1
+#endif
+
+/**
  * @def OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
  *
  * Define to 1 to enable csl transmitter logic.
@@ -401,16 +410,6 @@
 #endif
 
 /**
- * @def OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
- *
- * Define to 1 to enable support for IEEE 802.15.4 MAC Multipurpose frame format.
- */
-#ifndef OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
-#define OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME \
-    (OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE)
-#endif
-
-/**
  * @def OPENTHREAD_CONFIG_MAC_CSL_AUTO_SYNC_ENABLE
  *
  * This setting configures CSL auto synchronization based on data poll mechanism in Thread 1.2.
diff --git a/src/core/config/misc.h b/src/core/config/misc.h
index f83e63c..d7d9081 100644
--- a/src/core/config/misc.h
+++ b/src/core/config/misc.h
@@ -525,15 +525,6 @@
 #endif
 
 /**
- * @def OPENTHREAD_CONFIG_DUA_ENABLE
- *
- * Define as 1 to support Thread 1.2 Domain Unicast Address feature.
- */
-#ifndef OPENTHREAD_CONFIG_DUA_ENABLE
-#define OPENTHREAD_CONFIG_DUA_ENABLE 0
-#endif
-
-/**
  * @def OPENTHREAD_CONFIG_MLR_ENABLE
  *
  * Define as 1 to support Thread 1.2 Multicast Listener Registration feature.
diff --git a/src/core/config/mle.h b/src/core/config/mle.h
index 7792c77..df2e43e 100644
--- a/src/core/config/mle.h
+++ b/src/core/config/mle.h
@@ -83,15 +83,6 @@
 #endif
 
 /**
- * @def OPENTHREAD_CONFIG_MLE_IP_ADDRS_TO_REGISTER
- *
- * The maximum number of IPv6 address registrations for MTD.
- */
-#ifndef OPENTHREAD_CONFIG_MLE_IP_ADDRS_TO_REGISTER
-#define OPENTHREAD_CONFIG_MLE_IP_ADDRS_TO_REGISTER (OPENTHREAD_CONFIG_MLE_IP_ADDRS_PER_CHILD)
-#endif
-
-/**
  * @def OPENTHREAD_CONFIG_MLE_DEVICE_PROPERTY_LEADER_WEIGHT_ENABLE
  *
  * Define as 1 to enable feature to set device properties which are used for calculating the local leader weight on a
diff --git a/src/core/config/network_diagnostic.h b/src/core/config/net_diag.h
similarity index 76%
rename from src/core/config/network_diagnostic.h
rename to src/core/config/net_diag.h
index 0502cfe..0b83a55 100644
--- a/src/core/config/network_diagnostic.h
+++ b/src/core/config/net_diag.h
@@ -31,8 +31,8 @@
  *   This file includes compile-time configurations for the Network Diagnostics.
  */
 
-#ifndef OT_CORE_CONFIG_NETWORK_DIAGNOSTIC_H_
-#define OT_CORE_CONFIG_NETWORK_DIAGNOSTIC_H_
+#ifndef OT_CORE_CONFIG_NET_DIAG_H_
+#define OT_CORE_CONFIG_NET_DIAG_H_
 
 /**
  * @addtogroup config-network-diagnostic
@@ -89,6 +89,30 @@
 #endif
 
 /**
+ * @def OPENTHREAD_CONFIG_NET_DIAG_VENDOR_OUI
+ *
+ * Specifies the default Vendor OUI (Organizationally Unique Identifier) value.
+ *
+ * This configuration supports multiple layout formats to maintain backward compatibility:
+ *
+ * - 24-bit OUI (MA-L): Hexadecimal representation of a 24-bit value (e.g., OUI 64-16-66 is represented as
+ *   `0x641666`). Values <= `0xffffff` are implicitly treated as a 24-bit OUI.
+ *
+ * - Explicit OUI with various lengths (24, 28, or 36 bits): A 48-bit hexadecimal value where the most significant
+ *   byte (bits 40-47) represents the prefix bit-length, and the lower 5 bytes (bits 0-39) represent the OUI bytes
+ *   in big-endian order:
+ *   - 28-bit OUI `00-1A-2B-3` is represented as    `0x1c001a2b3000ULL`.
+ *   - 36-bit OUI `00-1A-2B-3C-4` is represented as `0x24001a2b3c40ULL`.
+ *
+ * The value of `0xffffffff` (UINT32_MAX) is used to indicate the Vendor OUI is not specified.
+ *
+ * The configured value is validated at compile-time to ensure it conforms to one of the supported prefix lengths.
+ */
+#ifndef OPENTHREAD_CONFIG_NET_DIAG_VENDOR_OUI
+#define OPENTHREAD_CONFIG_NET_DIAG_VENDOR_OUI (0xffffffff)
+#endif
+
+/**
  * @def OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE
  *
  * Define as 1 to add APIs to allow Vendor Name, Model, SW Version to change at run-time.
@@ -110,4 +134,4 @@
  * @}
  */
 
-#endif // OT_CORE_CONFIG_NETWORK_DIAGNOSTIC_H_
+#endif // OT_CORE_CONFIG_NET_DIAG_H_
diff --git a/src/core/config/openthread-core-config-check.h b/src/core/config/openthread-core-config-check.h
index b6fa3f8..fc43c7b 100644
--- a/src/core/config/openthread-core-config-check.h
+++ b/src/core/config/openthread-core-config-check.h
@@ -97,7 +97,7 @@
 #endif
 
 #ifdef OPENTHREAD_ENABLE_MTD_NETWORK_DIAGNOSTIC
-#error "OPENTHREAD_ENABLE_MTD_NETWORK_DIAGNOSTIC was replaces by OPENTHREAD_CONFIG_TMF_NETWORK_DIAG_MTD_ENABLE."
+#error "OPENTHREAD_ENABLE_MTD_NETWORK_DIAGNOSTIC was replaced by OPENTHREAD_CONFIG_TMF_NETWORK_DIAG_MTD_ENABLE."
 #endif
 
 #ifdef OPENTHREAD_ENABLE_APPLICATION_COAP_SECURE
@@ -698,4 +698,13 @@
 #error "OPENTHREAD_CONFIG_DTLS_ENABLE was replaced by OPENTHREAD_CONFIG_SECURE_TRANSPORT_ENABLE"
 #endif
 
+#ifdef OPENTHREAD_CONFIG_MLE_IP_ADDRS_TO_REGISTER
+#error "OPENTHREAD_CONFIG_MLE_IP_ADDRS_TO_REGISTER is removed. All addresses are now registered."
+#endif
+
+#ifdef OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
+#error "OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME was removed and is no longer supported. " \
+       "It was originally implemented as a provisional solution for the wake mechanism."
+#endif
+
 #endif // OT_CORE_CONFIG_OPENTHREAD_CORE_CONFIG_CHECK_H_
diff --git a/src/core/config/platform.h b/src/core/config/platform.h
index f00bef8..f191a89 100644
--- a/src/core/config/platform.h
+++ b/src/core/config/platform.h
@@ -34,6 +34,7 @@
 #ifndef OT_CORE_CONFIG_PLATFORM_H_
 #define OT_CORE_CONFIG_PLATFORM_H_
 
+#include "config/crypto.h"
 #include "config/srp_server.h"
 
 /**
@@ -156,7 +157,8 @@
  * Define to 1 if you want to enable key ref usage support as defined by platform.
  */
 #ifndef OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE
-#define OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE 0
+#define OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE \
+    (OPENTHREAD_CONFIG_CRYPTO_LIB == OPENTHREAD_CONFIG_CRYPTO_LIB_PSA)
 #endif
 
 /**
diff --git a/src/core/config/tmf.h b/src/core/config/tmf.h
index 2936904..f8c722b 100644
--- a/src/core/config/tmf.h
+++ b/src/core/config/tmf.h
@@ -213,20 +213,6 @@
 #endif
 
 /**
- * @def OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
- *
- * Define to 1 for Thread 1.2 FTD device to register DUA of its MTD children registered
- * even if it doesn't enable DUA feature itself.
- */
-#ifndef OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-#define OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
-#endif
-
-#if OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE && OPENTHREAD_CONFIG_THREAD_VERSION < OT_THREAD_VERSION_1_2
-#error "Thread 1.2 or higher version is required for OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE"
-#endif
-
-/**
  * @def OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
  *
  * This setting configures the Multicast Listener Registration parent proxying in Thread 1.2.
diff --git a/src/core/crypto/aes_ccm.cpp b/src/core/crypto/aes_ccm.cpp
index a160749..9cb7616 100644
--- a/src/core/crypto/aes_ccm.cpp
+++ b/src/core/crypto/aes_ccm.cpp
@@ -36,44 +36,226 @@
 #include "common/code_utils.hpp"
 #include "common/debug.hpp"
 #include "common/encoding.hpp"
+#include "common/num_utils.hpp"
 
 namespace ot {
 namespace Crypto {
 
-void AesCcm::SetKey(const uint8_t *aKey, uint16_t aKeyLength)
-{
-    Key cryptoKey;
+//---------------------------------------------------------------------------------------------------------------------
+// AesCcm
 
-    cryptoKey.Set(aKey, aKeyLength);
-    SetKey(cryptoKey);
+AesCcm::AesCcm(void)
+{
+    mConfig.Clear();
+    mAuthData = nullptr;
 }
 
-void AesCcm::SetKey(const Mac::KeyMaterial &aMacKey)
+void AesCcm::SetNonce(const void *aNonce, uint8_t aLength)
 {
-    Key cryptoKey;
-
-    aMacKey.ConvertToCryptoKey(cryptoKey);
-    SetKey(cryptoKey);
+    mConfig.mNonce       = reinterpret_cast<const uint8_t *>(aNonce);
+    mConfig.mNonceLength = aLength;
 }
 
-void AesCcm::Init(uint32_t    aHeaderLength,
-                  uint32_t    aPlainTextLength,
-                  uint8_t     aTagLength,
-                  const void *aNonce,
-                  uint8_t     aNonceLength)
+void AesCcm::SetTagLength(uint8_t aTagLength)
 {
-    const uint8_t *nonceBytes  = reinterpret_cast<const uint8_t *>(aNonce);
-    uint8_t        blockLength = 0;
-    uint32_t       len;
-    uint8_t        L;
-    uint8_t        i;
+    OT_ASSERT(((aTagLength & 0x1) == 0) && IsValueInRange(aTagLength, kMinTagLength, kMaxTagLength));
 
-    // Tag length must be even and within [kMinTagLength, kMaxTagLength]
-    OT_ASSERT(((aTagLength & 0x1) == 0) && (kMinTagLength <= aTagLength) && (aTagLength <= kMaxTagLength));
+    mConfig.mTagLength = aTagLength;
+}
+
+void AesCcm::SetAuthData(const void *aAuthData, uint32_t aLength)
+{
+    mAuthData             = reinterpret_cast<const uint8_t *>(aAuthData);
+    mConfig.mHeaderLength = aLength;
+}
+
+Error AesCcm::Process(Operation aOperation, uint8_t *aData, uint32_t aLength)
+{
+    Engine engine;
+
+    mConfig.mPlainTextLength = aLength;
+
+    return engine.ProcessOneShot(aOperation, mConfig, mAuthData, aData);
+}
+
+#if OPENTHREAD_FTD || OPENTHREAD_MTD
+
+Error AesCcm::Process(Operation aOperation, Message &aMessage, uint16_t aOffset)
+{
+    Error                 error = kErrorNone;
+    Engine                engine;
+    Message::MutableChunk chunk;
+    uint16_t              remainingLength;
+    uint8_t               tag[kMaxTagLength];
+
+    VerifyOrExit(aOffset <= aMessage.GetLength(), error = kErrorInvalidArgs);
+
+    switch (aOperation)
+    {
+    case kEncrypt:
+        SuccessOrExit(error = aMessage.IncreaseLength(mConfig.mTagLength));
+        break;
+    case kDecrypt:
+        VerifyOrExit(aMessage.GetLength() - aOffset >= mConfig.mTagLength, error = kErrorSecurity);
+        break;
+    }
+
+    mConfig.mPlainTextLength = aMessage.GetLength() - aOffset - mConfig.mTagLength;
+
+    // First, check if the entire payload and tag are present in
+    // a single chunk (i.e., in one contiguous buffer).
+
+    remainingLength = aMessage.GetLength() - aOffset;
+
+    aMessage.GetFirstChunk(aOffset, remainingLength, chunk);
+
+    if (chunk.GetLength() == mConfig.mPlainTextLength + mConfig.mTagLength)
+    {
+        error = engine.ProcessOneShot(aOperation, mConfig, mAuthData, chunk.GetBytes());
+        ExitNow();
+    }
+
+    // The payload content spans multiple chunks. We need to process
+    // it iteratively using multi-part `engine` methods.
+
+    remainingLength = aMessage.GetLength() - aOffset - mConfig.mTagLength;
+
+    engine.Start(mConfig);
+    engine.AddHeader(mAuthData, mConfig.mHeaderLength);
+
+    aMessage.GetFirstChunk(aOffset, remainingLength, chunk);
+
+    while (chunk.GetLength() > 0)
+    {
+        engine.AddPayload(chunk.GetBytes(), chunk.GetBytes(), chunk.GetLength(), aOperation);
+        aMessage.GetNextChunk(remainingLength, chunk);
+    }
+
+    engine.Finalize(tag);
+
+    switch (aOperation)
+    {
+    case kEncrypt:
+        aMessage.WriteBytes(aMessage.GetLength() - mConfig.mTagLength, tag, mConfig.mTagLength);
+        break;
+
+    case kDecrypt:
+        VerifyOrExit(aMessage.CompareBytes(aMessage.GetLength() - mConfig.mTagLength, tag, mConfig.mTagLength),
+                     error = kErrorSecurity);
+        break;
+    }
+
+exit:
+    if ((aOperation == kDecrypt) && (error == kErrorNone))
+    {
+        aMessage.RemoveFooter(mConfig.mTagLength);
+    }
+
+    return error;
+}
+#endif //  OPENTHREAD_FTD || OPENTHREAD_MTD
+
+void AesCcm::Perform(Operation   aOperation,
+                     const Key  &aKey,
+                     uint8_t     aTagLength,
+                     const void *aNonce,
+                     uint8_t     aNonceLength,
+                     const void *aAuthData,
+                     uint32_t    aAuthDataLength,
+                     void       *aPlainText,
+                     void       *aCipherText,
+                     uint32_t    aLength,
+                     void       *aTag)
+{
+    Config config;
+    Engine engine;
+
+    config.mKey             = aKey;
+    config.mTagLength       = aTagLength;
+    config.mNonce           = reinterpret_cast<const uint8_t *>(aNonce);
+    config.mNonceLength     = aNonceLength;
+    config.mHeaderLength    = aAuthDataLength;
+    config.mPlainTextLength = aLength;
+
+    engine.Start(config);
+    engine.AddHeader(reinterpret_cast<const uint8_t *>(aAuthData), aAuthDataLength);
+    engine.AddPayload(aPlainText, aCipherText, aLength, aOperation);
+    engine.Finalize(aTag);
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// AesCcm::Config
+
+bool AesCcm::Config::IsValid(void) const
+{
+    bool isValid = false;
+
+    VerifyOrExit(IsValueInRange(mNonceLength, kMinNonceLength, kMaxNonceLength));
+    VerifyOrExit(mNonce != nullptr);
+
+    VerifyOrExit((mTagLength % 2) == 0);
+    VerifyOrExit(IsValueInRange(mTagLength, kMinTagLength, kMaxTagLength));
+
+    isValid = true;
+
+exit:
+    return isValid;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// AesCcm::Engine
+
+Error AesCcm::Engine::ProcessOneShot(Operation      aOperation,
+                                     const Config  &aConfig,
+                                     const uint8_t *aHeader,
+                                     uint8_t       *aData)
+{
+    Error error = kErrorNone;
+
+#if OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+    error = otPlatCryptoAesCcmProcessOneShot(aOperation == kEncrypt, &aConfig, aHeader, aData);
+#else
+    uint8_t tag[kMaxTagLength];
+
+    Start(aConfig);
+    AddHeader(aHeader, aConfig.mHeaderLength);
+    AddPayload(aData, aData, aConfig.mPlainTextLength, aOperation);
+    Finalize(tag);
+
+    switch (aOperation)
+    {
+    case kEncrypt:
+        memcpy(aData + aConfig.mPlainTextLength, tag, aConfig.mTagLength);
+        break;
+
+    case kDecrypt:
+        error = (memcmp(aData + aConfig.mPlainTextLength, tag, aConfig.mTagLength) == 0) ? kErrorNone : kErrorSecurity;
+        break;
+    }
+#endif
+
+    return error;
+}
+
+void AesCcm::Engine::Start(const Config &aConfig)
+{
+    uint8_t  blockLength = 0;
+    uint32_t len;
+    uint8_t  L;
+    uint8_t  i;
+
+    OT_ASSERT(aConfig.IsValid());
+
+    mEcb.SetKey(aConfig.GetKey());
+
+    mNonceLength     = aConfig.mNonceLength;
+    mTagLength       = aConfig.mTagLength;
+    mHeaderLength    = aConfig.mHeaderLength;
+    mPlainTextLength = aConfig.mPlainTextLength;
 
     L = 0;
 
-    for (len = aPlainTextLength; len; len >>= 8)
+    for (len = mPlainTextLength; len; len >>= 8)
     {
         L++;
     }
@@ -83,36 +265,36 @@
         L = 2;
     }
 
-    if (aNonceLength > 13)
+    if (mNonceLength > 13)
     {
-        aNonceLength = 13;
+        mNonceLength = 13;
     }
 
     // increase L to match nonce len
-    if (L < (15 - aNonceLength))
+    if (L < (15 - mNonceLength))
     {
-        L = 15 - aNonceLength;
+        L = 15 - mNonceLength;
     }
 
     // decrease nonceLength to match L
-    if (aNonceLength > (15 - L))
+    if (mNonceLength > (15 - L))
     {
-        aNonceLength = 15 - L;
+        mNonceLength = 15 - L;
     }
 
     // setup initial block
 
     // write flags
-    mBlock[0] = (static_cast<uint8_t>((aHeaderLength != 0) << 6) | static_cast<uint8_t>(((aTagLength - 2) >> 1) << 3) |
+    mBlock[0] = (static_cast<uint8_t>((mHeaderLength != 0) << 6) | static_cast<uint8_t>(((mTagLength - 2) >> 1) << 3) |
                  static_cast<uint8_t>(L - 1));
 
     // write nonce
-    memcpy(&mBlock[1], nonceBytes, aNonceLength);
+    memcpy(&mBlock[1], aConfig.mNonce, mNonceLength);
 
     // write len
-    len = aPlainTextLength;
+    len = mPlainTextLength;
 
-    for (i = sizeof(mBlock) - 1; i > aNonceLength; i--)
+    for (i = sizeof(mBlock) - 1; i > mNonceLength; i--)
     {
         mBlock[i] = len & 0xff;
         len >>= 8;
@@ -122,44 +304,41 @@
     mEcb.Encrypt(mBlock, mBlock);
 
     // process header
-    if (aHeaderLength > 0)
+    if (mHeaderLength > 0)
     {
         // process length
-        if (aHeaderLength < (65536U - 256U))
+        if (mHeaderLength < (65536U - 256U))
         {
-            mBlock[blockLength++] ^= aHeaderLength >> 8;
-            mBlock[blockLength++] ^= aHeaderLength >> 0;
+            mBlock[blockLength++] ^= mHeaderLength >> 8;
+            mBlock[blockLength++] ^= mHeaderLength >> 0;
         }
         else
         {
             mBlock[blockLength++] ^= 0xff;
             mBlock[blockLength++] ^= 0xfe;
-            mBlock[blockLength++] ^= aHeaderLength >> 24;
-            mBlock[blockLength++] ^= aHeaderLength >> 16;
-            mBlock[blockLength++] ^= aHeaderLength >> 8;
-            mBlock[blockLength++] ^= aHeaderLength >> 0;
+            mBlock[blockLength++] ^= mHeaderLength >> 24;
+            mBlock[blockLength++] ^= mHeaderLength >> 16;
+            mBlock[blockLength++] ^= mHeaderLength >> 8;
+            mBlock[blockLength++] ^= mHeaderLength >> 0;
         }
     }
 
     // init counter
     mCtr[0] = L - 1;
-    memcpy(&mCtr[1], nonceBytes, aNonceLength);
-    memset(&mCtr[aNonceLength + 1], 0, sizeof(mCtr) - aNonceLength - 1);
+    memcpy(&mCtr[1], aConfig.mNonce, mNonceLength);
+    memset(&mCtr[mNonceLength + 1], 0, sizeof(mCtr) - mNonceLength - 1);
 
-    mNonceLength     = aNonceLength;
-    mHeaderLength    = aHeaderLength;
-    mHeaderCur       = 0;
-    mPlainTextLength = aPlainTextLength;
-    mPlainTextCur    = 0;
-    mBlockLength     = blockLength;
-    mCtrLength       = sizeof(mCtrPad);
-    mTagLength       = aTagLength;
+    mHeaderCur    = 0;
+    mPlainTextCur = 0;
+    mBlockLength  = blockLength;
+    mCtrLength    = sizeof(mCtrPad);
 }
 
-void AesCcm::Header(const void *aHeader, uint32_t aHeaderLength)
+void AesCcm::Engine::AddHeader(const void *aHeader, uint32_t aHeaderLength)
 {
     const uint8_t *headerBytes = reinterpret_cast<const uint8_t *>(aHeader);
 
+    OT_ASSERT((aHeaderLength == 0) || aHeader != nullptr);
     OT_ASSERT(mHeaderCur + aHeaderLength <= mHeaderLength);
 
     // process header
@@ -188,7 +367,7 @@
     }
 }
 
-void AesCcm::Payload(void *aPlainText, void *aCipherText, uint32_t aLength, Mode aMode)
+void AesCcm::Engine::AddPayload(void *aPlainText, void *aCipherText, uint32_t aLength, Operation aOperation)
 {
     uint8_t *plaintextBytes  = reinterpret_cast<uint8_t *>(aPlainText);
     uint8_t *ciphertextBytes = reinterpret_cast<uint8_t *>(aCipherText);
@@ -212,7 +391,7 @@
             mCtrLength = 0;
         }
 
-        if (aMode == kEncrypt)
+        if (aOperation == kEncrypt)
         {
             byte = plaintextBytes[i];
 
@@ -254,22 +433,7 @@
     }
 }
 
-#if OPENTHREAD_FTD || OPENTHREAD_MTD
-void AesCcm::Payload(Message &aMessage, uint16_t aOffset, uint16_t aLength, Mode aMode)
-{
-    Message::MutableChunk chunk;
-
-    aMessage.GetFirstChunk(aOffset, aLength, chunk);
-
-    while (chunk.GetLength() > 0)
-    {
-        Payload(chunk.GetBytes(), chunk.GetBytes(), chunk.GetLength(), aMode);
-        aMessage.GetNextChunk(aLength, chunk);
-    }
-}
-#endif
-
-void AesCcm::Finalize(void *aTag)
+void AesCcm::Engine::Finalize(void *aTag)
 {
     uint8_t *tagBytes = reinterpret_cast<uint8_t *>(aTag);
 
@@ -283,18 +447,14 @@
     }
 }
 
-void AesCcm::GenerateNonce(const Mac::ExtAddress &aAddress,
-                           uint32_t               aFrameCounter,
-                           uint8_t                aSecurityLevel,
-                           uint8_t               *aNonce)
+//---------------------------------------------------------------------------------------------------------------------
+// AesCcm::Nonce
+
+void AesCcm::Nonce::InitFrom(const Mac::ExtAddress &aExtAddress, uint32_t aFrameCounter, uint8_t aSecurityLevel)
 {
-    memcpy(aNonce, aAddress.m8, sizeof(Mac::ExtAddress));
-    aNonce += sizeof(Mac::ExtAddress);
-
-    BigEndian::WriteUint32(aFrameCounter, aNonce);
-    aNonce += sizeof(uint32_t);
-
-    aNonce[0] = aSecurityLevel;
+    mExtAddress    = aExtAddress;
+    mFrameCounter  = BigEndian::HostSwap32(aFrameCounter);
+    mSecurityLevel = aSecurityLevel;
 }
 
 } // namespace Crypto
diff --git a/src/core/crypto/aes_ccm.hpp b/src/core/crypto/aes_ccm.hpp
index 675ac03..16615b9 100644
--- a/src/core/crypto/aes_ccm.hpp
+++ b/src/core/crypto/aes_ccm.hpp
@@ -61,145 +61,227 @@
 class AesCcm
 {
 public:
-    static constexpr uint8_t kMinTagLength = 4;                  ///< Minimum tag length (in bytes).
-    static constexpr uint8_t kMaxTagLength = AesEcb::kBlockSize; ///< Maximum tag length (in bytes).
-    static constexpr uint8_t kNonceSize    = 13;                 ///< Size of IEEE 802.15.4 Nonce (in bytes).
+    static constexpr uint8_t kMinTagLength   = 4;                  ///< Minimum tag length (in bytes).
+    static constexpr uint8_t kMaxTagLength   = AesEcb::kBlockSize; ///< Maximum tag length (in bytes).
+    static constexpr uint8_t kMinNonceLength = 7;                  ///< Minimum nonce length (in bytes).
+    static constexpr uint8_t kMaxNonceLength = 13;                 ///< Maximum nonce length (in bytes).
 
     /**
-     * Type represent the encryption vs decryption mode.
+     * Initializes the AES-CCM object.
      */
-    enum Mode : uint8_t
+    AesCcm(void);
+
+    /**
+     * Represents the operation to perform (encryption or decryption)
+     */
+    enum Operation : uint8_t
     {
-        kEncrypt, // Encryption mode.
-        kDecrypt, // Decryption mode.
+        kEncrypt, ///< Encrypt.
+        kDecrypt, ///< Decrypt.
     };
 
     /**
+     * Represents an IEEE 802.15.4 nonce byte sequence.
+     */
+    OT_TOOL_PACKED_BEGIN
+    class Nonce : public Clearable<Nonce>
+    {
+    public:
+        /**
+         * Initializes the nonce from a given extended address, frame counter, and security level.
+         *
+         * @param[in]  aExtAddress     An extended address.
+         * @param[in]  aFrameCounter   A frame counter.
+         * @param[in]  aSecurityLevel  A security level.
+         */
+        void InitFrom(const Mac::ExtAddress &aExtAddress, uint32_t aFrameCounter, uint8_t aSecurityLevel);
+
+    private:
+        Mac::ExtAddress mExtAddress;
+        uint32_t        mFrameCounter;
+        uint8_t         mSecurityLevel;
+    } OT_TOOL_PACKED_END;
+
+    static_assert(sizeof(Nonce) == 13, "Nonce format is not valid");
+
+    /**
      * Sets the key.
      *
-     * @param[in]  aKey    Crypto Key used in AES operation
+     * The passed-in @p aKey and its underlying buffer must remain valid during the lifecycle of the `AesCcm` object.
+     *
+     * @param[in]  aKey    Crypto Key used in AES operation.
      */
-    void SetKey(const Key &aKey) { mEcb.SetKey(aKey); }
+    void SetKey(const Key &aKey) { mConfig.mKey = aKey; }
 
     /**
      * Sets the key.
      *
+     * The passed-in @p aKey buffer must remain valid during the lifecycle of the `AesCcm` object.
+     *
      * @param[in]  aKey        A pointer to the key.
      * @param[in]  aKeyLength  Length of the key in bytes.
      */
-    void SetKey(const uint8_t *aKey, uint16_t aKeyLength);
+    void SetKey(const uint8_t *aKey, uint16_t aKeyLength) { mConfig.GetKey().Set(aKey, aKeyLength); }
 
     /**
      * Sets the key.
      *
+     * The passed-in @p aMacKey and its underlying buffer must remain valid during the lifecycle of the `AesCcm` object.
+     *
      * @param[in]  aMacKey        Key Material for AES operation.
      */
-    void SetKey(const Mac::KeyMaterial &aMacKey);
+    void SetKey(const Mac::KeyMaterial &aMacKey) { aMacKey.ConvertToCryptoKey(mConfig.GetKey()); }
 
     /**
-     * Initializes the AES CCM computation.
+     * Sets the Nonce.
      *
-     * @param[in]  aHeaderLength     Length of header in bytes.
-     * @param[in]  aPlainTextLength  Length of plaintext in bytes.
-     * @param[in]  aTagLength        Length of tag in bytes (must be even and in `[kMinTagLength, kMaxTagLength]`).
-     * @param[in]  aNonce            A pointer to the nonce.
-     * @param[in]  aNonceLength      Length of nonce in bytes.
+     * The passed-in @p aNonce must remain valid during the lifecycle of the `AesCcm` object.
+     *
+     * @param[in]  aNonce  A reference to the Nonce object.
      */
-    void Init(uint32_t    aHeaderLength,
-              uint32_t    aPlainTextLength,
-              uint8_t     aTagLength,
-              const void *aNonce,
-              uint8_t     aNonceLength);
+    void SetNonce(const Nonce &aNonce) { SetNonce(&aNonce, sizeof(Nonce)); }
 
     /**
-     * Processes the header.
+     * Sets the Nonce.
      *
-     * @param[in]  aHeader        A pointer to the header.
-     * @param[in]  aHeaderLength  Length of header in bytes.
+     * The passed-in @p aNonce buffer must remain valid during the lifecycle of the `AesCcm` object.
+     *
+     * @param[in]  aNonce   A pointer to the buffer containing the nonce.
+     * @param[in]  aLength  The length of the nonce in bytes.
      */
-    void Header(const void *aHeader, uint32_t aHeaderLength);
+    void SetNonce(const void *aNonce, uint8_t aLength);
 
     /**
-     * Processes the header.
+     * Sets the Additional Authenticated Data.
      *
-     * @tparam    ObjectType   The object type.
+     * The passed-in @p aAuthData buffer must remain valid during the lifecycle of the `AesCcm` object.
      *
-     * @param[in] aObject      A reference to the object to add to header.
+     * @param[in]  aAuthData  A pointer to the buffer containing the data.
+     * @param[in]  aLength    The length of data in bytes.
      */
-    template <typename ObjectType> void Header(const ObjectType &aObject)
-    {
-        static_assert(!TypeTraits::IsPointer<ObjectType>::kValue, "ObjectType must not be a pointer");
-
-        Header(&aObject, sizeof(ObjectType));
-    }
+    void SetAuthData(const void *aAuthData, uint32_t aLength);
 
     /**
-     * Processes the payload.
+     * Sets the AES-CCM tag (MIC) length.
      *
-     * When decrypting (`kDecrypt`), @p aPlainText can be `nullptr` if the decrypted plaintext is not needed.
-     * Similarly, when encrypting (`kEncrypt`), @p aCipherText can be `nullptr` if the ciphertext is not needed.
-     *
-     * @param[in,out]  aPlainText   A pointer to the plaintext.
-     * @param[in,out]  aCipherText  A pointer to the ciphertext.
-     * @param[in]      aLength      Payload length in bytes.
-     * @param[in]      aMode        Mode to indicate whether to encrypt (`kEncrypt`) or decrypt (`kDecrypt`).
+     * @param[in]  aTagLength  The tag length in bytes (must be even and in [kMinTagLength, kMaxTagLength]).
      */
-    void Payload(void *aPlainText, void *aCipherText, uint32_t aLength, Mode aMode);
+    void SetTagLength(uint8_t aTagLength);
+
+    /**
+     * Performs in-place AES-CCM computation (encryption or decryption) on a contiguous buffer.
+     *
+     * Before calling this method, the `AesCcm` object must be fully configured by calling `SetKey()`, `SetNonce()`,
+     * `SetAuthData()`, and `SetTagLength()`. Otherwise, the behavior of this method is undefined.
+     *
+     * The buffer @p aData must have sufficient space. For encryption, it must be large enough to hold the plaintext
+     * plus the tag. The tag will be appended immediately after the payload bytes. For decryption, the tag must be
+     * present immediately after the ciphertext bytes.
+     *
+     * @param[in]      aOperation   The operation (kEncrypt or kDecrypt).
+     * @param[in,out]  aData        A pointer to the data buffer.
+     * @param[in]      aLength      The length of the payload in bytes (excluding the tag).
+     *
+     * @retval kErrorNone      Operation succeeded (for decryption, this means the tag matched).
+     * @retval kErrorSecurity  Decryption failed because the tag did not match.
+     */
+    Error Process(Operation aOperation, uint8_t *aData, uint32_t aLength);
 
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
     /**
-     * Processes the payload within a given message.
+     * Performs in-place AES-CCM computation (encryption or decryption) on a `Message`
      *
-     * Encrypts/decrypts the payload content in place within the @p aMessage.
+     * Before calling this method, the `AesCcm` object must be fully configured by calling `SetKey()`, `SetNonce()`,
+     * `SetAuthData()`, and `SetTagLength()`. Otherwise, the behavior of this method is undefined.
      *
-     * @param[in,out]  aMessage     The message to read from and update.
-     * @param[in]      aOffset      The offset in @p aMessage to start of payload.
-     * @param[in]      aLength      Payload length in bytes.
-     * @param[in]      aMode        Mode to indicate whether to encrypt (`kEncrypt`) or decrypt (`kDecrypt`).
+     * The operation starts at @p aOffset in the message.
+     *
+     * For encryption, the payload is from @p aOffset to the end of the message. The calculated tag is appended to the
+     * end of the message.
+     *
+     * For decryption, the tag is assumed to be the last bytes of the message. The payload is from @p aOffset up to the
+     * tag (tag length bytes before the end). If decryption succeeds (tag matches), the tag is removed from the
+     * message.
+     *
+     * @param[in]      aOperation   The operation (kEncrypt or kDecrypt).
+     * @param[in,out]  aMessage     The Message.
+     * @param[in]      aOffset      The offset in the message where the payload starts.
+     *
+     * @retval kErrorNone         Operation succeeded.
+     * @retval kErrorSecurity     Decryption failed because the tag did not match.
+     * @retval kErrorNoBufs       Failed to grow the message to append the tag (during encryption).
+     * @retval kErrorInvalidArgs  The @p aOffset is invalid (larger than message length).
      */
-    void Payload(Message &aMessage, uint16_t aOffset, uint16_t aLength, Mode aMode);
+    Error Process(Operation aOperation, Message &aMessage, uint16_t aOffset);
 #endif
 
     /**
-     * Returns the tag length in bytes.
+     * Performs a complete AES-CCM computation (encryption or decryption).
      *
-     * @returns The tag length in bytes.
-     */
-    uint8_t GetTagLength(void) const { return mTagLength; }
-
-    /**
-     * Generates the tag.
+     * @deprecated This method is provided to support the public `otCrypto` API and should not be used
+     *             within the OpenThread core. Core modules should instantiate an `AesCcm` object and use
+     *             the `Process()` methods instead.
      *
-     * @param[out]  aTag        A pointer to the tag (must have `GetTagLength()` bytes).
+     * @param[in]      aOperation       The operation (kEncrypt or kDecrypt).
+     * @param[in]      aKey             The crypto key to use.
+     * @param[in]      aTagLength       The tag length in bytes.
+     * @param[in]      aNonce           A pointer to the nonce.
+     * @param[in]      aNonceLength     The length of the nonce in bytes.
+     * @param[in]      aAuthData        A pointer to the additional authentication data.
+     * @param[in]      aAuthDataLength  The length of the authentication data in bytes.
+     * @param[in,out]  aPlainText       A pointer to the plaintext buffer.
+     * @param[in,out]  aCipherText      A pointer to the ciphertext buffer.
+     * @param[in]      aLength          The length of the payload (plaintext/ciphertext) in bytes.
+     * @param[out]     aTag             A pointer to a buffer to output the calculated tag.
      */
-    void Finalize(void *aTag);
-
-    /**
-     * Generates IEEE 802.15.4 nonce byte sequence.
-     *
-     * @param[in]  aAddress        An extended address.
-     * @param[in]  aFrameCounter   A frame counter.
-     * @param[in]  aSecurityLevel  A security level.
-     * @param[out] aNonce          A buffer (with `kNonceSize` bytes) to place the generated nonce.
-     */
-    static void GenerateNonce(const Mac::ExtAddress &aAddress,
-                              uint32_t               aFrameCounter,
-                              uint8_t                aSecurityLevel,
-                              uint8_t               *aNonce);
+    static void Perform(Operation   aOperation,
+                        const Key  &aKey,
+                        uint8_t     aTagLength,
+                        const void *aNonce,
+                        uint8_t     aNonceLength,
+                        const void *aAuthData,
+                        uint32_t    aAuthDataLength,
+                        void       *aPlainText,
+                        void       *aCipherText,
+                        uint32_t    aLength,
+                        void       *aTag);
 
 private:
-    AesEcb   mEcb;
-    uint8_t  mBlock[AesEcb::kBlockSize];
-    uint8_t  mCtr[AesEcb::kBlockSize];
-    uint8_t  mCtrPad[AesEcb::kBlockSize];
-    uint32_t mHeaderLength;
-    uint32_t mHeaderCur;
-    uint32_t mPlainTextLength;
-    uint32_t mPlainTextCur;
-    uint16_t mBlockLength;
-    uint16_t mCtrLength;
-    uint8_t  mNonceLength;
-    uint8_t  mTagLength;
+    struct Config : public otPlatCryptoAesCcmConfig, public Clearable<Config>
+    {
+        bool       IsValid(void) const;
+        Key       &GetKey(void) { return AsCoreType(&mKey); }
+        const Key &GetKey(void) const { return AsCoreType(&mKey); }
+    };
+
+    class Engine
+    {
+    public:
+        Error ProcessOneShot(Operation aOperation, const Config &aConfig, const uint8_t *aHeader, uint8_t *aData);
+
+        // Multi-part
+        void Start(const Config &aConfig);
+        void AddHeader(const void *aHeader, uint32_t aHeaderLength);
+        void AddPayload(void *aPlainText, void *aCipherText, uint32_t aLength, Operation aOperation);
+        void Finalize(void *aTag);
+
+    private:
+        AesEcb   mEcb;
+        uint8_t  mBlock[AesEcb::kBlockSize];
+        uint8_t  mCtr[AesEcb::kBlockSize];
+        uint8_t  mCtrPad[AesEcb::kBlockSize];
+        uint32_t mHeaderLength;
+        uint32_t mHeaderCur;
+        uint32_t mPlainTextLength;
+        uint32_t mPlainTextCur;
+        uint16_t mBlockLength;
+        uint16_t mCtrLength;
+        uint8_t  mNonceLength;
+        uint8_t  mTagLength;
+    };
+
+    Config         mConfig;
+    const uint8_t *mAuthData;
 };
 
 /**
diff --git a/src/core/crypto/aes_ecb.cpp b/src/core/crypto/aes_ecb.cpp
index 70966e5..577103e 100644
--- a/src/core/crypto/aes_ecb.cpp
+++ b/src/core/crypto/aes_ecb.cpp
@@ -38,12 +38,7 @@
 namespace ot {
 namespace Crypto {
 
-AesEcb::AesEcb(void)
-{
-    mContext.mContext     = mContextStorage;
-    mContext.mContextSize = sizeof(mContextStorage);
-    SuccessOrAssert(otPlatCryptoAesInit(&mContext));
-}
+AesEcb::AesEcb(void) { SuccessOrAssert(otPlatCryptoAesInit(&mContext)); }
 
 void AesEcb::SetKey(const Key &aKey) { SuccessOrAssert(otPlatCryptoAesSetKey(&mContext, &aKey)); }
 
diff --git a/src/core/crypto/aes_ecb.hpp b/src/core/crypto/aes_ecb.hpp
index ef29ef2..d518a0d 100644
--- a/src/core/crypto/aes_ecb.hpp
+++ b/src/core/crypto/aes_ecb.hpp
@@ -85,8 +85,7 @@
     void Encrypt(const uint8_t aInput[kBlockSize], uint8_t aOutput[kBlockSize]);
 
 private:
-    otCryptoContext mContext;
-    OT_DEFINE_ALIGNED_VAR(mContextStorage, kAesContextSize, uint64_t);
+    ContextWith<kAesContextSize> mContext;
 };
 
 /**
diff --git a/src/core/crypto/crypto_platform_mbedtls.cpp b/src/core/crypto/crypto_platform_mbedtls.cpp
index e946ad3..1f1e0fb 100644
--- a/src/core/crypto/crypto_platform_mbedtls.cpp
+++ b/src/core/crypto/crypto_platform_mbedtls.cpp
@@ -37,6 +37,7 @@
 #include <string.h>
 
 #include <mbedtls/aes.h>
+#include <mbedtls/ccm.h>
 #include <mbedtls/cmac.h>
 #include <mbedtls/ctr_drbg.h>
 #include <mbedtls/ecdsa.h>
@@ -152,6 +153,59 @@
     return error;
 }
 
+#if OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+
+OT_TOOL_WEAK otError otPlatCryptoAesCcmProcessOneShot(bool                            aEncrypt,
+                                                      const otPlatCryptoAesCcmConfig *aConfig,
+                                                      const uint8_t                  *aHeader,
+                                                      uint8_t                        *aData)
+{
+    Error               error = kErrorNone;
+    mbedtls_ccm_context ctx;
+    int                 ret;
+
+    mbedtls_ccm_init(&ctx);
+
+    VerifyOrExit(aConfig != nullptr && aConfig->mNonce != nullptr && aData != nullptr, error = kErrorInvalidArgs);
+
+    {
+        const LiteralKey key(*static_cast<const Key *>(&aConfig->mKey));
+
+        ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, key.GetBytes(), key.GetLength() * kBitsPerByte);
+        VerifyOrExit(ret == 0, error = kErrorFailed);
+
+        if (aEncrypt)
+        {
+            ret = mbedtls_ccm_encrypt_and_tag(&ctx, aConfig->mPlainTextLength, aConfig->mNonce, aConfig->mNonceLength,
+                                              aHeader, aConfig->mHeaderLength, aData, aData,
+                                              aData + aConfig->mPlainTextLength, aConfig->mTagLength);
+            VerifyOrExit(ret == 0, error = kErrorFailed);
+        }
+        else
+        {
+            // MBEDTLS_ERR_CCM_AUTH_FAILED is the expected return on tag mismatch; map to kErrorSecurity.
+            ret = mbedtls_ccm_auth_decrypt(&ctx, aConfig->mPlainTextLength, aConfig->mNonce, aConfig->mNonceLength,
+                                           aHeader, aConfig->mHeaderLength, aData, aData,
+                                           aData + aConfig->mPlainTextLength, aConfig->mTagLength);
+
+            if (ret == MBEDTLS_ERR_CCM_AUTH_FAILED)
+            {
+                error = kErrorSecurity;
+            }
+            else
+            {
+                VerifyOrExit(ret == 0, error = kErrorFailed);
+            }
+        }
+    }
+
+exit:
+    mbedtls_ccm_free(&ctx);
+    return error;
+}
+
+#endif // OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
 
 // HMAC implementations
diff --git a/src/core/crypto/crypto_platform_psa.cpp b/src/core/crypto/crypto_platform_psa.cpp
index 8f2e2bd..b204b23 100644
--- a/src/core/crypto/crypto_platform_psa.cpp
+++ b/src/core/crypto/crypto_platform_psa.cpp
@@ -58,6 +58,10 @@
 
 #if OPENTHREAD_CONFIG_CRYPTO_LIB == OPENTHREAD_CONFIG_CRYPTO_LIB_PSA
 
+#if !OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE
+#error "`OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE` is required when `CONFIG_CRYPTO_LIB_PSA` is selected"
+#endif
+
 //---------------------------------------------------------------------------------------------------------------------
 // Default/weak implementation of crypto platform APIs
 
@@ -385,6 +389,49 @@
     return kErrorNone;
 }
 
+#if OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+
+OT_TOOL_WEAK otError otPlatCryptoAesCcmProcessOneShot(bool                            aEncrypt,
+                                                      const otPlatCryptoAesCcmConfig *aConfig,
+                                                      const uint8_t                  *aHeader,
+                                                      uint8_t                        *aData)
+{
+    Error           error = kErrorNone;
+    psa_status_t    status;
+    psa_algorithm_t algorithm;
+    size_t          outputLen = 0;
+
+    VerifyOrExit(aConfig != nullptr && aConfig->mNonce != nullptr && aData != nullptr, error = kErrorInvalidArgs);
+    VerifyOrExit(aConfig->mKey.mKey == nullptr, error = kErrorInvalidArgs);
+
+    algorithm = PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, aConfig->mTagLength);
+
+    if (aEncrypt)
+    {
+        // Output layout: ciphertext || tag, written in-place over the plaintext buffer.
+        status = psa_aead_encrypt(aConfig->mKey.mKeyRef, algorithm, aConfig->mNonce, aConfig->mNonceLength, aHeader,
+                                  aConfig->mHeaderLength, aData, aConfig->mPlainTextLength, aData,
+                                  aConfig->mPlainTextLength + aConfig->mTagLength, &outputLen);
+        SuccessOrExit(error = PsaToOtError(status));
+        VerifyOrExit(outputLen == aConfig->mPlainTextLength + aConfig->mTagLength, error = kErrorFailed);
+    }
+    else
+    {
+        // Input layout: ciphertext || tag contiguous at aData. Output plaintext written in-place.
+        status = psa_aead_decrypt(aConfig->mKey.mKeyRef, algorithm, aConfig->mNonce, aConfig->mNonceLength, aHeader,
+                                  aConfig->mHeaderLength, aData, aConfig->mPlainTextLength + aConfig->mTagLength, aData,
+                                  aConfig->mPlainTextLength, &outputLen);
+        error  = (status == PSA_ERROR_INVALID_SIGNATURE) ? kErrorSecurity : PsaToOtError(status);
+        SuccessOrExit(error);
+        VerifyOrExit(outputLen == aConfig->mPlainTextLength, error = kErrorFailed);
+    }
+
+exit:
+    return error;
+}
+
+#endif // OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
 
 OT_TOOL_WEAK otError otPlatCryptoHmacSha256Init(otCryptoContext *aContext)
diff --git a/src/core/crypto/ecdsa.hpp b/src/core/crypto/ecdsa.hpp
index 648db47..1e3e5de 100644
--- a/src/core/crypto/ecdsa.hpp
+++ b/src/core/crypto/ecdsa.hpp
@@ -164,7 +164,7 @@
          *
          * Gives non-const pointer to the buffer and is intended for populating the buffer and setting
          * the key-pair (e.g., reading the key-pair from non-volatile settings). The buffer contains `kMaxDerSize`
-         * bytes. After populating the buffer, `SetDerLength()` can be used to set the the number of bytes written.
+         * bytes. After populating the buffer, `SetDerLength()` can be used to set the number of bytes written.
          *
          * @returns The pointer to the start of key-pair buffer in DER format.
          */
diff --git a/src/core/crypto/hkdf_sha256.cpp b/src/core/crypto/hkdf_sha256.cpp
index b47b3a4..431f694 100644
--- a/src/core/crypto/hkdf_sha256.cpp
+++ b/src/core/crypto/hkdf_sha256.cpp
@@ -42,12 +42,7 @@
 namespace ot {
 namespace Crypto {
 
-HkdfSha256::HkdfSha256(void)
-{
-    mContext.mContext     = mContextStorage;
-    mContext.mContextSize = sizeof(mContextStorage);
-    SuccessOrAssert(otPlatCryptoHkdfInit(&mContext));
-}
+HkdfSha256::HkdfSha256(void) { SuccessOrAssert(otPlatCryptoHkdfInit(&mContext)); }
 
 HkdfSha256::~HkdfSha256(void) { SuccessOrAssert(otPlatCryptoHkdfDeinit(&mContext)); }
 
diff --git a/src/core/crypto/hkdf_sha256.hpp b/src/core/crypto/hkdf_sha256.hpp
index b2fed0c..d7c37c6 100644
--- a/src/core/crypto/hkdf_sha256.hpp
+++ b/src/core/crypto/hkdf_sha256.hpp
@@ -42,6 +42,7 @@
 #include "common/code_utils.hpp"
 #include "crypto/context_size.hpp"
 #include "crypto/hmac_sha256.hpp"
+#include "crypto/storage.hpp"
 
 namespace ot {
 namespace Crypto {
@@ -93,8 +94,7 @@
     void Expand(const uint8_t *aInfo, uint16_t aInfoLength, uint8_t *aOutputKey, uint16_t aOutputKeyLength);
 
 private:
-    otCryptoContext mContext;
-    OT_DEFINE_ALIGNED_VAR(mContextStorage, kHkdfContextSize, uint64_t);
+    ContextWith<kHkdfContextSize> mContext;
 };
 
 /**
diff --git a/src/core/crypto/hmac_sha256.cpp b/src/core/crypto/hmac_sha256.cpp
index 0f49da0..bca6709 100644
--- a/src/core/crypto/hmac_sha256.cpp
+++ b/src/core/crypto/hmac_sha256.cpp
@@ -39,13 +39,7 @@
 namespace ot {
 namespace Crypto {
 
-HmacSha256::HmacSha256(void)
-{
-    mContext.mContext     = mContextStorage;
-    mContext.mContextSize = sizeof(mContextStorage);
-
-    SuccessOrAssert(otPlatCryptoHmacSha256Init(&mContext));
-}
+HmacSha256::HmacSha256(void) { SuccessOrAssert(otPlatCryptoHmacSha256Init(&mContext)); }
 
 HmacSha256::~HmacSha256(void) { SuccessOrAssert(otPlatCryptoHmacSha256Deinit(&mContext)); }
 
diff --git a/src/core/crypto/hmac_sha256.hpp b/src/core/crypto/hmac_sha256.hpp
index dcfb254..7b79896 100644
--- a/src/core/crypto/hmac_sha256.hpp
+++ b/src/core/crypto/hmac_sha256.hpp
@@ -123,8 +123,7 @@
     void Finish(Hash &aHash);
 
 private:
-    otCryptoContext mContext;
-    OT_DEFINE_ALIGNED_VAR(mContextStorage, kHmacSha256ContextSize, uint64_t);
+    ContextWith<kHmacSha256ContextSize> mContext;
 };
 
 /**
diff --git a/src/core/crypto/mbedtls.cpp b/src/core/crypto/mbedtls.cpp
index fadc1b5..57d7508 100644
--- a/src/core/crypto/mbedtls.cpp
+++ b/src/core/crypto/mbedtls.cpp
@@ -33,7 +33,7 @@
 
 #include "mbedtls.hpp"
 
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
 #include <mbedtls/ctr_drbg.h>
 #include <mbedtls/entropy.h>
 #endif
@@ -71,7 +71,7 @@
     switch (aMbedTlsError)
     {
 #if OPENTHREAD_CONFIG_ECDSA_ENABLE
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     case MBEDTLS_ERR_ECP_BAD_INPUT_DATA:
     case MBEDTLS_ERR_MPI_BAD_INPUT_DATA:
 #endif
@@ -88,7 +88,7 @@
     case MBEDTLS_ERR_PK_INVALID_PUBKEY:
     case MBEDTLS_ERR_PK_INVALID_ALG:
     case MBEDTLS_ERR_PK_UNKNOWN_NAMED_CURVE:
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     case MBEDTLS_ERR_PK_BAD_INPUT_DATA:
 #endif
     case MBEDTLS_ERR_X509_SIG_MISMATCH:
@@ -107,7 +107,7 @@
     case MBEDTLS_ERR_X509_INVALID_EXTENSIONS:
     case MBEDTLS_ERR_X509_UNKNOWN_VERSION:
 #endif // MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     case MBEDTLS_ERR_SSL_BAD_INPUT_DATA:
     case MBEDTLS_ERR_CTR_DRBG_REQUEST_TOO_BIG:
     case MBEDTLS_ERR_CTR_DRBG_INPUT_TOO_BIG:
@@ -117,7 +117,7 @@
         error = kErrorInvalidArgs;
         break;
 
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
 #if OPENTHREAD_CONFIG_ECDSA_ENABLE
     case MBEDTLS_ERR_ECP_BUFFER_TOO_SMALL:
     case MBEDTLS_ERR_MPI_BUFFER_TOO_SMALL:
@@ -135,7 +135,7 @@
     case PSA_ERROR_BUFFER_TOO_SMALL:
 #endif
     case MBEDTLS_ERR_SSL_WANT_WRITE:
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     case MBEDTLS_ERR_ENTROPY_MAX_SOURCES:
 #endif
         error = kErrorNoBufs;
@@ -143,13 +143,13 @@
 
 #ifdef MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED
     case MBEDTLS_ERR_PK_FEATURE_UNAVAILABLE:
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     case MBEDTLS_ERR_PK_SIG_LEN_MISMATCH:
 #endif
     case MBEDTLS_ERR_X509_FEATURE_UNAVAILABLE:
     case MBEDTLS_ERR_X509_CERT_VERIFY_FAILED:
 #endif // MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     case MBEDTLS_ERR_CTR_DRBG_ENTROPY_SOURCE_FAILED:
     case MBEDTLS_ERR_ENTROPY_SOURCE_FAILED:
     case MBEDTLS_ERR_ENTROPY_NO_SOURCES_DEFINED:
@@ -158,7 +158,7 @@
 #if (MBEDTLS_VERSION_NUMBER < 0x03000000)
     case MBEDTLS_ERR_SSL_PEER_VERIFY_FAILED:
 #endif
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     case MBEDTLS_ERR_THREADING_BAD_INPUT_DATA:
 #endif
     case MBEDTLS_ERR_THREADING_MUTEX_ERROR:
diff --git a/src/core/crypto/sha256.cpp b/src/core/crypto/sha256.cpp
index c4c5225..b453f1f 100644
--- a/src/core/crypto/sha256.cpp
+++ b/src/core/crypto/sha256.cpp
@@ -40,12 +40,7 @@
 namespace ot {
 namespace Crypto {
 
-Sha256::Sha256(void)
-{
-    mContext.mContext     = mContextStorage;
-    mContext.mContextSize = sizeof(mContextStorage);
-    SuccessOrAssert(otPlatCryptoSha256Init(&mContext));
-}
+Sha256::Sha256(void) { SuccessOrAssert(otPlatCryptoSha256Init(&mContext)); }
 
 Sha256::~Sha256(void) { SuccessOrAssert(otPlatCryptoSha256Deinit(&mContext)); }
 
diff --git a/src/core/crypto/sha256.hpp b/src/core/crypto/sha256.hpp
index 2e6c272..dadb6f0 100644
--- a/src/core/crypto/sha256.hpp
+++ b/src/core/crypto/sha256.hpp
@@ -47,6 +47,7 @@
 #include "common/equatable.hpp"
 #include "common/type_traits.hpp"
 #include "crypto/context_size.hpp"
+#include "crypto/storage.hpp"
 
 namespace ot {
 
@@ -135,8 +136,7 @@
     void Finish(Hash &aHash);
 
 private:
-    otCryptoContext mContext;
-    OT_DEFINE_ALIGNED_VAR(mContextStorage, kSha256ContextSize, uint64_t);
+    ContextWith<kSha256ContextSize> mContext;
 };
 
 /**
diff --git a/src/core/crypto/storage.hpp b/src/core/crypto/storage.hpp
index e1c11ee..d2a1d96 100644
--- a/src/core/crypto/storage.hpp
+++ b/src/core/crypto/storage.hpp
@@ -44,6 +44,7 @@
 #include "common/error.hpp"
 #include "common/locator.hpp"
 #include "common/non_copyable.hpp"
+#include "common/num_utils.hpp"
 
 namespace ot {
 namespace Crypto {
@@ -264,6 +265,60 @@
 #endif // OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE
 
 /**
+ * Represents a crypto context.
+ */
+class Context : public otCryptoContext
+{
+public:
+    /**
+     * Gets the pointer to the context buffer.
+     *
+     * @returns A pointer to the context buffer.
+     */
+    void *GetContext(void) { return mContext; }
+
+    /**
+     * Gets the size of the context buffer.
+     *
+     * @returns The size of the context buffer in bytes.
+     */
+    uint16_t GetSize(void) const { return mContextSize; }
+
+    /**
+     * Sets the context buffer.
+     *
+     * @param[in] aContext A pointer to the context buffer.
+     * @param[in] aSize    The size of the context buffer in bytes.
+     */
+    void SetContext(void *aContext, uint16_t aSize) { mContext = aContext, mContextSize = aSize; }
+};
+
+/**
+ * Represents a crypto context with a locally allocated buffer.
+ *
+ * @tparam kContextSize The size of the context buffer in bytes.
+ */
+template <uint16_t kContextSize> class ContextWith : public Context
+{
+public:
+    /**
+     * Initializes the context and the locally allocated buffer.
+     */
+    ContextWith(void)
+    {
+        ClearAllBytes(*this);
+#if !OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT
+        SetContext(mStorage, kContextSize);
+#endif
+    }
+
+private:
+#if !OPENTHREAD_CONFIG_CRYPTO_PLATFORM_ALLOCS_CONTEXT
+    uint64_t mStorage[DivideAndRoundUp<uint16_t>(kContextSize, sizeof(uint64_t))];
+#endif
+};
+
+/**
  * Represents a crypto key.
  *
  * The `Key` can represent a literal key (i.e., a pointer to a byte array containing the key along with a key length)
@@ -393,6 +448,7 @@
 
 } // namespace Crypto
 
+DefineCoreType(otCryptoContext, Crypto::Context);
 DefineCoreType(otCryptoKey, Crypto::Key);
 
 } // namespace ot
diff --git a/src/core/diags/factory_diags.cpp b/src/core/diags/factory_diags.cpp
index f9658e5..3071764 100644
--- a/src/core/diags/factory_diags.cpp
+++ b/src/core/diags/factory_diags.cpp
@@ -491,19 +491,24 @@
     VerifyOrExit(!Get<ThreadNetif>().IsUp(), error = kErrorInvalidState);
 #endif
 
+    mStats.Clear();
+    IgnoreError(Get<Radio>().Enable());
+    Get<Radio>().SetDiagMode(true);
     otPlatDiagChannelSet(mChannel);
     otPlatDiagTxPowerSet(mTxPower);
 
-    IgnoreError(Get<Radio>().Enable());
     Get<Radio>().SetPromiscuous(true);
     Get<Mac::SubMac>().SetRxOnWhenIdle(true);
     otPlatAlarmMilliStop(&GetInstance());
     SuccessOrExit(error = Get<Radio>().Receive(mChannel));
     SuccessOrExit(error = Get<Radio>().SetTransmitPower(mTxPower));
-    Get<Radio>().SetDiagMode(true);
-    mStats.Clear();
 
 exit:
+    if (error != kErrorNone)
+    {
+        Get<Radio>().SetDiagMode(false);
+    }
+
     return error;
 }
 
diff --git a/src/core/instance/instance.cpp b/src/core/instance/instance.cpp
index 2c076ed..6b5daac 100644
--- a/src/core/instance/instance.cpp
+++ b/src/core/instance/instance.cpp
@@ -44,20 +44,25 @@
 #error "Exactly one of {OPENTHREAD_FTD, OPENTHREAD_MTD, OPENTHREAD_RADIO} MUST be set"
 #endif
 
+// Size of `ot::Instance` in `uint64_t` unit (aligned).
+constexpr size_t kInstanceSizeInUint64s = DivideAndRoundUp<size_t>(sizeof(Instance), sizeof(uint64_t));
+
 #if !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+// The raw storage used for OpenThread instance (in single-instance case)
+uint64_t gInstanceRaw[kInstanceSizeInUint64s];
+#endif
 
-// Define the raw storage used for OpenThread instance (in single-instance case).
-OT_DEFINE_ALIGNED_VAR(gInstanceRaw, sizeof(Instance), uint64_t);
-
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+// The currently active instance
+Instance *gActiveInstance = nullptr;
 #endif
 
 #if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_MULTIPLE_STATIC_INSTANCE_ENABLE
 
-#define INSTANCE_SIZE_ALIGNED OT_ALIGNED_VAR_SIZE(sizeof(ot::Instance), uint64_t)
-#define MULTI_INSTANCE_SIZE (OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_NUM * INSTANCE_SIZE_ALIGNED)
+constexpr size_t kMultiInstanceSizeInUint64s = (Instance::kNumStaticInstances * kInstanceSizeInUint64s);
 
 // Define the raw storage used for OpenThread instance (in multi-instance case).
-static uint64_t gMultiInstanceRaw[MULTI_INSTANCE_SIZE];
+static uint64_t gMultiInstanceRaw[kMultiInstanceSizeInUint64s];
 
 #endif
 
@@ -68,12 +73,13 @@
 #endif
 #endif
 
-#if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
-LogLevel Instance::sLogLevel = static_cast<LogLevel>(OPENTHREAD_CONFIG_LOG_LEVEL_INIT);
+#if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE && OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+LogLevel Instance::sGlobalLogLevel = static_cast<LogLevel>(OPENTHREAD_CONFIG_LOG_LEVEL_INIT);
 #endif
 
 Instance::Instance(void)
-    : mTimerMilliScheduler(*this)
+    : mActiveInstanceTracker(*this)
+    , mTimerMilliScheduler(*this)
 #if OPENTHREAD_CONFIG_PLATFORM_USEC_TIMER_ENABLE
     , mTimerMicroScheduler(*this)
 #endif
@@ -104,6 +110,9 @@
     , mIp6(*this)
     , mThreadNetif(*this)
     , mTmfAgent(*this)
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+    , mPlatTcp(*this)
+#endif
 #if OPENTHREAD_CONFIG_DHCP6_CLIENT_ENABLE
     , mDhcp6Client(*this)
 #endif
@@ -164,9 +173,9 @@
 #endif
     , mNetworkDataServiceManager(*this)
     , mVendorInfo(*this)
-    , mNetworkDiagnosticServer(*this)
+    , mNetDiagServer(*this)
 #if OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE
-    , mNetworkDiagnosticClient(*this)
+    , mNetDiagClient(*this)
 #endif
 #if OPENTHREAD_CONFIG_BORDER_AGENT_ENABLE
     , mBorderAgentTxtData(*this)
@@ -209,10 +218,6 @@
 #if OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
     , mMlrManager(*this)
 #endif
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-    , mDuaManager(*this)
-#endif
 #if OPENTHREAD_CONFIG_SRP_SERVER_ENABLE
     , mSrpServer(*this)
 #if OPENTHREAD_CONFIG_SRP_SERVER_ADVERTISING_PROXY_ENABLE
@@ -312,8 +317,20 @@
 #if OPENTHREAD_CONFIG_POWER_CALIBRATION_ENABLE && OPENTHREAD_CONFIG_PLATFORM_POWER_CALIBRATION_ENABLE
     , mPowerCalibration(*this)
 #endif
+#if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
+    , mLogLevel(static_cast<LogLevel>(OPENTHREAD_CONFIG_LOG_LEVEL_INIT))
+#if OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+    , mOriginalLogLevel(kLogLevelNone)
+    , mOverrideLogLevel(kLogLevelNone)
+    , mIsLogLevelOverridden(false)
+#endif
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    , mIsLogLevelSet(false)
+#else
+#endif
+#endif
     , mIsInitialized(false)
-    , mId(Random::NonCrypto::GetUint32())
+    , mId(Random::NonCrypto::Generate<uint32_t>())
 {
 #if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE
 #if OPENTHREAD_CONFIG_MULTIPLE_STATIC_INSTANCE_ENABLE
@@ -325,6 +342,10 @@
 #endif
 }
 
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+Instance::~Instance(void) { gActiveInstance = this; }
+#endif
+
 #if (OPENTHREAD_MTD || OPENTHREAD_FTD) && !OPENTHREAD_CONFIG_HEAP_EXTERNAL_ENABLE
 Utils::Heap &Instance::GetHeap(void)
 {
@@ -361,18 +382,19 @@
 }
 
 #else // #if !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+
 #if OPENTHREAD_CONFIG_MULTIPLE_STATIC_INSTANCE_ENABLE
 
 Instance *Instance::InitMultiple(uint8_t aIdx)
 {
     size_t    bufferSize;
-    uint64_t *instanceBuffer = gMultiInstanceRaw + aIdx * INSTANCE_SIZE_ALIGNED;
+    uint64_t *instanceBuffer = gMultiInstanceRaw + aIdx * kInstanceSizeInUint64s;
     Instance *instance       = reinterpret_cast<Instance *>(instanceBuffer);
 
-    VerifyOrExit(aIdx < OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_NUM);
+    VerifyOrExit(aIdx < kNumStaticInstances);
     VerifyOrExit(!instance->mIsInitialized);
 
-    bufferSize = (&gMultiInstanceRaw[MULTI_INSTANCE_SIZE] - instanceBuffer) * sizeof(uint64_t);
+    bufferSize = (&gMultiInstanceRaw[kMultiInstanceSizeInUint64s] - instanceBuffer) * sizeof(uint64_t);
     instance   = Instance::Init(instanceBuffer, &bufferSize);
 
 exit:
@@ -381,13 +403,13 @@
 
 Instance &Instance::Get(uint8_t aIdx)
 {
-    void *instance = gMultiInstanceRaw + aIdx * INSTANCE_SIZE_ALIGNED;
+    void *instance = gMultiInstanceRaw + aIdx * kInstanceSizeInUint64s;
     return *static_cast<Instance *>(instance);
 }
 
 uint8_t Instance::GetIdx(Instance *aInstance)
 {
-    return static_cast<uint8_t>((reinterpret_cast<uint64_t *>(aInstance) - gMultiInstanceRaw) / INSTANCE_SIZE_ALIGNED);
+    return static_cast<uint8_t>((reinterpret_cast<uint64_t *>(aInstance) - gMultiInstanceRaw) / kInstanceSizeInUint64s);
 }
 
 #endif // #if OPENTHREAD_CONFIG_MULTIPLE_STATIC_INSTANCE_ENABLE
@@ -398,8 +420,11 @@
 
     VerifyOrExit(aBufferSize != nullptr);
 
-    // Make sure the input buffer is big enough
-    VerifyOrExit(sizeof(Instance) <= *aBufferSize, *aBufferSize = sizeof(Instance));
+    if (sizeof(Instance) > *aBufferSize)
+    {
+        *aBufferSize = kInstanceSizeInUint64s * sizeof(uint64_t);
+        ExitNow();
+    }
 
     VerifyOrExit(aBuffer != nullptr);
 
@@ -411,6 +436,10 @@
     return instance;
 }
 
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+Instance *Instance::GetActiveInstance(void) { return gActiveInstance; }
+#endif
+
 #endif // OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
 
 void Instance::Reset(void) { otPlatReset(this); }
@@ -474,16 +503,8 @@
 
     IgnoreError(Get<Mac::SubMac>().Disable());
 
-#if !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
-
-    /**
-     * Object was created on buffer, so instead of deleting
-     * the object we call destructor explicitly.
-     */
     this->~Instance();
 
-#endif // !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
-
 exit:
     return;
 }
@@ -569,17 +590,99 @@
 
 #if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
 
-void Instance::SetLogLevel(LogLevel aLogLevel)
+Error Instance::SetLogLevel(LogLevel aLogLevel)
 {
-    if (aLogLevel != sLogLevel)
+    Error error = kErrorNone;
+
+    VerifyOrExit(aLogLevel <= kLogLevelDebg, error = kErrorInvalidArgs);
+
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && !OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+    ExitNow(error = kErrorNotCapable);
+#else
+#if OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+    if (mIsLogLevelOverridden)
     {
-        sLogLevel = aLogLevel;
-        otPlatLogHandleLevelChanged(sLogLevel);
+        mOriginalLogLevel = aLogLevel;
+        aLogLevel         = Max(aLogLevel, mOverrideLogLevel);
     }
+#endif
+    VerifyOrExit(mLogLevel != aLogLevel);
+    mLogLevel = aLogLevel;
+    SignalLogLevelChange();
+#endif
+
+exit:
+    return error;
 }
 
+void Instance::SignalLogLevelChange(void)
+{
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    mIsLogLevelSet = true;
+#else
+    otPlatLogHandleLevelChanged(mLogLevel);
+#endif
+
+    otPlatLogHandleLogLevelChanged(this, mLogLevel);
+}
+
+#if OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+void Instance::OverrideLogLevel(LogLevel aLogLevel)
+{
+    LogLevel logLevel;
+
+    if (!mIsLogLevelOverridden)
+    {
+        mOriginalLogLevel     = GetLogLevel();
+        mIsLogLevelOverridden = true;
+    }
+
+    mOverrideLogLevel = aLogLevel;
+
+    logLevel = Max(mOverrideLogLevel, mOriginalLogLevel);
+
+    VerifyOrExit(mLogLevel != logLevel);
+    mLogLevel = logLevel;
+    SignalLogLevelChange();
+
+exit:
+    return;
+}
+
+void Instance::RestoreLogLevel(void)
+{
+    VerifyOrExit(mIsLogLevelOverridden);
+    mIsLogLevelOverridden = false;
+    IgnoreError(SetLogLevel(mOriginalLogLevel));
+
+exit:
+    return;
+}
+#endif // OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+Error Instance::SetGlobalLogLevel(LogLevel aLogLevel)
+{
+    Error error = kErrorNone;
+
+    VerifyOrExit(aLogLevel <= kLogLevelDebg, error = kErrorInvalidArgs);
+    VerifyOrExit(sGlobalLogLevel != aLogLevel);
+    sGlobalLogLevel = aLogLevel;
+    otPlatLogHandleLevelChanged(sGlobalLogLevel);
+
+exit:
+    return error;
+}
+#endif
+
 extern "C" OT_TOOL_WEAK void otPlatLogHandleLevelChanged(otLogLevel aLogLevel) { OT_UNUSED_VARIABLE(aLogLevel); }
 
-#endif
+extern "C" OT_TOOL_WEAK void otPlatLogHandleLogLevelChanged(otInstance *aInstance, otLogLevel aLogLevel)
+{
+    OT_UNUSED_VARIABLE(aInstance);
+    OT_UNUSED_VARIABLE(aLogLevel);
+}
+
+#endif // OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
 
 } // namespace ot
diff --git a/src/core/instance/instance.hpp b/src/core/instance/instance.hpp
index fc5a42a..1f0c77f 100644
--- a/src/core/instance/instance.hpp
+++ b/src/core/instance/instance.hpp
@@ -116,6 +116,7 @@
 #include "net/nat64_translator.hpp"
 #include "net/nd_agent.hpp"
 #include "net/netif.hpp"
+#include "net/plat_tcp.hpp"
 #include "net/slaac_address.hpp"
 #include "net/sntp_client.hpp"
 #include "net/srp_advertising_proxy.hpp"
@@ -128,7 +129,6 @@
 #include "thread/anycast_locator.hpp"
 #include "thread/child_supervision.hpp"
 #include "thread/discover_scanner.hpp"
-#include "thread/dua_manager.hpp"
 #include "thread/energy_scan_server.hpp"
 #include "thread/key_manager.hpp"
 #include "thread/link_metrics.hpp"
@@ -137,11 +137,11 @@
 #include "thread/message_framer.hpp"
 #include "thread/mle.hpp"
 #include "thread/mlr_manager.hpp"
+#include "thread/net_diag.hpp"
 #include "thread/network_data_local.hpp"
 #include "thread/network_data_notifier.hpp"
 #include "thread/network_data_publisher.hpp"
 #include "thread/network_data_service.hpp"
-#include "thread/network_diagnostic.hpp"
 #include "thread/panid_query_server.hpp"
 #include "thread/radio_selector.hpp"
 #include "thread/thread_netif.hpp"
@@ -210,6 +210,11 @@
 
 #if OPENTHREAD_CONFIG_MULTIPLE_STATIC_INSTANCE_ENABLE
     /**
+     * Specifies number of static OpenThread instances.
+     */
+    static constexpr uint16_t kNumStaticInstances = OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_NUM;
+
+    /**
      * This static method initializes the OpenThread instance.
      *
      * This method utilizes static buffer to initialize the OpenThread instance.
@@ -317,14 +322,30 @@
      *
      * @returns The log level.
      */
-    static LogLevel GetLogLevel(void)
-#if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
+    LogLevel GetLogLevel(void) const
     {
-        return sLogLevel;
-    }
-#else
-    {
+#if !OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
         return static_cast<LogLevel>(OPENTHREAD_CONFIG_LOG_LEVEL);
+#elif !OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+        return mLogLevel;
+#else
+        return (mIsLogLevelSet) ? mLogLevel : sGlobalLogLevel;
+#endif
+    }
+
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    /**
+     * Returns the global log level.
+     *
+     * @returns The global log level.
+     */
+    static LogLevel GetGlobalLogLevel(void)
+    {
+#if !OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
+        return static_cast<LogLevel>(OPENTHREAD_CONFIG_LOG_LEVEL);
+#else
+        return sGlobalLogLevel;
+#endif
     }
 #endif
 
@@ -333,9 +354,50 @@
      * Sets the log level.
      *
      * @param[in] aLogLevel  A log level.
+     *
+     * @retval kErrorNone         Successfully updated the log level.
+     * @retval kErrorInvalidArgs  The given log level is invalid.
+     * @retval kErrorNotCapable   Instance-aware logging is not enabled in a multi-instance configuration.
      */
-    static void SetLogLevel(LogLevel aLogLevel);
+    Error SetLogLevel(LogLevel aLogLevel);
+
+#if OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+    /**
+     * Overrides the current log level with a new one.
+     *
+     * The active log level will be the maximum of the original (user-set) log level and the override log level.
+     * If this is the first time an override is requested, the current log level is saved as the original log level.
+     *
+     * Subsequent calls to `SetLogLevel()` will update the original log level, and the active log level will be
+     * updated accordingly (again as the maximum of the new original and the override level).
+     *
+     * This method can be called multiple times to change the override log level.
+     *
+     * @param[in] aLogLevel  The override log level.
+     */
+    void OverrideLogLevel(LogLevel aLogLevel);
+
+    /**
+     * Restores the log level to its original (user-set) value.
+     *
+     * This clears the log level override state. If the log level is not currently overridden, calling this
+     * method has no effect.
+     */
+    void RestoreLogLevel(void);
+#endif // OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    /**
+     * Sets the global log level.
+     *
+     * @param[in] aLogLevel  A log level.
+     *
+     * @retval kErrorNone         Successfully updated the log level.
+     * @retval kErrorInvalidArgs  The given log level is invalid.
+     */
+    static Error SetGlobalLogLevel(LogLevel aLogLevel);
 #endif
+#endif // OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
 
     /**
      * Finalizes the OpenThread instance.
@@ -399,7 +461,7 @@
 #endif
 
     /**
-     * Retrieves the the Message Buffer information.
+     * Retrieves the Message Buffer information.
      *
      * @param[out]  aInfo  A `BufferInfo` where information is written.
      */
@@ -429,6 +491,17 @@
      */
     template <typename Type> inline Type &Get(void);
 
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+    /**
+     * Gets the currently active OpenThread instance.
+     *
+     * It is used to determine the active instance primarily for logging purposes.
+     *
+     * @returns A pointer to the active OpenThread instance, or `nullptr` if not known.
+     */
+    static Instance *GetActiveInstance(void);
+#endif
+
 #if OPENTHREAD_PLATFORM_NEXUS
     /**
      * Constructor to initialize an `Instance`
@@ -441,17 +514,54 @@
     void AfterInit(void);
 #endif
 
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+    /**
+     * Destructor
+     */
+    ~Instance(void);
+#endif
+
 private:
+    class ActiveInstanceTracker
+    {
+    public:
+        // This class is used to track and update the `gActiveInstance` pointer during the
+        // construction and destruction of an `Instance`.
+        //
+        // `mActiveInstanceTracker` is defined as the very first member variable in `Instance`.
+        // This ensures its constructor is called before any other member components are
+        // initialized, and its destructor is called after all other components are destroyed.
+        // This ensures `gActiveInstance` is correctly set during the entire lifecycle of
+        // the `Instance`, allowing member components to safely emit logs.
+        //
+        // The `Instance` destructor also explicitly sets `gActiveInstance` to the instance
+        // being destroyed at the beginning of its body. This ensures that during the
+        // entire destruction process, log messages are correctly associated with the
+        // instance being destroyed. Finally, when `mActiveInstanceTracker` itself is
+        // destroyed (at the end of the `Instance` destruction), `gActiveInstance` is
+        // set to `nullptr` to avoid any potential use of a dangling pointer.
+
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+        ActiveInstanceTracker(Instance &aInstance) { gActiveInstance = &aInstance; }
+        ~ActiveInstanceTracker(void) { gActiveInstance = nullptr; }
+#else
+        ActiveInstanceTracker(Instance &) {}
+#endif
+    };
+
 #if !OPENTHREAD_PLATFORM_NEXUS
     Instance(void);
     void AfterInit(void);
 #endif
+#if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
+    void SignalLogLevelChange(void);
+#endif
 
     //-----------------------------------------------------------------------------------------------------------------
     // `static` variables
 
-#if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
-    static LogLevel sLogLevel;
+#if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE && OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    static LogLevel sGlobalLogLevel;
 #endif
 
 #if (OPENTHREAD_MTD || OPENTHREAD_FTD) && !OPENTHREAD_CONFIG_HEAP_EXTERNAL_ENABLE
@@ -466,6 +576,8 @@
     // Tasklet and Timer Schedulers are first to ensure other
     // objects/classes can use them from their constructors.
 
+    ActiveInstanceTracker mActiveInstanceTracker;
+
     Tasklet::Scheduler    mTaskletScheduler;
     TimerMilli::Scheduler mTimerMilliScheduler;
 #if OPENTHREAD_CONFIG_PLATFORM_USEC_TIMER_ENABLE
@@ -520,6 +632,10 @@
     ThreadNetif mThreadNetif;
     Tmf::Agent  mTmfAgent;
 
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+    Ip6::PlatTcp mPlatTcp;
+#endif
+
 #if OPENTHREAD_CONFIG_DHCP6_CLIENT_ENABLE
     Dhcp6::Client mDhcp6Client;
 #endif
@@ -599,9 +715,9 @@
 
     VendorInfo mVendorInfo;
 
-    NetworkDiagnostic::Server mNetworkDiagnosticServer;
+    NetDiag::Server mNetDiagServer;
 #if OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE
-    NetworkDiagnostic::Client mNetworkDiagnosticClient;
+    NetDiag::Client mNetDiagClient;
 #endif
 
 #if OPENTHREAD_CONFIG_BORDER_AGENT_ENABLE
@@ -654,11 +770,7 @@
 #endif
 
 #if OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
-    MlrManager mMlrManager;
-#endif
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-    DuaManager mDuaManager;
+    Mlr::Manager mMlrManager;
 #endif
 
 #if OPENTHREAD_CONFIG_SRP_SERVER_ENABLE
@@ -785,6 +897,18 @@
     Utils::PowerCalibration mPowerCalibration;
 #endif
 
+#if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
+    LogLevel mLogLevel;
+#if OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE
+    LogLevel mOriginalLogLevel;
+    LogLevel mOverrideLogLevel;
+    bool     mIsLogLevelOverridden;
+#endif
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    bool mIsLogLevelSet;
+#endif
+#endif
+
     bool mIsInitialized;
 
     uint32_t mId;
@@ -946,6 +1070,10 @@
 
 template <> inline Ip6::Mpl &Instance::Get(void) { return mIp6.mMpl; }
 
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+template <> inline Ip6::PlatTcp &Instance::Get(void) { return mPlatTcp; }
+#endif
+
 template <> inline Tmf::Agent &Instance::Get(void) { return mTmfAgent; }
 
 #if OPENTHREAD_CONFIG_SECURE_TRANSPORT_ENABLE
@@ -1004,10 +1132,10 @@
 
 template <> inline VendorInfo &Instance::Get(void) { return mVendorInfo; }
 
-template <> inline NetworkDiagnostic::Server &Instance::Get(void) { return mNetworkDiagnosticServer; }
+template <> inline NetDiag::Server &Instance::Get(void) { return mNetDiagServer; }
 
 #if OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE
-template <> inline NetworkDiagnostic::Client &Instance::Get(void) { return mNetworkDiagnosticClient; }
+template <> inline NetDiag::Client &Instance::Get(void) { return mNetDiagClient; }
 #endif
 
 #if OPENTHREAD_CONFIG_DHCP6_CLIENT_ENABLE
@@ -1118,13 +1246,6 @@
 }
 #endif
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-template <> inline BackboneRouter::NdProxyTable &Instance::Get(void)
-{
-    return mBackboneRouterManager.GetNdProxyTable();
-}
-#endif
-
 template <> inline BackboneRouter::BackboneTmfAgent &Instance::Get(void)
 {
     return mBackboneRouterManager.GetBackboneTmfAgent();
@@ -1132,11 +1253,7 @@
 #endif
 
 #if OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
-template <> inline MlrManager &Instance::Get(void) { return mMlrManager; }
-#endif
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-template <> inline DuaManager &Instance::Get(void) { return mDuaManager; }
+template <> inline Mlr::Manager &Instance::Get(void) { return mMlrManager; }
 #endif
 
 #if OPENTHREAD_CONFIG_MLE_LINK_METRICS_INITIATOR_ENABLE
diff --git a/src/core/mac/channel_mask.cpp b/src/core/mac/channel_mask.cpp
index 810d53d..87e00db 100644
--- a/src/core/mac/channel_mask.cpp
+++ b/src/core/mac/channel_mask.cpp
@@ -80,7 +80,7 @@
 
     VerifyOrExit(!IsEmpty());
 
-    randomIndex = Random::NonCrypto::GetUint8InRange(0, GetNumberOfChannels());
+    randomIndex = Random::NonCrypto::GenerateUpToExcluding(GetNumberOfChannels());
 
     SuccessOrExit(GetNextChannel(channel));
 
diff --git a/src/core/mac/data_poll_handler.cpp b/src/core/mac/data_poll_handler.cpp
index e567daf..0e78d63 100644
--- a/src/core/mac/data_poll_handler.cpp
+++ b/src/core/mac/data_poll_handler.cpp
@@ -89,7 +89,8 @@
     Child       *child;
     uint16_t     indirectMsgCount;
 
-    VerifyOrExit(aFrame.GetSecurityEnabled());
+    VerifyOrExit(aFrame.IsSecuredWith(Mac::RxFrame::kAllowKeyIdMode1));
+
     VerifyOrExit(!Get<Mle::Mle>().IsDetached());
 
     SuccessOrExit(aFrame.GetSrcAddr(macSource));
diff --git a/src/core/mac/data_poll_sender.cpp b/src/core/mac/data_poll_sender.cpp
index 3595eb3..727b6ae 100644
--- a/src/core/mac/data_poll_sender.cpp
+++ b/src/core/mac/data_poll_sender.cpp
@@ -245,7 +245,7 @@
         mPollTxFailureCounter++;
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-        maxRetxAttempts = aFrame.HasCslIe() ? kMaxCslPollRetxAttempts : kMaxPollRetxAttempts;
+        maxRetxAttempts = aFrame.Has<Mac::CslIe>() ? kMaxCslPollRetxAttempts : kMaxPollRetxAttempts;
 #else
         maxRetxAttempts = kMaxPollRetxAttempts;
 #endif
@@ -329,7 +329,7 @@
     VerifyOrExit(aFrame.GetSecurityEnabled());
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-    if (aFrame.mInfo.mTxInfo.mIsARetx && aFrame.HasCslIe())
+    if (aFrame.mInfo.mTxInfo.mIsARetx && aFrame.Has<Mac::CslIe>())
     {
         // For retransmission frame, use a data poll to resync its parent with correct CSL phase
         sendDataPoll = true;
@@ -539,39 +539,39 @@
 
 Mac::TxFrame *DataPollSender::PrepareDataRequest(Mac::TxFrames &aTxFrames)
 {
-    Mac::TxFrame      *frame = nullptr;
-    Mac::TxFrame::Info frameInfo;
+    Mac::TxFrame           *frame = nullptr;
+    Mac::TxFrame::BuildInfo buildInfo;
 
 #if OPENTHREAD_CONFIG_MULTI_RADIO
     Mac::RadioType radio;
 
-    SuccessOrExit(GetPollDestinationAddress(frameInfo.mAddrs.mDestination, radio));
+    SuccessOrExit(GetPollDestinationAddress(buildInfo.mAddrs.mDestination, radio));
     frame = &aTxFrames.GetTxFrame(radio);
 #else
-    SuccessOrExit(GetPollDestinationAddress(frameInfo.mAddrs.mDestination));
+    SuccessOrExit(GetPollDestinationAddress(buildInfo.mAddrs.mDestination));
     frame = &aTxFrames.GetTxFrame();
 #endif
 
-    if (frameInfo.mAddrs.mDestination.IsExtended())
+    if (buildInfo.mAddrs.mDestination.IsExtended())
     {
-        frameInfo.mAddrs.mSource.SetExtended(Get<Mac::Mac>().GetExtAddress());
+        buildInfo.mAddrs.mSource.SetExtended(Get<Mac::Mac>().GetExtAddress());
     }
     else
     {
-        frameInfo.mAddrs.mSource.SetShort(Get<Mac::Mac>().GetShortAddress());
+        buildInfo.mAddrs.mSource.SetShort(Get<Mac::Mac>().GetShortAddress());
     }
 
-    frameInfo.mPanIds.SetBothSourceDestination(Get<Mac::Mac>().GetPanId());
+    buildInfo.mPanIds.SetBothSourceDestination(Get<Mac::Mac>().GetPanId());
 
-    frameInfo.mType          = Mac::Frame::kTypeMacCmd;
-    frameInfo.mCommandId     = Mac::Frame::kMacCmdDataRequest;
-    frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
-    frameInfo.mKeyIdMode     = Mac::Frame::kKeyIdMode1;
+    buildInfo.mType          = Mac::Frame::kTypeMacCmd;
+    buildInfo.mCommandId     = Mac::Frame::kMacCmdDataRequest;
+    buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+    buildInfo.mKeyIdMode     = Mac::Frame::kKeyIdMode1;
 
-    Get<MessageFramer>().PrepareMacHeaders(*frame, frameInfo, nullptr);
+    Get<MessageFramer>().PrepareMacHeaders(*frame, buildInfo, nullptr);
 
 #if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-    if (frame->HasCslIe())
+    if (frame->Has<Mac::CslIe>())
     {
         // Disable frame retransmission when the data poll has CSL IE included
         aTxFrames.SetMaxFrameRetries(0);
diff --git a/src/core/mac/link_raw.cpp b/src/core/mac/link_raw.cpp
index db5d5c8..119f217 100644
--- a/src/core/mac/link_raw.cpp
+++ b/src/core/mac/link_raw.cpp
@@ -253,9 +253,9 @@
 
     VerifyOrExit(IsEnabled(), error = kErrorInvalidState);
 
-    prevKey.SetFrom(aPrevKey);
-    currKey.SetFrom(aCurrKey);
-    nextKey.SetFrom(aNextKey);
+    prevKey.SetFrom(aPrevKey, kDefaultMacKeysExportable);
+    currKey.SetFrom(aCurrKey, kDefaultMacKeysExportable);
+    nextKey.SetFrom(aNextKey, kDefaultMacKeysExportable);
 
     mSubMac.SetMacKey(aKeyIdMode, aKeyId, prevKey, currKey, nextKey);
 
diff --git a/src/core/mac/mac.cpp b/src/core/mac/mac.cpp
index 37cc5e5..ec9821f 100644
--- a/src/core/mac/mac.cpp
+++ b/src/core/mac/mac.cpp
@@ -66,8 +66,8 @@
 #endif
     , mOperation(kOperationIdle)
     , mPendingOperations(0)
-    , mBeaconSequence(Random::NonCrypto::GetUint8())
-    , mDataSequence(Random::NonCrypto::GetUint8())
+    , mBeaconSequence(Random::NonCrypto::Generate<uint8_t>())
+    , mDataSequence(Random::NonCrypto::Generate<uint8_t>())
     , mBroadcastTransmitCount(0)
     , mPanId(kPanIdBroadcast)
     , mPanChannel(OPENTHREAD_CONFIG_DEFAULT_CHANNEL)
@@ -703,31 +703,31 @@
     mOperation = kOperationIdle;
 }
 
-TxFrame *Mac::PrepareBeaconRequest(void)
+TxFrame *Mac::PrepareBeaconRequest(TxFrames &aTxFrames)
 {
-    TxFrame      &frame = mLinks.GetTxFrames().GetBroadcastTxFrame();
-    TxFrame::Info frameInfo;
+    TxFrame           &frame = aTxFrames.GetBroadcastTxFrame();
+    TxFrame::BuildInfo buildInfo;
 
-    frameInfo.mAddrs.mSource.SetNone();
-    frameInfo.mAddrs.mDestination.SetShort(kShortAddrBroadcast);
-    frameInfo.mPanIds.SetDestination(kShortAddrBroadcast);
+    buildInfo.mAddrs.mSource.SetNone();
+    buildInfo.mAddrs.mDestination.SetShort(kShortAddrBroadcast);
+    buildInfo.mPanIds.SetDestination(kShortAddrBroadcast);
 
-    frameInfo.mType      = Frame::kTypeMacCmd;
-    frameInfo.mCommandId = Frame::kMacCmdBeaconRequest;
-    frameInfo.mVersion   = Frame::kVersion2003;
+    buildInfo.mType      = Frame::kTypeMacCmd;
+    buildInfo.mCommandId = Frame::kMacCmdBeaconRequest;
+    buildInfo.mVersion   = Frame::kVersion2003;
 
-    frameInfo.PrepareHeadersIn(frame);
+    buildInfo.PrepareHeadersIn(frame);
 
     LogInfo("Sending Beacon Request");
 
     return &frame;
 }
 
-TxFrame *Mac::PrepareBeacon(void)
+TxFrame *Mac::PrepareBeacon(TxFrames &aTxFrames)
 {
-    TxFrame      *frame;
-    TxFrame::Info frameInfo;
-    Beacon       *beacon = nullptr;
+    TxFrame           *frame;
+    TxFrame::BuildInfo buildInfo;
+    Beacon            *beacon = nullptr;
 #if OPENTHREAD_CONFIG_MAC_OUTGOING_BEACON_PAYLOAD_ENABLE
     uint8_t        beaconLength;
     BeaconPayload *beaconPayload = nullptr;
@@ -735,20 +735,20 @@
 
 #if OPENTHREAD_CONFIG_MULTI_RADIO
     OT_ASSERT(!mTxBeaconRadioLinks.IsEmpty());
-    frame = &mLinks.GetTxFrames().GetTxFrame(mTxBeaconRadioLinks);
+    frame = &aTxFrames.GetTxFrame(mTxBeaconRadioLinks);
     mTxBeaconRadioLinks.Clear();
 #else
-    frame = &mLinks.GetTxFrames().GetBroadcastTxFrame();
+    frame = &aTxFrames.GetBroadcastTxFrame();
 #endif
 
-    frameInfo.mAddrs.mSource.SetExtended(GetExtAddress());
-    frameInfo.mPanIds.SetSource(mPanId);
-    frameInfo.mAddrs.mDestination.SetNone();
+    buildInfo.mAddrs.mSource.SetExtended(GetExtAddress());
+    buildInfo.mPanIds.SetSource(mPanId);
+    buildInfo.mAddrs.mDestination.SetNone();
 
-    frameInfo.mType    = Frame::kTypeBeacon;
-    frameInfo.mVersion = Frame::kVersion2003;
+    buildInfo.mType    = Frame::kTypeBeacon;
+    buildInfo.mVersion = Frame::kVersion2003;
 
-    frameInfo.PrepareHeadersIn(*frame);
+    buildInfo.PrepareHeadersIn(*frame);
 
     beacon = reinterpret_cast<Beacon *>(frame->GetPayload());
     beacon->Init();
@@ -900,7 +900,7 @@
 
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
     // Transmit security will be processed after time IE content is updated.
-    VerifyOrExit(aFrame.GetTimeIeOffset() == 0);
+    VerifyOrExit(!aFrame.Has<TimeIe>());
 #endif
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
@@ -917,11 +917,9 @@
 void Mac::BeginTransmit(void)
 {
     TxFrame  *frame    = nullptr;
-    TxFrames &txFrames = mLinks.GetTxFrames();
+    TxFrames &txFrames = mLinks.InitTxFrames();
     Address   dstAddr;
 
-    txFrames.Clear();
-
 #if OPENTHREAD_CONFIG_MULTI_RADIO
     mTxPendingRadioLinks.Clear();
     mTxError = kErrorAbort;
@@ -933,7 +931,7 @@
     {
     case kOperationActiveScan:
         mLinks.SetPanId(kPanIdBroadcast);
-        frame = PrepareBeaconRequest();
+        frame = PrepareBeaconRequest(txFrames);
         VerifyOrExit(frame != nullptr);
         frame->SetChannel(mScanChannel);
         frame->SetSequence(0);
@@ -942,7 +940,7 @@
         break;
 
     case kOperationTransmitBeacon:
-        frame = PrepareBeacon();
+        frame = PrepareBeacon(txFrames);
         VerifyOrExit(frame != nullptr);
         frame->SetChannel(mRadioChannel);
         frame->SetSequence(mBeaconSequence++);
@@ -1019,12 +1017,17 @@
 
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
     {
-        uint8_t timeIeOffset = GetTimeIeOffset(*frame);
+        TimeIe *timeIe = frame->Find<TimeIe>();
 
-        frame->SetTimeIeOffset(timeIeOffset);
-
-        if (timeIeOffset != 0)
+        if (timeIe == nullptr)
         {
+            frame->SetTimeIeOffset(0);
+        }
+        else
+        {
+            uint8_t offset = static_cast<uint8_t>(timeIe->GetData() - frame->GetPsdu());
+
+            frame->SetTimeIeOffset(offset);
             frame->SetTimeSyncSeq(Get<TimeSync>().GetTimeSyncSeq());
             frame->SetNetworkTimeOffset(Get<TimeSync>().GetNetworkTimeOffset());
         }
@@ -1305,7 +1308,7 @@
                 ProcessCsl(*aAckFrame, dstAddr);
 #endif
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-                if (!mRxOnWhenIdle && aFrame.HasCslIe())
+                if (!mRxOnWhenIdle && aFrame.Has<CslIe>())
                 {
                     Get<DataPollSender>().ResetKeepAliveTimer();
                 }
@@ -1319,7 +1322,7 @@
     if (!aFrame.IsEmpty())
     {
         RadioType  radio          = aFrame.GetRadioType();
-        RadioTypes requiredRadios = mLinks.GetTxFrames().GetRequiredRadioTypes();
+        RadioTypes requiredRadios = mLinks.GetTxFramesRequiredRadioTypes();
 
         Get<RadioSelector>().UpdateOnSendDone(aFrame, aError);
 
@@ -1536,6 +1539,7 @@
     switch (keyIdMode)
     {
     case Frame::kKeyIdMode0:
+        VerifyOrExit(keyManager.IsKekSet(), error = kErrorSecurity);
         macKey     = &keyManager.GetKek();
         extAddress = &aSrcAddr.GetExtended();
         break;
@@ -2353,24 +2357,6 @@
 
 // LCOV_EXCL_STOP
 
-#if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-uint8_t Mac::GetTimeIeOffset(const Frame &aFrame)
-{
-    uint8_t        offset = 0;
-    const uint8_t *base   = aFrame.GetPsdu();
-    const uint8_t *cur    = nullptr;
-
-    cur = reinterpret_cast<const uint8_t *>(aFrame.GetTimeIe());
-    VerifyOrExit(cur != nullptr);
-
-    cur += sizeof(VendorIeHeader);
-    offset = static_cast<uint8_t>(cur - base);
-
-exit:
-    return offset;
-}
-#endif
-
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
 void Mac::SetCslCapable(bool aIsCslCapable)
 {
@@ -2490,9 +2476,10 @@
     CslNeighbor *neighbor = nullptr;
     const CslIe *csl;
 
-    VerifyOrExit(aFrame.IsVersion2015() && aFrame.GetSecurityEnabled());
+    VerifyOrExit(aFrame.IsVersion2015());
+    VerifyOrExit(aFrame.IsSecuredWith(RxFrame::kAllowKeyIdMode1));
 
-    csl = aFrame.GetCslIe();
+    csl = aFrame.Find<CslIe>();
     VerifyOrExit(csl != nullptr);
 
 #if OPENTHREAD_FTD
@@ -2511,7 +2498,7 @@
     neighbor->SetCslLastHeard(TimerMilli::GetNow());
     neighbor->SetLastRxTimestamp(aFrame.GetTimestamp());
     LogDebg("Timestamp=%lu Sequence=%u CslPeriod=%u CslPhase=%u TransmitPhase=%u",
-            ToUlong(static_cast<uint32_t>(aFrame.GetTimestamp())), aFrame.GetSequence(), csl->GetPeriod(),
+            ToUlong(ConvertRadioTime64To32(aFrame.GetTimestamp())), aFrame.GetSequence(), csl->GetPeriod(),
             csl->GetPhase(), neighbor->GetCslPhase());
 
 #if OPENTHREAD_FTD
@@ -2526,24 +2513,20 @@
 #if OPENTHREAD_CONFIG_MLE_LINK_METRICS_INITIATOR_ENABLE
 void Mac::ProcessEnhAckProbing(const RxFrame &aFrame, const Neighbor &aNeighbor)
 {
-    constexpr uint8_t kEnhAckProbingIeMaxLen = 2;
+    const LinkMetricsProbingIe *probingIe = aFrame.Find<LinkMetricsProbingIe>();
+    uint8_t                     dataLen;
 
-    const HeaderIe *enhAckProbingIe =
-        reinterpret_cast<const HeaderIe *>(aFrame.GetThreadIe(ThreadIe::kEnhAckProbingIe));
-    const uint8_t *data =
-        reinterpret_cast<const uint8_t *>(enhAckProbingIe) + sizeof(HeaderIe) + sizeof(VendorIeHeader);
-    uint8_t dataLen = 0;
+    VerifyOrExit(probingIe != nullptr);
 
-    VerifyOrExit(enhAckProbingIe != nullptr);
+    dataLen = probingIe->GetMetricsDataLen();
+    VerifyOrExit(dataLen <= LinkMetricsProbingIe::kMaxMetricsDataLen);
 
-    dataLen = enhAckProbingIe->GetLength() - sizeof(VendorIeHeader);
-    VerifyOrExit(dataLen <= kEnhAckProbingIeMaxLen);
+    Get<LinkMetrics::Initiator>().ProcessEnhAckIeData(probingIe->GetMetricsData(), dataLen, aNeighbor);
 
-    Get<LinkMetrics::Initiator>().ProcessEnhAckIeData(data, dataLen, aNeighbor);
 exit:
     return;
 }
-#endif // OPENTHREAD_CONFIG_MLE_LINK_METRICS_INITIATOR_ENABLE
+#endif
 
 #if OPENTHREAD_CONFIG_MAC_FILTER_ENABLE && OPENTHREAD_CONFIG_RADIO_LINK_IEEE_802_15_4_ENABLE
 void Mac::SetRadioFilterEnabled(bool aFilterEnabled)
@@ -2639,9 +2622,9 @@
     const ConnectionIe *connectionIe;
     Address             srcAddress;
     WakeupInfo          wakeupInfo;
-    uint32_t            rvTimeUs;
-    uint64_t            rvTimestampUs;
-    uint64_t            radioNowUs;
+    RadioTime32         rvTimeUs;
+    RadioTime64         rvTimestampUs;
+    RadioTime64         radioNowUs;
 
     VerifyOrExit(mWakeupListenEnabled && aFrame.IsWakeupFrame());
 
@@ -2649,18 +2632,18 @@
     VerifyOrExit(srcAddress.IsExtended(), error = kErrorDrop);
 
     wakeupInfo.mExtAddress    = srcAddress.GetExtended();
-    connectionIe              = aFrame.GetConnectionIe();
+    connectionIe              = aFrame.Find<ConnectionIe>();
     wakeupInfo.mRetryInterval = connectionIe->GetRetryInterval();
     wakeupInfo.mRetryCount    = connectionIe->GetRetryCount();
     VerifyOrExit(wakeupInfo.mRetryInterval > 0 && wakeupInfo.mRetryCount > 0, error = kErrorInvalidArgs);
 
-    radioNowUs    = otPlatRadioGetNow(&GetInstance());
-    rvTimeUs      = aFrame.GetRendezvousTimeIe()->GetRendezvousTime() * kUsPerTenSymbols;
+    radioNowUs    = Get<Radio>().GetNow();
+    rvTimeUs      = aFrame.Find<RendezvousTimeIe>()->GetRendezvousTime() * kUsPerTenSymbols;
     rvTimestampUs = aFrame.GetTimestamp() + kRadioHeaderPhrDuration + aFrame.GetLength() * kOctetDuration + rvTimeUs;
 
     if (rvTimestampUs > radioNowUs + kCslRequestAhead)
     {
-        wakeupInfo.mAttachDelayMs = static_cast<uint32_t>(rvTimestampUs - radioNowUs - kCslRequestAhead);
+        wakeupInfo.mAttachDelayMs = ConvertRadioTime64To32(rvTimestampUs - radioNowUs - kCslRequestAhead);
         wakeupInfo.mAttachDelayMs = wakeupInfo.mAttachDelayMs / Time::kOneMsecInUsec;
     }
     else
diff --git a/src/core/mac/mac.hpp b/src/core/mac/mac.hpp
index 9d6904d..97b8f92 100644
--- a/src/core/mac/mac.hpp
+++ b/src/core/mac/mac.hpp
@@ -832,8 +832,8 @@
     void     StartOperation(Operation aOperation);
     void     FinishOperation(void);
     void     PerformNextOperation(void);
-    TxFrame *PrepareBeaconRequest(void);
-    TxFrame *PrepareBeacon(void);
+    TxFrame *PrepareBeaconRequest(TxFrames &aTxFrames);
+    TxFrame *PrepareBeacon(TxFrames &aTxFrames);
     bool     ShouldSendBeacon(void) const;
     bool     IsJoinable(void) const;
     void     BeginTransmit(void);
@@ -853,10 +853,6 @@
     void LogFrameTxFailure(const TxFrame &aFrame, Error aError, uint8_t aRetryCount, bool aWillRetx) const;
     void LogBeacon(const char *aActionText) const;
 
-#if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-    uint8_t GetTimeIeOffset(const Frame &aFrame);
-#endif
-
 #if OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
     void ProcessCsl(const RxFrame &aFrame, const Address &aSrcAddr);
 #endif
diff --git a/src/core/mac/mac_frame.cpp b/src/core/mac/mac_frame.cpp
index 17d2a1b..8776d6f 100644
--- a/src/core/mac/mac_frame.cpp
+++ b/src/core/mac/mac_frame.cpp
@@ -41,14 +41,15 @@
 #include "common/log.hpp"
 #include "common/num_utils.hpp"
 #include "radio/trel_link.hpp"
-#if OPENTHREAD_FTD || OPENTHREAD_MTD || OPENTHREAD_CONFIG_MAC_SOFTWARE_TX_SECURITY_ENABLE
+#if OPENTHREAD_FTD || OPENTHREAD_MTD || OPENTHREAD_CONFIG_MAC_SOFTWARE_TX_SECURITY_ENABLE || \
+    (OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE)
 #include "crypto/aes_ccm.hpp"
 #endif
 
 namespace ot {
 namespace Mac {
 
-void TxFrame::Info::PrepareHeadersIn(TxFrame &aTxFrame) const
+void TxFrame::BuildInfo::PrepareHeadersIn(TxFrame &aTxFrame) const
 {
     uint16_t     fcf;
     FrameBuilder builder;
@@ -184,7 +185,7 @@
 #endif
 
     builder.Init(aTxFrame.mPsdu, aTxFrame.GetMtu());
-    IgnoreError(builder.AppendLittleEndianUint16(fcf));
+    IgnoreError(builder.AppendUint<kLittleEndian>(fcf));
 
     if (IsSequencePresent(fcf))
     {
@@ -193,14 +194,14 @@
 
     if (IsDstPanIdPresent(fcf))
     {
-        IgnoreError(builder.AppendLittleEndianUint16(mPanIds.GetDestination()));
+        IgnoreError(builder.AppendUint<kLittleEndian>(mPanIds.GetDestination()));
     }
 
     IgnoreError(builder.AppendMacAddress(mAddrs.mDestination));
 
     if (IsSrcPanIdPresent(fcf))
     {
-        IgnoreError(builder.AppendLittleEndianUint16(mPanIds.GetSource()));
+        IgnoreError(builder.AppendUint<kLittleEndian>(mPanIds.GetSource()));
     }
 
     IgnoreError(builder.AppendMacAddress(mAddrs.mSource));
@@ -222,7 +223,6 @@
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
     if (mAppendTimeIe)
     {
-        builder.Append<HeaderIe>()->Init(TimeIe::kHeaderIeId, sizeof(TimeIe));
         builder.Append<TimeIe>()->Init();
     }
 #endif
@@ -230,15 +230,14 @@
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
     if (mAppendCslIe)
     {
-        builder.Append<HeaderIe>()->Init(CslIe::kHeaderIeId, sizeof(CslIe));
-        builder.Append<CslIe>();
+        builder.Append<CslIe>()->Init();
         aTxFrame.SetCslIePresent(true);
     }
 #endif
 
     if ((fcf & kFcfIePresent) && ((mType == kTypeMacCmd) || !mEmptyPayload))
     {
-        builder.Append<HeaderIe>()->Init(Termination2Ie::kHeaderIeId, Termination2Ie::kIeContentSize);
+        builder.Append<Termination2Ie>()->Init();
     }
 
 #endif //  OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
@@ -253,21 +252,6 @@
     aTxFrame.mLength = builder.GetLength();
 }
 
-void Frame::SetFrameControlField(uint16_t aFcf)
-{
-#if OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
-    if (IsShortFcf(aFcf))
-    {
-        OT_ASSERT((aFcf >> 8) == 0);
-        mPsdu[0] = static_cast<uint8_t>(aFcf);
-    }
-    else
-#endif
-    {
-        LittleEndian::WriteUint16(aFcf, mPsdu);
-    }
-}
-
 Error Frame::ValidatePsdu(void) const
 {
     Error   error = kErrorNone;
@@ -299,88 +283,22 @@
 #if OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
 bool Frame::IsWakeupFrame(void) const
 {
-    const uint16_t      fcf    = GetFrameControlField();
-    bool                result = false;
-    uint8_t             keyIdMode;
-    uint8_t             firstIeIndex;
-    Address             srcAddress;
-    const ConnectionIe *connectionIe;
-
-    // Wake-up frame is a Multipurpose frame without Ack Request...
-    VerifyOrExit((fcf & kFcfFrameTypeMask) == kTypeMultipurpose);
-    VerifyOrExit((fcf & kMpFcfAckRequest) == 0);
-
-    // ... with extended source address...
-    SuccessOrExit(GetSrcAddr(srcAddress));
-    VerifyOrExit(srcAddress.IsExtended());
-
-    // ... secured with Key Id Mode 2...
-    SuccessOrExit(GetKeyIdMode(keyIdMode));
-    VerifyOrExit(keyIdMode == kKeyIdMode2);
-
-    // ... that has Rendezvous Time IE and Connection IE...
-    VerifyOrExit(GetRendezvousTimeIe() != nullptr);
-    VerifyOrExit((connectionIe = GetConnectionIe()) != nullptr);
-
-    // ... but no other IEs nor payload.
-    firstIeIndex = FindHeaderIeIndex();
-    VerifyOrExit(mPsdu + firstIeIndex + sizeof(HeaderIe) + RendezvousTimeIe::kIeContentSize + sizeof(HeaderIe) +
-                     connectionIe->GetHeaderIe()->GetLength() ==
-                 GetFooter());
-
-    result = true;
-
-exit:
-    return result;
+    // Placeholder implementation following removal of legacy Multipurpose frame format.
+    return false;
 }
-#endif // OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
+#endif
 
-void Frame::SetAckRequest(bool aAckRequest)
+void Frame::UpdateFcfFlag(bool aSet, uint16_t aBitFlag)
 {
-    uint16_t fcf  = GetFrameControlField();
-    uint16_t mask = Select<kFcfAckRequest, kMpFcfAckRequest>(fcf);
+    uint16_t fcf = GetFrameControlField();
 
-    if (aAckRequest)
+    if (aSet)
     {
-        fcf |= mask;
+        fcf |= aBitFlag;
     }
     else
     {
-        fcf &= ~mask;
-    }
-
-    SetFrameControlField(fcf);
-}
-
-void Frame::SetFramePending(bool aFramePending)
-{
-    uint16_t fcf  = GetFrameControlField();
-    uint16_t mask = Select<kFcfFramePending, kMpFcfFramePending>(fcf);
-
-    if (aFramePending)
-    {
-        fcf |= mask;
-    }
-    else
-    {
-        fcf &= ~mask;
-    }
-
-    SetFrameControlField(fcf);
-}
-
-void Frame::SetIePresent(bool aIePresent)
-{
-    uint16_t fcf  = GetFrameControlField();
-    uint16_t mask = Select<kFcfIePresent, kMpFcfIePresent>(fcf);
-
-    if (aIePresent)
-    {
-        fcf |= mask;
-    }
-    else
-    {
-        fcf &= ~mask;
+        fcf &= ~aBitFlag;
     }
 
     SetFrameControlField(fcf);
@@ -389,7 +307,7 @@
 uint8_t Frame::SkipSequenceIndex(void) const
 {
     uint16_t fcf   = GetFrameControlField();
-    uint8_t  index = GetFcfSize(fcf);
+    uint8_t  index = kFcfSize;
 
     if (IsSequencePresent(fcf))
     {
@@ -415,14 +333,7 @@
 {
     bool present;
 
-#if OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
-    if (IsMultipurpose(aFcf))
-    {
-        present = (aFcf & kMpFcfPanidPresent) != 0;
-    }
-    else
-#endif
-        if (IsVersion2015(aFcf))
+    if (IsVersion2015(aFcf))
     {
         // Original table at `InitMacHeader()`
         //
@@ -488,14 +399,14 @@
 {
     OT_ASSERT(IsSequencePresent());
 
-    return GetPsdu()[GetFcfSize(GetFrameControlField())];
+    return GetPsdu()[kFcfSize];
 }
 
 void Frame::SetSequence(uint8_t aSequence)
 {
     OT_ASSERT(IsSequencePresent());
 
-    GetPsdu()[GetFcfSize(GetFrameControlField())] = aSequence;
+    GetPsdu()[kFcfSize] = aSequence;
 }
 
 uint8_t Frame::FindDstAddrIndex(void) const { return SkipSequenceIndex() + (IsDstPanIdPresent() ? sizeof(PanId) : 0); }
@@ -559,15 +470,7 @@
 {
     bool present;
 
-#if OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
-    if (IsMultipurpose(aFcf))
-    {
-        // Sources PAN ID is implicitly equal to Destination PAN ID in Multipurpose frames
-        present = false;
-    }
-    else
-#endif
-        if (IsVersion2015(aFcf) && ((aFcf & (kFcfDstAddrMask | kFcfSrcAddrMask)) == (kFcfDstAddrExt | kFcfSrcAddrExt)))
+    if (IsVersion2015(aFcf) && ((aFcf & (kFcfDstAddrMask | kFcfSrcAddrMask)) == (kFcfDstAddrExt | kFcfSrcAddrExt)))
     {
         // Special case for a IEEE 802.15.4-2015 frame: When both
         // addresses are extended, then the source PAN iD is not present
@@ -1007,7 +910,7 @@
 
 uint8_t Frame::CalculateAddrFieldSize(uint16_t aFcf)
 {
-    uint8_t size = GetFcfSize(aFcf) + (IsSequencePresent(aFcf) ? kDsnSize : 0);
+    uint8_t size = kFcfSize + (IsSequencePresent(aFcf) ? kDsnSize : 0);
 
     // This static method calculates the size (number of bytes) of
     // Address header field for a given Frame Control `aFcf` value.
@@ -1080,15 +983,16 @@
 
         do
         {
-            const HeaderIe *ie = reinterpret_cast<const HeaderIe *>(&mPsdu[index]);
+            const HeaderIe *ie;
 
-            index += sizeof(HeaderIe);
+            VerifyOrExit(index + footerLength + sizeof(HeaderIe) <= mLength, index = kInvalidIndex);
+
+            ie = reinterpret_cast<const HeaderIe *>(&mPsdu[index]);
+            index += ie->GetSize();
+
             VerifyOrExit(index + footerLength <= mLength, index = kInvalidIndex);
 
-            index += ie->GetLength();
-            VerifyOrExit(index + footerLength <= mLength, index = kInvalidIndex);
-
-            if (ie->GetId() == Termination2Ie::kHeaderIeId)
+            if (ie->GetId() == Termination2Ie::kId)
             {
                 break;
             }
@@ -1139,6 +1043,7 @@
 const uint8_t *Frame::GetFooter(void) const { return mPsdu + mLength - GetFooterLength(); }
 
 #if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
+
 uint8_t Frame::FindHeaderIeIndex(void) const
 {
     uint8_t index;
@@ -1151,11 +1056,11 @@
     return index;
 }
 
-const uint8_t *Frame::GetHeaderIe(uint8_t aIeId) const
+const HeaderIe *Frame::FindHeaderIe(HeaderIeMatcher aMatcher) const
 {
-    uint16_t       index        = FindHeaderIeIndex();
-    uint16_t       payloadIndex = FindPayloadIndex();
-    const uint8_t *header       = nullptr;
+    uint16_t        index        = FindHeaderIeIndex();
+    uint16_t        payloadIndex = FindPayloadIndex();
+    const HeaderIe *matchedIe    = nullptr;
 
     // `FindPayloadIndex()` verifies that Header IE(s) in frame (if present)
     // are well-formed.
@@ -1166,120 +1071,50 @@
     {
         const HeaderIe *ie = reinterpret_cast<const HeaderIe *>(&mPsdu[index]);
 
-        if (ie->GetId() == aIeId)
+        if (aMatcher(*ie))
         {
-            header = &mPsdu[index];
+            matchedIe = ie;
             ExitNow();
         }
 
-        index += sizeof(HeaderIe) + ie->GetLength();
+        index += ie->GetSize();
     }
 
 exit:
-    return header;
+    return matchedIe;
 }
 
-#if OPENTHREAD_CONFIG_MLE_LINK_METRICS_INITIATOR_ENABLE || OPENTHREAD_CONFIG_MLE_LINK_METRICS_SUBJECT_ENABLE || \
-    OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
-const uint8_t *Frame::GetThreadIe(uint8_t aSubType) const
-{
-    uint16_t       index        = FindHeaderIeIndex();
-    uint16_t       payloadIndex = FindPayloadIndex();
-    const uint8_t *header       = nullptr;
-
-    // `FindPayloadIndex()` verifies that Header IE(s) in frame (if present)
-    // are well-formed.
-    VerifyOrExit((index != kInvalidIndex) && (payloadIndex != kInvalidIndex));
-
-    while (index < payloadIndex)
-    {
-        const HeaderIe *ie = reinterpret_cast<const HeaderIe *>(&mPsdu[index]);
-
-        if (ie->GetId() == VendorIeHeader::kHeaderIeId)
-        {
-            const VendorIeHeader *vendorIe =
-                reinterpret_cast<const VendorIeHeader *>(reinterpret_cast<const uint8_t *>(ie) + sizeof(HeaderIe));
-            if (vendorIe->GetVendorOui() == ThreadIe::kVendorOuiThreadCompanyId && vendorIe->GetSubType() == aSubType)
-            {
-                header = &mPsdu[index];
-                ExitNow();
-            }
-        }
-
-        index += sizeof(HeaderIe) + ie->GetLength();
-    }
-
-exit:
-    return header;
-}
-#endif // OPENTHREAD_CONFIG_MLE_LINK_METRICS_INITIATOR_ENABLE || OPENTHREAD_CONFIG_MLE_LINK_METRICS_SUBJECT_ENABLE ||
-       // OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
-
-#endif // OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
-
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-void Frame::SetCslIe(uint16_t aCslPeriod, uint16_t aCslPhase)
+void Frame::UpdateCslIe(uint16_t aCslPeriod, uint16_t aCslPhase)
 {
-    CslIe *csl = GetCslIe();
+    CslIe *csl = Find<CslIe>();
 
     VerifyOrExit(csl != nullptr);
+
     csl->SetPeriod(aCslPeriod);
     csl->SetPhase(aCslPhase);
 
 exit:
     return;
 }
-
-bool Frame::HasCslIe(void) const { return GetHeaderIe(CslIe::kHeaderIeId) != nullptr; }
-#endif // OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-
-#if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
-const CslIe *Frame::GetCslIe(void) const
-{
-    const uint8_t *cur;
-    const CslIe   *csl = nullptr;
-
-    cur = GetHeaderIe(CslIe::kHeaderIeId);
-    VerifyOrExit(cur != nullptr);
-    csl = reinterpret_cast<const CslIe *>(cur + sizeof(HeaderIe));
-
-exit:
-    return csl;
-}
 #endif
 
 #if OPENTHREAD_CONFIG_MLE_LINK_METRICS_SUBJECT_ENABLE
-void Frame::SetEnhAckProbingIe(const uint8_t *aValue, uint8_t aLen)
+void Frame::UpdateEnhAckProbingIe(const uint8_t *aData, uint8_t aLen)
 {
-    uint8_t *cur = GetThreadIe(ThreadIe::kEnhAckProbingIe);
+    LinkMetricsProbingIe *probingIe = Find<LinkMetricsProbingIe>();
 
-    VerifyOrExit(cur != nullptr);
-    memcpy(cur + sizeof(HeaderIe) + sizeof(VendorIeHeader), aValue, aLen);
+    VerifyOrExit(probingIe != nullptr);
+
+    VerifyOrExit(aLen >= probingIe->GetMetricsDataLen());
+    probingIe->WriteMetricsDataFrom(aData);
 
 exit:
     return;
 }
-#endif // OPENTHREAD_CONFIG_MLE_LINK_METRICS_SUBJECT_ENABLE
+#endif
 
-#if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-const TimeIe *Frame::GetTimeIe(void) const
-{
-    const TimeIe  *timeIe = nullptr;
-    const uint8_t *cur    = nullptr;
-
-    cur = GetHeaderIe(VendorIeHeader::kHeaderIeId);
-    VerifyOrExit(cur != nullptr);
-
-    cur += sizeof(HeaderIe);
-
-    timeIe = reinterpret_cast<const TimeIe *>(cur);
-    VerifyOrExit(timeIe->GetVendorOui() == TimeIe::kVendorOuiNest, timeIe = nullptr);
-    VerifyOrExit(timeIe->GetSubType() == TimeIe::kVendorIeTime, timeIe = nullptr);
-
-exit:
-    return timeIe;
-}
-#endif // OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
+#endif // OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
 
 #if OPENTHREAD_CONFIG_MULTI_RADIO
 uint16_t Frame::GetMtu(void) const
@@ -1374,26 +1209,24 @@
 void TxFrame::ProcessTransmitAesCcm(const ExtAddress &aExtAddress)
 {
 #if OPENTHREAD_FTD || OPENTHREAD_MTD || OPENTHREAD_CONFIG_MAC_SOFTWARE_TX_SECURITY_ENABLE
-    uint32_t       frameCounter = 0;
-    uint8_t        securityLevel;
-    uint8_t        nonce[Crypto::AesCcm::kNonceSize];
-    uint8_t        tagLength;
-    Crypto::AesCcm aesCcm;
+    uint32_t              frameCounter = 0;
+    uint8_t               securityLevel;
+    Crypto::AesCcm        aesCcm;
+    Crypto::AesCcm::Nonce nonce;
 
     VerifyOrExit(GetSecurityEnabled());
 
     SuccessOrExit(GetSecurityLevel(securityLevel));
     SuccessOrExit(GetFrameCounter(frameCounter));
 
-    Crypto::AesCcm::GenerateNonce(aExtAddress, frameCounter, securityLevel, nonce);
+    nonce.InitFrom(aExtAddress, frameCounter, securityLevel);
 
     aesCcm.SetKey(GetAesKey());
-    tagLength = GetFooterLength() - GetFcsSize();
+    aesCcm.SetNonce(nonce);
+    aesCcm.SetAuthData(GetHeader(), GetHeaderLength());
+    aesCcm.SetTagLength(GetFooterLength() - GetFcsSize());
 
-    aesCcm.Init(GetHeaderLength(), GetPayloadLength(), tagLength, nonce, sizeof(nonce));
-    aesCcm.Header(GetHeader(), GetHeaderLength());
-    aesCcm.Payload(GetPayload(), GetPayload(), GetPayloadLength(), Crypto::AesCcm::kEncrypt);
-    aesCcm.Finalize(GetFooter());
+    SuccessOrExit(aesCcm.Process(Crypto::AesCcm::kEncrypt, GetPayload(), GetPayloadLength()));
 
     SetIsSecurityProcessed(true);
 
@@ -1404,6 +1237,37 @@
 #endif // OPENTHREAD_FTD || OPENTHREAD_MTD || OPENTHREAD_CONFIG_MAC_SOFTWARE_TX_SECURITY_ENABLE
 }
 
+#if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE
+void TxFrame::RestoreTransmitSecurity(const ExtAddress &aExtAddress)
+{
+    uint32_t              frameCounter = 0;
+    uint8_t               securityLevel;
+    Crypto::AesCcm        aesCcm;
+    Crypto::AesCcm::Nonce nonce;
+
+    VerifyOrExit(GetSecurityEnabled() && IsSecurityProcessed());
+
+    SuccessOrExit(GetSecurityLevel(securityLevel));
+    SuccessOrExit(GetFrameCounter(frameCounter));
+
+    nonce.InitFrom(aExtAddress, frameCounter, securityLevel);
+
+    aesCcm.SetKey(GetAesKey());
+    aesCcm.SetNonce(nonce);
+    aesCcm.SetAuthData(GetHeader(), GetHeaderLength());
+    aesCcm.SetTagLength(GetFooterLength() - GetFcsSize());
+
+    // We expect success because we are only decrypting back to plaintext,
+    // and we know the ciphertext was generated correctly by us previously.
+    IgnoreError(aesCcm.Process(Crypto::AesCcm::kDecrypt, GetPayload(), GetPayloadLength()));
+
+    SetIsSecurityProcessed(false);
+
+exit:
+    SetIsHeaderUpdated(false);
+}
+#endif // OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE
+
 void TxFrame::GenerateImmAck(const RxFrame &aFrame, bool aIsFramePending)
 {
     uint16_t fcf = static_cast<uint16_t>(kTypeAck) | aFrame.GetVersion();
@@ -1425,12 +1289,12 @@
 #if OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2
 Error TxFrame::GenerateEnhAck(const RxFrame &aRxFrame, bool aIsFramePending, const uint8_t *aIeData, uint8_t aIeLength)
 {
-    Error   error = kErrorNone;
-    Info    frameInfo;
-    Address address;
-    PanId   panId;
-    uint8_t securityLevel = kSecurityNone;
-    uint8_t keyIdMode     = kKeyIdMode0;
+    Error     error = kErrorNone;
+    BuildInfo buildInfo;
+    Address   address;
+    PanId     panId;
+    uint8_t   securityLevel = kSecurityNone;
+    uint8_t   keyIdMode     = kKeyIdMode0;
 
     // Validate the received frame.
 
@@ -1447,8 +1311,8 @@
     // Check `aRxFrame` has a valid source, which is then used as
     // ack frames destination.
 
-    SuccessOrExit(error = aRxFrame.GetSrcAddr(frameInfo.mAddrs.mDestination));
-    VerifyOrExit(!frameInfo.mAddrs.mDestination.IsNone(), error = kErrorParse);
+    SuccessOrExit(error = aRxFrame.GetSrcAddr(buildInfo.mAddrs.mDestination));
+    VerifyOrExit(!buildInfo.mAddrs.mDestination.IsNone(), error = kErrorParse);
 
     if (aRxFrame.GetSecurityEnabled())
     {
@@ -1461,12 +1325,12 @@
     if (aRxFrame.IsSrcPanIdPresent())
     {
         SuccessOrExit(error = aRxFrame.GetSrcPanId(panId));
-        frameInfo.mPanIds.SetDestination(panId);
+        buildInfo.mPanIds.SetDestination(panId);
     }
     else if (aRxFrame.IsDstPanIdPresent())
     {
         SuccessOrExit(error = aRxFrame.GetDstPanId(panId));
-        frameInfo.mPanIds.SetDestination(panId);
+        buildInfo.mPanIds.SetDestination(panId);
     }
 
     // Prepare the ack frame
@@ -1474,12 +1338,12 @@
     mChannel = aRxFrame.mChannel;
     ClearAllBytes(mInfo.mTxInfo);
 
-    frameInfo.mType          = kTypeAck;
-    frameInfo.mVersion       = kVersion2015;
-    frameInfo.mSecurityLevel = static_cast<SecurityLevel>(securityLevel);
-    frameInfo.mKeyIdMode     = static_cast<KeyIdMode>(keyIdMode);
+    buildInfo.mType          = kTypeAck;
+    buildInfo.mVersion       = kVersion2015;
+    buildInfo.mSecurityLevel = static_cast<SecurityLevel>(securityLevel);
+    buildInfo.mKeyIdMode     = static_cast<KeyIdMode>(keyIdMode);
 
-    frameInfo.PrepareHeadersIn(*this);
+    buildInfo.PrepareHeadersIn(*this);
 
     SetFramePending(aIsFramePending);
     SetIePresent(aIeLength != 0);
@@ -1508,95 +1372,68 @@
 #if OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE
 Error TxFrame::GenerateWakeupFrame(PanId aPanId, const WakeupRequest &aWakeupRequest, const Address &aSource)
 {
-    Error        error = kErrorNone;
-    uint16_t     fcf;
-    uint8_t      secCtl;
-    uint8_t      wakeupIdLength;
-    FrameBuilder builder;
-    Address      dest;
+    // Placeholder implementation following removal of legacy Multipurpose frame format.
+    OT_UNUSED_VARIABLE(aPanId);
+    OT_UNUSED_VARIABLE(aWakeupRequest);
+    OT_UNUSED_VARIABLE(aSource);
 
-    fcf = kTypeMultipurpose | kMpFcfLongFrame | kMpFcfPanidPresent | kMpFcfSecurityEnabled | kMpFcfSequenceSuppression |
-          kMpFcfIePresent;
+    return kErrorFailed;
+}
+#endif
 
-    VerifyOrExit(!aSource.IsNone(), error = kErrorInvalidArgs);
+bool RxFrame::IsSecuredWith(KeyIdModeFlags aFlags) const
+{
+    bool    isSecure = false;
+    uint8_t keyIdMode;
 
-    if (aWakeupRequest.IsWakeupByExtAddress())
+    VerifyOrExit(GetSecurityEnabled());
+    SuccessOrExit(GetKeyIdMode(keyIdMode));
+
+    switch (keyIdMode)
     {
-        wakeupIdLength = 0;
-        dest.SetExtended(aWakeupRequest.GetExtAddress());
-    }
-    else
-    {
-        wakeupIdLength = GetWakeupIdLength(aWakeupRequest.GetWakeupId());
-        dest.SetNone();
+    case kKeyIdMode0:
+        VerifyOrExit(aFlags & kAllowKeyIdMode0);
+        break;
+    case kKeyIdMode1:
+        VerifyOrExit(aFlags & kAllowKeyIdMode1);
+        break;
+    default:
+        ExitNow();
     }
 
-    fcf |= DetermineFcfAddrType(dest, kMpFcfDstAddrShift);
-    fcf |= DetermineFcfAddrType(aSource, kMpFcfSrcAddrShift);
-
-    builder.Init(mPsdu, GetMtu());
-
-    IgnoreError(builder.AppendLittleEndianUint16(fcf));
-    IgnoreError(builder.AppendLittleEndianUint16(aPanId));
-    IgnoreError(builder.AppendMacAddress(dest));
-    IgnoreError(builder.AppendMacAddress(aSource));
-
-    secCtl = kKeyIdMode2 | kSecurityEncMic32;
-    IgnoreError(builder.AppendUint8(secCtl));
-    builder.AppendLength(CalculateSecurityHeaderSize(secCtl) - sizeof(secCtl));
-
-    builder.Append<HeaderIe>()->Init(RendezvousTimeIe::kHeaderIeId, sizeof(RendezvousTimeIe));
-    builder.Append<RendezvousTimeIe>();
-
-    builder.Append<HeaderIe>()->Init(ConnectionIe::kHeaderIeId, sizeof(ConnectionIe) + wakeupIdLength);
-    builder.Append<ConnectionIe>()->Init();
-    builder.AppendLength(wakeupIdLength);
-
-    builder.AppendLength(CalculateMicSize(secCtl) + GetFcsSize());
-
-    mLength = builder.GetLength();
+    isSecure = true;
 
 exit:
-    return error;
+    return isSecure;
 }
-#endif // OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE
 
 Error RxFrame::ProcessReceiveAesCcm(const ExtAddress &aExtAddress, const KeyMaterial &aMacKey)
 {
 #if OPENTHREAD_FTD || OPENTHREAD_MTD
-    Error          error        = kErrorSecurity;
-    uint32_t       frameCounter = 0;
-    uint8_t        securityLevel;
-    uint8_t        nonce[Crypto::AesCcm::kNonceSize];
-    uint8_t        tag[kMaxMicSize];
-    uint8_t        tagLength;
-    Crypto::AesCcm aesCcm;
+    Error                 error        = kErrorSecurity;
+    uint32_t              frameCounter = 0;
+    uint8_t               securityLevel;
+    Crypto::AesCcm        aesCcm;
+    Crypto::AesCcm::Nonce nonce;
 
     VerifyOrExit(GetSecurityEnabled(), error = kErrorNone);
 
     SuccessOrExit(GetSecurityLevel(securityLevel));
     SuccessOrExit(GetFrameCounter(frameCounter));
 
-    Crypto::AesCcm::GenerateNonce(aExtAddress, frameCounter, securityLevel, nonce);
+    nonce.InitFrom(aExtAddress, frameCounter, securityLevel);
 
     aesCcm.SetKey(aMacKey);
-    tagLength = GetFooterLength() - GetFcsSize();
+    aesCcm.SetNonce(nonce);
+    aesCcm.SetAuthData(GetHeader(), GetHeaderLength());
+    aesCcm.SetTagLength(GetFooterLength() - GetFcsSize());
 
-    aesCcm.Init(GetHeaderLength(), GetPayloadLength(), tagLength, nonce, sizeof(nonce));
-    aesCcm.Header(GetHeader(), GetHeaderLength());
-#ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
-    aesCcm.Payload(GetPayload(), GetPayload(), GetPayloadLength(), Crypto::AesCcm::kDecrypt);
-#else
-    // For fuzz tests, execute AES but do not alter the payload. A large
-    aesCcm.Payload(nullptr, GetPayload(), GetPayloadLength(), Crypto::AesCcm::kDecrypt);
-#endif
-    aesCcm.Finalize(tag);
-
-#ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
-    VerifyOrExit(memcmp(tag, GetFooter(), tagLength) == 0);
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+    // Do not decrypt when fuzzing
+    ExitNow(error = kErrorNone);
 #endif
 
-    error = kErrorNone;
+    error = aesCcm.Process(Crypto::AesCcm::kDecrypt, GetPayload(), GetPayloadLength());
 
 exit:
     return error;
@@ -1670,12 +1507,6 @@
 
         break;
 
-#if OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
-    case kTypeMultipurpose:
-        string.Append("MP");
-        break;
-#endif
-
     default:
         string.Append("%d", type);
         break;
diff --git a/src/core/mac/mac_frame.hpp b/src/core/mac/mac_frame.hpp
index 0e55fb0..c19a058 100644
--- a/src/core/mac/mac_frame.hpp
+++ b/src/core/mac/mac_frame.hpp
@@ -37,12 +37,14 @@
 #include "openthread-core-config.h"
 
 #include "common/as_core_type.hpp"
+#include "common/bit_utils.hpp"
 #include "common/const_cast.hpp"
 #include "common/encoding.hpp"
 #include "common/numeric_limits.hpp"
 #include "mac/mac_header_ie.hpp"
 #include "mac/mac_types.hpp"
 #include "meshcop/network_name.hpp"
+#include "radio/radio_types.hpp"
 
 namespace ot {
 namespace Mac {
@@ -194,45 +196,7 @@
      * @retval FALSE  If this is not a Wake-up frame.
      */
     bool IsWakeupFrame(void) const;
-
-    /**
-     * This method returns the Rendezvous Time IE of a wake-up frame.
-     *
-     * @returns Pointer to the Rendezvous Time IE.
-     */
-    RendezvousTimeIe *GetRendezvousTimeIe(void) { return AsNonConst(AsConst(this)->GetRendezvousTimeIe()); }
-
-    /**
-     * This method returns the Rendezvous Time IE of a wake-up frame.
-     *
-     * @returns Const pointer to the Rendezvous Time IE.
-     */
-    const RendezvousTimeIe *GetRendezvousTimeIe(void) const
-    {
-        const uint8_t *ie = GetHeaderIe(RendezvousTimeIe::kHeaderIeId);
-
-        return (ie != nullptr) ? reinterpret_cast<const RendezvousTimeIe *>(ie + sizeof(HeaderIe)) : nullptr;
-    }
-
-    /**
-     * This method returns the Connection IE of a wake-up frame.
-     *
-     * @returns Pointer to the Connection IE.
-     */
-    ConnectionIe *GetConnectionIe(void) { return AsNonConst(AsConst(this)->GetConnectionIe()); }
-
-    /**
-     * This method returns the Connection IE of a wake-up frame.
-     *
-     * @returns Const pointer to the Connection IE.
-     */
-    const ConnectionIe *GetConnectionIe(void) const
-    {
-        const uint8_t *ie = GetThreadIe(ConnectionIe::kThreadIeSubtype);
-
-        return (ie != nullptr) ? reinterpret_cast<const ConnectionIe *>(ie + sizeof(HeaderIe)) : nullptr;
-    }
-#endif // OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
+#endif
 
     /**
      * Returns the IEEE 802.15.4 Frame Version.
@@ -267,11 +231,9 @@
     /**
      * Sets the Frame Pending bit.
      *
-     * @note This method must not be called on a Multipurpose frame with short Frame Control field.
-     *
      * @param[in]  aFramePending  The Frame Pending bit.
      */
-    void SetFramePending(bool aFramePending);
+    void SetFramePending(bool aFramePending) { UpdateFcfFlag(aFramePending, kFcfFramePending); }
 
     /**
      * Indicates whether or not the Ack Request bit is set.
@@ -284,17 +246,13 @@
     /**
      * Sets the Ack Request bit.
      *
-     * @note This method must not be called on a Multipurpose frame with short Frame Control field.
-     *
      * @param[in]  aAckRequest  The Ack Request bit.
      */
-    void SetAckRequest(bool aAckRequest);
+    void SetAckRequest(bool aAckRequest) { UpdateFcfFlag(aAckRequest, kFcfAckRequest); }
 
     /**
      * Indicates whether or not the PanId Compression bit is set.
      *
-     * @note This method must not be called on a Multipurpose frame, which lacks this flag.
-     *
      * @retval TRUE   If the PanId Compression bit is set.
      * @retval FALSE  If the PanId Compression bit is not set.
      */
@@ -311,11 +269,9 @@
     /**
      * Sets the IE Present bit.
      *
-     * @note This method must not be called on a Multipurpose frame with short Frame Control field.
-     *
      * @param[in]  aIePresent   The IE Present bit.
      */
-    void SetIePresent(bool aIePresent);
+    void SetIePresent(bool aIePresent) { UpdateFcfFlag(aIePresent, kFcfIePresent); }
 
     /**
      * Returns the Sequence Number value.
@@ -608,107 +564,74 @@
      */
     const uint8_t *GetFooter(void) const;
 
-#if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-
-    /**
-     * Returns a pointer to the vendor specific Time IE.
-     *
-     * @returns A pointer to the Time IE, `nullptr` if not found.
-     */
-    TimeIe *GetTimeIe(void) { return AsNonConst(AsConst(this)->GetTimeIe()); }
-
-    /**
-     * Returns a pointer to the vendor specific Time IE.
-     *
-     * @returns A pointer to the Time IE, `nullptr` if not found.
-     */
-    const TimeIe *GetTimeIe(void) const;
-#endif // OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-
 #if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
     /**
-     * Returns a pointer to the Header IE.
+     * Indicates whether the frame contains header IEs.
      *
-     * @param[in] aIeId  The Element Id of the Header IE.
-     *
-     * @returns A pointer to the Header IE, `nullptr` if not found.
+     * @retval TRUE   The frame contains header IEs.
+     * @retval FALSE  The frame contains no header IEs.
      */
-    uint8_t *GetHeaderIe(uint8_t aIeId) { return AsNonConst(AsConst(this)->GetHeaderIe(aIeId)); }
+    bool HasAnyHeaderIe(void) const { return FindHeaderIeIndex() != kInvalidIndex; }
 
     /**
-     * Returns a pointer to the Header IE.
+     * Finds a specific Information Element (IE) in the frame.
      *
-     * @param[in] aIeId  The Element Id of the Header IE.
+     * This method searches the frame for a Header IE matching the Element ID of @p IeType and also validates that
+     * the content of the IE is well-formed according to @p IeType.
      *
-     * @returns A pointer to the Header IE, `nullptr` if not found.
+     * @tparam IeType  The IE subclass type to find.
+     *
+     * @returns A pointer to the IE, or `nullptr` if not found or if the IE content is malformed.
      */
-    const uint8_t *GetHeaderIe(uint8_t aIeId) const;
+    template <typename IeType> const IeType *Find(void) const
+    {
+        return static_cast<const IeType *>(FindHeaderIe(HeaderIe::ValidateAs<IeType>));
+    }
 
     /**
-     * Returns a pointer to a specific Thread IE.
+     * Finds a specific Information Element (IE) in the frame.
      *
-     * A Thread IE is a vendor specific IE with Vendor OUI as `kVendorOuiThreadCompanyId`.
+     * This method searches the frame for a Header IE matching the Element ID of @p IeType and also validates that
+     * the content of the IE is well-formed according to @p IeType.
      *
-     * @param[in] aSubType  The sub type of the Thread IE.
+     * @tparam IeType  The IE subclass type to find.
      *
-     * @returns A pointer to the Thread IE, `nullptr` if not found.
+     * @returns A pointer to the IE, or `nullptr` if not found or if the IE content is malformed.
      */
-    uint8_t *GetThreadIe(uint8_t aSubType) { return AsNonConst(AsConst(this)->GetThreadIe(aSubType)); }
+    template <typename IeType> IeType *Find(void) { return AsNonConst(AsConst(this)->Find<IeType>()); }
 
     /**
-     * Returns a pointer to a specific Thread IE.
+     * Indicates whether or not the frame contains a specific Information Element (IE).
      *
-     * A Thread IE is a vendor specific IE with Vendor OUI as `kVendorOuiThreadCompanyId`.
+     * This method checks whether the frame contains a Header IE matching the Element ID of @p IeType with valid
+     * content according to @p IeType.
      *
-     * @param[in] aSubType  The sub type of the Thread IE.
+     * @tparam IeType  The IE subclass type to check.
      *
-     * @returns A pointer to the Thread IE, `nullptr` if not found.
+     * @retval TRUE   The frame contains a valid instance of the IE.
+     * @retval FALSE  The frame does not contain the IE or its content is malformed.
      */
-    const uint8_t *GetThreadIe(uint8_t aSubType) const;
+    template <typename IeType> bool Has(void) const { return Find<IeType>() != nullptr; }
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
     /**
-     * Finds CSL IE in the frame and modify its content.
+     * Updates CSL IE content in the frame.
      *
      * @param[in] aCslPeriod  CSL Period in CSL IE.
      * @param[in] aCslPhase   CSL Phase in CSL IE.
      */
-    void SetCslIe(uint16_t aCslPeriod, uint16_t aCslPhase);
-
-    /**
-     * Indicates whether or not the frame contains CSL IE.
-     *
-     * @retval TRUE   If the frame contains CSL IE.
-     * @retval FALSE  If the frame doesn't contain CSL IE.
-     */
-    bool HasCslIe(void) const;
-#endif // OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-
-#if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
-    /**
-     * Returns a pointer to a CSL IE.
-     *
-     * @returns A pointer to the CSL IE, `nullptr` if not found.
-     */
-    const CslIe *GetCslIe(void) const;
-
-    /**
-     * Returns a pointer to a CSL IE.
-     *
-     * @returns A pointer to the CSL IE, `nullptr` if not found.
-     */
-    CslIe *GetCslIe(void) { return AsNonConst(AsConst(this)->GetCslIe()); }
-#endif // OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
+    void UpdateCslIe(uint16_t aCslPeriod, uint16_t aCslPhase);
+#endif
 
 #if OPENTHREAD_CONFIG_MLE_LINK_METRICS_SUBJECT_ENABLE
     /**
-     * Finds Enhanced ACK Probing (Vendor Specific) IE and set its value.
+     * Finds Enhanced ACK Probing (Vendor Specific) IE and updates its Link Metrics Data content.
      *
-     * @param[in] aValue  A pointer to the value to set.
-     * @param[in] aLen    The length of @p aValue.
+     * @param[in] aData   A pointer to the data to write.
+     * @param[in] aLen    The length of @p aData.
      */
-    void SetEnhAckProbingIe(const uint8_t *aValue, uint8_t aLen);
-#endif // OPENTHREAD_CONFIG_MLE_LINK_METRICS_SUBJECT_ENABLE
+    void UpdateEnhAckProbingIe(const uint8_t *aData, uint8_t aLen);
+#endif
 
 #endif // OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
 
@@ -768,22 +691,9 @@
      *
      * @returns The Frame Control field.
      */
-    uint16_t GetFrameControlField(void) const
-    {
-        uint16_t fcf = mPsdu[0];
-
-#if OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
-        if (!IsShortFcf(fcf))
-#endif
-        {
-            fcf |= (mPsdu[1] << 8);
-        }
-
-        return fcf;
-    }
+    uint16_t GetFrameControlField(void) const { return LittleEndian::ReadUint16(mPsdu); }
 
 protected:
-    static constexpr uint8_t kShortFcfSize        = sizeof(uint8_t);
     static constexpr uint8_t kSecurityControlSize = sizeof(uint8_t);
     static constexpr uint8_t kFrameCounterSize    = sizeof(uint32_t);
     static constexpr uint8_t kCommandIdSize       = sizeof(uint8_t);
@@ -815,25 +725,6 @@
     static constexpr uint16_t kFcfSrcAddrExt          = kFcfAddrExt << kFcfSrcAddrShift;
     static constexpr uint16_t kFcfSrcAddrMask         = kFcfAddrMask << kFcfSrcAddrShift;
 
-    // Frame Control field format for MAC Multipurpose frame
-    static constexpr uint16_t kMpFcfLongFrame           = 1 << 3;
-    static constexpr uint16_t kMpFcfDstAddrShift        = 4;
-    static constexpr uint16_t kMpFcfDstAddrNone         = kFcfAddrNone << kMpFcfDstAddrShift;
-    static constexpr uint16_t kMpFcfDstAddrShort        = kFcfAddrShort << kMpFcfDstAddrShift;
-    static constexpr uint16_t kMpFcfDstAddrExt          = kFcfAddrExt << kMpFcfDstAddrShift;
-    static constexpr uint16_t kMpFcfDstAddrMask         = kFcfAddrMask << kMpFcfDstAddrShift;
-    static constexpr uint16_t kMpFcfSrcAddrShift        = 6;
-    static constexpr uint16_t kMpFcfSrcAddrNone         = kFcfAddrNone << kMpFcfSrcAddrShift;
-    static constexpr uint16_t kMpFcfSrcAddrShort        = kFcfAddrShort << kMpFcfSrcAddrShift;
-    static constexpr uint16_t kMpFcfSrcAddrExt          = kFcfAddrExt << kMpFcfSrcAddrShift;
-    static constexpr uint16_t kMpFcfSrcAddrMask         = kFcfAddrMask << kMpFcfSrcAddrShift;
-    static constexpr uint16_t kMpFcfPanidPresent        = 1 << 8;
-    static constexpr uint16_t kMpFcfSecurityEnabled     = 1 << 9;
-    static constexpr uint16_t kMpFcfSequenceSuppression = 1 << 10;
-    static constexpr uint16_t kMpFcfFramePending        = 1 << 11;
-    static constexpr uint16_t kMpFcfAckRequest          = 1 << 14;
-    static constexpr uint16_t kMpFcfIePresent           = 1 << 15;
-
     static constexpr uint8_t kSecLevelMask  = 7 << 0;
     static constexpr uint8_t kKeyIdModeMask = 3 << 3;
 
@@ -852,7 +743,8 @@
     static constexpr uint8_t kInvalidSize  = kInvalidIndex;
     static constexpr uint8_t kMaxPsduSize  = kInvalidSize - 1;
 
-    void    SetFrameControlField(uint16_t aFcf);
+    void    SetFrameControlField(uint16_t aFcf) { LittleEndian::WriteUint16(aFcf, mPsdu); }
+    void    UpdateFcfFlag(bool aSet, uint16_t aBitFlag);
     uint8_t SkipSequenceIndex(void) const;
     uint8_t FindDstPanIdIndex(void) const;
     uint8_t FindDstAddrIndex(void) const;
@@ -866,63 +758,31 @@
     uint8_t FindHeaderIeIndex(void) const;
 #endif
 
-#if OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
-    static uint8_t GetFcfSize(uint16_t aFcf) { return IsShortFcf(aFcf) ? kShortFcfSize : kFcfSize; }
-#else
-    // clang-format off
-    static uint8_t GetFcfSize(uint16_t /* aFcf */) { return kFcfSize; }
-    // clang-format on
-#endif
-
-#if OPENTHREAD_CONFIG_MAC_MULTIPURPOSE_FRAME
-    template <uint16_t kValue, uint16_t kMpValue> static uint16_t Select(uint16_t aFcf)
-    {
-        return IsMultipurpose(aFcf) ? kMpValue : kValue;
-    }
-#else
-    template <uint16_t kValue, uint16_t kMpValue> static uint16_t Select(uint16_t /* aFcf */) { return kValue; }
-#endif
-
-    template <uint16_t kValue, uint16_t kMpValue> static uint16_t MaskFcf(uint16_t aFcf)
-    {
-        return aFcf & Select<kValue, kMpValue>(aFcf);
-    }
-
-    static uint16_t GetFcfDstAddr(uint16_t aFcf)
-    {
-        return MaskFcf<kFcfDstAddrMask, kMpFcfDstAddrMask>(aFcf) >> Select<kFcfDstAddrShift, kMpFcfDstAddrShift>(aFcf);
-    }
-
-    static uint16_t GetFcfSrcAddr(uint16_t aFcf)
-    {
-        return MaskFcf<kFcfSrcAddrMask, kMpFcfSrcAddrMask>(aFcf) >> Select<kFcfSrcAddrShift, kMpFcfSrcAddrShift>(aFcf);
-    }
-
-    static bool IsMultipurpose(uint16_t aFcf) { return (aFcf & kFcfFrameTypeMask) == kTypeMultipurpose; }
-    static bool IsShortFcf(uint16_t aFcf)
-    {
-        return (aFcf & (kFcfFrameTypeMask | kMpFcfLongFrame)) == (kTypeMultipurpose | 0);
-    }
-    static bool IsSequencePresent(uint16_t aFcf)
-    {
-        return !MaskFcf<kFcfSequenceSuppression, kMpFcfSequenceSuppression>(aFcf);
-    }
-    static bool IsDstAddrPresent(uint16_t aFcf) { return MaskFcf<kFcfDstAddrMask, kMpFcfDstAddrMask>(aFcf); }
-    static bool IsDstPanIdPresent(uint16_t aFcf);
-    static bool IsSrcAddrPresent(uint16_t aFcf) { return MaskFcf<kFcfSrcAddrMask, kMpFcfSrcAddrMask>(aFcf); }
-    static bool IsSrcPanIdPresent(uint16_t aFcf);
-    static bool IsSecurityEnabled(uint16_t aFcf) { return MaskFcf<kFcfSecurityEnabled, kMpFcfSecurityEnabled>(aFcf); }
-    static bool IsFramePending(uint16_t aFcf) { return MaskFcf<kFcfFramePending, kMpFcfFramePending>(aFcf); }
-    static bool IsIePresent(uint16_t aFcf) { return MaskFcf<kFcfIePresent, kMpFcfIePresent>(aFcf); }
-    static bool IsAckRequest(uint16_t aFcf) { return MaskFcf<kFcfAckRequest, kMpFcfAckRequest>(aFcf); }
-    static bool IsVersion2015(uint16_t aFcf) { return (aFcf & kFcfFrameVersionMask) == kVersion2015; }
-
+    static uint16_t GetFcfDstAddr(uint16_t aFcf) { return ReadBits<uint16_t, kFcfDstAddrMask>(aFcf); }
+    static uint16_t GetFcfSrcAddr(uint16_t aFcf) { return ReadBits<uint16_t, kFcfSrcAddrMask>(aFcf); }
+    static bool     IsSequencePresent(uint16_t aFcf) { return (aFcf & kFcfSequenceSuppression) == 0; }
+    static bool     IsDstAddrPresent(uint16_t aFcf) { return (aFcf & kFcfDstAddrMask) != 0; }
+    static bool     IsSrcAddrPresent(uint16_t aFcf) { return (aFcf & kFcfSrcAddrMask) != 0; }
+    static bool     IsSecurityEnabled(uint16_t aFcf) { return (aFcf & kFcfSecurityEnabled) != 0; }
+    static bool     IsFramePending(uint16_t aFcf) { return (aFcf & kFcfFramePending) != 0; }
+    static bool     IsIePresent(uint16_t aFcf) { return (aFcf & kFcfIePresent) != 0; }
+    static bool     IsAckRequest(uint16_t aFcf) { return (aFcf & kFcfAckRequest) != 0; }
+    static bool     IsVersion2015(uint16_t aFcf) { return (aFcf & kFcfFrameVersionMask) == kVersion2015; }
+    static bool     IsDstPanIdPresent(uint16_t aFcf);
+    static bool     IsSrcPanIdPresent(uint16_t aFcf);
     static uint16_t DetermineFcfAddrType(const Address &aAddress, uint16_t aBitShift);
+    static uint8_t  CalculateAddrFieldSize(uint16_t aFcf);
+    static uint8_t  CalculateSecurityHeaderSize(uint8_t aSecurityControl);
+    static uint8_t  CalculateKeySourceSize(uint8_t aSecurityControl);
+    static uint8_t  CalculateMicSize(uint8_t aSecurityControl);
 
-    static uint8_t CalculateAddrFieldSize(uint16_t aFcf);
-    static uint8_t CalculateSecurityHeaderSize(uint8_t aSecurityControl);
-    static uint8_t CalculateKeySourceSize(uint8_t aSecurityControl);
-    static uint8_t CalculateMicSize(uint8_t aSecurityControl);
+private:
+#if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
+    typedef bool (&HeaderIeMatcher)(const HeaderIe &aHeaderIe);
+
+    const HeaderIe *FindHeaderIe(HeaderIeMatcher aMatcher) const;
+    HeaderIe       *FindHeaderIe(HeaderIeMatcher aMatcher) { return AsNonConst(AsConst(this)->FindHeaderIe(aMatcher)); }
+#endif
 };
 
 /**
@@ -934,6 +794,30 @@
     friend class TxFrame;
 
     /**
+     * Defines flags to indicate allowed Key ID Modes, used in `IsSecuredWith()`.
+     */
+    enum KeyIdModeFlag : uint8_t
+    {
+        kAllowKeyIdMode0 = (1 << 0), ///< Allow Key ID Mode 0.
+        kAllowKeyIdMode1 = (1 << 1), ///< Allow Key ID Mode 1.
+    };
+
+    /**
+     * Represents a set of `KeyIdModeFlag`s.
+     */
+    typedef uint8_t KeyIdModeFlags;
+
+    /**
+     * Indicates whether the frame is secured with a given set of allowed Key ID Modes.
+     *
+     * @param[in] aFlags  A bitmask of `KeyIdModeFlags` specifying the allowed modes.
+     *
+     * @retval TRUE   The frame has security enabled and uses one of the allowed Key ID Modes.
+     * @retval FALSE  The frame does not have security enabled, or its Key ID Mode is not allowed.
+     */
+    bool IsSecuredWith(KeyIdModeFlags aFlags) const;
+
+    /**
      * Returns the RSSI in dBm used for reception.
      *
      * @returns The RSSI in dBm used for reception.
@@ -984,7 +868,7 @@
      *
      * @returns The timestamp in microseconds.
      */
-    const uint64_t &GetTimestamp(void) const { return mInfo.mRxInfo.mTimestamp; }
+    const RadioTime64 &GetTimestamp(void) const { return mInfo.mRxInfo.mTimestamp; }
 
     /**
      * Performs AES CCM on the frame which is received.
@@ -997,25 +881,6 @@
      * @retval kErrorSecurity  Received frame MIC check failed.
      */
     Error ProcessReceiveAesCcm(const ExtAddress &aExtAddress, const KeyMaterial &aMacKey);
-
-#if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-    /**
-     * Gets the offset to network time.
-     *
-     * @returns  The offset to network time.
-     */
-    int64_t ComputeNetworkTimeOffset(void) const
-    {
-        return static_cast<int64_t>(GetTimeIe()->GetTime() - GetTimestamp());
-    }
-
-    /**
-     * Gets the time sync sequence.
-     *
-     * @returns  The time sync sequence.
-     */
-    uint8_t ReadTimeSyncSeq(void) const { return GetTimeIe()->GetSequence(); }
-#endif // OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
 };
 
 /**
@@ -1025,22 +890,22 @@
 {
 public:
     /**
-     * Represents header information.
+     * Represents the information to use to build the frame.
      */
-    struct Info : public Clearable<Info>
+    struct BuildInfo : public Clearable<BuildInfo>
     {
         /**
-         * Initializes the `Info` by clearing all its fields (setting all bytes to zero).
+         * Initializes the `BuildInfo` by clearing all its fields (setting all bytes to zero).
          */
-        Info(void) { Clear(); }
+        BuildInfo(void) { Clear(); }
 
         /**
-         * Prepares MAC headers based on `Info` fields in a given `TxFrame`.
+         * Prepares MAC headers based on `BuildInfo` fields in a given `TxFrame`.
          *
-         * This method uses the `Info` structure to construct the MAC address and security headers in @p aTxFrame.
+         * This method uses the `BuildInfo` structure to construct the MAC address and security headers in @p aTxFrame.
          * It determines the Frame Control Field (FCF), including setting the appropriate frame type, security level,
          * and addressing mode flags. It populates the source and destination addresses and PAN IDs within the MAC
-         * header based on the information provided in the `Info` structure.
+         * header based on the information provided in the `BuildInfo` structure.
          *
          * It sets the Ack Request bit in the FCF if the following criteria are met:
          *   - A destination address is present
@@ -1217,6 +1082,14 @@
     void ProcessTransmitAesCcm(const ExtAddress &aExtAddress);
 
     /**
+     * Restore the frame for transmit processing.
+     *
+     * @param[in]  aExtAddress  A reference to the extended address, which will be used to generate nonce
+     *                          for AES CCM computation.
+     */
+    void RestoreTransmitSecurity(const ExtAddress &aExtAddress);
+
+    /**
      * Indicates whether or not the frame has security processed.
      *
      * @retval TRUE   The frame already has security processed.
@@ -1334,6 +1207,13 @@
 
 #if OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2
     /**
+     * Gets the TX delay field for the frame.
+     *
+     * @returns The delay time for the TX frame in microseconds.
+     */
+    uint32_t GetTxDelay(void) const { return mInfo.mTxInfo.mTxDelay; }
+
+    /**
      * Set TX delay field for the frame.
      *
      * @param[in]    aTxDelay    The delay time for the TX frame.
@@ -1341,11 +1221,18 @@
     void SetTxDelay(uint32_t aTxDelay) { mInfo.mTxInfo.mTxDelay = aTxDelay; }
 
     /**
+     * Gets the TX delay base time field for the frame.
+     *
+     * @returns The delay base time for the TX frame as a `RadioTime32`.
+     */
+    RadioTime32 GetTxDelayBaseTime(void) const { return mInfo.mTxInfo.mTxDelayBaseTime; }
+
+    /**
      * Set TX delay base time field for the frame.
      *
      * @param[in]    aTxDelayBaseTime    The delay base time for the TX frame.
      */
-    void SetTxDelayBaseTime(uint32_t aTxDelayBaseTime) { mInfo.mTxInfo.mTxDelayBaseTime = aTxDelayBaseTime; }
+    void SetTxDelayBaseTime(RadioTime32 aTxDelayBaseTime) { mInfo.mTxInfo.mTxDelayBaseTime = aTxDelayBaseTime; }
 #endif
 };
 
diff --git a/src/core/mac/mac_header_ie.cpp b/src/core/mac/mac_header_ie.cpp
index b6fb821..c9e5bf2 100644
--- a/src/core/mac/mac_header_ie.cpp
+++ b/src/core/mac/mac_header_ie.cpp
@@ -36,24 +36,61 @@
 namespace ot {
 namespace Mac {
 
-void HeaderIe::Init(uint16_t aId, uint8_t aLen)
+void HeaderIe::Init(uint8_t aId, uint8_t aLen)
 {
-    Init();
-    SetId(aId);
+    mLenIdType = 0;
     SetLength(aLen);
+    SetId(aId);
+}
+
+Error HeaderIe::StartIe(FrameBuilder &aBuilder, uint8_t aId, Bookmark &aBookmark)
+{
+    Error     error = kErrorNone;
+    HeaderIe *ie;
+
+    aBookmark = aBuilder.GetLength();
+
+    ie = aBuilder.Append<HeaderIe>();
+    VerifyOrExit(ie != nullptr, error = kErrorNoBufs);
+
+    ie->Init(aId, 0);
+
+exit:
+    return error;
+}
+
+Error HeaderIe::EndIe(FrameBuilder &aBuilder, const Bookmark &aBookmark)
+{
+    Error     error  = kErrorNone;
+    uint16_t  offset = aBookmark;
+    uint16_t  length;
+    HeaderIe *ie;
+
+    VerifyOrExit(offset + sizeof(HeaderIe) <= aBuilder.GetLength(), error = kErrorInvalidArgs);
+
+    ie = aBuilder.Read<HeaderIe>(offset);
+
+    length = aBuilder.GetLength() - offset - sizeof(HeaderIe);
+    VerifyOrExit(length <= kMaxLength, error = kErrorInvalidArgs);
+
+    ie->SetLength(static_cast<uint8_t>(length));
+
+exit:
+    return error;
 }
 
 #if OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
+
 Error ConnectionIe::SetWakeupId(WakeupId aWakeupId)
 {
-    Error           error          = kErrorNone;
-    const HeaderIe *headerIe       = GetHeaderIe();
-    uint8_t         wakeupIdLength = GetWakeupIdLength(aWakeupId);
+    Error   error          = kErrorNone;
+    uint8_t wakeupIdLength = GetWakeupIdLength(aWakeupId);
 
-    VerifyOrExit(headerIe->GetLength() > sizeof(ConnectionIe), error = kErrorParse);
-    VerifyOrExit(headerIe->GetLength() - sizeof(ConnectionIe) == wakeupIdLength, error = kErrorParse);
+    VerifyOrExit(GetSize() >= sizeof(ConnectionIe), error = kErrorParse);
+    VerifyOrExit(GetSize() - sizeof(ConnectionIe) == wakeupIdLength, error = kErrorParse);
+
     aWakeupId = LittleEndian::HostSwap64(aWakeupId);
-    memcpy(GetWakeupIdData(), reinterpret_cast<uint8_t *>(&aWakeupId), wakeupIdLength);
+    memcpy(GetBytes() + sizeof(ConnectionIe), reinterpret_cast<uint8_t *>(&aWakeupId), wakeupIdLength);
 
 exit:
     return error;
@@ -61,22 +98,22 @@
 
 Error ConnectionIe::GetWakeupId(WakeupId &aWakeupId) const
 {
-    Error           error    = kErrorNone;
-    const HeaderIe *headerIe = GetHeaderIe();
-    uint8_t         wakeupIdLength;
+    Error   error = kErrorNone;
+    uint8_t wakeupIdLength;
 
-    VerifyOrExit(headerIe->GetLength() > sizeof(ConnectionIe), error = kErrorParse);
+    VerifyOrExit(GetSize() > sizeof(ConnectionIe), error = kErrorParse);
 
-    wakeupIdLength = headerIe->GetLength() - sizeof(ConnectionIe);
+    wakeupIdLength = GetSize() - sizeof(ConnectionIe);
     VerifyOrExit(wakeupIdLength <= sizeof(WakeupId), error = kErrorParse);
 
     aWakeupId = 0;
-    memcpy(reinterpret_cast<uint8_t *>(&aWakeupId), GetWakeupIdData(), wakeupIdLength);
+    memcpy(reinterpret_cast<uint8_t *>(&aWakeupId), GetBytes() + sizeof(ConnectionIe), wakeupIdLength);
     aWakeupId = LittleEndian::HostSwap64(aWakeupId);
 
 exit:
     return error;
 }
+
 #endif // OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
 
 } // namespace Mac
diff --git a/src/core/mac/mac_header_ie.hpp b/src/core/mac/mac_header_ie.hpp
index e36234b..9da8b24 100644
--- a/src/core/mac/mac_header_ie.hpp
+++ b/src/core/mac/mac_header_ie.hpp
@@ -38,7 +38,10 @@
 
 #include "common/as_core_type.hpp"
 #include "common/bit_utils.hpp"
+#include "common/const_cast.hpp"
 #include "common/encoding.hpp"
+#include "common/frame_builder.hpp"
+#include "common/num_utils.hpp"
 #include "common/numeric_limits.hpp"
 #include "mac/mac_types.hpp"
 
@@ -52,84 +55,140 @@
  */
 
 /**
- * Implements IEEE 802.15.4 IE (Information Element) header generation and parsing.
+ * Implements IEEE 802.15.4 IE (Information Element) generation and parsing.
  */
 OT_TOOL_PACKED_BEGIN
 class HeaderIe
 {
 public:
-    /**
-     * Initializes the Header IE.
-     */
-    void Init(void) { mFields.m16 = 0; }
+    static constexpr uint8_t kMaxLength = 127; ///< Maximum Header IE length in bytes.
 
     /**
-     * Initializes the Header IE with Id and Length.
+     * Returns the IE Element ID.
      *
-     * @param[in]  aId   The IE Element Id.
-     * @param[in]  aLen  The IE content length.
+     * @returns the IE Element ID.
      */
-    void Init(uint16_t aId, uint8_t aLen);
-
-    /**
-     * Returns the IE Element Id.
-     *
-     * @returns the IE Element Id.
-     */
-    uint16_t GetId(void) const { return ReadBitsLittleEndian<uint16_t, kIdMask>(mFields.m16); }
-
-    /**
-     * Sets the IE Element Id.
-     *
-     * @param[in]  aId  The IE Element Id.
-     */
-    void SetId(uint16_t aId) { mFields.m16 = UpdateBitsLittleEndian<uint16_t, kIdMask>(mFields.m16, aId); }
+    uint8_t GetId(void) const { return static_cast<uint8_t>(ReadBitsLittleEndian<uint16_t, kIdMask>(mLenIdType)); }
 
     /**
      * Returns the IE content length.
      *
      * @returns the IE content length.
      */
-    uint8_t GetLength(void) const { return ReadBits<uint8_t, kLengthMask>(mFields.m8[0]); }
+    uint8_t GetLength(void) const { return static_cast<uint8_t>(ReadBitsLittleEndian<uint16_t, kLenMask>(mLenIdType)); }
 
     /**
-     * Sets the IE content length.
+     * Returns the total size of the Header IE (descriptor header plus content length) in bytes.
      *
-     * @param[in]  aLength  The IE content length.
+     * @note The total size fits in a `uint8_t` since the content length is limited to 7 bits (max 127 bytes).
+     *
+     * @returns The total size of the Header IE in bytes.
      */
-    void SetLength(uint8_t aLength) { WriteBits<uint8_t, kLengthMask>(mFields.m8[0], aLength); }
+    uint8_t GetSize(void) const { return GetLength() + sizeof(HeaderIe); }
+
+    /**
+     * Returns a pointer to the IE content bytes.
+     *
+     * @returns A pointer to the IE content bytes.
+     */
+    const uint8_t *GetContent(void) const { return GetBytes() + sizeof(HeaderIe); }
+
+    /**
+     * Returns a pointer to the IE content bytes.
+     *
+     * @returns A pointer to the IE content bytes.
+     */
+    uint8_t *GetContent(void) { return AsNonConst(AsConst(this)->GetContent()); }
+
+    /**
+     * Validates whether a given Header IE matches a specific IE subclass.
+     *
+     * This method checks whether @p aIe matches the Element ID of @p IeType (`IeType::kId`) and also casts @p aIe
+     * to @p IeType to validate its content structure via `IeType::IsValid()`.
+     *
+     * @tparam IeType  The IE subclass type to validate against.
+     *
+     * @param[in] aIe  The Header IE to validate.
+     *
+     * @retval TRUE   @p aIe matches @p IeType and its content is well-formed.
+     * @retval FALSE  @p aIe does not match @p IeType or its content is malformed.
+     */
+    template <typename IeType> static bool ValidateAs(const HeaderIe &aIe)
+    {
+        return (aIe.GetId() == IeType::kId) && static_cast<const IeType *>(&aIe)->IsValid();
+    }
+
+    /**
+     * Represents the opaque type for a bookmark used by `StartIe()/EndIe()`.
+     */
+    typedef uint16_t Bookmark;
+
+    /**
+     * Starts appending a (variable-length) `HeaderIe` in a `FrameBuilder`.
+     *
+     * On success, this method appends a `HeaderIe` descriptor (with length initialized to zero) to @p aBuilder and
+     * saves the current byte offset as @p aBookmark. The caller can then append the IE content bytes to @p aBuilder,
+     * and finally call `EndIe()` to update the IE length field automatically.
+     *
+     * @param[in,out] aBuilder   The `FrameBuilder` instance to append to.
+     * @param[in]     aId        The IE Element ID.
+     * @param[out]    aBookmark  A reference to a `Bookmark` to save the start offset.
+     *
+     * @retval kErrorNone    Successfully started the `HeaderIe`.
+     * @retval kErrorNoBufs  Insufficient space in @p aBuilder to append the `HeaderIe` header.
+     */
+    static Error StartIe(FrameBuilder &aBuilder, uint8_t aId, Bookmark &aBookmark);
+
+    /**
+     * Finishes appending a `HeaderIe` in a `FrameBuilder`.
+     *
+     * This method updates the length field of the `HeaderIe` previously started using `StartIe()`. It determines the
+     * IE length based on the number of bytes appended to @p aBuilder since `StartIe()` was called.
+     *
+     * @param[in,out] aBuilder   The `FrameBuilder` instance.
+     * @param[in]     aBookmark  The `Bookmark` used when calling `StartIe()`.
+     *
+     * @retval kErrorNone         Successfully finalized the `HeaderIe` length.
+     * @retval kErrorInvalidArgs  The @p aBookmark is invalid or the appended IE length exceeds `kMaxLength`.
+     */
+    static Error EndIe(FrameBuilder &aBuilder, const Bookmark &aBookmark);
+
+protected:
+    void           Init(uint8_t aId, uint8_t aLen);
+    uint8_t       *GetBytes(void) { return reinterpret_cast<uint8_t *>(this); }
+    const uint8_t *GetBytes(void) const { return reinterpret_cast<const uint8_t *>(this); }
 
 private:
-    // Header IE format:
+    // IEEE 802.15.4 Header IE descriptor (2 bytes, little-endian):
     //
-    // +-----------+------------+--------+
-    // | Bits: 0-6 |    7-14    |   15   |
-    // +-----------+------------+--------+
-    // | Length    | Element ID | Type=0 |
-    // +-----------+------------+--------+
+    // Bits 0-6  (7 bits) : Length of IE content in bytes (max 127).
+    // Bits 7-14 (8 bits) : Element ID.
+    // Bit 15    (1 bit)  : Type (0 for Header IE).
 
-    static constexpr uint8_t  kSize       = 2;
-    static constexpr uint8_t  kIdOffset   = 7;
-    static constexpr uint8_t  kLengthMask = 0x7f;
-    static constexpr uint16_t kIdMask     = 0x00ff << kIdOffset;
+    static constexpr uint16_t kLenMask = 0x007f << 0;
+    static constexpr uint16_t kIdMask  = 0x00ff << 7;
 
-    union OT_TOOL_PACKED_FIELD
-    {
-        uint8_t  m8[kSize];
-        uint16_t m16;
-    } mFields;
+    void SetId(uint8_t aId) { mLenIdType = UpdateBitsLittleEndian<uint16_t, kIdMask>(mLenIdType, aId); }
+    void SetLength(uint8_t aLength) { mLenIdType = UpdateBitsLittleEndian<uint16_t, kLenMask>(mLenIdType, aLength); }
 
+    uint16_t mLenIdType;
 } OT_TOOL_PACKED_END;
 
 /**
- * Implements CSL IE data structure.
+ * Represents a CSL IE.
  */
 OT_TOOL_PACKED_BEGIN
-class CslIe
+class CslIe : public HeaderIe
 {
+    friend class HeaderIe;
+
 public:
-    static constexpr uint8_t kHeaderIeId    = 0x1a;
-    static constexpr uint8_t kIeContentSize = sizeof(uint16_t) * 2;
+    static constexpr uint8_t kId = 0x1a; ///< The CSL IE Element ID.
+
+    /**
+     * Initializes the CSL IE.
+     */
+    void Init(void) { HeaderIe::Init(kId, sizeof(CslIe) - sizeof(HeaderIe)); }
 
     /**
      * Returns the CSL Period.
@@ -160,31 +219,40 @@
     void SetPhase(uint16_t aPhase) { mPhase = LittleEndian::HostSwap16(aPhase); }
 
 private:
+    bool IsValid(void) const { return GetSize() >= sizeof(CslIe); }
+
     uint16_t mPhase;
     uint16_t mPeriod;
 } OT_TOOL_PACKED_END;
 
 /**
- * Implements Termination2 IE.
- *
- * Is empty for template specialization.
- */
-class Termination2Ie
-{
-public:
-    static constexpr uint8_t kHeaderIeId    = 0x7f;
-    static constexpr uint8_t kIeContentSize = 0;
-};
-
-/**
- * Implements vendor specific Header IE generation and parsing.
+ * Represents a Termination2 IE.
  */
 OT_TOOL_PACKED_BEGIN
-class VendorIeHeader
+class Termination2Ie : public HeaderIe
+{
+    friend class HeaderIe;
+
+public:
+    static constexpr uint8_t kId = 0x7f; ///< The Termination2 IE Element ID.
+
+    /**
+     * Initializes the Termination2 IE.
+     */
+    void Init(void) { HeaderIe::Init(kId, sizeof(Termination2Ie) - sizeof(HeaderIe)); }
+
+private:
+    bool IsValid(void) const { return true; }
+} OT_TOOL_PACKED_END;
+
+/**
+ * Represents a Vendor Header IE.
+ */
+OT_TOOL_PACKED_BEGIN
+class VendorIe : public HeaderIe
 {
 public:
-    static constexpr uint8_t kHeaderIeId    = 0x00;
-    static constexpr uint8_t kIeContentSize = sizeof(uint8_t) * 4;
+    static constexpr uint8_t kId = 0x00; ///< The Vendor Specific IE Element ID.
 
     /**
      * Returns the Vendor OUI.
@@ -194,24 +262,14 @@
     uint32_t GetVendorOui(void) const { return LittleEndian::ReadUint24(mOui); }
 
     /**
-     * Sets the Vendor OUI.
-     *
-     * @param[in]  aVendorOui  A Vendor OUI.
-     */
-    void SetVendorOui(uint32_t aVendorOui) { LittleEndian::WriteUint24(aVendorOui, mOui); }
-
-    /**
      * Returns the Vendor IE sub-type.
      *
      * @returns The Vendor IE sub-type.
      */
     uint8_t GetSubType(void) const { return mSubType; }
 
-    /**
-     * Sets the Vendor IE sub-type.
-     *
-     * @param[in]  aSubType  The Vendor IE sub-type.
-     */
+protected:
+    void SetVendorOui(uint32_t aVendorOui) { LittleEndian::WriteUint24(aVendorOui, mOui); }
     void SetSubType(uint8_t aSubType) { mSubType = aSubType; }
 
 private:
@@ -223,24 +281,25 @@
 
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
 /**
- * Implements Time Header IE generation and parsing.
+ * Represents a Time Header IE.
+ *
+ * This IE is not specified in the Thread specification and is a custom feature in OpenThread (using Vendor IE
+ * with Nest OUI).
  */
 OT_TOOL_PACKED_BEGIN
-class TimeIe : public VendorIeHeader
+class TimeIe : public VendorIe
 {
-public:
-    static constexpr uint32_t kVendorOuiNest = 0x18b430;
-    static constexpr uint8_t  kVendorIeTime  = 0x01;
-    static constexpr uint8_t  kHeaderIeId    = VendorIeHeader::kHeaderIeId;
-    static constexpr uint8_t  kIeContentSize = VendorIeHeader::kIeContentSize + sizeof(uint8_t) + sizeof(uint64_t);
+    friend class HeaderIe;
 
+public:
     /**
-     * Initializes the time IE.
+     * Initializes the Time IE.
      */
     void Init(void)
     {
+        HeaderIe::Init(kId, sizeof(TimeIe) - sizeof(HeaderIe));
         SetVendorOui(kVendorOuiNest);
-        SetSubType(kVendorIeTime);
+        SetSubType(kSubType);
     }
 
     /**
@@ -271,22 +330,100 @@
      */
     void SetTime(uint64_t aTime) { mTime = LittleEndian::HostSwap64(aTime); }
 
+    /**
+     * Returns a pointer to the start of Time IE specific data content (i.e., sequence field).
+     *
+     * @returns A pointer to the Time IE data content bytes.
+     */
+    const uint8_t *GetData(void) const { return &mSequence; }
+
 private:
+    bool IsValid(void) const
+    {
+        return (GetSize() >= sizeof(TimeIe)) && (GetVendorOui() == kVendorOuiNest) && (GetSubType() == kSubType);
+    }
+
+    static constexpr uint32_t kVendorOuiNest = 0x18b430;
+    static constexpr uint8_t  kSubType       = 0x01;
+
     uint8_t  mSequence;
     uint64_t mTime;
 } OT_TOOL_PACKED_END;
 #endif // OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
 
-class ThreadIe
+/**
+ * Represents a Thread Vendor IE.
+ */
+OT_TOOL_PACKED_BEGIN
+class ThreadVendorIe : public VendorIe
 {
-public:
-    static constexpr uint8_t  kHeaderIeId               = VendorIeHeader::kHeaderIeId;
-    static constexpr uint8_t  kIeContentSize            = VendorIeHeader::kIeContentSize;
-    static constexpr uint32_t kVendorOuiThreadCompanyId = 0xeab89b;
-    static constexpr uint8_t  kEnhAckProbingIe          = 0x00;
-};
+protected:
+    static constexpr uint32_t kVendorOuiThread = 0xeab89b;
+} OT_TOOL_PACKED_END;
 
-#if OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
+/**
+ * Represents a Link Metrics Probing IE (using in Enhanced Ack).
+ */
+OT_TOOL_PACKED_BEGIN
+class LinkMetricsProbingIe : public ThreadVendorIe
+{
+    friend class HeaderIe;
+
+public:
+    /**
+     * The maximum length of Link Metrics Data in bytes (Thread specification limits metrics to 2).
+     */
+    static constexpr uint8_t kMaxMetricsDataLen = 2;
+
+    /**
+     * Initializes the Link Metrics Probing IE.
+     *
+     * @param[in] aMetricsDataLen  The requested length of Link Metrics Data. If greater than `kMaxMetricsDataLen`,
+     *                             then `kMaxMetricsDataLen` is used instead.
+     */
+    void Init(uint8_t aMetricsDataLen)
+    {
+        HeaderIe::Init(kId, sizeof(LinkMetricsProbingIe) - sizeof(HeaderIe) + Min(aMetricsDataLen, kMaxMetricsDataLen));
+        SetVendorOui(kVendorOuiThread);
+        SetSubType(kSubType);
+    }
+
+    /**
+     * Returns the length of Link Metrics Data in bytes.
+     *
+     * @returns The length of Link Metrics Data in bytes.
+     */
+    uint8_t GetMetricsDataLen(void) const { return GetSize() - sizeof(LinkMetricsProbingIe); }
+
+    /**
+     * Returns a pointer to the Link Metrics Data bytes.
+     *
+     * @returns A pointer to the Link Metrics Data bytes.
+     */
+    const uint8_t *GetMetricsData(void) const { return GetBytes() + sizeof(LinkMetricsProbingIe); }
+
+    /**
+     * Writes Link Metrics Data content from a given buffer.
+     *
+     * @param[in] aData  A pointer to a buffer containing the data to write. The caller must ensure that at least
+     *                   `GetMetricsDataLen()` bytes are available in @p aData.
+     */
+    void WriteMetricsDataFrom(const uint8_t *aData)
+    {
+        memcpy(AsNonConst(GetMetricsData()), aData, GetMetricsDataLen());
+    }
+
+private:
+    static constexpr uint8_t kSubType = 0x00;
+
+    bool IsValid(void) const
+    {
+        return (GetSize() >= sizeof(LinkMetricsProbingIe)) && (GetVendorOui() == kVendorOuiThread) &&
+               (GetSubType() == kSubType);
+    }
+
+} OT_TOOL_PACKED_END;
+
 /**
  * This class implements Rendezvous Time IE data structure.
  *
@@ -295,11 +432,17 @@
  * not included in this class.
  */
 OT_TOOL_PACKED_BEGIN
-class RendezvousTimeIe
+class RendezvousTimeIe : public HeaderIe
 {
+    friend class HeaderIe;
+
 public:
-    static constexpr uint8_t kHeaderIeId    = 0x1d;
-    static constexpr uint8_t kIeContentSize = sizeof(uint16_t);
+    static constexpr uint8_t kId = 0x1d; ///< The Rendezvous Time IE Element ID.
+
+    /**
+     * Initializes the Rendezvous Time IE.
+     */
+    void Init(void) { HeaderIe::Init(kId, sizeof(RendezvousTimeIe) - sizeof(HeaderIe)); }
 
     /**
      * This method returns the Rendezvous Time.
@@ -316,27 +459,32 @@
     void SetRendezvousTime(uint16_t aRendezvousTime) { mRendezvousTime = LittleEndian::HostSwap16(aRendezvousTime); }
 
 private:
+    bool IsValid(void) const { return GetSize() >= sizeof(RendezvousTimeIe); }
+
     uint16_t mRendezvousTime;
 } OT_TOOL_PACKED_END;
 
+#if OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
+
 /**
  * Implements Connection IE data structure.
  */
 OT_TOOL_PACKED_BEGIN
-class ConnectionIe : public VendorIeHeader
+class ConnectionIe : public ThreadVendorIe
 {
-public:
-    static constexpr uint8_t kHeaderIeId      = ThreadIe::kHeaderIeId;
-    static constexpr uint8_t kIeContentSize   = ThreadIe::kIeContentSize + sizeof(uint8_t);
-    static constexpr uint8_t kThreadIeSubtype = 0x01;
+    friend class HeaderIe;
 
+public:
     /**
      * Initializes the Connection IE.
+     *
+     * @param[in] aWakeupIdLength  The length of the Wakeup ID field in bytes.
      */
-    void Init(void)
+    void Init(uint8_t aWakeupIdLength)
     {
-        SetVendorOui(ThreadIe::kVendorOuiThreadCompanyId);
-        SetSubType(kThreadIeSubtype);
+        HeaderIe::Init(kId, sizeof(ConnectionIe) - sizeof(HeaderIe) + aWakeupIdLength);
+        SetVendorOui(kVendorOuiThread);
+        SetSubType(kSubType);
         mConnectionWindow = 0;
     }
 
@@ -397,25 +545,20 @@
      */
     Error GetWakeupId(WakeupId &aWakeupId) const;
 
-    /**
-     * Gets the pointer to the HeaderIe of this ConnectionIe.
-     *
-     * @returns A pointer to the HeaderIe.
-     */
-    const HeaderIe *GetHeaderIe(void) const
+private:
+    static constexpr uint8_t kSubType = 0x01;
+
+    static constexpr uint8_t kRetryIntervalMask = 0x3 << 4;
+    static constexpr uint8_t kRetryCountMask    = 0xf << 0;
+
+    bool IsValid(void) const
     {
-        return reinterpret_cast<const HeaderIe *>(reinterpret_cast<const uint8_t *>(this) - sizeof(HeaderIe));
+        return (GetSize() >= sizeof(ConnectionIe)) && (GetVendorOui() == kVendorOuiThread) &&
+               (GetSubType() == kSubType);
     }
 
-private:
-    static constexpr uint8_t kRetryIntervalOffset = 4;
-    static constexpr uint8_t kRetryIntervalMask   = 0x3 << kRetryIntervalOffset;
-    static constexpr uint8_t kRetryCountMask      = 0xf;
-
-    const uint8_t *GetWakeupIdData(void) const { return reinterpret_cast<const uint8_t *>(this) + sizeof(*this); }
-    uint8_t       *GetWakeupIdData(void) { return reinterpret_cast<uint8_t *>(this) + sizeof(*this); }
-
     uint8_t mConnectionWindow;
+    // Followed by variable length Wakeup ID
 } OT_TOOL_PACKED_END;
 #endif // OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
 
diff --git a/src/core/mac/mac_links.hpp b/src/core/mac/mac_links.hpp
index 5610ec0..27432e7 100644
--- a/src/core/mac/mac_links.hpp
+++ b/src/core/mac/mac_links.hpp
@@ -517,18 +517,22 @@
     }
 
     /**
-     * Gets the radio transmit frames.
+     * Initializes and retrieves the radio transmit frames `TxFrames`.
      *
      * @returns The transmit frames.
      */
-    TxFrames &GetTxFrames(void) { return mTxFrames; }
+    TxFrames &InitTxFrames(void)
+    {
+        mTxFrames.Clear();
+        return mTxFrames;
+    }
 
 #if !OPENTHREAD_CONFIG_MULTI_RADIO
 
     /**
      * Sends a prepared frame.
      *
-     * The prepared frame is from `GetTxFrames()`. This method is available only in single radio link mode.
+     * The prepared frame is from `InitTxFrames()`. This method is available only in single radio link mode.
      */
     void Send(void)
     {
@@ -545,13 +549,24 @@
     /**
      * Sends prepared frames over a given set of radio links.
      *
-     * The prepared frame must be from `GetTxFrames()`. This method is available only in multi radio link mode.
+     * The prepared frame must be from `InitTxFrames()`. This method is available only in multi radio link mode.
      *
      * @param[in] aFrame       A reference to a prepared frame.
      * @param[in] aRadioTypes  A set of radio types to send on.
      */
     void Send(TxFrame &aFrame, RadioTypes aRadioTypes);
 
+    /**
+     * Gets the required radio types (`GetRequiredRadioTypes()`) from `TxFrames`.
+     *
+     * This set specifies the radio links for which we expect the frame tx to be successful to consider the overall tx
+     * successful. If the set is empty, successful tx over any radio link is sufficient for overall tx to be considered
+     * successful. The required radio type set is expected to be a subset of selected radio types.
+     *
+     * @returns The required radio types.
+     */
+    RadioTypes GetTxFramesRequiredRadioTypes(void) const { return mTxFrames.GetRequiredRadioTypes(); }
+
 #endif // !OPENTHREAD_CONFIG_MULTI_RADIO
 
     /**
diff --git a/src/core/mac/mac_types.cpp b/src/core/mac/mac_types.cpp
index ce18d7e..4ec56c1 100644
--- a/src/core/mac/mac_types.cpp
+++ b/src/core/mac/mac_types.cpp
@@ -52,7 +52,7 @@
 
     do
     {
-        panId = Random::NonCrypto::GetUint16();
+        panId = Random::NonCrypto::Generate<uint16_t>();
     } while (panId == kPanIdBroadcast);
 
     return panId;
@@ -84,6 +84,29 @@
     return string;
 }
 
+Error ExtAddress::FromString(const char *aString)
+{
+    Error   error = kErrorNone;
+    uint8_t high;
+    uint8_t low;
+
+    VerifyOrExit(aString != nullptr, error = kErrorInvalidArgs);
+
+    for (uint8_t &byte : m8)
+    {
+        SuccessOrExit(error = ParseHexDigit(*aString, high));
+        aString++;
+        SuccessOrExit(error = ParseHexDigit(*aString, low));
+        aString++;
+        byte = static_cast<uint8_t>((high << 4) | low);
+    }
+
+    VerifyOrExit(*aString == kNullChar, error = kErrorParse);
+
+exit:
+    return error;
+}
+
 void ExtAddress::CopyAddress(uint8_t *aDst, const uint8_t *aSrc, CopyByteOrder aByteOrder)
 {
     switch (aByteOrder)
diff --git a/src/core/mac/mac_types.hpp b/src/core/mac/mac_types.hpp
index d667fd7..f49c9b5 100644
--- a/src/core/mac/mac_types.hpp
+++ b/src/core/mac/mac_types.hpp
@@ -80,6 +80,8 @@
 
 constexpr ShortAddress kShortAddrBroadcast = OT_RADIO_BROADCAST_SHORT_ADDR; ///< Broadcast Short Address.
 constexpr ShortAddress kShortAddrInvalid   = OT_RADIO_INVALID_SHORT_ADDR;   ///< Invalid Short Address.
+constexpr bool         kDefaultMacKeysExportable =
+    OPENTHREAD_CONFIG_PLATFORM_MAC_KEYS_EXPORTABLE_ENABLE; ///< Default exportability policy for MAC key refs.
 
 /**
  * Represents the wake-up identifier.
@@ -232,6 +234,20 @@
      */
     InfoString ToString(void) const;
 
+    /**
+     * Parses an Extended Address from a string.
+     *
+     * The string must be a hex representation of the address (e.g., "0123456789abcdef").
+     * The parsing is case-insensitive.
+     *
+     * @param[in]  aString  A pointer to the string to parse.
+     *
+     * @retval kErrorNone          Successfully parsed the Extended Address.
+     * @retval kErrorInvalidArgs   @p aString is `nullptr`.
+     * @retval kErrorParse         @p aString is not a valid hex string representation of an Extended Address.
+     */
+    Error FromString(const char *aString);
+
 private:
     static constexpr uint8_t kGroupFlag = (1 << 0);
     static constexpr uint8_t kLocalFlag = (1 << 1);
diff --git a/src/core/mac/sub_mac.cpp b/src/core/mac/sub_mac.cpp
index 27cefde..e949bb2 100644
--- a/src/core/mac/sub_mac.cpp
+++ b/src/core/mac/sub_mac.cpp
@@ -63,6 +63,10 @@
     mCslParentAccuracy.Init();
 #endif
 
+#if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && !OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE
+    // Assuming the platform must deal with the retransmission security correctly.
+    OT_ASSERT(mRadioCaps & OT_RADIO_CAPS_TRANSMIT_RETRIES);
+#endif
     Init();
 }
 
@@ -409,7 +413,7 @@
 
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
     // Transmit security will be processed after time IE content is updated.
-    VerifyOrExit(mTransmitFrame.GetTimeIeOffset() == 0);
+    VerifyOrExit(!mTransmitFrame.Has<TimeIe>());
 #endif
 
     mTransmitFrame.ProcessTransmitAesCcm(*extAddress);
@@ -423,32 +427,29 @@
     uint8_t backoffExponent = kCsmaMinBe + mCsmaBackoffs;
 
 #if !OPENTHREAD_MTD && OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
-    if (mTransmitFrame.mInfo.mTxInfo.mTxDelay != 0 || mTransmitFrame.mInfo.mTxInfo.mTxDelayBaseTime != 0)
+    if (mTransmitFrame.GetTxDelay() != 0 || mTransmitFrame.GetTxDelayBaseTime() != 0)
     {
         SetState(kStateCslTransmit);
 
         if (ShouldHandleTransmitTargetTime())
         {
             static constexpr uint32_t kAheadTime = kCcaSampleInterval + kCslTransmitTimeAhead + kRadioHeaderShrDuration;
-            Time                      txStartTime = Time(mTransmitFrame.mInfo.mTxInfo.mTxDelayBaseTime);
-            Time                      radioNow    = Time(static_cast<uint32_t>(Get<Radio>().GetNow()));
+
+            RadioTime32 radioNow    = Get<Radio>().GetNowAsRadioTime32();
+            RadioTime32 txStartTime = mTransmitFrame.GetTxDelayBaseTime();
 
             txStartTime += (mTransmitFrame.mInfo.mTxInfo.mTxDelay - kAheadTime);
 
-            if (radioNow < txStartTime)
+            if (IsRadioTimeStrictlyBefore(radioNow, txStartTime))
             {
                 StartTimer(txStartTime - radioNow);
+                ExitNow();
             }
-            else // Transmit without delay
-            {
-                BeginTransmit();
-            }
-        }
-        else
-        {
-            BeginTransmit();
+
+            // Transmit without delay
         }
 
+        BeginTransmit();
         ExitNow();
     }
 #endif // !OPENTHREAD_MTD && OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
@@ -469,7 +470,7 @@
 {
     uint32_t backoff;
 
-    backoff = Random::NonCrypto::GetUint32InRange(0, static_cast<uint32_t>(1UL << aBackoffExponent));
+    backoff = Random::NonCrypto::GenerateUpToExcluding(static_cast<uint32_t>(1UL << aBackoffExponent));
     backoff *= (kUnitBackoffPeriod * Radio::kSymbolTime);
 
     if (mRxOnWhenIdle)
@@ -601,6 +602,15 @@
         mTransmitRetries++;
         aFrame.SetIsARetransmission(true);
 
+#if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE
+        if (aFrame.GetSecurityEnabled() && aFrame.HasAnyHeaderIe())
+        {
+            aFrame.RestoreTransmitSecurity(GetExtAddress());
+        }
+
+        ProcessTransmitSecurity();
+#endif
+
 #if OPENTHREAD_CONFIG_MAC_ADD_DELAY_ON_NO_ACK_ERROR_BEFORE_RETRY
         if (aError == kErrorNoAck)
         {
diff --git a/src/core/mac/sub_mac.hpp b/src/core/mac/sub_mac.hpp
index e05a6a4..b43ca5d 100644
--- a/src/core/mac/sub_mac.hpp
+++ b/src/core/mac/sub_mac.hpp
@@ -672,28 +672,26 @@
     SubMacTimer mTimer;
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-    uint16_t mCslPeriod;             // The CSL sample period, in units of 10 symbols (160 microseconds).
-    uint8_t  mCslChannel : 7;        // The CSL sample channel.
-    bool     mIsCslSampling : 1;     // Indicates that the current time is in CSL sample window
-                                     // for platforms not supporting `Radio::ReceiveAt()`.
-    uint16_t    mCslPeerShort;       // The CSL peer short address.
-    uint32_t    mCslSampleTimeRadio; // The CSL sample time of the current period based on radio time (lower 32-bit).
-    TimeMicro   mCslSampleTimeLocal; // The CSL sample time of the current period based on local time.
-    TimeMicro   mCslLastSync;        // The timestamp of the last successful CSL synchronization.
-    CslAccuracy mCslParentAccuracy;  // The parent's CSL accuracy (clock accuracy and uncertainty).
-    TimerMicro  mCslTimer;
+    uint16_t mCslPeriod;                     // The CSL sample period, in units of 10 symbols (160 microseconds).
+    uint8_t  mCslChannel : 7;                // The CSL sample channel.
+    bool     mIsCslSampling : 1;             // Indicates that the current time is in CSL sample window
+                                             // for platforms not supporting `Radio::ReceiveAt()`.
+    uint16_t             mCslPeerShort;      // The CSL peer short address.
+    SyncedRadioLocalTime mCslSampleTime;     // The CSL sample time for current period.
+    TimeMicro            mCslLastSync;       // The timestamp of the last successful CSL synchronization.
+    CslAccuracy          mCslParentAccuracy; // The parent's CSL accuracy (clock accuracy and uncertainty).
+    TimerMicro           mCslTimer;
 #endif
 
 #if OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
-    bool mIsWedSampling : 1;          // Indicates that the current time is in WED's sample window
-                                      // for platforms not supporting `Radio::ReceiveAt()`.
-    bool       mIsWedEnabled : 1;     // Indicates if the WED is enabled.
-    uint32_t   mWakeupListenInterval; // The wake-up listen interval, in microseconds.
-    uint32_t   mWakeupListenDuration; // The wake-up listen duration, in microseconds.
-    uint8_t    mWakeupChannel;        // The wake-up sample channel.
-    TimeMicro  mWedSampleTime;        // The WED sample time of the current interval in local time.
-    uint64_t   mWedSampleTimeRadio;   // The WED sample time of the current interval in radio time.
-    TimerMicro mWedTimer;
+    bool mIsWedSampling : 1;                    // Indicates that the current time is in WED's sample window
+                                                // for platforms not supporting `Radio::ReceiveAt()`.
+    bool                 mIsWedEnabled : 1;     // Indicates if the WED is enabled.
+    uint32_t             mWakeupListenInterval; // The wake-up listen interval, in microseconds.
+    uint32_t             mWakeupListenDuration; // The wake-up listen duration, in microseconds.
+    uint8_t              mWakeupChannel;        // The wake-up sample channel.
+    SyncedRadioLocalTime mWedSampleTime;        // The WED sample time of the current interval.
+    TimerMicro           mWedTimer;
 #endif
 };
 
diff --git a/src/core/mac/sub_mac_csl_receiver.cpp b/src/core/mac/sub_mac_csl_receiver.cpp
index bec5263..c8894c8 100644
--- a/src/core/mac/sub_mac_csl_receiver.cpp
+++ b/src/core/mac/sub_mac_csl_receiver.cpp
@@ -44,12 +44,11 @@
 
 void SubMac::CslInit(void)
 {
-    mCslPeriod          = 0;
-    mCslChannel         = 0;
-    mCslPeerShort       = 0;
-    mIsCslSampling      = false;
-    mCslSampleTimeRadio = 0;
-    mCslSampleTimeLocal.SetValue(0);
+    mCslPeriod     = 0;
+    mCslChannel    = 0;
+    mCslPeerShort  = 0;
+    mIsCslSampling = false;
+    mCslSampleTime.Clear();
     mCslLastSync.SetValue(0);
     mCslTimer.Stop();
 }
@@ -66,8 +65,7 @@
         // Rewind sample times by one period. HandleCslTimer() will add this
         // period back, effectively re-evaluating the current CSL period's
         // schedule using the updated mCslLastSync.
-        mCslSampleTimeRadio -= periodUs;
-        mCslSampleTimeLocal -= periodUs;
+        mCslSampleTime -= periodUs;
 
         HandleCslTimer();
     }
@@ -77,7 +75,7 @@
 {
     // Actual synchronization timestamp should be from the sent frame instead of the current time.
     // Assuming the error here since it is bounded and has very small effect on the final window duration.
-    if (aAckFrame != nullptr && aFrame.HasCslIe())
+    if (aAckFrame != nullptr && aFrame.Has<CslIe>())
     {
         mCslLastSync = TimeMicro(GetLocalTime());
     }
@@ -99,7 +97,7 @@
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_LOCAL_TIME_SYNC
         mCslLastSync = TimerMicro::GetNow();
 #else
-        mCslLastSync = TimeMicro(static_cast<uint32_t>(aFrame->mInfo.mRxInfo.mTimestamp));
+        mCslLastSync = TimeMicro(ConvertRadioTime64To32(aFrame->GetTimestamp()));
 #endif
     }
 
@@ -127,12 +125,13 @@
     mCslPeriod     = aPeriod;
 
     mCslTimer.Stop();
+
     if (mCslPeriod > 0)
     {
-        mCslSampleTimeRadio = static_cast<uint32_t>(Get<Radio>().GetNow());
-        mCslSampleTimeLocal = TimerMicro::GetNow();
+        mCslSampleTime.SetToNow(Get<Radio>());
+
         // Update CSL sync time whenever CSL parameters are re-initialized.
-        mCslLastSync = mCslSampleTimeLocal;
+        mCslLastSync = mCslSampleTime.GetAsTimeMicro();
 
         HandleCslTimer();
     }
@@ -180,19 +179,18 @@
      *       x-|------------|-------------------------------------x-|------------|---------------------------------------|
      *            sample                   sleep                        sample                    sleep
      */
-    uint32_t periodUs = mCslPeriod * kUsPerTenSymbols;
-    uint32_t winStart;
-    uint32_t winDuration;
+    uint32_t    periodUs = mCslPeriod * kUsPerTenSymbols;
+    RadioTime32 winStart;
+    uint32_t    winDuration;
 
-    mCslTimer.FireAt(mCslSampleTimeLocal + periodUs - aTimeAhead - GetNextCycleDrift());
+    mCslTimer.FireAt(mCslSampleTime.GetAsTimeMicro() + periodUs - aTimeAhead - GetNextCycleDrift());
     aTimeAhead -= kCslReceiveTimeAhead;
-    winStart    = mCslSampleTimeRadio - aTimeAhead;
+    winStart    = mCslSampleTime.GetAsRadio32() - aTimeAhead;
     winDuration = aTimeAhead + aTimeAfter;
 
-    mCslSampleTimeRadio += periodUs;
-    mCslSampleTimeLocal += periodUs;
+    mCslSampleTime += periodUs;
 
-    Get<Radio>().UpdateCslSampleTime(mCslSampleTimeRadio);
+    Get<Radio>().UpdateCslSampleTime(mCslSampleTime.GetAsRadio32());
 
     // Schedule reception window for any state except RX - so that CSL RX Window has lower priority
     // than scanning or RX after the data poll.
@@ -221,7 +219,7 @@
     if (mIsCslSampling)
     {
         mIsCslSampling = false;
-        mCslTimer.FireAt(mCslSampleTimeLocal - aTimeAhead - GetNextCycleDrift());
+        mCslTimer.FireAt(mCslSampleTime.GetAsTimeMicro() - aTimeAhead - GetNextCycleDrift());
         if (mState == kStateRadioSample)
         {
             LogDebg("CSL sleep %lu", ToUlong(mCslTimer.GetNow().GetValue()));
@@ -233,15 +231,14 @@
         uint32_t winStart;
         uint32_t winDuration;
 
-        mCslTimer.FireAt(mCslSampleTimeLocal + aTimeAfter);
+        mCslTimer.FireAt(mCslSampleTime.GetAsTimeMicro() + aTimeAfter);
         mIsCslSampling = true;
         winStart       = TimerMicro::GetNow().GetValue();
         winDuration    = aTimeAhead + aTimeAfter;
 
-        mCslSampleTimeRadio += periodUs;
-        mCslSampleTimeLocal += periodUs;
+        mCslSampleTime += periodUs;
 
-        Get<Radio>().UpdateCslSampleTime(mCslSampleTimeRadio);
+        Get<Radio>().UpdateCslSampleTime(mCslSampleTime.GetAsRadio32());
 
         LogCslWindow(winStart, winDuration);
     }
@@ -290,7 +287,7 @@
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_LOCAL_TIME_SYNC
     now = TimerMicro::GetNow().GetValue();
 #else
-    now = static_cast<uint32_t>(Get<Radio>().GetNow());
+    now = Get<Radio>().GetNowAsRadioTime32();
 #endif
 
     return now;
@@ -321,16 +318,15 @@
                  (dst.GetType() == Address::kTypeExtended && dst.GetExtended() == GetExtAddress()));
 
     LogDebg("Received frame in state (SubMac %s, CSL %s), timestamp %lu", StateToString(mState),
-            mIsCslSampling ? "CslSample" : "CslSleep",
-            ToUlong(static_cast<uint32_t>(aFrame->mInfo.mRxInfo.mTimestamp)));
+            mIsCslSampling ? "CslSample" : "CslSleep", ToUlong(ConvertRadioTime64To32(aFrame->GetTimestamp())));
 
     VerifyOrExit(mState == kStateRadioSample);
 
     GetCslWindowEdges(ahead, after);
     ahead -= kMinReceiveOnAhead + kCslReceiveTimeAhead;
 
-    sampleTime = mCslSampleTimeRadio - mCslPeriod * kUsPerTenSymbols;
-    deviation  = static_cast<uint32_t>(aFrame->mInfo.mRxInfo.mTimestamp) + kRadioHeaderPhrDuration - sampleTime;
+    sampleTime = mCslSampleTime.GetAsRadio32() - mCslPeriod * kUsPerTenSymbols;
+    deviation  = ConvertRadioTime64To32(aFrame->GetTimestamp()) + kRadioHeaderPhrDuration - sampleTime;
 
     // This logs three values (all in microseconds):
     // - Absolute sample time in which the CSL receiver expected the MHR of the received frame.
diff --git a/src/core/mac/sub_mac_wed.cpp b/src/core/mac/sub_mac_wed.cpp
index 9cbd325..ebf6a89 100644
--- a/src/core/mac/sub_mac_wed.cpp
+++ b/src/core/mac/sub_mac_wed.cpp
@@ -62,8 +62,9 @@
 
     if (aEnable)
     {
-        mWedSampleTime      = TimerMicro::GetNow() + kCslReceiveTimeAhead - mWakeupListenInterval;
-        mWedSampleTimeRadio = Get<Radio>().GetNow() + kCslReceiveTimeAhead - mWakeupListenInterval;
+        mWedSampleTime.SetToNow(Get<Radio>());
+        mWedSampleTime += kCslReceiveTimeAhead;
+        mWedSampleTime -= mWakeupListenInterval;
 
         HandleWedTimer();
     }
@@ -90,13 +91,12 @@
 void SubMac::HandleWedReceiveAt(void)
 {
     mWedSampleTime += mWakeupListenInterval;
-    mWedSampleTimeRadio += mWakeupListenInterval;
-    mWedTimer.FireAt(mWedSampleTime + mWakeupListenDuration + kWedReceiveTimeAfter);
+
+    mWedTimer.FireAt(mWedSampleTime.GetAsTimeMicro() + mWakeupListenDuration + kWedReceiveTimeAfter);
 
     if (mState != kStateDisabled)
     {
-        IgnoreError(
-            Get<Radio>().ReceiveAt(mWakeupChannel, static_cast<uint32_t>(mWedSampleTimeRadio), mWakeupListenDuration));
+        IgnoreError(Get<Radio>().ReceiveAt(mWakeupChannel, mWedSampleTime.GetAsRadio32(), mWakeupListenDuration));
     }
 }
 
@@ -108,12 +108,12 @@
 
     if (mIsWedSampling)
     {
-        fireTime = mWedSampleTime + mWakeupListenDuration + kMinReceiveOnAfter;
+        fireTime = mWedSampleTime.GetAsTimeMicro() + mWakeupListenDuration + kMinReceiveOnAfter;
     }
     else
     {
         mWedSampleTime += mWakeupListenInterval;
-        fireTime = mWedSampleTime - kMinReceiveOnAhead;
+        fireTime = mWedSampleTime.GetAsTimeMicro() - kMinReceiveOnAhead;
     }
 
     mWedTimer.FireAt(fireTime);
diff --git a/src/core/mac/wakeup_tx_scheduler.cpp b/src/core/mac/wakeup_tx_scheduler.cpp
index cb3967b..16bc561 100644
--- a/src/core/mac/wakeup_tx_scheduler.cpp
+++ b/src/core/mac/wakeup_tx_scheduler.cpp
@@ -100,7 +100,7 @@
 
     VerifyOrExit(frame->GenerateWakeupFrame(Get<Mac::Mac>().GetPanId(), mWakeupRequest, source) == kErrorNone,
                  frame = nullptr);
-    frame->SetTxDelayBaseTime(static_cast<uint32_t>(Get<Radio>().GetNow()));
+    frame->SetTxDelayBaseTime(Get<Radio>().GetNowAsRadioTime32());
     frame->SetTxDelay(radioTxDelay);
     frame->SetCsmaCaEnabled(kWakeupFrameTxCca);
     frame->SetMaxCsmaBackoffs(0);
@@ -112,9 +112,9 @@
     rendezvousTimeUs = mIntervalUs;
     rendezvousTimeUs += (mIntervalUs - (kWakeupFrameLength + kParentRequestLength) * kOctetDuration) / 2;
 
-    frame->GetRendezvousTimeIe()->SetRendezvousTime(ClampToUint16(rendezvousTimeUs / kUsPerTenSymbols));
+    frame->Find<Mac::RendezvousTimeIe>()->SetRendezvousTime(ClampToUint16(rendezvousTimeUs / kUsPerTenSymbols));
 
-    connectionIe = frame->GetConnectionIe();
+    connectionIe = frame->Find<Mac::ConnectionIe>();
     connectionIe->SetRetryInterval(kConnectionRetryInterval);
     connectionIe->SetRetryCount(kConnectionRetryCount);
 
diff --git a/src/core/meshcop/border_agent.cpp b/src/core/meshcop/border_agent.cpp
index dc55253..9625523 100644
--- a/src/core/meshcop/border_agent.cpp
+++ b/src/core/meshcop/border_agent.cpp
@@ -35,6 +35,7 @@
 
 #if OPENTHREAD_CONFIG_BORDER_AGENT_ENABLE
 
+#include "common/num_utils.hpp"
 #include "instance/instance.hpp"
 #include "meshcop/border_agent_txt_data.hpp"
 
@@ -70,6 +71,9 @@
 #if OPENTHREAD_CONFIG_BORDER_AGENT_ID_ENABLE
     , mIdInitialized(false)
 #endif
+#if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
+    , mServiceRenameIndex(0)
+#endif
 {
     mCommissionerAloc.InitAsThreadOriginMeshLocal();
 
@@ -80,6 +84,8 @@
 
     static_assert(sizeof(kDefaultBaseServiceName) - 1 <= kBaseServiceNameMaxLen,
                   "OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_BASE_NAME is too long");
+
+    SuccessOrAssert(StringCopy(mBaseServiceName, kDefaultBaseServiceName));
 #endif
 }
 
@@ -166,8 +172,7 @@
     mDtlsTransport.SetAcceptCallback(Manager::HandleAcceptSession, this);
     mDtlsTransport.SetRemoveSessionCallback(Manager::HandleRemoveSession, this);
 
-    SuccessOrExit(error = mDtlsTransport.Open());
-    SuccessOrExit(error = mDtlsTransport.Bind(kUdpPort));
+    SuccessOrExit(error = mDtlsTransport.Open(kUdpPort));
 
     Get<KeyManager>().GetPskc(pskc);
     SuccessOrExit(error = mDtlsTransport.SetPsk(pskc.m8, Pskc::kSize));
@@ -253,7 +258,18 @@
 
 Manager::CoapDtlsSession *Manager::HandleAcceptSession(void)
 {
-    return CoapDtlsSession::Allocate(GetInstance(), mDtlsTransport);
+    CoapDtlsSession *session = nullptr;
+
+    if (mDtlsTransport.GetSessions().CountAllEntries() >= kMaxSessions)
+    {
+        LogWarn("Accept session failed: reached max concurrent secure sessions limit (%lu)", ToUlong(kMaxSessions));
+        ExitNow();
+    }
+
+    session = CoapDtlsSession::Allocate(GetInstance(), mDtlsTransport);
+
+exit:
+    return session;
 }
 
 void Manager::HandleRemoveSession(void *aContext, SecureSession &aSession)
@@ -315,7 +331,7 @@
 
     mCommissionerSession = &aSession;
 
-    Get<Mle::Mle>().GetCommissionerAloc(aSessionId, mCommissionerAloc.GetAddress());
+    Get<Mle::Mle>().ComposeCommissionerAloc(aSessionId, mCommissionerAloc.GetAddress());
     Get<ThreadNetif>().AddUnicastAddress(mCommissionerAloc);
 
     IgnoreError(Get<Ip6::Udp>().AddReceiver(mCommissionerUdpReceiver));
@@ -394,18 +410,15 @@
 
 Error Manager::SetServiceBaseName(const char *aBaseName)
 {
-    Error                  error = kErrorNone;
-    Dns::Name::LabelBuffer newName;
+    Error error = kErrorNone;
 
-    VerifyOrExit(StringLength(aBaseName, kBaseServiceNameMaxLen + 1) <= kBaseServiceNameMaxLen,
-                 error = kErrorInvalidArgs);
+    VerifyOrExit(!StringMatch(mBaseServiceName, aBaseName));
 
-    ConstructServiceName(aBaseName, newName);
-
-    VerifyOrExit(!StringMatch(newName, mServiceName));
+    SuccessOrExit(error = StringCopy(mBaseServiceName, aBaseName));
+    mServiceRenameIndex = 0;
 
     UnregisterService();
-    IgnoreError(StringCopy(mServiceName, newName));
+    ConstructServiceName();
     RegisterService();
 
 exit:
@@ -416,17 +429,28 @@
 {
     if (IsServiceNameEmpty())
     {
-        ConstructServiceName(kDefaultBaseServiceName, mServiceName);
+        ConstructServiceName();
     }
 
     return mServiceName;
 }
 
-void Manager::ConstructServiceName(const char *aBaseName, Dns::Name::LabelBuffer &aNameBuffer)
-{
-    StringWriter writer(aNameBuffer, sizeof(Dns::Name::LabelBuffer));
+void Manager::ConstructServiceName(void) { ConstructServiceName(mServiceRenameIndex, mServiceName); }
 
-    writer.Append("%.*s%s", kBaseServiceNameMaxLen, aBaseName, Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+void Manager::ConstructServiceName(uint16_t aRenameIndex, Dns::Name::LabelBuffer &aNameBuffer)
+{
+    static constexpr uint8_t kExtAddrSuffixLength = 2;
+
+    StringWriter           writer(aNameBuffer, sizeof(Dns::Name::LabelBuffer));
+    const Mac::ExtAddress &extAddr = Get<Mac::Mac>().GetExtAddress();
+
+    writer.Append("%.*s #", kBaseServiceNameMaxLen, mBaseServiceName);
+    writer.AppendHexBytesUppercase(GetArrayEnd(extAddr.m8) - kExtAddrSuffixLength, kExtAddrSuffixLength);
+
+    if (aRenameIndex != 0)
+    {
+        writer.Append(" (%u)", aRenameIndex % 1000);
+    }
 }
 
 void Manager::RegisterService(void)
@@ -454,7 +478,8 @@
     }
 #endif
 
-    Get<Dnssd>().RegisterService(service, /* aRequestId */ 0, /* aCallback */ nullptr);
+    LogInfo("Registering service %s %s", service.mServiceInstance, kServiceType);
+    Get<Dnssd>().RegisterService(service, /* aRequestId */ 0, HandleRegisterDone);
 
 exit:
     return;
@@ -471,12 +496,37 @@
     service.mServiceInstance = GetServiceName();
     service.mServiceType     = kServiceType;
 
+    LogInfo("Unregistering service %s %s", service.mServiceInstance, kServiceType);
+
     Get<Dnssd>().UnregisterService(service, /* aRequestId */ 0, /* aCallback */ nullptr);
 
 exit:
     return;
 }
 
+void Manager::HandleRegisterDone(otInstance *aInstance, otPlatDnssdRequestId aRequestId, otError aError)
+{
+    OT_UNUSED_VARIABLE(aRequestId);
+    AsCoreType(aInstance).Get<Manager>().HandleRegisterDone(aError);
+}
+
+void Manager::HandleRegisterDone(Error aError)
+{
+    VerifyOrExit(aError == kErrorDuplicated);
+
+    LogInfoOnError(aError, "register service %s %s - retrying with a new name", GetServiceName(), kServiceType);
+
+    UnregisterService();
+
+    mServiceRenameIndex++;
+    ConstructServiceName();
+
+    RegisterService();
+
+exit:
+    return;
+}
+
 #endif // OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
 
 #if OPENTHREAD_CONFIG_BORDER_AGENT_COMMISSIONER_EVICTION_API_ENABLE
@@ -541,7 +591,8 @@
     SetResourceHandler(&HandleResource);
     SetConnectCallback(&HandleConnected, this);
 
-    LogInfo("Allocating session %u", mIndex);
+    LogInfo("Allocating session %u - starting handshake timer for %lu ms", mIndex, ToUlong(kHandshakeTimeout));
+    mTimer.Start(kHandshakeTimeout);
 }
 
 Error Manager::CoapDtlsSession::SendMessage(OwnedPtr<Coap::Message> aMessage)
@@ -852,7 +903,6 @@
 {
     Error                     error = kErrorNone;
     OwnedPtr<Coap::Message>   message;
-    ExtendedTlv               extTlv;
     UdpEncapsulationTlvHeader udpEncapHeader;
     OffsetRange               offsetRange;
 
@@ -863,13 +913,11 @@
 
     offsetRange.InitFromMessageOffsetToEnd(aMessage);
 
-    extTlv.SetType(Tlv::kUdpEncapsulation);
-    extTlv.SetLength(sizeof(UdpEncapsulationTlvHeader) + offsetRange.GetLength());
-
     udpEncapHeader.SetSourcePort(aMessageInfo.GetPeerPort());
     udpEncapHeader.SetDestinationPort(aMessageInfo.GetSockPort());
 
-    SuccessOrExit(error = message->Append(extTlv));
+    SuccessOrExit(error = Tlv::AppendTlvHeader(*message, Tlv::kUdpEncapsulation,
+                                               sizeof(UdpEncapsulationTlvHeader) + offsetRange.GetLength()));
     SuccessOrExit(error = message->Append(udpEncapHeader));
     SuccessOrExit(error = message->AppendBytesFromMessage(aMessage, offsetRange));
 
@@ -965,8 +1013,7 @@
 
     SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(aMsg.mMessage, Tlv::kUdpEncapsulation, offsetRange));
 
-    SuccessOrExit(error = aMsg.mMessage.Read(offsetRange, udpEncapHeader));
-    offsetRange.AdvanceOffset(sizeof(UdpEncapsulationTlvHeader));
+    SuccessOrExit(error = aMsg.mMessage.ReadAndAdvance(offsetRange, udpEncapHeader));
 
     VerifyOrExit(udpEncapHeader.GetSourcePort() > 0 && udpEncapHeader.GetDestinationPort() > 0, error = kErrorDrop);
 
@@ -1111,8 +1158,13 @@
         ResignEnroller();
 #endif
         LogInfo("Session %u timed out - disconnecting", mIndex);
-        DisconnectTimeout();
     }
+    else
+    {
+        LogInfo("Session %u handshake timeout - disconnecting", mIndex);
+    }
+
+    DisconnectTimeout();
 }
 
 void Manager::CoapDtlsSession::CopyInfoTo(SessionInfo &aInfo, UptimeMsec aUptimeNow) const
diff --git a/src/core/meshcop/border_agent.hpp b/src/core/meshcop/border_agent.hpp
index 38cff74..398c791 100644
--- a/src/core/meshcop/border_agent.hpp
+++ b/src/core/meshcop/border_agent.hpp
@@ -264,6 +264,8 @@
 private:
     static constexpr uint16_t kUdpPort          = OPENTHREAD_CONFIG_BORDER_AGENT_UDP_PORT;
     static constexpr uint32_t kKeepAliveTimeout = 50 * 1000; // Timeout to reject a commissioner (in msec)
+    static constexpr uint32_t kHandshakeTimeout = 15 * 1000; // Handshake timeout (in msec)
+    static constexpr uint32_t kMaxSessions      = 16;        // Max concurrent secure sessions
 
 #if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
     static constexpr uint16_t kDummyUdpPort          = 49152;
@@ -341,7 +343,7 @@
         void  SendEnrollerResponse(Uri aUri, StateTlv::State aResponseState, const Coap::Message &aRequest);
         void  SendEnrollerReportState(uint8_t aAdmitterState);
         Error AppendAdmitterTlvs(Coap::Message &aMessage, uint8_t aAdmitterState);
-        void  ForwardUdpRelayToEnroller(const Coap::Message &aMessage);
+        void  ForwardUdpRelayToEnroller(const Coap::Message &aMessage, bool aCheckEnrollerMode);
         void  ForwardUdpProxyToEnroller(const Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
 
         static Error ReadSteeringDataTlv(const Message &aMessage, SteeringData &aSteeringData);
@@ -408,9 +410,13 @@
 
     const char *GetServiceName(void);
     bool        IsServiceNameEmpty(void) const { return mServiceName[0] == kNullChar; }
-    void        ConstructServiceName(const char *aBaseName, Dns::Name::LabelBuffer &aNameBuffer);
+    void        ConstructServiceName(void);
+    void        ConstructServiceName(uint16_t aRenameIndex, Dns::Name::LabelBuffer &aNameBuffer);
     void        RegisterService(void);
     void        UnregisterService(void);
+    void        HandleRegisterDone(Error aError);
+
+    static void HandleRegisterDone(otInstance *aInstance, otPlatDnssdRequestId aRequestId, otError aError);
 #endif
 
 #if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
@@ -435,7 +441,10 @@
     bool mIdInitialized;
 #endif
 #if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
+
+    char                   mBaseServiceName[kBaseServiceNameMaxLen + 1];
     Dns::Name::LabelBuffer mServiceName;
+    uint16_t               mServiceRenameIndex;
 #endif
     Counters mCounters;
 };
diff --git a/src/core/meshcop/border_agent_admitter.cpp b/src/core/meshcop/border_agent_admitter.cpp
index 643b6e4..3428dc5 100644
--- a/src/core/meshcop/border_agent_admitter.cpp
+++ b/src/core/meshcop/border_agent_admitter.cpp
@@ -236,14 +236,16 @@
         if (joiner != nullptr)
         {
             joiner->UpdateExpirationTime();
-            iter.GetSessionAs<Manager::CoapDtlsSession>()->ForwardUdpRelayToEnroller(aMsg.mMessage);
+            iter.GetSessionAs<Manager::CoapDtlsSession>()->ForwardUdpRelayToEnroller(aMsg.mMessage,
+                                                                                     /* aCheckEnrollerMode */ false);
             ExitNow();
         }
     }
 
     for (EnrollerIterator iter(GetInstance()); !iter.IsDone(); iter.Advance())
     {
-        iter.GetSessionAs<Manager::CoapDtlsSession>()->ForwardUdpRelayToEnroller(aMsg.mMessage);
+        iter.GetSessionAs<Manager::CoapDtlsSession>()->ForwardUdpRelayToEnroller(aMsg.mMessage,
+                                                                                 /* aCheckEnrollerMode */ true);
     }
 
 exit:
@@ -676,7 +678,7 @@
 {
     Ip6::Address alocAddr;
 
-    Get<Mle::Mle>().GetCommissionerAloc(mSessionId, alocAddr);
+    Get<Mle::Mle>().ComposeCommissionerAloc(mSessionId, alocAddr);
 
     if (Get<ThreadNetif>().HasUnicastAddress(mAloc))
     {
@@ -811,7 +813,7 @@
     VerifyOrExit(message != nullptr, error = kErrorNoBufs);
 
     SuccessOrExit(error = Tlv::Append<CommissionerSessionIdTlv>(*message, mSessionId));
-    SuccessOrExit(error = Tlv::Append<SteeringDataTlv>(*message, mSteeringData.GetData(), mSteeringData.GetLength()));
+    SuccessOrExit(error = SteeringDataTlv::AppendTo(*message, mSteeringData));
 
     if (mJoinerUdpPort != 0)
     {
@@ -1191,29 +1193,15 @@
 
 Error Manager::CoapDtlsSession::ReadSteeringDataTlv(const Message &aMessage, SteeringData &aSteeringData)
 {
-    Error       error;
-    OffsetRange offsetRange;
+    Error error;
 
-    SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(aMessage, Tlv::kSteeringData, offsetRange));
+    SuccessOrExit(error = SteeringDataTlv::FindIn(aMessage, aSteeringData));
 
     // Ensure the read steering data has a valid length. A length of
     // one byte is only allowed to indicate `PermitsAllJoiners()`.
 
-    error = kErrorInvalidArgs;
-
-    for (uint8_t validLength : Admitter::kEnrollerValidSteeringDataLengths)
-    {
-        if (offsetRange.GetLength() == validLength)
-        {
-            error = kErrorNone;
-            break;
-        }
-    }
-
-    SuccessOrExit(error);
-
-    IgnoreError(aSteeringData.Init(static_cast<uint8_t>(offsetRange.GetLength())));
-    aMessage.ReadBytes(offsetRange, aSteeringData.GetData());
+    VerifyOrExit(DoesArrayContain(Admitter::kEnrollerValidSteeringDataLengths, aSteeringData.GetLength()),
+                 error = kErrorInvalidArgs);
 
     if (aSteeringData.GetLength() == 1)
     {
@@ -1367,15 +1355,7 @@
 
     SuccessOrExit(Tlv::Append<StateTlv>(*response, static_cast<uint8_t>(aResponseState)));
 
-    switch (aUri)
-    {
-    case kUriEnrollerRegister:
-    case kUriEnrollerKeepAlive:
-        SuccessOrExit(AppendAdmitterTlvs(*response, Get<Admitter>().DetermineState()));
-        break;
-    default:
-        break;
-    }
+    SuccessOrExit(AppendAdmitterTlvs(*response, Get<Admitter>().DetermineState()));
 
     SuccessOrExit(SendMessage(response.PassOwnership()));
 
@@ -1383,7 +1363,7 @@
             StateTlv::StateToString(aResponseState), mIndex);
 
 exit:
-    return;
+    OT_UNUSED_VARIABLE(aUri);
 }
 
 void Manager::CoapDtlsSession::SendEnrollerReportState(uint8_t aAdmitterState)
@@ -1425,12 +1405,16 @@
     return error;
 }
 
-void Manager::CoapDtlsSession::ForwardUdpRelayToEnroller(const Coap::Message &aMessage)
+void Manager::CoapDtlsSession::ForwardUdpRelayToEnroller(const Coap::Message &aMessage, bool aCheckEnrollerMode)
 {
     Error error = kErrorNone;
 
     VerifyOrExit(IsEnroller());
-    VerifyOrExit(mEnroller->ShouldForwardJoinerRelay());
+
+    if (aCheckEnrollerMode)
+    {
+        VerifyOrExit(mEnroller->ShouldForwardJoinerRelay());
+    }
 
     SuccessOrExit(error = ForwardUdpRelay(aMessage));
     LogInfo("Forward %s to enroller - session %u", UriToString<kUriRelayRx>(), mIndex);
diff --git a/src/core/meshcop/border_agent_ephemeral_key.cpp b/src/core/meshcop/border_agent_ephemeral_key.cpp
index 7602527..e156513 100644
--- a/src/core/meshcop/border_agent_ephemeral_key.cpp
+++ b/src/core/meshcop/border_agent_ephemeral_key.cpp
@@ -59,7 +59,13 @@
     , mCoapDtlsSession(nullptr)
     , mTimer(aInstance)
     , mCallbackTask(aInstance)
+#if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
+    , mServiceRenameIndex(0)
+#endif
 {
+#if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
+    ClearAllBytes(mServiceName);
+#endif
 }
 
 void EphemeralKeyManager::SetEnabled(bool aEnabled)
@@ -97,8 +103,7 @@
     mDtlsTransport.SetAcceptCallback(EphemeralKeyManager::HandleAcceptSession, this);
     mDtlsTransport.SetRemoveSessionCallback(EphemeralKeyManager::HandleRemoveSession, this);
 
-    SuccessOrExit(error = mDtlsTransport.Open());
-    SuccessOrExit(error = mDtlsTransport.Bind(aUdpPort));
+    SuccessOrExit(error = mDtlsTransport.Open(aUdpPort));
 
     SuccessOrExit(
         error = mDtlsTransport.SetPsk(reinterpret_cast<const uint8_t *>(aKeyString), static_cast<uint8_t>(length)));
@@ -215,7 +220,8 @@
 void EphemeralKeyManager::SetState(State aState)
 {
 #if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
-    bool isServiceRegistered = ShouldRegisterService();
+    bool wasServiceRegistered = ShouldRegisterService();
+    bool shouldRegister;
 #endif
 
     VerifyOrExit(mState != aState);
@@ -224,8 +230,22 @@
     mCallbackTask.Post();
 
 #if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
-    VerifyOrExit(isServiceRegistered != ShouldRegisterService());
-    RegisterOrUnregisterService();
+
+    shouldRegister = ShouldRegisterService();
+    VerifyOrExit(wasServiceRegistered != shouldRegister);
+
+    if (shouldRegister)
+    {
+        // We start with the same service name as used by the `Manager`.
+        // We keep track of it separately, so it can be renamed on its
+        // own.
+
+        mServiceRenameIndex = Get<Manager>().mServiceRenameIndex;
+        Get<Manager>().ConstructServiceName(mServiceRenameIndex, mServiceName);
+    }
+
+    RegisterOrUnregisterService(shouldRegister);
+
 #endif
 
 exit:
@@ -349,20 +369,27 @@
     return shouldRegister;
 }
 
-void EphemeralKeyManager::RegisterOrUnregisterService(void)
+void EphemeralKeyManager::ConstructServiceName(void)
+{
+    Get<Manager>().ConstructServiceName(mServiceRenameIndex, mServiceName);
+}
+
+void EphemeralKeyManager::RegisterOrUnregisterService(bool aRegister)
 {
     Dnssd::Service service;
 
     VerifyOrExit(Get<Dnssd>().IsReady());
 
     service.Clear();
-    service.mServiceInstance = Get<Manager>().GetServiceName();
+    service.mServiceInstance = mServiceName;
     service.mServiceType     = kServiceType;
     service.mPort            = GetUdpPort();
 
-    if (ShouldRegisterService())
+    LogInfo("%segistering service %s %s", aRegister ? "R" : "Unr", mServiceName, kServiceType);
+
+    if (aRegister)
     {
-        Get<Dnssd>().RegisterService(service, /* aRequestId */ 0, /* aCallback */ nullptr);
+        Get<Dnssd>().RegisterService(service, /* aRequestId */ 0, HandleRegisterDone);
     }
     else
     {
@@ -373,6 +400,29 @@
     return;
 }
 
+void EphemeralKeyManager::HandleRegisterDone(otInstance *aInstance, otPlatDnssdRequestId aRequestId, otError aError)
+{
+    OT_UNUSED_VARIABLE(aRequestId);
+    AsCoreType(aInstance).Get<EphemeralKeyManager>().HandleRegisterDone(aError);
+}
+
+void EphemeralKeyManager::HandleRegisterDone(Error aError)
+{
+    VerifyOrExit(aError == kErrorDuplicated);
+
+    LogInfoOnError(aError, "register service %s %s - retrying with a new name", mServiceName, kServiceType);
+
+    RegisterOrUnregisterService(/* aRegister */ false);
+
+    mServiceRenameIndex++;
+    ConstructServiceName();
+
+    RegisterOrUnregisterService(/* aRegister */ true);
+
+exit:
+    return;
+}
+
 #endif // OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
 
 const char *EphemeralKeyManager::StateToString(State aState)
diff --git a/src/core/meshcop/border_agent_ephemeral_key.hpp b/src/core/meshcop/border_agent_ephemeral_key.hpp
index 057a1f5..e2a168c 100644
--- a/src/core/meshcop/border_agent_ephemeral_key.hpp
+++ b/src/core/meshcop/border_agent_ephemeral_key.hpp
@@ -229,7 +229,11 @@
     void UpdateCountersAndRecordEvent(DeactivationReason aReason);
 #if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
     bool ShouldRegisterService(void) const;
-    void RegisterOrUnregisterService(void);
+    void ConstructServiceName(void);
+    void RegisterOrUnregisterService(bool aRegister);
+    void HandleRegisterDone(Error aError);
+
+    static void HandleRegisterDone(otInstance *aInstance, otPlatDnssdRequestId aRequestId, otError aError);
 #endif
 
     // Session or Transport callbacks
@@ -257,6 +261,10 @@
     TimeoutTimer              mTimer;
     CallbackTask              mCallbackTask;
     Callback<CallbackHandler> mCallback;
+#if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
+    Dns::Name::LabelBuffer mServiceName;
+    uint16_t               mServiceRenameIndex;
+#endif
 };
 
 } // namespace BorderAgent
diff --git a/src/core/meshcop/border_agent_txt_data.cpp b/src/core/meshcop/border_agent_txt_data.cpp
index c57416d..f832c86 100644
--- a/src/core/meshcop/border_agent_txt_data.cpp
+++ b/src/core/meshcop/border_agent_txt_data.cpp
@@ -59,6 +59,7 @@
 const char TxtData::Key::kExtAddress[]      = "xa";
 const char TxtData::Key::kVendorName[]      = "vn";
 const char TxtData::Key::kModelName[]       = "mn";
+const char TxtData::Key::kVendorOui[]       = "vo";
 
 TxtData::TxtData(Instance &aInstance)
     : InstanceLocator(aInstance)
@@ -68,6 +69,7 @@
 #if OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
     , mShouldAddVendorName(true)
     , mShouldAddVendorModel(true)
+    , mShouldAddVendorOui(true)
 #endif
 {
 }
@@ -78,7 +80,8 @@
                        uint16_t  aBufferSize,
                        uint16_t &aLength,
                        bool      aAddVendorName,
-                       bool      aAddVendorModel)
+                       bool      aAddVendorModel,
+                       bool      aAddVendorOui)
 {
     Error                  error = kErrorNone;
     Dns::TxtDataEncoder    encoder(aBuffer, aBufferSize);
@@ -168,6 +171,13 @@
         SuccessOrExit(error = encoder.AppendStringEntry(Key::kModelName, Get<VendorInfo>().GetModel()));
     }
 
+    if (aAddVendorOui && Get<VendorInfo>().GetOui().IsValid())
+    {
+        const VendorInfo::Oui &oui = Get<VendorInfo>().GetOui();
+
+        SuccessOrExit(error = encoder.AppendBytesEntry(Key::kVendorOui, oui.GetBytes(), oui.GetSize()));
+    }
+
     aLength = encoder.GetLength();
 
 exit:
@@ -177,7 +187,7 @@
 Error TxtData::Prepare(ServiceTxtData &aTxtData)
 {
     return Prepare(aTxtData.mData, sizeof(aTxtData.mData), aTxtData.mLength, /* aAddVendorName */ false,
-                   /* aAddVendorModel */ false);
+                   /* aAddVendorModel */ false, /* aAddVendorOui */ false);
 }
 
 void TxtData::SetChangedCallback(ChangedCallback aCallback, void *aContext)
@@ -235,11 +245,16 @@
         size += Tlv::kMaxVendorModelLength + sizeof(Key::kModelName) + sizeof('=');
     }
 
+    if (mShouldAddVendorOui)
+    {
+        size += VendorInfo::Oui::kMaxSize + sizeof(Key::kVendorOui) + sizeof('=');
+    }
+
     buffer = reinterpret_cast<uint8_t *>(Heap::CAlloc(size, sizeof(uint8_t)));
 
     OT_ASSERT(buffer != nullptr);
 
-    SuccessOrAssert(Prepare(buffer, size, length, mShouldAddVendorName, mShouldAddVendorModel));
+    SuccessOrAssert(Prepare(buffer, size, length, mShouldAddVendorName, mShouldAddVendorModel, mShouldAddVendorOui));
 
     if (mVendorData.GetLength() != 0)
     {
@@ -265,6 +280,7 @@
 
     mShouldAddVendorName  = true;
     mShouldAddVendorModel = true;
+    mShouldAddVendorOui   = true;
 
     iterator.Init(mVendorData.GetBytes(), mVendorData.GetLength());
 
@@ -278,6 +294,10 @@
         {
             mShouldAddVendorModel = false;
         }
+        else if (entry.MatchesKey(Key::kVendorOui))
+        {
+            mShouldAddVendorOui = false;
+        }
     }
 
     Refresh();
@@ -302,6 +322,14 @@
     }
 }
 
+void TxtData::HandleVendorOuiChange(void)
+{
+    if (mShouldAddVendorOui)
+    {
+        Refresh();
+    }
+}
+
 #endif // OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
 
 uint32_t TxtData::StateBitmap::Determine(Instance &aInstance)
@@ -478,6 +506,10 @@
         ReadStringValue(aEntry, mModelName);
         mHasModelName = true;
     }
+    else if (aEntry.MatchesKey(Key::kVendorOui))
+    {
+        mHasVendorOui = ReadVendorOui(aEntry, AsCoreType(&mVendorOui));
+    }
 }
 
 bool TxtData::Info::ReadValue(const Dns::TxtEntry &aEntry, void *aBuffer, uint16_t aSize)
@@ -511,13 +543,18 @@
     VerifyOrExit(length <= Ip6::Prefix::kMaxLength);
     VerifyOrExit(aEntry.mValueLength >= sizeof(uint8_t) + Ip6::Prefix::SizeForLength(length));
 
-    aPrefix.Set(&aEntry.mValue[1], length);
+    aPrefix.InitFrom(&aEntry.mValue[1], length);
     didRead = true;
 
 exit:
     return didRead;
 }
 
+bool TxtData::Info::ReadVendorOui(const Dns::TxtEntry &aEntry, VendorInfo::Oui &aOui)
+{
+    return (aOui.SetFrom(aEntry.mValue, aEntry.mValueLength) == kErrorNone);
+}
+
 void TxtData::StateBitmap::Parse(uint32_t aBitmap, Info &aInfo)
 {
     ClearAllBytes(aInfo);
diff --git a/src/core/meshcop/border_agent_txt_data.hpp b/src/core/meshcop/border_agent_txt_data.hpp
index 8794ae3..23cade3 100644
--- a/src/core/meshcop/border_agent_txt_data.hpp
+++ b/src/core/meshcop/border_agent_txt_data.hpp
@@ -125,6 +125,7 @@
         static bool ReadValue(const Dns::TxtEntry &aEntry, void *aBuffer, uint16_t aSize);
         static void ReadStringValue(const Dns::TxtEntry &aEntry, char *aString, uint16_t aStringSize);
         static bool ReadOmrPrefix(const Dns::TxtEntry &aEntry, Ip6::Prefix &aPrefix);
+        static bool ReadVendorOui(const Dns::TxtEntry &aEntry, VendorInfo::Oui &aOui);
 
         template <typename ObjectType> bool ReadValue(const Dns::TxtEntry &aEntry, ObjectType &aObject)
         {
@@ -300,6 +301,7 @@
         static const char kExtAddress[];
         static const char kVendorName[];
         static const char kModelName[];
+        static const char kVendorOui[];
     };
 
     struct StateBitmap
@@ -357,7 +359,12 @@
 #endif
     };
 
-    Error Prepare(uint8_t *aBuffer, uint16_t aBufferSize, uint16_t &aLength, bool aAddVendorName, bool aAddVendorModel);
+    Error Prepare(uint8_t  *aBuffer,
+                  uint16_t  aBufferSize,
+                  uint16_t &aLength,
+                  bool      aAddVendorName,
+                  bool      aAddVendorModel,
+                  bool      aAddVendorOui);
 
 #if OPENTHREAD_CONFIG_BORDER_AGENT_ENABLE
     // Callback from Notifier
@@ -365,6 +372,7 @@
     // Callbacks from VendorInfo
     void HandleVendorNameChange(void);
     void HandleVendorModelChange(void);
+    void HandleVendorOuiChange(void);
 
     void HandleChangedTask(void);
 
@@ -376,6 +384,7 @@
     Heap::Data mVendorData;
     bool       mShouldAddVendorName;
     bool       mShouldAddVendorModel;
+    bool       mShouldAddVendorOui;
 #endif
 #endif
 };
diff --git a/src/core/meshcop/commissioner.cpp b/src/core/meshcop/commissioner.cpp
index 687b62b..b1def2d 100644
--- a/src/core/meshcop/commissioner.cpp
+++ b/src/core/meshcop/commissioner.cpp
@@ -265,8 +265,7 @@
     VerifyOrExit(Get<Mle::Mle>().IsAttached(), error = kErrorInvalidState);
     VerifyOrExit(mState == kStateDisabled, error = kErrorAlready);
 
-    SuccessOrExit(error = Get<Tmf::SecureAgent>().Open());
-    SuccessOrExit(error = Get<Tmf::SecureAgent>().Bind(SendRelayTransmit, this));
+    SuccessOrExit(error = Get<Tmf::SecureAgent>().Open(SendRelayTransmit, this));
 
     Get<Tmf::SecureAgent>().SetConnectCallback(HandleSecureAgentConnectEvent, this);
 
@@ -653,7 +652,7 @@
     {
         const SteeringData &steeringData = aDataset.GetSteeringData();
 
-        SuccessOrExit(error = Tlv::Append<SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+        SuccessOrExit(error = SteeringDataTlv::AppendTo(*message, steeringData));
     }
 
     if (aDataset.IsJoinerUdpPortSet())
@@ -736,7 +735,7 @@
         ExitNow();
     }
 
-    Get<Mle::Mle>().GetCommissionerAloc(mSessionId, mCommissionerAloc.GetAddress());
+    Get<Mle::Mle>().ComposeCommissionerAloc(mSessionId, mCommissionerAloc.GetAddress());
     Get<ThreadNetif>().AddUnicastAddress(mCommissionerAloc);
 
     SetState(kStateActive);
@@ -859,8 +858,7 @@
     aMsg.mMessage.SetOffset(offsetRange.GetOffset());
     SuccessOrExit(error = aMsg.mMessage.SetLength(offsetRange.GetEndOffset()));
 
-    joinerMessageInfo.SetPeerAddr(Get<Mle::Mle>().GetMeshLocalEid());
-    joinerMessageInfo.GetPeerAddr().SetIid(mJoinerIid);
+    Get<Mle::Mle>().ComposeMeshLocalAddress(mJoinerIid, joinerMessageInfo.GetPeerAddr());
     joinerMessageInfo.SetPeerPort(mJoinerPort);
 
     Get<Tmf::SecureAgent>().HandleReceive(aMsg.mMessage, joinerMessageInfo);
@@ -972,7 +970,7 @@
     OT_UNUSED_VARIABLE(aMessageInfo);
 
     Error                   error = kErrorNone;
-    ExtendedTlv             tlv;
+    OffsetRange             offsetRange;
     OwnedPtr<Coap::Message> message;
     Kek                     kek;
 
@@ -990,10 +988,9 @@
         SuccessOrExit(error = Tlv::Append<JoinerRouterKekTlv>(*message, kek));
     }
 
-    tlv.SetType(Tlv::kJoinerDtlsEncapsulation);
-    tlv.SetLength(aMessage.GetLength());
-    SuccessOrExit(error = message->Append(tlv));
-    SuccessOrExit(error = message->AppendBytesFromMessage(aMessage, 0, aMessage.GetLength()));
+    offsetRange.InitFromMessageFullLength(aMessage);
+    SuccessOrExit(
+        error = Tlv::AppendTlvWithValueFromMessage(*message, Tlv::kJoinerDtlsEncapsulation, aMessage, offsetRange));
 
     SuccessOrExit(error = Get<Tmf::Agent>().SendMessageToRloc(*message, mJoinerRloc));
     message.Release();
diff --git a/src/core/meshcop/dataset.cpp b/src/core/meshcop/dataset.cpp
index 362e774..85c2f75 100644
--- a/src/core/meshcop/dataset.cpp
+++ b/src/core/meshcop/dataset.cpp
@@ -611,6 +611,51 @@
     return isSubset;
 }
 
+bool Dataset::AffectsConnectivity(Instance &aInstance) const
+{
+    bool               affects = true;
+    ChannelTlvValue    channelValue;
+    Mac::PanId         panId;
+    Ip6::NetworkPrefix meshLocalPrefix;
+
+    if (Read<ChannelTlv>(channelValue) == kErrorNone)
+    {
+        VerifyOrExit(channelValue.GetChannel() == aInstance.Get<Mac::Mac>().GetPanChannel());
+    }
+
+    if (Read<PanIdTlv>(panId) == kErrorNone)
+    {
+        VerifyOrExit(panId == aInstance.Get<Mac::Mac>().GetPanId());
+    }
+
+    if (Read<MeshLocalPrefixTlv>(meshLocalPrefix) == kErrorNone)
+    {
+        VerifyOrExit(meshLocalPrefix == aInstance.Get<Mle::Mle>().GetMeshLocalPrefix());
+    }
+
+    VerifyOrExit(!AffectsNetworkKey(aInstance));
+
+    affects = false;
+
+exit:
+    return affects;
+}
+
+bool Dataset::AffectsNetworkKey(Instance &aInstance) const
+{
+    bool       affects = false;
+    NetworkKey networkKey;
+    NetworkKey localNetworkKey;
+
+    SuccessOrExit(Read<NetworkKeyTlv>(networkKey));
+
+    aInstance.Get<KeyManager>().GetNetworkKey(localNetworkKey);
+    affects = (networkKey != localNetworkKey);
+
+exit:
+    return affects;
+}
+
 const char *Dataset::TypeToString(Type aType) { return (aType == kActive) ? "Active" : "Pending"; }
 
 } // namespace MeshCoP
diff --git a/src/core/meshcop/dataset.hpp b/src/core/meshcop/dataset.hpp
index 6358d18..3de8b09 100644
--- a/src/core/meshcop/dataset.hpp
+++ b/src/core/meshcop/dataset.hpp
@@ -656,6 +656,36 @@
     bool IsSubsetOf(const Dataset &aOther) const;
 
     /**
+     * Indicates whether or not the Dataset affects connectivity.
+     *
+     * A Dataset affects connectivity if it contains a different Channel, PAN ID, Mesh Local Prefix, Network Key, or
+     * Security Policy than the current values in use.
+     *
+     * The following security policy changes are considered to affect connectivity:
+     * - Disabling routers (R bit: 1 to 0).
+     * - Enabling non-CCM routers (NCR bit: 0 to 1).
+     * - Increasing the version threshold for routing (VR field).
+     *
+     * @param[in] aInstance  The OpenThread instance.
+     *
+     * @retval TRUE   The Dataset affects connectivity.
+     * @retval FALSE  The Dataset does not affect connectivity.
+     */
+    bool AffectsConnectivity(Instance &aInstance) const;
+
+    /**
+     * Indicates whether or not the Dataset affects the Network Key.
+     *
+     * A Dataset affects the Network Key if it contains a different Network Key than the current value in use.
+     *
+     * @param[in] aInstance  The OpenThread instance.
+     *
+     * @retval TRUE   The Dataset affects the Network Key.
+     * @retval FALSE  The Dataset does not affect the Network Key.
+     */
+    bool AffectsNetworkKey(Instance &aInstance) const;
+
+    /**
      * Converts a Dataset Type to a string.
      *
      * @param[in]  aType   A Dataset type.
diff --git a/src/core/meshcop/dataset_manager.cpp b/src/core/meshcop/dataset_manager.cpp
index 73c2c2e..c46d12a 100644
--- a/src/core/meshcop/dataset_manager.cpp
+++ b/src/core/meshcop/dataset_manager.cpp
@@ -543,9 +543,8 @@
         {
             uint8_t tlvType;
 
-            IgnoreError(aRequest.Read(offsetRange, tlvType));
+            IgnoreError(aRequest.ReadAndAdvance(offsetRange, tlvType));
             tlvList.Add(tlvType);
-            offsetRange.AdvanceOffset(sizeof(uint8_t));
         }
 
         // MGMT_PENDING_GET.rsp must include Delay Timer TLV (Thread 1.1.1
diff --git a/src/core/meshcop/dataset_manager_ftd.cpp b/src/core/meshcop/dataset_manager_ftd.cpp
index 697963d..8f59162 100644
--- a/src/core/meshcop/dataset_manager_ftd.cpp
+++ b/src/core/meshcop/dataset_manager_ftd.cpp
@@ -49,16 +49,12 @@
                                                  const Coap::Message &aMessage,
                                                  RequestInfo         &aInfo) const
 {
-    Error              error = kErrorParse;
-    Dataset            dataset;
-    OffsetRange        offsetRange;
-    Timestamp          activeTimestamp;
-    ChannelTlvValue    channelValue;
-    uint16_t           sessionId;
-    Ip6::NetworkPrefix meshLocalPrefix;
-    NetworkKey         networkKey;
-    uint16_t           panId;
-    uint32_t           delayTimer;
+    Error       error = kErrorParse;
+    Dataset     dataset;
+    OffsetRange offsetRange;
+    Timestamp   activeTimestamp;
+    uint16_t    sessionId;
+    uint32_t    delayTimer;
 
     aInfo.Clear();
 
@@ -86,35 +82,8 @@
     // Determine whether the new Dataset affects connectivity
     // or network key.
 
-    if ((dataset.Read<ChannelTlv>(channelValue) == kErrorNone) &&
-        (channelValue.GetChannel() != Get<Mac::Mac>().GetPanChannel()))
-    {
-        aInfo.mAffectsConnectivity = true;
-    }
-
-    if ((dataset.Read<PanIdTlv>(panId) == kErrorNone) && (panId != Get<Mac::Mac>().GetPanId()))
-    {
-        aInfo.mAffectsConnectivity = true;
-    }
-
-    if ((dataset.Read<MeshLocalPrefixTlv>(meshLocalPrefix) == kErrorNone) &&
-        (meshLocalPrefix != Get<Mle::Mle>().GetMeshLocalPrefix()))
-    {
-        aInfo.mAffectsConnectivity = true;
-    }
-
-    if (dataset.Read<NetworkKeyTlv>(networkKey) == kErrorNone)
-    {
-        NetworkKey localNetworkKey;
-
-        Get<KeyManager>().GetNetworkKey(localNetworkKey);
-
-        if (networkKey != localNetworkKey)
-        {
-            aInfo.mAffectsConnectivity = true;
-            aInfo.mAffectsNetworkKey   = true;
-        }
-    }
+    aInfo.mAffectsConnectivity = dataset.AffectsConnectivity(GetInstance());
+    aInfo.mAffectsNetworkKey   = dataset.AffectsNetworkKey(GetInstance());
 
     // Check active timestamp rollback. If there is no change to
     // network key, active timestamp must be ahead of local value.
@@ -221,7 +190,7 @@
         Ip6::Address destination;
 
         SuccessOrExit(Get<NetworkData::Leader>().FindCommissioningSessionId(localSessionId));
-        Get<Mle::Mle>().GetCommissionerAloc(localSessionId, destination);
+        Get<Mle::Mle>().ComposeCommissionerAloc(localSessionId, destination);
         Get<Leader>().SendDatasetChanged(destination);
     }
 
@@ -233,20 +202,11 @@
 
 void DatasetManager::SendSetOrReplaceResponse(const Coap::Msg &aMsg, StateTlv::State aState)
 {
-    Error          error = kErrorNone;
-    Coap::Message *message;
-
-    message = Get<Tmf::Agent>().AllocateAndInitPriorityResponseFor(aMsg.mMessage);
-    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
-
-    SuccessOrExit(error = Tlv::Append<StateTlv>(*message, aState));
-
-    SuccessOrExit(error = Get<Tmf::Agent>().SendMessage(*message, aMsg.mMessageInfo));
-
+    SuccessOrExit(Get<Tmf::Agent>().SendResponseWithStateTlv(aMsg, aState));
     LogInfo("sent dataset set/replace response");
 
 exit:
-    FreeMessageOnError(message, error);
+    return;
 }
 
 //----------------------------------------------------------------------------------------------------------------------
diff --git a/src/core/meshcop/joiner.cpp b/src/core/meshcop/joiner.cpp
index 43bf6b3..61a1fe1 100644
--- a/src/core/meshcop/joiner.cpp
+++ b/src/core/meshcop/joiner.cpp
@@ -126,7 +126,8 @@
     VerifyOrExit(mState == kStateIdle, error = kErrorBusy);
     VerifyOrExit(!Get<Seeker>().IsRunning(), error = kErrorBusy);
 
-    SuccessOrExit(error = Get<Tmf::SecureAgent>().Open(Ip6::NetifIdentifier::kNetifThreadInternal));
+    SuccessOrExit(
+        error = Get<Tmf::SecureAgent>().Open(Get<Seeker>().GetUdpPort(), Ip6::NetifIdentifier::kNetifThreadInternal));
 
     if (mDiscerner.IsEmpty())
     {
@@ -137,7 +138,6 @@
     // (free allocated message, stop seeker, close agent, etc).
     shouldCleanup = true;
 
-    SuccessOrExit(error = Get<Tmf::SecureAgent>().Bind(Get<Seeker>().GetUdpPort()));
     Get<Tmf::SecureAgent>().SetConnectCallback(HandleSecureCoapClientConnect, this);
     Get<Tmf::SecureAgent>().SetPsk(joinerPskd);
 
@@ -184,6 +184,7 @@
     case kStateJoined:
         Get<Tmf::SecureAgent>().Disconnect();
         mTimer.Stop();
+        Get<KeyManager>().ClearKek();
 
         OT_FALL_THROUGH;
 
@@ -421,7 +422,8 @@
     Error         error;
     Dataset::Info datasetInfo;
 
-    VerifyOrExit(mState == kStateEntrust && aMsg.IsConfirmable(), error = kErrorDrop);
+    VerifyOrExit(mState == kStateEntrust && aMsg.IsConfirmable() && aMsg.mMessage.IsLinkSecurityEnabled(),
+                 error = kErrorDrop);
 
     LogInfo("Received %s", UriToString<kUriJoinerEntrust>());
     LogCert("[THCI] direction=recv | type=JOIN_ENT.ntf");
diff --git a/src/core/meshcop/joiner_router.cpp b/src/core/meshcop/joiner_router.cpp
index 21060bb..0a0b001 100644
--- a/src/core/meshcop/joiner_router.cpp
+++ b/src/core/meshcop/joiner_router.cpp
@@ -118,7 +118,6 @@
 {
     Error          error;
     Coap::Message *message = nullptr;
-    ExtendedTlv    tlv;
     uint16_t       borderAgentRloc;
     OffsetRange    offsetRange;
 
@@ -134,11 +133,8 @@
     SuccessOrExit(error = Tlv::Append<JoinerRouterLocatorTlv>(*message, Get<Mle::Mle>().GetRloc16()));
 
     offsetRange.InitFromMessageOffsetToEnd(aMessage);
-
-    tlv.SetType(Tlv::kJoinerDtlsEncapsulation);
-    tlv.SetLength(offsetRange.GetLength());
-    SuccessOrExit(error = message->Append(tlv));
-    SuccessOrExit(error = message->AppendBytesFromMessage(aMessage, offsetRange));
+    SuccessOrExit(
+        error = Tlv::AppendTlvWithValueFromMessage(*message, Tlv::kJoinerDtlsEncapsulation, aMessage, offsetRange));
 
     SuccessOrExit(error = Get<Tmf::Agent>().SendMessageToRloc(*message, borderAgentRloc));
 
@@ -172,7 +168,7 @@
 
     SuccessOrExit(error = message->AppendBytesFromMessage(aMsg.mMessage, offsetRange));
 
-    messageInfo.GetPeerAddr().SetToLinkLocalAddress(joinerIid);
+    messageInfo.GetPeerAddr().InitAsLinkLocalAddress(joinerIid);
     messageInfo.SetPeerPort(joinerPort);
 
     SuccessOrExit(error = mSocket.SendTo(*message, messageInfo));
@@ -302,6 +298,8 @@
 
 void JoinerRouter::HandleJoinerEntrustResponse(Coap::Msg *aMsg, Error aResult)
 {
+    Get<KeyManager>().ClearKek();
+
     SendDelayedJoinerEntrust();
 
     VerifyOrExit(aResult == kErrorNone && aMsg != nullptr);
diff --git a/src/core/meshcop/meshcop_leader.cpp b/src/core/meshcop/meshcop_leader.cpp
index afa5262..fec1506 100644
--- a/src/core/meshcop/meshcop_leader.cpp
+++ b/src/core/meshcop/meshcop_leader.cpp
@@ -45,7 +45,7 @@
 Leader::Leader(Instance &aInstance)
     : InstanceLocator(aInstance)
     , mTimer(aInstance)
-    , mSessionId(Random::NonCrypto::GetUint16())
+    , mSessionId(Random::NonCrypto::Generate<uint16_t>())
 {
 }
 
@@ -158,20 +158,12 @@
 
 void Leader::SendKeepAliveResponse(const Coap::Msg &aMsg, StateTlv::State aState)
 {
-    Error          error = kErrorNone;
-    Coap::Message *message;
+    Error error;
 
-    message = Get<Tmf::Agent>().AllocateAndInitPriorityResponseFor(aMsg.mMessage);
-    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
-
-    SuccessOrExit(error = Tlv::Append<StateTlv>(*message, aState));
-
-    SuccessOrExit(error = Get<Tmf::Agent>().SendMessage(*message, aMsg.mMessageInfo));
-
+    SuccessOrExit(error = Get<Tmf::Agent>().SendResponseWithStateTlv(aMsg, aState));
     LogInfo("Sent %s response", UriToString<kUriLeaderKeepAlive>());
 
 exit:
-    FreeMessageOnError(message, error);
     LogWarnOnError(error, "send keep alive response");
 }
 
diff --git a/src/core/meshcop/meshcop_tlvs.cpp b/src/core/meshcop/meshcop_tlvs.cpp
index 9b207f0..267a44e 100644
--- a/src/core/meshcop/meshcop_tlvs.cpp
+++ b/src/core/meshcop/meshcop_tlvs.cpp
@@ -65,6 +65,19 @@
     return aSteeringData.Init(GetSteeringDataLength(), mSteeringData);
 }
 
+Error SteeringDataTlv::FindIn(const Message &aMessage, SteeringData &aSteeringData)
+{
+    Error       error;
+    OffsetRange offsetRange;
+
+    SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(aMessage, Tlv::kSteeringData, offsetRange));
+    SuccessOrExit(error = aSteeringData.Init(ClampToUint8(offsetRange.GetLength())));
+    error = aMessage.Read(offsetRange, aSteeringData.GetData(), aSteeringData.GetLength());
+
+exit:
+    return error;
+}
+
 bool SecurityPolicyTlv::IsValid(void) const
 {
     return GetLength() >= sizeof(mRotationTime) && GetFlagsLength() >= kThread11FlagsLength;
diff --git a/src/core/meshcop/meshcop_tlvs.hpp b/src/core/meshcop/meshcop_tlvs.hpp
index 508aee2..7fea36b 100644
--- a/src/core/meshcop/meshcop_tlvs.hpp
+++ b/src/core/meshcop/meshcop_tlvs.hpp
@@ -370,6 +370,34 @@
      */
     Error CopyTo(SteeringData &aSteeringData) const;
 
+    /**
+     * Searches within a given message for Steering Data TLV, parses and validates the TLV value and returns the
+     * read Steering Data.
+     *
+     * @param[in]  aMessage       The message to search in.
+     * @param[out] aSteeringData  A reference to return the read Steering Data.
+     *
+     * @retval kErrorNone         Found the TLV, successfully parsed its value, @p aSteeringData is updated.
+     * @retval kErrorNotFound     No Steering Data TLV found in the @p aMessage.
+     * @retval kErrorParse        Found the TLV, but failed to parse it (e.g. not enough bytes in message).
+     * @retval kErrorInvalidArgs  Found the TLV, but TLV length is not valid for Steering Data (e.g., larger than max).
+     */
+    static Error FindIn(const Message &aMessage, SteeringData &aSteeringData);
+
+    /**
+     * Append a Steering Data TLV to a given message.
+     *
+     * @param[in] aMessage        The message to append to.
+     * @param[in] aSteeringData   The Steering Data value.
+     *
+     * @retval kErrorNone          Successfully appended the TLV to @p aMessage.
+     * @retval kErrorNoBufs        Insufficient available buffers to grow the message.
+     */
+    static Error AppendTo(Message &aMessage, const SteeringData &aSteeringData)
+    {
+        return Tlv::Append<SteeringDataTlv>(aMessage, aSteeringData.GetData(), aSteeringData.GetLength());
+    }
+
 private:
     uint8_t mSteeringData[SteeringData::kMaxLength];
 } OT_TOOL_PACKED_END;
diff --git a/src/core/meshcop/secure_transport.cpp b/src/core/meshcop/secure_transport.cpp
index 268b374..b73c024 100644
--- a/src/core/meshcop/secure_transport.cpp
+++ b/src/core/meshcop/secure_transport.cpp
@@ -199,7 +199,7 @@
     }
 #endif
 
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
     mbedtls_ssl_conf_rng(&mConf, Crypto::MbedTls::CryptoSecurePrng, nullptr);
 #endif
 #if (MBEDTLS_VERSION_NUMBER >= 0x03020000)
@@ -280,7 +280,7 @@
 
         if (mIsServer)
         {
-#if (MBEDTLS_VERSION_NUMBER <= 0x03060500)
+#if (MBEDTLS_VERSION_NUMBER < 0x04000000)
             rval = mbedtls_ssl_cookie_setup(&mCookieCtx, Crypto::MbedTls::CryptoSecurePrng, nullptr);
 #else
             rval = mbedtls_ssl_cookie_setup(&mCookieCtx);
@@ -680,14 +680,41 @@
     OT_UNUSED_VARIABLE(mVerifyPeerCertificate);
 }
 
-Error SecureTransport::Open(Ip6::NetifIdentifier aNetifIdentifier)
+Error SecureTransport::Open(uint16_t aPort, Ip6::NetifIdentifier aNetifIdentifier)
 {
     Error error;
 
     VerifyOrExit(!mIsOpen, error = kErrorAlready);
 
     SuccessOrExit(error = mSocket.Open(aNetifIdentifier));
-    mIsOpen                      = true;
+
+    error = mSocket.Bind(aPort);
+
+    if (error != kErrorNone)
+    {
+        IgnoreError(mSocket.Close());
+        ExitNow();
+    }
+
+    mIsOpen = true;
+    mTransportCallback.Clear();
+
+    mRemainingConnectionAttempts = mMaxConnectionAttempts;
+
+exit:
+    return error;
+}
+
+Error SecureTransport::Open(TransportCallback aCallback, void *aContext)
+{
+    Error error = kErrorNone;
+
+    VerifyOrExit(!mIsOpen, error = kErrorAlready);
+    VerifyOrExit(aCallback != nullptr, error = kErrorInvalidArgs);
+
+    mIsOpen = true;
+    mTransportCallback.Set(aCallback, aContext);
+
     mRemainingConnectionAttempts = mMaxConnectionAttempts;
 
 exit:
@@ -737,37 +764,6 @@
     return;
 }
 
-Error SecureTransport::Bind(uint16_t aPort)
-{
-    Error error;
-
-    VerifyOrExit(mIsOpen, error = kErrorInvalidState);
-    VerifyOrExit(!mTransportCallback.IsSet(), error = kErrorAlready);
-
-    VerifyOrExit(mSessions.IsEmpty(), error = kErrorInvalidState);
-
-    error = mSocket.Bind(aPort);
-
-exit:
-    return error;
-}
-
-Error SecureTransport::Bind(TransportCallback aCallback, void *aContext)
-{
-    Error error = kErrorNone;
-
-    VerifyOrExit(mIsOpen, error = kErrorInvalidState);
-    VerifyOrExit(!mSocket.IsBound(), error = kErrorAlready);
-    VerifyOrExit(!mTransportCallback.IsSet(), error = kErrorAlready);
-
-    VerifyOrExit(mSessions.IsEmpty(), error = kErrorInvalidState);
-
-    mTransportCallback.Set(aCallback, aContext);
-
-exit:
-    return error;
-}
-
 void SecureTransport::Close(void)
 {
     VerifyOrExit(mIsOpen);
@@ -789,10 +785,15 @@
 
     RemoveDisconnectedSessions();
 
+    if (UsesSocket())
+    {
+        IgnoreError(mSocket.Close());
+    }
+
+    mTransportCallback.Clear();
+
     mIsOpen    = false;
     mIsClosing = false;
-    mTransportCallback.Clear();
-    IgnoreError(mSocket.Close());
     mTimer.Stop();
 
 exit:
@@ -864,13 +865,13 @@
 
     SuccessOrExit(error = message->AppendBytes(aBuf, static_cast<uint16_t>(aLength)));
 
-    if (mTransportCallback.IsSet())
+    if (UsesSocket())
     {
-        error = mTransportCallback.Invoke(*message, aMessageInfo);
+        error = mSocket.SendTo(*message, aMessageInfo);
     }
     else
     {
-        error = mSocket.SendTo(*message, aMessageInfo);
+        error = mTransportCallback.Invoke(*message, aMessageInfo);
     }
 
 exit:
@@ -1044,7 +1045,7 @@
         break;
     }
 
-    LogAt(logLevel, "[%u] %s", mSocket.GetSockName().mPort, aStr);
+    LogAt(logLevel, "[%u] %s", UsesSocket() ? mSocket.GetSockName().mPort : 0, aStr);
 
     OT_UNUSED_VARIABLE(aStr);
     OT_UNUSED_VARIABLE(aFile);
@@ -1207,27 +1208,20 @@
                                                            size_t        *aCertLength,
                                                            size_t         aCertBufferSize)
 {
-    Error          error   = kErrorNone;
-    SecureSession *session = mSecureTransport.mSessions.GetHead();
+    Error                   error = kErrorNone;
+    SecureSession          *session;
+    const mbedtls_x509_crt *peerCert;
 
+    session = mSecureTransport.mSessions.GetHead();
     VerifyOrExit(session != nullptr, error = kErrorInvalidState);
     VerifyOrExit(session->IsConnected(), error = kErrorInvalidState);
 
-#if (MBEDTLS_VERSION_NUMBER >= 0x03010000)
-    VerifyOrExit(mbedtls_base64_encode(aPeerCert, aCertBufferSize, aCertLength,
-                                       session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->raw.p,
-                                       session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->raw.len) ==
+    peerCert = mbedtls_ssl_get_peer_cert(&session->mSsl);
+    VerifyOrExit(peerCert != nullptr, error = kErrorInvalidState);
+
+    VerifyOrExit(mbedtls_base64_encode(aPeerCert, aCertBufferSize, aCertLength, peerCert->raw.p, peerCert->raw.len) ==
                      0,
                  error = kErrorNoBufs);
-#else
-    VerifyOrExit(
-        mbedtls_base64_encode(
-            aPeerCert, aCertBufferSize, aCertLength,
-            session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->MBEDTLS_PRIVATE(raw).MBEDTLS_PRIVATE(p),
-            session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->MBEDTLS_PRIVATE(raw).MBEDTLS_PRIVATE(
-                len)) == 0,
-        error = kErrorNoBufs);
-#endif
 
 exit:
     return error;
@@ -1237,30 +1231,21 @@
 #if defined(MBEDTLS_SSL_KEEP_PEER_CERTIFICATE)
 Error SecureTransport::Extension::GetPeerCertificateDer(uint8_t *aPeerCert, size_t *aCertLength, size_t aCertBufferSize)
 {
-    Error          error   = kErrorNone;
-    SecureSession *session = mSecureTransport.mSessions.GetHead();
+    Error                   error = kErrorNone;
+    SecureSession          *session;
+    const mbedtls_x509_crt *peerCert;
 
+    session = mSecureTransport.mSessions.GetHead();
+    VerifyOrExit(session != nullptr, error = kErrorInvalidState);
     VerifyOrExit(session->IsConnected(), error = kErrorInvalidState);
 
-#if (MBEDTLS_VERSION_NUMBER >= 0x03010000)
-    VerifyOrExit(session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->raw.len < aCertBufferSize,
-                 error = kErrorNoBufs);
+    peerCert = mbedtls_ssl_get_peer_cert(&session->mSsl);
+    VerifyOrExit(peerCert != nullptr, error = kErrorInvalidState);
 
-    *aCertLength = session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->raw.len;
-    memcpy(aPeerCert, session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->raw.p, *aCertLength);
+    VerifyOrExit(peerCert->raw.len <= aCertBufferSize, error = kErrorNoBufs);
 
-#else
-    VerifyOrExit(
-        session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->MBEDTLS_PRIVATE(raw).MBEDTLS_PRIVATE(len) <
-            aCertBufferSize,
-        error = kErrorNoBufs);
-
-    *aCertLength =
-        session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->MBEDTLS_PRIVATE(raw).MBEDTLS_PRIVATE(len);
-    memcpy(aPeerCert,
-           session->mSsl.MBEDTLS_PRIVATE(session)->MBEDTLS_PRIVATE(peer_cert)->MBEDTLS_PRIVATE(raw).MBEDTLS_PRIVATE(p),
-           *aCertLength);
-#endif
+    *aCertLength = peerCert->raw.len;
+    memcpy(aPeerCert, peerCert->raw.p, *aCertLength);
 
 exit:
     return error;
diff --git a/src/core/meshcop/secure_transport.hpp b/src/core/meshcop/secure_transport.hpp
index fde6f26..c80f258 100644
--- a/src/core/meshcop/secure_transport.hpp
+++ b/src/core/meshcop/secure_transport.hpp
@@ -343,7 +343,7 @@
     typedef Error (*TransportCallback)(void *aContext, ot::Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
 
     /**
-     * Callback to notify when the socket is automatically closed due to reaching the maximum number of connection
+     * Callback to notify when the transport is automatically closed due to reaching the maximum number of connection
      * attempts (set from `SetMaxConnectionAttempts()`).
      *
      * @param[in] aContext    A pointer to arbitrary context information.
@@ -617,26 +617,46 @@
 #endif // OPENTHREAD_CONFIG_TLS_API_ENABLE
 
     /**
-     * Opens the transport.
+     * Opens the secure transport and binds it to a UDP socket.
      *
-     * @param[in] aNetifIdentifier A network interface identifier. If not explicitly provided, kNetifUnspecified will
-     *                             be used by default.
+     * This flavor of `Open` creates and binds a UDP socket to the specified @p aPort and @p aNetifIdentifier. The
+     * transport will then use this socket for all its traffic.
      *
-     * @retval kErrorNone     Successfully opened the socket.
-     * @retval kErrorAlready  The connection is already open.
+     * If @p aPort is zero, an ephemeral port number is picked by the socket. The chosen port can be retrieved using
+     * `GetUdpPort()`.
+     *
+     * @param[in] aPort             The port to bind to. If zero, an ephemeral port is used.
+     * @param[in] aNetifIdentifier  A network interface identifier. `kNetifUnspecified` will be used by default.
+     *
+     * @retval kErrorNone     Successfully opened the socket and bound it to @p aPort.
+     * @retval kErrorAlready  The secure transport is already open.
      */
-    Error Open(Ip6::NetifIdentifier aNetifIdentifier = Ip6::NetifIdentifier::kNetifUnspecified);
+    Error Open(uint16_t aPort, Ip6::NetifIdentifier aNetifIdentifier = Ip6::NetifIdentifier::kNetifUnspecified);
 
     /**
-     * Sets the maximum number of allowed connection requests before socket is automatically closed.
+     * Opens the secure transport using a callback for transmission.
      *
-     * This method can be called when socket is closed. Otherwise `kErrorInvalidSatet` is returned.
+     * This flavor of `Open` does not use a UDP socket. Instead, it relies on the provided @p aCallback for all outgoing
+     * message transmissions. It also expects received messages to be passed in through `HandleReceive()`.
      *
-     * If @p aMaxAttempts is zero, no limit is applied and connections are allowed until the socket is closed. This is
-     * the default behavior if `SetMaxConnectionAttempts()` is not called.
+     * @param[in] aCallback  A pointer to a function for sending messages.
+     * @param[in] aContext   A pointer to arbitrary context information.
+     *
+     * @retval kErrorNone     Successfully opened the transport.
+     * @retval kErrorAlready  The secure transport is already open.
+     */
+    Error Open(TransportCallback aCallback, void *aContext);
+
+    /**
+     * Sets the maximum number of allowed connection requests before transport is automatically closed.
+     *
+     * This method can be called when transport is closed. Otherwise `kErrorInvalidState` is returned.
+     *
+     * If @p aMaxAttempts is zero, no limit is applied and connections are allowed until the transport is closed. This
+     * is the default behavior if `SetMaxConnectionAttempts()` is not called.
      *
      * @param[in] aMaxAttempts    Maximum number of allowed connection attempts.
-     * @param[in] aCallback       Callback to notify when max number of attempts has reached and socket is closed.
+     * @param[in] aCallback       Callback to notify when max number of attempts has reached and transport is closed.
      * @param[in] aContext        A pointer to arbitrary context to use with `AutoCloseCallback`.
      *
      * @retval kErrorNone          Successfully set the maximum allowed connection attempts and callback.
@@ -664,45 +684,32 @@
     }
 
     /**
-     * Binds this DTLS to a UDP port.
+     * Indicates whether or not the secure transport uses a UDP socket.
      *
-     * @param[in]  aPort              The port to bind.
-     *
-     * @retval kErrorNone           Successfully bound the socket.
-     * @retval kErrorInvalidState   The socket is not open.
-     * @retval kErrorAlready        Already bound.
+     * @retval TRUE   The secure transport is open and uses a UDP socket.
+     * @retval FALSE  The secure transport is either closed or does not use a UDP socket.
      */
-    Error Bind(uint16_t aPort);
+    bool UsesSocket(void) const { return mIsOpen && !mTransportCallback.IsSet(); }
 
     /**
-     * Gets the UDP port of this session.
+     * Gets the UDP port of the secure transport.
      *
-     * @returns  UDP port number.
+     * This method can only be used if the secure transport is open and uses a UDP socket. Otherwise, it returns 0.
+     *
+     * @returns  The UDP port number if the transport uses a socket, 0 otherwise.
      */
-    uint16_t GetUdpPort(void) const { return mSocket.GetSockName().GetPort(); }
+    uint16_t GetUdpPort(void) const { return UsesSocket() ? mSocket.GetSockName().GetPort() : 0; }
 
     /**
-     * Binds with a transport callback.
+     * Indicates whether or not the secure transport is closed.
      *
-     * @param[in]  aCallback  A pointer to a function for sending messages.
-     * @param[in]  aContext   A pointer to arbitrary context information.
-     *
-     * @retval kErrorNone           Successfully bound the socket.
-     * @retval kErrorInvalidState   The socket is not open.
-     * @retval kErrorAlready        Already bound.
-     */
-    Error Bind(TransportCallback aCallback, void *aContext);
-
-    /**
-     * Indicates whether or not the secure transpose socket is closed.
-     *
-     * @retval TRUE   The secure transport socket closed.
-     * @retval FALSE  The secure transport socket is not closed.
+     * @retval TRUE   The secure transport closed.
+     * @retval FALSE  The secure transport is not closed.
      */
     bool IsClosed(void) const { return !mIsOpen; }
 
     /**
-     * Closes the socket.
+     * Closes the transport.
      */
     void Close(void);
 
@@ -724,11 +731,13 @@
     void SetPsk(const JoinerPskd &aPskd);
 
     /**
-     * Checks and handles a received message provided to the SecureTransport object. If checks based on
-     * the message info and current connection state pass, the message is processed.
+     * Checks and handles a received message provided to the `SecureTransport`.
      *
-     * @param[in]  aMessage  A reference to the message to receive.
-     * @param[in]  aMessageInfo A reference to the message info associated with @p aMessage.
+     * This method is intended to be used with the flavor of `Open()` that uses a `TransportCallback`. It is used to
+     * pass in a received message for the transport to process.
+     *
+     * @param[in] aMessage      A reference to the message to receive.
+     * @param[in] aMessageInfo  A reference to the message info associated with @p aMessage.
      */
     void HandleReceive(Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
 
diff --git a/src/core/meshcop/seeker.cpp b/src/core/meshcop/seeker.cpp
index 559bde9..12db287 100644
--- a/src/core/meshcop/seeker.cpp
+++ b/src/core/meshcop/seeker.cpp
@@ -279,7 +279,7 @@
 
     aSockAddr.Clear();
     aSockAddr.SetPort(entry.mJoinerUdpPort);
-    aSockAddr.GetAddress().SetToLinkLocalAddress(entry.mExtAddr);
+    aSockAddr.GetAddress().InitAsLinkLocalAddress(entry.mExtAddr);
 
 exit:
     return error;
diff --git a/src/core/meshcop/steering_data.hpp b/src/core/meshcop/steering_data.hpp
index 7cafba1..a857e0c 100644
--- a/src/core/meshcop/steering_data.hpp
+++ b/src/core/meshcop/steering_data.hpp
@@ -43,6 +43,7 @@
 #include "common/code_utils.hpp"
 #include "common/equatable.hpp"
 #include "common/error.hpp"
+#include "common/message.hpp"
 #include "common/string.hpp"
 #include "mac/mac_types.hpp"
 
diff --git a/src/core/meshcop/tcat_agent.cpp b/src/core/meshcop/tcat_agent.cpp
index 7d364fe..b2ebea8 100644
--- a/src/core/meshcop/tcat_agent.cpp
+++ b/src/core/meshcop/tcat_agent.cpp
@@ -39,7 +39,7 @@
 #include "common/error.hpp"
 #include "crypto/storage.hpp"
 #include "instance/instance.hpp"
-#include "thread/network_diagnostic.hpp"
+#include "thread/net_diag.hpp"
 
 namespace ot {
 namespace MeshCoP {
@@ -60,8 +60,11 @@
     , mTimerSetsToActive(false)
     , mActiveOrStandbyTimer(aInstance)
     , mTcatActiveDurationMs(0)
+    , mHashVerificationAttempts(1)
+    , mLastDeviceRole(Mle::kRoleDisabled)
 {
     ClearCommissionerState();
+    mLastHashVerificationTimestamp = Get<UptimeTracker>().GetUptimeInSeconds();
 }
 
 void TcatAgent::ClearCommissionerState(void)
@@ -93,8 +96,16 @@
     mState                = kStateActive;
     mNextState            = kStateActive;
     mTcatActiveDurationMs = 0;
+    mLastDeviceRole       = Get<Mle::Mle>().GetRole();
+
     LogInfo("Start");
 
+    if (!mVendorInfo->mKeepActiveAfterJoining && mLastDeviceRole != Mle::kRoleDisabled &&
+        Get<ActiveDatasetManager>().IsCommissioned())
+    {
+        IgnoreError(Standby());
+    }
+
 exit:
     LogWarnOnError(error, "Start");
     return error;
@@ -394,29 +405,24 @@
 {
     Error          error;
     StatusCode     statusCode = kStatusGeneralError;
-    ot::Tlv        tlv;
-    uint16_t       offset                   = aIncomingMessage.GetOffset();
+    Tlv::Info      tlvInfo;
     const uint16_t initialOutgoingMsgLength = aOutgoingMessage.GetLength();
-    uint16_t       length;
-    bool           response = false;
+    bool           response                 = false;
 
+    VerifyOrExit(mVendorInfo != nullptr, error = kErrorInvalidState);
     VerifyOrExit(IsConnected(), error = kErrorInvalidState);
-    SuccessOrExit(error = aIncomingMessage.Read(offset, tlv));
 
-    if (tlv.IsExtended())
+    SuccessOrExit(error = tlvInfo.ParseFrom(aIncomingMessage, aIncomingMessage.GetOffset()));
+
+    if (mVendorInfo->mTlvSupportHandler != nullptr &&
+        !mVendorInfo->mTlvSupportHandler(&GetInstance(), static_cast<otTcatCommandTlvType>(tlvInfo.GetType()),
+                                         mJoinCallback.GetContext()))
     {
-        ot::ExtendedTlv extTlv;
-        SuccessOrExit(error = aIncomingMessage.Read(offset, extTlv));
-        length = extTlv.GetLength();
-        offset += sizeof(ot::ExtendedTlv);
-    }
-    else
-    {
-        length = tlv.GetLength();
-        offset += sizeof(ot::Tlv);
+        statusCode = kStatusUnsupported;
+        ExitNow();
     }
 
-    switch (tlv.GetType())
+    switch (tlvInfo.GetType())
     {
     case kTlvDisconnect:
         error    = kErrorAbort;
@@ -424,7 +430,7 @@
         break;
 
     case kTlvSetActiveOperationalDataset:
-        error = HandleSetActiveOperationalDataset(aIncomingMessage, offset, length);
+        error = HandleSetActiveOperationalDataset(aIncomingMessage, tlvInfo.GetValueOffsetRange());
         break;
 
     case kTlvGetActiveOperationalDataset:
@@ -432,7 +438,7 @@
         break;
 
     case kTlvGetDiagnosticTlvs:
-        error = HandleGetDiagnosticTlvs(aIncomingMessage, aOutgoingMessage, offset, length, response);
+        error = HandleGetDiagnosticTlvs(aIncomingMessage, aOutgoingMessage, tlvInfo.GetValueOffsetRange(), response);
         break;
 
     case kTlvStartThreadInterface:
@@ -452,8 +458,8 @@
     case kTlvSendApplicationData3:
     case kTlvSendApplicationData4:
     case kTlvSendVendorSpecificData:
-        error = HandleApplicationData(aIncomingMessage, offset, static_cast<TcatApplicationProtocol>(tlv.GetType()),
-                                      response);
+        error = HandleApplicationData(aIncomingMessage, tlvInfo.GetValueOffset(),
+                                      static_cast<TcatApplicationProtocol>(tlvInfo.GetType()), response);
         break;
 
     case kTlvDecommission:
@@ -461,7 +467,7 @@
         break;
 
     case kTlvPing:
-        error = HandlePing(aIncomingMessage, aOutgoingMessage, offset, length, response);
+        error = HandlePing(aIncomingMessage, aOutgoingMessage, tlvInfo.GetValueOffsetRange(), response);
         break;
 
     case kTlvGetNetworkName:
@@ -481,25 +487,21 @@
         break;
 
     case kTlvPresentPskdHash:
-        error = HandlePresentPskdHash(aIncomingMessage, offset, length);
+        error = HandlePresentPskdHash(aIncomingMessage, tlvInfo.GetValueOffsetRange());
         break;
 
     case kTlvPresentPskcHash:
-        error = HandlePresentPskcHash(aIncomingMessage, offset, length);
+        error = HandlePresentPskcHash(aIncomingMessage, tlvInfo.GetValueOffsetRange());
         break;
 
     case kTlvPresentInstallCodeHash:
-        error = HandlePresentInstallCodeHash(aIncomingMessage, offset, length);
+        error = HandlePresentInstallCodeHash(aIncomingMessage, tlvInfo.GetValueOffsetRange());
         break;
 
     case kTlvRequestRandomNumChallenge:
         error = HandleRequestRandomNumberChallenge(aOutgoingMessage, response);
         break;
 
-    case kTlvRequestPskdHash:
-        error = HandleRequestPskdHash(aIncomingMessage, aOutgoingMessage, offset, length, response);
-        break;
-
     case kTlvGetCommissionerCertificate:
         error = HandleGetCommissionerCertificate(aOutgoingMessage, response);
         break;
@@ -569,18 +571,16 @@
     return error;
 }
 
-Error TcatAgent::HandleSetActiveOperationalDataset(const Message &aIncomingMessage, uint16_t aOffset, uint16_t aLength)
+Error TcatAgent::HandleSetActiveOperationalDataset(const Message &aIncomingMessage, const OffsetRange &aOffsetRange)
 {
-    Dataset     dataset;
-    OffsetRange offsetRange;
-    Error       error;
-    uint8_t     buf[kCommissionerCertMaxLength];
-    size_t      bufLen = sizeof(buf);
+    Dataset dataset;
+    Error   error;
+    uint8_t buf[kCommissionerCertMaxLength];
+    size_t  bufLen = sizeof(buf);
 
     VerifyOrExit(!mIsCommissioned, error = kErrorAlready);
 
-    offsetRange.Init(aOffset, aLength);
-    SuccessOrExit(error = dataset.SetFrom(aIncomingMessage, offsetRange));
+    SuccessOrExit(error = dataset.SetFrom(aIncomingMessage, aOffsetRange));
     SuccessOrExit(error = dataset.ValidateTlvs());
     VerifyOrExit(dataset.ContainsTlv(Tlv::kNetworkKey), error = kErrorInvalidArgs);
 
@@ -625,21 +625,18 @@
     return error;
 }
 
-Error TcatAgent::HandleGetDiagnosticTlvs(const Message &aIncomingMessage,
-                                         Message       &aOutgoingMessage,
-                                         uint16_t       aOffset,
-                                         uint16_t       aLength,
-                                         bool          &aResponse)
+Error TcatAgent::HandleGetDiagnosticTlvs(const Message     &aIncomingMessage,
+                                         Message           &aOutgoingMessage,
+                                         const OffsetRange &aOffsetRange,
+                                         bool              &aResponse)
 {
     Error           error = kErrorNone;
-    OffsetRange     offsetRange;
     ot::ExtendedTlv extTlv;
     uint16_t        initialLength;
     uint16_t        length;
 
     VerifyOrExit(IsCommandClassAuthorized(kCommissioning), error = kErrorRejected);
 
-    offsetRange.Init(aOffset, aLength);
     initialLength = aOutgoingMessage.GetLength();
 
     // Start with extTlv to avoid the need for a temporary message buffer to calculate reply length
@@ -647,8 +644,7 @@
     extTlv.SetLength(0);
     SuccessOrExit(error = aOutgoingMessage.Append(extTlv));
 
-    error =
-        Get<NetworkDiagnostic::Server>().AppendRequestedTlvsForTcat(aIncomingMessage, aOutgoingMessage, offsetRange);
+    error = Get<NetDiag::Server>().AppendRequestedTlvsForTcat(aIncomingMessage, aOutgoingMessage, aOffsetRange);
 
     // Ensure enough message buffers are left for transmission of the result. Report error otherwise.
     if (Get<MessagePool>().GetFreeBufferCount() < kBufferReserve)
@@ -687,13 +683,19 @@
 
     VerifyOrExit(IsCommandClassAuthorized(kDecommissioning), error = kErrorRejected);
     SuccessOrExit(error = Get<Ble::BleSecure>().GetPeerCertificateDer(buf, &bufLen, bufLen));
-    Get<Settings>().SaveTcatCommissionerCertificate(buf, static_cast<uint16_t>(bufLen));
 
-    IgnoreReturnValue(otThreadSetEnabled(&GetInstance(), false));
+    Get<Mle::Mle>().Stop();
+
+    if (!mVendorInfo->mDoNotActivateAfterLeaving)
+    {
+        IgnoreError(Activate(0, 0));
+    }
+
     Get<ActiveDatasetManager>().Clear();
     Get<PendingDatasetManager>().Clear();
 
     IgnoreReturnValue(Get<Instance>().ErasePersistentInfo());
+    Get<Settings>().SaveTcatCommissionerCertificate(buf, static_cast<uint16_t>(bufLen));
 
 #if !OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE
     {
@@ -703,37 +705,24 @@
     }
 #endif
 
+    mJoinCallback.InvokeIfSet(&GetInstance(), /* aIsJoin */ false, error);
     mIsCommissioned = false; // enable repeated commissioning/decommissioning in a session
 
 exit:
     return error;
 }
 
-Error TcatAgent::HandlePing(const Message &aIncomingMessage,
-                            Message       &aOutgoingMessage,
-                            uint16_t       aOffset,
-                            uint16_t       aLength,
-                            bool          &aResponse)
+Error TcatAgent::HandlePing(const Message     &aIncomingMessage,
+                            Message           &aOutgoingMessage,
+                            const OffsetRange &aOffsetRange,
+                            bool              &aResponse)
 {
-    Error           error = kErrorNone;
-    ot::ExtendedTlv extTlv;
-    ot::Tlv         tlv;
+    Error error;
 
-    VerifyOrExit(aLength <= kPingPayloadMaxLength, error = kErrorParse);
-    if (aLength > ot::Tlv::kBaseTlvMaxLength)
-    {
-        extTlv.SetType(kTlvResponseWithPayload);
-        extTlv.SetLength(aLength);
-        SuccessOrExit(error = aOutgoingMessage.Append(extTlv));
-    }
-    else
-    {
-        tlv.SetType(kTlvResponseWithPayload);
-        tlv.SetLength(static_cast<uint8_t>(aLength));
-        SuccessOrExit(error = aOutgoingMessage.Append(tlv));
-    }
+    VerifyOrExit(aOffsetRange.GetLength() <= kPingPayloadMaxLength, error = kErrorParse);
 
-    SuccessOrExit(error = aOutgoingMessage.AppendBytesFromMessage(aIncomingMessage, aOffset, aLength));
+    SuccessOrExit(error = Tlv::AppendTlvWithValueFromMessage(aOutgoingMessage, kTlvResponseWithPayload,
+                                                             aIncomingMessage, aOffsetRange));
     aResponse = true;
 
 exit:
@@ -822,14 +811,14 @@
     return error;
 }
 
-Error TcatAgent::HandlePresentPskdHash(const Message &aIncomingMessage, uint16_t aOffset, uint16_t aLength)
+Error TcatAgent::HandlePresentPskdHash(const Message &aIncomingMessage, const OffsetRange &aOffsetRange)
 {
     Error error = kErrorNone;
 
     VerifyOrExit(mVendorInfo != nullptr, error = kErrorInvalidState);
     VerifyOrExit(mVendorInfo->mPskdString != nullptr, error = kErrorSecurity);
 
-    SuccessOrExit(error = VerifyHash(aIncomingMessage, aOffset, aLength, mVendorInfo->mPskdString,
+    SuccessOrExit(error = VerifyHash(aIncomingMessage, aOffsetRange, mVendorInfo->mPskdString,
                                      StringLength(mVendorInfo->mPskdString, kMaxPskdLength)));
     mPskdVerified = true;
 
@@ -837,7 +826,7 @@
     return error;
 }
 
-Error TcatAgent::HandlePresentPskcHash(const Message &aIncomingMessage, uint16_t aOffset, uint16_t aLength)
+Error TcatAgent::HandlePresentPskcHash(const Message &aIncomingMessage, const OffsetRange &aOffsetRange)
 {
     Error         error = kErrorNone;
     Dataset::Info datasetInfo;
@@ -847,21 +836,21 @@
     VerifyOrExit(datasetInfo.IsPresent<Dataset::kPskc>(), error = kErrorSecurity);
     pskc = datasetInfo.Get<Dataset::kPskc>();
 
-    SuccessOrExit(error = VerifyHash(aIncomingMessage, aOffset, aLength, pskc.m8, Pskc::kSize));
+    SuccessOrExit(error = VerifyHash(aIncomingMessage, aOffsetRange, pskc.m8, Pskc::kSize));
     mPskcVerified = true;
 
 exit:
     return error;
 }
 
-Error TcatAgent::HandlePresentInstallCodeHash(const Message &aIncomingMessage, uint16_t aOffset, uint16_t aLength)
+Error TcatAgent::HandlePresentInstallCodeHash(const Message &aIncomingMessage, const OffsetRange &aOffsetRange)
 {
     Error error = kErrorNone;
 
     VerifyOrExit(mVendorInfo != nullptr, error = kErrorInvalidState);
     VerifyOrExit(mVendorInfo->mInstallCode != nullptr, error = kErrorSecurity);
 
-    SuccessOrExit(error = VerifyHash(aIncomingMessage, aOffset, aLength, mVendorInfo->mInstallCode,
+    SuccessOrExit(error = VerifyHash(aIncomingMessage, aOffsetRange, mVendorInfo->mInstallCode,
                                      StringLength(mVendorInfo->mInstallCode, kInstallCodeMaxSize)));
     mInstallCodeVerified = true;
 
@@ -883,49 +872,33 @@
     return error;
 }
 
-Error TcatAgent::HandleRequestPskdHash(const Message &aIncomingMessage,
-                                       Message       &aOutgoingMessage,
-                                       uint16_t       aOffset,
-                                       uint16_t       aLength,
-                                       bool          &aResponse)
-{
-    Error                    error             = kErrorNone;
-    uint64_t                 providedChallenge = 0;
-    Crypto::HmacSha256::Hash hash;
-
-    VerifyOrExit(mVendorInfo != nullptr, error = kErrorInvalidState);
-    VerifyOrExit(StringLength(mVendorInfo->mPskdString, kMaxPskdLength) != 0, error = kErrorFailed);
-    VerifyOrExit(aLength == sizeof(providedChallenge), error = kErrorParse);
-
-    SuccessOrExit(error = aIncomingMessage.Read(aOffset, &providedChallenge, aLength));
-
-    SuccessOrExit(error = CalculateHash(providedChallenge, mVendorInfo->mPskdString,
-                                        StringLength(mVendorInfo->mPskdString, kMaxPskdLength), hash));
-
-    SuccessOrExit(error = Tlv::AppendTlv(aOutgoingMessage, kTlvResponseWithPayload, hash.GetBytes(),
-                                         Crypto::HmacSha256::Hash::kSize));
-    aResponse = true;
-
-exit:
-    return error;
-}
-
-Error TcatAgent::VerifyHash(const Message &aIncomingMessage,
-                            uint16_t       aOffset,
-                            uint16_t       aLength,
-                            const void    *aBuf,
-                            size_t         aBufLen)
+Error TcatAgent::VerifyHash(const Message     &aIncomingMessage,
+                            const OffsetRange &aOffsetRange,
+                            const void        *aBuf,
+                            size_t             aBufLen)
 {
     Error                    error = kErrorNone;
     Crypto::HmacSha256::Hash hash;
+    UptimeSec                currentTime = Get<UptimeTracker>().GetUptimeInSeconds();
+    uint32_t newAttempts   = (currentTime - mLastHashVerificationTimestamp) / kHashVerificationAttemptTime;
+    uint32_t totalAttempts = newAttempts + mHashVerificationAttempts;
 
-    VerifyOrExit(aLength == Crypto::HmacSha256::Hash::kSize, error = kErrorSecurity);
+    VerifyOrExit(aOffsetRange.GetLength() == Crypto::HmacSha256::Hash::kSize, error = kErrorSecurity);
     VerifyOrExit(mRandomChallenge != 0, error = kErrorSecurity);
 
+    // In case uptime has overflowed (will never happen in practical functional operational life), up to
+    // kHashVerificationMaxAttempts additional attempts can be tolerated.
+    mHashVerificationAttempts = static_cast<uint8_t>(Min<uint32_t>(totalAttempts, kHashVerificationMaxAttempts));
+
+    VerifyOrExit(mHashVerificationAttempts > 0, error = kErrorBusy);
+    mLastHashVerificationTimestamp = currentTime;
+    mHashVerificationAttempts--;
+
     SuccessOrExit(error = CalculateHash(mRandomChallenge, reinterpret_cast<const char *>(aBuf), aBufLen, hash));
     DumpDebg("Hash", &hash, sizeof(hash));
 
-    VerifyOrExit(aIncomingMessage.Compare(aOffset, hash), error = kErrorSecurity);
+    VerifyOrExit(aIncomingMessage.Compare(aOffsetRange.GetOffset(), hash), error = kErrorSecurity);
+    mHashVerificationAttempts++;
 
 exit:
     return error;
@@ -1031,24 +1004,29 @@
 #endif
 
     Get<ThreadNetif>().Up();
-    error = Get<Mle::Mle>().Start();
+    SuccessOrExit(error = Get<Mle::Mle>().Start());
 
 exit:
     // error values for callback MUST be limited to the allowed set, see #JoinCallback
-    mJoinCallback.InvokeIfSet(error);
+    mJoinCallback.InvokeIfSet(&GetInstance(), /* aIsJoin */ true, error);
     return error;
 }
 
 Error TcatAgent::HandleStopThreadInterface(void)
 {
-    Error error;
+    Error error = kErrorNone;
 
     VerifyOrExit(IsCommandClassAuthorized(kCommissioning), error = kErrorRejected);
 
-    error = otThreadSetEnabled(&GetInstance(), false);
+    Get<Mle::Mle>().Stop();
+
+    if (!mVendorInfo->mDoNotActivateAfterLeaving)
+    {
+        IgnoreError(Activate(0, 0));
+    }
 
 exit:
-    mJoinCallback.InvokeIfSet(error);
+    mJoinCallback.InvokeIfSet(&GetInstance(), /* aIsJoin */ false, error);
     return error;
 }
 
@@ -1091,8 +1069,27 @@
 // internally called when TcatAgent state changes: perform any required actions.
 void TcatAgent::NotifyStateChange(void)
 {
-    Get<Ble::BleSecure>().NotifySendAdvertisements(mState == kStateActive || mState == kStateActiveTemporary ||
-                                                   mState == kStateConnected);
+    Get<Ble::BleSecure>().NotifySendAdvertisements(mState == kStateActive || mState == kStateActiveTemporary);
+}
+
+void TcatAgent::HandleNotifierEvents(Events aEvents)
+{
+    VerifyOrExit(IsStarted());
+    VerifyOrExit(mVendorInfo != nullptr);
+
+    if (aEvents.ContainsAny(kEventThreadRoleChanged))
+    {
+        if (!mVendorInfo->mKeepActiveAfterJoining && Get<Mle::Mle>().IsAttached() &&
+            (mLastDeviceRole == Mle::kRoleDisabled || mLastDeviceRole == Mle::kRoleDetached))
+        {
+            IgnoreError(Standby());
+        }
+
+        mLastDeviceRole = Get<Mle::Mle>().GetRole();
+    }
+
+exit:
+    return;
 }
 
 template <> void TcatAgent::HandleTmf<kUriTcatEnable>(Coap::Msg &aMsg)
diff --git a/src/core/meshcop/tcat_agent.hpp b/src/core/meshcop/tcat_agent.hpp
index bc444bd..0cb0e41 100644
--- a/src/core/meshcop/tcat_agent.hpp
+++ b/src/core/meshcop/tcat_agent.hpp
@@ -38,6 +38,10 @@
 
 #if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
 
+#if !OPENTHREAD_CONFIG_UPTIME_ENABLE
+#error "OPENTHREAD_CONFIG_UPTIME_ENABLE is required for TCAT agent"
+#endif
+
 #include <openthread/netdiag.h>
 #include <openthread/tcat.h>
 #include <openthread/platform/ble.h>
@@ -48,6 +52,8 @@
 #include "common/log.hpp"
 #include "common/message.hpp"
 #include "common/non_copyable.hpp"
+#include "common/notifier.hpp"
+#include "common/uptime.hpp"
 #include "mac/mac_types.hpp"
 #include "meshcop/dataset.hpp"
 #include "meshcop/meshcop.hpp"
@@ -69,6 +75,7 @@
 {
     friend class Ble::BleSecure;
     friend class UnitTester;
+    friend class ot::Notifier;
 
 public:
     /**
@@ -159,50 +166,44 @@
     enum CommandTlvType : uint8_t
     {
         // Command Class General
-        kTlvResponseWithStatus        = 0x01, ///< TCAT response with status value TLV
-        kTlvResponseWithPayload       = 0x02, ///< TCAT response with payload TLV
-        kTlvResponseEvent             = 0x03, ///< TCAT response event TLV (reserved)
-        kTlvGetNetworkName            = 0x08, ///< TCAT network name query TLV
-        kTlvDisconnect                = 0x09, ///< TCAT disconnect request TLV
-        kTlvPing                      = 0x0A, ///< TCAT ping request TLV
-        kTlvGetDeviceId               = 0x0B, ///< TCAT device ID query TLV
-        kTlvGetExtendedPanID          = 0x0C, ///< TCAT extended PAN ID query TLV
-        kTlvGetProvisioningURL        = 0x0D, ///< TCAT provisioning URL query TLV
-        kTlvPresentPskdHash           = 0x10, ///< TCAT commissioner rights elevation request TLV using PSKd hash
-        kTlvPresentPskcHash           = 0x11, ///< TCAT commissioner rights elevation request TLV using PSKc hash
-        kTlvPresentInstallCodeHash    = 0x12, ///< TCAT commissioner rights elevation request TLV using install code
-        kTlvRequestRandomNumChallenge = 0x13, ///< TCAT random number challenge query TLV
-        kTlvRequestPskdHash           = 0x14, ///< TCAT PSKd hash request TLV
+        kTlvResponseWithStatus        = OT_TCAT_TLV_RESPONSE_WITH_STATUS,
+        kTlvResponseWithPayload       = OT_TCAT_TLV_RESPONSE_WITH_PAYLOAD,
+        kTlvResponseEvent             = OT_TCAT_TLV_RESPONSE_EVENT,
+        kTlvGetNetworkName            = OT_TCAT_TLV_GET_NETWORK_NAME,
+        kTlvDisconnect                = OT_TCAT_TLV_DISCONNECT,
+        kTlvPing                      = OT_TCAT_TLV_PING,
+        kTlvGetDeviceId               = OT_TCAT_TLV_GET_DEVICE_ID,
+        kTlvGetExtendedPanID          = OT_TCAT_TLV_GET_EXTENDED_PAN_ID,
+        kTlvGetProvisioningURL        = OT_TCAT_TLV_GET_PROVISIONING_URL,
+        kTlvPresentPskdHash           = OT_TCAT_TLV_PRESENT_PSKD_HASH,
+        kTlvPresentPskcHash           = OT_TCAT_TLV_PRESENT_PSKC_HASH,
+        kTlvPresentInstallCodeHash    = OT_TCAT_TLV_PRESENT_INSTALL_CODE_HASH,
+        kTlvRequestRandomNumChallenge = OT_TCAT_TLV_REQUEST_RANDOM_CHALLENGE,
 
         // Command Class Commissioning
-        kTlvSetActiveOperationalDataset    = 0x20, ///< TCAT active operational dataset TLV
-        kTlvSetActiveOperationalDatasetAlt = 0x21, ///< TCAT active operational dataset alternative #1 TLV (reserved)
-        kTlvGetCommissionerCertificate     = 0x25, ///< TCAT commissioner certificate query TLV
-        kTlvGetDiagnosticTlvs              = 0x26, ///< TCAT diagnostics TLVs query TLV
-        kTlvStartThreadInterface           = 0x27, ///< TCAT start thread interface request TLV
-        kTlvStopThreadInterface            = 0x28, ///< TCAT stop thread interface request TLV
+        kTlvSetActiveOperationalDataset    = OT_TCAT_TLV_SET_ACTIVE_OPERATIONAL_DATASET,
+        kTlvSetActiveOperationalDatasetAlt = OT_TCAT_TLV_SET_ACTIVE_OPERATIONAL_DATASET_ALT,
+        kTlvGetCommissionerCertificate     = OT_TCAT_TLV_GET_COMMISSIONER_CERTIFICATE,
+        kTlvGetDiagnosticTlvs              = OT_TCAT_TLV_GET_DIAGNOSTIC_TLVS,
+        kTlvStartThreadInterface           = OT_TCAT_TLV_START_THREAD_INTERFACE,
+        kTlvStopThreadInterface            = OT_TCAT_TLV_STOP_THREAD_INTERFACE,
 
         // Command Class Extraction
-        kTlvGetActiveOperationalDataset    = 0x40, ///< TCAT active operational dataset query TLV
-        kTlvGetActiveOperationalDatasetAlt = 0x41, ///< TCAT active operational dataset alternative #1 query TLV (rsv)
+        kTlvGetActiveOperationalDataset    = OT_TCAT_TLV_GET_ACTIVE_OPERATIONAL_DATASET,
+        kTlvGetActiveOperationalDatasetAlt = OT_TCAT_TLV_GET_ACTIVE_OPERATIONAL_DATASET_ALT,
 
         // Command Class Decommissioning
-        kTlvDecommission = 0x60, ///< TCAT decommission request TLV
+        kTlvDecommission = OT_TCAT_TLV_DECOMMISSION,
 
         // Command Class Application
-        kTlvGetApplicationLayers   = 0x80, ///< TCAT get application layers request TLV
-        kTlvSendApplicationData1   = 0x81, ///< TCAT send application data 1 TLV
-        kTlvSendApplicationData2   = 0x82, ///< TCAT send application data 2 TLV
-        kTlvSendApplicationData3   = 0x83, ///< TCAT send application data 3 TLV
-        kTlvSendApplicationData4   = 0x84, ///< TCAT send application data 4 TLV
-        kTlvServiceNameUdp         = 0x89, ///< TCAT service name UDP sub-TLV (not used as a command)
-        kTlvServiceNameTcp         = 0x8A, ///< TCAT service name TCP sub-TLV (not used as a command)
-        kTlvSendVendorSpecificData = 0x9F, ///< TCAT send vendor specific command or data TLV
-
-        // Command Class CCM
-        kTlvSetLDevIdOperationalCert = 0xA0, ///< TCAT set LDevID operational certificate TLV (reserved)
-        kTlvSetLDevIdPrivateKey      = 0xA1, ///< TCAT set LDevID operational certificate private key TLV (reserved)
-        kTlvSetDomainCaCert          = 0xA2, ///< TCAT set domain CA certificate TLV (reserved)
+        kTlvGetApplicationLayers   = OT_TCAT_TLV_GET_APPLICATION_LAYERS,
+        kTlvSendApplicationData1   = OT_TCAT_TLV_SEND_APPLICATION_DATA_1,
+        kTlvSendApplicationData2   = OT_TCAT_TLV_SEND_APPLICATION_DATA_2,
+        kTlvSendApplicationData3   = OT_TCAT_TLV_SEND_APPLICATION_DATA_3,
+        kTlvSendApplicationData4   = OT_TCAT_TLV_SEND_APPLICATION_DATA_4,
+        kTlvServiceNameUdp         = OT_TCAT_TLV_SERVICE_NAME_UDP,
+        kTlvServiceNameTcp         = OT_TCAT_TLV_SERVICE_NAME_TCP,
+        kTlvSendVendorSpecificData = OT_TCAT_TLV_SEND_VENDOR_SPECIFIC_DATA,
     };
 
     /**
@@ -437,37 +438,31 @@
 private:
     void  NotifyApplicationResponseSent(void) { mApplicationResponsePending = false; }
     void  NotifyStateChange(void);
+    void  HandleNotifierEvents(Events aEvents);
     void  ClearCommissionerState();
     Error Connected(MeshCoP::Tls::Extension &aTls);
     void  Disconnected(void);
 
     Error HandleSingleTlv(const Message &aIncomingMessage, Message &aOutgoingMessage);
-    Error HandleSetActiveOperationalDataset(const Message &aIncomingMessage, uint16_t aOffset, uint16_t aLength);
+    Error HandleSetActiveOperationalDataset(const Message &aIncomingMessage, const OffsetRange &aOffsetRange);
     Error HandleGetActiveOperationalDataset(Message &aOutgoingMessage, bool &aResponse);
-    Error HandleGetDiagnosticTlvs(const Message &aIncomingMessage,
-                                  Message       &aOutgoingMessage,
-                                  uint16_t       aOffset,
-                                  uint16_t       aLength,
-                                  bool          &response);
+    Error HandleGetDiagnosticTlvs(const Message     &aIncomingMessage,
+                                  Message           &aOutgoingMessage,
+                                  const OffsetRange &aOffsetRange,
+                                  bool              &aResponse);
     Error HandleDecommission(void);
-    Error HandlePing(const Message &aIncomingMessage,
-                     Message       &aOutgoingMessage,
-                     uint16_t       aOffset,
-                     uint16_t       aLength,
-                     bool          &aResponse);
+    Error HandlePing(const Message     &aIncomingMessage,
+                     Message           &aOutgoingMessage,
+                     const OffsetRange &aOffsetRange,
+                     bool              &aResponse);
     Error HandleGetNetworkName(Message &aOutgoingMessage, bool &aResponse);
     Error HandleGetDeviceId(Message &aOutgoingMessage, bool &aResponse);
     Error HandleGetExtPanId(Message &aOutgoingMessage, bool &aResponse);
     Error HandleGetProvisioningUrl(Message &aOutgoingMessage, bool &aResponse);
-    Error HandlePresentPskdHash(const Message &aIncomingMessage, uint16_t aOffset, uint16_t aLength);
-    Error HandlePresentPskcHash(const Message &aIncomingMessage, uint16_t aOffset, uint16_t aLength);
-    Error HandlePresentInstallCodeHash(const Message &aIncomingMessage, uint16_t aOffset, uint16_t aLength);
+    Error HandlePresentPskdHash(const Message &aIncomingMessage, const OffsetRange &aOffsetRange);
+    Error HandlePresentPskcHash(const Message &aIncomingMessage, const OffsetRange &aOffsetRange);
+    Error HandlePresentInstallCodeHash(const Message &aIncomingMessage, const OffsetRange &aOffsetRange);
     Error HandleRequestRandomNumberChallenge(Message &aOutgoingMessage, bool &aResponse);
-    Error HandleRequestPskdHash(const Message &aIncomingMessage,
-                                Message       &aOutgoingMessage,
-                                uint16_t       aOffset,
-                                uint16_t       aLength,
-                                bool          &aResponse);
     Error HandleStartThreadInterface(void);
     Error HandleStopThreadInterface(void);
     Error HandleGetCommissionerCertificate(Message &aOutgoingMessage, bool &aResponse);
@@ -478,11 +473,10 @@
                                 bool                   &aResponse);
     void  HandleTimer(void);
     void  AdaptToExistingActivePeriod(uint32_t &aPeriodDelayMs, uint32_t &aPeriodDurationMs);
-    Error VerifyHash(const Message &aIncomingMessage,
-                     uint16_t       aOffset,
-                     uint16_t       aLength,
-                     const void    *aBuf,
-                     size_t         aBufLen);
+    Error VerifyHash(const Message     &aIncomingMessage,
+                     const OffsetRange &aOffsetRange,
+                     const void        *aBuf,
+                     size_t             aBufLen);
     Error CalculateHash(uint64_t aChallenge, const char *aBuf, size_t aBufLen, Crypto::HmacSha256::Hash &aHash);
 
     bool    IsCommandClassAuthorizedWithFlags(CommandClassFlags aCommissionerCommandClassFlags,
@@ -490,16 +484,18 @@
                                               const Dataset    *aCommSuppliedDataset) const;
     uint8_t CheckAuthorizationRequirements(CommandClassFlags aFlagsChecked, Dataset::Info *aActiveDatasetInfo) const;
 
-    static constexpr uint16_t kPingPayloadMaxLength      = 512;
-    static constexpr uint16_t kProvisioningUrlMaxLength  = OT_NETWORK_DIAGNOSTIC_MAX_VENDOR_APP_URL_TLV_LENGTH;
-    static constexpr uint16_t kMaxPskdLength             = OT_JOINER_MAX_PSKD_LENGTH;
-    static constexpr uint16_t kTcatMaxDeviceIdSize       = OT_TCAT_MAX_DEVICEID_SIZE;
-    static constexpr uint16_t kInstallCodeMaxSize        = 255;
-    static constexpr uint16_t kCommissionerCertMaxLength = 1024;
-    static constexpr uint16_t kBufferReserve             = 2048 / (Buffer::kSize - sizeof(otMessageBuffer)) + 1;
-    static constexpr uint8_t  kServiceNameMaxLength      = OT_TCAT_SERVICE_NAME_MAX_LENGTH;
-    static constexpr uint8_t  kApplicationLayerMaxCount  = OT_TCAT_APPLICATION_LAYER_MAX_COUNT;
-    static constexpr uint16_t kTcatTmfEnableDefaultSec   = OT_TCAT_ENABLE_MAX;
+    static constexpr uint16_t kPingPayloadMaxLength        = 512;
+    static constexpr uint16_t kProvisioningUrlMaxLength    = OT_NETWORK_DIAGNOSTIC_MAX_VENDOR_APP_URL_TLV_LENGTH;
+    static constexpr uint16_t kMaxPskdLength               = OT_JOINER_MAX_PSKD_LENGTH;
+    static constexpr uint16_t kTcatMaxDeviceIdSize         = OT_TCAT_MAX_DEVICEID_SIZE;
+    static constexpr uint16_t kInstallCodeMaxSize          = 255;
+    static constexpr uint16_t kCommissionerCertMaxLength   = 1024;
+    static constexpr uint16_t kBufferReserve               = 2048 / (Buffer::kSize - sizeof(otMessageBuffer)) + 1;
+    static constexpr uint8_t  kServiceNameMaxLength        = OT_TCAT_SERVICE_NAME_MAX_LENGTH;
+    static constexpr uint8_t  kApplicationLayerMaxCount    = OT_TCAT_APPLICATION_LAYER_MAX_COUNT;
+    static constexpr uint16_t kTcatTmfEnableDefaultSec     = OT_TCAT_ENABLE_MAX;
+    static constexpr uint32_t kHashVerificationAttemptTime = 5;
+    static constexpr uint8_t  kHashVerificationMaxAttempts = 10;
 
     const VendorInfo                *mVendorInfo;
     Callback<JoinCallback>           mJoinCallback;
@@ -522,8 +518,11 @@
     bool                             mIsCommissioned : 1;
     bool                             mApplicationResponsePending : 1;
     using ExpireTimer = TimerMilliIn<TcatAgent, &TcatAgent::HandleTimer>;
-    ExpireTimer mActiveOrStandbyTimer;
-    uint32_t    mTcatActiveDurationMs;
+    ExpireTimer     mActiveOrStandbyTimer;
+    uint32_t        mTcatActiveDurationMs;
+    UptimeSec       mLastHashVerificationTimestamp;
+    uint8_t         mHashVerificationAttempts;
+    Mle::DeviceRole mLastDeviceRole;
 };
 
 DeclareTmfHandler(TcatAgent, kUriTcatEnable);
diff --git a/src/core/meshcop/timestamp.cpp b/src/core/meshcop/timestamp.cpp
index 28f6ab3..f86aa35 100644
--- a/src/core/meshcop/timestamp.cpp
+++ b/src/core/meshcop/timestamp.cpp
@@ -124,7 +124,7 @@
 {
     uint16_t ticks = GetTicks();
 
-    ticks += Random::NonCrypto::GetUint32InRange(1, kMaxTicks + 1);
+    ticks += Random::NonCrypto::GenerateInClosedRange<uint16_t>(1, kMaxTicks);
 
     if (ticks > kMaxTicks)
     {
diff --git a/src/core/net/checksum.cpp b/src/core/net/checksum.cpp
index 237cac3..03fae81 100644
--- a/src/core/net/checksum.cpp
+++ b/src/core/net/checksum.cpp
@@ -86,7 +86,7 @@
 
     if (checksum != 0xffff)
     {
-        checksum = ~checksum;
+        checksum = static_cast<uint16_t>(~checksum);
     }
 
     checksum = BigEndian::HostSwap16(checksum);
@@ -176,15 +176,15 @@
     switch (aIpProto)
     {
     case Ip6::kProtoTcp:
-        headerOffset = Ip6::Tcp::Header::kChecksumFieldOffset;
+        headerOffset = Ip6::TcpHeader::kChecksumFieldOffset;
         break;
 
     case Ip6::kProtoUdp:
-        headerOffset = Ip6::Udp::Header::kChecksumFieldOffset;
+        headerOffset = Ip6::UdpHeader::kChecksumFieldOffset;
         break;
 
     case Ip6::kProtoIcmp6:
-        headerOffset = Ip6::Icmp::Header::kChecksumFieldOffset;
+        headerOffset = Ip6::Icmp6Header::kChecksumFieldOffset;
         break;
 
     default:
@@ -211,15 +211,15 @@
     switch (aIpProto)
     {
     case Ip4::kProtoTcp:
-        headerOffset = Ip4::Tcp::Header::kChecksumFieldOffset;
+        headerOffset = Ip4::TcpHeader::kChecksumFieldOffset;
         break;
 
     case Ip4::kProtoUdp:
-        headerOffset = Ip4::Udp::Header::kChecksumFieldOffset;
+        headerOffset = Ip4::UdpHeader::kChecksumFieldOffset;
         break;
 
     case Ip4::kProtoIcmp:
-        headerOffset = Ip4::Icmp::Header::kChecksumFieldOffset;
+        headerOffset = Ip4::Icmp4Header::kChecksumFieldOffset;
         break;
 
     default:
@@ -241,7 +241,7 @@
 
     aHeader.SetChecksum(0);
     checksum.AddData(reinterpret_cast<const uint8_t *>(&aHeader), sizeof(aHeader));
-    aHeader.SetChecksum(~checksum.GetValue());
+    aHeader.SetChecksum(static_cast<uint16_t>(~checksum.GetValue()));
 }
 
 } // namespace ot
diff --git a/src/core/net/dhcp6_client.cpp b/src/core/net/dhcp6_client.cpp
index 294e860..6104d56 100644
--- a/src/core/net/dhcp6_client.cpp
+++ b/src/core/net/dhcp6_client.cpp
@@ -268,7 +268,7 @@
 #if OPENTHREAD_ENABLE_DHCP6_MULTICAST_SOLICIT
     messageInfo.SetPeerAddr(Ip6::Address::GetRealmLocalAllRoutersMulticast());
 #else
-    messageInfo.GetPeerAddr().SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), aRloc16);
+    Get<Mle::Mle>().ComposeRloc(aRloc16, messageInfo.GetPeerAddr());
 #endif
     messageInfo.SetSockAddr(Get<Mle::Mle>().GetMeshLocalRloc());
     messageInfo.mPeerPort = kDhcpServerPort;
@@ -407,9 +407,7 @@
     Option::Iterator iterator;
 
     SuccessOrExit(error = Option::FindOption(aMessage, Option::kIaNa, offsetRange));
-    SuccessOrExit(error = aMessage.Read(offsetRange, option));
-
-    offsetRange.AdvanceOffset(sizeof(IaNaOption));
+    SuccessOrExit(error = aMessage.ReadAndAdvance(offsetRange, option));
 
     // Iterate over and check the sub-options within `IaNaOption`.
 
diff --git a/src/core/net/dhcp6_server.cpp b/src/core/net/dhcp6_server.cpp
index 2d449c9..6beb155 100644
--- a/src/core/net/dhcp6_server.cpp
+++ b/src/core/net/dhcp6_server.cpp
@@ -164,7 +164,8 @@
 
     VerifyOrExit(newEntry != nullptr, error = kErrorNoBufs);
 
-    newEntry->Set(aIp6Prefix, Get<Mle::Mle>().GetMeshLocalPrefix(), aContextId);
+    newEntry->SetPrefix(aIp6Prefix);
+    newEntry->ComposeAloc(GetInstance(), aContextId);
     Get<ThreadNetif>().AddUnicastAddress(newEntry->GetAloc());
     mPrefixAgentsCount++;
 
@@ -243,12 +244,10 @@
     Option::Iterator iterator;
 
     SuccessOrExit(error = Option::FindOption(aMessage, Option::kIaNa, offsetRange));
-    SuccessOrExit(error = aMessage.Read(offsetRange, iaNaOption));
+    SuccessOrExit(error = aMessage.ReadAndAdvance(offsetRange, iaNaOption));
 
     aIaid = iaNaOption.GetIaid();
 
-    offsetRange.AdvanceOffset(sizeof(IaNaOption));
-
     mPrefixAgentsMask = 0;
 
     // Iterate and parse `kIaAddress` sub-options within `IaNaOption`.
@@ -404,7 +403,7 @@
 
     option.Init();
     option.GetAddress().SetPrefix(aPrefix.mFields.m8, OT_IP6_PREFIX_BITSIZE);
-    option.GetAddress().GetIid().SetFromExtAddress(aClientAddress);
+    option.GetAddress().GetIid().InitFromExtAddress(aClientAddress);
     option.SetPreferredLifetime(IaAddressOption::kDefaultPreferredLifetime);
     option.SetValidLifetime(IaAddressOption::kDefaultValidLifetime);
     SuccessOrExit(error = aMessage.Append(option));
@@ -413,6 +412,12 @@
     return error;
 }
 
+void Server::PrefixAgent::ComposeAloc(Instance &aInstance, uint8_t aContextId)
+{
+    mAloc.InitAsThreadOriginMeshLocal();
+    aInstance.Get<Mle::Mle>().ComposeAloc((Ip6::Address::kAloc16Mask << 8) + aContextId, mAloc.GetAddress());
+}
+
 } // namespace Dhcp6
 } // namespace ot
 
diff --git a/src/core/net/dhcp6_server.hpp b/src/core/net/dhcp6_server.hpp
index 1620ec2..a7fd4da 100644
--- a/src/core/net/dhcp6_server.hpp
+++ b/src/core/net/dhcp6_server.hpp
@@ -141,20 +141,19 @@
         }
 
         /**
-         * Sets the ALOC.
+         * Sets the IPv6 prefix.
          *
-         * @param[in]  aPrefix           The IPv6 prefix.
-         * @param[in]  aMeshLocalPrefix  The Mesh Local Prefix.
+         * @param[in] aPrefix  The IPv6 prefix.
+         */
+        void SetPrefix(const Ip6::Prefix &aPrefix) { mPrefix = aPrefix; }
+
+        /**
+         * Composes the ALOC address.
+         *
+         * @param[in]  aInstance         The OpenThread instance.
          * @param[in]  aContextId        The 6LoWPAN Context ID.
          */
-        void Set(const Ip6::Prefix &aPrefix, const Ip6::NetworkPrefix &aMeshLocalPrefix, uint8_t aContextId)
-        {
-            mPrefix = aPrefix;
-
-            mAloc.InitAsThreadOrigin();
-            mAloc.GetAddress().SetToAnycastLocator(aMeshLocalPrefix, (Ip6::Address::kAloc16Mask << 8) + aContextId);
-            mAloc.mMeshLocal = true;
-        }
+        void ComposeAloc(Instance &aInstance, uint8_t aContextId);
 
     private:
         Ip6::Netif::UnicastAddress mAloc;
diff --git a/src/core/net/dhcp6_types.cpp b/src/core/net/dhcp6_types.cpp
index 911db29..06bda0a 100644
--- a/src/core/net/dhcp6_types.cpp
+++ b/src/core/net/dhcp6_types.cpp
@@ -304,7 +304,7 @@
 {
     uint8_t length = Min(GetPrefixLength(), Ip6::Prefix::kMaxLength);
 
-    aPrefix.Set(mPrefix.mFields.m8, length);
+    aPrefix.InitFrom(mPrefix.mFields.m8, length);
     aPrefix.Tidy();
 }
 
diff --git a/src/core/net/dhcp6_types.hpp b/src/core/net/dhcp6_types.hpp
index fceb433..59765d9 100644
--- a/src/core/net/dhcp6_types.hpp
+++ b/src/core/net/dhcp6_types.hpp
@@ -161,7 +161,6 @@
         kElapsedTime               = 8,  ///< Elapsed Time Option.
         kRelayMessage              = 9,  ///< Relay Message Option.
         kAuthentication            = 11, ///< Authentication Option.
-        kServerUnicast             = 12, ///< Server Unicast Option.
         kStatusCode                = 13, ///< Status Code Option.
         kRapidCommit               = 14, ///< Rapid Commit Option.
         kUserClass                 = 15, ///< User Class Option.
@@ -1020,36 +1019,6 @@
 } OT_TOOL_PACKED_END;
 
 /**
- * Represents a Server Unicast Option.
- */
-OT_TOOL_PACKED_BEGIN
-class ServerUnicastOption : public Option
-{
-public:
-    /**
-     * Initializes the DHCPv6 Option.
-     */
-    void Init(void) { SetCode(kServerUnicast), SetLength(sizeof(*this) - sizeof(Option)); }
-
-    /**
-     * Returns the server IPv6 address.
-     *
-     * @returns the server IPv6 address.
-     */
-    const Ip6::Address &GetServerAddress(void) const { return mServerAddress; }
-
-    /**
-     * Sets the server IPv6 address.
-     *
-     * @param[in] aServerAddress  The server IPv6 address.
-     */
-    void SetServerAddress(const Ip6::Address &aServerAddress) { mServerAddress = aServerAddress; }
-
-private:
-    Ip6::Address mServerAddress;
-} OT_TOOL_PACKED_END;
-
-/**
  * Represents an SOL_MAX_RT Option (Max Solicit timeout value).
  */
 OT_TOOL_PACKED_BEGIN
diff --git a/src/core/net/dns_client.cpp b/src/core/net/dns_client.cpp
index 9260ea0..9fe35c7 100644
--- a/src/core/net/dns_client.cpp
+++ b/src/core/net/dns_client.cpp
@@ -1497,6 +1497,7 @@
     VerifyOrExit(aQuery != nullptr, error = kErrorNotFound);
 
     info.ReadFrom(*aQuery);
+    VerifyOrExit(info.mSavedResponse == nullptr, error = kErrorDrop);
 
     queryName.SetFromMessage(*aQuery, kNameOffsetInQuery);
 
@@ -1788,7 +1789,7 @@
 
     if (mLimitedQueryServers.IsFull())
     {
-        uint8_t randomIndex = Random::NonCrypto::GetUint8InRange(0, mLimitedQueryServers.GetMaxSize());
+        uint8_t randomIndex = Random::NonCrypto::GenerateUpToExcluding(mLimitedQueryServers.GetMaxSize());
 
         mLimitedQueryServers.Remove(mLimitedQueryServers[randomIndex]);
     }
@@ -1872,6 +1873,7 @@
         info.mMessageId         = 0;
         info.mTransmissionCount = 0;
         info.mMainQuery         = &FindMainQuery(aQuery);
+        info.mSavedResponse     = nullptr;
 
         SuccessOrExit(AllocateQuery(info, nullptr, hostName, newQuery));
         IgnoreError(SendQuery(*newQuery, info, /* aUpdateTimer */ true));
diff --git a/src/core/net/dns_client.hpp b/src/core/net/dns_client.hpp
index b2849f8..74762f5 100644
--- a/src/core/net/dns_client.hpp
+++ b/src/core/net/dns_client.hpp
@@ -397,7 +397,7 @@
          * MUST only be used from `AddressCallback`.
          *
          * The response may include multiple IPv6 address records. @p aIndex can be used to iterate through the list of
-         * addresses. Index zero gets the the first address and so on. When we reach end of the list, this method
+         * addresses. Index zero gets the first address and so on. When we reach end of the list, this method
          * returns `kErrorNotFound`.
          *
          * @param[in]  aIndex        The address record index to retrieve.
@@ -455,7 +455,7 @@
          * MUST only be used from `BrowseCallback`.
          *
          * A response may include multiple service instance records. @p aIndex can be used to iterate through the list.
-         * Index zero gives the the first record. When we reach end of the list, `kErrorNotFound` is returned.
+         * Index zero gives the first record. When we reach end of the list, `kErrorNotFound` is returned.
          *
          * Note that this method gets the service instance label and not the full service instance name which is of the
          * form `<Instance>.<Service>.<Domain>`.
diff --git a/src/core/net/dns_dso.cpp b/src/core/net/dns_dso.cpp
index 1102bb1..245044c 100644
--- a/src/core/net/dns_dso.cpp
+++ b/src/core/net/dns_dso.cpp
@@ -631,7 +631,7 @@
     // that its padded length is a multiple of the chosen block
     // length.
 
-    blockLength = kBlockLengths[Random::NonCrypto::GetUint8InRange(0, GetArrayLength(kBlockLengths))];
+    blockLength = kBlockLengths[Random::NonCrypto::GenerateUpToExcluding(GetArrayLength(kBlockLengths))];
 
     paddingTlv.Init((blockLength - ((aMessage.GetLength() + sizeof(Tlv)) % blockLength)) % blockLength);
 
diff --git a/src/core/net/dns_types.cpp b/src/core/net/dns_types.cpp
index 66f7247..d8a21db 100644
--- a/src/core/net/dns_types.cpp
+++ b/src/core/net/dns_types.cpp
@@ -639,6 +639,9 @@
     aLabelBuffer[mLabelLength] = kNullChar;
     aLabelLength               = mLabelLength;
 
+    // Check that there is no `kNullChar` (`\0`) in the read label.
+    VerifyOrExit(StringLength(aLabelBuffer, mLabelLength) == mLabelLength, error = kErrorParse);
+
     if (!aAllowDotCharInLabel)
     {
         VerifyOrExit(StringFind(aLabelBuffer, kLabelSeparatorChar) == nullptr, error = kErrorParse);
@@ -1046,7 +1049,7 @@
     // `ResourceRecord`. `aSkipRecord` indicates whether to skip over
     // the entire resource record or just the read name. On exit, when
     // successfully read, `aOffset` is updated to either point after the
-    // end of record or after the the name field.
+    // end of record or after the name field.
     //
     // When read successfully, this method returns `kErrorNone`. On a
     // parse error (invalid format) returns `kErrorParse`. If the
diff --git a/src/core/net/dns_types.hpp b/src/core/net/dns_types.hpp
index a9c9898..e18a792 100644
--- a/src/core/net/dns_types.hpp
+++ b/src/core/net/dns_types.hpp
@@ -619,7 +619,7 @@
      *
      * If the name is empty (not specified), then root "." is appended to @p aMessage. If the name is from a C string
      * then the string is checked and appended (similar to static `AppendName(const char *aName, Message &)` method).
-     * If the the name is from a message, then it is read from the message and appended to @p aMessage. Note that in
+     * If the name is from a message, then it is read from the message and appended to @p aMessage. Note that in
      * this case independent of whether the name is compressed or not in its original message, the name is appended
      * as full (uncompressed) in @p aMessage.
      *
@@ -814,7 +814,7 @@
      * Can be used to compare labels one by one. It checks whether the label read from @p aMessage matches
      * @p aLabel string (case-insensitive comparison).
      *
-     * Unlike `CompareName()` which requires the labels in the the name string to contain no dot '.' character, this
+     * Unlike `CompareName()` which requires the labels in the name string to contain no dot '.' character, this
      * method allows @p aLabel to include any character.
      *
      * @param[in]     aMessage        The message to read the label from to compare. `aMessage.GetOffset()` MUST point
diff --git a/src/core/net/icmp6.cpp b/src/core/net/icmp6.cpp
index b160002..8125739 100644
--- a/src/core/net/icmp6.cpp
+++ b/src/core/net/icmp6.cpp
@@ -74,7 +74,10 @@
     return error;
 }
 
-Error Icmp::SendError(Header::Type aType, Header::Code aCode, const MessageInfo &aMessageInfo, const Message &aMessage)
+Error Icmp::SendError(Icmp6Header::Type  aType,
+                      Icmp6Header::Code  aCode,
+                      const MessageInfo &aMessageInfo,
+                      const Message     &aMessage)
 {
     Error   error;
     Headers headers;
@@ -86,7 +89,10 @@
     return error;
 }
 
-Error Icmp::SendError(Header::Type aType, Header::Code aCode, const MessageInfo &aMessageInfo, const Headers &aHeaders)
+Error Icmp::SendError(Icmp6Header::Type  aType,
+                      Icmp6Header::Code  aCode,
+                      const MessageInfo &aMessageInfo,
+                      const Headers     &aHeaders)
 {
     Error             error = kErrorNone;
     MessageInfo       messageInfoLocal;
diff --git a/src/core/net/icmp6.hpp b/src/core/net/icmp6.hpp
index bfb1f75..ffcdf9d 100644
--- a/src/core/net/icmp6.hpp
+++ b/src/core/net/icmp6.hpp
@@ -69,8 +69,6 @@
              private NonCopyable
 {
 public:
-    typedef Icmp6Header Header; ///< ICMPv6 header
-
     /**
      * Implements ICMPv6 message handlers.
      */
@@ -93,7 +91,7 @@
         }
 
     private:
-        void HandleReceiveMessage(Message &aMessage, const MessageInfo &aMessageInfo, const Header &aIcmp6Header)
+        void HandleReceiveMessage(Message &aMessage, const MessageInfo &aMessageInfo, const Icmp6Header &aIcmp6Header)
         {
             mReceiveCallback(mContext, &aMessage, &aMessageInfo, &aIcmp6Header);
         }
@@ -150,7 +148,10 @@
      * @retval kErrorNone     Successfully enqueued the ICMPv6 error message.
      * @retval kErrorNoBufs   Insufficient buffers available.
      */
-    Error SendError(Header::Type aType, Header::Code aCode, const MessageInfo &aMessageInfo, const Message &aMessage);
+    Error SendError(Icmp6Header::Type  aType,
+                    Icmp6Header::Code  aCode,
+                    const MessageInfo &aMessageInfo,
+                    const Message     &aMessage);
 
     /**
      * Sends an ICMPv6 error message.
@@ -163,7 +164,10 @@
      * @retval kErrorNone     Successfully enqueued the ICMPv6 error message.
      * @retval kErrorNoBufs   Insufficient buffers available.
      */
-    Error SendError(Header::Type aType, Header::Code aCode, const MessageInfo &aMessageInfo, const Headers &aHeaders);
+    Error SendError(Icmp6Header::Type  aType,
+                    Icmp6Header::Code  aCode,
+                    const MessageInfo &aMessageInfo,
+                    const Headers     &aHeaders);
 
     /**
      * Handles an ICMPv6 message.
@@ -210,6 +214,8 @@
     uint16_t GetEchoSequence(void) const { return mEchoSequence; }
 
 private:
+    typedef Icmp6Header Header;
+
     Error HandleEchoRequest(Message &aRequestMessage, const MessageInfo &aMessageInfo);
 
     LinkedList<Handler> mHandlers;
diff --git a/src/core/net/ip4_types.cpp b/src/core/net/ip4_types.cpp
index 6d45b55..5417be0 100644
--- a/src/core/net/ip4_types.cpp
+++ b/src/core/net/ip4_types.cpp
@@ -34,6 +34,7 @@
 #include "ip4_types.hpp"
 
 #include "common/numeric_limits.hpp"
+#include "common/offset_range.hpp"
 #include "net/ip6_address.hpp"
 
 namespace ot {
@@ -71,7 +72,7 @@
     Error error = kErrorNone;
 
     VerifyOrExit(aIp6Address.IsIp4Mapped(), error = kErrorParse);
-    SetBytes(&aIp6Address.GetBytes()[12]);
+    InitFrom(&aIp6Address.GetBytes()[12]);
 
 exit:
     return error;
@@ -208,33 +209,83 @@
     VerifyOrExit(IsValid());
     VerifyOrExit(GetTotalLength() == aMessage.GetLength());
 
+    if (GetIhl() > kMinIhl)
+    {
+        VerifyOrExit(!HasSourceRouteOption(aMessage));
+    }
+
     error = kErrorNone;
 
 exit:
     return error;
 }
 
+bool Header::HasSourceRouteOption(const Message &aMessage) const
+{
+    bool        hasSourceRoute = false;
+    uint16_t    headerLen      = GetHeaderLength();
+    OffsetRange range;
+
+    VerifyOrExit(headerLen <= aMessage.GetLength());
+    range.InitFromRange(sizeof(Header), headerLen);
+
+    while (!range.IsEmpty())
+    {
+        uint8_t optionType;
+        uint8_t optionLen;
+
+        SuccessOrExit(aMessage.ReadAndAdvance(range, optionType));
+
+        if (optionType == kOptionEnd)
+        {
+            break;
+        }
+
+        if (optionType == kOptionNop)
+        {
+            continue;
+        }
+
+        SuccessOrExit(aMessage.Read(range, optionLen));
+        VerifyOrExit(optionLen >= 2 && range.Contains(optionLen - 1));
+
+        if (optionType == kOptionLsrr || optionType == kOptionSsrr)
+        {
+            hasSourceRoute = true;
+            ExitNow();
+        }
+
+        range.AdvanceOffset(optionLen - 1);
+    }
+
+exit:
+    return hasSourceRoute;
+}
+
 //---------------------------------------------------------------------------------------------------------------------
 // Headers
 
 Error Headers::ParseFrom(const Message &aMessage)
 {
-    Error error = kErrorParse;
+    Error    error = kErrorParse;
+    uint16_t headerLen;
 
     Clear();
 
     SuccessOrExit(mIp4Header.ParseFrom(aMessage));
 
+    headerLen = mIp4Header.GetHeaderLength();
+
     switch (mIp4Header.GetProtocol())
     {
     case kProtoUdp:
-        SuccessOrExit(aMessage.Read(sizeof(Header), mHeader.mUdp));
+        SuccessOrExit(aMessage.Read(headerLen, mHeader.mUdp));
         break;
     case kProtoTcp:
-        SuccessOrExit(aMessage.Read(sizeof(Header), mHeader.mTcp));
+        SuccessOrExit(aMessage.Read(headerLen, mHeader.mTcp));
         break;
     case kProtoIcmp:
-        SuccessOrExit(aMessage.Read(sizeof(Header), mHeader.mIcmp));
+        SuccessOrExit(aMessage.Read(headerLen, mHeader.mIcmp));
         break;
     default:
         break;
diff --git a/src/core/net/ip4_types.hpp b/src/core/net/ip4_types.hpp
index 6ee9a55..6535e7a 100644
--- a/src/core/net/ip4_types.hpp
+++ b/src/core/net/ip4_types.hpp
@@ -110,12 +110,12 @@
     const uint8_t *GetBytes(void) const { return mFields.m8; }
 
     /**
-     * Sets the IPv4 address from a given byte array.
+     * Initializes the IPv4 address from a given byte array.
      *
      * @param[in] aBuffer    Pointer to an array containing the IPv4 address. `kSize` bytes from the buffer
      *                       are copied to form the IPv4 address.
      */
-    void SetBytes(const uint8_t *aBuffer) { memcpy(mFields.m8, aBuffer, kSize); }
+    void InitFrom(const uint8_t *aBuffer) { memcpy(mFields.m8, aBuffer, kSize); }
 
     /**
      * Sets the IPv4 address from a given IPv4-mapped IPv6 address.
@@ -296,7 +296,24 @@
      * @retval TRUE    If the header appears to be well-formed.
      * @retval FALSE   If the header does not appear to be well-formed.
      */
-    bool IsValid(void) const { return IsVersion4(); }
+    bool IsValid(void) const
+    {
+        return IsVersion4() && (GetIhl() >= kMinIhl) && (GetHeaderLength() <= GetTotalLength());
+    }
+
+    /**
+     * Returns the IPv4 Internet Header Length (IHL) value.
+     *
+     * @returns The IPv4 IHL value.
+     */
+    uint8_t GetIhl(void) const { return mVersIhl & kIhlMask; }
+
+    /**
+     * Returns the IPv4 Header Length value.
+     *
+     * @returns The IPv4 Header Length value.
+     */
+    uint16_t GetHeaderLength(void) const { return static_cast<uint16_t>(GetIhl()) * 4; }
 
     /**
      * Initializes the Version to 4 and sets Traffic Class and Flow fields to zero.
@@ -516,6 +533,7 @@
     static constexpr uint8_t  kVersion4           = 0x40;   // Use with `mVersIhl`
     static constexpr uint8_t  kVersionMask        = 0xf0;   // Use with `mVersIhl`
     static constexpr uint8_t  kIhlMask            = 0x0f;   // Use with `mVersIhl`
+    static constexpr uint8_t  kMinIhl             = 5;      ///< Minimum IPv4 Internet Header Length (in 32-bit words).
     static constexpr uint8_t  kDscpOffset         = 2;      // Use with `mDscpEcn`
     static constexpr uint16_t kDscpMask           = 0xfc;   // Use with `mDscpEcn`
     static constexpr uint8_t  kEcnOffset          = 0;      // Use with `mDscpEcn`
@@ -524,7 +542,13 @@
     static constexpr uint16_t kFlagsDf            = 0x4000; // Use with `mFlagsFragmentOffset`
     static constexpr uint16_t kFlagsMf            = 0x2000; // Use with `mFlagsFragmentOffset`
     static constexpr uint16_t kFragmentOffsetMask = 0x1fff; // Use with `mFlagsFragmentOffset`
-    static constexpr uint32_t kVersIhlInit        = 0x45;   // Version 4, Header length = 5x8 bytes.
+    static constexpr uint32_t kVersIhlInit        = 0x45;   // Version 4, Header length = 5x4 bytes.
+    static constexpr uint8_t  kOptionEnd          = 0;      ///< End of Options List
+    static constexpr uint8_t  kOptionNop          = 1;      ///< No Operation
+    static constexpr uint8_t  kOptionLsrr         = 131;    ///< Loose Source and Record Route
+    static constexpr uint8_t  kOptionSsrr         = 137;    ///< Strict Source and Record Route
+
+    bool HasSourceRouteOption(const Message &aMessage) const;
 
     uint8_t  mVersIhl;
     uint8_t  mDscpEcn;
@@ -539,135 +563,135 @@
 } OT_TOOL_PACKED_END;
 
 /**
- * Implements ICMP(v4).
- * Note: ICMP(v4) messages will only be generated / handled by NAT64. So only header definition is required.
+ * Represents an ICMPv4 header.
  */
-class Icmp
+OT_TOOL_PACKED_BEGIN
+class Icmp4Header : public Clearable<Icmp4Header>
 {
 public:
+    static constexpr uint16_t kChecksumFieldOffset = 2; ///< The byte offset of the Checksum field in the ICMPv4 header.
+
     /**
-     * Represents an IPv4 ICMP header.
+     * ICMPv4 message types.
+     *
+     * Only the ICMPv4 types used with NAT64 are listed here.
      */
-    OT_TOOL_PACKED_BEGIN
-    class Header : public Clearable<Header>
+    enum Type : uint8_t
     {
-    public:
-        static constexpr uint16_t kChecksumFieldOffset = 2;
-        // A few ICMP types, only the ICMP types work with NAT64 are listed here.
-        enum Type : uint8_t
-        {
-            kTypeEchoReply              = 0,
-            kTypeDestinationUnreachable = 3,
-            kTypeEchoRequest            = 8,
-            kTypeTimeExceeded           = 11,
-        };
+        kTypeEchoReply              = 0,  ///< Echo Reply.
+        kTypeDestinationUnreachable = 3,  ///< Destination Unreachable.
+        kTypeEchoRequest            = 8,  ///< Echo Request.
+        kTypeTimeExceeded           = 11, ///< Time Exceeded.
+    };
 
-        enum Code : uint8_t
-        {
-            kCodeNone = 0,
-            // Destination Unreachable codes
-            kCodeNetworkUnreachable  = 0,
-            kCodeHostUnreachable     = 1,
-            kCodeProtocolUnreachable = 2,
-            kCodePortUnreachable     = 3,
-            kCodeSourceRouteFailed   = 5,
-            kCodeNetworkUnknown      = 6,
-            kCodeHostUnknown         = 7,
-        };
+    /**
+     * ICMPv4 message codes.
+     */
+    enum Code : uint8_t
+    {
+        kCodeNone = 0, ///< None.
 
-        /**
-         * Returns the type of the ICMP message.
-         *
-         * @returns The type field of the ICMP message.
-         */
-        uint8_t GetType(void) const { return mType; }
+        kCodeNetworkUnreachable  = 0, ///< Network Unreachable.
+        kCodeHostUnreachable     = 1, ///< Host Unreachable.
+        kCodeProtocolUnreachable = 2, ///< Protocol Unreachable.
+        kCodePortUnreachable     = 3, ///< Port Unreachable.
+        kCodeSourceRouteFailed   = 5, ///< Source Route Failed.
+        kCodeNetworkUnknown      = 6, ///< Network Unknown.
+        kCodeHostUnknown         = 7, ///< Host Unknown.
+    };
 
-        /**
-         * Sets the type of the ICMP message.
-         *
-         * @param[in] aType The type of the ICMP message.
-         */
-        void SetType(uint8_t aType) { mType = aType; }
+    /**
+     * Returns the ICMPv4 message type.
+     *
+     * @returns The ICMPv4 message type.
+     */
+    uint8_t GetType(void) const { return mType; }
 
-        /**
-         * Returns the code of the ICMP message.
-         *
-         * @returns The code field of the ICMP message.
-         */
-        uint8_t GetCode(void) const { return mCode; }
+    /**
+     * Sets the ICMPv4 message type.
+     *
+     * @param[in] aType  The ICMPv4 message type.
+     */
+    void SetType(uint8_t aType) { mType = aType; }
 
-        /**
-         * Sets the code of the ICMP message.
-         *
-         * @param[in] aCode The code of the ICMP message.
-         */
-        void SetCode(uint8_t aCode) { mCode = aCode; }
+    /**
+     * Returns the ICMPv4 message code.
+     *
+     * @returns The ICMPv4 message code.
+     */
+    uint8_t GetCode(void) const { return mCode; }
 
-        /**
-         * Sets the checksum field in the ICMP message.
-         *
-         * @returns The checksum of the ICMP message.
-         */
-        uint16_t GetChecksum(void) const { return BigEndian::HostSwap16(mChecksum); }
+    /**
+     * Sets the ICMPv4 message code.
+     *
+     * @param[in] aCode  The ICMPv4 message code.
+     */
+    void SetCode(uint8_t aCode) { mCode = aCode; }
 
-        /**
-         * Sets the checksum field in the ICMP message.
-         *
-         * @param[in] aChecksum The checksum of the ICMP message.
-         */
-        void SetChecksum(uint16_t aChecksum) { mChecksum = BigEndian::HostSwap16(aChecksum); }
+    /**
+     * Returns the ICMPv4 message checksum.
+     *
+     * @returns The ICMPv4 message checksum.
+     */
+    uint16_t GetChecksum(void) const { return BigEndian::HostSwap16(mChecksum); }
 
-        /**
-         * Returns the ICMPv4 message ID for Echo Requests and Replies.
-         *
-         * @returns The ICMPv4 message ID.
-         */
-        uint16_t GetId(void) const { return BigEndian::HostSwap16(mData.m16[0]); }
+    /**
+     * Sets the ICMPv4 message checksum.
+     *
+     * @param[in] aChecksum  The ICMPv4 message checksum.
+     */
+    void SetChecksum(uint16_t aChecksum) { mChecksum = BigEndian::HostSwap16(aChecksum); }
 
-        /**
-         * Sets the ICMPv4 message ID for Echo Requests and Replies.
-         *
-         * @param[in]  aId  The ICMPv4 message ID.
-         */
-        void SetId(uint16_t aId) { mData.m16[0] = BigEndian::HostSwap16(aId); }
+    /**
+     * Returns the ICMPv4 message ID for Echo Requests and Replies.
+     *
+     * @returns The ICMPv4 message ID.
+     */
+    uint16_t GetId(void) const { return BigEndian::HostSwap16(mData.m16[0]); }
 
-        /**
-         * Returns the rest of header field in the ICMP message.
-         *
-         * @returns The rest of header field in the ICMP message. The returned buffer has 4 octets.
-         */
-        const uint8_t *GetRestOfHeader(void) const { return mData.m8; }
+    /**
+     * Sets the ICMPv4 message ID for Echo Requests and Replies.
+     *
+     * @param[in]  aId  The ICMPv4 message ID.
+     */
+    void SetId(uint16_t aId) { mData.m16[0] = BigEndian::HostSwap16(aId); }
 
-        /**
-         * Sets the rest of header field in the ICMP message.
-         *
-         * @param[in] aRestOfHeader The rest of header field in the ICMP message. The buffer should have 4 octets.
-         */
-        void SetRestOfHeader(const uint8_t *aRestOfHeader) { memcpy(mData.m8, aRestOfHeader, sizeof(mData)); }
+    /**
+     * Returns the "Rest of Header" field in the ICMPv4 message.
+     *
+     * @returns The "Rest of Header" field in the ICMPv4 message. The returned buffer has 4 octets.
+     */
+    const uint8_t *GetRestOfHeader(void) const { return mData.m8; }
 
-    private:
-        uint8_t  mType;
-        uint8_t  mCode;
-        uint16_t mChecksum;
-        union OT_TOOL_PACKED_FIELD
-        {
-            uint8_t  m8[4];
-            uint16_t m16[2];
-            uint32_t m32[1];
-        } mData;
-    } OT_TOOL_PACKED_END;
-};
+    /**
+     * Sets the "Rest of Header" field in the ICMPv4 message.
+     *
+     * @param[in] aRestOfHeader  The "Rest of Header" field in the ICMPv4 message. The buffer should have 4 octets.
+     */
+    void SetRestOfHeader(const uint8_t *aRestOfHeader) { memcpy(mData.m8, aRestOfHeader, sizeof(mData)); }
+
+private:
+    uint8_t  mType;
+    uint8_t  mCode;
+    uint16_t mChecksum;
+    union OT_TOOL_PACKED_FIELD
+    {
+        uint8_t  m8[4];
+        uint16_t m16[2];
+        uint32_t m32[1];
+    } mData;
+} OT_TOOL_PACKED_END;
 
 // Internet Protocol Numbers
 static constexpr uint8_t kProtoTcp  = Ip6::kProtoTcp; ///< Transmission Control Protocol
 static constexpr uint8_t kProtoUdp  = Ip6::kProtoUdp; ///< User Datagram
 static constexpr uint8_t kProtoIcmp = 1;              ///< ICMP for IPv4
 
-using Tcp = Ip6::Tcp; // TCP in IPv4 is the same as TCP in IPv6
-using Udp = Ip6::Udp; // UDP in IPv4 is the same as UDP in IPv6
+using TcpHeader = Ip6::TcpHeader; ///< TCP header (the same as in IPv6)
+using UdpHeader = Ip6::UdpHeader; ///< UDP header (the same as in IPv6)
 
 /**
- * Represents parsed IPv4 header along with UDP/TCP/ICMP4 headers from a received message/frame.
+ * Represents parsed IPv4 header along with UDP/TCP/ICMPv4 headers from a received message/frame.
  */
 class Headers : private Clearable<Headers>
 {
@@ -757,7 +781,7 @@
      *
      * @returns The UDP header.
      */
-    const Udp::Header &GetUdpHeader(void) const { return mHeader.mUdp; }
+    const UdpHeader &GetUdpHeader(void) const { return mHeader.mUdp; }
 
     /**
      * Returns the TCP header.
@@ -766,7 +790,7 @@
      *
      * @returns The TCP header.
      */
-    const Tcp::Header &GetTcpHeader(void) const { return mHeader.mTcp; }
+    const TcpHeader &GetTcpHeader(void) const { return mHeader.mTcp; }
 
     /**
      * Returns the ICMPv4 header.
@@ -775,7 +799,7 @@
      *
      * @returns The ICMPv4 header.
      */
-    const Icmp::Header &GetIcmpHeader(void) const { return mHeader.mIcmp; }
+    const Icmp4Header &GetIcmpHeader(void) const { return mHeader.mIcmp; }
 
     /**
      * Returns the source port number if the header is UDP or TCP, or zero otherwise
@@ -809,9 +833,9 @@
     Header mIp4Header;
     union
     {
-        Udp::Header  mUdp;
-        Tcp::Header  mTcp;
-        Icmp::Header mIcmp;
+        UdpHeader   mUdp;
+        TcpHeader   mTcp;
+        Icmp4Header mIcmp;
     } mHeader;
 };
 
diff --git a/src/core/net/ip6.cpp b/src/core/net/ip6.cpp
index dae1b3f..0ef583c 100644
--- a/src/core/net/ip6.cpp
+++ b/src/core/net/ip6.cpp
@@ -35,18 +35,16 @@
 
 #include "instance/instance.hpp"
 
-using IcmpType = ot::Ip6::Icmp::Header::Type;
-
-static const IcmpType kForwardIcmpTypes[] = {
-    IcmpType::kTypeDstUnreach,       IcmpType::kTypePacketToBig, IcmpType::kTypeTimeExceeded,
-    IcmpType::kTypeParameterProblem, IcmpType::kTypeEchoRequest, IcmpType::kTypeEchoReply,
-};
-
 namespace ot {
 namespace Ip6 {
 
 RegisterLogModule("Ip6");
 
+const uint8_t Ip6::kForwardIcmpTypes[] = {
+    Icmp6Header::kTypeDstUnreach,       Icmp6Header::kTypePacketToBig, Icmp6Header::kTypeTimeExceeded,
+    Icmp6Header::kTypeParameterProblem, Icmp6Header::kTypeEchoRequest, Icmp6Header::kTypeEchoReply,
+};
+
 Ip6::Ip6(Instance &aInstance)
     : InstanceLocator(aInstance)
     , mReceiveFilterEnabled(false)
@@ -293,8 +291,7 @@
 
     offsetRange.InitFromMessageFullLength(aMessage);
 
-    IgnoreError(aMessage.Read(offsetRange, ip6Header));
-    offsetRange.AdvanceOffset(sizeof(ip6Header));
+    IgnoreError(aMessage.ReadAndAdvance(offsetRange, ip6Header));
 
     VerifyOrExit(ip6Header.GetNextHeader() == kProtoHopOpts);
 
@@ -491,7 +488,7 @@
     return error;
 }
 
-Error Ip6::HandleOptions(Message &aMessage, const Header &aHeader, bool &aReceive)
+Error Ip6::HandleOptions(Message &aMessage, const Header &aHeader, bool &aReceive, bool aIsHopByHop)
 {
     Error          error        = kErrorNone;
     bool           hasMplOption = false;
@@ -518,7 +515,7 @@
             continue;
         }
 
-        if (option.GetType() == MplOption::kType)
+        if (aIsHopByHop && (option.GetType() == MplOption::kType))
         {
             VerifyOrExit(!hasMplOption, error = kErrorDrop);
             hasMplOption = true;
@@ -561,7 +558,7 @@
     header.SetNextHeader(kProtoFragment);
 
     fragmentHeader.Init();
-    fragmentHeader.SetIdentification(Random::NonCrypto::GetUint32());
+    fragmentHeader.SetIdentification(Random::NonCrypto::Generate<uint32_t>());
     fragmentHeader.SetNextHeader(aIpProto);
     fragmentHeader.SetMoreFlag();
 
@@ -668,6 +665,7 @@
     if (message == nullptr)
     {
         LogDebg("start reassembly");
+        VerifyOrExit(offset == 0, error = kErrorDrop);
         VerifyOrExit((message = NewMessage()) != nullptr, error = kErrorNoBufs);
         mReassemblyList.Enqueue(*message);
 
@@ -680,6 +678,10 @@
 
         Get<TimeTicker>().RegisterReceiver(TimeTicker::kIp6FragmentReassembler);
     }
+    else
+    {
+        VerifyOrExit(offset == message->GetOffset(), error = kErrorDrop);
+    }
 
     // increase message buffer if necessary
     if (message->GetLength() < offset + payloadFragment + aMessage.GetOffset())
@@ -692,6 +694,8 @@
         /* aWriteOffset */ aMessage.GetOffset() + offset, aMessage,
         /* aReadOffset */ aMessage.GetOffset() + sizeof(fragmentHeader), /* aLength */ payloadFragment);
 
+    message->SetOffset(offset + payloadFragment);
+
     // check if it is the last frame
     if (!fragmentHeader.IsMoreFlagSet())
     {
@@ -752,14 +756,14 @@
         if (now - message.GetTimestamp() >= TimeMilli::SecToMsec(kReassemblyTimeout))
         {
             LogInfo("Reassembly timeout.");
-            SendIcmpError(message, Icmp::Header::kTypeTimeExceeded, Icmp::Header::kCodeFragmReasTimeEx);
+            SendIcmpError(message, Icmp6Header::kTypeTimeExceeded, Icmp6Header::kCodeFragmReasTimeEx);
 
             mReassemblyList.DequeueAndFree(message);
         }
     }
 }
 
-void Ip6::SendIcmpError(Message &aMessage, Icmp::Header::Type aIcmpType, Icmp::Header::Code aIcmpCode)
+void Ip6::SendIcmpError(Message &aMessage, Icmp6Header::Type aIcmpType, Icmp6Header::Code aIcmpCode)
 {
     Error       error = kErrorNone;
     Header      header;
@@ -809,6 +813,7 @@
 {
     Error           error = kErrorNone;
     ExtensionHeader extHeader;
+    bool            first = true;
 
     while (aReceive || aNextHeader == kProtoHopOpts)
     {
@@ -817,8 +822,12 @@
         switch (aNextHeader)
         {
         case kProtoHopOpts:
+            VerifyOrExit(first, error = kErrorDrop);
+            SuccessOrExit(error = HandleOptions(*aMessagePtr, aHeader, aReceive, /* aIsHopByHop */ true));
+            break;
+
         case kProtoDstOpts:
-            SuccessOrExit(error = HandleOptions(*aMessagePtr, aHeader, aReceive));
+            SuccessOrExit(error = HandleOptions(*aMessagePtr, aHeader, aReceive, /* aIsHopByHop */ false));
             break;
 
         case kProtoFragment:
@@ -838,12 +847,56 @@
         }
 
         aNextHeader = extHeader.GetNextHeader();
+        first       = false;
     }
 
 exit:
     return error;
 }
 
+bool Ip6::HasIp6InIpTunnel(const Message &aMessage, uint8_t aNextHeader) const
+{
+    bool        hasTunnel = false;
+    OffsetRange offsetRange;
+
+    offsetRange.InitFromMessageOffsetToEnd(aMessage);
+
+    while (!offsetRange.IsEmpty())
+    {
+        if (aNextHeader == kProtoIp6)
+        {
+            hasTunnel = true;
+            break;
+        }
+
+        if (aNextHeader == kProtoHopOpts || aNextHeader == kProtoDstOpts || aNextHeader == kProtoRouting)
+        {
+            ExtensionHeader extHeader;
+            uint16_t        size;
+
+            SuccessOrExit(aMessage.Read(offsetRange, extHeader));
+            size = extHeader.GetSize();
+            VerifyOrExit(offsetRange.Contains(size));
+            offsetRange.AdvanceOffset(size);
+            aNextHeader = extHeader.GetNextHeader();
+        }
+        else if (aNextHeader == kProtoFragment)
+        {
+            FragmentHeader fragHeader;
+
+            SuccessOrExit(aMessage.ReadAndAdvance(offsetRange, fragHeader));
+            aNextHeader = fragHeader.GetNextHeader();
+        }
+        else
+        {
+            break;
+        }
+    }
+
+exit:
+    return hasTunnel;
+}
+
 Error Ip6::TakeOrCopyMessagePtr(OwnedPtr<Message> &aTargetPtr,
                                 OwnedPtr<Message> &aMessagePtr,
                                 MessageOwnership   aMessageOwnership)
@@ -958,17 +1011,17 @@
         case kProtoIcmp6:
             if (mIcmp.ShouldHandleEchoRequest(aHeader.GetDestination()))
             {
-                Icmp::Header icmp;
+                Icmp6Header icmp;
 
                 IgnoreError(aMessagePtr->Read(aMessagePtr->GetOffset(), icmp));
-                VerifyOrExit(icmp.GetType() != Icmp::Header::kTypeEchoRequest, error = kErrorDrop);
+                VerifyOrExit(icmp.GetType() != Icmp6Header::kTypeEchoRequest, error = kErrorDrop);
             }
 
             break;
 
         case kProtoUdp:
         {
-            Udp::Header udp;
+            UdpHeader udp;
 
             IgnoreError(aMessagePtr->Read(aMessagePtr->GetOffset(), udp));
             VerifyOrExit(!Get<Udp>().IsPortInUse(udp.GetDestinationPort()), error = kErrorNoRoute);
@@ -1041,18 +1094,20 @@
     SuccessOrExit(error = header.ParseFrom(*aMessagePtr));
     VerifyOrExit(!header.GetSource().IsMulticast(), error = kErrorInvalidSourceAddress);
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-    // The filtering rules don't apply to packets from DUA.
-    if (!Get<BackboneRouter::Leader>().IsDomainUnicast(header.GetSource()))
-#endif
+    aMessagePtr->SetOffset(sizeof(header));
+
+    if (aMessagePtr->IsOriginHostUntrusted() && HasIp6InIpTunnel(*aMessagePtr, header.GetNextHeader()))
     {
-        // When the packet is forwarded from host to Thread, if its source is on-mesh or its destination is
-        // mesh-local, we'll drop the packet unless the packet originates from this device.
-        if (Get<NetworkData::Leader>().IsOnMesh(header.GetSource()) ||
-            Get<Mle::Mle>().IsMeshLocalAddress(header.GetDestination()))
-        {
-            VerifyOrExit(Get<ThreadNetif>().HasUnicastAddress(header.GetSource()), error = kErrorDrop);
-        }
+        LogInfo("Dropping host-untrusted IP-in-IP packet");
+        ExitNow(error = kErrorDrop);
+    }
+
+    // When the packet is forwarded from host to Thread, if its source is on-mesh or its destination is
+    // mesh-local, we'll drop the packet unless the packet originates from this device.
+    if (Get<NetworkData::Leader>().IsOnMesh(header.GetSource()) ||
+        Get<Mle::Mle>().IsMeshLocalAddress(header.GetDestination()))
+    {
+        VerifyOrExit(Get<ThreadNetif>().HasUnicastAddress(header.GetSource()), error = kErrorDrop);
     }
 
     if (header.GetDestination().IsMulticast())
@@ -1181,13 +1236,7 @@
 
     if (IsOnLink(aHeader.GetDestination()))
     {
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-        aForwardThread = (!aMessage.IsLoopbackToHostAllowed() ||
-                          !Get<BackboneRouter::Manager>().ShouldForwardDuaToBackbone(aHeader.GetDestination()));
-        aForwardHost   = !aForwardThread;
-#else
         aForwardThread = true;
-#endif
         ExitNow();
     }
 
@@ -1216,10 +1265,12 @@
     return;
 }
 
-Error Ip6::HandleDatagram(OwnedPtr<Message> aMessagePtr, bool aIsReassembled)
+Error Ip6::HandleDatagram(OwnedPtr<Message> aMessagePtr, bool aIsReassembled, uint8_t aRecursionDepth)
 {
-    Error   error;
-    Header  header;
+    Error  error;
+    Header header;
+
+    VerifyOrExit(aRecursionDepth <= kMaxRecursionDepth, error = kErrorDrop);
     bool    receive;
     bool    forwardThread;
     bool    forwardHost;
@@ -1258,7 +1309,7 @@
 
         Get<MeshForwarder>().LogMessage(MeshForwarder::kMessageReceive, *messagePtr);
 
-        IgnoreError(HandleDatagram(messagePtr.PassOwnership(), aIsReassembled));
+        SuccessOrExit(error = HandleDatagram(messagePtr.PassOwnership(), aIsReassembled, aRecursionDepth + 1));
 
         receive     = false;
         forwardHost = false;
@@ -1293,27 +1344,15 @@
 
             SuccessOrExit(error = aMessagePtr->Read(aMessagePtr->GetOffset(), icmpType));
 
-            error = kErrorDrop;
-
-            for (IcmpType type : kForwardIcmpTypes)
-            {
-                if (icmpType == type)
-                {
-                    error = kErrorNone;
-                    break;
-                }
-            }
-
-            SuccessOrExit(error);
+            VerifyOrExit(DoesArrayContain(kForwardIcmpTypes, icmpType), error = kErrorDrop);
         }
-
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
         if (mTmfOriginFilterEnabled)
 #endif
         {
             if (aMessagePtr->IsOriginHostUntrusted() && (nextHeader == kProtoUdp))
             {
-                Udp::Header udpHeader;
+                UdpHeader udpHeader;
 
                 SuccessOrExit(error = aMessagePtr->Read(aMessagePtr->GetOffset(), udpHeader));
 
diff --git a/src/core/net/ip6.hpp b/src/core/net/ip6.hpp
index 6cc41b3..3cff4c9 100644
--- a/src/core/net/ip6.hpp
+++ b/src/core/net/ip6.hpp
@@ -87,6 +87,9 @@
  * @defgroup core-ip6-mpl MPL
  * @defgroup core-ip6-netif Network Interfaces
  * @defgroup core-ip6-slaac SLAAC
+ * @defgroup core-tcp TCP
+ * @defgroup core-tcp-ext TCP Extension
+ * @defgroup core-udp UDP
  *
  * @}
  */
@@ -179,7 +182,7 @@
      * @retval kErrorNoRoute  No route to host.
      * @retval kErrorParse    Encountered a malformed header when processing the message.
      */
-    Error HandleDatagram(OwnedPtr<Message> aMessagePtr, bool aIsReassembled = false);
+    Error HandleDatagram(OwnedPtr<Message> aMessagePtr, bool aIsReassembled = false, uint8_t aRecursionDepth = 0);
 
     /**
      * Sets the callback to provide received raw IPv6 datagrams.
@@ -318,6 +321,7 @@
 #endif
 
 private:
+    static constexpr uint8_t kMaxRecursionDepth = 4;
     static constexpr uint8_t kReassemblyTimeout = OPENTHREAD_CONFIG_IP6_REASSEMBLY_TIMEOUT;
 
     static constexpr uint16_t kMinimalMtu = 1280;
@@ -349,20 +353,21 @@
                                  const Header      &aHeader,
                                  uint8_t           &aNextHeader,
                                  bool              &aReceive);
+    bool  HasIp6InIpTunnel(const Message &aMessage, uint8_t aNextHeader) const;
     Error FragmentDatagram(Message &aMessage, uint8_t aIpProto);
     Error HandleFragment(Message &aMessage);
 #if OPENTHREAD_CONFIG_IP6_FRAGMENTATION_ENABLE
     void CleanupFragmentationBuffer(void);
     void HandleTimeTick(void);
     void UpdateReassemblyList(void);
-    void SendIcmpError(Message &aMessage, Icmp::Header::Type aIcmpType, Icmp::Header::Code aIcmpCode);
+    void SendIcmpError(Message &aMessage, Icmp6Header::Type aIcmpType, Icmp6Header::Code aIcmpCode);
 #endif
     Error ReadHopByHopHeader(const Message &aMessage, OffsetRange &aOffsetRange, HopByHopHeader &aHbhHeader) const;
     Error AddMplOption(Message &aMessage, Header &aHeader);
     Error PrepareMulticastToLargerThanRealmLocal(Message &aMessage, const Header &aHeader);
     Error InsertMplOption(Message &aMessage, Header &aHeader);
     Error RemoveMplOption(Message &aMessage);
-    Error HandleOptions(Message &aMessage, const Header &aHeader, bool &aReceive);
+    Error HandleOptions(Message &aMessage, const Header &aHeader, bool &aReceive, bool aIsHopByHop);
     Error Receive(Header            &aIp6Header,
                   OwnedPtr<Message> &aMessagePtr,
                   uint8_t            aIpProto,
@@ -371,6 +376,8 @@
     void UpdateBorderRoutingCounters(const Header &aHeader, uint16_t aMessageLength, bool aIsInbound);
 #endif
 
+    static const uint8_t kForwardIcmpTypes[];
+
     using SendQueueTask = TaskletIn<Ip6, &Ip6::HandleSendQueue>;
 
     bool mReceiveFilterEnabled;
@@ -521,7 +528,7 @@
      *
      * @returns The UDP header.
      */
-    const Udp::Header &GetUdpHeader(void) const { return mHeader.mUdp; }
+    const UdpHeader &GetUdpHeader(void) const { return mHeader.mUdp; }
 
     /**
      * Returns the TCP header.
@@ -530,7 +537,7 @@
      *
      * @returns The TCP header.
      */
-    const Tcp::Header &GetTcpHeader(void) const { return mHeader.mTcp; }
+    const TcpHeader &GetTcpHeader(void) const { return mHeader.mTcp; }
 
     /**
      * Returns the ICMPv6 header.
@@ -539,7 +546,7 @@
      *
      * @returns The ICMPv6 header.
      */
-    const Icmp::Header &GetIcmpHeader(void) const { return mHeader.mIcmp; }
+    const Icmp6Header &GetIcmpHeader(void) const { return mHeader.mIcmp; }
 
     /**
      * Returns the source port number if header is UDP or TCP, or zero otherwise
@@ -573,9 +580,9 @@
     Header mIp6Header;
     union
     {
-        Udp::Header  mUdp;
-        Tcp::Header  mTcp;
-        Icmp::Header mIcmp;
+        UdpHeader   mUdp;
+        TcpHeader   mTcp;
+        Icmp6Header mIcmp;
     } mHeader;
 };
 
diff --git a/src/core/net/ip6_address.cpp b/src/core/net/ip6_address.cpp
index 8c598bf..ca5bfc9 100644
--- a/src/core/net/ip6_address.cpp
+++ b/src/core/net/ip6_address.cpp
@@ -48,7 +48,7 @@
     return Random::Crypto::FillBuffer(&m8[1], kSize - 1);
 }
 
-Error NetworkPrefix::SetFrom(const Prefix &aPrefix)
+Error NetworkPrefix::InitFrom(const Prefix &aPrefix)
 {
     Error error = kErrorNone;
 
@@ -62,7 +62,7 @@
 //---------------------------------------------------------------------------------------------------------------------
 // Prefix methods
 
-void Prefix::Set(const uint8_t *aPrefix, uint8_t aLength)
+void Prefix::InitFrom(const uint8_t *aPrefix, uint8_t aLength)
 {
     memcpy(mPrefix.mFields.m8, aPrefix, SizeForLength(aLength));
     mLength = aLength;
@@ -98,7 +98,7 @@
 void Prefix::Tidy(void)
 {
     uint8_t byteLength      = GetBytesSize();
-    uint8_t lastByteBitMask = ~(static_cast<uint8_t>(1 << (byteLength * 8 - mLength)) - 1);
+    uint8_t lastByteBitMask = static_cast<uint8_t>(~(static_cast<uint8_t>(1 << (byteLength * 8 - mLength)) - 1));
 
     if (byteLength != 0)
     {
@@ -227,9 +227,9 @@
 
 void InterfaceIdentifier::GenerateRandom(void) { SuccessOrAssert(Random::Crypto::Fill(*this)); }
 
-void InterfaceIdentifier::SetBytes(const uint8_t *aBuffer) { memcpy(mFields.m8, aBuffer, kSize); }
+void InterfaceIdentifier::InitFrom(const uint8_t *aBuffer) { memcpy(mFields.m8, aBuffer, kSize); }
 
-void InterfaceIdentifier::SetFromExtAddress(const Mac::ExtAddress &aExtAddress)
+void InterfaceIdentifier::InitFromExtAddress(const Mac::ExtAddress &aExtAddress)
 {
     Mac::ExtAddress addr;
 
@@ -238,7 +238,7 @@
     addr.CopyTo(mFields.m8);
 }
 
-void InterfaceIdentifier::SetToLocator(uint16_t aLocator)
+void InterfaceIdentifier::InitAsLocator(uint16_t aLocator)
 {
     // Locator IID pattern `0000:00ff:fe00:xxxx`
     mFields.m32[0] = BigEndian::HostSwap32(0x000000ff);
@@ -307,14 +307,14 @@
     return (mFields.m16[0] & BigEndian::HostSwap16(0xffc0)) == BigEndian::HostSwap16(0xfe80);
 }
 
-void Address::SetToLinkLocalAddress(const Mac::ExtAddress &aExtAddress)
+void Address::InitAsLinkLocalAddress(const Mac::ExtAddress &aExtAddress)
 {
     mFields.m32[0] = BigEndian::HostSwap32(0xfe800000);
     mFields.m32[1] = 0;
-    GetIid().SetFromExtAddress(aExtAddress);
+    GetIid().InitFromExtAddress(aExtAddress);
 }
 
-void Address::SetToLinkLocalAddress(const InterfaceIdentifier &aIid)
+void Address::InitAsLinkLocalAddress(const InterfaceIdentifier &aIid)
 {
     mFields.m32[0] = BigEndian::HostSwap32(0xfe800000);
     mFields.m32[1] = 0;
@@ -334,7 +334,7 @@
     return (mFields.m32[0] == 0) && (mFields.m32[1] == 0) && (mFields.m32[2] == BigEndian::HostSwap32(0xffff));
 }
 
-void Address::SetToIp4Mapped(const Ip4::Address &aIp4Address)
+void Address::InitAsIp4Mapped(const Ip4::Address &aIp4Address)
 {
     Clear();
     mFields.m16[5] = 0xffff;
@@ -375,7 +375,7 @@
         // ((0x80 >> 2) - 1) = (0b0010_0000 - 1) = 0b0001_1111
 
         aDst[numBytes] &= mask;
-        aDst[numBytes] |= (aSrc[numBytes] & ~mask);
+        aDst[numBytes] |= (aSrc[numBytes] & static_cast<uint8_t>(~mask));
     }
 }
 
@@ -385,10 +385,10 @@
     mFields.m8[kMulticastNetworkPrefixLengthOffset] = aPrefixLength;
 }
 
-void Address::SetToLocator(const NetworkPrefix &aNetworkPrefix, uint16_t aLocator)
+void Address::InitAsLocator(const NetworkPrefix &aNetworkPrefix, uint16_t aLocator)
 {
     SetPrefix(aNetworkPrefix);
-    GetIid().SetToLocator(aLocator);
+    GetIid().InitAsLocator(aLocator);
 }
 
 uint8_t Address::GetScope(void) const
diff --git a/src/core/net/ip6_address.hpp b/src/core/net/ip6_address.hpp
index ffe73dc..338fca8 100644
--- a/src/core/net/ip6_address.hpp
+++ b/src/core/net/ip6_address.hpp
@@ -83,14 +83,14 @@
     Error GenerateRandomUla(void);
 
     /**
-     * Sets the Network Prefix from a given `Prefix`.
+     * Initializes the Network Prefix from a given `Prefix`.
      *
      * @param[in] aPrefix  The prefix to use to set the Network Prefix.
      *
      * @retval kErrorNone          Successfully set the Network Prefix from @p aPrefix.
      * @retval kErrorInvalidArgs   The @p aPrefix length is not valid (must be `kLength`).
      */
-    Error SetFrom(const Prefix &aPrefix);
+    Error InitFrom(const Prefix &aPrefix);
 
 } OT_TOOL_PACKED_END;
 
@@ -140,19 +140,19 @@
     uint8_t GetBytesSize(void) const { return SizeForLength(mLength); }
 
     /**
-     * Sets the prefix.
+     * Initializes the prefix.
      *
      * @param[in] aPrefix  A pointer to buffer containing the prefix bytes.
      * @param[in] aLength  The length or prefix in bits.
      */
-    void Set(const uint8_t *aPrefix, uint8_t aLength);
+    void InitFrom(const uint8_t *aPrefix, uint8_t aLength);
 
     /**
-     * Sets the prefix from a given Network Prefix.
+     * Initializes the prefix from a given Network Prefix.
      *
      * @param[in] aNetworkPrefix    A Network Prefix.
      */
-    void Set(const NetworkPrefix &aNetworkPrefix) { Set(aNetworkPrefix.m8, NetworkPrefix::kLength); }
+    void InitFrom(const NetworkPrefix &aNetworkPrefix) { InitFrom(aNetworkPrefix.m8, NetworkPrefix::kLength); }
 
     /**
      * Sets the subnet ID of the prefix.
@@ -394,27 +394,27 @@
     const uint8_t *GetBytes(void) const { return mFields.m8; }
 
     /**
-     * Sets the Interface Identifier from a given byte array.
+     * Initializes the Interface Identifier from a given byte array.
      *
      * @param[in] aBuffer    Pointer to an array containing the Interface Identifier. `kSize` bytes from the buffer
      *                       are copied to form the Interface Identifier.
      */
-    void SetBytes(const uint8_t *aBuffer);
+    void InitFrom(const uint8_t *aBuffer);
 
     /**
-     * Sets the Interface Identifier from a given IEEE 802.15.4 Extended Address.
+     * Initializes the Interface Identifier from a given IEEE 802.15.4 Extended Address.
      *
      * @param[in] aExtAddress  An Extended Address.
      */
-    void SetFromExtAddress(const Mac::ExtAddress &aExtAddress);
+    void InitFromExtAddress(const Mac::ExtAddress &aExtAddress);
 
     /**
-     * Sets the Interface Identifier to Routing/Anycast Locator pattern `0000:00ff:fe00:xxxx` with a given
+     * Initializes the Interface Identifier to Routing/Anycast Locator pattern `0000:00ff:fe00:xxxx` with a given
      * locator (RLOC16 or ALOC16) value.
      *
      * @param[in]  aLocator    RLOC16 or ALOC16.
      */
-    void SetToLocator(uint16_t aLocator);
+    void InitAsLocator(uint16_t aLocator);
 
     /**
      * Indicates whether or not the Interface Identifier matches the locator pattern `0000:00ff:fe00:xxxx`.
@@ -470,7 +470,7 @@
     /**
      * Sets the Interface Identifier (IID) address locator field.
      *
-     * Unlike `SetToLocator()`, this method only changes the last 2 bytes of the IID and keeps the rest of the address
+     * Unlike `InitAsLocator()`, this method only changes the last 2 bytes of the IID and keeps the rest of the address
      * as before.
      *
      * @param[in]  aLocator   RLOC16 or ALOC16.
@@ -539,12 +539,12 @@
     const uint8_t *GetBytes(void) const { return mFields.m8; }
 
     /**
-     * Sets the IPv6 address from a given byte array.
+     * Initializes the IPv6 address from a given byte array.
      *
      * @param[in] aBuffer    Pointer to an array containing the IPv6 address. `kSize` bytes from the buffer
      *                       are copied to form the IPv6 address.
      */
-    void SetBytes(const uint8_t *aBuffer) { memcpy(mFields.m8, aBuffer, kSize); }
+    void InitFrom(const uint8_t *aBuffer) { memcpy(mFields.m8, aBuffer, kSize); }
 
     /**
      * Indicates whether or not the IPv6 address is the Unspecified Address.
@@ -571,19 +571,19 @@
     bool IsLinkLocalUnicast(void) const;
 
     /**
-     * Sets the IPv6 address to a Link-Local address with Interface Identifier generated from a given
-     * MAC Extended Address.
+     * Initializes the IPv6 address as a Link-Local address with Interface Identifier generated from a given MAC
+     * Extended Address.
      *
      * @param[in]  aExtAddress  A MAC Extended Address (used to generate the IID).
      */
-    void SetToLinkLocalAddress(const Mac::ExtAddress &aExtAddress);
+    void InitAsLinkLocalAddress(const Mac::ExtAddress &aExtAddress);
 
     /**
-     * Sets the IPv6 address to a Link-Local address with a given Interface Identifier.
+     * Initializes the IPv6 address to a Link-Local address with a given Interface Identifier.
      *
      * @param[in]  aIid   An Interface Identifier.
      */
-    void SetToLinkLocalAddress(const InterfaceIdentifier &aIid);
+    void InitAsLinkLocalAddress(const InterfaceIdentifier &aIid);
 
     /**
      * Indicates whether or not the IPv6 address is multicast address.
@@ -661,28 +661,13 @@
     static const Address &GetRealmLocalAllMplForwarders(void);
 
     /**
-     * Sets the IPv6 address to a Routing Locator (RLOC) IPv6 address with a given Network Prefix and
-     * RLOC16 value.
+     * Initializes the IPv6 address to a Routing/Anycast Locator (RLOC/ALOC) IPv6 address with a given Network Prefix
+     * and a locator (RLOC16 or ALOC16) value.
      *
      * @param[in]  aNetworkPrefix    A Network Prefix.
-     * @param[in]  aRloc16           A RLOC16 value.
+     * @param[in]  aLocator          RLOC16 or ALOC16.
      */
-    void SetToRoutingLocator(const NetworkPrefix &aNetworkPrefix, uint16_t aRloc16)
-    {
-        SetToLocator(aNetworkPrefix, aRloc16);
-    }
-
-    /**
-     * Sets the IPv6 address to a Anycast Locator (ALOC) IPv6 address with a given Network Prefix and
-     * ALOC16 value.
-     *
-     * @param[in]  aNetworkPrefix    A Network Prefix.
-     * @param[in]  aAloc16           A ALOC16 value.
-     */
-    void SetToAnycastLocator(const NetworkPrefix &aNetworkPrefix, uint16_t aAloc16)
-    {
-        SetToLocator(aNetworkPrefix, aAloc16);
-    }
+    void InitAsLocator(const NetworkPrefix &aNetworkPrefix, uint16_t aLocator);
 
     /**
      * Indicates whether or not the IPv6 address follows the IPv4-mapped format.
@@ -697,11 +682,11 @@
     bool IsIp4Mapped(void) const;
 
     /**
-     * Sets the IPv6 address to follow the IPv4-mapped IPv6 address for a given IPv4 address.
+     * Initializes the IPv6 address to follow the IPv4-mapped IPv6 address for a given IPv4 address.
      *
      * @param[in] aIp4Address  An IPv4 address.
      */
-    void SetToIp4Mapped(const Ip4::Address &aIp4Address);
+    void InitAsIp4Mapped(const Ip4::Address &aIp4Address);
 
     /**
      * Returns the Network Prefix of the IPv6 address (most significant 64 bits of the address).
@@ -719,7 +704,7 @@
      * @param[in]  aLength  The length of prefix in bits.
      * @param[out] aPrefix  A reference to a prefix to output the fetched prefix.
      */
-    void GetPrefix(uint8_t aLength, Prefix &aPrefix) const { aPrefix.Set(mFields.m8, aLength); }
+    void GetPrefix(uint8_t aLength, Prefix &aPrefix) const { aPrefix.InitFrom(mFields.m8, aLength); }
 
     /**
      * Indicates whether the IPv6 address matches a given prefix.
@@ -743,6 +728,18 @@
     bool MatchesPrefix(const uint8_t *aPrefix, uint8_t aPrefixLength) const;
 
     /**
+     * Indicates whether the address matches a given other address.
+     *
+     * This method is intended to be used with `FindMatching()` in collections.
+     *
+     * @param[in] aAddress  The IPv6 address to match against.
+     *
+     * @retval TRUE   If the address matches @p aAddress.
+     * @retval FALSE  If the address does not match @p aAddress.
+     */
+    bool Matches(const Ip6::Address &aAddress) const { return (*this == aAddress); }
+
+    /**
      * Sets the IPv6 address prefix.
      *
      * Only changes the first @p aPrefixLength bits of the address and keeps the rest of the bits in the
@@ -883,6 +880,15 @@
     void ToString(char *aBuffer, uint16_t aSize) const;
 
     /**
+     * Appends the IPv6 address to a given `StringWriter`.
+     *
+     * The IPv6 address string is formatted as 16 hex values separated by ':' (i.e., "%x:%x:%x:...:%x").
+     *
+     * @param[in,out] aWriter  A reference to a `StringWriter` to append the string to.
+     */
+    void ToString(StringWriter &aWriter) const;
+
+    /**
      * Overloads operator `<` to compare two IPv6 addresses.
      *
      * @param[in] aOther  The other IPv6 address to compare with.
@@ -896,8 +902,6 @@
     static constexpr uint8_t kMulticastNetworkPrefixLengthOffset = 3; // Prefix-Based Multicast Address (RFC3306)
     static constexpr uint8_t kMulticastNetworkPrefixOffset       = 4; // Prefix-Based Multicast Address (RFC3306)
 
-    void SetToLocator(const NetworkPrefix &aNetworkPrefix, uint16_t aLocator);
-    void ToString(StringWriter &aWriter) const;
     void AppendHexWords(StringWriter &aWriter, uint8_t aLength) const;
 
     static void CopyBits(uint8_t *aDst, const uint8_t *aSrc, uint8_t aNumBits);
diff --git a/src/core/net/ip6_filter.cpp b/src/core/net/ip6_filter.cpp
index c4f518b..5fc5cee 100644
--- a/src/core/net/ip6_filter.cpp
+++ b/src/core/net/ip6_filter.cpp
@@ -56,11 +56,12 @@
 
     VerifyOrExit(headers.GetDestinationAddress().IsLinkLocalUnicastOrMulticast());
 
-    // Allow all link-local IPv6 datagrams when Thread is not enabled
-    if (Get<Mle::Mle>().GetRole() == Mle::kRoleDisabled)
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    if (mAllowUnsecureWhenDisabled && Get<Mle::Mle>().IsDisabled())
     {
         ExitNow(error = kErrorNone);
     }
+#endif
 
     dstPort = headers.GetDestinationPort();
 
diff --git a/src/core/net/ip6_filter.hpp b/src/core/net/ip6_filter.hpp
index 60fc63d..a1bc668 100644
--- a/src/core/net/ip6_filter.hpp
+++ b/src/core/net/ip6_filter.hpp
@@ -66,6 +66,9 @@
      */
     explicit Filter(Instance &aInstance)
         : InstanceLocator(aInstance)
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+        , mAllowUnsecureWhenDisabled(false)
+#endif
     {
     }
 
@@ -131,6 +134,23 @@
         return &mUnsecurePorts[0];
     }
 
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    /**
+     * Sets whether to allow link-local unsecure IPv6 datagrams when the Thread role is disabled.
+     *
+     * @param[in] aAllow  TRUE to allow, FALSE otherwise.
+     */
+    void SetAllowUnsecureWhenDisabled(bool aAllow) { mAllowUnsecureWhenDisabled = aAllow; }
+
+    /**
+     * Indicates whether allowing link-local unsecure IPv6 datagrams when the Thread role is disabled is enabled.
+     *
+     * @retval TRUE   Does allow unsecure IPv6 datagrams when the Thread role is disabled.
+     * @retval FALSE  Does not allow unsecure IPv6 datagrams when the Thread role is disabled.
+     */
+    bool IsUnsecureAllowedWhenDisabled(void) const { return mAllowUnsecureWhenDisabled; }
+#endif
+
 private:
     static constexpr uint16_t kMaxUnsecurePorts = 2;
 
@@ -143,6 +163,9 @@
     Error UpdateUnsecurePorts(Action aAction, uint16_t aPort);
 
     Array<uint16_t, kMaxUnsecurePorts> mUnsecurePorts;
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    bool mAllowUnsecureWhenDisabled;
+#endif
 };
 
 } // namespace Ip6
diff --git a/src/core/net/ip6_mpl.cpp b/src/core/net/ip6_mpl.cpp
index 0790210..cbfe7e2 100644
--- a/src/core/net/ip6_mpl.cpp
+++ b/src/core/net/ip6_mpl.cpp
@@ -448,7 +448,7 @@
 void Mpl::Metadata::GenerateNextTransmissionTime(TimeMilli aCurrentTime, uint8_t aInterval)
 {
     // Emulate Trickle timer behavior and set up the next retransmission within [0,I) range.
-    uint8_t t = (aInterval == 0) ? aInterval : Random::NonCrypto::GetUint8InRange(0, aInterval);
+    uint8_t t = (aInterval == 0) ? aInterval : Random::NonCrypto::GenerateUpToExcluding(aInterval);
 
     // Set transmission time at the beginning of the next interval.
     mTransmissionTime = aCurrentTime + static_cast<uint32_t>(mIntervalOffset + t);
diff --git a/src/core/net/mdns.cpp b/src/core/net/mdns.cpp
index 616a8a0..175a4e9 100644
--- a/src/core/net/mdns.cpp
+++ b/src/core/net/mdns.cpp
@@ -4337,7 +4337,7 @@
 
     if (shouldDelay)
     {
-        delay = Random::NonCrypto::GetUint16InRange(kMinResponseDelay, kMaxResponseDelay);
+        delay = Random::NonCrypto::GenerateInClosedRange(kMinResponseDelay, kMaxResponseDelay);
     }
 
     for (const Question &question : mQuestions)
@@ -4963,6 +4963,8 @@
 
     mRxMsgEntries.RemoveMatching(aRxMessagePtr->GetSenderAddress());
 
+    VerifyOrExit(mRxMsgEntries.CountAllEntries() < kMaxRxMsgEntries);
+
     newEntry = RxMsgEntry::Allocate(GetInstance());
     VerifyOrExit(newEntry != nullptr);
 
@@ -5022,26 +5024,18 @@
 
 void Core::MultiPacketRxMessages::RxMsgEntry::Add(OwnedPtr<RxMessage> &aRxMessagePtr)
 {
-    uint16_t numMsgs = 0;
+    // If a subsequent received `RxMessage` is also marked as
+    // truncated, we again delay the process time. To avoid
+    // continuous delay and piling up of messages in the list,
+    // we limit the number of messages.
 
-    for (const RxMessage &rxMsg : mRxMessages)
-    {
-        // If a subsequent received `RxMessage` is also marked as
-        // truncated, we again delay the process time. To avoid
-        // continuous delay and piling up of messages in the list,
-        // we limit the number of messages.
-
-        numMsgs++;
-        VerifyOrExit(numMsgs < kMaxNumMessages);
-
-        OT_UNUSED_VARIABLE(rxMsg);
-    }
+    VerifyOrExit(mRxMessages.CountAllEntries() < kMaxNumMessagesPerEntry);
 
     mProcessTime = TimerMilli::GetNow();
 
     if (aRxMessagePtr->IsTruncated())
     {
-        mProcessTime += Random::NonCrypto::GetUint32InRange(kMinProcessDelay, kMaxProcessDelay);
+        mProcessTime += Random::NonCrypto::GenerateInClosedRange(kMinProcessDelay, kMaxProcessDelay);
     }
 
     // We push the new `RxMessage` at tail of the list to keep the
@@ -5422,7 +5416,7 @@
 
     if ((mNextProbeTxTime - now) >= kMaxProbeDelay)
     {
-        mNextProbeTxTime = now + Random::NonCrypto::GetUint32InRange(kMinProbeDelay, kMaxProbeDelay);
+        mNextProbeTxTime = now + Random::NonCrypto::GenerateInClosedRange(kMinProbeDelay, kMaxProbeDelay);
     }
 
     return mNextProbeTxTime;
@@ -5434,7 +5428,7 @@
 
     if ((mNextQueryTxTime - now) >= kMaxInitialQueryDelay)
     {
-        mNextQueryTxTime = now + Random::NonCrypto::GetUint32InRange(kMinInitialQueryDelay, kMaxInitialQueryDelay);
+        mNextQueryTxTime = now + Random::NonCrypto::GenerateInClosedRange(kMinInitialQueryDelay, kMaxInitialQueryDelay);
     }
 
     return mNextQueryTxTime;
@@ -5556,7 +5550,7 @@
 
         if (queryTime > now)
         {
-            queryTime += Random::NonCrypto::GetUint32InRange(0, GetClampedTtl() * kQueryTtlVariation);
+            queryTime += Random::NonCrypto::GenerateUpToExcluding(GetClampedTtl() * kQueryTtlVariation);
             aCacheEntry.ScheduleQuery(queryTime);
             break;
         }
@@ -7248,7 +7242,7 @@
     SuccessOrExit(aMessage.Read(aRecordOffset, aRecord));
     VerifyOrExit(aRecord.GetLength() >= sizeof(Ip4::Address));
 
-    address.SetToIp4Mapped(aRecord.GetAddress());
+    address.InitAsIp4Mapped(aRecord.GetAddress());
 
     AddNewResponseAddress(address, aRecord.GetTtl(), aRecord.GetClass() & kClassCacheFlushFlag);
 
diff --git a/src/core/net/mdns.hpp b/src/core/net/mdns.hpp
index 95355fb..b2808b6 100644
--- a/src/core/net/mdns.hpp
+++ b/src/core/net/mdns.hpp
@@ -1660,13 +1660,15 @@
     private:
         static constexpr uint32_t kMinProcessDelay = 400; // msec
         static constexpr uint32_t kMaxProcessDelay = 500; // msec
-        static constexpr uint16_t kMaxNumMessages  = 10;
+        static constexpr uint16_t kMaxRxMsgEntries = 64;
 
         struct RxMsgEntry : public InstanceLocator,
                             public LinkedListEntry<RxMsgEntry>,
                             public Heap::Allocatable<RxMsgEntry>,
                             private NonCopyable
         {
+            static constexpr uint16_t kMaxNumMessagesPerEntry = 10;
+
             explicit RxMsgEntry(Instance &aInstance);
 
             bool Matches(const AddressInfo &aAddress) const;
diff --git a/src/core/net/nat64_translator.cpp b/src/core/net/nat64_translator.cpp
index fe0f985..ccb3d71 100644
--- a/src/core/net/nat64_translator.cpp
+++ b/src/core/net/nat64_translator.cpp
@@ -249,7 +249,7 @@
     dstPortOrId = GetDestinationPortOrIcmp4Id(ip4Headers);
 #endif
 
-    aMessage.RemoveHeader(sizeof(Ip4::Header));
+    aMessage.RemoveHeader(ip4Headers.GetIp4Header().GetHeaderLength());
 
     ip6Header.Clear();
     ip6Header.InitVersionTrafficClassFlow();
@@ -365,7 +365,7 @@
 
     do
     {
-        port = Random::NonCrypto::GetUint16InRange(kMinTranslationPort, kMaxTranslationPort);
+        port = Random::NonCrypto::GenerateInClosedRange(kMinTranslationPort, kMaxTranslationPort);
 
         // The NAT64 SHOULD preserve the port parity (odd/even), as
         // per Section 4.2.2 of [RFC4787]). Determine if original and
@@ -622,9 +622,9 @@
 
 Error Translator::TranslateIcmp4(Message &aMessage, uint16_t aOriginalId)
 {
-    Error             error = kErrorNone;
-    Ip4::Icmp::Header icmp4Header;
-    Ip6::Icmp::Header icmp6Header;
+    Error            error = kErrorNone;
+    Ip4::Icmp4Header icmp4Header;
+    Ip6::Icmp6Header icmp6Header;
 
     // TODO: Implement the translation of other ICMP messages.
 
@@ -634,12 +634,12 @@
 
     switch (icmp4Header.GetType())
     {
-    case Ip4::Icmp::Header::Type::kTypeEchoReply:
+    case Ip4::Icmp4Header::Type::kTypeEchoReply:
         // The only difference between ICMPv6 echo and ICMP4 echo is
         // the message type field, so we can reinterpret it as ICMP6
         // header and set the message type.
         SuccessOrExit(error = aMessage.Read(0, icmp6Header));
-        icmp6Header.SetType(Ip6::Icmp::Header::kTypeEchoReply);
+        icmp6Header.SetType(Ip6::Icmp6Header::kTypeEchoReply);
         icmp6Header.SetId(aOriginalId);
         aMessage.Write(0, icmp6Header);
         break;
@@ -655,9 +655,9 @@
 
 Error Translator::TranslateIcmp6(Message &aMessage, uint16_t aTranslatedId)
 {
-    Error             error = kErrorNone;
-    Ip4::Icmp::Header icmp4Header;
-    Ip6::Icmp::Header icmp6Header;
+    Error            error = kErrorNone;
+    Ip4::Icmp4Header icmp4Header;
+    Ip6::Icmp6Header icmp6Header;
 
     // TODO: Implement the translation of other ICMP messages.
 
@@ -667,12 +667,12 @@
 
     switch (icmp6Header.GetType())
     {
-    case Ip6::Icmp::Header::kTypeEchoRequest:
+    case Ip6::Icmp6Header::kTypeEchoRequest:
         // The only difference between ICMPv6 echo and ICMP4 echo is
-        // the message type field, so we can reinterpret it as ICMP6
+        // the message type field, so we can reinterpret it as ICMP4
         // header and set the message type.
         SuccessOrExit(error = aMessage.Read(0, icmp4Header));
-        icmp4Header.SetType(Ip4::Icmp::Header::Type::kTypeEchoRequest);
+        icmp4Header.SetType(Ip4::Icmp4Header::Type::kTypeEchoRequest);
         icmp4Header.SetId(aTranslatedId);
         aMessage.Write(0, icmp4Header);
         break;
diff --git a/src/core/net/nd6.cpp b/src/core/net/nd6.cpp
index 689138e..e2ff91e 100644
--- a/src/core/net/nd6.cpp
+++ b/src/core/net/nd6.cpp
@@ -96,7 +96,7 @@
     mPrefix       = AsCoreType(&aPrefix.mPrefix);
 }
 
-void PrefixInfoOption::GetPrefix(Prefix &aPrefix) const { aPrefix.Set(mPrefix.GetBytes(), mPrefixLength); }
+void PrefixInfoOption::GetPrefix(Prefix &aPrefix) const { aPrefix.InitFrom(mPrefix.GetBytes(), mPrefixLength); }
 
 bool PrefixInfoOption::IsValid(void) const
 {
@@ -131,7 +131,7 @@
     memcpy(GetPrefixBytes(), aPrefix.GetBytes(), aPrefix.GetBytesSize());
 }
 
-void RouteInfoOption::GetPrefix(Prefix &aPrefix) const { aPrefix.Set(GetPrefixBytes(), mPrefixLength); }
+void RouteInfoOption::GetPrefix(Prefix &aPrefix) const { aPrefix.InitFrom(GetPrefixBytes(), mPrefixLength); }
 
 bool RouteInfoOption::IsValid(void) const
 {
@@ -234,7 +234,7 @@
 
     VerifyOrExit(prefixLengthCode < GetArrayLength(kPrefixLengths), error = kErrorParse);
 
-    aPrefix.Set(mPrefixMsb, kPrefixLengths[prefixLengthCode]);
+    aPrefix.InitFrom(mPrefixMsb, kPrefixLengths[prefixLengthCode]);
 
 exit:
     return error;
@@ -275,7 +275,7 @@
     OT_UNUSED_VARIABLE(mRetransTimer);
 
     Clear();
-    mType = Icmp::Header::kTypeRouterAdvert;
+    mType = Icmp6Header::kTypeRouterAdvert;
 }
 
 RoutePreference RouterAdvert::Header::GetDefaultRouterPreference(void) const
@@ -443,7 +443,7 @@
 RouterSolicitHeader::RouterSolicitHeader(void)
 {
     mHeader.Clear();
-    mHeader.SetType(Icmp::Header::kTypeRouterSolicit);
+    mHeader.SetType(Icmp6Header::kTypeRouterSolicit);
 }
 
 //----------------------------------------------------------------------------------------------------------------------
@@ -455,7 +455,7 @@
     OT_UNUSED_VARIABLE(mReserved);
 
     Clear();
-    mType = Icmp::Header::kTypeNeighborSolicit;
+    mType = Icmp6Header::kTypeNeighborSolicit;
 }
 
 //----------------------------------------------------------------------------------------------------------------------
@@ -467,7 +467,7 @@
     OT_UNUSED_VARIABLE(mReserved);
 
     Clear();
-    mType = Icmp::Header::kTypeNeighborAdvert;
+    mType = Icmp6Header::kTypeNeighborAdvert;
 }
 
 } // namespace Nd
diff --git a/src/core/net/nd6.hpp b/src/core/net/nd6.hpp
index 819c8e2..65bdf05 100644
--- a/src/core/net/nd6.hpp
+++ b/src/core/net/nd6.hpp
@@ -832,7 +832,7 @@
          * @retval TRUE  The header is valid.
          * @retval FALSE The header is not valid.
          */
-        bool IsValid(void) const { return GetType() == Icmp::Header::kTypeRouterAdvert; }
+        bool IsValid(void) const { return GetType() == Icmp6Header::kTypeRouterAdvert; }
 
         /**
          * Sets the RA message to default values.
@@ -920,7 +920,7 @@
          *
          * @returns The ICMPv6 message type.
          */
-        Icmp::Header::Type GetType(void) const { return static_cast<Icmp::Header::Type>(mType); }
+        Icmp6Header::Type GetType(void) const { return static_cast<Icmp6Header::Type>(mType); }
 
     private:
         // Router Advertisement Message
@@ -989,7 +989,7 @@
         bool IsValid(void) const
         {
             return (mData.GetBytes() != nullptr) && (mData.GetLength() >= sizeof(Header)) &&
-                   (GetHeader().GetType() == Icmp::Header::kTypeRouterAdvert);
+                   (GetHeader().GetType() == Icmp6Header::kTypeRouterAdvert);
         }
 
         /**
@@ -1125,7 +1125,7 @@
     RouterSolicitHeader(void);
 
 private:
-    Icmp::Header mHeader; // The common ICMPv6 header.
+    Icmp6Header mHeader; // The common ICMPv6 header.
 } OT_TOOL_PACKED_END;
 
 static_assert(sizeof(RouterSolicitHeader) == 8, "invalid RouterSolicitHeader structure");
@@ -1147,7 +1147,7 @@
      * @retval TRUE  If the message header is valid.
      * @retval FALSE If the message header is not valid.
      */
-    bool IsValid(void) const { return (mType == Icmp::Header::kTypeNeighborSolicit) && (mCode == 0); }
+    bool IsValid(void) const { return (mType == Icmp6Header::kTypeNeighborSolicit) && (mCode == 0); }
 
     /**
      * Gets the Target Address field.
@@ -1208,7 +1208,7 @@
      * @retval TRUE  If the message is valid.
      * @retval FALSE If the message is not valid.
      */
-    bool IsValid(void) const { return (mType == Icmp::Header::kTypeNeighborAdvert) && (mCode == 0); }
+    bool IsValid(void) const { return (mType == Icmp6Header::kTypeNeighborAdvert) && (mCode == 0); }
 
     /**
      * Indicates whether or not the Router Flag is set in the NA message.
diff --git a/src/core/net/nd_agent.cpp b/src/core/net/nd_agent.cpp
index 2b0ced7..e683548 100644
--- a/src/core/net/nd_agent.cpp
+++ b/src/core/net/nd_agent.cpp
@@ -102,9 +102,8 @@
         {
             uint16_t aloc16 = Mle::Aloc16::FromNdAgentContextId(lowpanContext.GetContextId());
 
-            mAloc.InitAsThreadOrigin();
-            mAloc.GetAddress().SetToAnycastLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), aloc16);
-            mAloc.mMeshLocal = true;
+            mAloc.InitAsThreadOriginMeshLocal();
+            Get<Mle::Mle>().ComposeAloc(aloc16, mAloc.GetAddress());
             Get<ThreadNetif>().AddUnicastAddress(mAloc);
             ExitNow();
         }
diff --git a/src/core/net/netif.cpp b/src/core/net/netif.cpp
index c4a9f53..62d5915 100644
--- a/src/core/net/netif.cpp
+++ b/src/core/net/netif.cpp
@@ -613,11 +613,6 @@
 {
     Clear();
     mAddressOrigin = kOriginManual;
-
-#if OPENTHREAD_CONFIG_MLR_ENABLE
-    // Make sure `Clear()` sets the "MlrState" to `kMlrStateToRegister` value.
-    static_assert(kMlrStateToRegister == 0, "kMlrStateToRegister is not correct.");
-#endif
 }
 
 #if OPENTHREAD_CONFIG_MLR_ENABLE
@@ -627,11 +622,6 @@
     return (GetOrigin() == kOriginManual) && GetAddress().IsMulticastLargerThanRealmLocal();
 }
 
-bool Netif::MulticastAddress::Matches(MlrState aMlrState) const
-{
-    return IsMlrCandidate() && (GetMlrState() == aMlrState);
-}
-
 #endif
 
 } // namespace Ip6
diff --git a/src/core/net/netif.hpp b/src/core/net/netif.hpp
index d65e10e..5d2d76b 100644
--- a/src/core/net/netif.hpp
+++ b/src/core/net/netif.hpp
@@ -290,28 +290,19 @@
         bool IsMlrCandidate(void) const;
 
         /**
-         * Returns the current Multicast Listener Registration (MLR) state.
+         * Indicates whether the multicast address is registered via Multicast Listener Registration (MLR).
          *
-         * @returns The current Multicast Listener Registration state.
+         * @retval TRUE   If the multicast address is MLR registered.
+         * @retval FALSE  If the multicast address is not MLR registered.
          */
-        MlrState GetMlrState(void) const { return static_cast<MlrState>(mData); }
+        bool IsMlrRegistered(void) const { return (mData != 0); }
 
         /**
-         * Sets the Multicast Listener Registration (MLR) state.
+         * Sets whether the multicast address is registered via Multicast Listener Registration (MLR).
          *
-         * @param[in] aState  The new Multicast Listener Registration state.
+         * @param[in] aRegistered  TRUE if MLR registered, FALSE otherwise.
          */
-        void SetMlrState(MlrState aState) { mData = aState; }
-
-        /**
-         * Indicates whether or not the address is an MLR candidate and matches a given MLR state.
-         *
-         * @param[in] aMlrState  The MLR state to match against.
-         *
-         * @retval TRUE  If the address is an MLR candidate and its state matches @p aMlrState.
-         * @retval FALSE If the address is not an MLR candidate or its state does not match @p aMlrState.
-         */
-        bool Matches(MlrState aMlrState) const;
+        void SetMlrRegistered(bool aRegistered) { mData = aRegistered ? 1 : 0; }
 #endif
 
     private:
diff --git a/src/core/net/plat_tcp.cpp b/src/core/net/plat_tcp.cpp
new file mode 100644
index 0000000..b31a656
--- /dev/null
+++ b/src/core/net/plat_tcp.cpp
@@ -0,0 +1,693 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file implements platform TCP.
+ */
+
+#include "plat_tcp.hpp"
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+#include "instance/instance.hpp"
+
+namespace ot {
+namespace Ip6 {
+
+RegisterLogModule("PlatTcp");
+
+//---------------------------------------------------------------------------------------------------------------------
+// otPlatTcp functions (callbacks)
+
+extern "C" otPlatTcpConnection *otPlatTcpAccept(otPlatTcpListener *aListener, const otPlatTcpSockAddr *aPeerSockAddr)
+{
+    return AsCoreType(aListener).Accept(AsCoreType(aPeerSockAddr));
+}
+
+extern "C" bool otPlatTcpIsConnecting(otPlatTcpConnection *aConn)
+{
+    return AsCoreType(aConn).GetState() == PlatTcp::Connection::kStateConnecting;
+}
+
+extern "C" void otPlatTcpHandleConnected(otPlatTcpConnection *aConn) { AsCoreType(aConn).HandleConnected(); }
+
+extern "C" bool otPlatTcpIsTxPending(otPlatTcpConnection *aConn) { return AsCoreType(aConn).IsTxPending(); }
+
+extern "C" void otPlatTcpHandleTxReady(otPlatTcpConnection *aConn) { AsCoreType(aConn).HandleTxReady(); }
+
+extern "C" void otPlatTcpHandleReceive(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength)
+{
+    AsCoreType(aConn).HandleReceive(aBuffer, aLength);
+}
+
+extern "C" void otPlatTcpHandleDisconnected(otPlatTcpConnection *aConn, otPlatTcpDisconnectReason aReason)
+{
+    AsCoreType(aConn).HandleDisconnected(static_cast<PlatTcp::DisconnectReason>(aReason));
+}
+
+extern "C" otInstance *otPlatTcpGetInstanceForConnection(otPlatTcpConnection *aConn)
+{
+    return &AsCoreType(aConn).GetInstance();
+}
+
+extern "C" otInstance *otPlatTcpGetInstanceForListener(otPlatTcpListener *aListener)
+{
+    return &AsCoreType(aListener).GetInstance();
+}
+
+extern "C" otPlatTcpListener *otPlatTcpIterateListeners(otInstance *aInstance, otPlatTcpListener *aPrevListener)
+{
+    return AsCoreType(aInstance).Get<PlatTcp>().IterateListeners(AsCoreTypePtr(aPrevListener));
+}
+
+extern "C" otPlatTcpConnection *otPlatTcpIterateConnections(otInstance *aInstance, otPlatTcpConnection *aPrevConn)
+{
+    return AsCoreType(aInstance).Get<PlatTcp>().IterateConnections(AsCoreTypePtr(aPrevConn));
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// PlatTcp::SockAddr
+
+bool PlatTcp::SockAddr::IsAllUnspecified(void) const
+{
+    return (GetPort() == 0) && (mIfIndex == 0) && GetAddress().IsUnspecified();
+}
+
+PlatTcp::SockAddr::InfoString PlatTcp::SockAddr::ToString(void) const
+{
+    InfoString string;
+
+    string.Append("[");
+    GetAddress().ToString(string);
+    string.Append("%%%lu]:%u", ToUlong(GetIfIndex()), GetPort());
+
+    return string;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// PlatTcp::Listener
+
+PlatTcp::Listener::Listener(Instance &aInstance, AcceptHandler aAcceptHandler, DeleteHandler aDeleteHandler)
+    : InstanceLocator(aInstance)
+    , mNext(nullptr)
+    , mState(kStateUnused)
+    , mAcceptHandler(aAcceptHandler)
+    , mDeleteHandler(aDeleteHandler)
+{
+    ClearAllBytes(mData);
+}
+
+Error PlatTcp::Listener::Enable(const SockAddr &aLocalSockAddr)
+{
+    Error error = kErrorNone;
+
+    switch (GetState())
+    {
+    case kStateUnused:
+        Get<PlatTcp>().AddListener(*this);
+        break;
+    case kStateDisabled:
+        break;
+    case kStateEnabled:
+        error = kErrorAlready;
+        ExitNow();
+    }
+
+    ClearAllBytes(mData);
+    mLocalSockAddr = aLocalSockAddr;
+
+    SetState(kStateEnabled);
+
+    error = otPlatTcpEnableListener(this, &mLocalSockAddr);
+
+    if (error != kErrorNone)
+    {
+        SetState(kStateDisabled);
+    }
+
+exit:
+    return error;
+}
+
+void PlatTcp::Listener::Disable(void)
+{
+    VerifyOrExit(GetState() == kStateEnabled);
+
+    SetState(kStateDisabled);
+
+    otPlatTcpDisableListener(this);
+
+exit:
+    return;
+}
+
+void PlatTcp::Listener::SetState(State aState)
+{
+    VerifyOrExit(mState != aState);
+
+    mState = aState;
+
+    switch (mState)
+    {
+    case kStateUnused:
+        VerifyOrExit(mDeleteHandler != nullptr);
+        mDeleteHandler(*this);
+        break;
+
+    case kStateEnabled:
+        break;
+
+    case kStateDisabled:
+        Get<PlatTcp>().PostListenerTask();
+        break;
+    }
+
+exit:
+    return;
+}
+
+PlatTcp::Connection *PlatTcp::Listener::Accept(const SockAddr &aPeerSockAddr)
+{
+    Connection *connection = nullptr;
+
+    VerifyOrExit(GetState() == kStateEnabled);
+
+    VerifyOrExit(mAcceptHandler != nullptr);
+
+    connection = mAcceptHandler(*this, aPeerSockAddr);
+    VerifyOrExit(connection != nullptr);
+
+    if (connection->Prepare(mLocalSockAddr, aPeerSockAddr) != kErrorNone)
+    {
+        connection = nullptr;
+    }
+
+exit:
+    return connection;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// PlatTcp::Connection
+
+PlatTcp::Connection::Connection(Instance &aInstance, EventHandler aEventHandler, DeleteHandler aDeleteHandler)
+    : InstanceLocator(aInstance)
+    , mNext(nullptr)
+    , mState(kStateUnused)
+    , mDisconnectReason(kDisconnectReasonClosed)
+    , mRxMessage(nullptr)
+    , mEventHandler(aEventHandler)
+    , mDeleteHandler(aDeleteHandler)
+{
+    ClearAllBytes(mData);
+}
+
+void PlatTcp::Connection::SetState(State aState)
+{
+    VerifyOrExit(mState != aState);
+
+    mState = aState;
+
+    switch (mState)
+    {
+    case kStateUnused:
+        FreeRxMessage();
+        mTxQueue.DequeueAndFreeAll();
+        VerifyOrExit(mDeleteHandler != nullptr);
+        mDeleteHandler(*this);
+        break;
+
+    case kStateConnecting:
+    case kStateConnected:
+    case kStateToClose:
+    case kStateClosing:
+        break;
+
+    case kStateDisconnected:
+        mTxQueue.DequeueAndFreeAll();
+        Get<PlatTcp>().PostConnectionTask();
+        break;
+    }
+
+exit:
+    return;
+}
+
+Error PlatTcp::Connection::Prepare(const SockAddr &aLocalSockAddr, const SockAddr &aPeerSockAddr)
+{
+    Error error = kErrorNone;
+
+    switch (GetState())
+    {
+    case kStateUnused:
+        Get<PlatTcp>().AddConnection(*this);
+        break;
+    case kStateDisconnected:
+        break;
+
+    case kStateConnecting:
+    case kStateConnected:
+    case kStateToClose:
+    case kStateClosing:
+        error = kErrorInvalidState;
+        ExitNow();
+    }
+
+    ClearAllBytes(mData);
+    FreeRxMessage();
+    mLocalSockAddr = aLocalSockAddr;
+    mPeerSockAddr  = aPeerSockAddr;
+
+    SetState(kStateConnecting);
+
+exit:
+    return error;
+}
+
+Error PlatTcp::Connection::Connect(const SockAddr &aPeerSockAddr) { return BindAndConnect(SockAddr(), aPeerSockAddr); }
+
+Error PlatTcp::Connection::BindAndConnect(const SockAddr &aLocalSockAddr, const SockAddr &aPeerSockAddr)
+{
+    Error error;
+
+    SuccessOrExit(error = Prepare(aLocalSockAddr, aPeerSockAddr));
+
+    error = otPlatTcpConnect(this, &mPeerSockAddr, mLocalSockAddr.IsAllUnspecified() ? nullptr : &mLocalSockAddr);
+
+    if (error != kErrorNone)
+    {
+        SetState(kStateDisconnected);
+    }
+
+exit:
+    return error;
+}
+
+void PlatTcp::Connection::HandleConnected(void)
+{
+    VerifyOrExit(GetState() == kStateConnecting);
+
+    SetState(kStateConnected);
+    SignalEvent(kEventConnected);
+
+exit:
+    return;
+}
+
+Error PlatTcp::Connection::Send(OwnedPtr<Message> aMessage)
+{
+    Error error = kErrorNone;
+    bool  shouldNotify;
+
+    VerifyOrExit(GetState() == kStateConnected, error = kErrorInvalidState);
+
+    VerifyOrExit(aMessage != nullptr);
+    VerifyOrExit(aMessage->GetLength() > 0);
+
+    aMessage->SetOffset(0);
+
+    shouldNotify = mTxQueue.IsEmpty();
+    mTxQueue.Enqueue(*aMessage.Release());
+
+    if (shouldNotify)
+    {
+        otPlatTcpNotifyTxPending(this);
+    }
+
+exit:
+    return error;
+}
+
+void PlatTcp::Connection::HandleTxReady(void)
+{
+    switch (GetState())
+    {
+    case kStateConnected:
+    case kStateToClose:
+        break;
+    case kStateUnused:
+    case kStateConnecting:
+    case kStateClosing:
+    case kStateDisconnected:
+        ExitNow();
+    }
+
+    while (!mTxQueue.IsEmpty())
+    {
+        Message       &message         = *mTxQueue.GetHead();
+        uint16_t       remainingLength = message.DetermineLengthAfterOffset();
+        uint16_t       bytesSent;
+        Message::Chunk chunk;
+
+        message.GetFirstChunk(message.GetOffset(), remainingLength, chunk);
+
+        while (chunk.GetLength() > 0)
+        {
+            bytesSent = otPlatTcpSend(this, chunk.GetBytes(), chunk.GetLength());
+
+            message.SetOffset(message.GetOffset() + bytesSent);
+
+            if (bytesSent < chunk.GetLength())
+            {
+                otPlatTcpNotifyTxPending(this);
+                ExitNow();
+            }
+
+            message.GetNextChunk(remainingLength, chunk);
+        }
+
+        mTxQueue.DequeueAndFree(message);
+    }
+
+    SignalEvent(kEventSendDone);
+
+    if (GetState() == kStateToClose)
+    {
+        Close();
+    }
+
+exit:
+    return;
+}
+
+void PlatTcp::Connection::HandleReceive(const uint8_t *aBuffer, uint16_t aLength)
+{
+    Error error = kErrorNone;
+
+    VerifyOrExit(aLength > 0);
+
+    switch (GetState())
+    {
+    case kStateConnecting:
+    case kStateConnected:
+    case kStateToClose:
+    case kStateClosing:
+        break;
+    case kStateUnused:
+    case kStateDisconnected:
+        ExitNow();
+    }
+
+    if (mRxMessage == nullptr)
+    {
+        mRxMessage = Get<MessagePool>().Allocate(Message::kTypeOther);
+        VerifyOrExit(mRxMessage != nullptr, error = kErrorNoBufs);
+    }
+
+    mRxMessage->SetOffset(mRxMessage->GetLength());
+
+    SuccessOrExit(error = mRxMessage->AppendBytes(aBuffer, aLength));
+
+    SignalEvent(kEventReceive);
+
+exit:
+    if (error != kErrorNone)
+    {
+        Abort();
+        mDisconnectReason = kDisconnectReasonNoBufs;
+        SignalEvent(kEventDisconnected);
+    }
+}
+
+void PlatTcp::Connection::RemoveParsedLengthFromRxMessage(uint16_t aRemoveLength)
+{
+    uint16_t newLength = 0;
+    uint16_t newOffset = 0;
+
+    VerifyOrExit(mRxMessage != nullptr);
+
+    if (mRxMessage->GetLength() > aRemoveLength)
+    {
+        newLength = mRxMessage->GetLength() - aRemoveLength;
+        newOffset = (mRxMessage->GetOffset() > aRemoveLength) ? mRxMessage->GetOffset() - aRemoveLength : 0;
+
+        mRxMessage->WriteBytesFromMessage(/* aWriteOffset */ 0, *mRxMessage, /* aReadOffset */ aRemoveLength,
+                                          newLength);
+    }
+
+    IgnoreError(mRxMessage->SetLength(newLength));
+    mRxMessage->SetOffset(newOffset);
+
+exit:
+    return;
+}
+
+void PlatTcp::Connection::FreeRxMessage(void)
+{
+    VerifyOrExit(mRxMessage != nullptr);
+
+    mRxMessage->Free();
+    mRxMessage = nullptr;
+
+exit:
+    return;
+}
+
+void PlatTcp::Connection::Close(void)
+{
+    switch (GetState())
+    {
+    case kStateConnecting:
+    case kStateConnected:
+    case kStateToClose:
+        break;
+
+    case kStateUnused:
+    case kStateDisconnected:
+    case kStateClosing:
+        ExitNow();
+    }
+
+    if (!mTxQueue.IsEmpty())
+    {
+        SetState(kStateToClose);
+    }
+    else
+    {
+        SetState(kStateClosing);
+        otPlatTcpClose(this);
+    }
+
+exit:
+    return;
+}
+
+void PlatTcp::Connection::Abort(void)
+{
+    switch (GetState())
+    {
+    case kStateConnecting:
+    case kStateConnected:
+    case kStateToClose:
+    case kStateClosing:
+        break;
+
+    case kStateUnused:
+    case kStateDisconnected:
+        ExitNow();
+    }
+
+    mDisconnectReason = kDisconnectReasonAbort;
+    SetState(kStateDisconnected);
+
+    otPlatTcpAbort(this);
+
+exit:
+    return;
+}
+
+void PlatTcp::Connection::HandleDisconnected(DisconnectReason aReason)
+{
+    switch (GetState())
+    {
+    case kStateConnecting:
+    case kStateConnected:
+    case kStateToClose:
+    case kStateClosing:
+        break;
+
+    case kStateUnused:
+    case kStateDisconnected:
+        ExitNow();
+    }
+
+    mDisconnectReason = aReason;
+    SetState(kStateDisconnected);
+
+    SignalEvent(kEventDisconnected);
+
+exit:
+    return;
+}
+
+void PlatTcp::Connection::SignalEvent(Event aEvent)
+{
+    VerifyOrExit(mEventHandler != nullptr);
+    mEventHandler(*this, aEvent);
+
+exit:
+    return;
+}
+
+const char *PlatTcp::Connection::EventToString(Event aEvent)
+{
+#define ConnEventMapList(_)               \
+    _(kEventConnected, "Connected")       \
+    _(kEventDisconnected, "Disconnected") \
+    _(kEventSendDone, "SendDone")         \
+    _(kEventReceive, "Receive")
+
+    DefineEnumStringArray(ConnEventMapList);
+
+    return kStrings[aEvent];
+}
+
+const char *PlatTcp::Connection::DisconnectReasonToString(DisconnectReason aDisconnectReason)
+{
+#define ConnDisconnectReasonMapList(_)     \
+    _(kDisconnectReasonClosed, "Closed")   \
+    _(kDisconnectReasonTimeout, "Timeout") \
+    _(kDisconnectReasonRefused, "Refused") \
+    _(kDisconnectReasonReset, "Reset")     \
+    _(kDisconnectReasonError, "Error")     \
+    _(kDisconnectReasonAbort, "Abort")     \
+    _(kDisconnectReasonNoBufs, "NoBufs")
+
+    DefineEnumStringArray(ConnDisconnectReasonMapList);
+
+    return kStrings[aDisconnectReason];
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// PlatTcp
+
+PlatTcp::PlatTcp(Instance &aInstance)
+    : InstanceLocator(aInstance)
+    , mListenerTask(aInstance)
+    , mConnectionTask(aInstance)
+{
+}
+
+PlatTcp::~PlatTcp(void)
+{
+    for (Listener &listener : mListeners)
+    {
+        listener.Disable();
+    }
+
+    for (Connection &connection : mConnections)
+    {
+        connection.Abort();
+    }
+
+    HandleListenerTask();
+    HandleConnectionTask();
+
+    mListenerTask.Unpost();
+    mConnectionTask.Unpost();
+}
+
+void PlatTcp::HandleListenerTask(void)
+{
+    LinkedList<Listener> disabledListeners;
+    Listener            *listener;
+
+    mListeners.RemoveAllMatching(disabledListeners, Listener::kStateDisabled);
+
+    while ((listener = disabledListeners.Pop()) != nullptr)
+    {
+        listener->mNext = nullptr;
+        listener->SetState(Listener::kStateUnused);
+    }
+}
+
+void PlatTcp::HandleConnectionTask(void)
+{
+    LinkedList<Connection> disconnectedConnections;
+    Connection            *connection;
+
+    mConnections.RemoveAllMatching(disconnectedConnections, Connection::kStateDisconnected);
+
+    while ((connection = disconnectedConnections.Pop()) != nullptr)
+    {
+        connection->mNext = nullptr;
+        connection->SetState(Connection::kStateUnused);
+    }
+}
+
+PlatTcp::Listener *PlatTcp::IterateListeners(Listener *aPrev)
+{
+    Listener *listener = (aPrev == nullptr) ? mListeners.GetHead() : aPrev->GetNext();
+
+    while (listener != nullptr)
+    {
+        switch (listener->GetState())
+        {
+        case Listener::kStateUnused:
+        case Listener::kStateDisabled:
+            break;
+        case Listener::kStateEnabled:
+            ExitNow();
+        }
+
+        listener = listener->GetNext();
+    }
+
+exit:
+    return listener;
+}
+
+PlatTcp::Connection *PlatTcp::IterateConnections(Connection *aPrev)
+{
+    Connection *connection = (aPrev == nullptr) ? mConnections.GetHead() : aPrev->GetNext();
+
+    while (connection != nullptr)
+    {
+        switch (connection->GetState())
+        {
+        case Connection::kStateUnused:
+        case Connection::kStateDisconnected:
+            break;
+        case Connection::kStateConnecting:
+        case Connection::kStateConnected:
+        case Connection::kStateToClose:
+        case Connection::kStateClosing:
+            ExitNow();
+        }
+
+        connection = connection->GetNext();
+    }
+
+exit:
+    return connection;
+}
+
+} // namespace Ip6
+} // namespace ot
+
+#endif // OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
diff --git a/src/core/net/plat_tcp.hpp b/src/core/net/plat_tcp.hpp
new file mode 100644
index 0000000..0582e05
--- /dev/null
+++ b/src/core/net/plat_tcp.hpp
@@ -0,0 +1,624 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file includes definitions for platform TCP.
+ */
+
+#ifndef OT_CORE_NET_PLAT_TCP_HPP_
+#define OT_CORE_NET_PLAT_TCP_HPP_
+
+#include "openthread-core-config.h"
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+#include <openthread/platform/tcp.h>
+
+#include "common/as_core_type.hpp"
+#include "common/clearable.hpp"
+#include "common/linked_list.hpp"
+#include "common/locator.hpp"
+#include "common/message.hpp"
+#include "common/non_copyable.hpp"
+#include "common/owned_ptr.hpp"
+#include "common/tasklet.hpp"
+#include "net/ip6_address.hpp"
+#include "net/socket.hpp"
+
+namespace ot {
+namespace Ip6 {
+
+/**
+ * @addtogroup core-tcp
+ *
+ * @brief
+ *   This module includes definitions for platform TCP manager.
+ *
+ * @{
+ */
+
+extern "C" otPlatTcpConnection *otPlatTcpAccept(otPlatTcpListener *aListener, const otPlatTcpSockAddr *aPeerSockAddr);
+
+extern "C" void otPlatTcpHandleConnected(otPlatTcpConnection *aConn);
+extern "C" void otPlatTcpHandleTxReady(otPlatTcpConnection *aConn);
+extern "C" void otPlatTcpHandleReceive(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength);
+extern "C" void otPlatTcpHandleDisconnected(otPlatTcpConnection *aConn, otPlatTcpDisconnectReason aReason);
+
+/**
+ * Represents the platform TCP manager.
+ */
+class PlatTcp : public InstanceLocator, private NonCopyable
+{
+public:
+    class Connection;
+
+    /**
+     * Defines the reason for a TCP connection disconnection.
+     */
+    enum DisconnectReason : uint8_t
+    {
+        kDisconnectReasonClosed  = OT_PLAT_TCP_DISCONNECT_REASON_CLOSED,  ///< Closed
+        kDisconnectReasonTimeout = OT_PLAT_TCP_DISCONNECT_REASON_TIMEOUT, ///< Timed out
+        kDisconnectReasonRefused = OT_PLAT_TCP_DISCONNECT_REASON_REFUSED, ///< Refused by the peer
+        kDisconnectReasonReset   = OT_PLAT_TCP_DISCONNECT_REASON_RESET,   ///< Reset by the peer
+        kDisconnectReasonError   = OT_PLAT_TCP_DISCONNECT_REASON_ERROR,   ///< Other errors
+        kDisconnectReasonAbort,                                           ///< Aborted
+        kDisconnectReasonNoBufs,                                          ///< Buffer allocation fail.
+    };
+
+    /**
+     * Represents a TCP socket address.
+     */
+    class SockAddr : public otPlatTcpSockAddr, public Clearable<SockAddr>, public Equatable<SockAddr>
+    {
+    public:
+        static constexpr uint16_t kInfoStringSize = 80; ///< String size used by `ToString()`
+
+        /**
+         * Defines the fixed-length `String` object returned from `ToString()`.
+         */
+        typedef String<kInfoStringSize> InfoString;
+
+        /**
+         * Initializes the socket address (all fields are cleared).
+         */
+        SockAddr(void) { Clear(); }
+
+        /**
+         * Gets the port number.
+         *
+         * @returns The port number.
+         */
+        uint16_t GetPort(void) const { return mSockAddr.mPort; }
+
+        /**
+         * Gets the IP address.
+         *
+         * @returns The IP address.
+         */
+        const Address &GetAddress(void) const { return AsCoreType(&mSockAddr.mAddress); }
+
+        /**
+         * Gets the interface index.
+         *
+         * @returns The interface index.
+         */
+        uint32_t GetIfIndex(void) const { return mIfIndex; }
+
+        /**
+         * Gets the IP address.
+         *
+         * @returns A reference to the IP address.
+         */
+        Address &GetAddress(void) { return AsCoreType(&mSockAddr.mAddress); }
+
+        /**
+         * Sets the IP address.
+         *
+         * @param[in] aAddress  The IP address.
+         */
+        void SetAddress(const Address &aAddress) { mSockAddr.mAddress = aAddress; }
+
+        /**
+         * Sets the port number.
+         *
+         * @param[in] aPort  The port number.
+         */
+        void SetPort(uint16_t aPort) { mSockAddr.mPort = aPort; }
+
+        /**
+         * Sets the interface index.
+         *
+         * @param[in] aIfIndex  The interface index.
+         */
+        void SetIfIndex(uint32_t aIfIndex) { mIfIndex = aIfIndex; }
+
+        /**
+         * Indicates whether all fields are unspecified (zero).
+         *
+         * @retval TRUE   All fields are unspecified.
+         * @retval FALSE  Not all fields are unspecified.
+         */
+        bool IsAllUnspecified(void) const;
+
+        /**
+         * Converts the SockAddr to human-readbale string
+         *
+         * The string is formatted as "[<ipv6-address>%<if-index>]:<port>".
+         *
+         * @returns An `InfoString` representation of the SockAddr
+         */
+        InfoString ToString(void) const;
+    };
+
+    /**
+     * Represents a TCP listener.
+     */
+    class Listener : public otPlatTcpListener,
+                     public InstanceLocator,
+                     public LinkedListEntry<Listener>,
+                     private NonCopyable
+    {
+        friend class PlatTcp;
+        friend class LinkedList<Listener>;
+        friend class LinkedListEntry<Listener>;
+        friend otPlatTcpConnection *otPlatTcpAccept(otPlatTcpListener       *aListener,
+                                                    const otPlatTcpSockAddr *aPeerSockAddr);
+
+    public:
+        /**
+         * Defines the state of the listener.
+         */
+        enum State : uint8_t
+        {
+            kStateUnused,   ///< Listener is unused and not tracked by `PlatTcp`.
+            kStateEnabled,  ///< Listener is enabled.
+            kStateDisabled, ///< Listener is disabled.
+        };
+
+        /**
+         * Defines the accept handler callback.
+         *
+         * This callback is invoked when an incoming connection request is received by the listener.
+         *
+         * To accept the connection, the handler should return a pointer to a `Connection` instance. The ownership
+         * of this `Connection` is then transferred to the `PlatTcp` module. To reject the incoming connection
+         * request, the handler should return `nullptr`.
+         *
+         * If the returned `Connection` instance is in the unused or disconnected state, it will be used to accept
+         * the request. If the instance is already in-use, the connection request is automatically rejected. This
+         * behavior simplifies the callback implementation. For example, an implementation managing a single
+         * `Connection` instance can simply return it, knowing it will be safely used only if it is available.
+         *
+         * @param[in] aListener      The listener receiving the connection request.
+         * @param[in] aPeerSockAddr  The peer's socket address.
+         *
+         * @returns A pointer to a `Connection` to accept the request, or `nullptr` to reject it.
+         */
+        typedef Connection *(*AcceptHandler)(Listener &aListener, const SockAddr &aPeerSockAddr);
+
+        /**
+         * Defines the delete handler callback.
+         *
+         * This callback notifies the caller that the listener instance is no longer being tracked or used by the
+         * `PlatTcp` module and can be safely reclaimed or deallocated.
+         *
+         * When a listener is enabled (via `Enable()`), its ownership is effectively transferred to the `PlatTcp`
+         * module. Once it is disabled and all internal cleanup is complete, the `PlatTcp` module invokes this
+         * callback to return ownership to the caller.
+         */
+        typedef void (*DeleteHandler)(Listener &aListener);
+
+        /**
+         * Initializes a new listener.
+         *
+         * @param[in] aInstance       The OpenThread instance.
+         * @param[in] aAcceptHandler  The accept handler callback. Can be `nullptr` if not needed.
+         * @param[in] aDeleteHandler  The delete handler callback. Can be `nullptr` if not needed.
+         */
+        Listener(Instance &aInstance, AcceptHandler aAcceptHandler, DeleteHandler aDeleteHandler);
+
+        /**
+         * Gets the current state of the listener.
+         *
+         * @returns The state of the listener.
+         */
+        State GetState(void) const { return mState; }
+
+        /**
+         * Enables the listener.
+         *
+         * Calling this method transfers the ownership of the `Listener` instance to the `PlatTcp` module,
+         * which will track and manage it. The caller must not reclaim or deallocate the instance until the
+         * `DeleteHandler` callback is invoked, regardless of the error returned by this method.
+         *
+         * @param[in] aLocalSockAddr  The local socket address to listen on.
+         *
+         * @retval kErrorNone     Successfully enabled the listener.
+         * @retval kErrorAlready  Already listening on the same port/address.
+         * @retval kErrorFailed   Failed to enable the listener.
+         */
+        Error Enable(const SockAddr &aLocalSockAddr);
+
+        /**
+         * Disables the listener.
+         */
+        void Disable(void);
+
+    private:
+        bool        Matches(State aState) const { return mState == aState; }
+        void        SetState(State aState);
+        Connection *Accept(const SockAddr &aPeerSockAddr);
+
+        Listener     *mNext;
+        State         mState;
+        SockAddr      mLocalSockAddr;
+        AcceptHandler mAcceptHandler;
+        DeleteHandler mDeleteHandler;
+    };
+
+    /**
+     * Represents a TCP connection.
+     */
+    class Connection : public otPlatTcpConnection,
+                       public InstanceLocator,
+                       public LinkedListEntry<Connection>,
+                       private NonCopyable
+    {
+        friend class PlatTcp;
+        friend class LinkedList<Connection>;
+        friend class LinkedListEntry<Connection>;
+
+        friend void otPlatTcpHandleConnected(otPlatTcpConnection *aConn);
+        friend void otPlatTcpHandleTxReady(otPlatTcpConnection *aConn);
+        friend void otPlatTcpHandleReceive(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength);
+        friend void otPlatTcpHandleDisconnected(otPlatTcpConnection *aConn, otPlatTcpDisconnectReason aReason);
+
+    public:
+        /**
+         * Defines the state of the connection.
+         */
+        enum State : uint8_t
+        {
+            kStateUnused,       ///< Unused and not tracked by `PlatTcp`.
+            kStateConnecting,   ///< Connecting.
+            kStateConnected,    ///< Connected.
+            kStateToClose,      ///< To close (after sending queued message).
+            kStateClosing,      ///< Closing.
+            kStateDisconnected, ///< Disconnected.
+        };
+
+        /**
+         * Represents connection events.
+         */
+        enum Event : uint8_t
+        {
+            kEventConnected,    ///< Connection established.
+            kEventDisconnected, ///< Connection disconnected or failed to connect.
+            kEventSendDone,     ///< TX queue is empty.
+            kEventReceive,      ///< Data was received and is available to read.
+        };
+
+        /**
+         * Defines the event handler callback.
+         */
+        typedef void (*EventHandler)(Connection &aConnection, Event aEvent);
+
+        /**
+         * Defines the delete handler callback.
+         *
+         * This callback notifies the caller that the connection instance is no longer being tracked or used by the
+         * `PlatTcp` module and can be safely reclaimed or deallocated.
+         *
+         * When a connection is initiated (via `Connect()` or `BindAndConnect()`), or when an incoming connection is
+         * accepted, its ownership is effectively transferred to the `PlatTcp` module. Once the connection is fully
+         * disconnected or aborted and all internal cleanup is complete, the `PlatTcp` module invokes this callback
+         * to return ownership to the caller.
+         */
+        typedef void (*DeleteHandler)(Connection &aConnection);
+
+        /**
+         * Initializes a new connection.
+         *
+         * @param[in] aInstance       The OpenThread instance.
+         * @param[in] aEventHandler   The event handler callback. Can be `nullptr` if not needed.
+         * @param[in] aDeleteHandler  The delete handler callback. Can be `nullptr` if not needed.
+         */
+        Connection(Instance &aInstance, EventHandler aEventHandler, DeleteHandler aDeleteHandler);
+
+        /**
+         * Gets the current state of the connection.
+         *
+         * @returns The state of the connection.
+         */
+        State GetState(void) const { return mState; }
+
+        /**
+         * Initiates a TCP connection to a peer.
+         *
+         * Calling this method transfers the ownership of the `Connection` instance to the `PlatTcp` module,
+         * which will track and manage it. The caller must not reclaim or deallocate the instance until the
+         * `DeleteHandler` callback is invoked, regardless of the error returned by this method.
+         *
+         * If the connection is successfully initiated, the `EventHandler` callback will be invoked later with
+         * `kEventConnected` when the connection is established, or `kEventDisconnected` if it fails to connect.
+         *
+         * @param[in] aPeerSockAddr  The peer's socket address.
+         *
+         * @retval kErrorNone         Successfully initiated the connection.
+         * @retval kErrorInvalidState The connection is not in a valid state to initiate a connection.
+         * @retval kErrorFailed       Failed to initiate the connection.
+         */
+        Error Connect(const SockAddr &aPeerSockAddr);
+
+        /**
+         * Initiates a TCP connection to a peer, specifying the local address.
+         *
+         * Calling this method transfers the ownership of the `Connection` instance to the `PlatTcp` module,
+         * which will track and manage it. The caller must not reclaim or deallocate the instance until the
+         * `DeleteHandler` callback is invoked, regardless of the error returned by this method.
+         *
+         * If the connection is successfully initiated, the `EventHandler` callback will be invoked later with
+         * `kEventConnected` when the connection is established, or `kEventDisconnected` if it fails to connect.
+         *
+         * The `aLocalSockAddr` specifies the local address to bind to. If the port or address in `aLocalSockAddr`
+         * are left unspecified, the platform will dynamically select the appropriate local port and/or address.
+         *
+         * @param[in] aLocalSockAddr The local socket address.
+         * @param[in] aPeerSockAddr  The peer's socket address.
+         *
+         * @retval kErrorNone         Successfully initiated the connection.
+         * @retval kErrorInvalidState The connection is not in a valid state to initiate a connection.
+         * @retval kErrorFailed       Failed to initiate the connection.
+         */
+        Error BindAndConnect(const SockAddr &aLocalSockAddr, const SockAddr &aPeerSockAddr);
+
+        /**
+         * Sends data over the TCP connection.
+         *
+         * This method enqueues the provided @p aMessage into the connection's transmit queue. It should be called
+         * when the connection is in the `kStateConnected` state.
+         *
+         * The `Connection` implementation handles passing this data to the platform by notifying it of pending
+         * data and providing the data in chunks whenever the platform indicates readiness for transmission.
+         *
+         * Ownership of the @p aMessage is always transferred to this method, regardless of the error returned.
+         *
+         * To manage flow control, the `EventHandler` callback will be invoked with the `kEventSendDone` event once
+         * all previously queued data (from this and all prior `Send()` calls) has been successfully passed to the
+         * platform for transmission. This event is not per-message but serves as a stream-level notification that
+         * the connection's transmit queue is now empty. Higher layers can use this event to pace traffic generation.
+         *
+         * @param[in] aMessage       The message containing the data to send. The ownership is transferred.
+         *
+         * @retval kErrorNone         Successfully queued the data for transmission.
+         * @retval kErrorInvalidState Connection is not in connected state.
+         */
+        Error Send(OwnedPtr<Message> aMessage);
+
+        /**
+         * Indicates whether there is pending data for transmission.
+         *
+         * @retval TRUE   There is pending transmit data.
+         * @retval FALSE  There is no pending transmit data.
+         */
+        bool IsTxPending(void) const { return !mTxQueue.IsEmpty(); }
+
+        /**
+         * Gets the received message.
+         *
+         * This message contains all the data received over the connection that has not yet been removed or freed.
+         * It may include both newly arrived data and previously received data that has not yet been parsed by the
+         * caller. The `GetOffset()` of the message points to the beginning of the latest received chunk of data.
+         *
+         * Callers are notified of new received data via the `kEventReceive` event. Upon receiving this event, they can
+         * use this method to access the received message, parse the desired data from it, and then use
+         * `RemoveParsedLengthFromRxMessage()` to remove the processed portion while keeping any unparsed data in the
+         * `RxMessage`. Alternatively, `FreeRxMessage()` can be used to clear all received content.
+         *
+         * @returns A pointer to the received message, or `nullptr` if there is no received data.
+         */
+        const Message *GetRxMessage(void) const { return mRxMessage; }
+
+        /**
+         * Removes a specified number of parsed bytes from the received message.
+         *
+         * This method should be called after parsing/processing data from the received message to free up the
+         * buffer space. It adjusts the message length accordingly.
+         *
+         * It is recommended to parse all available information from `GetRxMessage()` first and then call this method
+         * once with the total number of bytes parsed. Since this method can involve data movement operations,
+         * batching the removal of parsed data is more efficient than calling it for each small parsed piece.
+         *
+         * @param[in] aRemoveLength  The number of bytes to remove from the beginning of `GetRxMessage()`.
+         */
+        void RemoveParsedLengthFromRxMessage(uint16_t aRemoveLength);
+
+        /**
+         * Frees the received message and clears all previously received data.
+         *
+         * This method is useful to discard all received data and free the associated `Message` instance.
+         *
+         * Calling this method invalidates the `Message` pointer previously obtained from `GetRxMessage()`, and it must
+         * no longer be used.
+         */
+        void FreeRxMessage(void);
+
+        /**
+         * Gracefully closes the TCP connection.
+         *
+         * This method can be called even if there are still pending messages to be sent. The implementation
+         * ensures that all previously queued transmit data is passed to the platform for transmission before
+         * initiating the connection closure.
+         *
+         * If the connection is already in the process of closing or is disconnected, this call is ignored.
+         *
+         * Once the connection is fully disconnected (or if an error occurs during the close operation),
+         * the `EventHandler` callback will be invoked with the `kEventDisconnected` event.
+         */
+        void Close(void);
+
+        /**
+         * Aborts the TCP connection.
+         *
+         * This forceful termination discards any unsent data. After this call, the connection is immediately moved
+         * to the disconnected state and no further events (including `kEventDisconnected`) will be emitted to
+         * the `EventHandler` callback.
+         */
+        void Abort(void);
+
+        /**
+         * Gets the disconnect reason.
+         *
+         * @returns The disconnect reason.
+         */
+        DisconnectReason GetDisconnectReason(void) const { return mDisconnectReason; }
+
+        /**
+         * Converts a connection event to a string.
+         *
+         * @param[in] aEvent  A connection event.
+         *
+         * @returns A string representation of @p aEvent.
+         */
+        static const char *EventToString(Event aEvent);
+
+        /**
+         * Converts a `DisconnectReason` to a human-readable string.
+         *
+         * @param[in] aReason  The disconnect reason.
+         *
+         * @returns A string representation of @p aReason.
+         */
+        static const char *DisconnectReasonToString(DisconnectReason aReason);
+
+    private:
+        bool  Matches(State aState) const { return mState == aState; }
+        void  SetState(State aState);
+        Error Prepare(const SockAddr &aLocalSockAddr, const SockAddr &aPeerSockAddr);
+        void  HandleConnected(void);
+        void  HandleTxReady(void);
+        void  HandleReceive(const uint8_t *aBuffer, uint16_t aLength);
+        void  HandleDisconnected(DisconnectReason aReason);
+        void  SignalEvent(Event aEvent);
+
+        Connection      *mNext;
+        State            mState;
+        DisconnectReason mDisconnectReason;
+        SockAddr         mLocalSockAddr;
+        SockAddr         mPeerSockAddr;
+        Message         *mRxMessage;
+        MessageQueue     mTxQueue;
+        EventHandler     mEventHandler;
+        DeleteHandler    mDeleteHandler;
+    };
+
+    /**
+     * Initializes the platform TCP manager.
+     *
+     * @param[in] aInstance  The OpenThread instance.
+     */
+    explicit PlatTcp(Instance &aInstance);
+
+    /**
+     * Destructor for the platform TCP manager.
+     *
+     * Disables all active listeners and aborts all active connections upon destruction.
+     */
+    ~PlatTcp(void);
+
+    /**
+     * Returns the list of active TCP listeners.
+     *
+     * @returns A reference to the list of active TCP listeners.
+     */
+    LinkedList<Listener> &GetListeners(void) { return mListeners; }
+
+    /**
+     * Returns the list of active TCP connections.
+     *
+     * @returns A reference to the list of active TCP connections.
+     */
+    LinkedList<Connection> &GetConnections(void) { return mConnections; }
+
+    /**
+     * Iterates over the active TCP listeners.
+     *
+     * This method iterates over all listeners skipping those that are in `kStateUnused` or `kStateDisabled` state.
+     *
+     * @param[in] aPrev  A pointer to the previous listener, or `nullptr` to start from the beginning.
+     *
+     * @returns A pointer to the next active listener, or `nullptr` if no more active listeners are found.
+     */
+    Listener *IterateListeners(Listener *aPrev);
+
+    /**
+     * Iterates over the active TCP connections.
+     *
+     * This method iterates over all connections skipping those that are in `kStateUnused` or `kStateDisconnected`
+     * state.
+     *
+     * @param[in] aPrev  A pointer to the previous connection, or `nullptr` to start from the beginning.
+     *
+     * @returns A pointer to the next active connection, or `nullptr` if no more active connections are found.
+     */
+    Connection *IterateConnections(Connection *aPrev);
+
+private:
+    void AddListener(Listener &aListener) { mListeners.Push(aListener); }
+    void AddConnection(Connection &aConnection) { mConnections.Push(aConnection); }
+    void PostListenerTask(void) { mListenerTask.Post(); }
+    void PostConnectionTask(void) { mConnectionTask.Post(); }
+    void HandleListenerTask(void);
+    void HandleConnectionTask(void);
+
+    using ListenerTask   = TaskletIn<PlatTcp, &PlatTcp::HandleListenerTask>;
+    using ConnectionTask = TaskletIn<PlatTcp, &PlatTcp::HandleConnectionTask>;
+
+    LinkedList<Listener>   mListeners;
+    LinkedList<Connection> mConnections;
+    ListenerTask           mListenerTask;
+    ConnectionTask         mConnectionTask;
+};
+
+/**
+ * @}
+ */
+
+} // namespace Ip6
+
+DefineCoreType(otPlatTcpSockAddr, Ip6::PlatTcp::SockAddr);
+DefineCoreType(otPlatTcpListener, Ip6::PlatTcp::Listener);
+DefineCoreType(otPlatTcpConnection, Ip6::PlatTcp::Connection);
+
+} // namespace ot
+
+#endif // OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+#endif // OT_CORE_NET_PLAT_TCP_HPP_
diff --git a/src/core/net/slaac_address.cpp b/src/core/net/slaac_address.cpp
index 792919b..efe6f61 100644
--- a/src/core/net/slaac_address.cpp
+++ b/src/core/net/slaac_address.cpp
@@ -118,7 +118,7 @@
 
 bool Slaac::IsSlaac(const NetworkData::OnMeshPrefixConfig &aConfig) const
 {
-    return aConfig.mSlaac && !aConfig.mDp && (aConfig.GetPrefix().GetLength() == NetworkPrefix::kLength);
+    return aConfig.mSlaac && (aConfig.GetPrefix().GetLength() == NetworkPrefix::kLength);
 }
 
 bool Slaac::IsFiltered(const NetworkData::OnMeshPrefixConfig &aConfig) const
@@ -439,7 +439,7 @@
         sha256.Update(secretKey);
         sha256.Finish(hash);
 
-        aAddress.GetAddress().GetIid().SetBytes(hash.GetBytes());
+        aAddress.GetAddress().GetIid().InitFrom(hash.GetBytes());
 
         // If the IID is reserved, try again with a new dadCounter
         if (aAddress.GetAddress().GetIid().IsReserved())
diff --git a/src/core/net/sntp_client.cpp b/src/core/net/sntp_client.cpp
index 5af0a33..a9d6fb0 100644
--- a/src/core/net/sntp_client.cpp
+++ b/src/core/net/sntp_client.cpp
@@ -45,7 +45,7 @@
 
 Client::Client(Instance &aInstance)
     : mSocket(aInstance, *this)
-    , mRetransmissionTimer(aInstance)
+    , mTimer(aInstance)
     , mUnixEra(0)
 {
 }
@@ -65,98 +65,80 @@
 {
     for (Message &message : mPendingQueries)
     {
-        QueryMetadata queryMetadata;
+        QueryMetadata metadata;
 
-        queryMetadata.ReadFrom(message);
-        FinalizeSntpTransaction(message, queryMetadata, 0, kErrorAbort);
+        metadata.ReadFrom(message);
+        Finalize(message, metadata, 0, kErrorAbort);
     }
 
     return mSocket.Close();
 }
 
-Error Client::Query(const otSntpQuery *aQuery, otSntpResponseHandler aHandler, void *aContext)
+Error Client::Query(const QueryInfo &aQuery, ResponseHandler aHandler, void *aContext)
 {
-    Error                   error;
-    QueryMetadata           queryMetadata;
-    Message                *message     = nullptr;
-    Message                *messageCopy = nullptr;
-    Header                  header;
-    const Ip6::MessageInfo *messageInfo;
+    Error         error;
+    QueryMetadata metadata;
+    Message      *message     = nullptr;
+    Message      *messageCopy = nullptr;
+    Header        header;
 
-    VerifyOrExit(aQuery->mMessageInfo != nullptr, error = kErrorInvalidArgs);
+    VerifyOrExit(aQuery.IsValid(), error = kErrorInvalidArgs);
 
     header.Init();
 
     // Originate timestamp is used only as a unique token.
-    header.SetTransmitTimestampSeconds(TimerMilli::GetNow().GetValue() / 1000 + kTimeAt1970);
+    header.GetTxTimestamp().SetSeconds(TimerMilli::GetNow().GetValue() / 1000 + kTimeAt1970);
 
     message = mSocket.NewMessage();
     VerifyOrExit(message != nullptr, error = kErrorNoBufs);
     SuccessOrExit(error = message->Append(header));
 
-    messageInfo = AsCoreTypePtr(aQuery->mMessageInfo);
+    metadata.mResponseCallback.Set(aHandler, aContext);
+    metadata.mTxTimestamp = header.GetTxTimestamp().GetSeconds();
+    metadata.mRetxTime    = TimerMilli::GetNow() + kResponseTimeout;
+    metadata.mSourceAddr  = aQuery.GetMessageInfo().GetSockAddr();
+    metadata.mDestPort    = aQuery.GetMessageInfo().GetPeerPort();
+    metadata.mDestAddr    = aQuery.GetMessageInfo().GetPeerAddr();
+    metadata.mRetxCount   = 0;
 
-    queryMetadata.mResponseHandler.Set(aHandler, aContext);
-    queryMetadata.mTransmitTimestamp   = header.GetTransmitTimestampSeconds();
-    queryMetadata.mTransmissionTime    = TimerMilli::GetNow() + kResponseTimeout;
-    queryMetadata.mSourceAddress       = messageInfo->GetSockAddr();
-    queryMetadata.mDestinationPort     = messageInfo->GetPeerPort();
-    queryMetadata.mDestinationAddress  = messageInfo->GetPeerAddr();
-    queryMetadata.mRetransmissionCount = 0;
+    messageCopy = CopyAndEnqueueMessage(*message, metadata);
+    VerifyOrExit(messageCopy != nullptr, error = kErrorNoBufs);
 
-    VerifyOrExit((messageCopy = CopyAndEnqueueMessage(*message, queryMetadata)) != nullptr, error = kErrorNoBufs);
-    SuccessOrExit(error = SendMessage(*message, *messageInfo));
+    SuccessOrExit(error = mSocket.SendTo(*message, aQuery.GetMessageInfo()));
+
+    mTimer.FireAtIfEarlier(metadata.mRetxTime);
+
+    message     = nullptr;
+    messageCopy = nullptr;
 
 exit:
+    FreeMessage(message);
 
-    if (error != kErrorNone)
+    if (messageCopy != nullptr)
     {
-        FreeMessage(message);
-
-        if (messageCopy)
-        {
-            DequeueMessage(*messageCopy);
-        }
+        mPendingQueries.DequeueAndFree(*messageCopy);
     }
 
     return error;
 }
 
-Message *Client::CopyAndEnqueueMessage(const Message &aMessage, const QueryMetadata &aQueryMetadata)
+Message *Client::CopyAndEnqueueMessage(const Message &aMessage, const QueryMetadata &aMetadata)
 {
-    Error    error       = kErrorNone;
-    Message *messageCopy = nullptr;
+    Error    error;
+    Message *messageCopy;
 
-    // Create a message copy for further retransmissions.
-    VerifyOrExit((messageCopy = aMessage.Clone<kNoReservedHeader>()) != nullptr, error = kErrorNoBufs);
+    messageCopy = aMessage.Clone<kNoReservedHeader>();
+    VerifyOrExit(messageCopy != nullptr, error = kErrorNoBufs);
 
-    // Append the copy with retransmission data and add it to the queue.
-    SuccessOrExit(error = aQueryMetadata.AppendTo(*messageCopy));
+    SuccessOrExit(error = aMetadata.AppendTo(*messageCopy));
+
     mPendingQueries.Enqueue(*messageCopy);
 
-    mRetransmissionTimer.FireAtIfEarlier(aQueryMetadata.mTransmissionTime);
-
 exit:
     FreeAndNullMessageOnError(messageCopy, error);
     return messageCopy;
 }
 
-void Client::DequeueMessage(Message &aMessage)
-{
-    if (mRetransmissionTimer.IsRunning() && (mPendingQueries.GetHead() == nullptr))
-    {
-        // No more requests pending, stop the timer.
-        mRetransmissionTimer.Stop();
-    }
-
-    mPendingQueries.DequeueAndFree(aMessage);
-}
-
-Error Client::SendMessage(Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
-{
-    return mSocket.SendTo(aMessage, aMessageInfo);
-}
-
 void Client::SendCopy(const Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
 {
     Error    error;
@@ -165,115 +147,114 @@
     messageCopy = mSocket.CloneMessageWithout<QueryMetadata>(aMessage);
     VerifyOrExit(messageCopy != nullptr, error = kErrorNoBufs);
 
-    SuccessOrExit(error = SendMessage(*messageCopy, aMessageInfo));
+    error = mSocket.SendTo(*messageCopy, aMessageInfo);
 
 exit:
-    if (error != kErrorNone)
-    {
-        FreeMessage(messageCopy);
-        LogWarnOnError(error, "send SNTP request");
-    }
+    FreeMessageOnError(messageCopy, error);
 }
 
-Message *Client::FindRelatedQuery(const Header &aResponseHeader, QueryMetadata &aQueryMetadata)
+Message *Client::FindRelatedQuery(const Header &aResponseHeader, QueryMetadata &aMetadata)
 {
-    Message *matchedMessage = nullptr;
+    Message *matched = nullptr;
 
     for (Message &message : mPendingQueries)
     {
-        // Read originate timestamp.
-        aQueryMetadata.ReadFrom(message);
+        aMetadata.ReadFrom(message);
 
-        if (aQueryMetadata.mTransmitTimestamp == aResponseHeader.GetOriginateTimestampSeconds())
+        if (aMetadata.mTxTimestamp == aResponseHeader.GetOriginateTimestamp().GetSeconds())
         {
-            matchedMessage = &message;
+            matched = &message;
             break;
         }
     }
 
-    return matchedMessage;
+    return matched;
 }
 
-void Client::FinalizeSntpTransaction(Message             &aQuery,
-                                     const QueryMetadata &aQueryMetadata,
-                                     uint64_t             aTime,
-                                     Error                aResult)
+void Client::Finalize(Message &aQuery, const QueryMetadata &aMetadata, uint64_t aTime, Error aResult)
 {
-    DequeueMessage(aQuery);
-    aQueryMetadata.mResponseHandler.InvokeIfSet(aTime, aResult);
+    mPendingQueries.DequeueAndFree(aQuery);
+    aMetadata.mResponseCallback.InvokeIfSet(aTime, aResult);
 }
 
-void Client::HandleRetransmissionTimer(void)
+void Client::HandleTimer(void)
 {
     NextFireTime     nextTime;
-    QueryMetadata    queryMetadata;
+    QueryMetadata    metadata;
     Ip6::MessageInfo messageInfo;
 
     for (Message &message : mPendingQueries)
     {
-        queryMetadata.ReadFrom(message);
+        metadata.ReadFrom(message);
 
-        if (nextTime.GetNow() >= queryMetadata.mTransmissionTime)
+        if (nextTime.GetNow() >= metadata.mRetxTime)
         {
-            if (queryMetadata.mRetransmissionCount >= kMaxRetransmit)
+            if (metadata.mRetxCount >= kMaxRetransmit)
             {
                 // No expected response.
-                FinalizeSntpTransaction(message, queryMetadata, 0, kErrorResponseTimeout);
+                Finalize(message, metadata, 0, kErrorResponseTimeout);
                 continue;
             }
 
             // Increment retransmission counter and timer.
-            queryMetadata.mRetransmissionCount++;
-            queryMetadata.mTransmissionTime = nextTime.GetNow() + kResponseTimeout;
-            queryMetadata.UpdateIn(message);
+            metadata.mRetxCount++;
+            metadata.mRetxTime = nextTime.GetNow() + kResponseTimeout;
+            metadata.UpdateIn(message);
 
             // Retransmit
-            messageInfo.SetPeerAddr(queryMetadata.mDestinationAddress);
-            messageInfo.SetPeerPort(queryMetadata.mDestinationPort);
-            messageInfo.SetSockAddr(queryMetadata.mSourceAddress);
+            messageInfo.SetPeerAddr(metadata.mDestAddr);
+            messageInfo.SetPeerPort(metadata.mDestPort);
+            messageInfo.SetSockAddr(metadata.mSourceAddr);
 
             SendCopy(message, messageInfo);
         }
 
-        nextTime.UpdateIfEarlier(queryMetadata.mTransmissionTime);
+        nextTime.UpdateIfEarlier(metadata.mRetxTime);
     }
 
-    mRetransmissionTimer.FireAt(nextTime);
+    mTimer.FireAt(nextTime);
 }
 
 void Client::HandleUdpReceive(Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
 {
     OT_UNUSED_VARIABLE(aMessageInfo);
 
-    Error         error = kErrorNone;
-    Header        responseHeader;
-    QueryMetadata queryMetadata;
-    Message      *message  = nullptr;
-    uint64_t      unixTime = 0;
+    Header        header;
+    Message      *query;
+    QueryMetadata metadata;
 
-    SuccessOrExit(aMessage.Read(aMessage.GetOffset(), responseHeader));
+    SuccessOrExit(aMessage.Read(aMessage.GetOffset(), header));
 
-    VerifyOrExit((message = FindRelatedQuery(responseHeader, queryMetadata)) != nullptr);
+    query = FindRelatedQuery(header, metadata);
+    VerifyOrExit(query != nullptr);
+
+    ProcessResponse(*query, metadata, header);
+
+exit:
+    return;
+}
+
+void Client::ProcessResponse(Message &aQuery, const QueryMetadata &aMetadata, Header &aResponseHeader)
+{
+    Error    error    = kErrorNone;
+    uint64_t unixTime = 0;
 
     // Check if response came from the server.
-    VerifyOrExit(responseHeader.GetMode() == Header::kModeServer, error = kErrorFailed);
+    VerifyOrExit(aResponseHeader.GetMode() == Header::kModeServer, error = kErrorFailed);
 
     // Check the Kiss-o'-death packet.
-    if (!responseHeader.GetStratum())
+    if (!aResponseHeader.GetStratum())
     {
         char kissCode[Header::kKissCodeLength + 1];
 
-        memcpy(kissCode, responseHeader.GetKissCode(), Header::kKissCodeLength);
-        kissCode[Header::kKissCodeLength] = 0;
+        memcpy(kissCode, aResponseHeader.GetKissCode(), Header::kKissCodeLength);
+        kissCode[Header::kKissCodeLength] = kNullChar;
 
         LogInfo("SNTP response contains the Kiss-o'-death packet with %s code", kissCode);
         ExitNow(error = kErrorBusy);
     }
 
-    // Check if timestamp has been set.
-    VerifyOrExit(responseHeader.GetTransmitTimestampSeconds() != 0 &&
-                     responseHeader.GetTransmitTimestampFraction() != 0,
-                 error = kErrorFailed);
+    VerifyOrExit(!aResponseHeader.GetTxTimestamp().IsZero(), error = kErrorFailed);
 
     // The NTP time starts at 1900 while the unix epoch starts at 1970.
     // Due to NTP protocol limitation, this module stops working correctly after around year 2106, if
@@ -281,24 +262,17 @@
     // obtained using NTP protocol, and client of this module is responsible to set it properly.
     unixTime = GetUnixEra() * (1ULL << 32);
 
-    if (responseHeader.GetTransmitTimestampSeconds() > kTimeAt1970)
+    if (aResponseHeader.GetTxTimestamp().GetSeconds() > kTimeAt1970)
     {
-        unixTime += static_cast<uint64_t>(responseHeader.GetTransmitTimestampSeconds()) - kTimeAt1970;
+        unixTime += static_cast<uint64_t>(aResponseHeader.GetTxTimestamp().GetSeconds()) - kTimeAt1970;
     }
     else
     {
-        unixTime += static_cast<uint64_t>(responseHeader.GetTransmitTimestampSeconds()) + (1ULL << 32) - kTimeAt1970;
+        unixTime += static_cast<uint64_t>(aResponseHeader.GetTxTimestamp().GetSeconds()) + (1ULL << 32) - kTimeAt1970;
     }
 
-    // Return the time since 1970.
-    FinalizeSntpTransaction(*message, queryMetadata, unixTime, kErrorNone);
-
 exit:
-
-    if (message != nullptr && error != kErrorNone)
-    {
-        FinalizeSntpTransaction(*message, queryMetadata, 0, error);
-    }
+    Finalize(aQuery, aMetadata, unixTime, error);
 }
 
 } // namespace Sntp
diff --git a/src/core/net/sntp_client.hpp b/src/core/net/sntp_client.hpp
index 071eda6..7901818 100644
--- a/src/core/net/sntp_client.hpp
+++ b/src/core/net/sntp_client.hpp
@@ -35,6 +35,7 @@
 
 #include <openthread/sntp.h>
 
+#include "common/as_core_type.hpp"
 #include "common/clearable.hpp"
 #include "common/message.hpp"
 #include "common/non_copyable.hpp"
@@ -59,6 +60,18 @@
     typedef otSntpResponseHandler ResponseHandler; ///< Response handler callback.
 
     /**
+     * Represents an SNTP Query parameters.
+     */
+    class QueryInfo : public otSntpQuery
+    {
+        friend class Client;
+
+    private:
+        bool                    IsValid(void) const { return mMessageInfo != nullptr; }
+        const Ip6::MessageInfo &GetMessageInfo(void) const { return AsCoreType(mMessageInfo); }
+    };
+
+    /**
      * Initializes the object.
      *
      * @param[in]  aInstance     A reference to the OpenThread instance.
@@ -105,7 +118,7 @@
      * @retval kErrorNoBufs       Failed to allocate retransmission data.
      * @retval kErrorInvalidArgs  Invalid arguments supplied.
      */
-    Error Query(const otSntpQuery *aQuery, ResponseHandler aHandler, void *aContext);
+    Error Query(const QueryInfo &aQuery, ResponseHandler aHandler, void *aContext);
 
 private:
     static constexpr uint32_t kTimeAt1970 = 2208988800UL; // num seconds between 1st Jan 1900 and 1st Jan 1970.
@@ -113,102 +126,56 @@
     static constexpr uint32_t kResponseTimeout = OPENTHREAD_CONFIG_SNTP_CLIENT_RESPONSE_TIMEOUT;
     static constexpr uint8_t  kMaxRetransmit   = OPENTHREAD_CONFIG_SNTP_CLIENT_MAX_RETRANSMIT;
 
+    typedef Callback<ResponseHandler> ResponseCallback;
+
+    OT_TOOL_PACKED_BEGIN
+    class Timestamp : public Clearable<Timestamp>
+    {
+    public:
+        bool     IsZero(void) const { return (mSeconds == 0) && (mFraction == 0); }
+        uint32_t GetSeconds(void) const { return BigEndian::HostSwap32(mSeconds); }
+        void     SetSeconds(uint32_t aSeconds) { mSeconds = BigEndian::HostSwap32(aSeconds); }
+        uint32_t GetFraction(void) const { return BigEndian::HostSwap32(mFraction); }
+        void     SetFraction(uint32_t aFraction) { mFraction = BigEndian::HostSwap32(aFraction); }
+
+    private:
+        uint32_t mSeconds;
+        uint32_t mFraction;
+    } OT_TOOL_PACKED_END;
+
     OT_TOOL_PACKED_BEGIN
     class Header : public Clearable<Header>
     {
     public:
-        enum Mode : uint8_t
-        {
-            kModeClient = 3,
-            kModeServer = 4,
-        };
-
+        static constexpr uint8_t kModeClient     = 3;
+        static constexpr uint8_t kModeServer     = 4;
         static constexpr uint8_t kKissCodeLength = 4;
 
-        void Init(void)
-        {
-            Clear();
-            mFlags = (kNtpVersion << kVersionOffset | kModeClient << kModeOffset);
-        }
-
-        uint8_t GetFlags(void) const { return mFlags; }
-        void    SetFlags(uint8_t aFlags) { mFlags = aFlags; }
-
-        Mode GetMode(void) const { return static_cast<Mode>(ReadBits<uint8_t, kModeMask>(mFlags)); }
-
-        uint8_t GetStratum(void) const { return mStratum; }
-        void    SetStratum(uint8_t aStratum) { mStratum = aStratum; }
-
-        uint8_t GetPoll(void) const { return mPoll; }
-        void    SetPoll(uint8_t aPoll) { mPoll = aPoll; }
-
-        uint8_t GetPrecision(void) const { return mPrecision; }
-        void    SetPrecision(uint8_t aPrecision) { mPrecision = aPrecision; }
-
-        uint32_t GetRootDelay(void) const { return BigEndian::HostSwap32(mRootDelay); }
-        void     SetRootDelay(uint32_t aRootDelay) { mRootDelay = BigEndian::HostSwap32(aRootDelay); }
-
-        uint32_t GetRootDispersion(void) const { return BigEndian::HostSwap32(mRootDispersion); }
-        void SetRootDispersion(uint32_t aRootDispersion) { mRootDispersion = BigEndian::HostSwap32(aRootDispersion); }
-
-        uint32_t GetReferenceId(void) const { return BigEndian::HostSwap32(mReferenceId); }
-        void     SetReferenceId(uint32_t aReferenceId) { mReferenceId = BigEndian::HostSwap32(aReferenceId); }
-
-        char *GetKissCode(void) { return reinterpret_cast<char *>(&mReferenceId); }
-
-        uint32_t GetReferenceTimestampSeconds(void) const { return BigEndian::HostSwap32(mReferenceTimestampSeconds); }
-        void     SetReferenceTimestampSeconds(uint32_t aTimestamp)
-        {
-            mReferenceTimestampSeconds = BigEndian::HostSwap32(aTimestamp);
-        }
-
-        uint32_t GetReferenceTimestampFraction(void) const
-        {
-            return BigEndian::HostSwap32(mReferenceTimestampFraction);
-        }
-        void SetReferenceTimestampFraction(uint32_t aFraction)
-        {
-            mReferenceTimestampFraction = BigEndian::HostSwap32(aFraction);
-        }
-
-        uint32_t GetOriginateTimestampSeconds(void) const { return BigEndian::HostSwap32(mOriginateTimestampSeconds); }
-        void     SetOriginateTimestampSeconds(uint32_t aTimestamp)
-        {
-            mOriginateTimestampSeconds = BigEndian::HostSwap32(aTimestamp);
-        }
-
-        uint32_t GetOriginateTimestampFraction(void) const
-        {
-            return BigEndian::HostSwap32(mOriginateTimestampFraction);
-        }
-        void SetOriginateTimestampFraction(uint32_t aFraction)
-        {
-            mOriginateTimestampFraction = BigEndian::HostSwap32(aFraction);
-        }
-
-        uint32_t GetReceiveTimestampSeconds(void) const { return BigEndian::HostSwap32(mReceiveTimestampSeconds); }
-        void     SetReceiveTimestampSeconds(uint32_t aTimestamp)
-        {
-            mReceiveTimestampSeconds = BigEndian::HostSwap32(aTimestamp);
-        }
-
-        uint32_t GetReceiveTimestampFraction(void) const { return BigEndian::HostSwap32(mReceiveTimestampFraction); }
-        void     SetReceiveTimestampFraction(uint32_t aFraction)
-        {
-            mReceiveTimestampFraction = BigEndian::HostSwap32(aFraction);
-        }
-
-        uint32_t GetTransmitTimestampSeconds(void) const { return BigEndian::HostSwap32(mTransmitTimestampSeconds); }
-        void     SetTransmitTimestampSeconds(uint32_t aTimestamp)
-        {
-            mTransmitTimestampSeconds = BigEndian::HostSwap32(aTimestamp);
-        }
-
-        uint32_t GetTransmitTimestampFraction(void) const { return BigEndian::HostSwap32(mTransmitTimestampFraction); }
-        void     SetTransmitTimestampFraction(uint32_t aFraction)
-        {
-            mTransmitTimestampFraction = BigEndian::HostSwap32(aFraction);
-        }
+        void             Init(void) { Clear(), mFlags = (kNtpVersion << kVersionOffset | kModeClient << kModeOffset); }
+        uint8_t          GetFlags(void) const { return mFlags; }
+        void             SetFlags(uint8_t aFlags) { mFlags = aFlags; }
+        uint8_t          GetMode(void) const { return ReadBits<uint8_t, kModeMask>(mFlags); }
+        uint8_t          GetStratum(void) const { return mStratum; }
+        void             SetStratum(uint8_t aStratum) { mStratum = aStratum; }
+        uint8_t          GetPoll(void) const { return mPoll; }
+        void             SetPoll(uint8_t aPoll) { mPoll = aPoll; }
+        uint8_t          GetPrecision(void) const { return mPrecision; }
+        void             SetPrecision(uint8_t aPrecision) { mPrecision = aPrecision; }
+        uint32_t         GetRootDelay(void) const { return BigEndian::HostSwap32(mRootDelay); }
+        void             SetRootDelay(uint32_t aDelay) { mRootDelay = BigEndian::HostSwap32(aDelay); }
+        uint32_t         GetRootDispersion(void) const { return BigEndian::HostSwap32(mRootDispersion); }
+        void             SetRootDispersion(uint32_t aDisp) { mRootDispersion = BigEndian::HostSwap32(aDisp); }
+        uint32_t         GetReferenceId(void) const { return BigEndian::HostSwap32(mReferenceId); }
+        void             SetReferenceId(uint32_t aId) { mReferenceId = BigEndian::HostSwap32(aId); }
+        char            *GetKissCode(void) { return reinterpret_cast<char *>(&mReferenceId); }
+        Timestamp       &GetReferenceTimestamp(void) { return mReferenceTimestamp; }
+        Timestamp       &GetOriginateTimestamp(void) { return mOriginateTimestamp; }
+        Timestamp       &GetRxTimestamp(void) { return mRxTimestamp; }
+        Timestamp       &GetTxTimestamp(void) { return mTxTimestamp; }
+        const Timestamp &GetReferenceTimestamp(void) const { return mReferenceTimestamp; }
+        const Timestamp &GetOriginateTimestamp(void) const { return mOriginateTimestamp; }
+        const Timestamp &GetRxTimestamp(void) const { return mRxTimestamp; }
+        const Timestamp &GetTxTimestamp(void) const { return mTxTimestamp; }
 
     private:
         static constexpr uint8_t kNtpVersion    = 4;                      // Current NTP version.
@@ -219,56 +186,51 @@
         static constexpr uint8_t kModeOffset    = 0;                      // Mode field offset.
         static constexpr uint8_t kModeMask      = 0x07 << kModeOffset;    // Mode filed mask.
 
-        uint8_t  mFlags;                      // SNTP flags: LI Leap Indicator, VN Version Number and Mode.
-        uint8_t  mStratum;                    // Packet Stratum.
-        uint8_t  mPoll;                       // Maximum interval between successive messages, in log2 seconds.
-        uint8_t  mPrecision;                  // The precision of the system clock, in log2 seconds.
-        uint32_t mRootDelay;                  // Total round-trip delay to the reference clock, in NTP short format.
-        uint32_t mRootDispersion;             // Total dispersion to the reference clock.
-        uint32_t mReferenceId;                // ID identifying the particular server or reference clock.
-        uint32_t mReferenceTimestampSeconds;  // Time the system clock was last set or corrected (NTP format).
-        uint32_t mReferenceTimestampFraction; // Fraction part of above value.
-        uint32_t mOriginateTimestampSeconds;  // Time at client when request departed for the server (NTP format).
-        uint32_t mOriginateTimestampFraction; // Fraction part of above value.
-        uint32_t mReceiveTimestampSeconds;    // Time at server when request arrived from the client (NTP format).
-        uint32_t mReceiveTimestampFraction;   // Fraction part of above value.
-        uint32_t mTransmitTimestampSeconds;   // Time at server when the response left for the client (NTP format).
-        uint32_t mTransmitTimestampFraction;  // Fraction part of above value.
+        uint8_t   mFlags;              // SNTP flags: LI Leap Indicator, VN Version Number and Mode.
+        uint8_t   mStratum;            // Packet Stratum.
+        uint8_t   mPoll;               // Maximum interval between successive messages, in log2 seconds.
+        uint8_t   mPrecision;          // The precision of the system clock, in log2 seconds.
+        uint32_t  mRootDelay;          // Total round-trip delay to the reference clock, in NTP short format.
+        uint32_t  mRootDispersion;     // Total dispersion to the reference clock.
+        uint32_t  mReferenceId;        // ID identifying the particular server or reference clock.
+        Timestamp mReferenceTimestamp; // Time the system clock was last set or corrected.
+        Timestamp mOriginateTimestamp; // Time at client when request departed for the server.
+        Timestamp mRxTimestamp;        // Time at server when request arrived from the client.
+        Timestamp mTxTimestamp;        // Time at server when the response left for the client.
     } OT_TOOL_PACKED_END;
 
     struct QueryMetadata : public Message::FooterData<QueryMetadata>
     {
-        uint32_t                  mTransmitTimestamp;   // Time at client when request departed for server
-        Callback<ResponseHandler> mResponseHandler;     // Response handler callback
-        TimeMilli                 mTransmissionTime;    // Time when the timer should shoot for this message
-        Ip6::Address              mSourceAddress;       // Source IPv6 address
-        Ip6::Address              mDestinationAddress;  // Destination IPv6 address
-        uint16_t                  mDestinationPort;     // Destination UDP port
-        uint8_t                   mRetransmissionCount; // Number of retransmissions
+        uint32_t         mTxTimestamp;      // Time at client when request departed for server
+        ResponseCallback mResponseCallback; // Response handler callback
+        TimeMilli        mRetxTime;         // Time to retx the query (`kResponseTimeout` after last tx)
+        Ip6::Address     mSourceAddr;       // Source IPv6 address
+        Ip6::Address     mDestAddr;         // Destination IPv6 address
+        uint16_t         mDestPort;         // Destination UDP port
+        uint8_t          mRetxCount;        // Number of retransmissions
     };
 
-    Message *CopyAndEnqueueMessage(const Message &aMessage, const QueryMetadata &aQueryMetadata);
-    void     DequeueMessage(Message &aMessage);
-    Error    SendMessage(Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
+    Message *CopyAndEnqueueMessage(const Message &aMessage, const QueryMetadata &aMetadata);
     void     SendCopy(const Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
+    Message *FindRelatedQuery(const Header &aResponseHeader, QueryMetadata &aMetadata);
+    void     Finalize(Message &aQuery, const QueryMetadata &aMetadata, uint64_t aTime, Error aResult);
+    void     HandleTimer(void);
+    void     HandleUdpReceive(Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
+    void     ProcessResponse(Message &aQuery, const QueryMetadata &aMetadata, Header &aResponseHeader);
 
-    Message *FindRelatedQuery(const Header &aResponseHeader, QueryMetadata &aQueryMetadata);
-    void FinalizeSntpTransaction(Message &aQuery, const QueryMetadata &aQueryMetadata, uint64_t aTime, Error aResult);
-
-    void HandleRetransmissionTimer(void);
-    void HandleUdpReceive(Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
-
-    using RetxTimer    = TimerMilliIn<Client, &Client::HandleRetransmissionTimer>;
+    using RetxTimer    = TimerMilliIn<Client, &Client::HandleTimer>;
     using ClientSocket = Ip6::Udp::SocketIn<Client, &Client::HandleUdpReceive>;
 
     ClientSocket mSocket;
     MessageQueue mPendingQueries;
-    RetxTimer    mRetransmissionTimer;
-
-    uint32_t mUnixEra;
+    RetxTimer    mTimer;
+    uint32_t     mUnixEra;
 };
 
 } // namespace Sntp
+
+DefineCoreType(otSntpQuery, Sntp::Client::QueryInfo);
+
 } // namespace ot
 
 #endif // OPENTHREAD_CONFIG_SNTP_CLIENT_ENABLE
diff --git a/src/core/net/srp_advertising_proxy.cpp b/src/core/net/srp_advertising_proxy.cpp
index b40abf4..f75d9ca 100644
--- a/src/core/net/srp_advertising_proxy.cpp
+++ b/src/core/net/srp_advertising_proxy.cpp
@@ -1152,7 +1152,10 @@
 
 void AdvertisingProxy::CopyNameAndRemoveDomain(DnsName &aName, const char *aFullName)
 {
-    IgnoreError(Dns::Name::ExtractLabels(aFullName, Get<Server>().GetDomain(), aName, sizeof(aName)));
+    if (Dns::Name::ExtractLabels(aFullName, Get<Server>().GetDomain(), aName, sizeof(aName)) != kErrorNone)
+    {
+        aName[0] = kNullChar;
+    }
 }
 
 void AdvertisingProxy::HandleRegistered(otInstance *aInstance, otPlatDnssdRequestId aRequestId, otError aError)
diff --git a/src/core/net/srp_client.cpp b/src/core/net/srp_client.cpp
index d5c6a17..04afd31 100644
--- a/src/core/net/srp_client.cpp
+++ b/src/core/net/srp_client.cpp
@@ -232,7 +232,7 @@
         mRequestedMax = 0;
     }
 
-    delay = Random::NonCrypto::GetUint32InRange(kMinTxJitter, maxJitter);
+    delay = Random::NonCrypto::GenerateInClosedRange(kMinTxJitter, maxJitter);
     LogInfo("Use random tx jitter %lu from [%lu, %lu]", ToUlong(delay), ToUlong(kMinTxJitter), ToUlong(maxJitter));
 
     return delay;
@@ -332,6 +332,7 @@
     , mShouldRemoveKeyLease(false)
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     , mServiceKeyRecordEnabled(false)
+    , mHostKeyRecordEnabled(true)
     , mUseShortLeaseOption(false)
 #endif
     , mCurMessageId(0)
@@ -982,7 +983,7 @@
     info.mSingleServiceMode = false;
     SuccessOrExit(error = PrepareUpdateMessage(info));
 
-    length = info.mMessage->GetLength() + sizeof(Ip6::Udp::Header) + sizeof(Ip6::Header);
+    length = info.mMessage->GetLength() + sizeof(Ip6::UdpHeader) + sizeof(Ip6::Header);
 
     if (length >= Ip6::kMaxDatagramLength)
     {
@@ -1545,7 +1546,12 @@
     // KEY RR
 
     SuccessOrExit(error = AppendHostName(aInfo));
-    SuccessOrExit(error = AppendKeyRecord(aInfo));
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    if (mHostKeyRecordEnabled)
+#endif
+    {
+        SuccessOrExit(error = AppendKeyRecord(aInfo));
+    }
 
 exit:
     return error;
@@ -1936,7 +1942,7 @@
 
     do
     {
-        mCurMessageId = Random::NonCrypto::GetUint16();
+        mCurMessageId = Random::NonCrypto::Generate<uint16_t>();
     } while (oldId == mCurMessageId);
 }
 
diff --git a/src/core/net/srp_client.hpp b/src/core/net/srp_client.hpp
index a350a24..d5cd207 100644
--- a/src/core/net/srp_client.hpp
+++ b/src/core/net/srp_client.hpp
@@ -715,6 +715,27 @@
     bool IsServiceKeyRecordEnabled(void) const { return mServiceKeyRecordEnabled; }
 
     /**
+     * Enables/disables "host key record inclusion" mode.
+     *
+     * When enabled (default), SRP client will include KEY record in Host Description Instruction.
+     *
+     * @note Host KEY record is required in Host Description Instruction. The default behavior of the SRP client is to
+     * include it. This method is added under the `REFERENCE_DEVICE` config and is intended to override the default
+     * behavior for testing only. `SetHostKeyRecordEnabled(false)` makes the SRP client non-functional and non-compliant
+     * and is used solely for testing to validate SRP server behavior.
+     *
+     * @param[in] aEnabled   TRUE to enable, FALSE to disable the "host key record inclusion" mode.
+     */
+    void SetHostKeyRecordEnabled(bool aEnabled) { mHostKeyRecordEnabled = aEnabled; }
+
+    /**
+     * Indicates whether the "host key record inclusion" mode is enabled or disabled.
+     *
+     * @returns TRUE if "host key record inclusion" mode is enabled, FALSE otherwise.
+     */
+    bool IsHostKeyRecordEnabled(void) const { return mHostKeyRecordEnabled; }
+
+    /**
      * Enables/disables "use short Update Lease Option" behavior.
      *
      * When enabled, the SRP client will use the short variant format of Update Lease Option in its message. The short
@@ -745,7 +766,7 @@
         OPENTHREAD_CONFIG_SRP_CLIENT_MAX_TIMEOUT_FAILURES_TO_SWITCH_SERVER;
 #endif
 
-    static constexpr uint16_t kUdpPayloadSize = Ip6::kMaxDatagramLength - sizeof(Ip6::Udp::Header);
+    static constexpr uint16_t kUdpPayloadSize = Ip6::kMaxDatagramLength - sizeof(Ip6::UdpHeader);
 
     // -------------------------------
     // Lease related constants
@@ -1079,6 +1100,7 @@
     bool    mShouldRemoveKeyLease : 1;
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     bool mServiceKeyRecordEnabled : 1;
+    bool mHostKeyRecordEnabled : 1;
     bool mUseShortLeaseOption : 1;
 #endif
 
diff --git a/src/core/net/srp_server.cpp b/src/core/net/srp_server.cpp
index 6b72cd5..f48c670 100644
--- a/src/core/net/srp_server.cpp
+++ b/src/core/net/srp_server.cpp
@@ -80,7 +80,7 @@
     , mLeaseTimer(aInstance)
     , mOutstandingUpdatesTimer(aInstance)
     , mCompletedUpdateTask(aInstance)
-    , mServiceUpdateId(Random::NonCrypto::GetUint32())
+    , mServiceUpdateId(Random::NonCrypto::Generate<uint32_t>())
     , mPort(kUninitializedPort)
     , mState(kStateDisabled)
     , mAddressMode(kDefaultAddressMode)
@@ -727,8 +727,9 @@
 exit:
     if (aMessageInfo != nullptr)
     {
-        if (aError == kErrorNone && (grantedLease != hostLease || grantedKeyLease != hostKeyLease))
+        if (aError == kErrorNone)
         {
+            // RFC 9664: server MUST echo the Update Lease option in any successful (RCODE=0) response.
             SendResponse(aDnsHeader, grantedLease, grantedKeyLease, useShortLease, *aMessageInfo);
         }
         else
diff --git a/src/core/net/srp_server.hpp b/src/core/net/srp_server.hpp
index 6ce3b20..5009d40 100644
--- a/src/core/net/srp_server.hpp
+++ b/src/core/net/srp_server.hpp
@@ -832,7 +832,7 @@
 private:
     static constexpr uint8_t kSrpVersion = 0;
 
-    static constexpr uint16_t kUdpPayloadSize = Ip6::kMaxDatagramLength - sizeof(Ip6::Udp::Header);
+    static constexpr uint16_t kUdpPayloadSize = Ip6::kMaxDatagramLength - sizeof(Ip6::UdpHeader);
 
     static constexpr uint32_t kDefaultMinLease             = 30;          // 30 seconds.
     static constexpr uint32_t kDefaultMaxLease             = 27u * 3600;  // 27 hours (in seconds).
diff --git a/src/core/net/tcp6.cpp b/src/core/net/tcp6.cpp
index 3a72826..409d85e 100644
--- a/src/core/net/tcp6.cpp
+++ b/src/core/net/tcp6.cpp
@@ -109,7 +109,10 @@
     return error;
 }
 
-Instance &Tcp::Endpoint::GetInstance(void) const { return AsNonConst(AsCoreType(GetTcb().instance)); }
+Instance &Tcp::Endpoint::GetInstance(void) const
+{
+    return *UpdateActiveInstance(&AsNonConst(AsCoreType(GetTcb().instance)));
+}
 
 const SockAddr &Tcp::Endpoint::GetLocalAddress(void) const
 {
@@ -547,7 +550,10 @@
     return error;
 }
 
-Instance &Tcp::Listener::GetInstance(void) const { return AsNonConst(AsCoreType(GetTcbListen().instance)); }
+Instance &Tcp::Listener::GetInstance(void) const
+{
+    return *UpdateActiveInstance(&AsNonConst(AsCoreType(GetTcbListen().instance)));
+}
 
 Error Tcp::Listener::Listen(const SockAddr &aSockName)
 {
@@ -634,7 +640,7 @@
     int                  nextAction;
 
     VerifyOrExit(length == aMessage.DetermineLengthAfterOffset(), error = kErrorParse);
-    VerifyOrExit(length >= sizeof(Tcp::Header), error = kErrorParse);
+    VerifyOrExit(length >= sizeof(TcpHeader), error = kErrorParse);
     SuccessOrExit(error = aMessage.Read(aMessage.GetOffset() + offsetof(struct tcphdr, th_off_x2), headerSize));
     headerSize = static_cast<uint8_t>((headerSize >> TH_OFF_SHIFT) << 2);
     VerifyOrExit(headerSize >= sizeof(struct tcphdr) && headerSize <= sizeof(header) &&
diff --git a/src/core/net/tcp6.hpp b/src/core/net/tcp6.hpp
index c1cbb2b..510ed85 100644
--- a/src/core/net/tcp6.hpp
+++ b/src/core/net/tcp6.hpp
@@ -36,6 +36,8 @@
 
 #include "openthread-core-config.h"
 
+#if OPENTHREAD_CONFIG_TCP_ENABLE
+
 #include <openthread/tcp.h>
 
 #include "common/as_core_type.hpp"
@@ -86,8 +88,6 @@
 class Tcp : public InstanceLocator, private NonCopyable
 {
 public:
-    typedef TcpHeader Header; ///< TCP Header.
-
     /**
      * Represents an endpoint of a TCP/IPv6 connection.
      */
@@ -562,6 +562,8 @@
     static constexpr uint8_t kReceiveAvailableCallbackFlag = (1 << 3);
     static constexpr uint8_t kDisconnectedCallbackFlag     = (1 << 4);
 
+    typedef TcpHeader Header;
+
     void ProcessSignals(Endpoint             &aEndpoint,
                         otLinkedBuffer       *aPriorHead,
                         size_t                aPriorBacklog,
@@ -592,4 +594,6 @@
 
 } // namespace ot
 
+#endif // OPENTHREAD_CONFIG_TCP_ENABLE
+
 #endif // OT_CORE_NET_TCP6_HPP_
diff --git a/src/core/net/tcp6_ext.hpp b/src/core/net/tcp6_ext.hpp
index ffa1872..e9f7ab1 100644
--- a/src/core/net/tcp6_ext.hpp
+++ b/src/core/net/tcp6_ext.hpp
@@ -36,6 +36,8 @@
 
 #include "openthread-core-config.h"
 
+#if OPENTHREAD_CONFIG_TCP_ENABLE
+
 #include <openthread/tcp_ext.h>
 
 #include "net/tcp6.hpp"
@@ -130,4 +132,6 @@
 
 } // namespace ot
 
+#endif // OPENTHREAD_CONFIG_TCP_ENABLE
+
 #endif // OT_CORE_NET_TCP6_EXT_HPP_
diff --git a/src/core/net/udp6.cpp b/src/core/net/udp6.cpp
index 1e2ce44..bb48deb 100644
--- a/src/core/net/udp6.cpp
+++ b/src/core/net/udp6.cpp
@@ -223,6 +223,9 @@
 
     aSocket.Clear();
     aSocket.SetNetifId(aNetifId);
+#if OPENTHREAD_PLATFORM_NEXUS && OPENTHREAD_CONFIG_PLATFORM_UDP_ENABLE
+    aSocket.mHandle = &GetInstance();
+#endif
     aSocket.mHandler = aHandler;
     aSocket.mContext = aContext;
 
diff --git a/src/core/net/udp6.hpp b/src/core/net/udp6.hpp
index b2ecc92..448bca2 100644
--- a/src/core/net/udp6.hpp
+++ b/src/core/net/udp6.hpp
@@ -83,7 +83,6 @@
 class Udp : public InstanceLocator, public MessageAllocator<Udp, ReservedHeaderSize::kUdpMessage>, private NonCopyable
 {
 public:
-    typedef UdpHeader    Header;         ///< UDP header.
     typedef otUdpReceive ReceiveHandler; ///< Receive handler callback.
 
     /**
@@ -500,11 +499,11 @@
     void HandlePayload(Message &aMessage, MessageInfo &aMessageInfo);
 
     /**
-     * Returns the head of UDP Sockets list.
+     * Returns the UDP Sockets linked list.
      *
-     * @returns A pointer to the head of UDP Socket linked list.
+     * @returns The UDP Sockets linked list.
      */
-    SocketHandle *GetUdpSockets(void) { return mSockets.GetHead(); }
+    LinkedList<SocketHandle> &GetUdpSockets(void) { return mSockets; }
 
 #if OPENTHREAD_CONFIG_UDP_FORWARD_ENABLE
     /**
@@ -535,6 +534,8 @@
     static constexpr uint16_t kSrpServerPortMin = OPENTHREAD_CONFIG_SRP_SERVER_UDP_PORT_MIN;
     static constexpr uint16_t kSrpServerPortMax = OPENTHREAD_CONFIG_SRP_SERVER_UDP_PORT_MAX;
 
+    typedef UdpHeader Header;
+
 #if OPENTHREAD_CONFIG_PLATFORM_UDP_ENABLE
     struct Plat
     {
diff --git a/src/core/openthread-core-config.h b/src/core/openthread-core-config.h
index 0fe8f06..576403d 100644
--- a/src/core/openthread-core-config.h
+++ b/src/core/openthread-core-config.h
@@ -97,8 +97,8 @@
 #include "config/misc.h"
 #include "config/mle.h"
 #include "config/nat64.h"
+#include "config/net_diag.h"
 #include "config/netdata_publisher.h"
-#include "config/network_diagnostic.h"
 #include "config/p2p.h"
 #include "config/parent_search.h"
 #include "config/ping_sender.h"
diff --git a/src/core/radio/ble_secure.cpp b/src/core/radio/ble_secure.cpp
index cff1f51..50a3924 100644
--- a/src/core/radio/ble_secure.cpp
+++ b/src/core/radio/ble_secure.cpp
@@ -56,7 +56,7 @@
     , mSendMessage(nullptr)
     , mTransmitTask(aInstance)
     , mBleState(kStopped)
-    , mBleAdvRequestedState(kAdvertising)
+    , mIsBleAdvRequested(false)
     , mMtuSize(kInitialMtuSize)
 {
 }
@@ -81,15 +81,14 @@
     VerifyOrExit(advertisementData != nullptr, error = kErrorFailed);
     SuccessOrExit(error = otPlatBleGapAdvSetData(&GetInstance(), advertisementData, advertisementLen));
 
-    SuccessOrExit(error = mTls.Open());
+    SuccessOrExit(error = mTls.Open(HandleTransport, this));
     mTls.SetReceiveCallback(HandleTlsReceive, this);
     mTls.SetConnectCallback(HandleTlsConnectEvent, this);
-    SuccessOrExit(error = mTls.Bind(HandleTransport, this));
 
     // attempt to start BLE advertising only if everything else succeeded.
-    mBleState             = kNotAdvertising;
-    mBleAdvRequestedState = kAdvertising;
-    error                 = SetRequestedBleAdvertisementsState();
+    mBleState          = kNotAdvertising;
+    mIsBleAdvRequested = true;
+    error              = SetRequestedBleAdvertisementsState();
 
 exit:
     if (error != kErrorNone && error != kErrorAlready)
@@ -119,9 +118,9 @@
     // Even if stop-advertisements or disable BLE would fail, we continue closing TLS and stopping TCAT agent.
     IgnoreError(otPlatBleGapAdvStop(&GetInstance()));
     IgnoreError(otPlatBleDisable(&GetInstance()));
-    mBleState             = kStopped;
-    mBleAdvRequestedState = kStopped;
-    mMtuSize              = kInitialMtuSize;
+    mBleState          = kStopped;
+    mIsBleAdvRequested = false;
+    mMtuSize           = kInitialMtuSize;
 
     mTls.Close();
     Get<MeshCoP::TcatAgent>().Stop();
@@ -181,14 +180,22 @@
 
     if (mBleState == kConnected)
     {
-        // request platform to close BLE. Once this closing is done, #HandleBleDisconnected will
-        // be called by the platform, which will call BleSecure::Disconnect again.
-        IgnoreError(otPlatBleGapDisconnect(&GetInstance()));
+        // Request the platform to close the BLE connection. When the platform signals completion
+        // (asynchronously) it calls otPlatBleGapOnDisconnected() -> #HandleBleDisconnected(), which
+        // in turn calls this method again - now with mBleState no longer kConnected - to invoke below
+        // mConnectCallback and update the advertisement data. We therefore return early here when
+        // the platform accepted the request, to avoid invoking the callback (and updating the
+        // advertisement data) twice. If the platform did not start the disconnection, no completion
+        // callback will follow and we handle mConnectCallback directly below.
+        VerifyOrExit(otPlatBleGapDisconnect(&GetInstance()) != kErrorNone);
     }
 
     mConnectCallback.InvokeIfSet(&GetInstance(), false, false);
     // Update advertisement data
     IgnoreError(NotifyAdvertisementChanged());
+
+exit:
+    return;
 }
 
 Error BleSecure::NotifyAdvertisementChanged(void)
@@ -197,18 +204,35 @@
     uint16_t advertisementLen  = 0;
     uint8_t *advertisementData = nullptr;
 
-    VerifyOrExit(mBleState == kAdvertising);
+    VerifyOrExit(mBleState != kStopped);
     SuccessOrExit(error = otPlatBleGetAdvertisementBuffer(&GetInstance(), &advertisementData));
     SuccessOrExit(error = Get<MeshCoP::TcatAgent>().GetAdvertisementData(advertisementLen, advertisementData));
-    SuccessOrExit(error = otPlatBleGapAdvUpdateData(&GetInstance(), advertisementData, advertisementLen));
+
+    if (mBleState == kAdvertising)
+    {
+        SuccessOrExit(error = otPlatBleGapAdvUpdateData(&GetInstance(), advertisementData, advertisementLen));
+    }
+    else
+    {
+        // Any other state: update buffered data for when advertising resumes.
+        SuccessOrExit(error = otPlatBleGapAdvSetData(&GetInstance(), advertisementData, advertisementLen));
+    }
 
 exit:
     return error;
 }
 
+void BleSecure::HandleNotifierEvents(Events aEvents)
+{
+    if (aEvents.ContainsAny(kEventActiveDatasetChanged | kEventThreadRoleChanged))
+    {
+        IgnoreError(NotifyAdvertisementChanged());
+    }
+}
+
 void BleSecure::NotifySendAdvertisements(bool aSendAdvertisements)
 {
-    mBleAdvRequestedState = aSendAdvertisements ? kAdvertising : kNotAdvertising;
+    mIsBleAdvRequested = aSendAdvertisements;
     IgnoreError(SetRequestedBleAdvertisementsState());
 }
 
@@ -219,12 +243,12 @@
     Error error = kErrorNone;
 
     // Must not make GapAdv platform calls when kStopped, or kConnected.
-    if (mBleAdvRequestedState == kAdvertising && mBleState == kNotAdvertising)
+    if (mIsBleAdvRequested && mBleState == kNotAdvertising)
     {
         SuccessOrExit(error = otPlatBleGapAdvStart(&GetInstance(), OT_BLE_ADV_INTERVAL_DEFAULT));
         mBleState = kAdvertising;
     }
-    else if (mBleAdvRequestedState == kNotAdvertising && mBleState == kAdvertising)
+    else if (!mIsBleAdvRequested && mBleState == kAdvertising)
     {
         SuccessOrExit(error = otPlatBleGapAdvStop(&GetInstance()));
         mBleState = kNotAdvertising;
@@ -399,14 +423,12 @@
 {
     OT_UNUSED_VARIABLE(aConnectionId);
 
-    // kAdvertising is the state that the BLE stack will automatically assume, after a BLE client disconnects.
-    mBleState = kAdvertising;
+    mBleState = kNotAdvertising; // per otPlatBleGapAdvStart() API, advertising stopped already when client connected.
     mMtuSize  = kInitialMtuSize;
 
     Disconnect(); // Stop TLS connection and update advertisement data
 
-    // if a different BLE advertising state was requested earlier while a BLE client was connected,
-    // then now's the time to fulfill the request.
+    // Resume advertising (or fulfill a different advertising state requested while a client was connected).
     IgnoreError(SetRequestedBleAdvertisementsState());
 }
 
@@ -474,7 +496,10 @@
 
 void BleSecure::HandleTlsReceive(uint8_t *aBuf, uint16_t aLength)
 {
-    otError error = kErrorNone;
+    Error    error = kErrorNone;
+    Tlv      tlv;
+    uint32_t requiredBytes;
+    uint32_t offset;
 
     VerifyOrExit(mReceivedMessage != nullptr);
     DumpDebg("Rx", aBuf, aLength);
@@ -484,113 +509,122 @@
         SuccessOrExit(error = mReceivedMessage->AppendBytes(aBuf, aLength));
         mReceiveCallback.InvokeIfSet(&GetInstance(), mReceivedMessage, 0, OT_TCAT_APPLICATION_PROTOCOL_NONE);
         IgnoreError(mReceivedMessage->SetLength(0));
+        ExitNow();
     }
-    else
+
+    requiredBytes = sizeof(Tlv);
+
+    while (aLength > 0)
     {
-        ot::Tlv  tlv;
-        uint32_t requiredBytes = sizeof(Tlv);
-        uint32_t offset;
-
-        while (aLength > 0)
+        if (mReceivedMessage->GetLength() < requiredBytes)
         {
-            if (mReceivedMessage->GetLength() < requiredBytes)
-            {
-                uint32_t missingBytes = requiredBytes - mReceivedMessage->GetLength();
+            uint32_t missingBytes = requiredBytes - mReceivedMessage->GetLength();
 
-                if (missingBytes > aLength)
-                {
-                    SuccessOrExit(error = mReceivedMessage->AppendBytes(aBuf, aLength));
-                    break;
-                }
-                else
-                {
-                    SuccessOrExit(error = mReceivedMessage->AppendBytes(aBuf, static_cast<uint16_t>(missingBytes)));
-                    aLength -= missingBytes;
-                    aBuf += missingBytes;
-                }
+            if (missingBytes > aLength)
+            {
+                error = mReceivedMessage->AppendBytes(aBuf, aLength);
+                ExitNow();
             }
 
-            IgnoreError(mReceivedMessage->Read(0, tlv));
+            SuccessOrExit(error = mReceivedMessage->AppendBytes(aBuf, static_cast<uint16_t>(missingBytes)));
+            aLength -= missingBytes;
+            aBuf += missingBytes;
+        }
 
-            if (tlv.IsExtended())
-            {
-                ot::ExtendedTlv extTlv;
-                requiredBytes = sizeof(extTlv);
+        IgnoreError(mReceivedMessage->Read(0, tlv));
 
-                if (mReceivedMessage->GetLength() < requiredBytes)
-                {
-                    continue;
-                }
+        if (tlv.IsExtended())
+        {
+            ExtendedTlv extTlv;
 
-                IgnoreError(mReceivedMessage->Read(0, extTlv));
-                requiredBytes = extTlv.GetSize();
-                offset        = sizeof(extTlv);
-            }
-            else
-            {
-                requiredBytes = tlv.GetSize();
-                offset        = sizeof(tlv);
-            }
+            requiredBytes = sizeof(extTlv);
 
             if (mReceivedMessage->GetLength() < requiredBytes)
             {
                 continue;
             }
 
-            // TLV fully loaded - let TCAT agent handle it, if connected
-            if (Get<MeshCoP::TcatAgent>().IsConnected())
+            IgnoreError(mReceivedMessage->Read(0, extTlv));
+            requiredBytes = extTlv.GetSize();
+            offset        = sizeof(extTlv);
+        }
+        else
+        {
+            requiredBytes = tlv.GetSize();
+            offset        = sizeof(tlv);
+        }
+
+        if (mReceivedMessage->GetLength() < requiredBytes)
+        {
+            continue;
+        }
+
+        // TLV fully loaded - let TCAT agent handle it, if connected
+
+        if (Get<MeshCoP::TcatAgent>().IsConnected())
+        {
+            Error errorTcatAgent = kErrorNone;
+
+            IgnoreError(Flush());
+
+            if (mSendMessage == nullptr)
             {
-                Error errorTcatAgent = kErrorNone;
-
-                IgnoreError(Flush());
-
-                if (mSendMessage == nullptr)
-                {
-                    mSendMessage = Get<MessagePool>().Allocate(Message::kTypeBle);
-                    VerifyOrExit(mSendMessage != nullptr, error = kErrorNoBufs);
-                }
-
-                errorTcatAgent = Get<MeshCoP::TcatAgent>().HandleSingleTlv(*mReceivedMessage, *mSendMessage);
-
-                if (errorTcatAgent != kErrorNone)
-                {
-                    // kErrorAbort indicates that a Disconnect command TLV has been received.
-                    if (errorTcatAgent == kErrorAbort)
-                    {
-                        LogInfo("Disconnecting TCAT client.");
-                    }
-                    else
-                    {
-                        // fatal error: TLV integrity and order can't be guaranteed anymore for this connection.
-                        LogWarnOnError(errorTcatAgent, "HandleSingleTlv");
-                    }
-                    Disconnect();
-                    // BleSecure is not stopped here, it must remain active in advertising state and
-                    // must be ready to receive a next TCAT commissioner.
-                    ExitNow();
-                }
-                SuccessOrExit(error = Flush()); // send out the response generated by the TcatAgent
-            }
-            else
-            {
-                // if the TCAT agent is not connected - do callback using the TLV's value as the message
-                mReceivedMessage->SetOffset(static_cast<uint16_t>(offset));
-                mReceiveCallback.InvokeIfSet(&GetInstance(), mReceivedMessage, static_cast<int32_t>(offset),
-                                             OT_TCAT_APPLICATION_PROTOCOL_NONE);
+                mSendMessage = Get<MessagePool>().Allocate(Message::kTypeBle);
+                VerifyOrExit(mSendMessage != nullptr, error = kErrorNoBufs);
             }
 
-            IgnoreError(mReceivedMessage->SetLength(0)); // also sets the offset to 0
-            requiredBytes = sizeof(Tlv);                 // set for the next TLV
-        } // while (aLength > 0)
-    }
+            errorTcatAgent = Get<MeshCoP::TcatAgent>().HandleSingleTlv(*mReceivedMessage, *mSendMessage);
+
+            switch (errorTcatAgent)
+            {
+            case kErrorNone:
+                SuccessOrExit(error = Flush());
+                break;
+
+            case kErrorAbort:
+                // `kErrorAbort` indicates that a Disconnect command TLV
+                // has been received.
+                LogInfo("Disconnecting TCAT client.");
+                errorTcatAgent = kErrorNone;
+
+                OT_FALL_THROUGH;
+
+            default:
+                LogWarnOnError(errorTcatAgent, "HandleSingleTlv");
+
+                // BleSecure is disconnected but not stopped here, it
+                // must remain active in advertising state and must be
+                // ready to receive a next TCAT commissioner.
+
+                Disconnect();
+                ExitNow();
+            }
+        }
+        else
+        {
+            // If the TCAT agent is not connected - do callback using
+            // the TLV's value as the message
+            mReceivedMessage->SetOffset(static_cast<uint16_t>(offset));
+            mReceiveCallback.InvokeIfSet(&GetInstance(), mReceivedMessage, static_cast<int32_t>(offset),
+                                         OT_TCAT_APPLICATION_PROTOCOL_NONE);
+        }
+
+        IgnoreError(mReceivedMessage->SetLength(0));
+        requiredBytes = sizeof(Tlv);
+
+    } // while loop
 
 exit:
     if (error != kErrorNone)
     {
-        // in this very rare case, a partial TLV is received, or a TLV has been fully dropped, or Flush() failed.
-        // mSendMessage is most likely not initialized; so appending a GeneralError status TLV to mSendMessage would
-        // fail also. In this case it's not possible to recover TLV integrity and client/server sync.
-        // It's handled by logging the error and (necessarily) closing the secure connection.
+        // In this very rare case, a partial TLV is received, or a TLV
+        // has been fully dropped, or `Flush()` failed. `mSendMessage` is
+        // most likely not initialized; so appending a `GeneralError`
+        // status TLV to `mSendMessage` would fail also. In this case
+        // it's not possible to recover TLV integrity and client/server
+        // sync. It's handled by logging the error and (necessarily)
+        // closing the secure connection.
+
         LogCritOnError(error, "HandleTlsReceive");
         Disconnect();
     }
@@ -634,38 +668,25 @@
 
 Error BleSecure::HandleTransport(ot::Message &aMessage)
 {
-    otBleRadioPacket packet;
-    uint16_t         len    = aMessage.GetLength();
-    uint16_t         offset = 0;
-    Error            error  = kErrorNone;
+    Error       error = kErrorNone;
+    RadioPacket packet;
+    OffsetRange offsetRange;
 
-    while (len > 0)
+    offsetRange.InitFromMessageFullLength(aMessage);
+
+    while (!offsetRange.IsEmpty())
     {
-        if (len <= mMtuSize - kGattOverhead)
-        {
-            packet.mLength = len;
-        }
-        else
-        {
-            packet.mLength = mMtuSize - kGattOverhead;
-        }
+        packet.mLength = Min(offsetRange.GetLength(), Min<uint16_t>(mMtuSize - kGattOverhead, kPacketBufferSize));
 
-        if (packet.mLength > kPacketBufferSize)
-        {
-            packet.mLength = kPacketBufferSize;
-        }
-
-        IgnoreError(aMessage.Read(offset, mPacketBuffer, packet.mLength));
+        IgnoreError(aMessage.ReadAndAdvance(offsetRange, mPacketBuffer, packet.mLength));
         packet.mValue = mPacketBuffer;
         packet.mPower = OT_BLE_DEFAULT_POWER;
 
         SuccessOrExit(error = otPlatBleGattServerIndicate(&GetInstance(), kTxBleHandle, &packet));
-
-        len -= packet.mLength;
-        offset += packet.mLength;
     }
 
     aMessage.Free();
+
 exit:
     return error;
 }
diff --git a/src/core/radio/ble_secure.hpp b/src/core/radio/ble_secure.hpp
index cd0f699..0d39f36 100644
--- a/src/core/radio/ble_secure.hpp
+++ b/src/core/radio/ble_secure.hpp
@@ -35,6 +35,7 @@
 
 #include <openthread/ble_secure.h>
 
+#include "common/notifier.hpp"
 #include "meshcop/meshcop.hpp"
 #include "meshcop/secure_transport.hpp"
 #include "meshcop/tcat_agent.hpp"
@@ -54,6 +55,8 @@
 
 class BleSecure : public InstanceLocator, public MeshCoP::Tls::Extension, private NonCopyable
 {
+    friend class ot::Notifier;
+
 public:
     /**
      * Pointer to call when the secure BLE connection state changes.
@@ -346,6 +349,8 @@
         kNotAdvertising = 3, // Ble secure is started but not advertising.
     };
 
+    typedef otBleRadioPacket RadioPacket;
+
     static constexpr uint8_t  kInitialMtuSize   = OT_BLE_ATT_MTU_DEFAULT;
     static constexpr uint8_t  kMinMtuSize       = OT_BLE_ATT_MTU_MIN;
     static constexpr uint8_t  kMaxMtuSize       = OT_BLE_ATT_MTU_MAX;
@@ -354,6 +359,8 @@
     static constexpr uint16_t kTxBleHandle      = 0;   // Characteristics Handle for TX (not used)
     static constexpr uint16_t kTlsDataMaxSize   = 800; // Maximum size of data chunks sent with mTls.Send(..)
 
+    void HandleNotifierEvents(Events aEvents);
+
     static void HandleTlsConnectEvent(MeshCoP::Tls::ConnectEvent aEvent, void *aContext);
     void        HandleTlsConnectEvent(MeshCoP::Tls::ConnectEvent aEvent);
 
@@ -379,7 +386,7 @@
     TxTask                    mTransmitTask;
     uint8_t                   mPacketBuffer[kPacketBufferSize];
     BleState                  mBleState;
-    BleState                  mBleAdvRequestedState;
+    bool                      mIsBleAdvRequested;
     uint16_t                  mMtuSize;
 };
 
diff --git a/src/core/radio/radio.cpp b/src/core/radio/radio.cpp
index 05ed0c1..1914603 100644
--- a/src/core/radio/radio.cpp
+++ b/src/core/radio/radio.cpp
@@ -128,6 +128,8 @@
 }
 #endif // OPENTHREAD_CONFIG_RADIO_LINK_IEEE_802_15_4_ENABLE
 
+//---------------------------------------------------------------------------------------------------------------------
+
 #if OPENTHREAD_CONFIG_RADIO_STATS_ENABLE && (OPENTHREAD_FTD || OPENTHREAD_MTD)
 inline uint64_t UintSafeMinus(uint64_t aLhs, uint64_t aRhs) { return aLhs > aRhs ? (aLhs - aRhs) : 0; }
 
diff --git a/src/core/radio/radio.hpp b/src/core/radio/radio.hpp
index 31cb1d2..09a0bbe 100644
--- a/src/core/radio/radio.hpp
+++ b/src/core/radio/radio.hpp
@@ -46,6 +46,7 @@
 #include "common/numeric_limits.hpp"
 #include "common/time.hpp"
 #include "mac/mac_frame.hpp"
+#include "radio/radio_types.hpp"
 
 namespace ot {
 
@@ -523,7 +524,7 @@
      * @retval kErrorNone    Successfully scheduled receive window.
      * @retval kErrorFailed  The receive window could not be scheduled.
      */
-    Error ReceiveAt(uint8_t aChannel, uint32_t aStart, uint32_t aDuration);
+    Error ReceiveAt(uint8_t aChannel, RadioTime32 aStart, uint32_t aDuration);
 #endif
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
@@ -532,7 +533,7 @@
      *
      * @param[in]  aCslSampleTime  The CSL sample time.
      */
-    void UpdateCslSampleTime(uint32_t aCslSampleTime);
+    void UpdateCslSampleTime(RadioTime32 aCslSampleTime);
 
     /**
      * Enables CSL sampling in radio.
@@ -559,15 +560,21 @@
     Error ResetCsl(void);
 #endif // OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
 
-#if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE || \
-    OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
+#if OT_CONFIG_RADIO_TIME_ENABLE
     /**
      * Get the current radio time in microseconds referenced to a continuous monotonic local radio clock (64 bits
      * width).
      *
      * @returns The current radio clock time.
      */
-    uint64_t GetNow(void);
+    RadioTime64 GetNow(void);
+
+    /**
+     * Get the current radio time in microseconds as a 32-bit value (lower 32 bits of the full radio time).
+     *
+     * @returns The current radio clock time as a `RadioTime32`.
+     */
+    RadioTime32 GetNowAsRadioTime32(void) { return ConvertRadioTime64To32(GetNow()); }
 
     /**
      * Get the current accuracy, in units of ± ppm, of the clock used for scheduling CSL operations.
@@ -584,7 +591,7 @@
      * @returns The CSL Uncertainty in units of 10 us.
      */
     uint8_t GetCslUncertainty(void);
-#endif // OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
+#endif // OT_CONFIG_RADIO_TIME_ENABLE
 
     /**
      * Gets the radio transmit frame buffer.
@@ -723,7 +730,7 @@
      * @param[in]  aLinkMetrics  This parameter specifies what metrics to query. Per spec 4.11.3.4.4.6, at most 2
      *                           metrics can be specified. The probing would be disabled if @p `aLinkMetrics` is
      *                           bitwise 0.
-     * @param[in]  aShortAddress The short address of the the probing Initiator.
+     * @param[in]  aShortAddress The short address of the probing Initiator.
      * @param[in]  aExtAddress   The extended source address of the probing Initiator.
      *
      * @retval kErrorNone            Successfully enable/disable or update Enhanced-ACK Based Probing for a specific
@@ -916,15 +923,15 @@
                              const Mac::KeyMaterial &aCurrKey,
                              const Mac::KeyMaterial &aNextKey)
 {
-    otRadioKeyType aKeyType;
+    otRadioKeyType keyType;
 
 #if OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE
-    aKeyType = OT_KEY_TYPE_KEY_REF;
+    keyType = OT_KEY_TYPE_KEY_REF;
 #else
-    aKeyType = OT_KEY_TYPE_LITERAL_KEY;
+    keyType = OT_KEY_TYPE_LITERAL_KEY;
 #endif
 
-    otPlatRadioSetMacKey(GetInstancePtr(), aKeyIdMode, aKeyId, &aPrevKey, &aCurrKey, &aNextKey, aKeyType);
+    otPlatRadioSetMacKey(GetInstancePtr(), aKeyIdMode, aKeyId, &aPrevKey, &aCurrKey, &aNextKey, keyType);
 }
 
 inline Error Radio::GetTransmitPower(int8_t &aPower) { return otPlatRadioGetTransmitPower(GetInstancePtr(), &aPower); }
@@ -984,7 +991,7 @@
 }
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE
-inline Error Radio::ReceiveAt(uint8_t aChannel, uint32_t aStart, uint32_t aDuration)
+inline Error Radio::ReceiveAt(uint8_t aChannel, RadioTime32 aStart, uint32_t aDuration)
 {
     Error error = otPlatRadioReceiveAt(GetInstancePtr(), aChannel, aStart, aDuration);
 #if OPENTHREAD_CONFIG_RADIO_STATS_ENABLE && (OPENTHREAD_FTD || OPENTHREAD_MTD)
@@ -998,7 +1005,7 @@
 #endif
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-inline void Radio::UpdateCslSampleTime(uint32_t aCslSampleTime)
+inline void Radio::UpdateCslSampleTime(RadioTime32 aCslSampleTime)
 {
     otPlatRadioUpdateCslSampleTime(GetInstancePtr(), aCslSampleTime);
 }
@@ -1011,9 +1018,8 @@
 inline Error Radio::ResetCsl(void) { return otPlatRadioResetCsl(GetInstancePtr()); }
 #endif
 
-#if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE || \
-    OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-inline uint64_t Radio::GetNow(void) { return otPlatRadioGetNow(GetInstancePtr()); }
+#if OT_CONFIG_RADIO_TIME_ENABLE
+inline RadioTime64 Radio::GetNow(void) { return otPlatRadioGetNow(GetInstancePtr()); }
 
 inline uint8_t Radio::GetCslAccuracy(void) { return otPlatRadioGetCslAccuracy(GetInstancePtr()); }
 
@@ -1112,16 +1118,15 @@
 #endif
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-inline void Radio::UpdateCslSampleTime(uint32_t) {}
+inline void Radio::UpdateCslSampleTime(RadioTime32) {}
 
 inline Error Radio::EnableCsl(uint32_t, Mac::ShortAddress, const Mac::ExtAddress &) { return kErrorNotImplemented; }
 
 inline Error Radio::ResetCsl(void) { return kErrorNotImplemented; }
 #endif
 
-#if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE || \
-    OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-inline uint64_t Radio::GetNow(void) { return NumericLimits<uint64_t>::kMax; }
+#if OT_CONFIG_RADIO_TIME_ENABLE
+inline RadioTime64 Radio::GetNow(void) { return NumericLimits<uint64_t>::kMax; }
 
 inline uint8_t Radio::GetCslAccuracy(void) { return NumericLimits<uint8_t>::kMax; }
 
diff --git a/src/core/radio/radio_platform.cpp b/src/core/radio/radio_platform.cpp
index 976f8ab..fb09cdb 100644
--- a/src/core/radio/radio_platform.cpp
+++ b/src/core/radio/radio_platform.cpp
@@ -281,7 +281,7 @@
 
 extern "C" OT_TOOL_WEAK uint64_t otPlatTimeGet(void) { return UINT64_MAX; }
 
-extern "C" OT_TOOL_WEAK uint64_t otPlatRadioGetNow(otInstance *aInstance)
+extern "C" OT_TOOL_WEAK otRadioTime64 otPlatRadioGetNow(otInstance *aInstance)
 {
     OT_UNUSED_VARIABLE(aInstance);
 
@@ -366,10 +366,10 @@
     return kErrorNotImplemented;
 }
 
-extern "C" OT_TOOL_WEAK otError otPlatRadioReceiveAt(otInstance *aInstance,
-                                                     uint8_t     aChannel,
-                                                     uint32_t    aStart,
-                                                     uint32_t    aDuration)
+extern "C" OT_TOOL_WEAK otError otPlatRadioReceiveAt(otInstance   *aInstance,
+                                                     uint8_t       aChannel,
+                                                     otRadioTime32 aStart,
+                                                     uint32_t      aDuration)
 {
     OT_UNUSED_VARIABLE(aInstance);
     OT_UNUSED_VARIABLE(aChannel);
diff --git a/src/core/radio/radio_types.cpp b/src/core/radio/radio_types.cpp
new file mode 100644
index 0000000..29a064e
--- /dev/null
+++ b/src/core/radio/radio_types.cpp
@@ -0,0 +1,61 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file includes implementation of OpenThread radio types.
+ */
+
+#include "radio_types.hpp"
+
+#include "instance/instance.hpp"
+
+namespace ot {
+
+bool IsRadioTimeStrictlyBefore(RadioTime32 aFirstTime, RadioTime32 aSecondTime)
+{
+    Time firstTime(aFirstTime);
+    Time secondTime(aSecondTime);
+
+    return (firstTime < secondTime);
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// SyncedRadioLocalTime
+
+#if OT_CONFIG_RADIO_TIME_ENABLE && OPENTHREAD_CONFIG_PLATFORM_USEC_TIMER_ENABLE
+
+void SyncedRadioLocalTime::SetToNow(Radio &aRadio)
+{
+    mRadioTime = aRadio.GetNow();
+    mLocalTime = TimerMicro::GetNow();
+}
+
+#endif
+
+} // namespace ot
diff --git a/src/core/radio/radio_types.hpp b/src/core/radio/radio_types.hpp
new file mode 100644
index 0000000..579c596
--- /dev/null
+++ b/src/core/radio/radio_types.hpp
@@ -0,0 +1,156 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file includes definitions for OpenThread radio types.
+ */
+
+#ifndef OT_CORE_RADIO_RADIO_TYPES_HPP_
+#define OT_CORE_RADIO_RADIO_TYPES_HPP_
+
+#include "openthread-core-config.h"
+
+#include <openthread/platform/radio.h>
+
+#include "common/clearable.hpp"
+#include "common/time.hpp"
+
+namespace ot {
+
+#ifdef OT_CONFIG_RADIO_TIME_ENABLE
+#error "OT_CONFIG_RADIO_TIME_ENABLE MUST NOT be defined directly. It is derived from other configs"
+#endif
+
+#define OT_CONFIG_RADIO_TIME_ENABLE                                                               \
+    (OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE || \
+     OPENTHREAD_CONFIG_WAKEUP_END_DEVICE_ENABLE || OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE || \
+     OPENTHREAD_CONFIG_TIME_SYNC_ENABLE)
+
+class Radio;
+
+/**
+ * Represents a 64-bit radio time in microseconds referenced to a continuous monotonic local radio clock.
+ */
+typedef otRadioTime64 RadioTime64;
+
+/**
+ * Represents a 32-bit radio time in microseconds (holds the lower 32 bits of a `RadioTime64`).
+ */
+typedef otRadioTime32 RadioTime32;
+
+/**
+ * Converts a 64-bit radio time to a 32-bit radio time.
+ *
+ * @param[in] aRadioTime64  The 64-bit radio time to convert.
+ *
+ * @returns The converted 32-bit radio time (lower 32 bits of @p aRadioTime64).
+ */
+inline RadioTime32 ConvertRadioTime64To32(RadioTime64 aRadioTime64) { return static_cast<RadioTime32>(aRadioTime64); }
+
+/**
+ * Indicates whether a given 32-bit radio time is strictly before another 32-bit radio time.
+ *
+ * This function correctly accounts for 32-bit microsecond counter roll-over.
+ *
+ * @param[in] aFirstTime   The first 32-bit radio time to compare.
+ * @param[in] aSecondTime  The second 32-bit radio time to compare.
+ *
+ * @retval TRUE   @p aFirstTime is strictly before @p aSecondTime.
+ * @retval FALSE  @p aFirstTime is not strictly before @p aSecondTime.
+ */
+bool IsRadioTimeStrictlyBefore(RadioTime32 aFirstTime, RadioTime32 aSecondTime);
+
+#if OT_CONFIG_RADIO_TIME_ENABLE && OPENTHREAD_CONFIG_PLATFORM_USEC_TIMER_ENABLE
+
+/**
+ * Represents synchronized radio time and local `TimeMicro`.
+ */
+class SyncedRadioLocalTime : public Clearable<SyncedRadioLocalTime>
+{
+public:
+    /**
+     * Sets the synchronized radio and local time values to the current time.
+     *
+     * @param[in] aRadio  The `Radio` to use for radio time.
+     */
+    void SetToNow(Radio &aRadio);
+
+    /**
+     * Gets the local time as `TimeMicro`
+     *
+     * @returns The local microsecond time.
+     */
+    TimeMicro GetAsTimeMicro(void) const { return mLocalTime; }
+
+    /**
+     * Gets the 64-bit radio time.
+     *
+     * @returns The 64-bit radio time.
+     */
+    RadioTime64 GetAsRadio64(void) const { return mRadioTime; }
+
+    /**
+     * Gets the 32-bit radio time.
+     *
+     * @returns The 32-bit radio time.
+     */
+    RadioTime32 GetAsRadio32(void) const { return ConvertRadioTime64To32(mRadioTime); }
+
+    /**
+     * Advances the synchronized radio and local time values by a given duration.
+     *
+     * @param[in] aDuration  The duration in microseconds to add.
+     */
+    void operator+=(uint32_t aDuration)
+    {
+        mRadioTime += aDuration;
+        mLocalTime += aDuration;
+    }
+
+    /**
+     * Rewinds the synchronized radio and local time values by a given duration.
+     *
+     * @param[in] aDuration  The duration in microseconds to subtract.
+     */
+    void operator-=(uint32_t aDuration)
+    {
+        mRadioTime -= aDuration;
+        mLocalTime -= aDuration;
+    }
+
+private:
+    RadioTime64 mRadioTime;
+    TimeMicro   mLocalTime;
+};
+
+#endif // OT_CONFIG_RADIO_TIME_ENABLE && OPENTHREAD_CONFIG_PLATFORM_USEC_TIMER_ENABLE
+
+} // namespace ot
+
+#endif // OT_CORE_RADIO_RADIO_TYPES_HPP_
diff --git a/src/core/radio/trel_link.cpp b/src/core/radio/trel_link.cpp
index 46de530..aa3980f 100644
--- a/src/core/radio/trel_link.cpp
+++ b/src/core/radio/trel_link.cpp
@@ -326,12 +326,10 @@
 
     if (type != Header::kTypeAck)
     {
-        // No need to check state or channel for a TREL ack packet.
-        // Note that TREL ack may be received much later than the tx
-        // and device can be on a different rx channel.
+        // We do not check the radio state for a TREL ACK packet, as it
+        // can be received much later than the transmission.
 
         VerifyOrExit((mState == kStateReceive) || (mState == kStateTransmit));
-        VerifyOrExit(aPacket.GetHeader().GetChannel() == mRxChannel);
     }
 
     if (mPanId != Mac::kPanIdBroadcast)
@@ -370,6 +368,14 @@
         SendAck(aPacket);
     }
 
+    // Drop the packet if there is a channel mismatch. We perform this
+    // check after all other validations to ensure we still `SendAck()`.
+    // TREL ACKs are used to monitor the TREL link status between peers
+    // and should be sent even if the packet is sent on a different
+    // channel (e.g., an MLE Announce message).
+
+    VerifyOrExit(aPacket.GetHeader().GetChannel() == mRxChannel);
+
     mRxFrame.mPsdu    = aPacket.GetPayload();
     mRxFrame.mLength  = aPacket.GetPayloadLength();
     mRxFrame.mChannel = aPacket.GetHeader().GetChannel();
diff --git a/src/core/thread/address_resolver.cpp b/src/core/thread/address_resolver.cpp
index 41e9e9f..f81ea0a 100644
--- a/src/core/thread/address_resolver.cpp
+++ b/src/core/thread/address_resolver.cpp
@@ -123,8 +123,7 @@
         VerifyOrExit(entry->IsLastTransactionTimeValid());
 
         aInfo.mLastTransTime = entry->GetLastTransactionTime();
-        AsCoreType(&aInfo.mMeshLocalEid).SetPrefix(Get<Mle::Mle>().GetMeshLocalPrefix());
-        AsCoreType(&aInfo.mMeshLocalEid).SetIid(entry->GetMeshLocalIid());
+        Get<Mle::Mle>().ComposeMeshLocalAddress(entry->GetMeshLocalIid(), AsCoreType(&aInfo.mMeshLocalEid));
 
         ExitNow();
     }
@@ -628,17 +627,6 @@
     Get<TimeTicker>().RegisterReceiver(TimeTicker::kAddressResolver);
     FreeMessageOnError(message, error);
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    if (Get<BackboneRouter::Local>().IsPrimary() && Get<BackboneRouter::Leader>().IsDomainUnicast(aEid))
-    {
-        uint16_t selfRloc16 = Get<Mle::Mle>().GetRloc16();
-
-        LogInfo("Extending %s to %s for target %s, rloc16=%04x(self)", UriToString<kUriAddressQuery>(),
-                UriToString<kUriBackboneQuery>(), aEid.ToString().AsCString(), selfRloc16);
-        IgnoreError(Get<BackboneRouter::Manager>().SendBackboneQuery(aEid, selfRloc16));
-    }
-#endif
-
     return error;
 }
 
@@ -765,17 +753,7 @@
         if (address.GetAddress() == target && Get<Mle::Mle>().GetMeshLocalEid().GetIid() != meshLocalIid)
         {
             // Target EID matches address and Mesh Local EID differs
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-            if (Get<BackboneRouter::Leader>().IsDomainUnicast(address.GetAddress()))
-            {
-                Get<DuaManager>().NotifyDuplicateDomainUnicastAddress();
-            }
-            else
-#endif
-            {
-                Get<ThreadNetif>().RemoveUnicastAddress(address);
-            }
-
+            Get<ThreadNetif>().RemoveUnicastAddress(address);
             ExitNow();
         }
     }
@@ -797,7 +775,7 @@
 
             if (child.RemoveIp6Address(target) == kErrorNone)
             {
-                destination.SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), child.GetRloc16());
+                Get<Mle::Mle>().ComposeRloc(child.GetRloc16(), destination);
 
                 SendAddressError(target, meshLocalIid, destination);
                 ExitNow();
@@ -847,17 +825,6 @@
         }
     }
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-    if (Get<BackboneRouter::Local>().IsPrimary() && Get<BackboneRouter::Leader>().IsDomainUnicast(target))
-    {
-        uint16_t srcRloc16 = aMsg.mMessageInfo.GetPeerAddr().GetIid().GetLocator();
-
-        LogInfo("Extending %s to %s for target %s rloc16=%04x", UriToString<kUriAddressQuery>(),
-                UriToString<kUriBackboneQuery>(), target.ToString().AsCString(), srcRloc16);
-        IgnoreError(Get<BackboneRouter::Manager>().SendBackboneQuery(target, srcRloc16));
-    }
-#endif
-
 exit:
     return;
 }
@@ -1017,16 +984,16 @@
                                                                 AsCoreType(aIcmpHeader));
 }
 
-void AddressResolver::HandleIcmpReceive(Message                 &aMessage,
-                                        const Ip6::MessageInfo  &aMessageInfo,
-                                        const Ip6::Icmp::Header &aIcmpHeader)
+void AddressResolver::HandleIcmpReceive(Message                &aMessage,
+                                        const Ip6::MessageInfo &aMessageInfo,
+                                        const Ip6::Icmp6Header &aIcmpHeader)
 {
     OT_UNUSED_VARIABLE(aMessageInfo);
 
     Ip6::Header ip6Header;
 
-    VerifyOrExit(aIcmpHeader.GetType() == Ip6::Icmp::Header::kTypeDstUnreach);
-    VerifyOrExit(aIcmpHeader.GetCode() == Ip6::Icmp::Header::kCodeDstUnreachNoRoute);
+    VerifyOrExit(aIcmpHeader.GetType() == Ip6::Icmp6Header::kTypeDstUnreach);
+    VerifyOrExit(aIcmpHeader.GetCode() == Ip6::Icmp6Header::kCodeDstUnreachNoRoute);
     SuccessOrExit(aMessage.Read(aMessage.GetOffset(), ip6Header));
 
     Remove(ip6Header.GetDestination(), kReasonReceivedIcmpDstUnreachNoRoute);
diff --git a/src/core/thread/address_resolver.hpp b/src/core/thread/address_resolver.hpp
index 76b8540..e0feccb 100644
--- a/src/core/thread/address_resolver.hpp
+++ b/src/core/thread/address_resolver.hpp
@@ -122,7 +122,7 @@
      * @param[out]    aInfo      An `EntryInfo` where the EID cache entry information is placed.
      * @param[in,out] aIterator  An iterator. It will be updated to point to the next entry on success.
      *                           To get the first entry, initialize the iterator by setting all its fields to zero.
-     *                           e.g., `memset` the the iterator structure to zero.
+     *                           e.g., `memset` the iterator structure to zero.
      *
      * @retval kErrorNone      Successfully populated @p aInfo with the info for the next EID cache entry.
      * @retval kErrorNotFound  No more entries in the address cache table.
@@ -366,9 +366,9 @@
                                   otMessage           *aMessage,
                                   const otMessageInfo *aMessageInfo,
                                   const otIcmp6Header *aIcmpHeader);
-    void        HandleIcmpReceive(Message                 &aMessage,
-                                  const Ip6::MessageInfo  &aMessageInfo,
-                                  const Ip6::Icmp::Header &aIcmpHeader);
+    void        HandleIcmpReceive(Message                &aMessage,
+                                  const Ip6::MessageInfo &aMessageInfo,
+                                  const Ip6::Icmp6Header &aIcmpHeader);
 
     void        HandleTimeTick(void);
     void        LogCacheEntryChange(EntryChange       aChange,
diff --git a/src/core/thread/announce_sender.cpp b/src/core/thread/announce_sender.cpp
index 9790885..f49e71c 100644
--- a/src/core/thread/announce_sender.cpp
+++ b/src/core/thread/announce_sender.cpp
@@ -68,7 +68,7 @@
     mCount   = aCount;
     mChannel = mStartingChannel;
 
-    mTimer.Start(Random::NonCrypto::GetUint32InRange(0, mJitter + 1));
+    mTimer.Start(Random::NonCrypto::GenerateUpToExcluding<uint32_t>(mJitter + 1));
 
 exit:
     return;
@@ -206,7 +206,7 @@
 
     case Mle::kRoleChild:
 #if OPENTHREAD_FTD
-        if (Get<Mle::Mle>().IsRouterEligible() && Get<Mle::Mle>().IsRxOnWhenIdle())
+        if (Get<Mle::Mle>().IsRouterRoleAllowed() && Get<Mle::Mle>().IsRxOnWhenIdle())
         {
             break;
         }
diff --git a/src/core/thread/child.cpp b/src/core/thread/child.cpp
index a69b355..a39c45b 100644
--- a/src/core/thread/child.cpp
+++ b/src/core/thread/child.cpp
@@ -72,47 +72,6 @@
 }
 
 //---------------------------------------------------------------------------------------------------------------------
-// Child::Ip6AddrEntry
-
-#if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-
-MlrState Child::Ip6AddrEntry::GetMlrState(const Child &aChild) const
-{
-    MlrState                   state = kMlrStateRegistering;
-    Ip6AddressArray::IndexType index;
-
-    OT_ASSERT(aChild.mIp6Addresses.IsInArrayBuffer(this));
-
-    index = aChild.mIp6Addresses.IndexOf(*this);
-
-    if (aChild.mMlrToRegisterSet.Has(index))
-    {
-        state = kMlrStateToRegister;
-    }
-    else if (aChild.mMlrRegisteredSet.Has(index))
-    {
-        state = kMlrStateRegistered;
-    }
-
-    return state;
-}
-
-// NOLINTNEXTLINE(readability-make-member-function-const)
-void Child::Ip6AddrEntry::SetMlrState(MlrState aState, Child &aChild)
-{
-    Ip6AddressArray::IndexType index;
-
-    OT_ASSERT(aChild.mIp6Addresses.IsInArrayBuffer(this));
-
-    index = aChild.mIp6Addresses.IndexOf(*this);
-
-    aChild.mMlrToRegisterSet.Update(index, aState == kMlrStateToRegister);
-    aChild.mMlrRegisteredSet.Update(index, aState == kMlrStateRegistered);
-}
-
-#endif // OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-
-//---------------------------------------------------------------------------------------------------------------------
 // Child
 
 void Child::Clear(void)
@@ -128,7 +87,6 @@
     mMeshLocalIid.Clear();
     mIp6Addresses.Clear();
 #if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    mMlrToRegisterSet.Clear();
     mMlrRegisteredSet.Clear();
 #endif
 }
@@ -152,8 +110,7 @@
 
     VerifyOrExit(!mMeshLocalIid.IsUnspecified(), error = kErrorNotFound);
 
-    aAddress.SetPrefix(Get<Mle::Mle>().GetMeshLocalPrefix());
-    aAddress.SetIid(mMeshLocalIid);
+    Get<Mle::Mle>().ComposeMeshLocalAddress(mMeshLocalIid, aAddress);
 
 exit:
     return error;
@@ -188,6 +145,16 @@
 
     VerifyOrExit(!aAddress.IsUnspecified(), error = kErrorInvalidArgs);
 
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    if (Get<ChildTable>().IsMaxChildIpAddressesOverridden())
+    {
+        uint8_t num = mMeshLocalIid.IsUnspecified() ? 0 : 1;
+
+        num += mIp6Addresses.GetLength();
+        VerifyOrExit(num < Get<ChildTable>().GetMaxChildIpAddresses(), error = kErrorNoBufs);
+    }
+#endif
+
     if (Get<Mle::Mle>().IsMeshLocalAddress(aAddress))
     {
         VerifyOrExit(mMeshLocalIid.IsUnspecified(), error = kErrorAlready);
@@ -196,7 +163,7 @@
     }
 
     VerifyOrExit(!mIp6Addresses.ContainsMatching(aAddress), error = kErrorAlready);
-    error = mIp6Addresses.PushBack(static_cast<const Ip6AddrEntry &>(aAddress));
+    error = mIp6Addresses.PushBack(aAddress);
 
 exit:
     return error;
@@ -205,7 +172,7 @@
 Error Child::RemoveIp6Address(const Ip6::Address &aAddress)
 {
     Error         error = kErrorNotFound;
-    Ip6AddrEntry *entry;
+    Ip6::Address *entry;
 
     if (Get<Mle::Mle>().IsMeshLocalAddress(aAddress))
     {
@@ -230,9 +197,6 @@
         uint16_t entryIndex = mIp6Addresses.IndexOf(*entry);
         uint16_t lastIndex  = mIp6Addresses.GetLength() - 1;
 
-        mMlrToRegisterSet.Update(entryIndex, mMlrToRegisterSet.Has(lastIndex));
-        mMlrToRegisterSet.Remove(lastIndex);
-
         mMlrRegisteredSet.Update(entryIndex, mMlrRegisteredSet.Has(lastIndex));
         mMlrRegisteredSet.Remove(lastIndex);
     }
@@ -263,42 +227,49 @@
     return hasAddress;
 }
 
-#if OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-Error Child::GetDomainUnicastAddress(Ip6::Address &aAddress) const
-{
-    Error error = kErrorNotFound;
-
-    for (const Ip6::Address &ip6Address : mIp6Addresses)
-    {
-        if (Get<BackboneRouter::Leader>().IsDomainUnicast(ip6Address))
-        {
-            aAddress = ip6Address;
-            error    = kErrorNone;
-            ExitNow();
-        }
-    }
-
-exit:
-    return error;
-}
-#endif
-
 #if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
 
 bool Child::HasMlrRegisteredAddress(const Ip6::Address &aAddress) const
 {
     bool                hasAddress = false;
-    const Ip6AddrEntry *entry;
+    const Ip6::Address *entry;
 
     entry = mIp6Addresses.FindMatching(aAddress);
     VerifyOrExit(entry != nullptr);
-    hasAddress = entry->GetMlrState(*this) == kMlrStateRegistered;
+
+    hasAddress = mMlrRegisteredSet.Has(mIp6Addresses.IndexOf(*entry));
 
 exit:
     return hasAddress;
 }
 
-#endif
+void Child::SetAddressMlrRegistrationState(const Ip6::Address &aAddress, bool aRegistered)
+{
+    Ip6::Address *entry;
+
+    entry = mIp6Addresses.FindMatching(aAddress);
+    VerifyOrExit(entry != nullptr);
+
+    mMlrRegisteredSet.Update(mIp6Addresses.IndexOf(*entry), aRegistered);
+
+exit:
+    return;
+}
+
+void Child::GetAllMlrRegisteredAddresses(Ip6AddressArray &aAddressArray) const
+{
+    aAddressArray.Clear();
+
+    for (const Ip6::Address &entry : mIp6Addresses)
+    {
+        if (mMlrRegisteredSet.Has(mIp6Addresses.IndexOf(entry)))
+        {
+            IgnoreError(aAddressArray.PushBack(entry));
+        }
+    }
+}
+
+#endif // OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
 
 #endif // OPENTHREAD_FTD
 
diff --git a/src/core/thread/child.hpp b/src/core/thread/child.hpp
index abcd27d..9335b73 100644
--- a/src/core/thread/child.hpp
+++ b/src/core/thread/child.hpp
@@ -61,6 +61,13 @@
     static constexpr uint16_t kNumIp6Addresses = OPENTHREAD_CONFIG_MLE_IP_ADDRS_PER_CHILD - 1;
 
     /**
+     * Represents an array of IPv6 addresses registered by an MTD child.
+     *
+     * This array does not include the mesh-local EID.
+     */
+    typedef Array<Ip6::Address, kNumIp6Addresses> Ip6AddressArray;
+
+    /**
      * Represents the iterator for registered IPv6 address list of an MTD child.
      */
     typedef otChildIp6AddressIterator AddressIterator;
@@ -85,49 +92,6 @@
     };
 
     /**
-     * Represents an IPv6 address entry registered by an MTD child.
-     */
-    class Ip6AddrEntry : public Ip6::Address
-    {
-    public:
-        /**
-         * Indicates whether the entry matches a given IPv6 address.
-         *
-         * @param[in] aAddress   The IPv6 address.
-         *
-         * @retval TRUE   The entry matches @p aAddress.
-         * @retval FALSE  The entry does not match @p aAddress.
-         */
-        bool Matches(const Ip6::Address &aAddress) const { return (*this == aAddress); }
-
-#if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-        /**
-         * Gets the MLR state of the IPv6 address entry.
-         *
-         * @param[in] aChild  The child owning this address entry.
-         *
-         * @returns The MLR state of IPv6 address entry.
-         */
-        MlrState GetMlrState(const Child &aChild) const;
-
-        /**
-         * Sets the MLR state of the IPv6 address entry.
-         *
-         * @param[in] aState    The MLR state.
-         * @param[in] aChild    The child owning this address entry.
-         */
-        void SetMlrState(MlrState aState, Child &aChild);
-#endif
-    };
-
-    /**
-     * Represents an array of IPv6 address entries registered by an MTD child.
-     *
-     * This array does not include the mesh-local EID.
-     */
-    typedef Array<Ip6AddrEntry, kNumIp6Addresses> Ip6AddressArray;
-
-    /**
      * Initializes the `Child` object.
      *
      * @param[in] aInstance  A reference to OpenThread instance.
@@ -178,15 +142,6 @@
     const Ip6AddressArray &GetIp6Addresses(void) const { return mIp6Addresses; }
 
     /**
-     * Gets an array of registered IPv6 address entries by the child.
-     *
-     * The array does not include the mesh-local EID. The ML-EID can retrieved using  `GetMeshLocalIp6Address()`.
-     *
-     * @returns The array of registered IPv6 addresses by the child.
-     */
-    Ip6AddressArray &GetIp6Addresses(void) { return mIp6Addresses; }
-
-    /**
      * Iterates over all registered IPv6 addresses (using an iterator).
      *
      * @param[in,out]  aIterator  The iterator to use. On success the iterator will be updated.
@@ -201,7 +156,7 @@
     /**
      * Adds an IPv6 address to the list.
      *
-     * @param[in]  aAddress           A reference to IPv6 address to be added.
+     * @param[in]  aAddress        The IPv6 address to be added.
      *
      * @retval kErrorNone          Successfully added the new address.
      * @retval kErrorAlready       Address is already in the list.
@@ -213,7 +168,7 @@
     /**
      * Removes an IPv6 address from the list.
      *
-     * @param[in]  aAddress               A reference to IPv6 address to be removed.
+     * @param[in]  aAddress           The IPv6 address to be removed.
      *
      * @retval kErrorNone             Successfully removed the address.
      * @retval kErrorNotFound         Address was not found in the list.
@@ -224,25 +179,13 @@
     /**
      * Indicates whether an IPv6 address is in the list of IPv6 addresses of the child.
      *
-     * @param[in]  aAddress   A reference to IPv6 address.
+     * @param[in]  aAddress   The IPv6 address to check.
      *
      * @retval TRUE           The address exists on the list.
      * @retval FALSE          Address was not found in the list.
      */
     bool HasIp6Address(const Ip6::Address &aAddress) const;
 
-#if OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    /**
-     * Retrieves the Domain Unicast Address registered by the child.
-     *
-     * @param[out] aAddress    A reference to return the DUA address.
-     *
-     * @retval kErrorNone      Successfully retrieved the DUA address, @p aAddress is updated.
-     * @retval kErrorNotFound  Could not find any DUA address.
-     */
-    Error GetDomainUnicastAddress(Ip6::Address &aAddress) const;
-#endif
-
     /**
      * Gets the child timeout.
      *
@@ -336,32 +279,55 @@
      */
     void ResetSecondsSinceLastSupervision(void) { mSecondsSinceSupervision = 0; }
 
-#if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
     /**
-     * Returns if the Child has IPv6 address @p aAddress of MLR state `kMlrStateRegistered`.
+     * Indicates whether the child is blocking the parent from downgrading its router role.
+     *
+     * @retval TRUE   The child is blocking the parent from downgrading.
+     * @retval FALSE  The child is not blocking the parent from downgrading.
+     */
+    bool IsBlockingParentDowngrade(void) const { return mBlockParentDowngrade; }
+
+    /**
+     * Sets whether the child is blocking the parent from downgrading its router role.
+     *
+     * @param[in] aBlock  TRUE to block parent downgrade, FALSE otherwise.
+     */
+    void SetBlockParentDowngrade(bool aBlock) { mBlockParentDowngrade = aBlock; }
+
+#if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
+
+    /**
+     * Clears the Multicast Listener Registration (MLR) registration state of all IPv6 addresses of the child.
+     */
+    void ClearAllAddressesMlrRegistrationState(void) { mMlrRegisteredSet.Clear(); }
+
+    /**
+     * Indicates whether the child has a given IPv6 address that is MLR registered.
      *
      * @param[in] aAddress  The IPv6 address.
      *
-     * @retval true   If the Child has IPv6 address @p aAddress of MLR state `kMlrStateRegistered`.
-     * @retval false  If the Child does not have IPv6 address @p aAddress of MLR state `kMlrStateRegistered`.
+     * @retval TRUE   If the child has the IPv6 address @p aAddress and it is MLR registered.
+     * @retval FALSE  If the child does not have the IPv6 address @p aAddress or it is not MLR registered.
      */
     bool HasMlrRegisteredAddress(const Ip6::Address &aAddress) const;
 
     /**
-     * Returns if the Child has any IPv6 address of MLR state `kMlrStateRegistered`.
+     * Sets the MLR registered state of a given IPv6 address of the child.
      *
-     * @retval true   If the Child has any IPv6 address of MLR state `kMlrStateRegistered`.
-     * @retval false  If the Child does not have any IPv6 address of MLR state `kMlrStateRegistered`.
+     * If @p aAddress is not present on the list, calling this method does nothing.
+     *
+     * @param[in] aAddress     The address.
+     * @param[in] aRegistered  TRUE if MLR registered, FALSE otherwise.
      */
-    bool HasAnyMlrRegisteredAddress(void) const { return !mMlrRegisteredSet.IsEmpty(); }
+    void SetAddressMlrRegistrationState(const Ip6::Address &aAddress, bool aRegistered);
 
     /**
-     * Returns if the Child has any IPv6 address of MLR state `kMlrStateToRegister`.
+     * Gets all the MLR registered IPv6 addresses of the child.
      *
-     * @retval true   If the Child has any IPv6 address of MLR state `kMlrStateToRegister`.
-     * @retval false  If the Child does not have any IPv6 address of MLR state `kMlrStateToRegister`.
+     * @param[out] aAddressArray  A reference to an array to output the addresses.
      */
-    bool HasAnyMlrToRegisterAddress(void) const { return !mMlrToRegisterSet.IsEmpty(); }
+    void GetAllMlrRegisteredAddresses(Ip6AddressArray &aAddressArray) const;
+
 #endif // OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
 
 private:
@@ -372,10 +338,10 @@
     Ip6::InterfaceIdentifier mMeshLocalIid;
     Ip6AddressArray          mIp6Addresses;
 #if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    ChildIp6AddressSet mMlrToRegisterSet;
     ChildIp6AddressSet mMlrRegisteredSet;
 #endif
 
+    bool    mBlockParentDowngrade;
     uint8_t mNetworkDataVersion;
 
     union
diff --git a/src/core/thread/child_table.cpp b/src/core/thread/child_table.cpp
index e836049..35ec5b7 100644
--- a/src/core/thread/child_table.cpp
+++ b/src/core/thread/child_table.cpp
@@ -54,7 +54,7 @@
 {
     mItem = &Get<ChildTable>().mChildren[0];
 
-    if (!mItem->MatchesFilter(mFilter))
+    if (!mItem->Matches(mFilter))
     {
         Advance();
     }
@@ -67,8 +67,8 @@
     do
     {
         mItem++;
-        VerifyOrExit(mItem < &Get<ChildTable>().mChildren[Get<ChildTable>().mMaxChildrenAllowed], mItem = nullptr);
-    } while (!mItem->MatchesFilter(mFilter));
+        VerifyOrExit(mItem < Get<ChildTable>().mChildren.end(), mItem = nullptr);
+    } while (!mItem->Matches(mFilter));
 
 exit:
     return;
@@ -79,9 +79,13 @@
 
 ChildTable::ChildTable(Instance &aInstance)
     : InstanceLocator(aInstance)
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    , mMaxChildIpAddresses(0)
+#endif
     , mNextChildId(Mle::kMaxChildId)
-    , mMaxChildrenAllowed(kMaxChildren)
 {
+    mChildren.SetLength(kMaxChildren);
+
     for (Child &child : mChildren)
     {
         child.Init(aInstance);
@@ -97,16 +101,7 @@
     }
 }
 
-Child *ChildTable::GetChildAtIndex(uint16_t aChildIndex)
-{
-    Child *child = nullptr;
-
-    VerifyOrExit(aChildIndex < mMaxChildrenAllowed);
-    child = &mChildren[aChildIndex];
-
-exit:
-    return child;
-}
+Child *ChildTable::GetChildAtIndex(uint16_t aChildIndex) { return mChildren.At(aChildIndex); }
 
 Child *ChildTable::GetNewChild(void)
 {
@@ -141,20 +136,7 @@
 
 const Child *ChildTable::FindChild(const Child::AddressMatcher &aMatcher) const
 {
-    const Child *child = mChildren;
-
-    for (uint16_t num = mMaxChildrenAllowed; num != 0; num--, child++)
-    {
-        if (child->Matches(aMatcher))
-        {
-            ExitNow();
-        }
-    }
-
-    child = nullptr;
-
-exit:
-    return child;
+    return mChildren.FindMatching(aMatcher);
 }
 
 Child *ChildTable::FindChild(uint16_t aRloc16, Child::StateFilter aFilter)
@@ -174,24 +156,10 @@
 
 bool ChildTable::HasChildren(Child::StateFilter aFilter) const
 {
-    return (FindChild(Child::AddressMatcher(aFilter)) != nullptr);
+    return mChildren.ContainsMatching(Child::AddressMatcher(aFilter));
 }
 
-uint16_t ChildTable::GetNumChildren(Child::StateFilter aFilter) const
-{
-    uint16_t     numChildren = 0;
-    const Child *child       = mChildren;
-
-    for (uint16_t num = mMaxChildrenAllowed; num != 0; num--, child++)
-    {
-        if (child->MatchesFilter(aFilter))
-        {
-            numChildren++;
-        }
-    }
-
-    return numChildren;
-}
+uint16_t ChildTable::GetNumChildren(Child::StateFilter aFilter) const { return mChildren.CountMatching(aFilter); }
 
 Error ChildTable::SetMaxChildrenAllowed(uint16_t aMaxChildren)
 {
@@ -200,7 +168,7 @@
     VerifyOrExit(aMaxChildren > 0 && aMaxChildren <= kMaxChildren, error = kErrorInvalidArgs);
     VerifyOrExit(!HasChildren(Child::kInStateAnyExceptInvalid), error = kErrorInvalidState);
 
-    mMaxChildrenAllowed = aMaxChildren;
+    mChildren.SetLength(aMaxChildren);
 
 exit:
     return error;
@@ -322,18 +290,16 @@
 
 void ChildTable::RefreshStoredChildren(void)
 {
-    const Child *child = &mChildren[0];
-
     Get<Settings>().DeleteAllChildInfo();
 
-    for (uint16_t num = mMaxChildrenAllowed; num != 0; num--, child++)
+    for (const Child &child : mChildren)
     {
-        if (child->IsStateInvalid())
+        if (child.IsStateInvalid())
         {
             continue;
         }
 
-        SuccessOrExit(StoreChild(*child));
+        SuccessOrExit(StoreChild(child));
     }
 
 exit:
@@ -358,12 +324,11 @@
 
 bool ChildTable::HasSleepyChildWithAddress(const Ip6::Address &aIp6Address) const
 {
-    bool         hasChild = false;
-    const Child *child    = &mChildren[0];
+    bool hasChild = false;
 
-    for (uint16_t num = mMaxChildrenAllowed; num != 0; num--, child++)
+    for (const Child &child : mChildren)
     {
-        if (child->IsStateValidOrRestoring() && !child->IsRxOnWhenIdle() && child->HasIp6Address(aIp6Address))
+        if (child.IsStateValidOrRestoring() && !child.IsRxOnWhenIdle() && child.HasIp6Address(aIp6Address))
         {
             hasChild = true;
             break;
@@ -373,6 +338,21 @@
     return hasChild;
 }
 
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+
+Error ChildTable::OverrideMaxChildIpAddresses(uint8_t aMaxIpAddresses)
+{
+    Error error = kErrorNone;
+
+    VerifyOrExit(aMaxIpAddresses <= kMaxChildIpAddresses, error = kErrorInvalidArgs);
+    mMaxChildIpAddresses = aMaxIpAddresses;
+
+exit:
+    return error;
+}
+
+#endif
+
 } // namespace ot
 
 #endif // OPENTHREAD_FTD
diff --git a/src/core/thread/child_table.hpp b/src/core/thread/child_table.hpp
index f6b3abf..af64f2d 100644
--- a/src/core/thread/child_table.hpp
+++ b/src/core/thread/child_table.hpp
@@ -38,6 +38,7 @@
 
 #if OPENTHREAD_FTD
 
+#include "common/array.hpp"
 #include "common/const_cast.hpp"
 #include "common/iterator_utils.hpp"
 #include "common/locator.hpp"
@@ -115,7 +116,7 @@
      *
      * @returns The index corresponding to @p aChild.
      */
-    uint16_t GetChildIndex(const Child &aChild) const { return static_cast<uint16_t>(&aChild - mChildren); }
+    uint16_t GetChildIndex(const Child &aChild) const { return mChildren.IndexOf(aChild); }
 
     /**
      * Returns a pointer to a `Child` entry at a given index, or `nullptr` if the index is out of bounds,
@@ -207,7 +208,7 @@
      *
      * @returns  The maximum number of children allowed.
      */
-    uint16_t GetMaxChildrenAllowed(void) const { return mMaxChildrenAllowed; }
+    uint16_t GetMaxChildrenAllowed(void) const { return mChildren.GetLength(); }
 
     /**
      * Sets the maximum number of children allowed.
@@ -306,15 +307,46 @@
      * @retval TRUE  if @p aNeighbor is a `Child` in the child table.
      * @retval FALSE if @p aNeighbor is not a `Child` in the child table.
      */
-    bool Contains(const Neighbor &aNeighbor) const
-    {
-        const void *child = &aNeighbor;
+    bool Contains(const Neighbor &aNeighbor) const { return mChildren.IsInArrayBuffer(&aNeighbor); }
 
-        return (mChildren <= child) && (child < GetArrayEnd(mChildren));
+    /**
+     * Gets the maximum number of IP addresses that each MTD child may register with this device as parent.
+     *
+     * @returns The maximum number of IP addresses that each MTD child may register with this device as parent.
+     */
+    uint8_t GetMaxChildIpAddresses(void) const
+    {
+        return
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+            IsMaxChildIpAddressesOverridden() ? mMaxChildIpAddresses :
+#endif
+                                              kMaxChildIpAddresses;
     }
 
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    /**
+     * Indicates whether the maximum number of IP addresses is overridden.
+     *
+     * @retval TRUE   If the maximum number of IP addresses is overridden.
+     * @retval FALSE  If the maximum number of IP addresses is not overridden.
+     */
+    bool IsMaxChildIpAddressesOverridden(void) const { return (mMaxChildIpAddresses != 0); }
+
+    /**
+     * Overrides the maximum number of IP addresses that each MTD child may register with this device as parent.
+     *
+     * @param[in]  aMaxIpAddresses  The maximum number of IP addresses that each MTD child may register with this
+     *                              device as parent. Zero to clear the setting and restore the default.
+     *
+     * @retval kErrorNone           Successfully set/cleared the number.
+     * @retval kErrorInvalidArgs    If exceeds the allowed maximum number.
+     */
+    Error OverrideMaxChildIpAddresses(uint8_t aMaxIpAddresses);
+#endif
+
 private:
-    static constexpr uint16_t kMaxChildren = OPENTHREAD_CONFIG_MLE_MAX_CHILDREN;
+    static constexpr uint16_t kMaxChildren         = OPENTHREAD_CONFIG_MLE_MAX_CHILDREN;
+    static constexpr uint8_t  kMaxChildIpAddresses = OPENTHREAD_CONFIG_MLE_IP_ADDRS_PER_CHILD;
 
     class IteratorBuilder : public InstanceLocator
     {
@@ -337,9 +369,11 @@
     const Child *FindChild(const Child::AddressMatcher &aMatcher) const;
     void         RefreshStoredChildren(void);
 
-    uint16_t mNextChildId;
-    uint16_t mMaxChildrenAllowed;
-    Child    mChildren[kMaxChildren];
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    uint8_t mMaxChildIpAddresses;
+#endif
+    Array<Child, kMaxChildren, uint16_t> mChildren;
+    uint16_t                             mNextChildId;
 };
 
 } // namespace ot
diff --git a/src/core/thread/csl_tx_scheduler.cpp b/src/core/thread/csl_tx_scheduler.cpp
index 6e4ba08..9b2a89d 100644
--- a/src/core/thread/csl_tx_scheduler.cpp
+++ b/src/core/thread/csl_tx_scheduler.cpp
@@ -135,12 +135,12 @@
                                                      uint32_t          &aDelayFromLastRx,
                                                      uint32_t           aAheadUs) const
 {
-    uint64_t radioNow   = Get<Radio>().GetNow();
-    uint32_t periodInUs = aCslNeighbor.GetCslPeriod() * kUsPerTenSymbols;
+    // See CslTxScheduler::NeighborInfo::mCslPhase
 
-    /* see CslTxScheduler::NeighborInfo::mCslPhase */
-    uint64_t firstTxWindow = aCslNeighbor.GetLastRxTimestamp() + aCslNeighbor.GetCslPhase() * kUsPerTenSymbols;
-    uint64_t nextTxWindow  = radioNow - (radioNow % periodInUs) + (firstTxWindow % periodInUs);
+    RadioTime64 radioNow      = Get<Radio>().GetNow();
+    uint32_t    periodInUs    = aCslNeighbor.GetCslPeriod() * kUsPerTenSymbols;
+    RadioTime64 firstTxWindow = aCslNeighbor.GetLastRxTimestamp() + aCslNeighbor.GetCslPhase() * kUsPerTenSymbols;
+    RadioTime64 nextTxWindow  = radioNow - (radioNow % periodInUs) + (firstTxWindow % periodInUs);
 
     while (nextTxWindow < radioNow + aAheadUs)
     {
@@ -222,8 +222,7 @@
     VerifyOrExit(delay <= mCslFrameRequestAheadUs + kFramePreparationGuardInterval, frame = nullptr);
 
     frame->SetTxDelay(txDelay);
-    frame->SetTxDelayBaseTime(
-        static_cast<uint32_t>(mCslTxNeighbor->GetLastRxTimestamp())); // Only LSB part of the time is required.
+    frame->SetTxDelayBaseTime(ConvertRadioTime64To32(mCslTxNeighbor->GetLastRxTimestamp()));
     frame->SetCsmaCaEnabled(true);
 
 exit:
diff --git a/src/core/thread/csl_tx_scheduler.hpp b/src/core/thread/csl_tx_scheduler.hpp
index 78e6556..85fa43b 100644
--- a/src/core/thread/csl_tx_scheduler.hpp
+++ b/src/core/thread/csl_tx_scheduler.hpp
@@ -95,8 +95,8 @@
         TimeMilli GetCslLastHeard(void) const { return mCslLastHeard; }
         void      SetCslLastHeard(TimeMilli aCslLastHeard) { mCslLastHeard = aCslLastHeard; }
 
-        uint64_t GetLastRxTimestamp(void) const { return mLastRxTimestamp; }
-        void     SetLastRxTimestamp(uint64_t aLastRxTimestamp) { mLastRxTimestamp = aLastRxTimestamp; }
+        RadioTime64 GetLastRxTimestamp(void) const { return mLastRxTimestamp; }
+        void        SetLastRxTimestamp(RadioTime64 aLastRxTimestamp) { mLastRxTimestamp = aLastRxTimestamp; }
 
     private:
         uint8_t  mCslTxAttempts : 7;   ///< Number of CSL triggered tx attempts.
diff --git a/src/core/thread/discover_scanner.cpp b/src/core/thread/discover_scanner.cpp
index 77f06e1..5c24bf8 100644
--- a/src/core/thread/discover_scanner.cpp
+++ b/src/core/thread/discover_scanner.cpp
@@ -318,7 +318,7 @@
     ScanResult                         result;
     OffsetRange                        offsetRange;
     MeshCoP::DiscoveryResponseTlvValue respTlvValue;
-    MeshCoP::SteeringDataTlv           steeringDataTlv;
+    MeshCoP::SteeringData              steeringData;
 
     Mle::Log(Mle::kMessageReceive, Mle::kTypeDiscoveryResponse, aRxInfo.mMessageInfo.GetPeerAddr());
 
@@ -363,19 +363,14 @@
         ExitNow(error = kErrorParse);
     }
 
-    switch (Tlv::FindTlv(aRxInfo.mMessage, steeringDataTlv))
+    switch (MeshCoP::SteeringDataTlv::FindIn(aRxInfo.mMessage, steeringData))
     {
     case kErrorNone:
-        if (steeringDataTlv.IsValid())
+        AsCoreType(&result.mSteeringData) = steeringData;
+
+        if (mEnableFiltering)
         {
-            MeshCoP::SteeringData &steeringData = AsCoreType(&result.mSteeringData);
-
-            IgnoreError(steeringDataTlv.CopyTo(steeringData));
-
-            if (mEnableFiltering)
-            {
-                VerifyOrExit(steeringData.Contains(mFilterIndexes));
-            }
+            VerifyOrExit(steeringData.Contains(mFilterIndexes));
         }
 
         break;
diff --git a/src/core/thread/dua_manager.cpp b/src/core/thread/dua_manager.cpp
deleted file mode 100644
index e5f24b1..0000000
--- a/src/core/thread/dua_manager.cpp
+++ /dev/null
@@ -1,755 +0,0 @@
-/*
- *  Copyright (c) 2020, The OpenThread Authors.
- *  All rights reserved.
- *
- *  Redistribution and use in source and binary forms, with or without
- *  modification, are permitted provided that the following conditions are met:
- *  1. Redistributions of source code must retain the above copyright
- *     notice, this list of conditions and the following disclaimer.
- *  2. Redistributions in binary form must reproduce the above copyright
- *     notice, this list of conditions and the following disclaimer in the
- *     documentation and/or other materials provided with the distribution.
- *  3. Neither the name of the copyright holder nor the
- *     names of its contributors may be used to endorse or promote products
- *     derived from this software without specific prior written permission.
- *
- *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- *  POSSIBILITY OF SUCH DAMAGE.
- */
-
-/**
- * @file
- *   This file implements managing DUA.
- */
-
-#include "dua_manager.hpp"
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-
-#include "instance/instance.hpp"
-
-namespace ot {
-
-RegisterLogModule("DuaManager");
-
-DuaManager::DuaManager(Instance &aInstance)
-    : InstanceLocator(aInstance)
-    , mRegistrationTask(aInstance)
-    , mIsDuaPending(false)
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    , mDuaState(kNotExist)
-    , mDadCounter(0)
-    , mLastRegistrationTime(0)
-#endif
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    , mChildIndexDuaRegistering(Mle::kMaxChildren)
-#endif
-{
-    mDelay.mValue = 0;
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    mDomainUnicastAddress.InitAsThreadOriginGlobalScope();
-    mFixedDuaInterfaceIdentifier.Clear();
-#endif
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    mChildDuaMask.Clear();
-    mChildDuaRegisteredMask.Clear();
-#endif
-}
-
-void DuaManager::HandleDomainPrefixUpdate(BackboneRouter::DomainPrefixEvent aEvent)
-{
-    if ((aEvent == BackboneRouter::kDomainPrefixRemoved) || (aEvent == BackboneRouter::kDomainPrefixRefreshed))
-    {
-        if (mIsDuaPending)
-        {
-            IgnoreError(Get<Tmf::Agent>().AbortTransaction(HandleDuaResponse, this));
-        }
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-        RemoveDomainUnicastAddress();
-#endif
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-        if (!mChildDuaMask.IsEmpty())
-        {
-            mChildDuaMask.Clear();
-            mChildDuaRegisteredMask.Clear();
-        }
-#endif
-    }
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    switch (aEvent)
-    {
-    case BackboneRouter::kDomainPrefixUnchanged:
-        // In case removed for some reason e.g. the kDuaInvalid response from PBBR forcefully
-        VerifyOrExit(!Get<ThreadNetif>().HasUnicastAddress(GetDomainUnicastAddress()));
-
-        OT_FALL_THROUGH;
-
-    case BackboneRouter::kDomainPrefixRefreshed:
-    case BackboneRouter::kDomainPrefixAdded:
-    {
-        const Ip6::Prefix *prefix = Get<BackboneRouter::Leader>().GetDomainPrefix();
-        OT_ASSERT(prefix != nullptr);
-        mDomainUnicastAddress.mPrefixLength = prefix->GetLength();
-        mDomainUnicastAddress.GetAddress().Clear();
-        mDomainUnicastAddress.GetAddress().SetPrefix(*prefix);
-    }
-    break;
-    default:
-        ExitNow();
-    }
-
-    // Apply cached DUA Interface Identifier manually specified.
-    if (IsFixedDuaInterfaceIdentifierSet())
-    {
-        mDomainUnicastAddress.GetAddress().SetIid(mFixedDuaInterfaceIdentifier);
-    }
-    else
-    {
-        SuccessOrExit(GenerateDomainUnicastAddressIid());
-    }
-
-    AddDomainUnicastAddress();
-
-exit:
-    return;
-#endif
-}
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-Error DuaManager::GenerateDomainUnicastAddressIid(void)
-{
-    Error   error;
-    uint8_t dadCounter = mDadCounter;
-
-    if ((error = Get<Ip6::Slaac>().GenerateIid(mDomainUnicastAddress, dadCounter)) == kErrorNone)
-    {
-        if (dadCounter != mDadCounter)
-        {
-            mDadCounter = dadCounter;
-            Store();
-        }
-
-        LogInfo("Generated DUA: %s", mDomainUnicastAddress.GetAddress().ToString().AsCString());
-    }
-    else
-    {
-        LogWarnOnError(error, "generate DUA");
-    }
-
-    return error;
-}
-
-Error DuaManager::SetFixedDuaInterfaceIdentifier(const Ip6::InterfaceIdentifier &aIid)
-{
-    Error error = kErrorNone;
-
-    VerifyOrExit(!aIid.IsReserved(), error = kErrorInvalidArgs);
-    VerifyOrExit(mFixedDuaInterfaceIdentifier.IsUnspecified() || mFixedDuaInterfaceIdentifier != aIid);
-
-    mFixedDuaInterfaceIdentifier = aIid;
-    LogInfo("Set DUA IID: %s", mFixedDuaInterfaceIdentifier.ToString().AsCString());
-
-    if (Get<ThreadNetif>().HasUnicastAddress(GetDomainUnicastAddress()))
-    {
-        RemoveDomainUnicastAddress();
-        mDomainUnicastAddress.GetAddress().SetIid(mFixedDuaInterfaceIdentifier);
-        AddDomainUnicastAddress();
-    }
-
-exit:
-    return error;
-}
-
-void DuaManager::ClearFixedDuaInterfaceIdentifier(void)
-{
-    // Nothing to clear.
-    VerifyOrExit(IsFixedDuaInterfaceIdentifierSet());
-
-    if (GetDomainUnicastAddress().GetIid() == mFixedDuaInterfaceIdentifier &&
-        Get<ThreadNetif>().HasUnicastAddress(GetDomainUnicastAddress()))
-    {
-        RemoveDomainUnicastAddress();
-
-        if (GenerateDomainUnicastAddressIid() == kErrorNone)
-        {
-            AddDomainUnicastAddress();
-        }
-    }
-
-    LogInfo("Cleared DUA IID: %s", mFixedDuaInterfaceIdentifier.ToString().AsCString());
-    mFixedDuaInterfaceIdentifier.Clear();
-
-exit:
-    return;
-}
-
-void DuaManager::Restore(void)
-{
-    Settings::DadInfo dadInfo;
-
-    SuccessOrExit(Get<Settings>().Read(dadInfo));
-    mDadCounter = dadInfo.GetDadCounter();
-
-exit:
-    return;
-}
-
-void DuaManager::Store(void)
-{
-    Settings::DadInfo dadInfo;
-
-    dadInfo.SetDadCounter(mDadCounter);
-    Get<Settings>().Save(dadInfo);
-}
-
-void DuaManager::AddDomainUnicastAddress(void)
-{
-    mDuaState             = kToRegister;
-    mLastRegistrationTime = TimerMilli::GetNow();
-    Get<ThreadNetif>().AddUnicastAddress(mDomainUnicastAddress);
-}
-
-void DuaManager::RemoveDomainUnicastAddress(void)
-{
-    if (mDuaState == kRegistering && mIsDuaPending)
-    {
-        IgnoreError(Get<Tmf::Agent>().AbortTransaction(HandleDuaResponse, this));
-    }
-
-    mDuaState                        = kNotExist;
-    mDomainUnicastAddress.mPreferred = false;
-    Get<ThreadNetif>().RemoveUnicastAddress(mDomainUnicastAddress);
-}
-
-void DuaManager::UpdateRegistrationDelay(uint8_t aDelay)
-{
-    if (mDelay.mFields.mRegistrationDelay == 0 || mDelay.mFields.mRegistrationDelay > aDelay)
-    {
-        mDelay.mFields.mRegistrationDelay = aDelay;
-
-        LogDebg("update regdelay %d", mDelay.mFields.mRegistrationDelay);
-        UpdateTimeTickerRegistration();
-    }
-}
-
-void DuaManager::NotifyDuplicateDomainUnicastAddress(void)
-{
-    RemoveDomainUnicastAddress();
-    mDadCounter++;
-
-    if (GenerateDomainUnicastAddressIid() == kErrorNone)
-    {
-        AddDomainUnicastAddress();
-    }
-}
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE
-
-void DuaManager::UpdateReregistrationDelay(void)
-{
-    uint16_t               delay = 0;
-    BackboneRouter::Config config;
-
-    VerifyOrExit(Get<BackboneRouter::Leader>().GetConfig(config) == kErrorNone);
-
-    delay = config.mReregistrationDelay > 1 ? Random::NonCrypto::GetUint16InRange(1, config.mReregistrationDelay) : 1;
-
-    if (mDelay.mFields.mReregistrationDelay == 0 || mDelay.mFields.mReregistrationDelay > delay)
-    {
-        mDelay.mFields.mReregistrationDelay = delay;
-        UpdateTimeTickerRegistration();
-        LogDebg("update reregdelay %d", mDelay.mFields.mReregistrationDelay);
-    }
-
-exit:
-    return;
-}
-
-void DuaManager::UpdateCheckDelay(uint8_t aDelay)
-{
-    if (mDelay.mFields.mCheckDelay == 0 || mDelay.mFields.mCheckDelay > aDelay)
-    {
-        mDelay.mFields.mCheckDelay = aDelay;
-
-        LogDebg("update checkdelay %d", mDelay.mFields.mCheckDelay);
-        UpdateTimeTickerRegistration();
-    }
-}
-
-void DuaManager::HandleNotifierEvents(Events aEvents)
-{
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    if (aEvents.Contains(kEventThreadNetdataChanged) && Get<ThreadNetif>().HasUnicastAddress(GetDomainUnicastAddress()))
-    {
-        Lowpan::Context context;
-
-        Get<NetworkData::Leader>().FindContextForAddress(GetDomainUnicastAddress(), context);
-
-        if (!context.IsValid())
-        {
-            RemoveDomainUnicastAddress();
-        }
-    }
-#endif
-
-    VerifyOrExit(Get<Mle::Mle>().IsAttached(), mDelay.mValue = 0);
-
-    if (aEvents.Contains(kEventThreadRoleChanged))
-    {
-        if (Get<Mle::Mle>().HasRestored())
-        {
-            UpdateReregistrationDelay();
-        }
-#if OPENTHREAD_CONFIG_DUA_ENABLE && OPENTHREAD_FTD
-        else if (Get<Mle::Mle>().IsRouter())
-        {
-            // Wait for link establishment with neighboring routers.
-            UpdateRegistrationDelay(kNewRouterRegistrationDelay);
-        }
-        else if (Get<Mle::Mle>().WillBecomeRouterSoon())
-        {
-            // Will check again in case the device decides to stay REED when jitter timeout expires.
-            UpdateRegistrationDelay(Get<Mle::Mle>().GetRouterRoleTransitionTimeout() + kNewRouterRegistrationDelay + 1);
-        }
-#endif
-    }
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    if (aEvents.ContainsAny(kEventIp6AddressAdded))
-    {
-        UpdateRegistrationDelay(kNewDuaRegistrationDelay);
-    }
-#endif
-
-exit:
-    return;
-}
-
-void DuaManager::HandleBackboneRouterPrimaryUpdate(BackboneRouter::Leader::State aState,
-                                                   const BackboneRouter::Config &aConfig)
-{
-    OT_UNUSED_VARIABLE(aConfig);
-
-    if (aState == BackboneRouter::Leader::kStateAdded || aState == BackboneRouter::Leader::kStateToTriggerRereg)
-    {
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-        if (Get<Mle::Mle>().IsFullThreadDevice() || Get<Mle::Mle>().GetParent().IsThreadVersion1p1())
-#endif
-        {
-            UpdateReregistrationDelay();
-        }
-    }
-}
-
-void DuaManager::HandleTimeTick(void)
-{
-    bool attempt = false;
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    LogDebg("regdelay %d, reregdelay %d, checkdelay %d", mDelay.mFields.mRegistrationDelay,
-            mDelay.mFields.mReregistrationDelay, mDelay.mFields.mCheckDelay);
-
-    if ((mDuaState != kNotExist) &&
-        (TimerMilli::GetNow() > (mLastRegistrationTime + TimeMilli::SecToMsec(kDuaDadPeriod))))
-    {
-        mDomainUnicastAddress.mPreferred = true;
-    }
-
-    if ((mDelay.mFields.mRegistrationDelay > 0) && (--mDelay.mFields.mRegistrationDelay == 0))
-    {
-        attempt = true;
-    }
-#else
-    LogDebg("reregdelay %d, checkdelay %d", mDelay.mFields.mReregistrationDelay, mDelay.mFields.mCheckDelay);
-#endif
-
-    if ((mDelay.mFields.mCheckDelay > 0) && (--mDelay.mFields.mCheckDelay == 0))
-    {
-        attempt = true;
-    }
-
-    if ((mDelay.mFields.mReregistrationDelay > 0) && (--mDelay.mFields.mReregistrationDelay == 0))
-    {
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-        if (mDuaState != kNotExist)
-        {
-            mDuaState = kToRegister;
-        }
-#endif
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-        mChildDuaRegisteredMask.Clear();
-#endif
-        attempt = true;
-    }
-
-    if (attempt)
-    {
-        mRegistrationTask.Post();
-    }
-
-    UpdateTimeTickerRegistration();
-}
-
-void DuaManager::UpdateTimeTickerRegistration(void)
-{
-    if (mDelay.mValue == 0)
-    {
-        Get<TimeTicker>().UnregisterReceiver(TimeTicker::kDuaManager);
-    }
-    else
-    {
-        Get<TimeTicker>().RegisterReceiver(TimeTicker::kDuaManager);
-    }
-}
-
-void DuaManager::PerformNextRegistration(void)
-{
-    Error          error   = kErrorNone;
-    Coap::Message *message = nullptr;
-    Ip6::Address   dua;
-    Ip6::Address   destAddr;
-
-    VerifyOrExit(Get<Mle::Mle>().IsAttached());
-    VerifyOrExit(Get<BackboneRouter::Leader>().HasPrimary());
-
-    // Only allow one outgoing DUA.req
-    VerifyOrExit(!mIsDuaPending);
-
-    // Only send DUA.req when necessary
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-#if OPENTHREAD_FTD
-    if (!Get<Mle::Mle>().IsRouterOrLeader() && Get<Mle::Mle>().WillBecomeRouterSoon())
-    {
-        UpdateRegistrationDelay(Get<Mle::Mle>().GetRouterRoleTransitionTimeout() + kNewRouterRegistrationDelay + 1);
-        ExitNow();
-    }
-#endif
-    VerifyOrExit(Get<Mle::Mle>().IsFullThreadDevice() || Get<Mle::Mle>().GetParent().IsThreadVersion1p1());
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE
-
-    {
-        bool needReg = false;
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-        needReg = (mDuaState == kToRegister && mDelay.mFields.mRegistrationDelay == 0);
-#endif
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-        needReg = needReg || (!mChildDuaMask.IsEmpty() && mChildDuaMask != mChildDuaRegisteredMask);
-#endif
-        VerifyOrExit(needReg);
-    }
-
-    // Prepare DUA.req
-    message = Get<Tmf::Agent>().AllocateAndInitPriorityConfirmablePostMessage(kUriDuaRegistrationRequest);
-    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    if (mDuaState == kToRegister && mDelay.mFields.mRegistrationDelay == 0)
-    {
-        dua = GetDomainUnicastAddress();
-        SuccessOrExit(error = Tlv::Append<ThreadTargetTlv>(*message, dua));
-        SuccessOrExit(error = Tlv::Append<ThreadMeshLocalEidTlv>(*message, Get<Mle::Mle>().GetMeshLocalEid().GetIid()));
-        mDuaState             = kRegistering;
-        mLastRegistrationTime = TimerMilli::GetNow();
-    }
-    else
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE
-    {
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-        uint32_t lastTransactionTime;
-        Child   *child = nullptr;
-
-        OT_ASSERT(mChildIndexDuaRegistering == Mle::kMaxChildren);
-
-        for (Child &iter : Get<ChildTable>().Iterate(Child::kInStateValid))
-        {
-            uint16_t childIndex = Get<ChildTable>().GetChildIndex(iter);
-
-            if (mChildDuaMask.Has(childIndex) && !mChildDuaRegisteredMask.Has(childIndex))
-            {
-                mChildIndexDuaRegistering = childIndex;
-                break;
-            }
-        }
-
-        child = Get<ChildTable>().GetChildAtIndex(mChildIndexDuaRegistering);
-        SuccessOrAssert(child->GetDomainUnicastAddress(dua));
-
-        SuccessOrExit(error = Tlv::Append<ThreadTargetTlv>(*message, dua));
-        SuccessOrExit(error = Tlv::Append<ThreadMeshLocalEidTlv>(*message, child->GetMeshLocalIid()));
-
-        lastTransactionTime = Time::MsecToSec(TimerMilli::GetNow() - child->GetLastHeard());
-        SuccessOrExit(error = Tlv::Append<ThreadLastTransactionTimeTlv>(*message, lastTransactionTime));
-#endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    }
-
-    if (!Get<Mle::Mle>().IsFullThreadDevice() && Get<Mle::Mle>().GetParent().IsThreadVersion1p1())
-    {
-        uint8_t pbbrServiceId;
-
-        SuccessOrExit(error = Get<BackboneRouter::Leader>().GetServiceId(pbbrServiceId));
-        Get<Mle::Mle>().GetServiceAloc(pbbrServiceId, destAddr);
-    }
-    else
-    {
-        destAddr.SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), Get<BackboneRouter::Leader>().GetServer16());
-    }
-
-    SuccessOrExit(error = Get<Tmf::Agent>().SendMessageTo(*message, destAddr, HandleDuaResponse, this));
-
-    mIsDuaPending   = true;
-    mRegisteringDua = dua;
-    mDelay.mValue   = 0;
-
-    // Generally Thread 1.2 Router would send DUA.req on behalf for DUA registered by its MTD child.
-    // When Thread 1.2 MTD attaches to Thread 1.1 parent, 1.2 MTD should send DUA.req to PBBR itself.
-    // In this case, Thread 1.2 sleepy end device relies on fast data poll to fetch the response timely.
-    if (!Get<Mle::Mle>().IsRxOnWhenIdle())
-    {
-        Get<DataPollSender>().SendFastPolls();
-    }
-
-    LogInfo("Sent %s for DUA %s", UriToString<kUriDuaRegistrationRequest>(), dua.ToString().AsCString());
-
-exit:
-    if (error == kErrorNoBufs)
-    {
-        UpdateCheckDelay(kNoBufDelay);
-    }
-
-    LogWarnOnError(error, "perform next registration");
-    FreeMessageOnError(message, error);
-}
-
-void DuaManager::HandleDuaResponse(Coap::Msg *aMsg, Error aResult)
-{
-    Error error;
-
-    mIsDuaPending = false;
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    mChildIndexDuaRegistering = Mle::kMaxChildren;
-#endif
-
-    if (aResult == kErrorResponseTimeout)
-    {
-        UpdateCheckDelay(KResponseTimeoutDelay);
-        ExitNow(error = aResult);
-    }
-
-    VerifyOrExit(aResult == kErrorNone, error = kErrorParse);
-    OT_ASSERT(aMsg != nullptr);
-
-    VerifyOrExit(aMsg->GetCode() == Coap::kCodeChanged || aMsg->GetCode() >= Coap::kCodeBadRequest,
-                 error = kErrorParse);
-
-    error = ProcessDuaResponse(aMsg->mMessage);
-
-exit:
-    if (error != kErrorResponseTimeout)
-    {
-        mRegistrationTask.Post();
-    }
-
-    LogInfo("Received %s response: %s", UriToString<kUriDuaRegistrationRequest>(), ErrorToString(error));
-}
-
-template <> void DuaManager::HandleTmf<kUriDuaRegistrationNotify>(Coap::Msg &aMsg)
-{
-    Error error;
-
-    if (aMsg.IsConfirmable() && Get<Tmf::Agent>().SendAckResponse(aMsg) == kErrorNone)
-    {
-        LogInfo("Sent %s ack", UriToString<kUriDuaRegistrationNotify>());
-    }
-
-    error = ProcessDuaResponse(aMsg.mMessage);
-
-    OT_UNUSED_VARIABLE(error);
-    LogInfo("Received %s: %s", UriToString<kUriDuaRegistrationNotify>(), ErrorToString(error));
-}
-
-Error DuaManager::ProcessDuaResponse(Coap::Message &aMessage)
-{
-    Error        error = kErrorNone;
-    Ip6::Address target;
-    uint8_t      status;
-
-    if (aMessage.ReadCode() >= Coap::kCodeBadRequest)
-    {
-        status = kDuaGeneralFailure;
-        target = mRegisteringDua;
-    }
-    else
-    {
-        SuccessOrExit(error = Tlv::Find<ThreadStatusTlv>(aMessage, status));
-        SuccessOrExit(error = Tlv::Find<ThreadTargetTlv>(aMessage, target));
-    }
-
-    VerifyOrExit(Get<BackboneRouter::Leader>().IsDomainUnicast(target), error = kErrorDrop);
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    if (Get<ThreadNetif>().HasUnicastAddress(target))
-    {
-        switch (static_cast<DuaStatus>(status))
-        {
-        case kDuaSuccess:
-            mLastRegistrationTime = TimerMilli::GetNow();
-            mDuaState             = kRegistered;
-            break;
-        case kDuaReRegister:
-            if (Get<ThreadNetif>().HasUnicastAddress(GetDomainUnicastAddress()))
-            {
-                RemoveDomainUnicastAddress();
-                AddDomainUnicastAddress();
-            }
-            break;
-        case kDuaInvalid:
-            // Domain Prefix might be invalid.
-            RemoveDomainUnicastAddress();
-            break;
-        case kDuaDuplicate:
-            NotifyDuplicateDomainUnicastAddress();
-            break;
-        case kDuaNoResources:
-        case kDuaNotPrimary:
-        case kDuaGeneralFailure:
-            UpdateReregistrationDelay();
-            break;
-        }
-    }
-    else
-#endif
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    {
-        Child   *child = nullptr;
-        uint16_t childIndex;
-
-        for (Child &iter : Get<ChildTable>().Iterate(Child::kInStateValid))
-        {
-            if (iter.HasIp6Address(target))
-            {
-                child = &iter;
-                break;
-            }
-        }
-
-        VerifyOrExit(child != nullptr, error = kErrorNotFound);
-
-        childIndex = Get<ChildTable>().GetChildIndex(*child);
-
-        switch (status)
-        {
-        case kDuaSuccess:
-            // Mark as Registered
-            if (mChildDuaMask.Has(childIndex))
-            {
-                mChildDuaRegisteredMask.Add(childIndex);
-            }
-            break;
-        case kDuaReRegister:
-            // Parent stops registering for the Child's DUA until next Child Update Request
-            mChildDuaMask.Remove(childIndex);
-            mChildDuaRegisteredMask.Remove(childIndex);
-            break;
-        case kDuaInvalid:
-        case kDuaDuplicate:
-            IgnoreError(child->RemoveIp6Address(target));
-            mChildDuaMask.Remove(childIndex);
-            mChildDuaRegisteredMask.Remove(childIndex);
-            break;
-        case kDuaNoResources:
-        case kDuaNotPrimary:
-        case kDuaGeneralFailure:
-            UpdateReregistrationDelay();
-            break;
-        }
-
-        if (status != kDuaSuccess)
-        {
-            SendAddressNotification(target, static_cast<DuaStatus>(status), *child);
-        }
-    }
-#endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-
-exit:
-    UpdateTimeTickerRegistration();
-    return error;
-}
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-void DuaManager::SendAddressNotification(Ip6::Address &aAddress, DuaStatus aStatus, const Child &aChild)
-{
-    Coap::Message *message = nullptr;
-    Error          error;
-
-    message = Get<Tmf::Agent>().AllocateAndInitPriorityConfirmablePostMessage(kUriDuaRegistrationNotify);
-    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
-
-    SuccessOrExit(error = Tlv::Append<ThreadStatusTlv>(*message, aStatus));
-    SuccessOrExit(error = Tlv::Append<ThreadTargetTlv>(*message, aAddress));
-
-    SuccessOrExit(error = Get<Tmf::Agent>().SendMessageToRloc(*message, aChild.GetRloc16()));
-
-    LogInfo("Sent %s for child %04x DUA %s", UriToString<kUriDuaRegistrationNotify>(), aChild.GetRloc16(),
-            aAddress.ToString().AsCString());
-
-exit:
-
-    if (error != kErrorNone)
-    {
-        FreeMessage(message);
-
-        // TODO: (DUA) (P4) may enhance to  guarantee the delivery of DUA.ntf
-        LogWarn("Sent %s for child %04x DUA %s Error %s", UriToString<kUriDuaRegistrationNotify>(), aChild.GetRloc16(),
-                aAddress.ToString().AsCString(), ErrorToString(error));
-    }
-}
-
-void DuaManager::HandleChildDuaAddressEvent(const Child &aChild, ChildDuaAddressEvent aEvent)
-{
-    uint16_t childIndex = Get<ChildTable>().GetChildIndex(aChild);
-
-    if ((aEvent == kAddressRemoved || aEvent == kAddressChanged) && mChildDuaMask.Has(childIndex))
-    {
-        // Abort on going proxy DUA.req for this child
-        if (mChildIndexDuaRegistering == childIndex)
-        {
-            IgnoreError(Get<Tmf::Agent>().AbortTransaction(HandleDuaResponse, this));
-        }
-
-        mChildDuaMask.Remove(childIndex);
-        mChildDuaRegisteredMask.Remove(childIndex);
-    }
-
-    if (aEvent == kAddressAdded || aEvent == kAddressChanged ||
-        (aEvent == kAddressUnchanged && !mChildDuaMask.Has(childIndex)))
-    {
-        if (mChildDuaMask == mChildDuaRegisteredMask)
-        {
-            UpdateCheckDelay(Random::NonCrypto::GetUint8InRange(1, BackboneRouter::kParentAggregateDelay));
-        }
-
-        mChildDuaMask.Add(childIndex);
-        mChildDuaRegisteredMask.Remove(childIndex);
-    }
-}
-#endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-
-} // namespace ot
-
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
diff --git a/src/core/thread/dua_manager.hpp b/src/core/thread/dua_manager.hpp
deleted file mode 100644
index cd2f09c..0000000
--- a/src/core/thread/dua_manager.hpp
+++ /dev/null
@@ -1,282 +0,0 @@
-/*
- *  Copyright (c) 2020, The OpenThread Authors.
- *  All rights reserved.
- *
- *  Redistribution and use in source and binary forms, with or without
- *  modification, are permitted provided that the following conditions are met:
- *  1. Redistributions of source code must retain the above copyright
- *     notice, this list of conditions and the following disclaimer.
- *  2. Redistributions in binary form must reproduce the above copyright
- *     notice, this list of conditions and the following disclaimer in the
- *     documentation and/or other materials provided with the distribution.
- *  3. Neither the name of the copyright holder nor the
- *     names of its contributors may be used to endorse or promote products
- *     derived from this software without specific prior written permission.
- *
- *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- *  POSSIBILITY OF SUCH DAMAGE.
- */
-
-/**
- * @file
- *   This file includes definitions for managing Domain Unicast Address feature defined in Thread 1.2.
- */
-
-#ifndef OT_CORE_THREAD_DUA_MANAGER_HPP_
-#define OT_CORE_THREAD_DUA_MANAGER_HPP_
-
-#include "openthread-core-config.h"
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE && (OPENTHREAD_CONFIG_THREAD_VERSION < OT_THREAD_VERSION_1_2)
-#error "Thread 1.2 or higher version is required for OPENTHREAD_CONFIG_DUA_ENABLE"
-#endif
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE && !OPENTHREAD_CONFIG_IP6_SLAAC_ENABLE
-#error "OPENTHREAD_CONFIG_IP6_SLAAC_ENABLE is required for OPENTHREAD_CONFIG_DUA_ENABLE"
-#endif
-
-#include "backbone_router/bbr_leader.hpp"
-#include "coap/coap_message.hpp"
-#include "common/locator.hpp"
-#include "common/non_copyable.hpp"
-#include "common/notifier.hpp"
-#include "common/tasklet.hpp"
-#include "common/time.hpp"
-#include "common/time_ticker.hpp"
-#include "common/timer.hpp"
-#include "net/netif.hpp"
-#include "thread/child.hpp"
-#include "thread/thread_tlvs.hpp"
-#include "thread/tmf.hpp"
-
-namespace ot {
-
-/**
- * @addtogroup core-dua
- *
- * @brief
- *   This module includes definitions for generating, managing, registering Domain Unicast Address.
- *
- * @{
- *
- * @defgroup core-dua Dua
- *
- * @}
- */
-
-/**
- * Domain Unicast Address (DUA) Registration Status values
- */
-enum DuaStatus : uint8_t
-{
-    kDuaSuccess        = 0, ///< Successful registration.
-    kDuaReRegister     = 1, ///< Registration was accepted but immediate reregistration is required to solve.
-    kDuaInvalid        = 2, ///< Registration rejected (Fatal): Target EID is not a valid DUA.
-    kDuaDuplicate      = 3, ///< Registration rejected (Fatal): DUA is already in use by another device.
-    kDuaNoResources    = 4, ///< Registration rejected (Non-fatal): Backbone Router Resource shortage.
-    kDuaNotPrimary     = 5, ///< Registration rejected (Non-fatal): Backbone Router is not primary at this moment.
-    kDuaGeneralFailure = 6, ///< Registration failure (Non-fatal): Reason(s) not further specified.
-};
-
-/**
- * Implements managing DUA.
- */
-class DuaManager : public InstanceLocator, private NonCopyable
-{
-    friend class ot::Notifier;
-    friend class ot::TimeTicker;
-    friend class Tmf::Agent;
-
-public:
-    /**
-     * Initializes the object.
-     *
-     * @param[in]  aInstance     A reference to the OpenThread instance.
-     */
-    explicit DuaManager(Instance &aInstance);
-
-    /**
-     * Notifies Domain Prefix changes.
-     *
-     * @param[in]  aEvent  The Domain Prefix event.
-     */
-    void HandleDomainPrefixUpdate(BackboneRouter::DomainPrefixEvent aEvent);
-
-    /**
-     * Notifies Primary Backbone Router status.
-     *
-     * @param[in]  aState   The state or state change of Primary Backbone Router.
-     * @param[in]  aConfig  The Primary Backbone Router service.
-     */
-    void HandleBackboneRouterPrimaryUpdate(BackboneRouter::Leader::State aState, const BackboneRouter::Config &aConfig);
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-
-    /**
-     * Returns a reference to the Domain Unicast Address.
-     *
-     * @returns A reference to the Domain Unicast Address.
-     */
-    const Ip6::Address &GetDomainUnicastAddress(void) const { return mDomainUnicastAddress.GetAddress(); }
-
-    /**
-     * Sets the Interface Identifier manually specified for the Thread Domain Unicast Address.
-     *
-     * @param[in]  aIid        A reference to the Interface Identifier to set.
-     *
-     * @retval kErrorNone          Successfully set the Interface Identifier.
-     * @retval kErrorInvalidArgs   The specified Interface Identifier is reserved.
-     */
-    Error SetFixedDuaInterfaceIdentifier(const Ip6::InterfaceIdentifier &aIid);
-
-    /**
-     * Clears the Interface Identifier manually specified for the Thread Domain Unicast Address.
-     */
-    void ClearFixedDuaInterfaceIdentifier(void);
-
-    /**
-     * Indicates whether or not there is Interface Identifier manually specified for the Thread
-     * Domain Unicast Address.
-     *
-     * @retval true  If there is Interface Identifier manually specified.
-     * @retval false If there is no Interface Identifier manually specified.
-     */
-    bool IsFixedDuaInterfaceIdentifierSet(void) { return !mFixedDuaInterfaceIdentifier.IsUnspecified(); }
-
-    /**
-     * Gets the Interface Identifier for the Thread Domain Unicast Address if manually specified.
-     *
-     * @returns A reference to the Interface Identifier.
-     */
-    const Ip6::InterfaceIdentifier &GetFixedDuaInterfaceIdentifier(void) const { return mFixedDuaInterfaceIdentifier; }
-
-    /*
-     * Restores duplicate address detection information from non-volatile memory.
-     */
-    void Restore(void);
-
-    /**
-     * Notifies duplicated Domain Unicast Address.
-     */
-    void NotifyDuplicateDomainUnicastAddress(void);
-#endif
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    /**
-     * Events related to a Child DUA address.
-     */
-    enum ChildDuaAddressEvent : uint8_t
-    {
-        kAddressAdded,     ///< A new DUA registered by the Child via Address Registration.
-        kAddressChanged,   ///< A different DUA registered by the Child via Address Registration.
-        kAddressRemoved,   ///< DUA registered by the Child is removed and not in Address Registration.
-        kAddressUnchanged, ///< The Child registers the same DUA again.
-    };
-
-    /**
-     * Handles Child DUA address event.
-     *
-     * @param[in] aChild   A child.
-     * @param[in] aEvent   The DUA address event for @p aChild.
-     */
-    void HandleChildDuaAddressEvent(const Child &aChild, ChildDuaAddressEvent aEvent);
-#endif
-
-private:
-    static constexpr uint32_t kDuaDadPeriod               = 100; // DAD wait time to become "Preferred" (in sec).
-    static constexpr uint8_t  kNoBufDelay                 = 5;   // In sec.
-    static constexpr uint8_t  KResponseTimeoutDelay       = 30;  // In sec.
-    static constexpr uint8_t  kNewRouterRegistrationDelay = 3;   // Delay (in sec) to establish link for a new router.
-    static constexpr uint8_t  kNewDuaRegistrationDelay    = 1;   // Delay (in sec) for newly added DUA.
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    Error GenerateDomainUnicastAddressIid(void);
-    void  Store(void);
-
-    void AddDomainUnicastAddress(void);
-    void RemoveDomainUnicastAddress(void);
-    void UpdateRegistrationDelay(uint8_t aDelay);
-#endif
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    void SendAddressNotification(Ip6::Address &aAddress, DuaStatus aStatus, const Child &aChild);
-#endif
-
-    void HandleNotifierEvents(Events aEvents);
-
-    void HandleTimeTick(void);
-
-    void UpdateTimeTickerRegistration(void);
-
-    DeclareTmfResponseHandlerIn(DuaManager, HandleDuaResponse);
-
-    template <Uri kUri> void HandleTmf(Coap::Msg &aMsg);
-
-    Error ProcessDuaResponse(Coap::Message &aMessage);
-
-    void PerformNextRegistration(void);
-    void UpdateReregistrationDelay(void);
-    void UpdateCheckDelay(uint8_t aDelay);
-
-    using RegistrationTask = TaskletIn<DuaManager, &DuaManager::PerformNextRegistration>;
-
-    RegistrationTask mRegistrationTask;
-    Ip6::Address     mRegisteringDua;
-    bool             mIsDuaPending : 1;
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    enum DuaState : uint8_t
-    {
-        kNotExist,    ///< DUA is not available.
-        kToRegister,  ///< DUA is to be registered.
-        kRegistering, ///< DUA is being registered.
-        kRegistered,  ///< DUA is registered.
-    };
-
-    DuaState  mDuaState;
-    uint8_t   mDadCounter;
-    TimeMilli mLastRegistrationTime; // The time (in milliseconds) when sent last DUA.req or received DUA.rsp.
-    Ip6::InterfaceIdentifier   mFixedDuaInterfaceIdentifier;
-    Ip6::Netif::UnicastAddress mDomainUnicastAddress;
-#endif
-
-    union
-    {
-        struct
-        {
-            uint16_t mReregistrationDelay; // Delay (in seconds) for DUA re-registration.
-            uint8_t  mCheckDelay;          // Delay (in seconds) for checking whether or not registration is required.
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-            uint8_t mRegistrationDelay; // Delay (in seconds) for DUA registration.
-#endif
-        } mFields;
-        uint32_t mValue; // Non-zero indicates timer should start.
-    } mDelay;
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    // TODO: (DUA) may re-evaluate the alternative option of distributing the flags into the child table:
-    //       - Child class itself have some padding - may save some RAM
-    //       - Avoid cross reference between a bit-vector and the child entry
-    ChildMask mChildDuaMask;             // Child Mask for child who registers DUA via Child Update Request.
-    ChildMask mChildDuaRegisteredMask;   // Child Mask for child's DUA that was registered by the parent on behalf.
-    uint16_t  mChildIndexDuaRegistering; // Child Index of the DUA being registered.
-#endif
-};
-
-DeclareTmfHandler(DuaManager, kUriDuaRegistrationNotify);
-
-} // namespace ot
-
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-#endif // OT_CORE_THREAD_DUA_MANAGER_HPP_
diff --git a/src/core/thread/key_manager.cpp b/src/core/thread/key_manager.cpp
index d3f5e2c..f0e6a51 100644
--- a/src/core/thread/key_manager.cpp
+++ b/src/core/thread/key_manager.cpp
@@ -175,6 +175,7 @@
     , mKeyRotationTimer(aInstance)
     , mKekFrameCounter(0)
     , mIsPskcSet(false)
+    , mIsKekSet(false)
 {
     otPlatCryptoInit();
 
@@ -343,13 +344,13 @@
         Mac::KeyMaterial prevKey;
         Mac::KeyMaterial nextKey;
 
-        curKey.SetFrom(hashKeys.GetMacKey(), kExportableMacKeys);
+        curKey.SetFrom(hashKeys.GetMacKey(), Mac::kDefaultMacKeysExportable);
 
         ComputeKeys(mKeySequence - 1, hashKeys);
-        prevKey.SetFrom(hashKeys.GetMacKey(), kExportableMacKeys);
+        prevKey.SetFrom(hashKeys.GetMacKey(), Mac::kDefaultMacKeysExportable);
 
         ComputeKeys(mKeySequence + 1, hashKeys);
-        nextKey.SetFrom(hashKeys.GetMacKey(), kExportableMacKeys);
+        nextKey.SetFrom(hashKeys.GetMacKey(), Mac::kDefaultMacKeysExportable);
 
         Get<Mac::SubMac>().SetMacKey(Mac::Frame::kKeyIdMode1, (mKeySequence & 0x7f) + 1, prevKey, curKey, nextKey);
     }
@@ -512,6 +513,13 @@
 {
     mKek.SetFrom(aKek, /* aIsExportable */ true);
     mKekFrameCounter = 0;
+    mIsKekSet        = true;
+}
+
+void KeyManager::ClearKek(void)
+{
+    mKek.Clear();
+    mIsKekSet = false;
 }
 
 void KeyManager::SetSecurityPolicy(const SecurityPolicy &aSecurityPolicy)
@@ -695,6 +703,7 @@
 {
     mMleKey.Clear();
     mKek.Clear();
+    mIsKekSet = false;
     Get<Mac::SubMac>().ClearMacKeys();
 }
 
diff --git a/src/core/thread/key_manager.hpp b/src/core/thread/key_manager.hpp
index 4bb3bd9..1a33688 100644
--- a/src/core/thread/key_manager.hpp
+++ b/src/core/thread/key_manager.hpp
@@ -457,6 +457,14 @@
     const KekKeyMaterial &GetKek(void) const { return mKek; }
 
     /**
+     * Indicates whether or not the KEK is set.
+     *
+     * @retval TRUE   If the KEK is set.
+     * @retval FALSE  If the KEK is not set.
+     */
+    bool IsKekSet(void) const { return mIsKekSet; }
+
+    /**
      * Retrieves the KEK as literal `Kek` key.
      *
      * @param[out] aKek  A reference to a `Kek` to output the retrieved KEK.
@@ -478,6 +486,11 @@
     void SetKek(const uint8_t *aKekBytes) { SetKek(*reinterpret_cast<const Kek *>(aKekBytes)); }
 
     /**
+     * Clears the KEK.
+     */
+    void ClearKek(void);
+
+    /**
      * Returns the current KEK Frame Counter value.
      *
      * @returns The current KEK Frame Counter value.
@@ -558,7 +571,6 @@
 private:
     static constexpr uint16_t kDefaultKeySwitchGuardTime    = 624; // ~ 93% of 672 (default key rotation time)
     static constexpr uint32_t kKeySwitchGuardTimePercentage = 93;  // Percentage of key rotation time.
-    static constexpr bool     kExportableMacKeys            = OPENTHREAD_CONFIG_PLATFORM_MAC_KEYS_EXPORTABLE_ENABLE;
 
     static_assert(kDefaultKeySwitchGuardTime ==
                       SecurityPolicy::kDefaultKeyRotationTime * kKeySwitchGuardTimePercentage / 100,
@@ -646,6 +658,7 @@
 
     SecurityPolicy mSecurityPolicy;
     bool           mIsPskcSet : 1;
+    bool           mIsKekSet : 1;
 };
 
 /**
diff --git a/src/core/thread/link_quality.cpp b/src/core/thread/link_quality.cpp
index 8814bbe..97f050a 100644
--- a/src/core/thread/link_quality.cpp
+++ b/src/core/thread/link_quality.cpp
@@ -85,7 +85,7 @@
 
     VerifyOrExit(mCount != 0, average = Radio::kInvalidRssi);
 
-    average = -static_cast<int8_t>(mAverage >> kPrecisionBitShift);
+    average = static_cast<int8_t>(-static_cast<int8_t>(mAverage >> kPrecisionBitShift));
 
     // Check for possible round up (e.g., average of -71.5 --> -72)
 
diff --git a/src/core/thread/lowpan.cpp b/src/core/thread/lowpan.cpp
index b90865b..7c58018 100644
--- a/src/core/thread/lowpan.cpp
+++ b/src/core/thread/lowpan.cpp
@@ -46,7 +46,7 @@
     mIsValid      = true;
     mCompressFlag = true;
     mContextId    = Mle::kMeshLocalPrefixContextId;
-    mPrefix.Set(aInstance.Get<Mle::Mle>().GetMeshLocalPrefix());
+    mPrefix.InitFrom(aInstance.Get<Mle::Mle>().GetMeshLocalPrefix());
 }
 
 void Context::InitFrom(const NetworkData::PrefixTlv &aPrefixTlv, const NetworkData::ContextTlv &aContextTlv)
@@ -87,11 +87,11 @@
     switch (aMacAddr.GetType())
     {
     case Mac::Address::kTypeShort:
-        aIid.SetToLocator(aMacAddr.GetShort());
+        aIid.InitAsLocator(aMacAddr.GetShort());
         break;
 
     case Mac::Address::kTypeExtended:
-        aIid.SetFromExtAddress(aMacAddr.GetExtended());
+        aIid.InitFromExtAddress(aMacAddr.GetExtended());
         break;
 
     default:
@@ -122,7 +122,7 @@
     else if (aIpAddr.GetIid().IsLocator())
     {
         aHcCtl |= kHcSrcAddrMode2;
-        error = aFrameBuilder.AppendBigEndianUint16(aIpAddr.GetIid().GetLocator());
+        error = aFrameBuilder.AppendUint<kBigEndian>(aIpAddr.GetIid().GetLocator());
     }
     else
     {
@@ -151,7 +151,7 @@
     else if (aIpAddr.GetIid().IsLocator())
     {
         aHcCtl |= kHcDstAddrMode2;
-        error = aFrameBuilder.AppendBigEndianUint16(aIpAddr.GetIid().GetLocator());
+        error = aFrameBuilder.AppendUint<kBigEndian>(aIpAddr.GetIid().GetLocator());
     }
     else
     {
@@ -219,16 +219,21 @@
     return error;
 }
 
-Error Lowpan::Compress(Message &aMessage, const Mac::Addresses &aMacAddrs, FrameBuilder &aFrameBuilder)
+Error Lowpan::Compress(Message              &aMessage,
+                       const Mac::Addresses &aMacAddrs,
+                       FrameBuilder         &aFrameBuilder,
+                       uint8_t               aRecursionDepth)
 {
     Error   error       = kErrorNone;
     uint8_t headerDepth = 0xff;
 
+    VerifyOrExit(aRecursionDepth <= kMaxRecursionDepth, error = kErrorParse);
+
     while (headerDepth > 0)
     {
         FrameBuilder frameBuilder = aFrameBuilder;
 
-        error = Compress(aMessage, aMacAddrs, aFrameBuilder, headerDepth);
+        error = Compress(aMessage, aMacAddrs, aFrameBuilder, headerDepth, aRecursionDepth);
 
         // We exit if `Compress()` is successful. Otherwise we reset
         // the `aFrameBuidler` to its earlier state (remove all
@@ -246,7 +251,8 @@
 Error Lowpan::Compress(Message              &aMessage,
                        const Mac::Addresses &aMacAddrs,
                        FrameBuilder         &aFrameBuilder,
-                       uint8_t              &aHeaderDepth)
+                       uint8_t              &aHeaderDepth,
+                       uint8_t               aRecursionDepth)
 {
     Error       error       = kErrorNone;
     uint16_t    startOffset = aMessage.GetOffset();
@@ -268,7 +274,7 @@
 
     // Lowpan HC Control Bits
     hcCtlOffset = aFrameBuilder.GetLength();
-    SuccessOrExit(error = aFrameBuilder.AppendBigEndianUint16(hcCtl));
+    SuccessOrExit(error = aFrameBuilder.AppendUint<kBigEndian>(hcCtl));
 
     // Context Identifier
     if (srcContext.GetContextId() != 0 || dstContext.GetContextId() != 0)
@@ -413,7 +419,7 @@
             // For IP-in-IP the NH bit of the LOWPAN_NHC encoding MUST be set to zero.
             SuccessOrExit(error = aFrameBuilder.AppendUint8(kExtHdrDispatch | kExtHdrEidIp6));
 
-            error = Compress(aMessage, aMacAddrs, aFrameBuilder);
+            error = Compress(aMessage, aMacAddrs, aFrameBuilder, aRecursionDepth + 1);
 
             OT_FALL_THROUGH;
 
@@ -517,11 +523,11 @@
 
 Error Lowpan::CompressUdp(Message &aMessage, FrameBuilder &aFrameBuilder)
 {
-    Error            error       = kErrorNone;
-    uint16_t         startOffset = aMessage.GetOffset();
-    Ip6::Udp::Header udpHeader;
-    uint16_t         source;
-    uint16_t         destination;
+    Error          error       = kErrorNone;
+    uint16_t       startOffset = aMessage.GetOffset();
+    Ip6::UdpHeader udpHeader;
+    uint16_t       source;
+    uint16_t       destination;
 
     SuccessOrExit(error = aMessage.ReadAtAndAdvanceOffset(udpHeader));
 
@@ -537,21 +543,21 @@
     {
         SuccessOrExit(error = aFrameBuilder.AppendUint8(kUdpDispatch | 2));
         SuccessOrExit(error = aFrameBuilder.AppendUint8(source & 0xff));
-        SuccessOrExit(error = aFrameBuilder.AppendBigEndianUint16(destination));
+        SuccessOrExit(error = aFrameBuilder.AppendUint<kBigEndian>(destination));
     }
     else if ((destination & 0xff00) == 0xf000)
     {
         SuccessOrExit(error = aFrameBuilder.AppendUint8(kUdpDispatch | 1));
-        SuccessOrExit(error = aFrameBuilder.AppendBigEndianUint16(source));
+        SuccessOrExit(error = aFrameBuilder.AppendUint<kBigEndian>(source));
         SuccessOrExit(error = aFrameBuilder.AppendUint8(destination & 0xff));
     }
     else
     {
         SuccessOrExit(error = aFrameBuilder.AppendUint8(kUdpDispatch));
-        SuccessOrExit(error = aFrameBuilder.AppendBytes(&udpHeader, Ip6::Udp::Header::kLengthFieldOffset));
+        SuccessOrExit(error = aFrameBuilder.AppendBytes(&udpHeader, Ip6::UdpHeader::kLengthFieldOffset));
     }
 
-    SuccessOrExit(error = aFrameBuilder.AppendBigEndianUint16(udpHeader.GetChecksum()));
+    SuccessOrExit(error = aFrameBuilder.AppendUint<kBigEndian>(udpHeader.GetChecksum()));
 
 exit:
     if (error != kErrorNone)
@@ -617,7 +623,7 @@
     Context  dstContext;
     uint8_t  nextHeader;
 
-    SuccessOrExit(aFrameData.ReadBigEndianUint16(hcCtl));
+    SuccessOrExit(aFrameData.ReadUint<kBigEndian>(hcCtl));
 
     // check Dispatch bits
     VerifyOrExit((hcCtl & kHcDispatchMask) == kHcDispatch);
@@ -889,7 +895,7 @@
     return error;
 }
 
-Error Lowpan::DecompressUdpHeader(Ip6::Udp::Header &aUdpHeader, FrameData &aFrameData)
+Error Lowpan::DecompressUdpHeader(Ip6::UdpHeader &aUdpHeader, FrameData &aFrameData)
 {
     Error    error = kErrorParse;
     uint8_t  udpCtl;
@@ -906,12 +912,12 @@
     switch (udpCtl & kUdpPortMask)
     {
     case 0:
-        SuccessOrExit(aFrameData.ReadBigEndianUint16(srcPort));
-        SuccessOrExit(aFrameData.ReadBigEndianUint16(dstPort));
+        SuccessOrExit(aFrameData.ReadUint<kBigEndian>(srcPort));
+        SuccessOrExit(aFrameData.ReadUint<kBigEndian>(dstPort));
         break;
 
     case 1:
-        SuccessOrExit(aFrameData.ReadBigEndianUint16(srcPort));
+        SuccessOrExit(aFrameData.ReadUint<kBigEndian>(srcPort));
         SuccessOrExit(aFrameData.ReadUint8(byte));
         dstPort = (0xf000 | byte);
         break;
@@ -919,7 +925,7 @@
     case 2:
         SuccessOrExit(aFrameData.ReadUint8(byte));
         srcPort = (0xf000 | byte);
-        SuccessOrExit(aFrameData.ReadBigEndianUint16(dstPort));
+        SuccessOrExit(aFrameData.ReadUint<kBigEndian>(dstPort));
         break;
 
     case 3:
@@ -940,7 +946,7 @@
     {
         uint16_t checksum;
 
-        SuccessOrExit(aFrameData.ReadBigEndianUint16(checksum));
+        SuccessOrExit(aFrameData.ReadUint<kBigEndian>(checksum));
         aUdpHeader.SetChecksum(checksum);
     }
 
@@ -952,8 +958,8 @@
 
 Error Lowpan::DecompressUdpHeader(Message &aMessage, FrameData &aFrameData, uint16_t aDatagramLength)
 {
-    Error            error;
-    Ip6::Udp::Header udpHeader;
+    Error          error;
+    Ip6::UdpHeader udpHeader;
 
     SuccessOrExit(error = DecompressUdpHeader(udpHeader, aFrameData));
 
@@ -964,6 +970,7 @@
     }
     else
     {
+        VerifyOrExit(aDatagramLength >= aMessage.GetOffset() + sizeof(Ip6::UdpHeader), error = kErrorParse);
         udpHeader.SetLength(aDatagramLength - aMessage.GetOffset());
     }
 
@@ -1029,6 +1036,7 @@
 
     if (aDatagramLength)
     {
+        VerifyOrExit(aDatagramLength >= currentOffset + sizeof(Ip6::Header), error = kErrorParse);
         ip6PayloadLength = BigEndian::HostSwap16(aDatagramLength - currentOffset - sizeof(Ip6::Header));
     }
     else
@@ -1189,8 +1197,8 @@
         SuccessOrExit(error = aFrameBuilder.AppendUint8(mHopsLeft));
     }
 
-    SuccessOrExit(error = aFrameBuilder.AppendBigEndianUint16(mSource));
-    SuccessOrExit(error = aFrameBuilder.AppendBigEndianUint16(mDestination));
+    SuccessOrExit(error = aFrameBuilder.AppendUint<kBigEndian>(mSource));
+    SuccessOrExit(error = aFrameBuilder.AppendUint<kBigEndian>(mDestination));
 
 exit:
     return error;
diff --git a/src/core/thread/lowpan.hpp b/src/core/thread/lowpan.hpp
index ed12ecf..5cec221 100644
--- a/src/core/thread/lowpan.hpp
+++ b/src/core/thread/lowpan.hpp
@@ -180,7 +180,10 @@
      *
      * @returns The size of the compressed header in bytes.
      */
-    Error Compress(Message &aMessage, const Mac::Addresses &aMacAddrs, FrameBuilder &aFrameBuilder);
+    Error Compress(Message              &aMessage,
+                   const Mac::Addresses &aMacAddrs,
+                   FrameBuilder         &aFrameBuilder,
+                   uint8_t               aRecursionDepth = 0);
 
     /**
      * Decompresses a LOWPAN_IPHC header.
@@ -231,7 +234,7 @@
      * @retval kErrorNone    The header was decompressed successfully. @p aUdpHeader and @p aFrameData are updated.
      * @retval kErrorParse   Failed to parse the lowpan header.
      */
-    Error DecompressUdpHeader(Ip6::Udp::Header &aUdpHeader, FrameData &aFrameData);
+    Error DecompressUdpHeader(Ip6::UdpHeader &aUdpHeader, FrameData &aFrameData);
 
     /**
      * Decompresses the IPv6 ECN field in a LOWPAN_IPHC header.
@@ -312,7 +315,8 @@
     Error Compress(Message              &aMessage,
                    const Mac::Addresses &aMacAddrs,
                    FrameBuilder         &aFrameBuilder,
-                   uint8_t              &aHeaderDepth);
+                   uint8_t              &aHeaderDepth,
+                   uint8_t               aRecursionDepth);
 
     Error CompressExtensionHeader(Message &aMessage, FrameBuilder &aFrameBuilder, uint8_t &aNextHeader);
     Error CompressSourceIid(const Mac::Address &aMacAddr,
@@ -412,7 +416,7 @@
     Error ParseFrom(const Message &aMessage, uint16_t &aHeaderLength);
 
     /**
-     * Returns the the Mesh Header length when written to a frame.
+     * Returns the Mesh Header length when written to a frame.
      *
      * @note The returned value from this method gives the header length (number of bytes) when the header is written
      * to a frame or message. This should not be used to determine the parsed length (number of bytes read) when the
diff --git a/src/core/thread/mesh_forwarder.cpp b/src/core/thread/mesh_forwarder.cpp
index 7bceab6..574e8d1 100644
--- a/src/core/thread/mesh_forwarder.cpp
+++ b/src/core/thread/mesh_forwarder.cpp
@@ -766,7 +766,7 @@
 #endif // OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-    if (aFrame.HasCslIe() && aIsDataPoll)
+    if (aFrame.Has<Mac::CslIe>() && aIsDataPoll)
     {
         failLimit = kFailedCslDataPollTransmissions;
     }
@@ -1091,7 +1091,7 @@
         }
 
         // Duplication suppression for a "next fragment" is handled
-        // by the code below where the the datagram offset is
+        // by the code below where the datagram offset is
         // checked against the offset of the corresponding message
         // (same datagram tag and size) in Reassembly List. Note
         // that if there is no matching message in the Reassembly
@@ -1201,6 +1201,27 @@
     }
 }
 
+Error MeshForwarder::RemoveUnsecureReassemblyMessage(EvictReason aEvictReason)
+{
+    Error error = kErrorNotFound;
+
+    VerifyOrExit(aEvictReason == kEvictReasonNoMessageBuffer);
+
+    for (Message &message : mReassemblyList)
+    {
+        if (!message.IsLinkSecurityEnabled())
+        {
+            LogMessage(kMessageReassemblyDrop, message, kErrorNoBufs);
+            mCounters.UpdateOnDrop(message);
+            mReassemblyList.DequeueAndFree(message);
+            ExitNow(error = kErrorNone);
+        }
+    }
+
+exit:
+    return error;
+}
+
 void MeshForwarder::HandleTimeTick(void)
 {
     bool continueRxingTicks = false;
@@ -1547,7 +1568,7 @@
         break;
     }
 
-    VerifyOrExit(Instance::GetLogLevel() >= logLevel);
+    VerifyOrExit(GetInstance().GetLogLevel() >= logLevel);
 
     switch (aMessage.GetType())
     {
diff --git a/src/core/thread/mesh_forwarder.hpp b/src/core/thread/mesh_forwarder.hpp
index 70db267..c891078 100644
--- a/src/core/thread/mesh_forwarder.hpp
+++ b/src/core/thread/mesh_forwarder.hpp
@@ -453,6 +453,7 @@
                                  Message::Priority       aPriority);
     Error HandleDatagram(Message &aMessage, const Mac::Address &aMacSource);
     void  ClearReassemblyList(void);
+    Error RemoveUnsecureReassemblyMessage(EvictReason aEvictReason);
     void  HandleDiscoverComplete(void);
 
     void          HandleReceivedFrame(Mac::RxFrame &aFrame);
diff --git a/src/core/thread/mesh_forwarder_ftd.cpp b/src/core/thread/mesh_forwarder_ftd.cpp
index 8399f99..f69096f 100644
--- a/src/core/thread/mesh_forwarder_ftd.cpp
+++ b/src/core/thread/mesh_forwarder_ftd.cpp
@@ -165,28 +165,6 @@
             continue;
         }
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-        // Pass back to IPv6 layer for DUA destination resolved
-        // by Backbone Query
-        if (Get<BackboneRouter::Local>().IsPrimary() && Get<BackboneRouter::Leader>().IsDomainUnicast(ip6Dst) &&
-            Get<Mle::Mle>().HasRloc16(Get<AddressResolver>().LookUp(ip6Dst)))
-        {
-            uint8_t hopLimit;
-
-            mSendQueue.Dequeue(message);
-
-            // Avoid decreasing Hop Limit twice
-            IgnoreError(message.Read(Ip6::Header::kHopLimitFieldOffset, hopLimit));
-            hopLimit++;
-            message.Write(Ip6::Header::kHopLimitFieldOffset, hopLimit);
-            message.SetLoopbackToHostAllowed(true);
-            message.SetOrigin(Message::kOriginHostTrusted);
-
-            IgnoreError(Get<Ip6::Ip6>().HandleDatagram(OwnedPtr<Message>(&message)));
-            continue;
-        }
-#endif
-
         message.SetResolvingAddress(false);
         didUpdate = true;
     }
@@ -202,6 +180,9 @@
     Error    error = kErrorNotFound;
     Message *evict = nullptr;
 
+    error = RemoveUnsecureReassemblyMessage(aEvictReason);
+    VerifyOrExit(error == kErrorNotFound);
+
 #if OPENTHREAD_CONFIG_DELAY_AWARE_QUEUE_MANAGEMENT_ENABLE
     error = RemoveAgedMessages();
     VerifyOrExit(error == kErrorNotFound);
@@ -558,10 +539,10 @@
 {
     Ip6::MessageInfo messageInfo;
 
-    messageInfo.GetPeerAddr().SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), aMeshSource);
+    Get<Mle::Mle>().ComposeRloc(aMeshSource, messageInfo.GetPeerAddr());
 
-    IgnoreError(Get<Ip6::Icmp>().SendError(Ip6::Icmp::Header::kTypeDstUnreach,
-                                           Ip6::Icmp::Header::kCodeDstUnreachNoRoute, messageInfo, aIp6Headers));
+    IgnoreError(Get<Ip6::Icmp>().SendError(Ip6::Icmp6Header::kTypeDstUnreach, Ip6::Icmp6Header::kCodeDstUnreachNoRoute,
+                                           messageInfo, aIp6Headers));
 }
 
 void MeshForwarder::HandleMesh(RxInfo &aRxInfo)
@@ -669,6 +650,7 @@
 {
     Neighbor *neighbor;
 
+    VerifyOrExit(aRxInfo.IsLinkSecurityEnabled());
     VerifyOrExit(!aRxInfo.GetDstAddr().IsBroadcast() && aRxInfo.GetSrcAddr().IsShort());
 
     SuccessOrExit(aRxInfo.ParseIp6Headers());
@@ -826,7 +808,7 @@
     error = GetFramePriority(aRxInfo, aPriority);
 
 exit:
-    LogInfoOnError(error, "get forwarded frame priority %s", aRxInfo.ToString().AsCString());
+    LogDebgOnError(error, "get forwarded frame priority %s", aRxInfo.ToString().AsCString());
 
     if ((error == kErrorNone) && isFragment)
     {
diff --git a/src/core/thread/mesh_forwarder_mtd.cpp b/src/core/thread/mesh_forwarder_mtd.cpp
index df47e56..7fdbbbe 100644
--- a/src/core/thread/mesh_forwarder_mtd.cpp
+++ b/src/core/thread/mesh_forwarder_mtd.cpp
@@ -56,11 +56,12 @@
 
 Error MeshForwarder::EvictMessage(Message::Priority aPriority, EvictReason aEvictReason)
 {
-    OT_UNUSED_VARIABLE(aEvictReason);
-
     Error    error = kErrorNotFound;
     Message *message;
 
+    error = RemoveUnsecureReassemblyMessage(aEvictReason);
+    VerifyOrExit(error == kErrorNotFound);
+
 #if OPENTHREAD_CONFIG_DELAY_AWARE_QUEUE_MANAGEMENT_ENABLE
     error = RemoveAgedMessages();
     VerifyOrExit(error == kErrorNotFound);
diff --git a/src/core/thread/message_framer.cpp b/src/core/thread/message_framer.cpp
index 9cb9424..fc2220d 100644
--- a/src/core/thread/message_framer.cpp
+++ b/src/core/thread/message_framer.cpp
@@ -40,7 +40,7 @@
 MessageFramer::MessageFramer(Instance &aInstance)
     : InstanceLocator(aInstance)
 {
-    mFragTag = Random::NonCrypto::GetUint16();
+    mFragTag = Random::NonCrypto::Generate<uint16_t>();
 }
 
 void MessageFramer::DetermineMacSourceAddress(const Ip6::Address &aIp6Addr, Mac::Addresses &aMacAddrs) const
@@ -53,11 +53,13 @@
     }
 }
 
-void MessageFramer::PrepareMacHeaders(Mac::TxFrame &aTxFrame, Mac::TxFrame::Info &aTxFrameInfo, const Message *aMessage)
+void MessageFramer::PrepareMacHeaders(Mac::TxFrame            &aTxFrame,
+                                      Mac::TxFrame::BuildInfo &aBuildInfo,
+                                      const Message           *aMessage)
 {
     const Neighbor *neighbor;
 
-    aTxFrameInfo.mVersion = Mac::Frame::kVersion2006;
+    aBuildInfo.mVersion = Mac::Frame::kVersion2006;
 
 #if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
 
@@ -67,7 +69,7 @@
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Determine frame version and Header IE entries
 
-    neighbor = Get<NeighborTable>().FindNeighbor(aTxFrameInfo.mAddrs.mDestination);
+    neighbor = Get<NeighborTable>().FindNeighbor(aBuildInfo.mAddrs.mDestination);
 
     if (neighbor == nullptr)
     {
@@ -75,20 +77,20 @@
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
     else if (Get<Mac::Mac>().IsCslEnabled())
     {
-        aTxFrameInfo.mAppendCslIe = true;
-        aTxFrameInfo.mVersion     = Mac::Frame::kVersion2015;
+        aBuildInfo.mAppendCslIe = true;
+        aBuildInfo.mVersion     = Mac::Frame::kVersion2015;
     }
 #endif
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
     else if ((Get<ChildTable>().Contains(*neighbor) && static_cast<const Child *>(neighbor)->IsCslSynchronized()))
     {
-        aTxFrameInfo.mVersion = Mac::Frame::kVersion2015;
+        aBuildInfo.mVersion = Mac::Frame::kVersion2015;
     }
 #endif
 #if OPENTHREAD_CONFIG_MLE_LINK_METRICS_INITIATOR_ENABLE
     else if (neighbor->IsEnhAckProbingActive())
     {
-        aTxFrameInfo.mVersion = Mac::Frame::kVersion2015;
+        aBuildInfo.mVersion = Mac::Frame::kVersion2015;
     }
 #endif
 
@@ -98,19 +100,19 @@
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
     if ((aMessage != nullptr) && aMessage->IsTimeSync())
     {
-        aTxFrameInfo.mAppendTimeIe = true;
-        aTxFrameInfo.mVersion      = Mac::Frame::kVersion2015;
+        aBuildInfo.mAppendTimeIe = true;
+        aBuildInfo.mVersion      = Mac::Frame::kVersion2015;
     }
 #endif
 
-    aTxFrameInfo.mEmptyPayload = (aMessage == nullptr) || (aMessage->GetLength() == 0);
+    aBuildInfo.mEmptyPayload = (aMessage == nullptr) || (aMessage->GetLength() == 0);
 
 #endif // OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Prepare MAC headers
 
-    aTxFrameInfo.PrepareHeadersIn(aTxFrame);
+    aBuildInfo.PrepareHeadersIn(aTxFrame);
 
     OT_UNUSED_VARIABLE(aMessage);
     OT_UNUSED_VARIABLE(neighbor);
@@ -118,23 +120,23 @@
 
 void MessageFramer::PrepareEmptyFrame(Mac::TxFrame &aFrame, const Mac::Address &aMacDest, bool aAckRequest)
 {
-    Mac::TxFrame::Info frameInfo;
+    Mac::TxFrame::BuildInfo buildInfo;
 
-    frameInfo.mAddrs.mSource.SetShort(Get<Mac::Mac>().GetShortAddress());
+    buildInfo.mAddrs.mSource.SetShort(Get<Mac::Mac>().GetShortAddress());
 
-    if (frameInfo.mAddrs.mSource.IsShortAddrInvalid() || aMacDest.IsExtended())
+    if (buildInfo.mAddrs.mSource.IsShortAddrInvalid() || aMacDest.IsExtended())
     {
-        frameInfo.mAddrs.mSource.SetExtended(Get<Mac::Mac>().GetExtAddress());
+        buildInfo.mAddrs.mSource.SetExtended(Get<Mac::Mac>().GetExtAddress());
     }
 
-    frameInfo.mAddrs.mDestination = aMacDest;
-    frameInfo.mPanIds.SetBothSourceDestination(Get<Mac::Mac>().GetPanId());
+    buildInfo.mAddrs.mDestination = aMacDest;
+    buildInfo.mPanIds.SetBothSourceDestination(Get<Mac::Mac>().GetPanId());
 
-    frameInfo.mType          = Mac::Frame::kTypeData;
-    frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
-    frameInfo.mKeyIdMode     = Mac::Frame::kKeyIdMode1;
+    buildInfo.mType          = Mac::Frame::kTypeData;
+    buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+    buildInfo.mKeyIdMode     = Mac::Frame::kKeyIdMode1;
 
-    PrepareMacHeaders(aFrame, frameInfo, nullptr);
+    PrepareMacHeaders(aFrame, buildInfo, nullptr);
 
     aFrame.SetAckRequest(aAckRequest);
     aFrame.SetPayloadLength(0);
@@ -148,34 +150,34 @@
                                      uint16_t              aMeshDest,
                                      bool                  aAddFragHeader)
 {
-    Mac::TxFrame::Info frameInfo;
-    uint16_t           payloadLength;
-    uint16_t           origMsgOffset;
-    uint16_t           nextOffset;
-    FrameBuilder       frameBuilder;
+    Mac::TxFrame::BuildInfo buildInfo;
+    uint16_t                payloadLength;
+    uint16_t                origMsgOffset;
+    uint16_t                nextOffset;
+    FrameBuilder            frameBuilder;
 
 start:
-    frameInfo.Clear();
+    buildInfo.Clear();
 
     if (aMessage.IsLinkSecurityEnabled())
     {
-        frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+        buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
 
         if (aMessage.GetSubType() == Message::kSubTypeJoinerEntrust)
         {
-            frameInfo.mKeyIdMode = Mac::Frame::kKeyIdMode0;
+            buildInfo.mKeyIdMode = Mac::Frame::kKeyIdMode0;
         }
         else if (aMessage.IsMleCommand(Mle::kCommandAnnounce))
         {
-            frameInfo.mKeyIdMode = Mac::Frame::kKeyIdMode2;
+            buildInfo.mKeyIdMode = Mac::Frame::kKeyIdMode2;
         }
         else
         {
-            frameInfo.mKeyIdMode = Mac::Frame::kKeyIdMode1;
+            buildInfo.mKeyIdMode = Mac::Frame::kKeyIdMode1;
         }
     }
 
-    frameInfo.mPanIds.SetBothSourceDestination(Get<Mac::Mac>().GetPanId());
+    buildInfo.mPanIds.SetBothSourceDestination(Get<Mac::Mac>().GetPanId());
 
     if (aMessage.IsSubTypeMle())
     {
@@ -184,12 +186,12 @@
         case Mle::kCommandAnnounce:
             aFrame.SetChannel(aMessage.GetChannel());
             aFrame.SetRxChannelAfterTxDone(Get<Mac::Mac>().GetPanChannel());
-            frameInfo.mPanIds.SetDestination(Mac::kPanIdBroadcast);
+            buildInfo.mPanIds.SetDestination(Mac::kPanIdBroadcast);
             break;
 
         case Mle::kCommandDiscoveryRequest:
         case Mle::kCommandDiscoveryResponse:
-            frameInfo.mPanIds.SetDestination(aMessage.GetPanId());
+            buildInfo.mPanIds.SetDestination(aMessage.GetPanId());
             break;
 
         default:
@@ -197,10 +199,10 @@
         }
     }
 
-    frameInfo.mType  = Mac::Frame::kTypeData;
-    frameInfo.mAddrs = aMacAddrs;
+    buildInfo.mType  = Mac::Frame::kTypeData;
+    buildInfo.mAddrs = aMacAddrs;
 
-    PrepareMacHeaders(aFrame, frameInfo, &aMessage);
+    PrepareMacHeaders(aFrame, buildInfo, &aMessage);
 
     frameBuilder.Init(aFrame.GetPayload(), aFrame.GetMaxPayloadLength());
 
@@ -350,15 +352,15 @@
 
 uint16_t MessageFramer::PrepareMeshFrame(Mac::TxFrame &aFrame, Message &aMessage, const Mac::Addresses &aMacAddrs)
 {
-    Mac::TxFrame::Info frameInfo;
+    Mac::TxFrame::BuildInfo buildInfo;
 
-    frameInfo.mType          = Mac::Frame::kTypeData;
-    frameInfo.mAddrs         = aMacAddrs;
-    frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
-    frameInfo.mKeyIdMode     = Mac::Frame::kKeyIdMode1;
-    frameInfo.mPanIds.SetBothSourceDestination(Get<Mac::Mac>().GetPanId());
+    buildInfo.mType          = Mac::Frame::kTypeData;
+    buildInfo.mAddrs         = aMacAddrs;
+    buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+    buildInfo.mKeyIdMode     = Mac::Frame::kKeyIdMode1;
+    buildInfo.mPanIds.SetBothSourceDestination(Get<Mac::Mac>().GetPanId());
 
-    PrepareMacHeaders(aFrame, frameInfo, &aMessage);
+    PrepareMacHeaders(aFrame, buildInfo, &aMessage);
 
     // write payload
     OT_ASSERT(aMessage.GetLength() <= aFrame.GetMaxPayloadLength());
diff --git a/src/core/thread/message_framer.hpp b/src/core/thread/message_framer.hpp
index 1fb0acc..5502677 100644
--- a/src/core/thread/message_framer.hpp
+++ b/src/core/thread/message_framer.hpp
@@ -131,7 +131,7 @@
     // (requiring one hop) and one as additional guard increment.
     static constexpr uint8_t kMeshHeaderHopsLeft = Mle::kMaxRouteCost + 3;
 
-    void PrepareMacHeaders(Mac::TxFrame &aTxFrame, Mac::TxFrame::Info &aTxFrameInfo, const Message *aMessage);
+    void PrepareMacHeaders(Mac::TxFrame &aTxFrame, Mac::TxFrame::BuildInfo &aBuildInfo, const Message *aMessage);
 
     uint16_t mFragTag;
 };
diff --git a/src/core/thread/mle.cpp b/src/core/thread/mle.cpp
index 0152854..cf3c390 100644
--- a/src/core/thread/mle.cpp
+++ b/src/core/thread/mle.cpp
@@ -80,23 +80,14 @@
     , mWedAttachTimer(aInstance)
 #endif
 #if OPENTHREAD_FTD
-    , mRouterEligible(true)
     , mAddressSolicitPending(false)
     , mAddressSolicitRejected(false)
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    , mCcmEnabled(false)
-    , mThreadVersionCheckEnabled(true)
-#endif
     , mNetworkIdTimeout(kNetworkIdTimeout)
-    , mRouterUpgradeThreshold(kRouterUpgradeThreshold)
-    , mRouterDowngradeThreshold(kRouterDowngradeThreshold)
     , mPreviousPartitionRouterIdSequence(0)
     , mPreviousPartitionIdTimeout(0)
     , mChildRouterLinks(kChildRouterLinks)
     , mAlternateRloc16Timeout(0)
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    , mMaxChildIpAddresses(0)
-#endif
+    , mLeaderUpgradeThreshold(kRouterUpgradeThreshold)
     , mParentPriority(kParentPriorityUnspecified)
     , mPreviousPartitionIdRouter(0)
     , mPreviousPartitionId(0)
@@ -106,6 +97,7 @@
     , mAdvertiseTrickleTimer(aInstance, Mle::HandleAdvertiseTrickleTimer)
     , mChildTable(aInstance)
     , mRouterTable(aInstance)
+    , mRoleTransitioner(aInstance)
 #endif // OPENTHREAD_FTD
 #if OPENTHREAD_CONFIG_P2P_ENABLE
     , mP2p(aInstance)
@@ -118,13 +110,13 @@
     ResetCounters();
 
     mLinkLocalAddress.InitAsThreadOrigin();
-    mLinkLocalAddress.GetAddress().SetToLinkLocalAddress(Get<Mac::Mac>().GetExtAddress());
+    mLinkLocalAddress.GetAddress().InitAsLinkLocalAddress(Get<Mac::Mac>().GetExtAddress());
 
     mMeshLocalEid.InitAsThreadOriginMeshLocal();
     mMeshLocalEid.GetAddress().GetIid().GenerateRandom();
 
     mMeshLocalRloc.InitAsThreadOriginMeshLocal();
-    mMeshLocalRloc.GetAddress().GetIid().SetToLocator(0);
+    mMeshLocalRloc.GetAddress().GetIid().InitAsLocator(0);
     mMeshLocalRloc.mRloc = true;
 
     mLinkLocalAllThreadNodes.InitAsThreadOrigin();
@@ -328,10 +320,6 @@
         break;
     }
 
-#if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
-    IgnoreError(Get<Ble::BleSecure>().NotifyAdvertisementChanged());
-#endif
-
     // If the previous state is disabled, the parent can be in kStateRestored.
     if (!IsChild() && oldRole != kRoleDisabled)
     {
@@ -365,10 +353,6 @@
     IgnoreError(Get<MeshCoP::ActiveDatasetManager>().Restore());
     IgnoreError(Get<MeshCoP::PendingDatasetManager>().Restore());
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    Get<DuaManager>().Restore();
-#endif
-
     SuccessOrExit(Get<Settings>().Read(networkInfo));
 
     Get<KeyManager>().SetCurrentKeySequence(networkInfo.GetKeySequence(),
@@ -452,6 +436,10 @@
     // non-volatile settings after boot.
     mHasRestored = true;
 
+#if OPENTHREAD_FTD
+    mRoleTransitioner.UpdateRouterRoleAllowed(RoleTransitioner::kReasonMleInit);
+#endif
+
 exit:
     return;
 }
@@ -600,6 +588,7 @@
     Get<MeshForwarder>().SetRxOnWhenIdle(true);
     Get<Mac::Mac>().SetBeaconEnabled(false);
 #if OPENTHREAD_FTD
+    mRoleTransitioner.SetDowngradeBlocked(false);
     ClearAlternateRloc16();
     HandleDetachStart();
 #endif
@@ -653,7 +642,7 @@
 {
     // Generates a random delay within `[1, aMaxDelay]` (inclusive).
 
-    return 1 + Random::NonCrypto::GetUint32InRange(0, aMaxDelay);
+    return Random::NonCrypto::GenerateInClosedRange<uint32_t>(1, aMaxDelay);
 }
 
 void Mle::SetTimeout(uint32_t aTimeout, TimeoutAction aAction)
@@ -719,6 +708,8 @@
     {
         ClearAlternateRloc16();
     }
+
+    mRoleTransitioner.UpdateRouterRoleAllowed(RoleTransitioner::kReasonDeviceModeChanged);
 #endif
 
     if (IsAttached())
@@ -770,7 +761,7 @@
 void Mle::UpdateLinkLocalAddress(void)
 {
     Get<ThreadNetif>().RemoveUnicastAddress(mLinkLocalAddress);
-    mLinkLocalAddress.GetAddress().GetIid().SetFromExtAddress(Get<Mac::Mac>().GetExtAddress());
+    mLinkLocalAddress.GetAddress().GetIid().InitFromExtAddress(Get<Mac::Mac>().GetExtAddress());
     Get<ThreadNetif>().AddUnicastAddress(mLinkLocalAddress);
 
     Get<Notifier>().Signal(kEventThreadLinkLocalAddrChanged);
@@ -854,7 +845,7 @@
         // We can always call `AddUnicastAddress(mMeshLocat16)` and if
         // the address is already added, it will perform no action.
 
-        mMeshLocalRloc.GetAddress().GetIid().SetLocator(aRloc16);
+        ComposeRloc(aRloc16, mMeshLocalRloc.GetAddress());
         Get<ThreadNetif>().AddUnicastAddress(mMeshLocalRloc);
 #if OPENTHREAD_FTD
         Get<AddressResolver>().RestartAddressQueries();
@@ -893,24 +884,29 @@
     mLeaderData.SetLeaderRouterId(aLeaderRouterId);
 }
 
-void Mle::GetLeaderRloc(Ip6::Address &aAddress) const
+void Mle::ComposeRloc(uint16_t aRloc16, Ip6::Address &aAddress) const
 {
-    aAddress.SetToRoutingLocator(mMeshLocalPrefix, GetLeaderRloc16());
+    aAddress.InitAsLocator(mMeshLocalPrefix, aRloc16);
 }
 
-void Mle::GetLeaderAloc(Ip6::Address &aAddress) const
+void Mle::ComposeLeaderRloc(Ip6::Address &aAddress) const { ComposeRloc(GetLeaderRloc16(), aAddress); }
+
+void Mle::ComposeLeaderAloc(Ip6::Address &aAddress) const { ComposeAloc(Aloc16::ForLeader(), aAddress); }
+
+void Mle::ComposeCommissionerAloc(uint16_t aSessionId, Ip6::Address &aAddress) const
 {
-    aAddress.SetToAnycastLocator(mMeshLocalPrefix, Aloc16::ForLeader());
+    ComposeAloc(Aloc16::FromCommissionerSessionId(aSessionId), aAddress);
 }
 
-void Mle::GetCommissionerAloc(uint16_t aSessionId, Ip6::Address &aAddress) const
+void Mle::ComposeServiceAloc(uint8_t aServiceId, Ip6::Address &aAddress) const
 {
-    aAddress.SetToAnycastLocator(mMeshLocalPrefix, Aloc16::FromCommissionerSessionId(aSessionId));
+    ComposeAloc(Aloc16::FromServiceId(aServiceId), aAddress);
 }
 
-void Mle::GetServiceAloc(uint8_t aServiceId, Ip6::Address &aAddress) const
+void Mle::ComposeMeshLocalAddress(const Ip6::InterfaceIdentifier &aIid, Ip6::Address &aAddress) const
 {
-    aAddress.SetToAnycastLocator(mMeshLocalPrefix, Aloc16::FromServiceId(aServiceId));
+    aAddress.SetPrefix(mMeshLocalPrefix);
+    aAddress.SetIid(aIid);
 }
 
 const LeaderData &Mle::GetLeaderData(void)
@@ -921,32 +917,34 @@
     return mLeaderData;
 }
 
-bool Mle::HasUnregisteredAddress(void)
+bool Mle::HasUnregisteredAddress(void) const
 {
-    bool retval = false;
+    bool has = false;
 
     // Checks whether there are any addresses in addition to the mesh-local
-    // address that need to be registered.
+    // address that need to be registered with the parent
 
     for (const Ip6::Netif::UnicastAddress &addr : Get<ThreadNetif>().GetUnicastAddresses())
     {
-        if (!addr.GetAddress().IsLinkLocalUnicast() && !IsRoutingLocator(addr.GetAddress()) &&
-            !IsAnycastLocator(addr.GetAddress()) && addr.GetAddress() != GetMeshLocalEid())
+        if (addr.GetAddress() == GetMeshLocalEid())
         {
-            ExitNow(retval = true);
+            continue;
+        }
+
+        if (ShouldRegisterUnicastAddrWithParent(addr))
+        {
+            ExitNow(has = true);
         }
     }
 
-    if (!IsRxOnWhenIdle())
+    if (ShouldRegisterMulticastAddrsWithParent())
     {
-        // For sleepy end-device, we register any external multicast
-        // addresses.
-
-        retval = Get<ThreadNetif>().HasAnyExternalMulticastAddress();
+        // We register any external multicast addresses.
+        has = Get<ThreadNetif>().HasAnyExternalMulticastAddress();
     }
 
 exit:
-    return retval;
+    return has;
 }
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
@@ -1041,7 +1039,23 @@
 #if OPENTHREAD_FTD
     if (aEvents.Contains(kEventSecurityPolicyChanged))
     {
-        HandleSecurityPolicyChanged();
+        mRoleTransitioner.UpdateRouterRoleAllowed(RoleTransitioner::kReasonSecurityPolicyChanged);
+    }
+
+    if (mRoleTransitioner.IsDowngradeBlocked() && aEvents.Contains(kEventThreadChildRemoved))
+    {
+        bool shouldBlock = false;
+
+        for (const Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
+        {
+            if (child.IsBlockingParentDowngrade())
+            {
+                shouldBlock = true;
+                break;
+            }
+        }
+
+        mRoleTransitioner.SetDowngradeBlocked(shouldBlock);
     }
 #endif
 
@@ -1061,6 +1075,24 @@
     return;
 }
 
+bool Mle::ShouldRegisterUnicastAddrWithParent(const Ip6::Netif::UnicastAddress &aUnicastAddress) const
+{
+    bool                shouldRegister = false;
+    const Ip6::Address &address        = aUnicastAddress.GetAddress();
+
+    VerifyOrExit(!IsFullThreadDevice());
+
+    VerifyOrExit(!address.IsLoopback());
+    VerifyOrExit(!address.IsLinkLocalUnicast());
+    VerifyOrExit(!IsRoutingLocator(address));
+    VerifyOrExit(!IsAnycastLocator(address));
+
+    shouldRegister = true;
+
+exit:
+    return shouldRegister;
+}
+
 bool Mle::ShouldRegisterMulticastAddrsWithParent(void) const
 {
     // When multicast subscription changes, SED always notifies
@@ -1069,12 +1101,21 @@
     // parent of 1.2 or higher version as it could depend on its
     // parent to perform Multicast Listener Report.
 
-    bool shouldRegister = !IsRxOnWhenIdle();
+    bool shouldRegister = false;
+
+    if (!IsRxOnWhenIdle())
+    {
+        ExitNow(shouldRegister = true);
+    }
 
 #if (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
-    shouldRegister |= !IsFullThreadDevice() && GetParent().IsThreadVersion1p2OrHigher();
+    if (!IsFullThreadDevice() && GetParent().IsThreadVersion1p2OrHigher())
+    {
+        ExitNow(shouldRegister = true);
+    }
 #endif
 
+exit:
     return shouldRegister;
 }
 
@@ -1082,7 +1123,7 @@
 {
     Ip6::Address destination;
 
-    destination.SetToLinkLocalAddress(mParent.GetExtAddress());
+    destination.InitAsLinkLocalAddress(mParent.GetExtAddress());
 
     return SendDataRequest(destination);
 }
@@ -1186,7 +1227,7 @@
     }
 
     VerifyOrExit((message = NewMleMessage(kCommandChildUpdateRequest)) != nullptr, error = kErrorNoBufs);
-    SuccessOrExit(error = message->AppendModeTlv(mDeviceMode));
+    SuccessOrExit(error = message->AppendModeTlv());
 
     switch (aMode)
     {
@@ -1216,8 +1257,7 @@
         break;
 
     case kRoleChild:
-        SuccessOrExit(error = message->AppendSourceAddressTlv());
-        SuccessOrExit(error = message->AppendLeaderDataTlv());
+        SuccessOrExit(error = message->AppendSourceAddressAndLeaderDataTlvs());
         SuccessOrExit(error = message->AppendTimeoutTlv((aMode == kAppendZeroTimeout) ? 0 : mTimeout));
         SuccessOrExit(error = message->AppendSupervisionIntervalTlvIfSleepyChild());
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
@@ -1240,7 +1280,7 @@
         SuccessOrExit(error = message->AppendAddressRegistrationTlv(addrRegMode));
     }
 
-    destination.SetToLinkLocalAddress(mParent.GetExtAddress());
+    destination.InitAsLinkLocalAddress(mParent.GetExtAddress());
     SuccessOrExit(error = message->SendTo(destination));
 
     Log(kMessageSend, kTypeChildUpdateRequestAsChild, destination);
@@ -1287,7 +1327,6 @@
 {
     Error      error = kErrorNone;
     TxMessage *message;
-    bool       checkAddress = false;
 
     VerifyOrExit((message = NewMleMessage(kCommandChildUpdateResponse)) != nullptr, error = kErrorNoBufs);
 
@@ -1314,13 +1353,30 @@
         case Tlv::kAddressRegistration:
             if (!IsFullThreadDevice())
             {
-                // We only register the mesh-local address in the "Child
-                // Update Response" message and if there are additional
-                // addresses to register we follow up with a "Child Update
-                // Request".
+                AddressRegistrationMode addrRegMode = kAppendAllAddresses;
 
-                SuccessOrExit(error = message->AppendAddressRegistrationTlv(kAppendMeshLocalOnly));
-                checkAddress = true;
+                if (!aInfo.mChallenge.IsEmpty())
+                {
+                    // If the parent is restoring its link, its request
+                    // will include a Challenge. In this case, we only
+                    // register the mesh-local address in the response
+                    // message (to ensure it does not get fragmented).
+                    // If there are additional addresses to register
+                    // and the child itself is attached (it is not
+                    // itself restoring its link to the parent), we
+                    // schedule a follow-up "Child Update Request" to
+                    // register the remaining addresses.
+
+                    addrRegMode = kAppendMeshLocalOnly;
+
+                    if (IsAttached() && HasUnregisteredAddress())
+                    {
+                        mDelayedSender.RemoveScheduledChildUpdateRequestToParent();
+                        mDelayedSender.ScheduleChildUpdateRequestToParent(0);
+                    }
+                }
+
+                SuccessOrExit(error = message->AppendAddressRegistrationTlv(addrRegMode));
             }
 
             break;
@@ -1356,11 +1412,6 @@
 
     Log(kMessageSend, kTypeChildUpdateResponseAsChild, aInfo.mDestination);
 
-    if (checkAddress && HasUnregisteredAddress())
-    {
-        IgnoreError(SendChildUpdateRequestToParent());
-    }
-
 exit:
     FreeMessageOnError(message, error);
     return error;
@@ -1449,14 +1500,13 @@
 }
 #endif
 
-Error Mle::ProcessMessageSecurity(Crypto::AesCcm::Mode    aMode,
-                                  Message                &aMessage,
-                                  const Ip6::MessageInfo &aMessageInfo,
-                                  uint16_t                aCmdOffset,
-                                  const SecurityHeader   &aHeader)
+Error Mle::ProcessMessageSecurity(Crypto::AesCcm::Operation aOperation,
+                                  Message                  &aMessage,
+                                  uint16_t                  aCmdOffset,
+                                  const AesCcmAuthData     &aAuthData)
 {
-    // This method performs MLE message security. Based on `aMode` it
-    // can be used to encrypt and append tag to `aMessage` or to
+    // This method performs MLE message security. Based on `aOperation`
+    // it can be used to encrypt and append tag to `aMessage` or to
     // decrypt and validate the tag in a received `aMessage` (which is
     // then removed from `aMessage`).
     //
@@ -1472,51 +1522,27 @@
     // `kErrorNone` message encrypted and tag appended to message.
     // `kErrorNoBufs` could not grow the message to append the tag.
 
-    Error               error = kErrorNone;
-    Crypto::AesCcm      aesCcm;
-    uint8_t             nonce[Crypto::AesCcm::kNonceSize];
-    uint8_t             tag[kMleSecurityTagSize];
-    Mac::ExtAddress     extAddress;
-    uint32_t            keySequence;
-    uint16_t            payloadLength   = aMessage.GetLength() - aCmdOffset;
-    const Ip6::Address *senderAddress   = &aMessageInfo.GetSockAddr();
-    const Ip6::Address *receiverAddress = &aMessageInfo.GetPeerAddr();
+    Error                 error = kErrorNone;
+    Crypto::AesCcm        aesCcm;
+    Crypto::AesCcm::Nonce nonce;
+    Mac::ExtAddress       extAddress;
+    uint32_t              keySequence;
 
-    switch (aMode)
-    {
-    case Crypto::AesCcm::kEncrypt:
-        // Use the initialized values for `senderAddress`,
-        // `receiverAddress` and `payloadLength`
-        break;
+    extAddress.SetFromIid(aAuthData.mSenderAddr.GetIid());
+    nonce.InitFrom(extAddress, aAuthData.mSecurityHeader.GetFrameCounter(), Mac::Frame::kSecurityEncMic32);
 
-    case Crypto::AesCcm::kDecrypt:
-        senderAddress   = &aMessageInfo.GetPeerAddr();
-        receiverAddress = &aMessageInfo.GetSockAddr();
-        // Ensure message contains command field (uint8_t) and
-        // tag. Then exclude the tag from payload to decrypt.
-        VerifyOrExit(aCmdOffset + sizeof(uint8_t) + kMleSecurityTagSize <= aMessage.GetLength(), error = kErrorParse);
-        payloadLength -= kMleSecurityTagSize;
-        break;
-    }
-
-    extAddress.SetFromIid(senderAddress->GetIid());
-    Crypto::AesCcm::GenerateNonce(extAddress, aHeader.GetFrameCounter(), Mac::Frame::kSecurityEncMic32, nonce);
-
-    keySequence = aHeader.GetKeyId();
+    keySequence = aAuthData.mSecurityHeader.GetKeyId();
 
     aesCcm.SetKey(keySequence == Get<KeyManager>().GetCurrentKeySequence()
                       ? Get<KeyManager>().GetCurrentMleKey()
                       : Get<KeyManager>().GetTemporaryMleKey(keySequence));
 
-    aesCcm.Init(sizeof(Ip6::Address) + sizeof(Ip6::Address) + sizeof(SecurityHeader), payloadLength,
-                kMleSecurityTagSize, nonce, sizeof(nonce));
-
-    aesCcm.Header(*senderAddress);
-    aesCcm.Header(*receiverAddress);
-    aesCcm.Header(aHeader);
+    aesCcm.SetNonce(nonce);
+    aesCcm.SetAuthData(&aAuthData, sizeof(AesCcmAuthData));
+    aesCcm.SetTagLength(kMleSecurityTagSize);
 
 #ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
-    if (aMode == Crypto::AesCcm::kDecrypt)
+    if (aOperation == Crypto::AesCcm::kDecrypt)
     {
         // Skip decrypting the message under fuzz build mode
         aMessage.RemoveFooter(kMleSecurityTagSize);
@@ -1524,18 +1550,8 @@
     }
 #endif
 
-    aesCcm.Payload(aMessage, aCmdOffset, payloadLength, aMode);
-    aesCcm.Finalize(tag);
-
-    if (aMode == Crypto::AesCcm::kEncrypt)
-    {
-        SuccessOrExit(error = aMessage.Append(tag));
-    }
-    else
-    {
-        VerifyOrExit(aMessage.Compare(aMessage.GetLength() - kMleSecurityTagSize, tag), error = kErrorSecurity);
-        aMessage.RemoveFooter(kMleSecurityTagSize);
-    }
+    error = aesCcm.Process(aOperation, aMessage, aCmdOffset);
+    ExitNow();
 
 exit:
     return error;
@@ -1546,7 +1562,7 @@
     Error           error = kErrorNone;
     RxInfo          rxInfo(aMessage, aMessageInfo);
     uint8_t         securitySuite;
-    SecurityHeader  header;
+    AesCcmAuthData  authData;
     uint32_t        keySequence;
     uint32_t        frameCounter;
     Mac::ExtAddress extAddr;
@@ -1588,20 +1604,22 @@
         ExitNow();
     }
 
-    VerifyOrExit(!IsDisabled(), error = kErrorInvalidState);
+    VerifyOrExit(!IsDisabled());
     VerifyOrExit(securitySuite == k154Security, error = kErrorParse);
 
-    SuccessOrExit(error = aMessage.ReadAtAndAdvanceOffset(header));
+    SuccessOrExit(error = aMessage.ReadAtAndAdvanceOffset(authData.mSecurityHeader));
 
-    VerifyOrExit(header.IsSecurityControlValid(), error = kErrorParse);
+    VerifyOrExit(authData.mSecurityHeader.IsSecurityControlValid(), error = kErrorParse);
 
-    keySequence  = header.GetKeyId();
-    frameCounter = header.GetFrameCounter();
+    keySequence  = authData.mSecurityHeader.GetKeyId();
+    frameCounter = authData.mSecurityHeader.GetFrameCounter();
 
-    SuccessOrExit(
-        error = ProcessMessageSecurity(Crypto::AesCcm::kDecrypt, aMessage, aMessageInfo, aMessage.GetOffset(), header));
+    authData.mSenderAddr   = aMessageInfo.GetPeerAddr();
+    authData.mReceiverAddr = aMessageInfo.GetSockAddr();
 
-    IgnoreError(aMessage.ReadAtAndAdvanceOffset(command));
+    SuccessOrExit(error = ProcessMessageSecurity(Crypto::AesCcm::kDecrypt, aMessage, aMessage.GetOffset(), authData));
+
+    SuccessOrExit(error = aMessage.ReadAtAndAdvanceOffset(command));
 
     extAddr.SetFromIid(aMessageInfo.GetPeerAddr().GetIid());
     neighbor = (command == kCommandChildIdResponse) ? mNeighborTable.FindParent(extAddr)
@@ -2627,7 +2645,7 @@
     SuccessOrExit(error = message->SetLength(0));
 
     messageInfo.SetSockAddr(GetMeshLocalEid());
-    messageInfo.GetPeerAddr().SetToRoutingLocator(mMeshLocalPrefix, mPreviousParentRloc);
+    ComposeRloc(mPreviousParentRloc, messageInfo.GetPeerAddr());
 
     SuccessOrExit(error = Get<Ip6::Ip6>().SendDatagram(*message, messageInfo, Ip6::kProtoNone));
 
@@ -2763,7 +2781,7 @@
         ExitNow();
     }
 
-    interval = Random::NonCrypto::GetUint32InRange(0, kJitterInterval);
+    interval = Random::NonCrypto::GenerateUpToExcluding(kJitterInterval);
 
     if (mIsInBackoff)
     {
@@ -2870,11 +2888,11 @@
 
 const char *Mle::MessageActionToString(MessageAction aAction)
 {
-#define MessageActionMapList(_)   \
-    _(kMessageSend, "Send")       \
-    _(kMessageReceive, "Receive") \
-    _(kMessageDelay, "Delay")     \
-    _(kMessageRemoveDelayed, "Remove Delayed")
+#define MessageActionMapList(_)                      \
+    _(kMessageSend, "Send")                          \
+    _(kMessageReceive, "Receive")                    \
+    _(kMessageScheduleDelayedSend, "Schedule tx of") \
+    _(kMessageRemoveDelayedSend, "Remove scheduled tx of")
 
     DefineEnumStringArray(MessageActionMapList);
 
@@ -3138,7 +3156,7 @@
 {
     Ip6::Address destination;
 
-    destination.SetToLinkLocalAddress(Get<Mle>().mParent.GetExtAddress());
+    destination.InitAsLinkLocalAddress(Get<Mle>().mParent.GetExtAddress());
     VerifyOrExit(!HasMatchingSchedule(kTypeChildUpdateRequestAsChild, destination));
     AddSchedule(kTypeChildUpdateRequestAsChild, destination, aDelay, nullptr, 0);
 
@@ -3150,7 +3168,7 @@
 {
     Ip6::Address destination;
 
-    destination.SetToLinkLocalAddress(Get<Mle>().mParent.GetExtAddress());
+    destination.InitAsLinkLocalAddress(Get<Mle>().mParent.GetExtAddress());
     RemoveMatchingSchedules(kTypeChildUpdateRequestAsChild, destination);
 }
 
@@ -3160,7 +3178,7 @@
 {
     Ip6::Address destination;
 
-    destination.SetToLinkLocalAddress(aInfo.mChildExtAddress);
+    destination.InitAsLinkLocalAddress(aInfo.mChildExtAddress);
 
     RemoveMatchingSchedules(kTypeParentResponse, destination);
     AddSchedule(kTypeParentResponse, destination, aDelay, &aInfo, sizeof(aInfo));
@@ -3197,7 +3215,7 @@
     Ip6::Address destination;
     uint16_t     routerRloc16;
 
-    destination.SetToLinkLocalAddress(aRouter.GetExtAddress());
+    destination.InitAsLinkLocalAddress(aRouter.GetExtAddress());
 
     VerifyOrExit(!HasMatchingSchedule(kTypeLinkRequest, destination));
     routerRloc16 = aRouter.GetRloc16();
@@ -3211,7 +3229,7 @@
 {
     Ip6::Address destination;
 
-    destination.SetToLinkLocalAddress(aRouter.GetExtAddress());
+    destination.InitAsLinkLocalAddress(aRouter.GetExtAddress());
     RemoveMatchingSchedules(kTypeLinkRequest, destination);
 }
 
@@ -3219,7 +3237,7 @@
 {
     Ip6::Address destination;
 
-    destination.SetToLinkLocalAddress(aRouter.GetExtAddress());
+    destination.InitAsLinkLocalAddress(aRouter.GetExtAddress());
 
     return HasMatchingSchedule(kTypeLinkRequest, destination);
 }
@@ -3228,7 +3246,7 @@
 {
     Ip6::Address destination;
 
-    destination.SetToLinkLocalAddress(aInfo.mExtAddress);
+    destination.InitAsLinkLocalAddress(aInfo.mExtAddress);
 
     RemoveMatchingSchedules(kTypeLinkAccept, destination);
     AddSchedule(kTypeLinkAccept, destination, aDelay, &aInfo, sizeof(aInfo));
@@ -3238,7 +3256,14 @@
                                                    const DiscoveryResponseInfo &aInfo,
                                                    uint32_t                     aDelay)
 {
+    RemoveMatchingSchedules(kTypeDiscoveryResponse, aDestination);
+
+    VerifyOrExit(CountMatchingSchedules(kTypeDiscoveryResponse) < kMaxScheduledDiscoveryResponse);
+
     AddSchedule(kTypeDiscoveryResponse, aDestination, aDelay, &aInfo, sizeof(aInfo));
+
+exit:
+    return;
 }
 
 #endif // OPENTHREAD_FTD
@@ -3269,7 +3294,8 @@
     mSchedules.Enqueue(*schedule);
     schedule = nullptr;
 
-    Log(kMessageDelay, aMessageType, aDestination);
+    Log(kMessageScheduleDelayedSend, aMessageType, aDestination);
+    LogInfo("    Will send in %lu msec", ToUlong(aDelay));
 
 exit:
     FreeMessage(schedule);
@@ -3433,13 +3459,31 @@
     }
 }
 
+uint32_t Mle::DelayedSender::CountMatchingSchedules(MessageType aMessageType) const
+{
+    uint32_t     count = 0;
+    Ip6::Address unspecifiedAddr;
+
+    unspecifiedAddr.Clear();
+
+    for (const Schedule &schedule : mSchedules)
+    {
+        if (Match(schedule, aMessageType, unspecifiedAddr))
+        {
+            count++;
+        }
+    }
+
+    return count;
+}
+
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
 void Mle::DelayedSender::LogRemove(const Schedule &aSchedule)
 {
     Header header;
 
     header.ReadFrom(aSchedule);
-    Log(kMessageRemoveDelayed, header.mMessageType, header.mDestination);
+    Log(kMessageRemoveDelayedSend, header.mMessageType, header.mDestination);
 }
 #else
 void Mle::DelayedSender::LogRemove(const Schedule &) {}
@@ -3488,6 +3532,9 @@
     return message;
 }
 
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// TxMessage - Append single TLV
+
 Error Mle::TxMessage::AppendSourceAddressTlv(void)
 {
     return Tlv::Append<SourceAddressTlv>(*this, Get<Mle>().GetRloc16());
@@ -3495,6 +3542,8 @@
 
 Error Mle::TxMessage::AppendStatusTlv(Status aStatus) { return Tlv::Append<StatusTlv>(*this, aStatus); }
 
+Error Mle::TxMessage::AppendModeTlv(void) { return AppendModeTlv(Get<Mle>().GetDeviceMode()); }
+
 Error Mle::TxMessage::AppendModeTlv(DeviceMode aMode) { return Tlv::Append<ModeTlv>(*this, aMode.Get()); }
 
 Error Mle::TxMessage::AppendTimeoutTlv(uint32_t aTimeout) { return Tlv::Append<TimeoutTlv>(*this, aTimeout); }
@@ -3532,17 +3581,6 @@
     return Tlv::Append<MleFrameCounterTlv>(*this, Get<KeyManager>().GetMleFrameCounter());
 }
 
-Error Mle::TxMessage::AppendLinkAndMleFrameCounterTlvs(void)
-{
-    Error error;
-
-    SuccessOrExit(error = AppendLinkFrameCounterTlv());
-    error = AppendMleFrameCounterTlv();
-
-exit:
-    return error;
-}
-
 Error Mle::TxMessage::AppendAddress16Tlv(uint16_t aRloc16) { return Tlv::Append<Address16Tlv>(*this, aRloc16); }
 
 Error Mle::TxMessage::AppendLeaderDataTlv(void)
@@ -3588,7 +3626,6 @@
 {
     Error         error = kErrorNone;
     Tlv::Bookmark tlvBookmark;
-    uint8_t       counter = 0;
 
     SuccessOrExit(error = Tlv::StartTlv(*this, Tlv::kAddressRegistration, tlvBookmark));
 
@@ -3597,37 +3634,20 @@
 
     // Continue to append the other addresses if not `kAppendMeshLocalOnly` mode
     VerifyOrExit(aMode != kAppendMeshLocalOnly);
-    counter++;
-
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    if (Get<ThreadNetif>().HasUnicastAddress(Get<DuaManager>().GetDomainUnicastAddress()))
-    {
-        // Prioritize DUA, compressed entry
-        SuccessOrExit(error = AppendAddressRegistrationEntry(Get<DuaManager>().GetDomainUnicastAddress()));
-        counter++;
-    }
-#endif
 
     for (const Ip6::Netif::UnicastAddress &addr : Get<ThreadNetif>().GetUnicastAddresses())
     {
-        if (addr.GetAddress().IsLoopback() || addr.GetAddress().IsLinkLocalUnicast() ||
-            Get<Mle>().IsRoutingLocator(addr.GetAddress()) || Get<Mle>().IsAnycastLocator(addr.GetAddress()) ||
-            addr.GetAddress() == Get<Mle>().GetMeshLocalEid())
+        if (!Get<Mle>().ShouldRegisterUnicastAddrWithParent(addr))
         {
             continue;
         }
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-        if (addr.GetAddress() == Get<DuaManager>().GetDomainUnicastAddress())
+        if (addr.GetAddress() == Get<Mle>().GetMeshLocalEid())
         {
             continue;
         }
-#endif
 
         SuccessOrExit(error = AppendAddressRegistrationEntry(addr.GetAddress()));
-        counter++;
-        // only continue to append if there is available entry.
-        VerifyOrExit(counter < kMaxIpAddressesToRegister);
     }
 
     // Append external multicast addresses.  For sleepy end device,
@@ -3655,9 +3675,6 @@
 #endif
 
             SuccessOrExit(error = AppendAddressRegistrationEntry(addr.GetAddress()));
-            counter++;
-            // only continue to append if there is available entry.
-            VerifyOrExit(counter < kMaxIpAddressesToRegister);
         }
     }
 
@@ -3728,13 +3745,12 @@
 
 Error Mle::TxMessage::AppendTimeParameterTlv(void)
 {
-    TimeParameterTlv tlv;
+    TimeParameterTlvValue tlvValue;
 
-    tlv.Init();
-    tlv.SetTimeSyncPeriod(Get<TimeSync>().GetTimeSyncPeriod());
-    tlv.SetXtalThreshold(Get<TimeSync>().GetXtalThreshold());
+    tlvValue.SetTimeSyncPeriod(Get<TimeSync>().GetTimeSyncPeriod());
+    tlvValue.SetXtalThreshold(Get<TimeSync>().GetXtalThreshold());
 
-    return tlv.AppendTo(*this);
+    return Tlv::Append<TimeParameterTlv>(*this, tlvValue);
 }
 
 Error Mle::TxMessage::AppendXtalAccuracyTlv(void)
@@ -3767,17 +3783,6 @@
     return error;
 }
 
-Error Mle::TxMessage::AppendActiveAndPendingTimestampTlvs(void)
-{
-    Error error;
-
-    SuccessOrExit(error = AppendActiveTimestampTlv());
-    error = AppendPendingTimestampTlv();
-
-exit:
-    return error;
-}
-
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
 Error Mle::TxMessage::AppendCslChannelTlv(void)
 {
@@ -3803,16 +3808,50 @@
 #if OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
 Error Mle::TxMessage::AppendCslClockAccuracyTlv(void)
 {
-    CslClockAccuracyTlv cslClockAccuracyTlv;
-
-    cslClockAccuracyTlv.Init();
-    cslClockAccuracyTlv.SetCslClockAccuracy(Get<Radio>().GetCslAccuracy());
-    cslClockAccuracyTlv.SetCslUncertainty(Get<Radio>().GetCslUncertainty());
-
-    return Append(cslClockAccuracyTlv);
+    return Tlv::Append<CslClockAccuracyTlv>(
+        *this, CslClockAccuracyTlvValue(Get<Radio>().GetCslAccuracy(), Get<Radio>().GetCslUncertainty()));
 }
 #endif
 
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// TxMessage - Append multiple TLVs
+
+Error Mle::TxMessage::AppendLinkAndMleFrameCounterTlvs(void)
+{
+    Error error;
+
+    SuccessOrExit(error = AppendLinkFrameCounterTlv());
+    error = AppendMleFrameCounterTlv();
+
+exit:
+    return error;
+}
+
+Error Mle::TxMessage::AppendSourceAddressAndLeaderDataTlvs(void)
+{
+    Error error;
+
+    SuccessOrExit(error = AppendSourceAddressTlv());
+    error = AppendLeaderDataTlv();
+
+exit:
+    return error;
+}
+
+Error Mle::TxMessage::AppendActiveAndPendingTimestampTlvs(void)
+{
+    Error error;
+
+    SuccessOrExit(error = AppendActiveTimestampTlv());
+    error = AppendPendingTimestampTlv();
+
+exit:
+    return error;
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// TxMessage - Misc
+
 Error Mle::TxMessage::SendTo(const Ip6::Address &aDestination)
 {
     Error            error  = kErrorNone;
@@ -3830,18 +3869,20 @@
 
     if (securitySuite == k154Security)
     {
-        SecurityHeader header;
+        AesCcmAuthData authData;
 
         // Update the fields in the security header
 
-        IgnoreError(Read(offset, header));
-        header.SetFrameCounter(Get<KeyManager>().GetMleFrameCounter());
-        header.SetKeyId(Get<KeyManager>().GetCurrentKeySequence());
-        Write(offset, header);
+        IgnoreError(Read(offset, authData.mSecurityHeader));
+        authData.mSecurityHeader.SetFrameCounter(Get<KeyManager>().GetMleFrameCounter());
+        authData.mSecurityHeader.SetKeyId(Get<KeyManager>().GetCurrentKeySequence());
+        Write(offset, authData.mSecurityHeader);
         offset += sizeof(SecurityHeader);
 
-        SuccessOrExit(
-            error = Get<Mle>().ProcessMessageSecurity(Crypto::AesCcm::kEncrypt, *this, messageInfo, offset, header));
+        authData.mSenderAddr   = messageInfo.GetSockAddr();
+        authData.mReceiverAddr = messageInfo.GetPeerAddr();
+
+        SuccessOrExit(error = Get<Mle>().ProcessMessageSecurity(Crypto::AesCcm::kEncrypt, *this, offset, authData));
 
         Get<KeyManager>().IncrementMleFrameCounter();
     }
@@ -3884,14 +3925,13 @@
     return error;
 }
 
-Error Mle::TxMessage::AppendRouteTlv(Neighbor *aNeighbor)
+Error Mle::TxMessage::AppendRouteTlv(void) { return AppendFullOrCompactRouteTlv(kInvalidRloc16); }
+
+Error Mle::TxMessage::AppendCompactRouteTlv(uint16_t aDestRloc16) { return AppendFullOrCompactRouteTlv(aDestRloc16); }
+
+Error Mle::TxMessage::AppendFullOrCompactRouteTlv(uint16_t aDestRloc16)
 {
-    RouteTlv tlv;
-
-    tlv.Init();
-    Get<RouterTable>().FillRouteTlv(tlv, aNeighbor);
-
-    return tlv.AppendTo(*this);
+    return Get<RouterTable>().AppendRouteTlv(*this, RouteTlv::kType, aDestRloc16);
 }
 
 Error Mle::TxMessage::AppendActiveDatasetTlv(void) { return AppendDatasetTlv(MeshCoP::Dataset::kActive); }
@@ -3955,7 +3995,7 @@
         SuccessOrExit(Get<NetworkData::Leader>().FindSteeringData(steeringData));
     }
 
-    error = Tlv::Append<MeshCoP::SteeringDataTlv>(*this, steeringData.GetData(), steeringData.GetLength());
+    error = MeshCoP::SteeringDataTlv::AppendTo(*this, steeringData);
 
 exit:
     return error;
@@ -4149,13 +4189,11 @@
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
 Error Mle::RxMessage::ReadCslClockAccuracyTlv(Mac::CslAccuracy &aCslAccuracy) const
 {
-    Error               error;
-    CslClockAccuracyTlv clockAccuracyTlv;
+    Error                    error;
+    CslClockAccuracyTlvValue tlvValue;
 
-    SuccessOrExit(error = Tlv::FindTlv(*this, clockAccuracyTlv));
-    VerifyOrExit(clockAccuracyTlv.IsValid(), error = kErrorParse);
-    aCslAccuracy.SetClockAccuracy(clockAccuracyTlv.GetCslClockAccuracy());
-    aCslAccuracy.SetUncertainty(clockAccuracyTlv.GetCslUncertainty());
+    SuccessOrExit(error = Tlv::Find<CslClockAccuracyTlv>(*this, tlvValue));
+    tlvValue.Get(aCslAccuracy);
 
 exit:
     return error;
@@ -4163,16 +4201,19 @@
 #endif
 
 #if OPENTHREAD_FTD
-Error Mle::RxMessage::ReadRouteTlv(RouteTlv &aRouteTlv) const
-{
-    Error error;
 
-    SuccessOrExit(error = Tlv::FindTlv(*this, aRouteTlv));
-    VerifyOrExit(aRouteTlv.IsValid(), error = kErrorParse);
+Error Mle::RxMessage::ReadRouteTlv(RouteTlv::Data &aRouteTlvData) const
+{
+    Error       error;
+    OffsetRange offsetRange;
+
+    SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(*this, RouteTlv::kType, offsetRange));
+    error = aRouteTlvData.ParseFrom(*this, offsetRange);
 
 exit:
     return error;
 }
+
 #endif
 
 //---------------------------------------------------------------------------------------------------------------------
@@ -4232,11 +4273,11 @@
     {
 #if OPENTHREAD_FTD
         VerifyOrExit((Get<Mle>().mLastSavedRole == kRoleRouter) || (Get<Mle>().mLastSavedRole == kRoleLeader));
-        VerifyOrExit(Get<Mle>().IsRouterEligible());
+        VerifyOrExit(Get<Mle>().IsRouterRoleAllowed());
 
         Get<MeshForwarder>().SetRxOnWhenIdle(true);
         SetState(kRestoringRouterOrLeaderRole);
-        DetermineMaxLinkRequestAttempts();
+        mAttempts = kRestoreLinkRequestAttempts;
         mTimer.Start(Get<Mle>().GenerateRandomDelay(kMaxStartDelay));
         error = kErrorNone;
 #endif
@@ -4381,23 +4422,20 @@
 
 #if OPENTHREAD_FTD
 
-void Mle::PrevRoleRestorer::DetermineMaxLinkRequestAttempts(void)
-{
-    mAttempts = kMaxCriticalTxCount;
-
-    if ((Get<Mle>().mLastSavedRole == kRoleRouter) &&
-        (Get<Mle>().mChildTable.GetNumChildren(Child::kInStateValidOrRestoring) < kMinCriticalChildrenCount))
-    {
-        mAttempts = kMaxTxCount;
-    }
-}
-
 void Mle::PrevRoleRestorer::SendMulticastLinkRequest(void)
 {
     uint32_t delay;
+    uint32_t retxDelayMin = kMulticastRetxDelayMin;
+    uint32_t retxDelayMax = kMulticastRetxDelayMax;
 
-    delay = (mAttempts == 0) ? kLinkRequestTimeout
-                             : Random::NonCrypto::GetUint32InRange(kMulticastRetxDelayMin, kMulticastRetxDelayMax);
+    if (Get<Mle>().mLastSavedRole == kRoleLeader)
+    {
+        retxDelayMin = kLeaderRetxDelayMin;
+        retxDelayMax = kLeaderRetxDelayMax;
+    }
+
+    delay =
+        (mAttempts == 0) ? kLinkRequestTimeout : Random::NonCrypto::GenerateInClosedRange(retxDelayMin, retxDelayMax);
 
     mTimer.Start(delay);
 
@@ -4419,6 +4457,7 @@
     , mAddressRegistrationMode(kAppendAllAddresses)
     , mParentRequestCounter(0)
     , mAnnounceChannel(0)
+    , mChildIdRequestsRemaining(kMaxChildIdRequests)
     , mAttachCounter(0)
     , mAnnounceDelay(kAnnounceTimeout)
     , mTimer(aInstance)
@@ -4577,7 +4616,7 @@
     }
 #endif // OPENTHREAD_CONFIG_MLE_ATTACH_BACKOFF_ENABLE
 
-    jitter = Random::NonCrypto::GetUint32InRange(0, kAttachStartJitter);
+    jitter = Random::NonCrypto::GenerateUpToExcluding(kAttachStartJitter);
 
     if (jitter + delay > delay) // check for overflow
     {
@@ -4655,15 +4694,15 @@
     bool              hasAcceptableParent = false;
     ParentRequestType parentReqType;
 
-    VerifyOrExit(mParentCandidate.IsStateParentResponse());
-
     switch (mState)
     {
     case kStateAnnounce:
+        VerifyOrExit(mParentCandidate.IsStateParentResponse());
         VerifyOrExit(!HasMoreChannelsToAnnounce());
         break;
 
     case kStateParentRequest:
+        VerifyOrExit(mParentCandidate.IsStateParentResponse());
         SuccessOrAssert(DetermineParentRequestType(parentReqType));
 
         if (parentReqType == kToRouters)
@@ -4677,6 +4716,11 @@
 
         break;
 
+    case kStateChildIdRequest:
+        VerifyOrExit(mParentCandidate.IsStateValid());
+        VerifyOrExit(mChildIdRequestsRemaining > 0);
+        break;
+
     default:
         ExitNow();
     }
@@ -4720,7 +4764,8 @@
     if (HasAcceptableParentCandidate() && (SendChildIdRequest() == kErrorNone))
     {
         SetState(kStateChildIdRequest);
-        delay = kChildIdResponseTimeout;
+        mChildIdRequestsRemaining--;
+        delay = Random::NonCrypto::AddJitter(kChildIdResponseTimeout, kChildIdResponseJitter);
         ExitNow();
     }
 
@@ -4738,6 +4783,7 @@
         mParentCandidate.SetState(Neighbor::kStateInvalid);
         mReceivedResponseFromParent = false;
         mParentRequestCounter       = 0;
+        mChildIdRequestsRemaining   = kMaxChildIdRequests;
         Get<MeshForwarder>().SetRxOnWhenIdle(true);
 
         OT_FALL_THROUGH;
@@ -4934,7 +4980,7 @@
     }
 
     VerifyOrExit((message = Get<Mle>().NewMleMessage(kCommandParentRequest)) != nullptr, error = kErrorNoBufs);
-    SuccessOrExit(error = message->AppendModeTlv(Get<Mle>().mDeviceMode));
+    SuccessOrExit(error = message->AppendModeTlv());
     SuccessOrExit(error = message->AppendChallengeTlv(mParentRequestChallenge));
     SuccessOrExit(error = message->AppendScanMaskTlv(scanMask));
     SuccessOrExit(error = message->AppendVersionTlv());
@@ -4950,7 +4996,7 @@
         messageToCurParent = static_cast<TxMessage *>(Get<Mle>().mSocket.CloneMessage(*message));
         VerifyOrExit(messageToCurParent != nullptr, error = kErrorNoBufs);
 
-        destination.SetToLinkLocalAddress(Get<Mle>().mParent.GetExtAddress());
+        destination.InitAsLinkLocalAddress(Get<Mle>().mParent.GetExtAddress());
         error = messageToCurParent->SendTo(destination);
 
         if (error != kErrorNone)
@@ -4961,7 +5007,7 @@
 
         Log(kMessageSend, kTypeParentRequestToRouters, destination);
 
-        destination.SetToLinkLocalAddress(Get<Mle>().mParentSearch.GetSelectedParent().GetExtAddress());
+        destination.InitAsLinkLocalAddress(Get<Mle>().mParentSearch.GetSelectedParent().GetExtAddress());
     }
     else
 #endif
@@ -5051,7 +5097,7 @@
     VerifyOrExit((message = Get<Mle>().NewMleMessage(kCommandChildIdRequest)) != nullptr, error = kErrorNoBufs);
     SuccessOrExit(error = message->AppendResponseTlv(mParentCandidate.mRxChallenge));
     SuccessOrExit(error = message->AppendLinkAndMleFrameCounterTlvs());
-    SuccessOrExit(error = message->AppendModeTlv(Get<Mle>().mDeviceMode));
+    SuccessOrExit(error = message->AppendModeTlv());
     SuccessOrExit(error = message->AppendTimeoutTlv(Get<Mle>().mTimeout));
     SuccessOrExit(error = message->AppendVersionTlv());
     SuccessOrExit(error = message->AppendSupervisionIntervalTlvIfSleepyChild());
@@ -5071,7 +5117,7 @@
 
     message->RegisterTxCallback(HandleChildIdRequestTxDone, this);
 
-    destination.SetToLinkLocalAddress(mParentCandidate.GetExtAddress());
+    destination.InitAsLinkLocalAddress(mParentCandidate.GetExtAddress());
     SuccessOrExit(error = message->SendTo(destination));
 
     Log(kMessageSend,
@@ -5180,9 +5226,6 @@
     uint32_t         mleFrameCounter;
     Mac::ExtAddress  extAddress;
     Mac::CslAccuracy cslAccuracy;
-#if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-    TimeParameterTlv timeParameterTlv;
-#endif
 
     SuccessOrExit(error = Tlv::Find<SourceAddressTlv>(aRxInfo.mMessage, sourceAddress));
 
@@ -5309,23 +5352,23 @@
     SuccessOrExit(error = aRxInfo.mMessage.ReadFrameCounterTlvs(linkFrameCounter, mleFrameCounter));
 
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-
-    if (Tlv::FindTlv(aRxInfo.mMessage, timeParameterTlv) == kErrorNone)
     {
-        VerifyOrExit(timeParameterTlv.IsValid());
+        TimeParameterTlvValue tlvValue;
 
-        Get<TimeSync>().SetTimeSyncPeriod(timeParameterTlv.GetTimeSyncPeriod());
-        Get<TimeSync>().SetXtalThreshold(timeParameterTlv.GetXtalThreshold());
-    }
-
+        if (Tlv::Find<TimeParameterTlv>(aRxInfo.mMessage, tlvValue) == kErrorNone)
+        {
+            Get<TimeSync>().SetTimeSyncPeriod(tlvValue.GetTimeSyncPeriod());
+            Get<TimeSync>().SetXtalThreshold(tlvValue.GetXtalThreshold());
+        }
 #if OPENTHREAD_CONFIG_TIME_SYNC_REQUIRED
-    else
-    {
-        // If the time sync feature is required, don't choose the
-        // parent which doesn't support it.
-        ExitNow();
-    }
+        else
+        {
+            // If the time sync feature is required, don't choose the
+            // parent which doesn't support it.
+            ExitNow();
+        }
 #endif
+    }
 #endif // OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
 
     SuccessOrExit(error = aRxInfo.mMessage.ReadChallengeTlv(mParentCandidate.mRxChallenge));
@@ -5438,7 +5481,12 @@
 
     Get<Mle>().mParent.SetRloc16(sourceAddress);
 
-    IgnoreError(aRxInfo.mMessage.ReadAndSetNetworkDataTlv(leaderData));
+    error = aRxInfo.mMessage.ReadAndSetNetworkDataTlv(leaderData);
+    if (error != kErrorNone)
+    {
+        IgnoreError(Get<Mle>().BecomeDetached());
+        ExitNow();
+    }
 
     Get<Mle>().SetStateChild(shortAddress);
 
@@ -5901,8 +5949,9 @@
         // back would be pointless.
 
 #if OPENTHREAD_FTD
-        if (Get<Mle>().IsFullThreadDevice() && Get<Mle>().IsRouterEligible())
+        if (Get<Mle>().IsFullThreadDevice() && Get<Mle>().IsRouterRoleAllowed())
         {
+            VerifyOrExit(!channelAndPanIdMatch);
             action = kSendAnnouceBack;
         }
         else
@@ -5933,14 +5982,14 @@
         break;
 
     case kAnnounceAttachAfterDelay:
-        // No action is required if device is detached, and current
-        // channel and pan-id match the values from the received MLE
-        // Announce message.
+        // `StartAnnounceAttach` only migrates the MAC channel and PAN
+        // ID. If they already match the announced values there is no
+        // migration to perform; skipping avoids a pointless
+        // Stop()/Start() that, in pathological topologies (two
+        // unmergeable partitions sharing channel/PAN ID), produces an
+        // endless role flap.
 
-        if (Get<Mle>().IsDetached())
-        {
-            VerifyOrExit(!channelAndPanIdMatch);
-        }
+        VerifyOrExit(!channelAndPanIdMatch);
 
         if (Get<MeshCoP::PendingDatasetManager>().ReadActiveTimestamp(pendingActiveTimestamp) == kErrorNone)
         {
diff --git a/src/core/thread/mle.hpp b/src/core/thread/mle.hpp
index 7273270..35901bc 100644
--- a/src/core/thread/mle.hpp
+++ b/src/core/thread/mle.hpp
@@ -131,6 +131,7 @@
 #if OPENTHREAD_FTD
     friend class ot::TimeTicker;
     friend class Tmf::Agent;
+    friend class ot::RouterTable;
 #endif
 
 public:
@@ -565,34 +566,58 @@
     uint16_t GetLeaderRloc16(void) const { return Rloc16FromRouterId(GetLeaderId()); }
 
     /**
-     * Retrieves the Leader's RLOC.
+     * Composes a Routing Locator (RLOC) address for a given RLOC16.
+     *
+     * @param[in]   aRloc16   The RLOC16 value.
+     * @param[out]  aAddress  A reference to an address to return the RLOC.
+     */
+    void ComposeRloc(uint16_t aRloc16, Ip6::Address &aAddress) const;
+
+    /**
+     * Composes an Anycast Locator (ALOC) address for a given ALOC16.
+     *
+     * @param[in]   aAloc16   The ALOC16 value.
+     * @param[out]  aAddress  A reference to an address to return the ALOC.
+     */
+    void ComposeAloc(uint16_t aAloc16, Ip6::Address &aAddress) const { ComposeRloc(aAloc16, aAddress); }
+
+    /**
+     * Composes the Leader's RLOC.
      *
      * @param[out]  aAddress  A reference to an address to return the Leader's RLOC.
      */
-    void GetLeaderRloc(Ip6::Address &aAddress) const;
+    void ComposeLeaderRloc(Ip6::Address &aAddress) const;
 
     /**
-     * Retrieves the Leader's ALOC.
+     * Composes the Leader's ALOC.
      *
      * @param[out]  aAddress  A reference to an address to return the Leader's ALOC.
      */
-    void GetLeaderAloc(Ip6::Address &aAddress) const;
+    void ComposeLeaderAloc(Ip6::Address &aAddress) const;
 
     /**
-     * Retrieves the Commissioner's ALOC for a given session ID.
+     * Composes the Commissioner's ALOC for a given session ID.
      *
-     * @param[in]   aSessionId      Commissioner session id.
+     * @param[in]   aSessionId      Commissioner session ID.
      * @param[out]  aAddress        A reference to an address to return the Commissioner's ALOC.
      */
-    void GetCommissionerAloc(uint16_t aSessionId, Ip6::Address &aAddress) const;
+    void ComposeCommissionerAloc(uint16_t aSessionId, Ip6::Address &aAddress) const;
 
     /**
-     * Retrieves the Service ALOC for given Service ID.
+     * Composes the Service ALOC for a given Service ID.
      *
-     * @param[in]   aServiceId Service ID to get ALOC for.
-     * @param[out]  aAddress   A reference to an address to return the Service ALOC.
+     * @param[in]   aServiceId   Service ID to compose ALOC for.
+     * @param[out]  aAddress     A reference to an address to return the Service ALOC.
      */
-    void GetServiceAloc(uint8_t aServiceId, Ip6::Address &aAddress) const;
+    void ComposeServiceAloc(uint8_t aServiceId, Ip6::Address &aAddress) const;
+
+    /**
+     * Composes a Mesh-Local IPv6 Address from a given Interface Identifier.
+     *
+     * @param[in]   aIid       The Interface Identifier to use.
+     * @param[out]  aAddress   A reference to the IPv6 Address to populate.
+     */
+    void ComposeMeshLocalAddress(const Ip6::InterfaceIdentifier &aIid, Ip6::Address &aAddress) const;
 
     /**
      * Returns the most recently received Leader Data.
@@ -760,14 +785,6 @@
 
 #if OPENTHREAD_FTD
     /**
-     * Indicates whether or not the device is router-eligible.
-     *
-     * @retval true   If device is router-eligible.
-     * @retval false  If device is not router-eligible.
-     */
-    bool IsRouterEligible(void) const;
-
-    /**
      * Sets whether or not the device is router-eligible.
      *
      * If @p aEligible is false and the device is currently operating as a router, this call will cause the device to
@@ -778,7 +795,18 @@
      * @retval kErrorNone         Successfully set the router-eligible configuration.
      * @retval kErrorNotCapable   The device is not capable of becoming a router.
      */
-    Error SetRouterEligible(bool aEligible);
+    Error SetRouterEligible(bool aEligible) { return mRoleTransitioner.SetRouterEligible(aEligible); }
+
+    /**
+     * Indicates whether the router role is currently allowed.
+     *
+     * A device is allowed to become a router if it is a Full Thread Device (FTD), is currently configured to be
+     * router-eligible (see `SetRouterEligible(true)`), and the active Security Policy permits routers.
+     *
+     * @retval TRUE   If the router role is allowed.
+     * @retval FALSE  If the router role is not allowed.
+     */
+    bool IsRouterRoleAllowed(void) const { return mRoleTransitioner.IsRouterRoleAllowed(); }
 
     /**
      * Indicates whether a node is the only router on the network.
@@ -870,7 +898,6 @@
      * @param[in]  aPartitionId  The preferred Leader Partition Id.
      */
     void SetPreferredLeaderPartitionId(uint32_t aPartitionId) { mPreferredLeaderPartitionId = aPartitionId; }
-#endif
 
     /**
      * Sets the preferred Router Id. Upon becoming a router/leader the node
@@ -885,6 +912,8 @@
      */
     Error SetPreferredRouterId(uint8_t aRouterId);
 
+#endif // OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+
     /**
      * Gets the Partition Id which the device joined successfully once.
      */
@@ -923,14 +952,14 @@
      *
      * @returns The ROUTER_SELECTION_JITTER value in seconds.
      */
-    uint8_t GetRouterSelectionJitter(void) const { return mRouterRoleTransition.GetJitter(); }
+    uint8_t GetRouterSelectionJitter(void) const { return mRoleTransitioner.GetJitter(); }
 
     /**
      * Sets the ROUTER_SELECTION_JITTER value.
      *
      * @param[in] aRouterJitter  The router selection jitter value (in seconds).
      */
-    void SetRouterSelectionJitter(uint8_t aRouterJitter) { mRouterRoleTransition.SetJitter(aRouterJitter); }
+    void SetRouterSelectionJitter(uint8_t aRouterJitter) { mRoleTransitioner.SetJitter(aRouterJitter); }
 
     /**
      * Indicates whether or not router role transition (upgrade from REED or downgrade to REED) is pending.
@@ -938,7 +967,7 @@
      * @retval TRUE    Router role transition is pending.
      * @retval FALSE   Router role transition is not pending
      */
-    bool IsRouterRoleTransitionPending(void) const { return mRouterRoleTransition.IsPending(); }
+    bool IsRouterRoleTransitionPending(void) const { return mRoleTransitioner.IsTransitionPending(); }
 
     /**
      * Returns the current timeout delay in seconds till router role transition (upgrade from REED or downgrade to
@@ -946,35 +975,76 @@
      *
      * @returns The timeout in seconds till router role transition, or zero if not pending role transition.
      */
-    uint8_t GetRouterRoleTransitionTimeout(void) const { return mRouterRoleTransition.GetTimeout(); }
+    uint8_t GetRouterRoleTransitionTimeout(void) const { return mRoleTransitioner.GetTimeout(); }
 
     /**
      * Returns the ROUTER_UPGRADE_THRESHOLD value.
      *
      * @returns The ROUTER_UPGRADE_THRESHOLD value.
      */
-    uint8_t GetRouterUpgradeThreshold(void) const { return mRouterUpgradeThreshold; }
+    uint8_t GetRouterUpgradeThreshold(void) const { return mRoleTransitioner.GetUpgradeThreshold(); }
 
     /**
      * Sets the ROUTER_UPGRADE_THRESHOLD value.
      *
+     * This threshold is used by the device to decide whether to upgrade from a child (REED) to a router role. It is
+     * not used when the device is acting as the Leader to evaluate upgrade requests from other devices.
+     *
      * @param[in]  aThreshold  The ROUTER_UPGRADE_THRESHOLD value.
      */
-    void SetRouterUpgradeThreshold(uint8_t aThreshold) { mRouterUpgradeThreshold = aThreshold; }
+    void SetRouterUpgradeThreshold(uint8_t aThreshold) { mRoleTransitioner.SetUpgradeThreshold(aThreshold); }
+
+    /**
+     * Returns the leader upgrade threshold value.
+     *
+     * This threshold is used only when the device is operating as the Leader to decide whether to accept or reject
+     * an Address Solicit request from a router-eligible end device (REED) wishing to upgrade to a router.
+     *
+     * @returns The leader upgrade threshold value.
+     */
+    uint8_t GetLeaderUpgradeThreshold(void) const { return mLeaderUpgradeThreshold; }
+
+    /**
+     * Sets the leader upgrade threshold value.
+     *
+     * This threshold is used only when the device is operating as the Leader to decide whether to accept or reject
+     * an Address Solicit request from a router-eligible end device (REED) wishing to upgrade to a router.
+     *
+     * @param[in]  aThreshold  The leader upgrade threshold value.
+     */
+    void SetLeaderUpgradeThreshold(uint8_t aThreshold) { mLeaderUpgradeThreshold = aThreshold; }
 
     /**
      * Returns the ROUTER_DOWNGRADE_THRESHOLD value.
      *
      * @returns The ROUTER_DOWNGRADE_THRESHOLD value.
      */
-    uint8_t GetRouterDowngradeThreshold(void) const { return mRouterDowngradeThreshold; }
+    uint8_t GetRouterDowngradeThreshold(void) const { return mRoleTransitioner.GetDowngradeThreshold(); }
 
     /**
      * Sets the ROUTER_DOWNGRADE_THRESHOLD value.
      *
      * @param[in]  aThreshold  The ROUTER_DOWNGRADE_THRESHOLD value.
      */
-    void SetRouterDowngradeThreshold(uint8_t aThreshold) { mRouterDowngradeThreshold = aThreshold; }
+    void SetRouterDowngradeThreshold(uint8_t aThreshold) { mRoleTransitioner.SetDowngradeThreshold(aThreshold); }
+
+    /**
+     * Indicates whether or not downgrading from router role to REED is blocked.
+     *
+     * If the transition to the router role was triggered by a Child ID Request, it indicates that the child has no
+     * other parent option. In this case the downgrade is blocked to prevent the parent router becoming REED to ensure
+     * this child remains connected. This flag is cleared in various situations:
+     *
+     * - When device detaches (e.g. partition change).
+     * - If a new router is added (new possible parent).
+     * - If all children blocking downgrade are disconnected.
+     *
+     * This method is intended for testing purposes only.
+     *
+     * @retval TRUE   The device is blocked from downgrading.
+     * @retval FALSE  The device is not blocked from downgrading.
+     */
+    bool IsDowngradeBlocked(void) const { return mRoleTransitioner.IsDowngradeBlocked(); }
 
     /**
      * Returns the MLE_CHILD_ROUTER_LINKS value.
@@ -999,7 +1069,7 @@
      * @retval TRUE   If the REED is going to become a Router soon.
      * @retval FALSE  If the REED is not going to become a Router soon.
      */
-    bool WillBecomeRouterSoon(void) const;
+    bool WillBecomeRouterSoon(void) const { return mRoleTransitioner.WillBecomeRouterSoon(); }
 
     /**
      * Removes a link to a neighbor.
@@ -1120,13 +1190,6 @@
      */
     void ResetAdvertiseInterval(void);
 
-    /**
-     * Updates the MLE Advertisement Trickle timer max interval (if timer is running).
-     *
-     * This is called when there is change in router table.
-     */
-    void UpdateAdvertiseInterval(void);
-
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
     /**
      * Generates an MLE Time Synchronization message.
@@ -1137,40 +1200,21 @@
     Error SendTimeSync(void);
 #endif
 
-    /**
-     * Gets the maximum number of IP addresses that each MTD child may register with this device as parent.
-     *
-     * @returns The maximum number of IP addresses that each MTD child may register with this device as parent.
-     */
-    uint8_t GetMaxChildIpAddresses(void) const;
-
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
 
     /**
-     * Sets/restores the maximum number of IP addresses that each MTD child may register with this
-     * device as parent.
-     *
-     * @param[in]  aMaxIpAddresses  The maximum number of IP addresses that each MTD child may register with this
-     *                              device as parent. 0 to clear the setting and restore the default.
-     *
-     * @retval kErrorNone           Successfully set/cleared the number.
-     * @retval kErrorInvalidArgs    If exceeds the allowed maximum number.
-     */
-    Error SetMaxChildIpAddresses(uint8_t aMaxIpAddresses);
-
-    /**
      * Sets whether the device was commissioned using CCM.
      *
      * @param[in]  aEnabled  TRUE if the device was commissioned using CCM, FALSE otherwise.
      */
-    void SetCcmEnabled(bool aEnabled) { mCcmEnabled = aEnabled; }
+    void SetCcmEnabled(bool aEnabled) { mRoleTransitioner.SetCcmEnabled(aEnabled); }
 
     /**
      * Sets whether the Security Policy TLV version-threshold for routing (VR field) is enabled.
      *
      * @param[in]  aEnabled  TRUE to enable Security Policy TLV version-threshold for routing, FALSE otherwise.
      */
-    void SetThreadVersionCheckEnabled(bool aEnabled) { mThreadVersionCheckEnabled = aEnabled; }
+    void SetThreadVersionCheckEnabled(bool aEnabled) { mRoleTransitioner.SetThreadVersionCheckEnabled(aEnabled); }
 
     /**
      * Gets the current Interval Max value used by Advertisement trickle timer.
@@ -1251,6 +1295,7 @@
     static constexpr uint32_t kParentRequestReedTimeout      = 1250; // Wait timer after tx of Parent Req to REEDs
     static constexpr uint32_t kParentRequestDuplicateTimeout = 700;  // Min time to detect duplicate Parent Req rx
     static constexpr uint32_t kChildIdResponseTimeout        = 1250; // Wait time to receive Child ID Response
+    static constexpr uint32_t kChildIdResponseJitter         = kChildIdResponseTimeout * 1 / 10; // Jitter 10%
     static constexpr uint32_t kAttachStartJitter             = 50;   // Max jitter time added to start of attach
     static constexpr uint32_t kAnnounceProcessTimeout        = 250;  // Delay after Announce rx before processing
     static constexpr uint32_t kAnnounceTimeout               = 1400; // Total timeout for sending Announce messages
@@ -1305,7 +1350,6 @@
     static constexpr uint8_t  kMleHopLimit                   = 255;
     static constexpr uint8_t  kMleSecurityTagSize            = 4;
     static constexpr uint32_t kDefaultStoreFrameCounterAhead = OPENTHREAD_CONFIG_STORE_FRAME_COUNTER_AHEAD;
-    static constexpr uint8_t  kMaxIpAddressesToRegister      = OPENTHREAD_CONFIG_MLE_IP_ADDRS_TO_REGISTER;
     static constexpr uint32_t kDefaultChildTimeout           = OPENTHREAD_CONFIG_MLE_CHILD_TIMEOUT_DEFAULT;
     static constexpr uint32_t kDefaultCslTimeout             = OPENTHREAD_CONFIG_CSL_TIMEOUT;
 
@@ -1331,6 +1375,7 @@
     static constexpr uint8_t  kRouterDowngradeThreshold      = 23;
     static constexpr uint8_t  kRouterUpgradeThreshold        = 16;
     static constexpr uint16_t kDiscoveryMaxJitter            = 250; // Max jitter delay Discovery Responses (in msec).
+    static constexpr uint32_t kMaxScheduledDiscoveryResponse = 16;  // Rate-limit Discovery response.
     static constexpr uint16_t kUnsolicitedDataResponseJitter = 500; // Max delay for unsol Data Response (in msec).
     static constexpr uint8_t  kLeaderDowngradeExtraDelay     = 10;  // Extra delay to downgrade leader (in sec).
     static constexpr uint8_t  kDefaultLeaderWeight           = 64;
@@ -1344,7 +1389,6 @@
     static constexpr uint8_t kLinkRequestMinMargin    = OPENTHREAD_CONFIG_MLE_LINK_REQUEST_MARGIN_MIN;
     static constexpr uint8_t kPartitionMergeMinMargin = OPENTHREAD_CONFIG_MLE_PARTITION_MERGE_MARGIN_MIN;
     static constexpr uint8_t kChildRouterLinks        = OPENTHREAD_CONFIG_MLE_CHILD_ROUTER_LINKS;
-    static constexpr uint8_t kMaxChildIpAddresses     = OPENTHREAD_CONFIG_MLE_IP_ADDRS_PER_CHILD;
 
     // Constants for gradual router link establishment (on FTD child)
     struct GradualChildRouterLink
@@ -1461,8 +1505,8 @@
     {
         kMessageSend,
         kMessageReceive,
-        kMessageDelay,
-        kMessageRemoveDelayed,
+        kMessageScheduleDelayedSend,
+        kMessageRemoveDelayedSend,
     };
 
     enum MessageType : uint8_t
@@ -1542,14 +1586,15 @@
     class TxMessage : public Message
     {
     public:
+        // Appending single TLV
         Error AppendSourceAddressTlv(void);
+        Error AppendModeTlv(void);
         Error AppendModeTlv(DeviceMode aMode);
         Error AppendTimeoutTlv(uint32_t aTimeout);
         Error AppendChallengeTlv(const TxChallenge &aChallenge);
         Error AppendResponseTlv(const RxChallenge &aResponse);
         Error AppendLinkFrameCounterTlv(void);
         Error AppendMleFrameCounterTlv(void);
-        Error AppendLinkAndMleFrameCounterTlvs(void);
         Error AppendAddress16Tlv(uint16_t aRloc16);
         Error AppendNetworkDataTlv(NetworkData::Type aType);
         Error AppendTlvRequestTlv(const uint8_t *aTlvs, uint8_t aTlvsLength);
@@ -1564,7 +1609,6 @@
         Error AppendXtalAccuracyTlv(void);
         Error AppendActiveTimestampTlv(void);
         Error AppendPendingTimestampTlv(void);
-        Error AppendActiveAndPendingTimestampTlvs(void);
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
         Error AppendTimeRequestTlv(void);
         Error AppendTimeParameterTlv(void);
@@ -1577,7 +1621,8 @@
         Error AppendCslClockAccuracyTlv(void);
 #endif
 #if OPENTHREAD_FTD
-        Error AppendRouteTlv(Neighbor *aNeighbor = nullptr);
+        Error AppendRouteTlv(void);
+        Error AppendCompactRouteTlv(uint16_t aDestRloc16);
         Error AppendActiveDatasetTlv(void);
         Error AppendPendingDatasetTlv(void);
         Error AppendConnectivityTlv(void);
@@ -1589,11 +1634,17 @@
             return AppendTlvRequestTlv(aTlvArray, kArrayLength);
         }
 
+        // Appending multiple TLVs
+        Error AppendLinkAndMleFrameCounterTlvs(void);
+        Error AppendSourceAddressAndLeaderDataTlvs(void);
+        Error AppendActiveAndPendingTimestampTlvs(void);
+
         Error SendTo(const Ip6::Address &aDestination);
 
     private:
         Error AppendAddressRegistrationEntry(const Ip6::Address &aAddress);
         Error AppendDatasetTlv(MeshCoP::Dataset::Type aDatasetType);
+        Error AppendFullOrCompactRouteTlv(uint16_t aDestRloc16);
     };
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -1618,7 +1669,7 @@
         Error ReadCslClockAccuracyTlv(Mac::CslAccuracy &aCslAccuracy) const;
 #endif
 #if OPENTHREAD_FTD
-        Error ReadRouteTlv(RouteTlv &aRouteTlv) const;
+        Error ReadRouteTlv(RouteTlv::Data &aRouteTlvData) const;
 #endif
 
     private:
@@ -1680,6 +1731,7 @@
         Mac::ExtAddress mExtAddress;       // The neighbor/router extended address.
         TlvList         mRequestedTlvList; // The requested TLVs in Link Request.
         RxChallenge     mRxChallenge;      // The challenge in Link Request.
+        uint16_t        mRloc16;           // The neighbor/router RLOC16.
         uint8_t         mLinkMargin;       // Link margin of the received Link Request.
     };
 #endif
@@ -1752,15 +1804,16 @@
             MessageType  mMessageType;
         };
 
-        void AddSchedule(MessageType         aMessageType,
-                         const Ip6::Address &aDestination,
-                         uint32_t            aDelay,
-                         const void         *aInfo,
-                         uint16_t            aInfoSize);
-        void Execute(const Schedule &aSchedule);
-        bool HasMatchingSchedule(MessageType aMessageType, const Ip6::Address &aDestination) const;
-        void RemoveMatchingSchedules(MessageType aMessageType, const Ip6::Address &aDestination);
-        void LogRemove(const Schedule &aSchedule);
+        void     AddSchedule(MessageType         aMessageType,
+                             const Ip6::Address &aDestination,
+                             uint32_t            aDelay,
+                             const void         *aInfo,
+                             uint16_t            aInfoSize);
+        void     Execute(const Schedule &aSchedule);
+        bool     HasMatchingSchedule(MessageType aMessageType, const Ip6::Address &aDestination) const;
+        void     RemoveMatchingSchedules(MessageType aMessageType, const Ip6::Address &aDestination);
+        uint32_t CountMatchingSchedules(MessageType aMessageType) const;
+        void     LogRemove(const Schedule &aSchedule);
 
         static bool Match(const Schedule &aSchedule, MessageType aMessageType, const Ip6::Address &aDestination);
 
@@ -1799,6 +1852,24 @@
         uint8_t  mKeyIndex;
     } OT_TOOL_PACKED_END;
 
+    static_assert(sizeof(SecurityHeader) == 10, "SecurityHeader is not packed");
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+    OT_TOOL_PACKED_BEGIN
+    struct AesCcmAuthData
+    {
+        // Represents the authenticated data used during MLE AES-CCM
+        // security processing. It includes the sender and receiver
+        // IPv6 addresses followed by the Aux Security Header.
+
+        Ip6::Address   mSenderAddr;
+        Ip6::Address   mReceiverAddr;
+        SecurityHeader mSecurityHeader;
+    } OT_TOOL_PACKED_END;
+
+    static_assert(sizeof(AesCcmAuthData) == 42, "AesCcmAuthData is not packed");
+
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 
     class ParentCandidate : public Parent
@@ -1857,6 +1928,9 @@
         static constexpr uint16_t kChildUpdateMinTimeout              = 1000; // in ms
         static constexpr uint16_t kChildUpdateStartTimeout            = 4000; // in ms
         static constexpr uint16_t kChildUpdateRetxJitter              = 25;   // in ms
+        static constexpr uint8_t  kRestoreLinkRequestAttempts         = 4;
+        static constexpr uint32_t kLeaderRetxDelayMin = kLinkRequestTimeout * 9 / 10;  // 0.9 * base delay
+        static constexpr uint32_t kLeaderRetxDelayMax = kLinkRequestTimeout * 11 / 10; // 1.1 * base delay
 
         enum State : uint8_t
         {
@@ -1869,7 +1943,6 @@
         void SendChildUpdate(void);
         void CheckIfMessageIsFromParent(RxInfo &aRxInfo);
 #if OPENTHREAD_FTD
-        void DetermineMaxLinkRequestAttempts(void);
         void SendMulticastLinkRequest(void);
 #endif
 
@@ -1889,6 +1962,8 @@
 
     class Attacher : public InstanceLocator
     {
+        friend class ot::UnitTester;
+
     public:
         explicit Attacher(Instance &aInstance);
 
@@ -1926,6 +2001,8 @@
             kReattachModePending, // Reattach using stored Pending Dataset
         };
 
+        static constexpr uint8_t kMaxChildIdRequests = 3;
+
         void     SetState(State aState);
         uint32_t GetStartDelay(void) const;
         bool     HasAcceptableParentCandidate(void) const;
@@ -1960,6 +2037,7 @@
         AddressRegistrationMode mAddressRegistrationMode;
         uint8_t                 mParentRequestCounter;
         uint8_t                 mAnnounceChannel;
+        uint8_t                 mChildIdRequestsRemaining;
         uint16_t                mAttachCounter;
         uint16_t                mAnnounceDelay;
         TxChallenge             mParentRequestChallenge;
@@ -2177,26 +2255,65 @@
 
 #if OPENTHREAD_FTD
 
-    class RouterRoleTransition
+    class RoleTransitioner : public InstanceLocator
     {
-    public:
-        RouterRoleTransition(void);
+        // Manages the router role upgrade/downgrade transitions
 
-        bool    IsPending(void) const { return (mTimeout != 0); }
+    public:
+        enum UpdateRouterRoleAllowedReason : uint8_t // Used in `UpdateRouterRoleAllowed()`
+        {
+            kReasonMleInit,
+            kReasonDeviceModeChanged,
+            kReasonConfigParameterChanged,
+            kReasonSecurityPolicyChanged,
+        };
+
+        explicit RoleTransitioner(Instance &aInstance);
+
+        bool    IsRouterRoleAllowed(void) const { return mRouterRoleAllowed; }
+        void    UpdateRouterRoleAllowed(UpdateRouterRoleAllowedReason aReason);
+        Error   SetRouterEligible(bool aEligible);
+        uint8_t GetJitter(void) const { return mJitter; }
+        void    SetJitter(uint8_t aJitter) { mJitter = aJitter; }
+        uint8_t GetUpgradeThreshold(void) const { return mUpgradeThreshold; }
+        void    SetUpgradeThreshold(uint8_t aThreshold) { mUpgradeThreshold = aThreshold; }
+        uint8_t GetDowngradeThreshold(void) const { return mDowngradeThreshold; }
+        void    SetDowngradeThreshold(uint8_t aThreshold) { mDowngradeThreshold = aThreshold; }
+        bool    IsDowngradeBlocked(void) const { return mDowngradeBlocked; }
+        void    SetDowngradeBlocked(bool aBlocked) { mDowngradeBlocked = aBlocked; }
+        bool    IsTransitionPending(void) const { return (mTimeout != 0); }
+        bool    WillBecomeRouterSoon(void) const;
         void    StartTimeout(void);
         void    StopTimeout(void) { mTimeout = 0; }
         void    IncreaseTimeout(uint8_t aIncrement) { mTimeout += aIncrement; }
         uint8_t GetTimeout(void) const { return mTimeout; }
-        bool    HandleTimeTick(void);
-        uint8_t GetJitter(void) const { return mJitter; }
-        void    SetJitter(uint8_t aJitter) { mJitter = aJitter; }
+        bool    IsRouterCountBelowUpgradeThreshold(void) const;
+        void    HandleTimeTick(void);
+        void    DecideWhetherToUpgrade(void);
+        void    DecideWhetherToDowngrade(uint8_t aNeighborId, const RouteTlv::Data &aRouteTlvData);
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+        void SetCcmEnabled(bool aEnabled);
+        void SetThreadVersionCheckEnabled(bool aEnabled);
+#endif
 
     private:
+        bool DetermineIfRouterRoleAllowed(void) const;
+        bool NeighborHasComparableConnectivity(uint8_t aNeighborId, const RouteTlv::Data &aRouteTlvData) const;
+
+        bool mRouterEligible : 1;
+        bool mRouterRoleAllowed : 1;
+        bool mDowngradeBlocked : 1;
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+        bool mCcmEnabled : 1;
+        bool mThreadVersionCheckEnabled : 1;
+#endif
         uint8_t mTimeout;
         uint8_t mJitter;
+        uint8_t mUpgradeThreshold;
+        uint8_t mDowngradeThreshold;
     };
 
-#endif
+#endif // OPENTHREAD_FTD
 
     //------------------------------------------------------------------------------------------------------------------
 #if OPENTHREAD_CONFIG_P2P_ENABLE
@@ -2304,17 +2421,17 @@
     void       HandleChildUpdateResponseOnChild(RxInfo &aRxInfo);
     void       HandleDataResponse(RxInfo &aRxInfo);
     Error      HandleLeaderData(RxInfo &aRxInfo);
-    bool       HasUnregisteredAddress(void);
     uint32_t   GetAttachStartDelay(void) const;
     void       SendAnnounce(uint8_t aChannel, AnnounceMode aMode);
     void       SendAnnounce(uint8_t aChannel, const Ip6::Address &aDestination, AnnounceMode aMode = kNormalAnnounce);
     bool       IsNetworkDataNewer(const LeaderData &aLeaderData);
+    bool       HasUnregisteredAddress(void) const;
+    bool       ShouldRegisterUnicastAddrWithParent(const Ip6::Netif::UnicastAddress &aUnicastAddress) const;
     bool       ShouldRegisterMulticastAddrsWithParent(void) const;
-    Error      ProcessMessageSecurity(Crypto::AesCcm::Mode    aMode,
-                                      Message                &aMessage,
-                                      const Ip6::MessageInfo &aMessageInfo,
-                                      uint16_t                aCmdOffset,
-                                      const SecurityHeader   &aHeader);
+    Error      ProcessMessageSecurity(Crypto::AesCcm::Operation aOperation,
+                                      Message                  &aMessage,
+                                      uint16_t                  aCmdOffset,
+                                      const AesCcmAuthData     &aAuthData);
 
 #if OPENTHREAD_CONFIG_MLE_INFORM_PREVIOUS_PARENT_ON_REATTACH
     void InformPreviousParent(void);
@@ -2382,7 +2499,6 @@
     void     DetermineConnectivity(Connectivity &aConnectivity) const;
     void     HandleDetachStart(void);
     void     HandleChildStart(void);
-    void     HandleSecurityPolicyChanged(void);
     void     HandleLinkRequest(RxInfo &aRxInfo);
     void     HandleLinkAccept(RxInfo &aRxInfo);
     void     HandleLinkAcceptAndRequest(RxInfo &aRxInfo);
@@ -2396,7 +2512,7 @@
     void     HandleNetworkDataUpdateRouter(void);
     void     HandleDiscoveryRequest(RxInfo &aRxInfo);
     void     EstablishRouterLinkOnFtdChild(Router &aRouter, RxInfo &aRxInfo, uint8_t aLinkMargin);
-    Error    ProcessRouteTlv(const RouteTlv &aRouteTlv, RxInfo &aRxInfo);
+    Error    ProcessRouteTlv(const RouteTlv::Data &aRouteTlvData, RxInfo &aRxInfo);
     Error    ReadAndProcessRouteTlvOnFtdChild(RxInfo &aRxInfo, uint8_t aParentId);
     void     StopAdvertiseTrickleTimer(void);
     uint32_t DetermineAdvertiseIntervalMax(void) const;
@@ -2427,19 +2543,14 @@
     void     SetChildStateToValid(Child &aChild);
     bool     HasChildren(void);
     void     RemoveChildren(void);
-    bool     ShouldDowngrade(uint8_t aNeighborId, const RouteTlv &aRouteTlv) const;
-    bool     NeighborHasComparableConnectivity(const RouteTlv &aRouteTlv, uint8_t aNeighborId) const;
     void     HandleAdvertiseTrickleTimer(void);
     void     HandleTimeTick(void);
+    void     HandleRouterTableEvent(RouterTable::Events aEvents);
 
     template <Uri kUri> void HandleTmf(Coap::Msg &aMsg);
 
     DeclareTmfResponseHandlerIn(Mle, HandleAddressSolicitResponse);
 
-#if OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    void SignalDuaAddressEvent(const Child &aChild, const Ip6::Address &aOldDua) const;
-#endif
-
     static bool IsMessageMleSubType(const Message &aMessage);
     static bool IsMessageChildUpdateRequest(const Message &aMessage);
     static void HandleAdvertiseTrickleTimer(TrickleTimer &aTimer);
@@ -2506,26 +2617,17 @@
 
 #if OPENTHREAD_FTD
 
-    bool mRouterEligible : 1;
-    bool mAddressSolicitPending : 1;
-    bool mAddressSolicitRejected : 1;
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    bool mCcmEnabled : 1;
-    bool mThreadVersionCheckEnabled : 1;
-#endif
-    uint8_t mRouterId;
-    uint8_t mPreviousRouterId;
-    uint8_t mNetworkIdTimeout;
-    uint8_t mRouterUpgradeThreshold;
-    uint8_t mRouterDowngradeThreshold;
-    uint8_t mLeaderWeight;
-    uint8_t mPreviousPartitionRouterIdSequence;
-    uint8_t mPreviousPartitionIdTimeout;
-    uint8_t mChildRouterLinks;
-    uint8_t mAlternateRloc16Timeout;
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    uint8_t mMaxChildIpAddresses;
-#endif
+    bool     mAddressSolicitPending : 1;
+    bool     mAddressSolicitRejected : 1;
+    uint8_t  mRouterId;
+    uint8_t  mPreviousRouterId;
+    uint8_t  mNetworkIdTimeout;
+    uint8_t  mLeaderWeight;
+    uint8_t  mPreviousPartitionRouterIdSequence;
+    uint8_t  mPreviousPartitionIdTimeout;
+    uint8_t  mChildRouterLinks;
+    uint8_t  mAlternateRloc16Timeout;
+    uint8_t  mLeaderUpgradeThreshold;
     int8_t   mParentPriority;
     uint32_t mPreviousPartitionIdRouter;
     uint32_t mPreviousPartitionId;
@@ -2535,7 +2637,7 @@
     TrickleTimer               mAdvertiseTrickleTimer;
     ChildTable                 mChildTable;
     RouterTable                mRouterTable;
-    RouterRoleTransition       mRouterRoleTransition;
+    RoleTransitioner           mRoleTransitioner;
     Ip6::Netif::UnicastAddress mLeaderAloc;
 #if OPENTHREAD_CONFIG_MLE_DEVICE_PROPERTY_LEADER_WEIGHT_ENABLE
     DeviceProperties mDeviceProperties;
diff --git a/src/core/thread/mle_ftd.cpp b/src/core/thread/mle_ftd.cpp
index a64dbe0..9814673 100644
--- a/src/core/thread/mle_ftd.cpp
+++ b/src/core/thread/mle_ftd.cpp
@@ -76,16 +76,17 @@
     mRouterTable.Clear();
 }
 
-bool Mle::IsRouterEligible(void) const
+bool Mle::RoleTransitioner::DetermineIfRouterRoleAllowed(void) const
 {
-    bool                  rval      = false;
+    bool                  allowed   = false;
     const SecurityPolicy &secPolicy = Get<KeyManager>().GetSecurityPolicy();
 
-    VerifyOrExit(mRouterEligible && IsFullThreadDevice());
+    VerifyOrExit(mRouterEligible && Get<Mle>().IsFullThreadDevice());
 
 #if OPENTHREAD_CONFIG_THREAD_VERSION == OT_THREAD_VERSION_1_1
     VerifyOrExit(secPolicy.mRoutersEnabled);
 #else
+
     if (secPolicy.mCommercialCommissioningEnabled)
     {
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
@@ -94,6 +95,7 @@
         VerifyOrExit(secPolicy.mNonCcmRoutersEnabled);
 #endif
     }
+
     if (!secPolicy.mRoutersEnabled)
     {
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
@@ -105,19 +107,80 @@
                      kThreadVersion);
 #endif
     }
-#endif
 
-    rval = true;
+#endif // #if OPENTHREAD_CONFIG_THREAD_VERSION == OT_THREAD_VERSION_1_1
+
+    allowed = true;
 
 exit:
-    return rval;
+    return allowed;
 }
 
-Error Mle::SetRouterEligible(bool aEligible)
+void Mle::RoleTransitioner::UpdateRouterRoleAllowed(UpdateRouterRoleAllowedReason aReason)
+{
+    bool allowed = DetermineIfRouterRoleAllowed();
+
+    VerifyOrExit(allowed != mRouterRoleAllowed);
+    mRouterRoleAllowed = allowed;
+
+    if (Get<Mle>().IsAttached())
+    {
+        Get<Mac::Mac>().SetBeaconEnabled(mRouterRoleAllowed);
+    }
+
+    // Take action based on the current role, the new `mRouterRoleAllowed`,
+    // and the reason for the change.
+
+    if (Get<Mle>().IsChild() && mRouterRoleAllowed && (aReason == kReasonConfigParameterChanged))
+    {
+        StartTimeout();
+    }
+
+    if (Get<Mle>().IsRouterOrLeader())
+    {
+        // If currently acting as router or leader, but the config or
+        // security policy changes such that the router role is no
+        // longer allowed, we take action based on the reason. If the
+        // change is due to a security policy update, we start a jitter
+        // timeout to downgrade (per Section 5.9.9 in the Thread spec),
+        // adding an extra delay if acting as leader. If this is
+        // triggered due to parameter change (`SetRouterEligible(false)`
+        // was called by the user), we take immediate action and become
+        // detached.
+
+        VerifyOrExit(!mRouterRoleAllowed);
+
+        switch (aReason)
+        {
+        case kReasonMleInit:
+        case kReasonDeviceModeChanged:
+            break;
+
+        case kReasonConfigParameterChanged:
+            IgnoreError(Get<Mle>().BecomeDetached());
+            break;
+
+        case kReasonSecurityPolicyChanged:
+            VerifyOrExit(!IsTransitionPending());
+            StartTimeout();
+
+            if (Get<Mle>().IsLeader())
+            {
+                IncreaseTimeout(kLeaderDowngradeExtraDelay);
+            }
+            break;
+        }
+    }
+
+exit:
+    return;
+}
+
+Error Mle::RoleTransitioner::SetRouterEligible(bool aEligible)
 {
     Error error = kErrorNone;
 
-    if (!IsFullThreadDevice())
+    if (!Get<Mle>().IsFullThreadDevice())
     {
         VerifyOrExit(!aEligible, error = kErrorNotCapable);
     }
@@ -125,57 +188,26 @@
     VerifyOrExit(aEligible != mRouterEligible);
 
     mRouterEligible = aEligible;
-
-    switch (mRole)
-    {
-    case kRoleDisabled:
-    case kRoleDetached:
-        break;
-
-    case kRoleChild:
-        if (mRouterEligible)
-        {
-            mRouterRoleTransition.StartTimeout();
-        }
-
-        Get<Mac::Mac>().SetBeaconEnabled(mRouterEligible);
-        break;
-
-    case kRoleRouter:
-    case kRoleLeader:
-        if (!mRouterEligible)
-        {
-            IgnoreError(BecomeDetached());
-        }
-
-        break;
-    }
+    UpdateRouterRoleAllowed(kReasonConfigParameterChanged);
 
 exit:
     return error;
 }
 
-void Mle::HandleSecurityPolicyChanged(void)
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+void Mle::RoleTransitioner::SetCcmEnabled(bool aEnabled)
 {
-    // If we are currently router or leader and no longer eligible to
-    // be a router (due to security policy change), we start jitter
-    // timeout to downgrade.
-
-    VerifyOrExit(IsRouterOrLeader() && !IsRouterEligible());
-
-    VerifyOrExit(!mRouterRoleTransition.IsPending());
-
-    mRouterRoleTransition.StartTimeout();
-
-    if (IsLeader())
-    {
-        mRouterRoleTransition.IncreaseTimeout(kLeaderDowngradeExtraDelay);
-    }
-
-exit:
-    return;
+    mCcmEnabled = aEnabled;
+    UpdateRouterRoleAllowed(kReasonConfigParameterChanged);
 }
 
+void Mle::RoleTransitioner::SetThreadVersionCheckEnabled(bool aEnabled)
+{
+    mThreadVersionCheckEnabled = aEnabled;
+    UpdateRouterRoleAllowed(kReasonConfigParameterChanged);
+}
+#endif
+
 #if OPENTHREAD_CONFIG_MLE_DEVICE_PROPERTY_LEADER_WEIGHT_ENABLE
 void Mle::SetDeviceProperties(const DeviceProperties &aDeviceProperties)
 {
@@ -204,12 +236,12 @@
         ExitNow();
     }
 
-    VerifyOrExit(IsRouterEligible(), error = kErrorNotCapable);
+    VerifyOrExit(IsRouterRoleAllowed(), error = kErrorNotCapable);
 
     LogInfo("Attempt to become router, reason:%s", RouterUpgradeReasonToString(aReason));
 
     Get<MeshForwarder>().SetRxOnWhenIdle(true);
-    mRouterRoleTransition.StopTimeout();
+    mRoleTransitioner.StopTimeout();
 
     error = SendAddressSolicit(aReason);
 
@@ -230,7 +262,7 @@
 #endif
     VerifyOrExit(!IsDisabled(), error = kErrorInvalidState);
     VerifyOrExit(!IsLeader(), error = kErrorNone);
-    VerifyOrExit(IsRouterEligible(), error = kErrorNotCapable);
+    VerifyOrExit(IsRouterRoleAllowed(), error = kErrorNotCapable);
 
     if ((aMode == kCheckLeaderWeight) && IsAttached())
     {
@@ -271,7 +303,7 @@
 #endif
 
     return IsValueInRange(mPreviousRouterId, minId, maxId) ? mPreviousRouterId
-                                                           : Random::NonCrypto::GetUint8InRange(minId, maxId + 1);
+                                                           : Random::NonCrypto::GenerateInClosedRange(minId, maxId);
 }
 
 uint32_t Mle::SelectPartitionId(void) const
@@ -286,7 +318,7 @@
     else
 #endif
     {
-        partitionId = Random::NonCrypto::GetUint32();
+        partitionId = Random::NonCrypto::Generate<uint32_t>();
     }
 
     return partitionId;
@@ -309,15 +341,12 @@
 {
     mAddressSolicitRejected = false;
 
-    mRouterRoleTransition.StartTimeout();
+    mRoleTransitioner.StartTimeout();
 
     StopLeader();
     Get<TimeTicker>().RegisterReceiver(TimeTicker::kMle);
 
-    if (mRouterEligible)
-    {
-        Get<Mac::Mac>().SetBeaconEnabled(true);
-    }
+    Get<Mac::Mac>().SetBeaconEnabled(IsRouterRoleAllowed());
 
     Get<ThreadNetif>().SubscribeAllRoutersMulticast();
 
@@ -380,7 +409,7 @@
 
 exit:
 
-    if (mRouterTable.GetActiveRouterCount() >= mRouterUpgradeThreshold &&
+    if (!mRoleTransitioner.IsRouterCountBelowUpgradeThreshold() &&
         (!IsRouterIdValid(mPreviousRouterId) || !HasChildren()))
     {
         SetRouterId(kInvalidRouterId);
@@ -427,7 +456,7 @@
 
     if (aRole == kRoleLeader)
     {
-        GetLeaderAloc(mLeaderAloc.GetAddress());
+        ComposeLeaderAloc(mLeaderAloc.GetAddress());
         Get<ThreadNetif>().AddUnicastAddress(mLeaderAloc);
         Get<NetworkData::Leader>().Start(aStartMode);
         Get<MeshCoP::ActiveDatasetManager>().StartLeader();
@@ -451,12 +480,14 @@
 
 void Mle::HandleAdvertiseTrickleTimer(void)
 {
-    VerifyOrExit(IsRouterEligible(), mAdvertiseTrickleTimer.Stop());
-
-    SendMulticastAdvertisement();
-
-exit:
-    return;
+    if (!IsRouterRoleAllowed())
+    {
+        mAdvertiseTrickleTimer.Stop();
+    }
+    else
+    {
+        SendMulticastAdvertisement();
+    }
 }
 
 void Mle::StopAdvertiseTrickleTimer(void) { mAdvertiseTrickleTimer.Stop(); }
@@ -478,8 +509,15 @@
     return interval;
 }
 
-void Mle::UpdateAdvertiseInterval(void)
+void Mle::HandleRouterTableEvent(RouterTable::Events aEvents)
 {
+    // Callback from `RouterTable` when there is a change.
+
+    if (aEvents & RouterTable::kEventRouterAdded)
+    {
+        mRoleTransitioner.SetDowngradeBlocked(false);
+    }
+
     if (IsRouterOrLeader() && mAdvertiseTrickleTimer.IsRunning())
     {
         mAdvertiseTrickleTimer.SetIntervalMax(DetermineAdvertiseIntervalMax());
@@ -501,13 +539,20 @@
     return;
 }
 
-void Mle::SendMulticastAdvertisement(void) { SendAdvertisement(Ip6::Address::GetLinkLocalAllNodesMulticast()); }
+void Mle::SendMulticastAdvertisement(void)
+{
+    VerifyOrExit(IsRouterRoleAllowed());
+    SendAdvertisement(Ip6::Address::GetLinkLocalAllNodesMulticast());
+
+exit:
+    return;
+}
 
 void Mle::ScheduleUnicastAdvertisementTo(const Router &aRouter)
 {
     Ip6::Address destination;
 
-    destination.SetToLinkLocalAddress(aRouter.GetExtAddress());
+    destination.InitAsLinkLocalAddress(aRouter.GetExtAddress());
     mDelayedSender.ScheduleAdvertisement(destination, GenerateRandomDelay(kMaxUnicastAdvertisementDelay));
 }
 
@@ -532,8 +577,7 @@
     VerifyOrExit(!mAddressSolicitPending);
 
     VerifyOrExit((message = NewMleMessage(kCommandAdvertisement)) != nullptr, error = kErrorNoBufs);
-    SuccessOrExit(error = message->AppendSourceAddressTlv());
-    SuccessOrExit(error = message->AppendLeaderDataTlv());
+    SuccessOrExit(error = message->AppendSourceAddressAndLeaderDataTlvs());
 
     switch (mRole)
     {
@@ -581,8 +625,7 @@
         break;
 
     case kRoleChild:
-        SuccessOrExit(error = message->AppendSourceAddressTlv());
-        SuccessOrExit(error = message->AppendLeaderDataTlv());
+        SuccessOrExit(error = message->AppendSourceAddressAndLeaderDataTlvs());
         break;
 
     case kRoleRouter:
@@ -596,8 +639,7 @@
             SuccessOrExit(error = message->AppendTlvRequestTlv(kValidNeighborTlvs));
         }
 
-        SuccessOrExit(error = message->AppendSourceAddressTlv());
-        SuccessOrExit(error = message->AppendLeaderDataTlv());
+        SuccessOrExit(error = message->AppendSourceAddressAndLeaderDataTlvs());
         break;
 
     case kRoleDisabled:
@@ -629,7 +671,7 @@
             SuccessOrExit(error = message->AppendChallengeTlv(challenge));
         }
 
-        destination.SetToLinkLocalAddress(aRouter->GetExtAddress());
+        destination.InitAsLinkLocalAddress(aRouter->GetExtAddress());
         aRouter->RestartLinkAcceptTimeout();
     }
 
@@ -647,7 +689,6 @@
     Neighbor      *neighbor = nullptr;
     uint16_t       version;
     LeaderData     leaderData;
-    uint16_t       sourceAddress;
     LinkAcceptInfo info;
 
     Log(kMessageReceive, kTypeLinkRequest, aRxInfo.mMessageInfo.GetPeerAddr());
@@ -675,12 +716,12 @@
 
     info.mLinkMargin = Get<Mac::Mac>().ComputeLinkMargin(aRxInfo.mMessage.GetAverageRss());
 
-    switch (Tlv::Find<SourceAddressTlv>(aRxInfo.mMessage, sourceAddress))
+    switch (Tlv::Find<SourceAddressTlv>(aRxInfo.mMessage, info.mRloc16))
     {
     case kErrorNone:
-        if (IsRouterRloc16(sourceAddress))
+        if (IsRouterRloc16(info.mRloc16))
         {
-            Router *router = mRouterTable.FindRouterByRloc16(sourceAddress);
+            Router *router = mRouterTable.FindRouterByRloc16(info.mRloc16);
 
             VerifyOrExit(router != nullptr, error = kErrorParse);
 
@@ -703,8 +744,9 @@
     case kErrorNotFound:
         // A missing source address indicates that the router was
         // recently reset.
-        VerifyOrExit(aRxInfo.IsNeighborStateValid() && IsRouterRloc16(aRxInfo.mNeighbor->GetRloc16()),
-                     error = kErrorDrop);
+        VerifyOrExit(aRxInfo.IsNeighborStateValid(), error = kErrorDrop);
+        info.mRloc16 = aRxInfo.mNeighbor->GetRloc16();
+        VerifyOrExit(IsRouterRloc16(info.mRloc16), error = kErrorDrop);
         neighbor = aRxInfo.mNeighbor;
         break;
 
@@ -797,7 +839,7 @@
         switch (tlvType)
         {
         case Tlv::kRoute:
-            SuccessOrExit(error = message->AppendRouteTlv(router));
+            SuccessOrExit(error = message->AppendCompactRouteTlv(aInfo.mRloc16));
             break;
 
         case Tlv::kAddress16:
@@ -828,7 +870,7 @@
     }
 #endif
 
-    destination.SetToLinkLocalAddress(aInfo.mExtAddress);
+    destination.InitAsLinkLocalAddress(aInfo.mExtAddress);
 
     SuccessOrExit(error = message->SendTo(destination));
 
@@ -857,10 +899,11 @@
     uint32_t        mleFrameCounter;
     uint8_t         routerId;
     uint16_t        address16;
-    RouteTlv        routeTlv;
+    RouteTlv::Data  routeTlvData;
     LeaderData      leaderData;
     uint8_t         linkMargin;
     bool            shouldUpdateRoutes = false;
+    Mac::ExtAddress extAddress;
 
     SuccessOrExit(error = Tlv::Find<SourceAddressTlv>(aRxInfo.mMessage, sourceAddress));
 
@@ -886,6 +929,8 @@
         break;
 
     case Neighbor::kStateValid:
+        extAddress.SetFromIid(aRxInfo.mMessageInfo.GetPeerAddr().GetIid());
+        VerifyOrExit(router->GetExtAddress() == extAddress, error = kErrorSecurity);
         break;
 
     default:
@@ -927,8 +972,8 @@
         SetLeaderData(leaderData);
 
         mRouterTable.Clear();
-        SuccessOrExit(error = aRxInfo.mMessage.ReadRouteTlv(routeTlv));
-        SuccessOrExit(error = ProcessRouteTlv(routeTlv, aRxInfo));
+        SuccessOrExit(error = aRxInfo.mMessage.ReadRouteTlv(routeTlvData));
+        SuccessOrExit(error = ProcessRouteTlv(routeTlvData, aRxInfo));
         router = mRouterTable.FindRouterById(routerId);
         VerifyOrExit(router != nullptr);
 
@@ -968,14 +1013,14 @@
             IgnoreError(SendDataRequest(aRxInfo.mMessageInfo.GetPeerAddr()));
         }
 
-        switch (aRxInfo.mMessage.ReadRouteTlv(routeTlv))
+        switch (aRxInfo.mMessage.ReadRouteTlv(routeTlvData))
         {
         case kErrorNone:
-            VerifyOrExit(routeTlv.IsRouterIdSet(routerId), error = kErrorParse);
+            VerifyOrExit(routeTlvData.IsAllocated(routerId), error = kErrorParse);
 
-            if (mRouterTable.IsRouteTlvIdSequenceMoreRecent(routeTlv))
+            if (mRouterTable.IsRouteTlvIdSequenceMoreRecent(routeTlvData))
             {
-                SuccessOrExit(error = ProcessRouteTlv(routeTlv, aRxInfo));
+                SuccessOrExit(error = ProcessRouteTlv(routeTlvData, aRxInfo));
                 router = mRouterTable.FindRouterById(routerId);
                 OT_ASSERT(router != nullptr);
             }
@@ -1000,26 +1045,38 @@
         OT_ASSERT(false);
     }
 
-    InitNeighbor(*router, aRxInfo);
-    router->SetRloc16(sourceAddress);
-    router->GetLinkFrameCounters().SetAll(linkFrameCounter);
-    router->SetLinkAckFrameCounter(linkFrameCounter);
-    router->SetMleFrameCounter(mleFrameCounter);
-    router->SetVersion(version);
-    router->SetDeviceMode(DeviceMode(DeviceMode::kModeFullThreadDevice | DeviceMode::kModeRxOnWhenIdle |
-                                     DeviceMode::kModeFullNetworkData));
+    if (neighborState != Neighbor::kStateValid)
+    {
+        InitNeighbor(*router, aRxInfo);
+        router->SetRloc16(sourceAddress);
+        router->GetLinkFrameCounters().SetAll(linkFrameCounter);
+        router->SetLinkAckFrameCounter(linkFrameCounter);
+        router->SetMleFrameCounter(mleFrameCounter);
+        router->SetDeviceMode(DeviceMode(DeviceMode::kModeFullThreadDevice | DeviceMode::kModeRxOnWhenIdle |
+                                         DeviceMode::kModeFullNetworkData));
+        router->SetKeySequence(aRxInfo.mKeySequence);
+        router->SetState(Neighbor::kStateValid);
+    }
+    else
+    {
+        router->GetLinkInfo().AddRss(aRxInfo.mMessage.GetAverageRss());
+        router->SetLastHeard(TimerMilli::GetNow());
+    }
+
     router->SetLinkQualityOut(LinkQualityForLinkMargin(linkMargin));
-    router->SetState(Neighbor::kStateValid);
-    router->SetKeySequence(aRxInfo.mKeySequence);
+    router->SetVersion(version);
     router->ClearLinkAcceptTimeout();
 
-    mNeighborTable.Signal(NeighborTable::kRouterAdded, *router);
+    if (neighborState != Neighbor::kStateValid)
+    {
+        mNeighborTable.Signal(NeighborTable::kRouterAdded, *router);
+    }
 
     mDelayedSender.RemoveScheduledLinkRequest(*router);
 
     if (shouldUpdateRoutes)
     {
-        mRouterTable.UpdateRoutes(routeTlv, routerId);
+        mRouterTable.UpdateRoutes(routeTlvData, routerId);
     }
 
     aRxInfo.mClass = RxInfo::kAuthoritativeMessage;
@@ -1041,6 +1098,7 @@
         }
 
         info.mExtAddress = router->GetExtAddress();
+        info.mRloc16     = router->GetRloc16();
         info.mLinkMargin = Get<Mac::Mac>().ComputeLinkMargin(aRxInfo.mMessage.GetAverageRss());
 
         SuccessOrExit(error = SendLinkAccept(info));
@@ -1050,9 +1108,9 @@
     LogProcessError(aMessageType, error);
 }
 
-Error Mle::ProcessRouteTlv(const RouteTlv &aRouteTlv, RxInfo &aRxInfo)
+Error Mle::ProcessRouteTlv(const RouteTlv::Data &aRouteTlvData, RxInfo &aRxInfo)
 {
-    // This method processes `aRouteTlv` read from an MLE message.
+    // This method processes `aRouteTlvData` read from an MLE message.
     //
     // During processing of Route TLV, the entries in the router table
     // may shuffle. This method ensures that the `aRxInfo.mNeighbor`
@@ -1069,7 +1127,7 @@
         neighborRloc16 = aRxInfo.mNeighbor->GetRloc16();
     }
 
-    mRouterTable.UpdateRouterIdSet(aRouteTlv.GetRouterIdSequence(), aRouteTlv.GetRouterIdMask());
+    mRouterTable.UpdateRouterIdMask(aRouteTlvData);
 
     if (IsAttached() && !mRouterTable.IsAllocated(RouterIdFromRloc16(GetRloc16())))
     {
@@ -1098,16 +1156,16 @@
     // It MUST be used only when device is acting as a child and
     // for a message received from device's current parent.
 
-    Error    error = kErrorNone;
-    RouteTlv routeTlv;
+    Error          error = kErrorNone;
+    RouteTlv::Data routeTlvData;
 
     VerifyOrExit(IsFullThreadDevice());
 
-    switch (aRxInfo.mMessage.ReadRouteTlv(routeTlv))
+    switch (aRxInfo.mMessage.ReadRouteTlv(routeTlvData))
     {
     case kErrorNone:
-        SuccessOrExit(error = ProcessRouteTlv(routeTlv, aRxInfo));
-        mRouterTable.UpdateRouterOnFtdChild(routeTlv, aParentId);
+        SuccessOrExit(error = ProcessRouteTlv(routeTlvData, aRxInfo));
+        mRouterTable.UpdateRouterOnFtdChild(routeTlvData, aParentId);
         mRequestRouteTlv = false;
         break;
     case kErrorNotFound:
@@ -1124,7 +1182,7 @@
 {
     bool isSingleton = true;
 
-    VerifyOrExit(IsAttached() && IsRouterEligible());
+    VerifyOrExit(IsAttached() && IsRouterRoleAllowed());
     isSingleton = (mRouterTable.GetActiveRouterCount() <= 1);
 
 exit:
@@ -1161,19 +1219,20 @@
     // - `aSourceAddress` is the read value from `SourceAddressTlv`.
     // - `aLeaderData` is the read value from `LeaderDataTlv`.
 
-    Error    error      = kErrorNone;
-    uint8_t  linkMargin = Get<Mac::Mac>().ComputeLinkMargin(aRxInfo.mMessage.GetAverageRss());
-    RouteTlv routeTlv;
-    Router  *router;
-    uint8_t  routerId;
-    uint32_t delay;
+    Error          error      = kErrorNone;
+    uint8_t        linkMargin = Get<Mac::Mac>().ComputeLinkMargin(aRxInfo.mMessage.GetAverageRss());
+    RouteTlv::Data routeTlvData;
+    bool           hasRouteTlv = false;
+    Router        *router;
+    uint8_t        routerId;
+    uint32_t       delay;
 
-    switch (aRxInfo.mMessage.ReadRouteTlv(routeTlv))
+    switch (aRxInfo.mMessage.ReadRouteTlv(routeTlvData))
     {
     case kErrorNone:
+        hasRouteTlv = true;
         break;
     case kErrorNotFound:
-        routeTlv.SetLength(0); // Mark that a Route TLV was not included.
         break;
     default:
         ExitNow(error = kErrorParse);
@@ -1189,11 +1248,11 @@
 
         VerifyOrExit(linkMargin >= kPartitionMergeMinMargin, error = kErrorLinkMarginLow);
 
-        if (routeTlv.IsValid() && (mPreviousPartitionIdTimeout > 0) &&
-            (aLeaderData.GetPartitionId() == mPreviousPartitionId))
+        if (hasRouteTlv && (mPreviousPartitionIdTimeout > 0) && (aLeaderData.GetPartitionId() == mPreviousPartitionId))
         {
-            VerifyOrExit(SerialNumber::IsGreater(routeTlv.GetRouterIdSequence(), mPreviousPartitionRouterIdSequence),
-                         error = kErrorDrop);
+            VerifyOrExit(
+                SerialNumber::IsGreater(routeTlvData.GetRouterIdSequence(), mPreviousPartitionRouterIdSequence),
+                error = kErrorDrop);
         }
 
         if (IsChild() && (aRxInfo.mNeighbor == &mParent))
@@ -1201,7 +1260,8 @@
             ExitNow();
         }
 
-        if (ComparePartitions(routeTlv.IsSingleton(), aLeaderData, IsSingleton(), mLeaderData) > 0
+        if (hasRouteTlv &&
+            ComparePartitions(routeTlvData.IsSingleton(), aLeaderData, IsSingleton(), mLeaderData) > 0
 #if OPENTHREAD_CONFIG_TIME_SYNC_REQUIRED
             // Allow a better partition if it also enables time sync.
             && aRxInfo.mMessage.GetTimeSyncSeq() != OT_TIME_SYNC_INVALID_SEQ
@@ -1231,7 +1291,7 @@
         ExitNow();
     }
 
-    VerifyOrExit(IsRouterRloc16(aSourceAddress) && routeTlv.IsValid());
+    VerifyOrExit(IsRouterRloc16(aSourceAddress) && hasRouteTlv);
     routerId = RouterIdFromRloc16(aSourceAddress);
 
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
@@ -1241,9 +1301,9 @@
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Process `RouteTlv`
 
-    if (aRxInfo.IsNeighborStateValid() && mRouterTable.IsRouteTlvIdSequenceMoreRecent(routeTlv))
+    if (aRxInfo.IsNeighborStateValid() && mRouterTable.IsRouteTlvIdSequenceMoreRecent(routeTlvData))
     {
-        SuccessOrExit(error = ProcessRouteTlv(routeTlv, aRxInfo));
+        SuccessOrExit(error = ProcessRouteTlv(routeTlvData, aRxInfo));
     }
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -1262,12 +1322,9 @@
                 ExitNow(error = kErrorDetached);
             }
 
-            if (!mRouterRoleTransition.IsPending() && (mRouterTable.GetActiveRouterCount() < mRouterUpgradeThreshold))
-            {
-                mRouterRoleTransition.StartTimeout();
-            }
+            mRouterTable.UpdateRouterOnFtdChild(routeTlvData, routerId);
 
-            mRouterTable.UpdateRouterOnFtdChild(routeTlv, routerId);
+            mRoleTransitioner.DecideWhetherToUpgrade();
         }
         else
         {
@@ -1288,12 +1345,12 @@
     }
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Update routers as a router or leader.
+    // Inform `RoleTransitioner` to decide whether we need to downgrade
 
-    if (IsRouter() && ShouldDowngrade(routerId, routeTlv))
-    {
-        mRouterRoleTransition.StartTimeout();
-    }
+    mRoleTransitioner.DecideWhetherToDowngrade(routerId, routeTlvData);
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Update routers as a router or leader.
 
     router = mRouterTable.FindRouterById(routerId);
     VerifyOrExit(router != nullptr);
@@ -1319,7 +1376,7 @@
     // unicast/multicast link request in progress
 
     if (!router->IsStateValid() && !router->IsStateLinkRequest() && (linkMargin >= kLinkRequestMinMargin) &&
-        routeTlv.IsRouterIdSet(mRouterId))
+        routeTlvData.IsAllocated(mRouterId))
     {
         InitNeighbor(*router, aRxInfo);
         router->SetState(Neighbor::kStateLinkRequest);
@@ -1331,7 +1388,7 @@
 
     router->SetLastHeard(TimerMilli::GetNow());
 
-    mRouterTable.UpdateRoutes(routeTlv, routerId);
+    mRouterTable.UpdateRoutes(routeTlvData, routerId);
 
 exit:
     if (aRxInfo.mNeighbor && aRxInfo.mNeighbor->GetRloc16() != aSourceAddress)
@@ -1390,7 +1447,8 @@
         VerifyOrExit(neighborCount < mChildRouterLinks + GradualChildRouterLink::kExtraChildRouterLinks);
         VerifyOrExit(LinkQualityForLinkMargin(aLinkMargin) >= kLinkQuality2);
         VerifyOrExit(GetCurrentAttachDuration() > GradualChildRouterLink::kWaitDurationAfterAttach);
-        VerifyOrExit(Random::NonCrypto::GetUint8InRange(0, 100) < GradualChildRouterLink::kProbabilityPercentage);
+        VerifyOrExit(Random::NonCrypto::GenerateUpToExcluding<uint8_t>(100) <
+                     GradualChildRouterLink::kProbabilityPercentage);
 
         minDelay = GradualChildRouterLink::kMinLinkRequestDelay;
         maxDelay = GradualChildRouterLink::kMaxLinkRequestDelay;
@@ -1399,7 +1457,7 @@
     InitNeighbor(aRouter, aRxInfo);
     aRouter.SetState(Neighbor::kStateLinkRequest);
     aRouter.ClearLinkAcceptTimeout();
-    mDelayedSender.ScheduleLinkRequest(aRouter, Random::NonCrypto::GetUint32InRange(minDelay, maxDelay));
+    mDelayedSender.ScheduleLinkRequest(aRouter, Random::NonCrypto::GenerateInClosedRange(minDelay, maxDelay));
 
 exit:
     return;
@@ -1417,7 +1475,7 @@
 
     Log(kMessageReceive, kTypeParentRequest, aRxInfo.mMessageInfo.GetPeerAddr());
 
-    VerifyOrExit(IsRouterEligible());
+    VerifyOrExit(IsRouterRoleAllowed());
     VerifyOrExit(!IsDetached() && !IsAttaching());
 
     VerifyOrExit(!mDetacher.IsDetaching());
@@ -1526,8 +1584,6 @@
 
 void Mle::HandleTimeTick(void)
 {
-    bool roleTransitionTimeoutExpired = false;
-
     VerifyOrExit(IsFullThreadDevice(), Get<TimeTicker>().UnregisterReceiver(TimeTicker::kMle));
 
     if (mPreviousPartitionIdTimeout > 0)
@@ -1548,33 +1604,22 @@
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Role transitions
 
-    roleTransitionTimeoutExpired = mRouterRoleTransition.HandleTimeTick();
+    mRoleTransitioner.HandleTimeTick();
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Check Leader's age
 
     switch (mRole)
     {
+    case kRoleDisabled:
     case kRoleDetached:
+    case kRoleLeader:
         break;
 
     case kRoleChild:
-        if (roleTransitionTimeoutExpired)
+        if (!IsRouterRoleAllowed())
         {
-            if (mRouterTable.GetActiveRouterCount() < mRouterUpgradeThreshold && HasNeighborWithGoodLinkQuality())
-            {
-                IgnoreError(BecomeRouter(kReasonTooFewRouters));
-            }
-            else
-            {
-                mAnnounceHandler.HandleRouterRoleTransitionAttemptDone();
-            }
-
-            if (!mAdvertiseTrickleTimer.IsRunning())
-            {
-                SendMulticastAdvertisement();
-
-                mAdvertiseTrickleTimer.Start(TrickleTimer::kModePlainTimer, kReedAdvIntervalMin, kReedAdvIntervalMax);
-            }
-
-            ExitNow();
+            break;
         }
 
         OT_FALL_THROUGH;
@@ -1588,25 +1633,7 @@
             mAttacher.Attach(kSamePartition);
         }
 
-        if (roleTransitionTimeoutExpired && mRouterTable.GetActiveRouterCount() > mRouterDowngradeThreshold)
-        {
-            LogNote("Downgrade to REED");
-            mAttacher.Attach(kDowngradeToReed);
-        }
-
-        OT_FALL_THROUGH;
-
-    case kRoleLeader:
-        if (roleTransitionTimeoutExpired && !IsRouterEligible())
-        {
-            LogInfo("No longer router eligible");
-            IgnoreError(BecomeDetached());
-        }
-
         break;
-
-    case kRoleDisabled:
-        OT_ASSERT(false);
     }
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -1740,7 +1767,7 @@
             continue;
         }
 
-        if (IsLeader() && (mRouterTable.FindNextHopOf(router) == nullptr) &&
+        if (IsLeader() && (mRouterTable.FindNextHopTowards(router) == nullptr) &&
             (mRouterTable.GetLinkCost(router) >= kMaxRouteCost) && (age >= kMaxLeaderToRouterTimeout))
         {
             LogInfo("Router 0x%04x ID timeout expired (no route)", router.GetRloc16());
@@ -1776,8 +1803,7 @@
     VerifyOrExit((message = NewMleMessage(kCommandParentResponse)) != nullptr, error = kErrorNoBufs);
     message->SetDirectTransmission();
 
-    SuccessOrExit(error = message->AppendSourceAddressTlv());
-    SuccessOrExit(error = message->AppendLeaderDataTlv());
+    SuccessOrExit(error = message->AppendSourceAddressAndLeaderDataTlvs());
     SuccessOrExit(error = message->AppendLinkAndMleFrameCounterTlvs());
     SuccessOrExit(error = message->AppendResponseTlv(aInfo.mRxChallenge));
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
@@ -1798,7 +1824,7 @@
     SuccessOrExit(error = message->AppendConnectivityTlv());
     SuccessOrExit(error = message->AppendVersionTlv());
 
-    destination.SetToLinkLocalAddress(aInfo.mChildExtAddress);
+    destination.InitAsLinkLocalAddress(aInfo.mChildExtAddress);
 
     SuccessOrExit(error = message->SendTo(destination));
 
@@ -1809,76 +1835,22 @@
     LogSendError(kTypeParentResponse, error);
 }
 
-uint8_t Mle::GetMaxChildIpAddresses(void) const
-{
-    uint8_t num = kMaxChildIpAddresses;
-
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    if (mMaxChildIpAddresses != 0)
-    {
-        num = mMaxChildIpAddresses;
-    }
-#endif
-
-    return num;
-}
-
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-Error Mle::SetMaxChildIpAddresses(uint8_t aMaxIpAddresses)
-{
-    Error error = kErrorNone;
-
-    VerifyOrExit(aMaxIpAddresses <= kMaxChildIpAddresses, error = kErrorInvalidArgs);
-
-    mMaxChildIpAddresses = aMaxIpAddresses;
-
-exit:
-    return error;
-}
-#endif
-
 Error Mle::ProcessAddressRegistrationTlv(RxInfo &aRxInfo, Child &aChild)
 {
     Error       error;
     OffsetRange offsetRange;
     uint8_t     count       = 0;
     uint8_t     storedCount = 0;
-#if OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    Ip6::Address oldDua;
-#endif
 #if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    MlrManager::MlrAddressArray oldMlrRegisteredAddresses;
+    Child::Ip6AddressArray oldMlrRegisteredAddresses;
 #endif
 
     OT_UNUSED_VARIABLE(storedCount);
 
     SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(aRxInfo.mMessage, Tlv::kAddressRegistration, offsetRange));
 
-#if OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    if (aChild.GetDomainUnicastAddress(oldDua) != kErrorNone)
-    {
-        oldDua.Clear();
-    }
-#endif
-
 #if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    if (aChild.HasAnyMlrRegisteredAddress())
-    {
-        OT_ASSERT(aChild.IsStateValid());
-
-        for (const Child::Ip6AddrEntry &addrEntry : aChild.GetIp6Addresses())
-        {
-            if (!addrEntry.IsMulticastLargerThanRealmLocal())
-            {
-                continue;
-            }
-
-            if (addrEntry.GetMlrState(aChild) == kMlrStateRegistered)
-            {
-                IgnoreError(oldMlrRegisteredAddresses.PushBack(addrEntry));
-            }
-        }
-    }
+    aChild.GetAllMlrRegisteredAddresses(oldMlrRegisteredAddresses);
 #endif
 
     aChild.ClearIp6Addresses();
@@ -1889,8 +1861,7 @@
         Ip6::Address address;
 
         // Read out the control byte (first byte in entry)
-        SuccessOrExit(error = aRxInfo.mMessage.Read(offsetRange, controlByte));
-        offsetRange.AdvanceOffset(sizeof(uint8_t));
+        SuccessOrExit(error = aRxInfo.mMessage.ReadAndAdvance(offsetRange, controlByte));
         count++;
 
         address.Clear();
@@ -1904,8 +1875,7 @@
             uint8_t         contextId = AddressRegistrationTlv::GetContextId(controlByte);
             Lowpan::Context context;
 
-            IgnoreError(aRxInfo.mMessage.Read(offsetRange, address.GetIid()));
-            offsetRange.AdvanceOffset(sizeof(Ip6::InterfaceIdentifier));
+            IgnoreError(aRxInfo.mMessage.ReadAndAdvance(offsetRange, address.GetIid()));
 
             Get<NetworkData::Leader>().FindContextForId(contextId, context);
 
@@ -1922,25 +1892,10 @@
         {
             // Uncompressed entry contains the full IPv6 address.
 
-            IgnoreError(aRxInfo.mMessage.Read(offsetRange, address));
-            offsetRange.AdvanceOffset(sizeof(Ip6::Address));
+            IgnoreError(aRxInfo.mMessage.ReadAndAdvance(offsetRange, address));
         }
 
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-        if (mMaxChildIpAddresses > 0 && storedCount >= mMaxChildIpAddresses)
-        {
-            // Skip remaining address registration entries but keep logging
-            // skipped addresses.
-            error = kErrorNoBufs;
-        }
-        else
-#endif
-        {
-            // We try to accept/add as many IPv6 addresses as possible.
-            // "Child ID/Update Response" will indicate the accepted
-            // addresses.
-            error = aChild.AddIp6Address(address);
-        }
+        error = aChild.AddIp6Address(address);
 
         if (error == kErrorNone)
         {
@@ -1982,12 +1937,9 @@
         // Clear EID-to-RLOC cache for the unicast address registered by the child.
         Get<AddressResolver>().RemoveEntryForAddress(address);
     }
-#if OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-    SignalDuaAddressEvent(aChild, oldDua);
-#endif
 
 #if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    Get<MlrManager>().UpdateProxiedSubscriptions(aChild, oldMlrRegisteredAddresses);
+    Get<Mlr::Manager>().UpdateChildRegistrations(aChild, oldMlrRegisteredAddresses);
 #endif
 
     if (count == 0)
@@ -2006,40 +1958,6 @@
     return error;
 }
 
-#if OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-void Mle::SignalDuaAddressEvent(const Child &aChild, const Ip6::Address &aOldDua) const
-{
-    DuaManager::ChildDuaAddressEvent event = DuaManager::kAddressUnchanged;
-    Ip6::Address                     newDua;
-
-    if (aChild.GetDomainUnicastAddress(newDua) == kErrorNone)
-    {
-        if (aOldDua.IsUnspecified())
-        {
-            event = DuaManager::kAddressAdded;
-        }
-        else if (aOldDua != newDua)
-        {
-            event = DuaManager::kAddressChanged;
-        }
-    }
-    else
-    {
-        // Child has no DUA address. If there was no old DUA, no need
-        // to signal.
-
-        VerifyOrExit(!aOldDua.IsUnspecified());
-
-        event = DuaManager::kAddressRemoved;
-    }
-
-    Get<DuaManager>().HandleChildDuaAddressEvent(aChild, event);
-
-exit:
-    return;
-}
-#endif // OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-
 bool Mle::IsMessageMleSubType(const Message &aMessage) { return aMessage.IsSubTypeMle(); }
 
 bool Mle::IsMessageChildUpdateRequest(const Message &aMessage)
@@ -2064,7 +1982,7 @@
 
     Log(kMessageReceive, kTypeChildIdRequest, aRxInfo.mMessageInfo.GetPeerAddr());
 
-    VerifyOrExit(IsRouterEligible(), error = kErrorInvalidState);
+    VerifyOrExit(IsRouterRoleAllowed(), error = kErrorInvalidState);
 
     VerifyOrExit(IsAttached(), error = kErrorInvalidState);
 
@@ -2712,7 +2630,7 @@
 
     Log(kMessageReceive, kTypeDiscoveryRequest, aRxInfo.mMessageInfo.GetPeerAddr());
 
-    VerifyOrExit(IsRouterEligible(), error = kErrorInvalidState);
+    VerifyOrExit(IsRouterRoleAllowed(), error = kErrorInvalidState);
 
     SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(aRxInfo.mMessage, Tlv::kDiscovery, offsetRange));
 
@@ -2862,8 +2780,7 @@
     TxMessage   *message;
 
     VerifyOrExit((message = NewMleMessage(kCommandChildIdResponse)) != nullptr, error = kErrorNoBufs);
-    SuccessOrExit(error = message->AppendSourceAddressTlv());
-    SuccessOrExit(error = message->AppendLeaderDataTlv());
+    SuccessOrExit(error = message->AppendSourceAddressAndLeaderDataTlvs());
     SuccessOrExit(error = message->AppendActiveAndPendingTimestampTlvs());
 
     if ((aChild.GetRloc16() == 0) || !HasMatchingRouterIdWith(aChild.GetRloc16()))
@@ -2919,7 +2836,7 @@
     }
 #endif
 
-    destination.SetToLinkLocalAddress(aChild.GetExtAddress());
+    destination.InitAsLinkLocalAddress(aChild.GetExtAddress());
     SuccessOrExit(error = message->SendTo(destination));
 
     SetChildStateToValid(aChild);
@@ -2951,8 +2868,7 @@
     Get<MeshForwarder>().RemoveMessagesForChild(aChild, IsMessageChildUpdateRequest);
 
     VerifyOrExit((message = NewMleMessage(kCommandChildUpdateRequest)) != nullptr, error = kErrorNoBufs);
-    SuccessOrExit(error = message->AppendSourceAddressTlv());
-    SuccessOrExit(error = message->AppendLeaderDataTlv());
+    SuccessOrExit(error = message->AppendSourceAddressAndLeaderDataTlvs());
     SuccessOrExit(error = message->AppendNetworkDataTlv(aChild.GetNetworkDataType()));
     SuccessOrExit(error = message->AppendActiveAndPendingTimestampTlvs());
 
@@ -2984,7 +2900,7 @@
         SuccessOrExit(error = message->AppendChallengeTlv(aChild.GetChallenge()));
     }
 
-    destination.SetToLinkLocalAddress(aChild.GetExtAddress());
+    destination.InitAsLinkLocalAddress(aChild.GetExtAddress());
     SuccessOrExit(error = message->SendTo(destination));
 
     if (aChild.IsRxOnWhenIdle())
@@ -3124,8 +3040,7 @@
     }
 
     VerifyOrExit((message = NewMleMessage(kCommandDataResponse)) != nullptr, error = kErrorNoBufs);
-    SuccessOrExit(error = message->AppendSourceAddressTlv());
-    SuccessOrExit(error = message->AppendLeaderDataTlv());
+    SuccessOrExit(error = message->AppendSourceAddressAndLeaderDataTlvs());
     SuccessOrExit(error = message->AppendActiveAndPendingTimestampTlvs());
 
     for (uint8_t tlvType : aTlvList)
@@ -3241,6 +3156,7 @@
     return;
 }
 
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
 Error Mle::SetPreferredRouterId(uint8_t aRouterId)
 {
     Error error = kErrorNone;
@@ -3252,6 +3168,7 @@
 exit:
     return error;
 }
+#endif
 
 void Mle::SetRouterId(uint8_t aRouterId)
 {
@@ -3283,7 +3200,7 @@
     SuccessOrExit(error = Tlv::Append<XtalAccuracyTlv>(*message, otPlatTimeGetXtalAccuracy()));
 #endif
 
-    GetLeaderRloc(leaderRloc);
+    ComposeLeaderRloc(leaderRloc);
 
     SuccessOrExit(error = Get<Tmf::Agent>().SendMessageTo(*message, leaderRloc, HandleAddressSolicitResponse, this));
     mAddressSolicitPending = true;
@@ -3307,7 +3224,7 @@
     SuccessOrExit(error = Tlv::Append<ThreadRloc16Tlv>(*message, Rloc16FromRouterId(mRouterId)));
     SuccessOrExit(error = Tlv::Append<ThreadExtMacAddressTlv>(*message, Get<Mac::Mac>().GetExtAddress()));
 
-    GetLeaderRloc(leaderRloc);
+    ComposeLeaderRloc(leaderRloc);
 
     SuccessOrExit(error = Get<Tmf::Agent>().SendMessageTo(*message, leaderRloc));
 
@@ -3320,11 +3237,11 @@
 
 void Mle::HandleAddressSolicitResponse(Coap::Msg *aMsg, Error aResult)
 {
-    uint8_t             status;
-    uint16_t            rloc16;
-    ThreadRouterMaskTlv routerMaskTlv;
-    uint8_t             routerId;
-    Router             *router;
+    uint8_t      status;
+    uint16_t     rloc16;
+    RouterIdMask routerIdMask;
+    uint8_t      routerId;
+    Router      *router;
 
     mAddressSolicitPending = false;
 
@@ -3356,8 +3273,9 @@
     SuccessOrExit(Tlv::Find<ThreadRloc16Tlv>(aMsg->mMessage, rloc16));
     routerId = RouterIdFromRloc16(rloc16);
 
-    SuccessOrExit(Tlv::FindTlv(aMsg->mMessage, routerMaskTlv));
-    VerifyOrExit(routerMaskTlv.IsValid());
+    SuccessOrExit(Tlv::Find<ThreadRouterMaskTlv>(aMsg->mMessage, routerIdMask));
+    VerifyOrExit(routerIdMask.IsValid());
+    VerifyOrExit(routerIdMask.IsAllocated(GetLeaderId()));
 
     SetAlternateRloc16(GetRloc16());
 
@@ -3374,7 +3292,7 @@
 
     mRouterTable.ClearNeighbors();
 
-    mRouterTable.UpdateRouterIdSet(routerMaskTlv.GetIdSequence(), routerMaskTlv.GetAssignedRouterIdMask());
+    mRouterTable.UpdateRouterIdMask(routerIdMask);
 
     router = mRouterTable.FindRouterById(routerId);
     VerifyOrExit(router != nullptr);
@@ -3420,6 +3338,20 @@
     for (Child &child : Get<ChildTable>().Iterate(Child::kInStateChildIdRequest))
     {
         IgnoreError(SendChildIdResponse(child));
+
+        // The transition to the router role was triggered by a Child
+        // ID Request. This indicates that the child has no other
+        // parent option. We set the flags to prevent the parent
+        // router from downgrading back to a REED to ensure this
+        // child remains connected.
+        //
+        // The `DowngradeBlocked` is cleared in various situations:
+        // - From `SetStateDetached()` (e.g. partition change).
+        // - If a new router is added (new possible parent).
+        // - If all children blocking downgrade are disconnected.
+
+        child.SetBlockParentDowngrade(true);
+        mRoleTransitioner.SetDowngradeBlocked(true);
     }
 
 exit:
@@ -3436,18 +3368,18 @@
     return error;
 }
 
-bool Mle::WillBecomeRouterSoon(void) const
+bool Mle::RoleTransitioner::WillBecomeRouterSoon(void) const
 {
     static constexpr uint8_t kMaxDelay = 10;
 
     bool willBecomeRouter = false;
 
-    VerifyOrExit(IsRouterEligible() && IsChild());
-    VerifyOrExit(!mAddressSolicitRejected);
+    VerifyOrExit(IsRouterRoleAllowed() && Get<Mle>().IsChild());
+    VerifyOrExit(!Get<Mle>().mAddressSolicitRejected);
 
-    if (!mAddressSolicitPending)
+    if (!Get<Mle>().mAddressSolicitPending)
     {
-        VerifyOrExit(mRouterRoleTransition.IsPending() && mRouterRoleTransition.GetTimeout() <= kMaxDelay);
+        VerifyOrExit(IsTransitionPending() && GetTimeout() <= kMaxDelay);
     }
 
     willBecomeRouter = true;
@@ -3525,7 +3457,7 @@
     switch (aInfo.mReason)
     {
     case kReasonTooFewRouters:
-        VerifyOrExit(mRouterTable.GetActiveRouterCount() < mRouterUpgradeThreshold);
+        VerifyOrExit(Get<RouterTable>().GetActiveRouterCount() < mLeaderUpgradeThreshold);
         break;
 
     case kReasonHaveChildIdRequest:
@@ -3533,7 +3465,7 @@
         break;
 
     case kReasonBorderRouterRequest:
-        if ((mRouterTable.GetActiveRouterCount() >= mRouterUpgradeThreshold) &&
+        if ((Get<RouterTable>().GetActiveRouterCount() >= mLeaderUpgradeThreshold) &&
             (Get<NetworkData::Leader>().CountBorderRouters(NetworkData::kRouterRoleOnly) >=
              kRouterUpgradeBorderRouterRequestThreshold))
         {
@@ -3586,15 +3518,12 @@
 
     if (info.mRouter != nullptr)
     {
-        ThreadRouterMaskTlv routerMaskTlv;
+        RouterIdMask routerIdMask;
 
         SuccessOrExit(Tlv::Append<ThreadRloc16Tlv>(*response, info.mRouter->GetRloc16()));
 
-        routerMaskTlv.Init();
-        routerMaskTlv.SetIdSequence(mRouterTable.GetRouterIdSequence());
-        mRouterTable.GetRouterIdSet(routerMaskTlv.GetAssignedRouterIdMask());
-
-        SuccessOrExit(routerMaskTlv.AppendTo(*response));
+        mRouterTable.GetRouterIdMask(routerIdMask);
+        SuccessOrExit(Tlv::Append<ThreadRouterMaskTlv>(*response, routerIdMask));
     }
 
     SuccessOrExit(Get<Tmf::Agent>().SendMessage(*response, aMsg.mMessageInfo));
@@ -3712,29 +3641,29 @@
     aTlvValue.InitFrom(connectivity);
 }
 
-bool Mle::ShouldDowngrade(uint8_t aNeighborId, const RouteTlv &aRouteTlv) const
+void Mle::RoleTransitioner::DecideWhetherToDowngrade(uint8_t aNeighborId, const RouteTlv::Data &aRouteTlvData)
 {
     // Determine whether all conditions are satisfied for the router
     // to downgrade after receiving info for a neighboring router
-    // with Router ID `aNeighborId` along with its `aRouteTlv`.
+    // with Router ID `aNeighborId` along with its `aRouteTlvData`.
 
-    bool    shouldDowngrade   = false;
-    uint8_t activeRouterCount = mRouterTable.GetActiveRouterCount();
+    uint8_t activeRouterCount = Get<RouterTable>().GetActiveRouterCount();
     uint8_t count;
 
-    VerifyOrExit(IsRouter());
-    VerifyOrExit(mRouterTable.IsAllocated(aNeighborId));
+    VerifyOrExit(Get<Mle>().IsRouter());
+    VerifyOrExit(Get<RouterTable>().IsAllocated(aNeighborId));
+    VerifyOrExit(!mDowngradeBlocked);
 
-    VerifyOrExit(!mRouterRoleTransition.IsPending());
+    VerifyOrExit(!IsTransitionPending());
 
-    VerifyOrExit(activeRouterCount > mRouterDowngradeThreshold);
+    VerifyOrExit(activeRouterCount > mDowngradeThreshold);
 
     // Check that we have at least `kMinDowngradeNeighbors`
     // neighboring routers with two-way link quality of 2 or better.
 
     count = 0;
 
-    for (const Router &router : mRouterTable)
+    for (const Router &router : Get<RouterTable>())
     {
         if (!router.IsStateValid() || (router.GetTwoWayLinkQuality() < kLinkQuality2))
         {
@@ -3753,49 +3682,51 @@
 
     // Check that we have fewer children than three times the number
     // of excess routers (defined as the difference between number of
-    // active routers and `mRouterDowngradeThreshold`).
+    // active routers and `mDowngradeThreshold`).
 
-    count = activeRouterCount - mRouterDowngradeThreshold;
-    VerifyOrExit(mChildTable.GetNumChildren(Child::kInStateValid) < count * 3);
+    count = activeRouterCount - mDowngradeThreshold;
+    VerifyOrExit(Get<ChildTable>().GetNumChildren(Child::kInStateValid) < count * 3);
 
     // Check that the neighbor has as good or better-quality links to
     // same routers.
 
-    VerifyOrExit(NeighborHasComparableConnectivity(aRouteTlv, aNeighborId));
+    VerifyOrExit(NeighborHasComparableConnectivity(aNeighborId, aRouteTlvData));
 
 #if OPENTHREAD_CONFIG_BORDER_ROUTER_ENABLE && OPENTHREAD_CONFIG_BORDER_ROUTER_REQUEST_ROUTER_ROLE
     // Check if we are eligible to be router due to being a BR.
     VerifyOrExit(!Get<NetworkData::Notifier>().IsEligibleForRouterRoleUpgradeAsBorderRouter());
 #endif
 
-    shouldDowngrade = true;
+    // All conditions are satisfied to start downgrade.
+    StartTimeout();
 
 exit:
-    return shouldDowngrade;
+    return;
 }
 
-bool Mle::NeighborHasComparableConnectivity(const RouteTlv &aRouteTlv, uint8_t aNeighborId) const
+bool Mle::RoleTransitioner::NeighborHasComparableConnectivity(uint8_t               aNeighborId,
+                                                              const RouteTlv::Data &aRouteTlvData) const
 {
     // Check whether the neighboring router with Router ID `aNeighborId`
-    // (along with its `aRouteTlv`) has as good or better-quality links
-    // to all our neighboring routers which have a two-way link quality
-    // of two or better.
+    // (along with its `aRouteTlvData`) has as good or better-quality
+    // links to all our neighboring routers which have a two-way link
+    // quality of two or better.
 
     bool isComparable = true;
 
-    for (uint8_t routerId = 0, index = 0; routerId <= kMaxRouterId;
-         index += aRouteTlv.IsRouterIdSet(routerId) ? 1 : 0, routerId++)
+    for (uint8_t routerId = 0; routerId <= kMaxRouterId; routerId++)
     {
-        const Router *router;
-        LinkQuality   localLinkQuality;
-        LinkQuality   peerLinkQuality;
+        const Router                *router;
+        LinkQuality                  localLinkQuality;
+        LinkQuality                  peerLinkQuality;
+        const RouteTlv::Data::Entry *entry;
 
-        if ((routerId == mRouterId) || (routerId == aNeighborId))
+        if ((routerId == Get<Mle>().mRouterId) || (routerId == aNeighborId))
         {
             continue;
         }
 
-        router = mRouterTable.FindRouterById(routerId);
+        router = Get<RouterTable>().FindRouterById(routerId);
 
         if ((router == nullptr) || !router->IsStateValid())
         {
@@ -3810,15 +3741,17 @@
         }
 
         // `router` is our neighbor with two-way link quality of
-        // at least two. Check that `aRouteTlv` has as good or
+        // at least two. Check that `aRouteTlvData` has as good or
         // better-quality link to it as well.
 
-        if (!aRouteTlv.IsRouterIdSet(routerId))
+        entry = aRouteTlvData.GetEntries().FindMatching(routerId);
+
+        if (entry == nullptr)
         {
             ExitNow(isComparable = false);
         }
 
-        peerLinkQuality = Min(aRouteTlv.GetLinkQualityIn(index), aRouteTlv.GetLinkQualityOut(index));
+        peerLinkQuality = Min(entry->GetLinkQualityIn(), entry->GetLinkQualityOut());
 
         if (peerLinkQuality < localLinkQuality)
         {
@@ -3838,7 +3771,7 @@
     IgnoreError(mChildTable.StoreChild(aChild));
 
 #if OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    Get<MlrManager>().UpdateProxiedSubscriptions(aChild, MlrManager::MlrAddressArray());
+    Get<Mlr::Manager>().UpdateChildRegistrations(aChild);
 #endif
 
     mNeighborTable.Signal(NeighborTable::kChildAdded, aChild);
@@ -3947,26 +3880,102 @@
 #endif // OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
 
 //----------------------------------------------------------------------------------------------------------------------
-// RouterRoleTransition
+// RoleTransitioner
 
-Mle::RouterRoleTransition::RouterRoleTransition(void)
-    : mTimeout(0)
+Mle::RoleTransitioner::RoleTransitioner(Instance &aInstance)
+    : InstanceLocator(aInstance)
+    , mRouterEligible(true)
+    , mRouterRoleAllowed(true)
+    , mDowngradeBlocked(false)
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+    , mCcmEnabled(false)
+    , mThreadVersionCheckEnabled(true)
+#endif
+    , mTimeout(0)
     , mJitter(kRouterSelectionJitter)
+    , mUpgradeThreshold(kRouterUpgradeThreshold)
+    , mDowngradeThreshold(kRouterDowngradeThreshold)
+
 {
 }
 
-void Mle::RouterRoleTransition::StartTimeout(void) { mTimeout = 1 + Random::NonCrypto::GetUint8InRange(0, mJitter); }
+void Mle::RoleTransitioner::StartTimeout(void) { mTimeout = 1 + Random::NonCrypto::GenerateUpToExcluding(mJitter); }
 
-bool Mle::RouterRoleTransition::HandleTimeTick(void)
+bool Mle::RoleTransitioner::IsRouterCountBelowUpgradeThreshold(void) const
 {
-    bool expired = false;
+    return Get<RouterTable>().GetActiveRouterCount() < mUpgradeThreshold;
+}
 
-    VerifyOrExit(mTimeout > 0);
-    mTimeout--;
-    expired = (mTimeout == 0);
+void Mle::RoleTransitioner::DecideWhetherToUpgrade(void)
+{
+    VerifyOrExit(Get<Mle>().IsChild());
+
+    VerifyOrExit(IsRouterRoleAllowed());
+
+    VerifyOrExit(!IsTransitionPending());
+    VerifyOrExit(IsRouterCountBelowUpgradeThreshold());
+
+    StartTimeout();
 
 exit:
-    return expired;
+    return;
+}
+
+void Mle::RoleTransitioner::HandleTimeTick(void)
+{
+    VerifyOrExit(mTimeout > 0);
+    mTimeout--;
+
+    VerifyOrExit(mTimeout == 0);
+
+    // Timeout expired
+
+    switch (Get<Mle>().mRole)
+    {
+    case kRoleDisabled:
+    case kRoleDetached:
+        break;
+
+    case kRoleChild:
+        if (IsRouterCountBelowUpgradeThreshold() && Get<Mle>().HasNeighborWithGoodLinkQuality())
+        {
+            IgnoreError(Get<Mle>().BecomeRouter(kReasonTooFewRouters));
+        }
+        else
+        {
+            Get<Mle>().mAnnounceHandler.HandleRouterRoleTransitionAttemptDone();
+        }
+
+        if (!Get<Mle>().mAdvertiseTrickleTimer.IsRunning())
+        {
+            Get<Mle>().SendMulticastAdvertisement();
+            Get<Mle>().mAdvertiseTrickleTimer.Start(TrickleTimer::kModePlainTimer, kReedAdvIntervalMin,
+                                                    kReedAdvIntervalMax);
+        }
+
+        break;
+
+    case kRoleRouter:
+        if (Get<RouterTable>().GetActiveRouterCount() > mDowngradeThreshold)
+        {
+            LogNote("Downgrade to REED");
+            Get<Mle>().mAttacher.Attach(kDowngradeToReed);
+        }
+
+        OT_FALL_THROUGH;
+
+    case kRoleLeader:
+        if (!IsRouterRoleAllowed())
+        {
+            LogInfo("Router role no longer allowed");
+            IgnoreError(Get<Mle>().BecomeDetached());
+        }
+
+        break;
+    }
+
+exit:
+    return;
 }
 
 } // namespace Mle
diff --git a/src/core/thread/mle_p2p.cpp b/src/core/thread/mle_p2p.cpp
index 7aa2844..b087363 100644
--- a/src/core/thread/mle_p2p.cpp
+++ b/src/core/thread/mle_p2p.cpp
@@ -174,7 +174,7 @@
 
     VerifyOrExit(aPeer != nullptr, error = kErrorInvalidArgs);
     VerifyOrExit((message = Get<Mle>().NewMleMessage(kCommandP2pLinkRequest)) != nullptr, error = kErrorNoBufs);
-    SuccessOrExit(error = message->AppendModeTlv(Get<Mle>().GetDeviceMode()));
+    SuccessOrExit(error = message->AppendModeTlv());
     SuccessOrExit(error = message->AppendVersionTlv());
 
     aPeer->GenerateChallenge();
@@ -225,7 +225,7 @@
     VerifyOrExit((message = Get<Mle>().NewMleMessage(command)) != nullptr, error = kErrorNoBufs);
     if (command == kCommandP2pLinkAcceptAndRequest)
     {
-        SuccessOrExit(error = message->AppendModeTlv(Get<Mle>().GetDeviceMode()));
+        SuccessOrExit(error = message->AppendModeTlv());
         SuccessOrExit(error = message->AppendVersionTlv());
     }
 
diff --git a/src/core/thread/mle_tlvs.cpp b/src/core/thread/mle_tlvs.cpp
index 61350c3..572027c 100644
--- a/src/core/thread/mle_tlvs.cpp
+++ b/src/core/thread/mle_tlvs.cpp
@@ -44,33 +44,150 @@
 //---------------------------------------------------------------------------------------------------------------------
 // RouteTlv
 
+Error RouteTlv::Data::ParseFrom(const Message &aMessage, const OffsetRange &aOffsetRange)
+{
+    Error        error;
+    RouterIdMask routerIdMask;
+    OffsetRange  offsetRange = aOffsetRange;
+#if OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+    bool isEven = true;
+#endif
+
+    SuccessOrExit(error = aMessage.ReadAndAdvance(offsetRange, routerIdMask));
+
+    mIdSequence = routerIdMask.GetSequence();
+
+    mEntries.Clear();
+
+    for (uint8_t routerId = 0; routerId <= kMaxRouterId; routerId++)
+    {
+        Entry *entry;
+
+        if (!routerIdMask.IsAllocated(routerId))
+        {
+            continue;
+        }
+
+        entry = mEntries.PushBack();
+
+        // If `mEntries` is full, it indicates that there are more than
+        // `kMaxRouters` allocated IDs in the mask, which makes it invalid.
+
+        VerifyOrExit(entry != nullptr, error = kErrorParse);
+
+        entry->mRouterId = routerId;
+
 #if !OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+        SuccessOrExit(error = aMessage.ReadAndAdvance<uint8_t>(offsetRange, entry->mRouteData));
+#else
+        {
+            EntryType value;
 
-void RouteTlv::Init(void)
-{
-    SetType(kRoute);
-    SetLength(sizeof(*this) - sizeof(Tlv));
-    mRouterIdMask.Clear();
-    ClearAllBytes(mRouteData);
-}
+            SuccessOrExit(error = aMessage.Read<EntryType>(offsetRange, value));
+            value = BigEndian::HostSwap16(value);
 
-bool RouteTlv::IsValid(void) const
-{
-    bool    isValid = false;
-    uint8_t numAllocatedIds;
+            if (isEven)
+            {
+                entry->mRouteData = ReadBits<uint16_t, kEvenEntryMask>(value);
+                offsetRange.AdvanceOffset(sizeof(uint8_t));
+            }
+            else
+            {
+                entry->mRouteData = ReadBits<uint16_t, kOddEntryMask>(value);
+                offsetRange.AdvanceOffset(sizeof(uint16_t));
+            }
 
-    VerifyOrExit(GetLength() >= sizeof(mRouterIdSequence) + sizeof(mRouterIdMask));
-
-    numAllocatedIds = mRouterIdMask.GetNumberOfAllocatedIds();
-    VerifyOrExit(numAllocatedIds <= kMaxRouters);
-
-    isValid = (GetRouteDataLength() >= numAllocatedIds);
+            isEven = !isEven;
+        }
+#endif
+    }
 
 exit:
-    return isValid;
+    return error;
 }
 
-#endif // #if !OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+bool RouteTlv::Data::IsAllocated(uint8_t aRouterId) const { return mEntries.FindMatching(aRouterId); }
+
+void RouteTlv::Data::DetermineRouterIdMask(RouterIdMask &aRouterIdMask) const
+{
+    aRouterIdMask.Clear();
+
+    aRouterIdMask.SetSequence(mIdSequence);
+
+    for (const Entry &entry : mEntries)
+    {
+        aRouterIdMask.Add(entry.mRouterId);
+    }
+}
+
+Error RouteTlv::AppendRouteDataEntry(Message    &aMessage,
+                                     LinkQuality aLqIn,
+                                     LinkQuality aLqOut,
+                                     uint8_t     aRouteCost
+#if OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+                                     ,
+                                     bool aIsEven
+#endif
+)
+{
+    Error     error;
+    EntryType entry = 0;
+
+    if (aRouteCost >= kMaxRouteCost)
+    {
+        aRouteCost = 0;
+    }
+
+    WriteBits<EntryType, kLinkQualityOutMask>(entry, aLqOut);
+    WriteBits<EntryType, kLinkQualityInMask>(entry, aLqIn);
+    WriteBits<EntryType, kRouteCostMask>(entry, aRouteCost);
+
+#if !OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+    ExitNow(error = aMessage.Append<uint8_t>(entry));
+#else
+    {
+        // Under `OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE`, each route
+        // data entry uses 1.5 bytes (12 bits). Two entries are packed
+        // into 3 bytes.
+        //
+        // For the even (first) entry, we grow the message by 2 bytes
+        // and write the 12 bits into the upper 12 bits of the new
+        // 16-bit word at the end of the message.
+        //
+        // For the odd (second) entry, we grow the message by 1 byte. We
+        // then read the last 16 bits (which overlap with the last byte
+        // of the even entry), write the new 12-bit entry into the
+        // lower 12 bits, and write the 16-bit word back. This
+        // perfectly packs the two 12-bit entries into 3 bytes.
+
+        uint16_t offset;
+        uint16_t data;
+
+        SuccessOrExit(error = aMessage.IncreaseLength(aIsEven ? sizeof(uint16_t) : sizeof(uint8_t)));
+
+        VerifyOrExit(aMessage.GetLength() >= sizeof(uint16_t), error = kErrorParse);
+        offset = aMessage.GetLength() - sizeof(uint16_t);
+
+        if (aIsEven)
+        {
+            data = 0;
+            WriteBits<uint16_t, kEvenEntryMask>(data, entry);
+        }
+        else
+        {
+            IgnoreError(aMessage.Read<uint16_t>(offset, data));
+            data = BigEndian::HostSwap16(data);
+
+            WriteBits<uint16_t, kOddEntryMask>(data, entry);
+        }
+
+        aMessage.Write<uint16_t>(offset, BigEndian::HostSwap16(data));
+    }
+#endif // OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+
+exit:
+    return error;
+}
 
 //---------------------------------------------------------------------------------------------------------------------
 // ConnectivityTlvValue
@@ -196,5 +313,24 @@
     aLeaderData.SetLeaderRouterId(mLeaderRouterId);
 }
 
+//---------------------------------------------------------------------------------------------------------------------
+// CslClockAccuracyTlvValue
+
+#if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
+
+CslClockAccuracyTlvValue::CslClockAccuracyTlvValue(uint8_t aClockAccuracy, uint8_t aUncertainty)
+    : mClockAccuracy(aClockAccuracy)
+    , mUncertainty(aUncertainty)
+{
+}
+
+void CslClockAccuracyTlvValue::Get(Mac::CslAccuracy &aAccuracy) const
+{
+    aAccuracy.SetClockAccuracy(mClockAccuracy);
+    aAccuracy.SetUncertainty(mUncertainty);
+}
+
+#endif
+
 } // namespace Mle
 } // namespace ot
diff --git a/src/core/thread/mle_tlvs.hpp b/src/core/thread/mle_tlvs.hpp
index a88dd90..14f770f 100644
--- a/src/core/thread/mle_tlvs.hpp
+++ b/src/core/thread/mle_tlvs.hpp
@@ -229,359 +229,207 @@
  */
 typedef UintTlvInfo<Tlv::kXtalAccuracy, uint16_t> XtalAccuracyTlv;
 
+/**
+ * Defines Route TLV constants and types.
+ */
+class RouteTlv : public TlvInfo<Tlv::kRoute>
+{
 #if !OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+    typedef uint8_t EntryType;
+#else
+    typedef uint16_t EntryType;
+#endif
 
-/**
- * Implements Route TLV generation and parsing.
- */
-OT_TOOL_PACKED_BEGIN
-class RouteTlv : public Tlv, public TlvInfo<Tlv::kRoute>
-{
 public:
     /**
-     * Initializes the TLV.
+     * Represents the parsed Route TLV data.
      */
-    void Init(void);
-
-    /**
-     * Indicates whether or not the TLV appears to be well-formed.
-     *
-     * @retval TRUE   If the TLV appears to be well-formed.
-     * @retval FALSE  If the TLV does not appear to be well-formed.
-     */
-    bool IsValid(void) const;
-
-    /**
-     * Returns the Router ID Sequence value.
-     *
-     * @returns The Router ID Sequence value.
-     */
-    uint8_t GetRouterIdSequence(void) const { return mRouterIdSequence; }
-
-    /**
-     * Sets the Router ID Sequence value.
-     *
-     * @param[in]  aSequence  The Router ID Sequence value.
-     */
-    void SetRouterIdSequence(uint8_t aSequence) { mRouterIdSequence = aSequence; }
-
-    /**
-     * Gets the Router ID Mask.
-     */
-    const RouterIdSet &GetRouterIdMask(void) const { return mRouterIdMask; }
-
-    /**
-     * Sets the Router ID Mask.
-     *
-     * @param[in]  aRouterIdSet The Router ID Mask to set.
-     */
-    void SetRouterIdMask(const RouterIdSet &aRouterIdSet) { mRouterIdMask = aRouterIdSet; }
-
-    /**
-     * Indicates whether or not a Router ID bit is set.
-     *
-     * @param[in]  aRouterId  The Router ID bit.
-     *
-     * @retval TRUE   If the Router ID bit is set.
-     * @retval FALSE  If the Router ID bit is not set.
-     */
-    bool IsRouterIdSet(uint8_t aRouterId) const { return mRouterIdMask.Contains(aRouterId); }
-
-    /**
-     * Indicates whether the `RouteTlv` is a singleton, i.e., only one router is allocated.
-     *
-     * @retval TRUE   It is a singleton.
-     * @retval FALSE  It is not a singleton.
-     */
-    bool IsSingleton(void) const { return IsValid() && (mRouterIdMask.GetNumberOfAllocatedIds() <= 1); }
-
-    /**
-     * Returns the Route Data Length value.
-     *
-     * @returns The Route Data Length value.
-     */
-    uint8_t GetRouteDataLength(void) const { return GetLength() - sizeof(mRouterIdSequence) - sizeof(mRouterIdMask); }
-
-    /**
-     * Sets the Route Data Length value.
-     *
-     * @param[in]  aLength  The Route Data Length value.
-     */
-    void SetRouteDataLength(uint8_t aLength) { SetLength(sizeof(mRouterIdSequence) + sizeof(mRouterIdMask) + aLength); }
-
-    /**
-     * Returns the Route Cost value for a given Router index.
-     *
-     * @param[in]  aRouterIndex  The Router index.
-     *
-     * @returns The Route Cost value for a given Router index.
-     */
-    uint8_t GetRouteCost(uint8_t aRouterIndex) const { return mRouteData[aRouterIndex] & kRouteCostMask; }
-
-    /**
-     * Returns the Link Quality In value for a given Router index.
-     *
-     * @param[in]  aRouterIndex  The Router index.
-     *
-     * @returns The Link Quality In value for a given Router index.
-     */
-    LinkQuality GetLinkQualityIn(uint8_t aRouterIndex) const
+    class Data
     {
-        return static_cast<LinkQuality>((mRouteData[aRouterIndex] & kLinkQualityInMask) >> kLinkQualityInOffset);
-    }
+    public:
+        /**
+         * Represents a single route entry.
+         */
+        class Entry
+        {
+            friend class Data;
 
-    /**
-     * Returns the Link Quality Out value for a given Router index.
-     *
-     * @param[in]  aRouterIndex  The Router index.
-     *
-     * @returns The Link Quality Out value for a given Router index.
-     */
-    LinkQuality GetLinkQualityOut(uint8_t aRouterIndex) const
-    {
-        return static_cast<LinkQuality>((mRouteData[aRouterIndex] & kLinkQualityOutMask) >> kLinkQualityOutOffset);
-    }
+        public:
+            /**
+             * Gets the Router ID.
+             *
+             * @returns The Router ID.
+             */
+            uint8_t GetRouterId(void) const { return mRouterId; }
 
+            /**
+             * Gets the Route Cost value.
+             *
+             * @returns The Route Cost value.
+             */
+            uint8_t GetRouteCost(void) const
+            {
+                return static_cast<uint8_t>(ReadBits<EntryType, kRouteCostMask>(mRouteData));
+            }
+
+            /**
+             * Gets the Link Quality Out value.
+             *
+             * @returns The Link Quality Out value.
+             */
+            LinkQuality GetLinkQualityOut(void) const
+            {
+                return static_cast<LinkQuality>(ReadBits<EntryType, kLinkQualityOutMask>(mRouteData));
+            }
+
+            /**
+             * Gets the Link Quality In value.
+             *
+             * @returns The Link Quality In value.
+             */
+            LinkQuality GetLinkQualityIn(void) const
+            {
+                return static_cast<LinkQuality>(ReadBits<EntryType, kLinkQualityInMask>(mRouteData));
+            }
+
+            /**
+             * Indicates whether the entry matches a given Router ID.
+             *
+             * @param[in] aRouterId  The Router ID to match against.
+             *
+             * @retval TRUE   The entry matches @p aRouterId.
+             * @retval FALSE  The entry does not match @p aRouterId.
+             */
+            bool Matches(uint8_t aRouterId) const { return (mRouterId == aRouterId); }
+
+        private:
+            uint8_t   mRouterId;
+            EntryType mRouteData;
+        };
+
+        /**
+         * Represents an array of route entries.
+         */
+        typedef Array<Entry, kMaxRouters> EntryArray;
+
+        /**
+         * Parses the Route TLV data from a given message and offset range.
+         *
+         * @param[in] aMessage      The message to parse from.
+         * @param[in] aOffsetRange  The offset range within the message to read from.
+         *
+         * @retval kErrorNone   Successfully parsed the Route TLV data.
+         * @retval kErrorParse  Failed to parse the Route TLV data.
+         */
+        Error ParseFrom(const Message &aMessage, const OffsetRange &aOffsetRange);
+
+        /**
+         * Gets the Router ID Sequence.
+         *
+         * @returns The Router ID Sequence.
+         */
+        uint8_t GetRouterIdSequence(void) const { return mIdSequence; }
+
+        /**
+         * Gets the array of route entries.
+         *
+         * @returns The array of route entries.
+         */
+        const EntryArray &GetEntries(void) const { return mEntries; }
+
+        /**
+         * Indicates whether a given Router ID is allocated in the route data.
+         *
+         * @param[in] aRouterId  The Router ID to check.
+         *
+         * @retval TRUE   The Router ID is allocated.
+         * @retval FALSE  The Router ID is not allocated.
+         */
+        bool IsAllocated(uint8_t aRouterId) const;
+
+        /**
+         * Indicates whether the Route TLV data is a singleton (at most one router is allocated).
+         *
+         * @retval TRUE   It is a singleton.
+         * @retval FALSE  It is not a singleton.
+         */
+        bool IsSingleton(void) const { return mEntries.GetLength() <= 1; }
+
+        /**
+         * Determines the Router ID Mask from the route data.
+         *
+         * @param[out] aRouterIdMask  A reference to a `RouterIdMask` to populate.
+         */
+        void DetermineRouterIdMask(RouterIdMask &aRouterIdMask) const;
+
+    private:
+        uint8_t    mIdSequence;
+        EntryArray mEntries;
+    };
+
+#if !OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
     /**
-     * Sets the Route Data (Link Quality In/Out and Route Cost) for a given Router index.
+     * Appends a Route Data entry (Link Quality In/Out and Route Cost) to a message.
      *
-     * @param[in]  aRouterIndex    The Router index.
-     * @param[in]  aLinkQualityIn  The Link Quality In value.
-     * @param[in]  aLinkQualityOut The Link Quality Out value.
+     * @param[in]  aMessage        The message to append to.
+     * @param[in]  aLqIn           The Link Quality In value.
+     * @param[in]  aLqOut          The Link Quality Out value.
      * @param[in]  aRouteCost      The Route Cost value.
+     *
+     * @retval kErrorNone      Successfully appended the data.
+     * @retval kErrorNoBufs    Insufficient available buffers to grow the message.
      */
-    void SetRouteData(uint8_t aRouterIndex, LinkQuality aLinkQualityIn, LinkQuality aLinkQualityOut, uint8_t aRouteCost)
-    {
-        mRouteData[aRouterIndex] = (((aLinkQualityIn << kLinkQualityInOffset) & kLinkQualityInMask) |
-                                    ((aLinkQualityOut << kLinkQualityOutOffset) & kLinkQualityOutMask) |
-                                    ((aRouteCost << kRouteCostOffset) & kRouteCostMask));
-    }
+    static Error AppendRouteDataEntry(Message &aMessage, LinkQuality aLqIn, LinkQuality aLqOut, uint8_t aRouteCost);
+#else
+    /**
+     * Appends a Route Data entry (Link Quality In/Out and Route Cost) to a message.
+     *
+     * Under `OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE`, each route data entry uses 1.5 bytes (12 bits). Two entries
+     * are packed into 3 bytes. @p aIsEven is used to indicate whether this is an even (first) or an odd (second) entry.
+     *
+     * @param[in]  aMessage        The message to append to.
+     * @param[in]  aLqIn           The Link Quality In value.
+     * @param[in]  aLqOut          The Link Quality Out value.
+     * @param[in]  aRouteCost      The Route Cost value.
+     * @param[in]  aIsEven         Indicates whether this is an even (first) entry.
+     *
+     * @retval kErrorNone      Successfully appended the data.
+     * @retval kErrorNoBufs    Insufficient available buffers to grow the message.
+     * @retval kErrorParse     Message length is invalid for parsing route data.
+     */
+    static Error AppendRouteDataEntry(Message    &aMessage,
+                                      LinkQuality aLqIn,
+                                      LinkQuality aLqOut,
+                                      uint8_t     aRouteCost,
+                                      bool        aIsEven);
+#endif
 
 private:
-    static constexpr uint8_t kLinkQualityOutOffset = 6;
-    static constexpr uint8_t kLinkQualityOutMask   = 3 << kLinkQualityOutOffset;
-    static constexpr uint8_t kLinkQualityInOffset  = 4;
-    static constexpr uint8_t kLinkQualityInMask    = 3 << kLinkQualityInOffset;
-    static constexpr uint8_t kRouteCostOffset      = 0;
-    static constexpr uint8_t kRouteCostMask        = 0xf << kRouteCostOffset;
+#if !OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+    //   7   6   5   4   3   2   1   0
+    // +---+---+---+---+---+---+---+---+
+    // | LQOut | LQIn  |  Route Cost   |
+    // +---+---+---+---+---+---+---+---+
 
-    uint8_t     mRouterIdSequence;
-    RouterIdSet mRouterIdMask;
-    uint8_t     mRouteData[kMaxRouterId + 1];
-} OT_TOOL_PACKED_END;
+    static constexpr uint8_t kLinkQualityOutMask = 0x03 << 6;
+    static constexpr uint8_t kLinkQualityInMask  = 0x03 << 4;
+    static constexpr uint8_t kRouteCostMask      = 0x0f << 0;
 
-#else // OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+#else
+    // Under `LOG_ROUTES` feature, Route Data is 12 bits per route
+    // (1.5 bytes). The first 4 bits are link qualities (out/in),
+    // remaining 8 bits are for the route cost. The even and odd
+    // entries are staggered.
 
-/**
- * Implements Route TLV generation and parsing.
- */
-OT_TOOL_PACKED_BEGIN
-class RouteTlv : public Tlv, public TlvInfo<Tlv::kRoute>
-{
-public:
-    /**
-     * Initializes the TLV.
-     */
-    void Init(void)
-    {
-        SetType(kRoute);
-        SetLength(sizeof(*this) - sizeof(Tlv));
-    }
+    static constexpr uint16_t kEvenEntryMask = 0xfff << 4;
+    static constexpr uint16_t kOddEntryMask  = 0xfff << 0;
 
-    /**
-     * Indicates whether or not the TLV appears to be well-formed.
-     *
-     * @retval TRUE   If the TLV appears to be well-formed.
-     * @retval FALSE  If the TLV does not appear to be well-formed.
-     */
-    bool IsValid(void) const { return GetLength() >= sizeof(mRouterIdSequence) + sizeof(mRouterIdMask); }
-
-    /**
-     * Returns the Router ID Sequence value.
-     *
-     * @returns The Router ID Sequence value.
-     */
-    uint8_t GetRouterIdSequence(void) const { return mRouterIdSequence; }
-
-    /**
-     * Sets the Router ID Sequence value.
-     *
-     * @param[in]  aSequence  The Router ID Sequence value.
-     */
-    void SetRouterIdSequence(uint8_t aSequence) { mRouterIdSequence = aSequence; }
-
-    /**
-     * Gets the Router ID Mask.
-     */
-    const RouterIdSet &GetRouterIdMask(void) const { return mRouterIdMask; }
-
-    /**
-     * Sets the Router ID Mask.
-     *
-     * @param[in]  aRouterIdSet The Router ID Mask to set.
-     */
-    void SetRouterIdMask(const RouterIdSet &aRouterIdSet) { mRouterIdMask = aRouterIdSet; }
-
-    /**
-     * Indicates whether or not a Router ID bit is set.
-     *
-     * @param[in]  aRouterId  The Router ID.
-     *
-     * @retval TRUE   If the Router ID bit is set.
-     * @retval FALSE  If the Router ID bit is not set.
-     */
-    bool IsRouterIdSet(uint8_t aRouterId) const { return mRouterIdMask.Contains(aRouterId); }
-
-    /**
-     * Indicates whether the `RouteTlv` is a singleton, i.e., only one router is allocated.
-     *
-     * @retval TRUE   It is a singleton.
-     * @retval FALSE  It is not a singleton.
-     */
-    bool IsSingleton(void) const { return IsValid() && (mRouterIdMask.GetNumberOfAllocatedIds() <= 1); }
-
-    /**
-     * Sets the Router ID bit.
-     *
-     * @param[in]  aRouterId  The Router ID bit to set.
-     */
-    void SetRouterId(uint8_t aRouterId) { mRouterIdMask.Add(aRouterId); }
-
-    /**
-     * Returns the Route Data Length value.
-     *
-     * @returns The Route Data Length value in bytes
-     */
-    uint8_t GetRouteDataLength(void) const { return GetLength() - sizeof(mRouterIdSequence) - sizeof(mRouterIdMask); }
-
-    /**
-     * Sets the Route Data Length value.
-     *
-     * @param[in]  aLength  The Route Data Length value in number of router entries
-     */
-    void SetRouteDataLength(uint8_t aLength)
-    {
-        SetLength(sizeof(mRouterIdSequence) + sizeof(mRouterIdMask) + aLength + (aLength + 1) / 2);
-    }
-
-    /**
-     * Returns the Route Cost value for a given Router index.
-     *
-     * @param[in]  aRouterIndex  The Router index.
-     *
-     * @returns The Route Cost value for a given Router index.
-     */
-    uint8_t GetRouteCost(uint8_t aRouterIndex) const
-    {
-        if (aRouterIndex & 1)
-        {
-            return mRouteData[aRouterIndex + aRouterIndex / 2 + 1];
-        }
-        else
-        {
-            return static_cast<uint8_t>((mRouteData[aRouterIndex + aRouterIndex / 2] & kRouteCostMask)
-                                        << kOddEntryOffset) |
-                   ((mRouteData[aRouterIndex + aRouterIndex / 2 + 1] &
-                     static_cast<uint8_t>(kRouteCostMask << kOddEntryOffset)) >>
-                    kOddEntryOffset);
-        }
-    }
-
-    /**
-     * Returns the Link Quality In value for a given Router index.
-     *
-     * @param[in]  aRouterIndex  The Router index.
-     *
-     * @returns The Link Quality In value for a given Router index.
-     */
-    LinkQuality GetLinkQualityIn(uint8_t aRouterIndex) const
-    {
-        int offset = ((aRouterIndex & 1) ? kOddEntryOffset : 0);
-        return static_cast<LinkQuality>(
-            (mRouteData[aRouterIndex + aRouterIndex / 2] & (kLinkQualityInMask >> offset)) >>
-            (kLinkQualityInOffset - offset));
-    }
-
-    /**
-     * Returns the Link Quality Out value for a given Router index.
-     *
-     * @param[in]  aRouterIndex  The Router index.
-     *
-     * @returns The Link Quality Out value for a given Router index.
-     */
-    LinkQuality GetLinkQualityOut(uint8_t aRouterIndex) const
-    {
-        int offset = ((aRouterIndex & 1) ? kOddEntryOffset : 0);
-        return static_cast<LinkQuality>(
-            (mRouteData[aRouterIndex + aRouterIndex / 2] & (kLinkQualityOutMask >> offset)) >>
-            (kLinkQualityOutOffset - offset));
-    }
-
-    /**
-     * Sets the Route Data (Link Quality In/Out and Route Cost) for a given Router index.
-     *
-     * @param[in]  aRouterIndex    The Router index.
-     * @param[in]  aLinkQualityIn  The Link Quality In value.
-     * @param[in]  aLinkQualityOut The Link Quality Out value.
-     * @param[in]  aRouteCost      The Route Cost value.
-     */
-    void SetRouteData(uint8_t aRouterIndex, LinkQuality aLinkQualityIn, LinkQuality aLinkQualityOut, uint8_t aRouteCost)
-    {
-        SetLinkQualityIn(aRouterIndex, aLinkQualityIn);
-        SetLinkQualityOut(aRouterIndex, aLinkQualityOut);
-        SetRouteCost(aRouterIndex, aRouteCost);
-    }
-
-private:
-    static constexpr uint8_t kLinkQualityOutOffset = 6;
-    static constexpr uint8_t kLinkQualityOutMask   = 3 << kLinkQualityOutOffset;
-    static constexpr uint8_t kLinkQualityInOffset  = 4;
-    static constexpr uint8_t kLinkQualityInMask    = 3 << kLinkQualityInOffset;
-    static constexpr uint8_t kRouteCostOffset      = 0;
-    static constexpr uint8_t kRouteCostMask        = 0xf << kRouteCostOffset;
-    static constexpr uint8_t kOddEntryOffset       = 4;
-
-    void SetRouteCost(uint8_t aRouterIndex, uint8_t aRouteCost)
-    {
-        if (aRouterIndex & 1)
-        {
-            mRouteData[aRouterIndex + aRouterIndex / 2 + 1] = aRouteCost;
-        }
-        else
-        {
-            mRouteData[aRouterIndex + aRouterIndex / 2] =
-                (mRouteData[aRouterIndex + aRouterIndex / 2] & ~kRouteCostMask) |
-                ((aRouteCost >> kOddEntryOffset) & kRouteCostMask);
-            mRouteData[aRouterIndex + aRouterIndex / 2 + 1] = static_cast<uint8_t>(
-                (mRouteData[aRouterIndex + aRouterIndex / 2 + 1] & ~(kRouteCostMask << kOddEntryOffset)) |
-                ((aRouteCost & kRouteCostMask) << kOddEntryOffset));
-        }
-    }
-
-    void SetLinkQualityIn(uint8_t aRouterIndex, uint8_t aLinkQuality)
-    {
-        int offset = ((aRouterIndex & 1) ? kOddEntryOffset : 0);
-        mRouteData[aRouterIndex + aRouterIndex / 2] =
-            (mRouteData[aRouterIndex + aRouterIndex / 2] & ~(kLinkQualityInMask >> offset)) |
-            ((aLinkQuality << (kLinkQualityInOffset - offset)) & (kLinkQualityInMask >> offset));
-    }
-
-    void SetLinkQualityOut(uint8_t aRouterIndex, LinkQuality aLinkQuality)
-    {
-        int offset = ((aRouterIndex & 1) ? kOddEntryOffset : 0);
-        mRouteData[aRouterIndex + aRouterIndex / 2] =
-            (mRouteData[aRouterIndex + aRouterIndex / 2] & ~(kLinkQualityOutMask >> offset)) |
-            ((aLinkQuality << (kLinkQualityOutOffset - offset)) & (kLinkQualityOutMask >> offset));
-    }
-
-    uint8_t     mRouterIdSequence;
-    RouterIdSet mRouterIdMask;
-    // Since we do hold 12 (compressible to 11) bits of data per router, each entry occupies 1.5 bytes,
-    // consecutively. First 4 bits are link qualities, remaining 8 bits are route cost.
-    uint8_t mRouteData[kMaxRouterId + 1 + kMaxRouterId / 2 + 1];
-} OT_TOOL_PACKED_END;
+    static constexpr uint16_t kLinkQualityOutMask = 0x03 << 10;
+    static constexpr uint16_t kLinkQualityInMask  = 0x03 << 8;
+    static constexpr uint16_t kRouteCostMask      = 0xff << 0;
 
 #endif // OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+};
 
 /**
  * Represents Leader Data TLV value.
@@ -867,30 +715,13 @@
 typedef TlvInfo<Tlv::kTimeRequest> TimeRequestTlv;
 
 /**
- * Implements Time Parameter TLV generation and parsing.
+ * Represents a Time Parameter TLV value.
  */
 OT_TOOL_PACKED_BEGIN
-class TimeParameterTlv : public Tlv, public TlvInfo<Tlv::kTimeParameter>
+class TimeParameterTlvValue
 {
 public:
     /**
-     * Initializes the TLV.
-     */
-    void Init(void)
-    {
-        SetType(kTimeParameter);
-        SetLength(sizeof(*this) - sizeof(Tlv));
-    }
-
-    /**
-     * Indicates whether or not the TLV appears to be well-formed.
-     *
-     * @retval TRUE   If the TLV appears to be well-formed.
-     * @retval FALSE  If the TLV does not appear to be well-formed.
-     */
-    bool IsValid(void) const { return GetLength() >= sizeof(*this) - sizeof(Tlv); }
-
-    /**
      * Returns the time sync period.
      *
      * @returns The time sync period.
@@ -923,66 +754,52 @@
     uint16_t mXtalThreshold;
 } OT_TOOL_PACKED_END;
 
+/**
+ * Defines Time Parameter TLV constants and types.
+ */
+typedef SimpleTlvInfo<Tlv::kTimeParameter, TimeParameterTlvValue> TimeParameterTlv;
+
 #endif // OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
+
 /**
- * Implements CSL Clock Accuracy TLV generation and parsing.
+ * Represents CSL Clock Accuracy TLV value.
  */
 OT_TOOL_PACKED_BEGIN
-class CslClockAccuracyTlv : public Tlv, public TlvInfo<Tlv::kCslClockAccuracy>
+class CslClockAccuracyTlvValue
 {
 public:
     /**
-     * Initializes the TLV.
+     * Default constructor.
      */
-    void Init(void)
-    {
-        SetType(kCslClockAccuracy);
-        SetLength(sizeof(*this) - sizeof(Tlv));
-    }
+    CslClockAccuracyTlvValue(void) = default;
 
     /**
-     * Indicates whether or not the TLV appears to be well-formed.
+     * Initializes the TLV value with given clock accuracy and uncertainty.
      *
-     * @retval TRUE   If the TLV appears to be well-formed.
-     * @retval FALSE  If the TLV does not appear to be well-formed.
+     * @param[in] aClockAccuracy  The clock accuracy in ppm.
+     * @param[in] aUncertainty    The clock uncertainty in units of 10 us.
      */
-    bool IsValid(void) const { return GetLength() >= sizeof(*this) - sizeof(Tlv); }
+    CslClockAccuracyTlvValue(uint8_t aClockAccuracy, uint8_t aUncertainty);
 
     /**
-     * Returns the CSL Clock Accuracy value.
+     * Gets the CSL clock accuracy and uncertainty values.
      *
-     * @returns The CSL Clock Accuracy value.
+     * @param[out] aAccuracy  A reference to a `Mac::CslAccuracy` to return the values.
      */
-    uint8_t GetCslClockAccuracy(void) const { return mCslClockAccuracy; }
-
-    /**
-     * Sets the CSL Clock Accuracy value.
-     *
-     * @param[in]  aCslClockAccuracy  The CSL Clock Accuracy value.
-     */
-    void SetCslClockAccuracy(uint8_t aCslClockAccuracy) { mCslClockAccuracy = aCslClockAccuracy; }
-
-    /**
-     * Returns the Clock Uncertainty value.
-     *
-     * @returns The Clock Uncertainty value.
-     */
-    uint8_t GetCslUncertainty(void) const { return mCslUncertainty; }
-
-    /**
-     * Sets the CSL Uncertainty value.
-     *
-     * @param[in]  aCslUncertainty  The CSL Uncertainty value.
-     */
-    void SetCslUncertainty(uint8_t aCslUncertainty) { mCslUncertainty = aCslUncertainty; }
+    void Get(Mac::CslAccuracy &aAccuracy) const;
 
 private:
-    uint8_t mCslClockAccuracy;
-    uint8_t mCslUncertainty;
+    uint8_t mClockAccuracy;
+    uint8_t mUncertainty;
 } OT_TOOL_PACKED_END;
 
+/**
+ * Defines CSL Clock Accuracy TLV constants and types.
+ */
+typedef SimpleTlvInfo<Tlv::kCslClockAccuracy, CslClockAccuracyTlvValue> CslClockAccuracyTlv;
+
 #endif // OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE || OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
 /**
  * @}
diff --git a/src/core/thread/mle_types.cpp b/src/core/thread/mle_types.cpp
index b2d5ba3..4b959bb 100644
--- a/src/core/thread/mle_types.cpp
+++ b/src/core/thread/mle_types.cpp
@@ -145,13 +145,13 @@
 #endif // #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_MLE_DEVICE_PROPERTY_LEADER_WEIGHT_ENABLE
 
 //---------------------------------------------------------------------------------------------------------------------
-// RouterIdSet
+// RouterIdMask
 
-uint8_t RouterIdSet::GetNumberOfAllocatedIds(void) const
+uint8_t RouterIdMask::DetermineAllocatedCount(void) const
 {
     uint8_t count = 0;
 
-    for (uint8_t byte : mRouterIdSet)
+    for (uint8_t byte : mMask)
     {
         count += CountBitsInMask(byte);
     }
@@ -159,6 +159,13 @@
     return count;
 }
 
+Error RouterIdMask::AppendMaskTo(Message &aMessage) const { return aMessage.AppendBytes(mMask, kMaskSize); }
+
+Error RouterIdMask::ReadMaskFrom(const Message &aMessage, const OffsetRange &aOffsetRange)
+{
+    return aMessage.Read(aOffsetRange, mMask, kMaskSize);
+}
+
 //---------------------------------------------------------------------------------------------------------------------
 // TxChallenge
 
diff --git a/src/core/thread/mle_types.hpp b/src/core/thread/mle_types.hpp
index e67d1df..7e87796 100644
--- a/src/core/thread/mle_types.hpp
+++ b/src/core/thread/mle_types.hpp
@@ -622,45 +622,99 @@
     void SetLeaderRouterId(uint8_t aRouterId) { mLeaderRouterId = aRouterId; }
 };
 
+/**
+ * Represents a Router ID Sequence and Mask.
+ *
+ * This type is defined as packed and is used in `RouteTlv` and `ThreadRouterMaskTlv`.
+ */
 OT_TOOL_PACKED_BEGIN
-class RouterIdSet : public Equatable<RouterIdSet>, public Clearable<RouterIdSet>
+class RouterIdMask : public Clearable<RouterIdMask>
 {
 public:
     /**
-     * Indicates whether or not a Router ID bit is set.
+     * The size of the Router ID mask in bytes.
+     */
+    static constexpr uint8_t kMaskSize = BytesForBitSize(kMaxRouterId + 1);
+
+    /**
+     * Indicates whether or not the mask is valid (count of allocated Router IDs is within the limit).
+     *
+     * @retval TRUE   If the mask is valid.
+     * @retval FALSE  If the mask is not valid.
+     */
+    bool IsValid(void) const { return (DetermineAllocatedCount() <= kMaxRouters); }
+
+    /**
+     * Gets the Router ID Sequence number associated with the mask.
+     *
+     * @returns The Router ID Sequence number.
+     */
+    uint8_t GetSequence(void) const { return mSequence; }
+
+    /**
+     * Sets the Router ID Sequence value.
+     *
+     * @param[in]  aSequence  The Router ID Sequence value.
+     */
+    void SetSequence(uint8_t aSequence) { mSequence = aSequence; }
+
+    /**
+     * Indicates whether or not a Router ID bit is set in the mask.
      *
      * @param[in]  aRouterId  The Router ID.
      *
-     * @retval TRUE   If the Router ID bit is set.
-     * @retval FALSE  If the Router ID bit is not set.
+     * @retval TRUE   If the Router ID bit is set in the mask.
+     * @retval FALSE  If the Router ID bit is not set in the mask.
      */
-    bool Contains(uint8_t aRouterId) const { return (mRouterIdSet[aRouterId / 8] & MaskFor(aRouterId)) != 0; }
+    bool IsAllocated(uint8_t aRouterId) const { return (mMask[aRouterId / 8] & MaskFor(aRouterId)) != 0; }
 
     /**
-     * Sets a given Router ID.
+     * Sets a given Router ID in the mask.
      *
      * @param[in]  aRouterId  The Router ID to set.
      */
-    void Add(uint8_t aRouterId) { mRouterIdSet[aRouterId / 8] |= MaskFor(aRouterId); }
+    void Add(uint8_t aRouterId) { mMask[aRouterId / 8] |= MaskFor(aRouterId); }
 
     /**
-     * Removes a given Router ID.
+     * Removes a given Router ID from the mask.
      *
      * @param[in]  aRouterId  The Router ID to remove.
      */
-    void Remove(uint8_t aRouterId) { mRouterIdSet[aRouterId / 8] &= ~MaskFor(aRouterId); }
+    void Remove(uint8_t aRouterId) { mMask[aRouterId / 8] &= ~MaskFor(aRouterId); }
 
     /**
-     * Calculates the number of allocated Router IDs in the set.
+     * Calculates the number of allocated Router IDs in the mask.
      *
-     * @returns The number of allocated Router IDs in the set.
+     * @returns The number of allocated Router IDs in the mask.
      */
-    uint8_t GetNumberOfAllocatedIds(void) const;
+    uint8_t DetermineAllocatedCount(void) const;
+
+    /**
+     * Appends the Router ID mask (excluding the sequence) to a message.
+     *
+     * @param[in]  aMessage  The message to append to.
+     *
+     * @retval kErrorNone    Successfully appended the mask.
+     * @retval kErrorNoBufs  Insufficient available buffers to grow the message.
+     */
+    Error AppendMaskTo(Message &aMessage) const;
+
+    /**
+     * Reads the Router ID mask (excluding the sequence) from a message within a given offset range.
+     *
+     * @param[in]  aMessage      The message to read from.
+     * @param[in]  aOffsetRange  The offset range to read from.
+     *
+     * @retval kErrorNone   Successfully read the mask.
+     * @retval kErrorParse  Not enough bytes remaining in the message to read the mask.
+     */
+    Error ReadMaskFrom(const Message &aMessage, const OffsetRange &aOffsetRange);
 
 private:
     static uint8_t MaskFor(uint8_t aRouterId) { return (0x80 >> (aRouterId % 8)); }
 
-    uint8_t mRouterIdSet[BytesForBitSize(kMaxRouterId + 1)];
+    uint8_t mSequence;
+    uint8_t mMask[kMaskSize];
 } OT_TOOL_PACKED_END;
 
 class TxChallenge;
diff --git a/src/core/thread/mlr_manager.cpp b/src/core/thread/mlr_manager.cpp
index 40ad9db..954846f 100644
--- a/src/core/thread/mlr_manager.cpp
+++ b/src/core/thread/mlr_manager.cpp
@@ -38,279 +38,421 @@
 #include "instance/instance.hpp"
 
 namespace ot {
+namespace Mlr {
 
 RegisterLogModule("MlrManager");
 
-MlrManager::MlrManager(Instance &aInstance)
+Manager::Manager(Instance &aInstance)
     : InstanceLocator(aInstance)
-    , mReregistrationDelay(0)
-    , mSendDelay(0)
-    , mMlrPending(false)
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
     , mRegisterPending(false)
 #endif
+    , mState(kStateStopped)
+    , mTimer(aInstance)
 {
 }
 
-void MlrManager::HandleNotifierEvents(Events aEvents)
+void Manager::EnterState(State aState)
+{
+    State oldState;
+
+    VerifyOrExit(mState != aState);
+
+    oldState = mState;
+    mState   = aState;
+
+    if (oldState == kStateRegistering)
+    {
+        IgnoreError(Get<Tmf::Agent>().AbortTransaction(HandleResponse, this));
+    }
+
+    if (mState == kStateToRegisterAll)
+    {
+        // Clear "MlrRegistered" state on all addresses
+
+#if OPENTHREAD_CONFIG_MLR_ENABLE
+        for (Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
+        {
+            if (addr.IsMlrCandidate())
+            {
+                addr.SetMlrRegistered(false);
+            }
+        }
+#endif
+#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
+        for (Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
+        {
+            child.ClearAllAddressesMlrRegistrationState();
+        }
+#endif
+    }
+
+exit:
+    return;
+}
+
+void Manager::UpdateState(void)
+{
+    // `UpdateState()` evaluates whether the MLR manager should be
+    // running or not. It handles starting or stopping the manager.
+
+    bool canRun = false;
+
+    if (Get<Mle::Mle>().IsAttached() && Get<BackboneRouter::Leader>().HasPrimary())
+    {
+        canRun = Get<Mle::Mle>().IsFullThreadDevice() || Get<Mle::Mle>().GetParent().IsThreadVersion1p1();
+    }
+
+    if (!IsRunning())
+    {
+        VerifyOrExit(canRun);
+        EnterState(kStateToRegisterAll);
+        ScheduleTimerForReregistrationDelay();
+    }
+    else
+    {
+        VerifyOrExit(!canRun);
+        EnterState(kStateStopped);
+        mTimer.Stop();
+    }
+
+exit:
+    return;
+}
+
+void Manager::HandleNotifierEvents(Events aEvents)
 {
 #if OPENTHREAD_CONFIG_MLR_ENABLE
     if (aEvents.Contains(kEventIp6MulticastSubscribed))
     {
-        UpdateLocalSubscriptions();
+        HandleEventIp6MulticastSubscribed();
     }
 #endif
 
-    if (aEvents.Contains(kEventThreadRoleChanged) && Get<Mle::Mle>().IsChild())
+    if (aEvents.Contains(kEventThreadRoleChanged))
     {
-        // Reregistration after re-attach
-        UpdateReregistrationDelay(true);
+        UpdateState();
     }
 }
 
-void MlrManager::HandleBackboneRouterPrimaryUpdate(BackboneRouter::Leader::State aState,
-                                                   const BackboneRouter::Config &aConfig)
+void Manager::HandleBackboneRouterPrimaryUpdate(BackboneRouter::PrimaryEvent aEvent)
 {
-    OT_UNUSED_VARIABLE(aConfig);
+    UpdateState();
 
-    bool needRereg =
-        aState == BackboneRouter::Leader::kStateAdded || aState == BackboneRouter::Leader::kStateToTriggerRereg;
+    switch (aEvent)
+    {
+    case BackboneRouter::kPrimaryAdded:
+    case BackboneRouter::kPrimaryRemoved:
+        break;
 
-    UpdateReregistrationDelay(needRereg);
+    case BackboneRouter::kPrimaryUpdatedReregister:
+        VerifyOrExit(IsRunning());
+        EnterState(kStateToRegisterAll);
+        ScheduleTimerForReregistrationDelay();
+        break;
+
+    case BackboneRouter::kPrimaryConfigParameterChanged:
+
+        // When PBBR config parameters (like MLR Timeout) change, we
+        // need to recalculate and update the renewal time for all
+        // currently registered addresses to ensure they don't expire
+        // prematurely.
+
+        switch (GetState())
+        {
+        case kStateStopped:
+        case kStateIdle:
+        case kStateToRegisterAll:
+        case kStateRegistering:
+            break;
+
+        case kStateRegistered:
+            mTimer.Stop();
+            OT_FALL_THROUGH;
+
+        case kStateNewAddrToRegister:
+            DetermineRenewTime();
+            mTimer.FireAtIfEarlier(mRenewTime);
+            break;
+        }
+
+        break;
+    }
+
+exit:
+    return;
 }
 
 #if OPENTHREAD_CONFIG_MLR_ENABLE
-void MlrManager::UpdateLocalSubscriptions(void)
+void Manager::HandleEventIp6MulticastSubscribed(void)
 {
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    // Check multicast addresses are newly listened against Children
+    bool hasUnregistered = false;
+
     for (Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
     {
-        if (addr.Matches(kMlrStateToRegister) && IsAddressMlrRegisteredByAnyChild(addr.GetAddress()))
+        if (!addr.IsMlrCandidate())
         {
-            addr.SetMlrState(kMlrStateRegistered);
+            continue;
         }
-    }
+
+        if (!addr.IsMlrRegistered())
+        {
+#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
+            if (IsAddressRegisteredByAnyChild(addr.GetAddress()))
+            {
+                addr.SetMlrRegistered(true);
+                continue;
+            }
 #endif
 
-    CheckInvariants();
-    ScheduleSend(0);
+            hasUnregistered = true;
+        }
+    }
+
+    if (hasUnregistered)
+    {
+        ScheduleNewAddrRegistration(0, kMaxNewNetifAddrRegistraionDelay);
+    }
 }
 
-bool MlrManager::IsAddressMlrRegisteredByNetif(const Ip6::Address &aAddress) const
+bool Manager::IsAddressRegisteredByNetif(const Ip6::Address &aAddress) const
 {
-    bool ret = false;
-
-    OT_ASSERT(aAddress.IsMulticastLargerThanRealmLocal());
+    bool isRegistered = false;
 
     for (const Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
     {
-        if (addr.Matches(kMlrStateRegistered) && (addr.GetAddress() == aAddress))
+        if (addr.IsMlrRegistered() && (addr.GetAddress() == aAddress))
         {
-            ret = true;
+            isRegistered = true;
             break;
         }
     }
 
-    return ret;
+    return isRegistered;
 }
 
 #endif // OPENTHREAD_CONFIG_MLR_ENABLE
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
 
-bool MlrManager::IsAddressMlrRegisteredByAnyChildExcept(const Ip6::Address &aAddress, const Child *aExceptChild) const
+bool Manager::IsAddressRegisteredByAnyChild(const Ip6::Address &aAddress) const
 {
-    bool ret = false;
+    return IsAddressRegisteredByAnyChildExcept(aAddress, nullptr);
+}
 
-    OT_ASSERT(aAddress.IsMulticastLargerThanRealmLocal());
+bool Manager::IsAddressRegisteredByAnyChildExcept(const Ip6::Address &aAddress, const Child *aExceptChild) const
+{
+    bool isRegistered = false;
 
     for (Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
     {
         if (&child != aExceptChild && child.HasMlrRegisteredAddress(aAddress))
         {
-            ExitNow(ret = true);
+            isRegistered = true;
+            break;
         }
     }
 
-exit:
-    return ret;
+    return isRegistered;
 }
 
-void MlrManager::UpdateProxiedSubscriptions(Child &aChild, const MlrAddressArray &aOldMlrRegisteredAddresses)
+void Manager::UpdateChildRegistrations(Child &aChild)
 {
+    Child::Ip6AddressArray emptyArray;
+
+    UpdateChildRegistrations(aChild, emptyArray);
+}
+
+void Manager::UpdateChildRegistrations(Child &aChild, const Child::Ip6AddressArray &aOldRegisteredAddresses)
+{
+    bool hasUnregistered = false;
+
     VerifyOrExit(aChild.IsStateValid());
 
-    // Search the new multicast addresses and set its flag accordingly
-    for (Child::Ip6AddrEntry &addrEntry : aChild.GetIp6Addresses())
+    for (const Ip6::Address &addr : aChild.GetIp6Addresses())
     {
-        bool isMlrRegistered;
+        bool isRegistered;
 
-        if (!addrEntry.IsMulticastLargerThanRealmLocal())
+        if (!addr.IsMulticastLargerThanRealmLocal())
         {
             continue;
         }
 
-        isMlrRegistered = aOldMlrRegisteredAddresses.Contains(addrEntry);
+        isRegistered = aOldRegisteredAddresses.ContainsMatching(addr);
 
 #if OPENTHREAD_CONFIG_MLR_ENABLE
-        // Check if it's a new multicast address against parent Netif
-        isMlrRegistered = isMlrRegistered || IsAddressMlrRegisteredByNetif(addrEntry);
+        isRegistered = isRegistered || IsAddressRegisteredByNetif(addr);
 #endif
-        // Check if it's a new multicast address against other Children
-        isMlrRegistered = isMlrRegistered || IsAddressMlrRegisteredByAnyChildExcept(addrEntry, &aChild);
+        isRegistered = isRegistered || IsAddressRegisteredByAnyChildExcept(addr, &aChild);
 
-        addrEntry.SetMlrState(isMlrRegistered ? kMlrStateRegistered : kMlrStateToRegister, aChild);
+        aChild.SetAddressMlrRegistrationState(addr, isRegistered);
+
+        if (!isRegistered)
+        {
+            hasUnregistered = true;
+        }
     }
 
-exit:
-    LogMulticastAddresses();
-    CheckInvariants();
-
-    if (aChild.HasAnyMlrToRegisterAddress())
+    if (hasUnregistered)
     {
-        ScheduleSend(Random::NonCrypto::GetUint16InRange(1, BackboneRouter::kParentAggregateDelay));
-    }
-}
-
-#endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-
-void MlrManager::ScheduleSend(uint16_t aDelay)
-{
-    OT_ASSERT(!mMlrPending || mSendDelay == 0);
-
-    VerifyOrExit(!mMlrPending);
-
-    if (aDelay == 0)
-    {
-        mSendDelay = 0;
-        SendMlr();
-    }
-    else if (mSendDelay == 0 || mSendDelay > aDelay)
-    {
-        mSendDelay = aDelay;
+        ScheduleNewAddrRegistration(kMinNewChildAddrRegistrationDelay, kMaxNewChildAddrRegistrationDelay);
     }
 
-    UpdateTimeTickerRegistration();
 exit:
     return;
 }
 
-void MlrManager::UpdateTimeTickerRegistration(void)
+#endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
+
+void Manager::ScheduleNewAddrRegistration(uint32_t aMinDelay, uint32_t aMaxDelay)
 {
-    if (mSendDelay == 0 && mReregistrationDelay == 0)
+    uint32_t delay;
+
+    switch (GetState())
     {
-        Get<TimeTicker>().UnregisterReceiver(TimeTicker::kMlrManager);
+    case kStateIdle:
+        EnterState(kStateToRegisterAll);
+        break;
+
+    case kStateRegistered:
+    case kStateNewAddrToRegister:
+        EnterState(kStateNewAddrToRegister);
+        break;
+
+    case kStateStopped:
+    case kStateToRegisterAll:
+    case kStateRegistering:
+        ExitNow();
     }
-    else
-    {
-        Get<TimeTicker>().RegisterReceiver(TimeTicker::kMlrManager);
-    }
+
+    delay = Random::NonCrypto::GenerateInClosedRange(aMinDelay, aMaxDelay);
+
+    // The timer may already be running for a periodic renewal or for a
+    // previously scheduled new address registration. We ensure the
+    // timer fires at the earliest requested time.
+
+    mTimer.FireAtIfEarlier(TimerMilli::GetNow() + delay);
+
+exit:
+    return;
 }
 
-void MlrManager::SendMlr(void)
+void Manager::DetermineAddressesToRegister(AddressArray &aAddresses) const
 {
-    Error        error;
-    AddressArray addresses;
-
-    VerifyOrExit(!mMlrPending, error = kErrorBusy);
-    VerifyOrExit(Get<Mle::Mle>().IsAttached(), error = kErrorInvalidState);
-    VerifyOrExit(Get<Mle::Mle>().IsFullThreadDevice() || Get<Mle::Mle>().GetParent().IsThreadVersion1p1(),
-                 error = kErrorInvalidState);
-    VerifyOrExit(Get<BackboneRouter::Leader>().HasPrimary(), error = kErrorInvalidState);
+    aAddresses.Clear();
 
 #if OPENTHREAD_CONFIG_MLR_ENABLE
-    // Append Netif multicast addresses
-    for (Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
+    for (const Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
     {
-        if (addresses.IsFull())
+        if (addr.IsMlrCandidate() && !addr.IsMlrRegistered())
         {
-            break;
-        }
-
-        if (addr.Matches(kMlrStateToRegister))
-        {
-            addresses.AddUnique(addr.GetAddress());
-            addr.SetMlrState(kMlrStateRegistering);
+            SuccessOrExit(aAddresses.AddUnique(addr.GetAddress()));
         }
     }
 #endif
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    // Append Child multicast addresses
-    for (Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
+    for (const Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
     {
-        if (addresses.IsFull())
+        for (const Ip6::Address &addr : child.GetIp6Addresses())
         {
-            break;
-        }
-
-        if (!child.HasAnyMlrToRegisterAddress())
-        {
-            continue;
-        }
-
-        for (Child::Ip6AddrEntry &addrEntry : child.GetIp6Addresses())
-        {
-            if (!addrEntry.IsMulticastLargerThanRealmLocal())
+            if (addr.IsMulticastLargerThanRealmLocal() && !child.HasMlrRegisteredAddress(addr))
             {
-                continue;
-            }
-
-            if (addresses.IsFull())
-            {
-                break;
-            }
-
-            if (addrEntry.GetMlrState(child) == kMlrStateToRegister)
-            {
-                addresses.AddUnique(addrEntry);
-                addrEntry.SetMlrState(kMlrStateRegistering, child);
+                SuccessOrExit(aAddresses.AddUnique(addr));
             }
         }
     }
 #endif
 
-    VerifyOrExit(!addresses.IsEmpty(), error = kErrorNotFound);
-    SuccessOrExit(
-        error = SendMlrMessage(addresses.GetArrayBuffer(), addresses.GetLength(), nullptr, HandleMlrResponse, this));
+exit:
+    return;
+}
 
-    mMlrPending = true;
+void Manager::SendNextRequest(void)
+{
+    AddressArray addresses;
 
-    // Generally Thread 1.2 Router would send MLR.req on behalf for MA (scope >=4) subscribed by its MTD child.
-    // When Thread 1.2 MTD attaches to Thread 1.1 parent, 1.2 MTD should send MLR.req to PBBR itself.
-    // In this case, Thread 1.2 sleepy end device relies on fast data poll to fetch the response timely.
+    IgnoreError(Get<Tmf::Agent>().AbortTransaction(HandleResponse, this));
+
+    DetermineAddressesToRegister(addresses);
+
+    if (addresses.IsEmpty())
+    {
+        // There are no (more) addresses to register. If we are still
+        // in `kStateToRegisterAll`, it indicates there is no multicast
+        // address to register at all, so we transition to `kStateIdle`.
+        // Otherwise, we at least sent one registration, and all addresses
+        // are now registered, so we transition to `kStateRegistered` and
+        // schedule the next periodic renewal.
+
+        switch (GetState())
+        {
+        case kStateToRegisterAll:
+            EnterState(kStateIdle);
+            break;
+
+        case kStateRegistering:
+        case kStateNewAddrToRegister:
+            EnterState(kStateRegistered);
+            mTimer.FireAt(mRenewTime);
+            break;
+
+        case kStateStopped:
+        case kStateIdle:
+        case kStateRegistered:
+            break;
+        }
+
+        ExitNow();
+    }
+
+    if (SendMessage(addresses.GetArrayBuffer(), addresses.GetLength(), nullptr, HandleResponse) != kErrorNone)
+    {
+        mTimer.Start(kSendFailureRetryDelay);
+        ExitNow();
+    }
+
+    // Transitioning from `kStateToRegisterAll` to `kStateRegistering`
+    // indicates the start of a full registration cycle. We record the
+    // start time and determine the renewal time. The start time is
+    // tracked so we can recalculate the renewal time if PBBR config
+    // parameters change later.
+
+    if (GetState() == kStateToRegisterAll)
+    {
+        mStartTime = TimerMilli::GetNow();
+        DetermineRenewTime();
+    }
+
+    EnterState(kStateRegistering);
+
+    // Generally, a Thread 1.2 Router sends MLR.req on behalf of its
+    // MTD children for multicast addresses. However, when a Thread
+    // 1.2 MTD child attaches to a Thread 1.1 parent, the MTD child
+    // must send the MLR.req to the PBBR itself. In this scenario, a
+    // Thread 1.2 Sleepy End Device relies on fast data polls to
+    // receive the response in a timely manner.
+
     if (!Get<Mle::Mle>().IsRxOnWhenIdle())
     {
         Get<DataPollSender>().SendFastPolls();
     }
 
 exit:
-    if (error != kErrorNone)
-    {
-        SetMulticastAddressMlrState(kMlrStateRegistering, kMlrStateToRegister);
-
-        if (error == kErrorNoBufs)
-        {
-            ScheduleSend(1);
-        }
-    }
-
-    LogMulticastAddresses();
-    CheckInvariants();
+    return;
 }
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
-Error MlrManager::RegisterMulticastListeners(const Ip6::Address *aAddresses,
-                                             uint8_t             aAddressNum,
-                                             const uint32_t     *aTimeout,
-                                             MlrCallback         aCallback,
-                                             void               *aContext)
+Error Manager::RegisterMulticastListeners(const Ip6::Address *aAddresses,
+                                          uint8_t             aAddressNum,
+                                          const uint32_t     *aTimeout,
+                                          RegisterCallback    aCallback,
+                                          void               *aContext)
 {
     Error error;
 
     VerifyOrExit(aAddresses != nullptr, error = kErrorInvalidArgs);
-    VerifyOrExit(aAddressNum > 0 && aAddressNum <= Ip6AddressesTlv::kMaxAddresses, error = kErrorInvalidArgs);
+    VerifyOrExit(aAddressNum > 0 && aAddressNum <= kMaxIp6Addresses, error = kErrorInvalidArgs);
     VerifyOrExit(aContext == nullptr || aCallback != nullptr, error = kErrorInvalidArgs);
 #if !OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     VerifyOrExit(Get<MeshCoP::Commissioner>().IsActive(), error = kErrorInvalidState);
@@ -324,7 +466,7 @@
     // Only allow one outstanding registration if callback is specified.
     VerifyOrExit(!mRegisterPending, error = kErrorBusy);
 
-    SuccessOrExit(error = SendMlrMessage(aAddresses, aAddressNum, aTimeout, HandleRegisterResponse, this));
+    SuccessOrExit(error = SendMessage(aAddresses, aAddressNum, aTimeout, HandleRegisterResponse));
 
     mRegisterPending = true;
     mRegisterCallback.Set(aCallback, aContext);
@@ -333,7 +475,7 @@
     return error;
 }
 
-void MlrManager::HandleRegisterResponse(Coap::Msg *aMsg, Error aResult)
+void Manager::HandleRegisterResponse(Coap::Msg *aMsg, Error aResult)
 {
     uint8_t      status;
     Error        error;
@@ -341,35 +483,30 @@
 
     mRegisterPending = false;
 
-    error = ParseMlrResponse(aResult, aMsg, status, failedAddresses);
+    error = ParseResponse(aResult, aMsg, status, failedAddresses);
 
     mRegisterCallback.InvokeAndClearIfSet(error, status, failedAddresses.GetArrayBuffer(), failedAddresses.GetLength());
 }
 
 #endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
 
-Error MlrManager::SendMlrMessage(const Ip6::Address         *aAddresses,
-                                 uint8_t                     aAddressNum,
-                                 const uint32_t             *aTimeout,
-                                 const Coap::ResponseHandler aResponseHandler,
-                                 void                       *aContext)
+Error Manager::SendMessage(const Ip6::Address         *aAddresses,
+                           uint8_t                     aAddressNum,
+                           const uint32_t             *aTimeout,
+                           const Coap::ResponseHandler aResponseHandler)
 {
     OT_UNUSED_VARIABLE(aTimeout);
 
-    Error           error   = kErrorNone;
-    Coap::Message  *message = nullptr;
-    Ip6::Address    destAddr;
-    Ip6AddressesTlv addressesTlv;
+    Error          error   = kErrorNone;
+    Coap::Message *message = nullptr;
+    Ip6::Address   destAddr;
 
     VerifyOrExit(Get<BackboneRouter::Leader>().HasPrimary(), error = kErrorInvalidState);
 
     message = Get<Tmf::Agent>().AllocateAndInitConfirmablePostMessage(kUriMlr);
     VerifyOrExit(message != nullptr, error = kErrorNoBufs);
 
-    addressesTlv.Init();
-    addressesTlv.SetLength(sizeof(Ip6::Address) * aAddressNum);
-    SuccessOrExit(error = message->Append(addressesTlv));
-    SuccessOrExit(error = message->AppendBytes(aAddresses, sizeof(Ip6::Address) * aAddressNum));
+    SuccessOrExit(error = Ip6AddressesTlv::AppendTo(*message, aAddresses, aAddressNum));
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
     if (Get<MeshCoP::Commissioner>().IsActive())
@@ -391,336 +528,223 @@
         uint8_t pbbrServiceId;
 
         SuccessOrExit(error = Get<BackboneRouter::Leader>().GetServiceId(pbbrServiceId));
-        Get<Mle::Mle>().GetServiceAloc(pbbrServiceId, destAddr);
+        Get<Mle::Mle>().ComposeServiceAloc(pbbrServiceId, destAddr);
     }
     else
     {
-        destAddr.SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), Get<BackboneRouter::Leader>().GetServer16());
+        Get<Mle::Mle>().ComposeRloc(Get<BackboneRouter::Leader>().GetServer16(), destAddr);
     }
 
-    error = Get<Tmf::Agent>().SendMessageTo(*message, destAddr, aResponseHandler, aContext);
+    error = Get<Tmf::Agent>().SendMessageTo(*message, destAddr, aResponseHandler, this);
 
     LogInfo("Sent MLR.req: addressNum=%d", aAddressNum);
 
 exit:
-    LogInfoOnError(error, "SendMlrMessage()");
+    LogInfoOnError(error, "SendMessage()");
     FreeMessageOnError(message, error);
     return error;
 }
 
-void MlrManager::HandleMlrResponse(Coap::Msg *aMsg, Error aResult)
+void Manager::HandleResponse(Coap::Msg *aMsg, Error aResult)
 {
-    uint8_t      status;
-    Error        error;
-    AddressArray failedAddresses;
+    VerifyOrExit(GetState() == kStateRegistering);
+    ProcessResponse(aMsg, aResult);
 
-    error = ParseMlrResponse(aResult, aMsg, status, failedAddresses);
+exit:
+    return;
+}
 
-    FinishMlr(error == kErrorNone && status == kMlrSuccess, failedAddresses);
+void Manager::ProcessResponse(Coap::Msg *aMsg, Error aResult)
+{
+    Error                   error;
+    uint8_t                 status;
+    AddressArray            failedAddresses;
+    AddressArray            registeredAddresses;
+    OwnedPtr<Coap::Message> requestMsg;
 
-    if (error == kErrorNone && status == kMlrSuccess)
+    if (!Get<Mle::Mle>().IsRxOnWhenIdle())
     {
-        // keep sending until all multicast addresses are registered.
-        ScheduleSend(0);
+        Get<DataPollSender>().StopFastPolls();
+    }
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Parse the response message
+
+    SuccessOrExit(error = ParseResponse(aResult, aMsg, status, failedAddresses));
+
+    if (status != kStatusSuccess)
+    {
+        // If status is failure and the failed address list is empty,
+        // all registrations failed. If a non-empty failed address
+        // list is provided, only the addresses in the list failed
+        // (if an address is not in the list, its registration was
+        // successful).
+
+        VerifyOrExit(!failedAddresses.IsEmpty());
+    }
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Retrieve the original TMF MLR request message and parse it
+    // to find the list of addresses included in the request. Remove
+    // addresses in `failedAddresses`.
+
+    SuccessOrExit(error = Get<Tmf::Agent>().GetDispatchingRequest(requestMsg));
+    SuccessOrExit(error = Ip6AddressesTlv::FindIn(*requestMsg, registeredAddresses));
+
+    for (const Ip6::Address &addr : failedAddresses)
+    {
+        registeredAddresses.RemoveMatching(addr);
+    }
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Update the registration state of addresses on `ThreadNetif` and
+    // `ChildTable`.
+
+#if OPENTHREAD_CONFIG_MLR_ENABLE
+    for (Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
+    {
+        if (addr.IsMlrCandidate() && !addr.IsMlrRegistered() && registeredAddresses.Contains(addr.GetAddress()))
+        {
+            addr.SetMlrRegistered(true);
+        }
+    }
+#endif
+
+#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
+    for (Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
+    {
+        for (const Ip6::Address &addr : child.GetIp6Addresses())
+        {
+            if (addr.IsMulticastLargerThanRealmLocal() && !child.HasMlrRegisteredAddress(addr) &&
+                registeredAddresses.Contains(addr))
+            {
+                child.SetAddressMlrRegistrationState(addr, true);
+            }
+        }
+    }
+#endif
+
+exit:
+    if ((error == kErrorNone) && (status == kStatusSuccess))
+    {
+        SendNextRequest();
     }
     else
     {
-        BackboneRouter::Config config;
-        uint16_t               reregDelay;
+        // If a registration attempt fails, retry it after a random
+        // delay (same as re-registration delay).
 
-        // The Device has just attempted a Multicast Listener Registration which failed, and it retries the same
-        // registration with a random time delay chosen in the interval [0, Reregistration Delay].
-        // This is required by Thread 1.2 Specification 5.24.2.3
-        if (Get<BackboneRouter::Leader>().GetConfig(config) == kErrorNone)
-        {
-            reregDelay = config.mReregistrationDelay > 1
-                             ? Random::NonCrypto::GetUint16InRange(1, config.mReregistrationDelay)
-                             : 1;
-            ScheduleSend(reregDelay);
-        }
+        ScheduleTimerForReregistrationDelay();
     }
 }
 
-Error MlrManager::ParseMlrResponse(Error aResult, Coap::Msg *aMsg, uint8_t &aStatus, AddressArray &aFailedAddresses)
+Error Manager::ParseResponse(Error aResult, Coap::Msg *aMsg, uint8_t &aStatus, AddressArray &aFailedAddresses)
 {
-    Error       error;
-    OffsetRange offsetRange;
+    Error error = aResult;
 
-    aStatus = kMlrGeneralFailure;
+    aStatus = kStatusGeneralFailure;
+    aFailedAddresses.Clear();
 
-    VerifyOrExit(aResult == kErrorNone && aMsg != nullptr, error = kErrorParse);
+    SuccessOrExit(error);
+    VerifyOrExit(aMsg != nullptr, error = kErrorParse);
     VerifyOrExit(aMsg->GetCode() == Coap::kCodeChanged, error = kErrorParse);
 
     SuccessOrExit(error = Tlv::Find<ThreadStatusTlv>(aMsg->mMessage, aStatus));
 
-    if (ThreadTlv::FindTlvValueOffsetRange(aMsg->mMessage, Ip6AddressesTlv::kIp6Addresses, offsetRange) == kErrorNone)
+    switch (error = Ip6AddressesTlv::FindIn(aMsg->mMessage, aFailedAddresses))
     {
-        VerifyOrExit(offsetRange.GetLength() % sizeof(Ip6::Address) == 0, error = kErrorParse);
-        VerifyOrExit(offsetRange.GetLength() / sizeof(Ip6::Address) <= Ip6AddressesTlv::kMaxAddresses,
-                     error = kErrorParse);
-
-        while (!offsetRange.IsEmpty())
-        {
-            IgnoreError(aMsg->mMessage.Read(offsetRange, *aFailedAddresses.PushBack()));
-            offsetRange.AdvanceOffset(sizeof(Ip6::Address));
-        }
+    case kErrorNone:
+        break;
+    case kErrorNotFound:
+        error = kErrorNone;
+        aFailedAddresses.Clear();
+        break;
+    default:
+        ExitNow();
     }
 
-    VerifyOrExit(aFailedAddresses.IsEmpty() || aStatus != kMlrSuccess, error = kErrorParse);
-
-exit:
-    LogMlrResponse(aResult, error, aStatus, aFailedAddresses);
-    return aResult != kErrorNone ? aResult : error;
-}
-
-void MlrManager::SetMulticastAddressMlrState(MlrState aFromState, MlrState aToState)
-{
-#if OPENTHREAD_CONFIG_MLR_ENABLE
-    for (Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
+    if (aStatus == kStatusSuccess)
     {
-        if (addr.Matches(aFromState))
-        {
-            addr.SetMlrState(aToState);
-        }
+        VerifyOrExit(aFailedAddresses.IsEmpty(), error = kErrorParse);
+
+        LogInfo("Receive MLR.rsp OK");
+        ExitNow();
     }
-#endif
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    for (Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
-    {
-        for (Child::Ip6AddrEntry &addrEntry : child.GetIp6Addresses())
-        {
-            if (!addrEntry.IsMulticastLargerThanRealmLocal())
-            {
-                continue;
-            }
-
-            if (addrEntry.GetMlrState(child) == aFromState)
-            {
-                addrEntry.SetMlrState(aToState, child);
-            }
-        }
-    }
-#endif
-}
-
-void MlrManager::FinishMlr(bool aSuccess, const AddressArray &aFailedAddresses)
-{
-    OT_ASSERT(mMlrPending);
-
-    mMlrPending = false;
-
-#if OPENTHREAD_CONFIG_MLR_ENABLE
-    for (Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
-    {
-        if (addr.Matches(kMlrStateRegistering))
-        {
-            bool success = aSuccess || !aFailedAddresses.IsEmptyOrContains(addr.GetAddress());
-
-            addr.SetMlrState(success ? kMlrStateRegistered : kMlrStateToRegister);
-        }
-    }
-#endif
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    for (Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
-    {
-        for (Child::Ip6AddrEntry &addrEntry : child.GetIp6Addresses())
-        {
-            if (!addrEntry.IsMulticastLargerThanRealmLocal())
-            {
-                continue;
-            }
-
-            if (addrEntry.GetMlrState(child) == kMlrStateRegistering)
-            {
-                bool success = aSuccess || !aFailedAddresses.IsEmptyOrContains(addrEntry);
-
-                addrEntry.SetMlrState(success ? kMlrStateRegistered : kMlrStateToRegister, child);
-            }
-        }
-    }
-#endif
-
-    LogMulticastAddresses();
-    CheckInvariants();
-}
-
-void MlrManager::HandleTimeTick(void)
-{
-    if (mSendDelay > 0 && --mSendDelay == 0)
-    {
-        SendMlr();
-    }
-
-    if (mReregistrationDelay > 0 && --mReregistrationDelay == 0)
-    {
-        Reregister();
-    }
-
-    UpdateTimeTickerRegistration();
-}
-
-void MlrManager::Reregister(void)
-{
-    LogInfo("MLR Reregister!");
-
-    SetMulticastAddressMlrState(kMlrStateRegistered, kMlrStateToRegister);
-    CheckInvariants();
-
-    ScheduleSend(0);
-
-    // Schedule for the next renewing.
-    UpdateReregistrationDelay(false);
-}
-
-void MlrManager::UpdateReregistrationDelay(bool aRereg)
-{
-    bool needSendMlr = (Get<Mle::Mle>().IsFullThreadDevice() || Get<Mle::Mle>().GetParent().IsThreadVersion1p1()) &&
-                       Get<BackboneRouter::Leader>().HasPrimary();
-
-    if (!needSendMlr)
-    {
-        mReregistrationDelay = 0;
-    }
-    else
-    {
-        BackboneRouter::Config config;
-        uint32_t               reregDelay;
-        uint32_t               effectiveMlrTimeout;
-
-        IgnoreError(Get<BackboneRouter::Leader>().GetConfig(config));
-
-        if (aRereg)
-        {
-            reregDelay = config.mReregistrationDelay > 1
-                             ? Random::NonCrypto::GetUint16InRange(1, config.mReregistrationDelay)
-                             : 1;
-        }
-        else
-        {
-            // Calculate renewing period according to Thread Spec. 5.24.2.3.2
-            // The random time t SHOULD be chosen such that (0.5* MLR-Timeout) < t < (MLR-Timeout – 9 seconds).
-            effectiveMlrTimeout = Max(config.mMlrTimeout, BackboneRouter::kMinMlrTimeout);
-            reregDelay = Random::NonCrypto::GetUint32InRange((effectiveMlrTimeout >> 1u) + 1, effectiveMlrTimeout - 9);
-        }
-
-        if (mReregistrationDelay == 0 || mReregistrationDelay > reregDelay)
-        {
-            mReregistrationDelay = reregDelay;
-        }
-    }
-
-    UpdateTimeTickerRegistration();
-
-    LogDebg("MlrManager::UpdateReregistrationDelay: rereg=%d, needSendMlr=%d, ReregDelay=%lu", aRereg, needSendMlr,
-            ToUlong(mReregistrationDelay));
-}
-
-void MlrManager::LogMulticastAddresses(void)
-{
-#if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_DEBG)
-    LogDebg("-------- Multicast Addresses --------");
-
-#if OPENTHREAD_CONFIG_MLR_ENABLE
-    for (const Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
-    {
-        if (!addr.IsMlrCandidate())
-        {
-            continue;
-        }
-
-        LogDebg("%-32s%c", addr.GetAddress().ToString().AsCString(), "-rR"[addr.GetMlrState()]);
-    }
-#endif
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    for (const Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
-    {
-        for (const Child::Ip6AddrEntry &addrEntry : child.GetIp6Addresses())
-        {
-            if (!addrEntry.IsMulticastLargerThanRealmLocal())
-            {
-                continue;
-            }
-
-            LogDebg("%-32s%c %04x", addrEntry.ToString().AsCString(), "-rR"[addrEntry.GetMlrState(child)],
-                    child.GetRloc16());
-        }
-    }
-#endif
-
-#endif // OT_SHOULD_LOG_AT(OT_LOG_LEVEL_DEBG)
-}
-
-void MlrManager::AddressArray::AddUnique(const Ip6::Address &aAddress)
-{
-    if (!Contains(aAddress))
-    {
-        IgnoreError(PushBack(aAddress));
-    }
-}
-
-void MlrManager::LogMlrResponse(Error aResult, Error aError, uint8_t aStatus, const AddressArray &aFailedAddresses)
-{
-    OT_UNUSED_VARIABLE(aResult);
-    OT_UNUSED_VARIABLE(aError);
-    OT_UNUSED_VARIABLE(aStatus);
-    OT_UNUSED_VARIABLE(aFailedAddresses);
 
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_WARN)
-    if (aResult == kErrorNone && aError == kErrorNone && aStatus == kMlrSuccess)
-    {
-        LogInfo("Receive MLR.rsp OK");
-    }
-    else
-    {
-        LogWarn("Receive MLR.rsp: result=%s, error=%s, status=%d, failedAddressNum=%d", ErrorToString(aResult),
-                ErrorToString(aError), aStatus, aFailedAddresses.GetLength());
+    LogWarn("Receive MLR.rsp: status=%u, failedAddressNum=%u", aStatus, aFailedAddresses.GetLength());
 
-        for (const Ip6::Address &address : aFailedAddresses)
-        {
-            LogWarn("MA failed: %s", address.ToString().AsCString());
-        }
+    for (const Ip6::Address &address : aFailedAddresses)
+    {
+        LogWarn("   %s", address.ToString().AsCString());
     }
 #endif
+
+exit:
+    if (error != kErrorNone)
+    {
+        aFailedAddresses.Clear();
+    }
+
+    LogWarnOnError(error, "parse MLR.rsp");
+    return error;
 }
 
-void MlrManager::CheckInvariants(void) const
+void Manager::ScheduleTimerForReregistrationDelay(void)
 {
-#if OPENTHREAD_EXAMPLES_SIMULATION && OPENTHREAD_CONFIG_ASSERT_ENABLE
-    uint16_t registeringNum = 0;
-
-    OT_UNUSED_VARIABLE(registeringNum);
-
-    OT_ASSERT(!mMlrPending || mSendDelay == 0);
-
-#if OPENTHREAD_CONFIG_MLR_ENABLE
-    for (Ip6::Netif::MulticastAddress &addr : Get<ThreadNetif>().GetMulticastAddresses())
-    {
-        if (addr.Matches(kMlrStateRegistering))
-        {
-            registeringNum++;
-        }
-    }
-#endif
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    for (const Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
-    {
-        for (const Child::Ip6AddrEntry &addrEntry : child.GetIp6Addresses())
-        {
-            if (!addrEntry.IsMulticastLargerThanRealmLocal())
-            {
-                continue;
-            }
-
-            registeringNum += (addrEntry.GetMlrState(child) == kMlrStateRegistering);
-        }
-    }
-#endif
-
-    OT_ASSERT(registeringNum == 0 || mMlrPending);
-#endif // OPENTHREAD_EXAMPLES_SIMULATION
+    mTimer.Start(Time::SecToMsec(Get<BackboneRouter::Leader>().GetConfig().SelectRandomReregistrationDelay()));
 }
 
+void Manager::HandleTimer(void)
+{
+    switch (GetState())
+    {
+    case kStateNewAddrToRegister:
+        if (mRenewTime > TimerMilli::GetNow())
+        {
+            break;
+        }
+
+        OT_FALL_THROUGH;
+
+    case kStateRegistered:
+        EnterState(kStateToRegisterAll);
+
+        OT_FALL_THROUGH;
+
+    case kStateToRegisterAll:
+    case kStateRegistering:
+        break;
+
+    case kStateStopped:
+    case kStateIdle:
+        ExitNow();
+    }
+
+    SendNextRequest();
+
+exit:
+    return;
+}
+
+void Manager::DetermineRenewTime(void)
+{
+    // As per Thread spec, the renew delay is randomly chosen
+    // between (0.5 * MLR-Timeout) and (MLR-Timeout - 9 seconds).
+    // The `RenewGuardTime`(9 sec) allows time for transmission,
+    // potential retransmissions, and acknowledgment before the
+    // actual timeout.
+
+    uint32_t timeout;
+
+    timeout = Get<BackboneRouter::Leader>().GetConfig().GetMlrTimeout();
+    timeout = Time::SecToMsec(Clamp<uint32_t>(timeout, BackboneRouter::kMinMlrTimeout, kLongRenewTimeout));
+
+    mRenewTime =
+        mStartTime + Random::NonCrypto::GenerateInClosedRange((timeout / 2) + 1, timeout - kRenewGuardTimeInMsec);
+}
+
+} // namespace Mlr
 } // namespace ot
 
 #endif // OPENTHREAD_CONFIG_MLR_ENABLE
diff --git a/src/core/thread/mlr_manager.hpp b/src/core/thread/mlr_manager.hpp
index 428c625..ffb2cf0 100644
--- a/src/core/thread/mlr_manager.hpp
+++ b/src/core/thread/mlr_manager.hpp
@@ -49,14 +49,15 @@
 #include "common/locator.hpp"
 #include "common/non_copyable.hpp"
 #include "common/notifier.hpp"
-#include "common/time_ticker.hpp"
 #include "common/timer.hpp"
 #include "net/netif.hpp"
 #include "thread/child.hpp"
+#include "thread/mlr_types.hpp"
 #include "thread/thread_tlvs.hpp"
 #include "thread/tmf.hpp"
 
 namespace ot {
+namespace Mlr {
 
 /**
  * @addtogroup core-mlr
@@ -74,41 +75,42 @@
 /**
  * Implements MLR management.
  */
-class MlrManager : public InstanceLocator, private NonCopyable
+class Manager : public InstanceLocator, private NonCopyable
 {
     friend class ot::Notifier;
-    friend class ot::TimeTicker;
 
 public:
-    typedef otIp6RegisterMulticastListenersCallback MlrCallback;
+    typedef otIp6RegisterMulticastListenersCallback RegisterCallback;
 
     /**
      * Initializes the object.
      *
      * @param[in]  aInstance     A reference to the OpenThread instance.
      */
-    explicit MlrManager(Instance &aInstance);
+    explicit Manager(Instance &aInstance);
 
     /**
-     * Notifies Primary Backbone Router status.
+     * Notifies the `MlrManager` of a Primary Backbone Router event.
      *
-     * @param[in]  aState   The state or state change of Primary Backbone Router.
-     * @param[in]  aConfig  The Primary Backbone Router service.
+     * @param[in]  aEvent   The Primary Backbone Router event.
      */
-    void HandleBackboneRouterPrimaryUpdate(BackboneRouter::Leader::State aState, const BackboneRouter::Config &aConfig);
+    void HandleBackboneRouterPrimaryUpdate(BackboneRouter::PrimaryEvent aEvent);
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    static constexpr uint16_t kMaxMlrAddresses = OPENTHREAD_CONFIG_MLE_IP_ADDRS_PER_CHILD - 1; ///< Max MLR addresses
-
-    typedef Array<Ip6::Address, kMaxMlrAddresses> MlrAddressArray; ///< Registered MLR addresses array.
+    /**
+     * Updates the MLR registration status of a given child's addresses.
+     *
+     * @param[in]  aChild  The child to update.
+     */
+    void UpdateChildRegistrations(Child &aChild);
 
     /**
-     * Updates the Multicast Subscription Table according to the Child information.
+     * Updates the MLR registration status of a given child's addresses.
      *
-     * @param[in]  aChild                       A reference to the child information.
-     * @param[in]  aOldMlrRegisteredAddresses   Array of the Child's previously registered IPv6 addresses.
+     * @param[in]  aChild                    The child to update.
+     * @param[in]  aOldRegisteredAddresses   Child's previously registered addresses.
      */
-    void UpdateProxiedSubscriptions(Child &aChild, const MlrAddressArray &aOldMlrRegisteredAddresses);
+    void UpdateChildRegistrations(Child &aChild, const Child::Ip6AddressArray &aOldRegisteredAddresses);
 #endif
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
@@ -136,74 +138,81 @@
     Error RegisterMulticastListeners(const Ip6::Address *aAddresses,
                                      uint8_t             aAddressNum,
                                      const uint32_t     *aTimeout,
-                                     MlrCallback         aCallback,
+                                     RegisterCallback    aCallback,
                                      void               *aContext);
 #endif
 
 private:
-    class AddressArray : public Array<Ip6::Address, Ip6AddressesTlv::kMaxAddresses>
+    // Delays (in msec) applied before registration attempts when new
+    // `Netif` or child multicast addresses are added. The longer
+    // delay for child addresses allows the parent to aggregate
+    // multiple address updates into a single MLR request.
+    static constexpr uint32_t kMaxNewNetifAddrRegistraionDelay  = 100;
+    static constexpr uint32_t kMinNewChildAddrRegistrationDelay = 750;
+    static constexpr uint32_t kMaxNewChildAddrRegistrationDelay = 5000;
+
+    static constexpr uint32_t kLongRenewTimeout      = 4 * Time::kOneHourInSec;    // `MLR_TIMEOUT_LONG` (in sec)
+    static constexpr uint32_t kRenewGuardTimeInMsec  = 9 * Time::kOneSecondInMsec; // (in msec)
+    static constexpr uint32_t kSendFailureRetryDelay = 1000;                       // (in msec)
+
+    enum State : uint8_t
     {
-    public:
-        bool IsEmptyOrContains(const Ip6::Address &aAddress) const { return IsEmpty() || Contains(aAddress); }
-        void AddUnique(const Ip6::Address &aAddress);
+        kStateStopped,           // Manager is stopped (e.g., no PBBR).
+        kStateIdle,              // Started but has no multicast addresses to register.
+        kStateToRegisterAll,     // Waiting to register (or re-register) all multicast addresses.
+        kStateRegistering,       // MLR.req is sent, waiting for MLR.rsp, or waiting to retry.
+        kStateRegistered,        // All addresses are registered, waiting for periodic renewal.
+        kStateNewAddrToRegister, // All were registered, but new addresses are pending registration.
     };
 
-    void HandleNotifierEvents(Events aEvents);
+    State GetState(void) const { return mState; }
+    bool  IsRunning(void) const { return mState != kStateStopped; }
+    void  EnterState(State aState);
+    void  UpdateState(void);
+    void  HandleNotifierEvents(Events aEvents);
+    void  DetermineAddressesToRegister(AddressArray &aAddresses) const;
+    void  SendNextRequest(void);
+    void  DetermineRenewTime(void);
+    void  ScheduleTimerForReregistrationDelay(void);
+    void  ScheduleNewAddrRegistration(uint32_t aMinDelay, uint32_t aMaxDelay);
+    void  ProcessResponse(Coap::Msg *aMsg, Error aResult);
+    void  HandleTimer(void);
+    Error SendMessage(const Ip6::Address   *aAddresses,
+                      uint8_t               aAddressNum,
+                      const uint32_t       *aTimeout,
+                      Coap::ResponseHandler aResponseHandler);
 
-    void  SendMlr(void);
-    Error SendMlrMessage(const Ip6::Address   *aAddresses,
-                         uint8_t               aAddressNum,
-                         const uint32_t       *aTimeout,
-                         Coap::ResponseHandler aResponseHandler,
-                         void                 *aContext);
+    DeclareTmfResponseHandlerIn(Manager, HandleResponse);
 
-    DeclareTmfResponseHandlerIn(MlrManager, HandleMlrResponse);
-
-    static Error ParseMlrResponse(Error aResult, Coap::Msg *aMsg, uint8_t &aStatus, AddressArray &aFailedAddresses);
+    static Error ParseResponse(Error aResult, Coap::Msg *aMsg, uint8_t &aStatus, AddressArray &aFailedAddresses);
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
-    DeclareTmfResponseHandlerIn(MlrManager, HandleRegisterResponse);
+    DeclareTmfResponseHandlerIn(Manager, HandleRegisterResponse);
 #endif
 
 #if OPENTHREAD_CONFIG_MLR_ENABLE
-    void UpdateLocalSubscriptions(void);
-    bool IsAddressMlrRegisteredByNetif(const Ip6::Address &aAddress) const;
+    void HandleEventIp6MulticastSubscribed(void);
+    bool IsAddressRegisteredByNetif(const Ip6::Address &aAddress) const;
 #endif
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE
-    bool IsAddressMlrRegisteredByAnyChild(const Ip6::Address &aAddress) const
-    {
-        return IsAddressMlrRegisteredByAnyChildExcept(aAddress, nullptr);
-    }
-    bool IsAddressMlrRegisteredByAnyChildExcept(const Ip6::Address &aAddress, const Child *aExceptChild) const;
+    bool IsAddressRegisteredByAnyChild(const Ip6::Address &aAddress) const;
+    bool IsAddressRegisteredByAnyChildExcept(const Ip6::Address &aAddress, const Child *aExceptChild) const;
 #endif
 
-    void SetMulticastAddressMlrState(MlrState aFromState, MlrState aToState);
-    void FinishMlr(bool aSuccess, const AddressArray &aFailedAddresses);
-
-    void ScheduleSend(uint16_t aDelay);
-    void UpdateTimeTickerRegistration(void);
-    void UpdateReregistrationDelay(bool aRereg);
-    void Reregister(void);
-    void HandleTimeTick(void);
-
-    void        LogMulticastAddresses(void);
-    void        CheckInvariants(void) const;
-    static void LogMlrResponse(Error aResult, Error aError, uint8_t aStatus, const AddressArray &aFailedAddresses);
+    using DelayTimer = TimerMilliIn<Manager, &Manager::HandleTimer>;
 
 #if (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE) && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
-    Callback<MlrCallback> mRegisterCallback;
+    Callback<RegisterCallback> mRegisterCallback;
+    bool                       mRegisterPending;
 #endif
-
-    uint32_t mReregistrationDelay;
-    uint16_t mSendDelay;
-
-    bool mMlrPending : 1;
-#if (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE) && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
-    bool mRegisterPending : 1;
-#endif
+    State      mState;
+    TimeMilli  mStartTime;
+    TimeMilli  mRenewTime;
+    DelayTimer mTimer;
 };
 
+} // namespace Mlr
 } // namespace ot
 
 #endif // OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
diff --git a/src/core/thread/mlr_types.cpp b/src/core/thread/mlr_types.cpp
new file mode 100644
index 0000000..8c6dc24
--- /dev/null
+++ b/src/core/thread/mlr_types.cpp
@@ -0,0 +1,52 @@
+/*
+ *  Copyright (c) 2020-2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file implements MLR types.
+ */
+
+#include "mlr_types.hpp"
+
+namespace ot {
+namespace Mlr {
+
+Error AddressArray::AddUnique(const Ip6::Address &aAddress)
+{
+    Error error = kErrorNone;
+
+    if (!Contains(aAddress))
+    {
+        error = PushBack(aAddress);
+    }
+
+    return error;
+}
+
+} // namespace Mlr
+} // namespace ot
diff --git a/src/core/thread/mlr_types.hpp b/src/core/thread/mlr_types.hpp
index fd91ebe..59c89e4 100644
--- a/src/core/thread/mlr_types.hpp
+++ b/src/core/thread/mlr_types.hpp
@@ -36,7 +36,12 @@
 
 #include "openthread-core-config.h"
 
+#include "common/array.hpp"
+#include "common/error.hpp"
+#include "net/ip6_address.hpp"
+
 namespace ot {
+namespace Mlr {
 
 /**
  * @addtogroup core-mlr
@@ -44,34 +49,42 @@
  * @{
  */
 
-#if OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
-
-/**
- * Multicast Listener Registration state for multicast addresses.
- */
-enum MlrState : uint8_t
-{
-    kMlrStateToRegister,  ///< The multicast address is to be registered.
-    kMlrStateRegistering, ///< The multicast address is being registered.
-    kMlrStateRegistered,  ///< The multicast address is registered.
-};
-
-#endif // OPENTHREAD_CONFIG_MLR_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE)
+constexpr uint8_t kMinIp6Addresses = 1;                        ///< Min number of addresses in IPv6 Addresses TLV.
+constexpr uint8_t kMaxIp6Addresses = OT_IP6_MAX_MLR_ADDRESSES; ///< Max number of addresses in IPv6 Addresses TLV.
 
 /**
  * Multicast Listener Registration (MLR) Status values.
  */
-enum MlrStatus
+enum Status : uint8_t
 {
-    kMlrSuccess        = 0, ///< Successful (de)registration of all IPv6 addresses.
-    kMlrInvalid        = 2, ///< Invalid IPv6 address(es) in request.
-    kMlrNoPersistent   = 3, ///< This device does not support persistent registrations.
-    kMlrNoResources    = 4, ///< BBR resource shortage.
-    kMlrBbrNotPrimary  = 5, ///< BBR is not Primary at this moment.
-    kMlrGeneralFailure = 6, ///< Reason(s) for failure are not further specified.
-    kMlrStatusMax      = 6, ///< Max MLR status.
+    kStatusSuccess        = 0, ///< Successful (de)registration of all IPv6 addresses.
+    kStatusInvalid        = 2, ///< Invalid IPv6 address(es) in request.
+    kStatusNoPersistent   = 3, ///< This device does not support persistent registrations.
+    kStatusNoResources    = 4, ///< BBR resource shortage.
+    kStatusBbrNotPrimary  = 5, ///< BBR is not Primary at this moment.
+    kStatusGeneralFailure = 6, ///< Reason(s) for failure are not further specified.
 };
 
+constexpr uint8_t kMaxStatusValue = kStatusGeneralFailure;
+
+/**
+ * Represents an array of IPv6 addresses.
+ */
+class AddressArray : public Array<Ip6::Address, kMaxIp6Addresses>
+{
+public:
+    /**
+     * Adds an IPv6 address to the array if it is not already present.
+     *
+     * @param[in] aAddress  The IPv6 address to add.
+     *
+     * @retval kErrorNone    Successfully added the address or it was already present.
+     * @retval kErrorNoBufs  The array is full.
+     */
+    Error AddUnique(const Ip6::Address &aAddress);
+};
+
+} // namespace Mlr
 } // namespace ot
 
 #endif // OT_CORE_THREAD_MLR_TYPES_HPP_
diff --git a/src/core/thread/neighbor.cpp b/src/core/thread/neighbor.cpp
index 9fceb27..77c7b1a 100644
--- a/src/core/thread/neighbor.cpp
+++ b/src/core/thread/neighbor.cpp
@@ -60,7 +60,7 @@
 {
     bool matches = false;
 
-    VerifyOrExit(aNeighbor.MatchesFilter(mStateFilter));
+    VerifyOrExit(aNeighbor.Matches(mStateFilter));
 
     if (mShortAddress != Mac::kShortAddrInvalid)
     {
@@ -130,7 +130,7 @@
     return rval;
 }
 
-bool Neighbor::MatchesFilter(StateFilter aFilter) const
+bool Neighbor::Matches(StateFilter aFilter) const
 {
     bool matches = false;
 
diff --git a/src/core/thread/neighbor.hpp b/src/core/thread/neighbor.hpp
index 2d9b8b9..5e621b4 100644
--- a/src/core/thread/neighbor.hpp
+++ b/src/core/thread/neighbor.hpp
@@ -289,7 +289,7 @@
      *
      * @returns TRUE if the neighbor state matches the filter, FALSE otherwise.
      */
-    bool MatchesFilter(StateFilter aFilter) const;
+    bool Matches(StateFilter aFilter) const;
 
     /**
      * Indicates whether neighbor matches a given `AddressMatcher`.
diff --git a/src/core/thread/neighbor_table.cpp b/src/core/thread/neighbor_table.cpp
index 490ca8e..a8257b0 100644
--- a/src/core/thread/neighbor_table.cpp
+++ b/src/core/thread/neighbor_table.cpp
@@ -197,7 +197,7 @@
     return neighbor;
 }
 
-Error NeighborTable::GetNextNeighborInfo(otNeighborInfoIterator &aIterator, Neighbor::Info &aNeighInfo)
+Error NeighborTable::GetNextNeighborInfo(Iterator &aIterator, Neighbor::Info &aNeighInfo)
 {
     Error   error = kErrorNone;
     int16_t index;
@@ -230,7 +230,7 @@
 
     // Negative iterator value gives the current index into mRouters array
 
-    for (index = -aIterator; index <= Mle::kMaxRouterId; index++)
+    for (index = static_cast<int16_t>(-aIterator); index <= Mle::kMaxRouterId; index++)
     {
         Router *router = Get<RouterTable>().FindRouterById(static_cast<uint8_t>(index));
 
@@ -239,12 +239,12 @@
             aNeighInfo.SetFrom(*router);
             aNeighInfo.mIsChild = false;
             index++;
-            aIterator = -index;
+            aIterator = static_cast<Iterator>(-index);
             ExitNow();
         }
     }
 
-    aIterator = -index;
+    aIterator = static_cast<Iterator>(-index);
     error     = kErrorNotFound;
 
 exit:
@@ -255,11 +255,11 @@
 
 #if OPENTHREAD_MTD
 
-Error NeighborTable::GetNextNeighborInfo(otNeighborInfoIterator &aIterator, Neighbor::Info &aNeighInfo)
+Error NeighborTable::GetNextNeighborInfo(Iterator &aIterator, Neighbor::Info &aNeighInfo)
 {
     Error error = kErrorNotFound;
 
-    VerifyOrExit(aIterator == OT_NEIGHBOR_INFO_ITERATOR_INIT);
+    VerifyOrExit(aIterator == kIteratorInit);
 
     aIterator++;
     VerifyOrExit(Get<Mle::Mle>().GetParent().IsStateValid());
@@ -323,10 +323,6 @@
 
     case kChildRemoved:
         Get<Notifier>().Signal(kEventThreadChildRemoved);
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE
-        Get<DuaManager>().HandleChildDuaAddressEvent(static_cast<const Child &>(aNeighbor),
-                                                     DuaManager::kAddressRemoved);
-#endif
         break;
 
 #if OPENTHREAD_FTD
diff --git a/src/core/thread/neighbor_table.hpp b/src/core/thread/neighbor_table.hpp
index a1df4d7..8ff3167 100644
--- a/src/core/thread/neighbor_table.hpp
+++ b/src/core/thread/neighbor_table.hpp
@@ -64,6 +64,16 @@
     typedef otNeighborTableEntryInfo EntryInfo;
 
     /**
+     * Iterator used to iterate through neighbor table.
+     */
+    typedef otNeighborInfoIterator Iterator;
+
+    /**
+     * Initializer value for `Iterator`.
+     */
+    static constexpr Iterator kIteratorInit = OT_NEIGHBOR_INFO_ITERATOR_INIT;
+
+    /**
      * Defines the constants used in `NeighborTable::Callback` to indicate whether a child or router
      * neighbor is being added or removed.
      */
@@ -196,13 +206,13 @@
      * the neighbor table.
      *
      * @param[in,out]  aIterator  A reference to the iterator context. To get the first neighbor entry
-                                  it should be set to OT_NEIGHBOR_INFO_ITERATOR_INIT.
+                                  it should be set to `kIteratorInit`.
      * @param[out]     aNeighInfo The neighbor information.
      *
      * @retval kErrorNone         Successfully found the next neighbor entry in table.
      * @retval kErrorNotFound     No subsequent neighbor entry exists in the table.
      */
-    Error GetNextNeighborInfo(otNeighborInfoIterator &aIterator, Neighbor::Info &aNeighInfo);
+    Error GetNextNeighborInfo(Iterator &aIterator, Neighbor::Info &aNeighInfo);
 
     /**
      * Registers the "neighbor table changed" callback function.
@@ -234,6 +244,8 @@
     Callback mCallback;
 };
 
+DefineMapEnum(otNeighborTableEvent, NeighborTable::Event);
+
 } // namespace ot
 
 #endif // OT_CORE_THREAD_NEIGHBOR_TABLE_HPP_
diff --git a/src/core/thread/network_diagnostic.cpp b/src/core/thread/net_diag.cpp
similarity index 62%
rename from src/core/thread/network_diagnostic.cpp
rename to src/core/thread/net_diag.cpp
index df31a07..36117cd 100644
--- a/src/core/thread/network_diagnostic.cpp
+++ b/src/core/thread/net_diag.cpp
@@ -31,7 +31,7 @@
  *   This file implements Thread's Network Diagnostic processing.
  */
 
-#include "network_diagnostic.hpp"
+#include "net_diag.hpp"
 
 #include "instance/instance.hpp"
 
@@ -39,13 +39,16 @@
 
 RegisterLogModule("NetDiag");
 
-namespace NetworkDiagnostic {
+namespace NetDiag {
 
 //---------------------------------------------------------------------------------------------------------------------
 // Server
 
 Server::Server(Instance &aInstance)
     : InstanceLocator(aInstance)
+#if OPENTHREAD_FTD
+    , mAnswerSender(aInstance, kAnswerTypeNetDiag)
+#endif
     , mNonPreferredChannels(0)
 {
 }
@@ -105,20 +108,20 @@
 {
     Error                 error = kErrorNone;
     Tlv::Bookmark         tlvBookmark;
-    Mle::RouterIdSet      routerIdSet;
+    Mle::RouterIdMask     routerIdMask;
     EnhancedRouteTlvEntry entry;
 
     VerifyOrExit(Get<Mle::Mle>().IsRouterOrLeader());
 
-    Get<RouterTable>().GetRouterIdSet(routerIdSet);
+    Get<RouterTable>().GetRouterIdMask(routerIdMask);
 
     SuccessOrExit(error = Tlv::StartTlv(aMessage, Tlv::kEnhancedRoute, tlvBookmark));
 
-    SuccessOrExit(error = aMessage.Append(routerIdSet));
+    SuccessOrExit(error = routerIdMask.AppendMaskTo(aMessage));
 
     for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
     {
-        if (!routerIdSet.Contains(routerId))
+        if (!routerIdMask.IsAllocated(routerId))
         {
             continue;
         }
@@ -144,14 +147,14 @@
 #if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
 Error Server::AppendChildTableAsChildTlvs(Message &aMessage)
 {
-    Error    error = kErrorNone;
-    ChildTlv childTlv;
+    Error         error = kErrorNone;
+    ChildTlvValue childTlvValue;
 
     for (Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
     {
-        childTlv.InitFrom(child);
+        childTlvValue.InitFrom(child);
 
-        SuccessOrExit(error = childTlv.AppendTo(aMessage));
+        SuccessOrExit(error = Tlv::Append<ChildTlv>(aMessage, childTlvValue));
     }
 
     error = Tlv::AppendEmpty<ChildTlv>(aMessage);
@@ -162,15 +165,15 @@
 
 Error Server::AppendRouterNeighborTlvs(Message &aMessage)
 {
-    Error             error = kErrorNone;
-    RouterNeighborTlv neighborTlv;
+    Error                  error = kErrorNone;
+    RouterNeighborTlvValue neighborTlvValue;
 
     for (Router &router : Get<RouterTable>())
     {
         if (router.IsStateValid())
         {
-            neighborTlv.InitFrom(router);
-            SuccessOrExit(error = neighborTlv.AppendTo(aMessage));
+            neighborTlvValue.InitFrom(router);
+            SuccessOrExit(error = Tlv::Append<RouterNeighborTlv>(aMessage, neighborTlvValue));
         }
     }
 
@@ -254,7 +257,7 @@
         ExitNow();
     }
 
-    IgnoreError(netPrefix.SetFrom(prefix));
+    IgnoreError(netPrefix.InitFrom(prefix));
 
 exit:
     return Tlv::AppendTlv(aMessage, aTlvType, &netPrefix, sizeof(netPrefix));
@@ -280,7 +283,7 @@
 }
 
 #if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
-Error Server::AppendRequestedTlvsForTcat(const Message &aRequest, Message &aResponse, OffsetRange &aOffsetRange)
+Error Server::AppendRequestedTlvsForTcat(const Message &aRequest, Message &aResponse, const OffsetRange &aOffsetRange)
 {
     Error               error = kErrorNone;
     TlvTypeListIterator iterator;
@@ -371,19 +374,19 @@
 
     case Tlv::kMacCounters:
     {
-        MacCountersTlv tlv;
+        MacCountersTlvValue tlvValue;
 
-        tlv.Init(Get<Mac::Mac>().GetCounters());
-        error = tlv.AppendTo(aMessage);
+        tlvValue.InitFrom(Get<Mac::Mac>().GetCounters());
+        error = Tlv::Append<MacCountersTlv>(aMessage, tlvValue);
         break;
     }
 
     case Tlv::kMleCounters:
     {
-        MleCountersTlv tlv;
+        MleCountersTlvValue tlvValue;
 
-        tlv.Init(Get<Mle::Mle>().GetCounters());
-        error = tlv.AppendTo(aMessage);
+        tlvValue.InitFrom(Get<Mle::Mle>().GetCounters());
+        error = Tlv::Append<MleCountersTlv>(aMessage, tlvValue);
         break;
     }
 
@@ -403,6 +406,15 @@
         error = Tlv::Append<VendorAppUrlTlv>(aMessage, Get<VendorInfo>().GetAppUrl());
         break;
 
+    case Tlv::kVendorOui:
+    {
+        const VendorInfo::Oui &oui = Get<VendorInfo>().GetOui();
+
+        VerifyOrExit(oui.IsValid());
+        error = Tlv::Append<VendorOuiTlv>(aMessage, oui.GetBytes(), oui.GetSize());
+        break;
+    }
+
     case Tlv::kThreadStackVersion:
         error = Tlv::Append<ThreadStackVersionTlv>(aMessage, otGetVersionString());
         break;
@@ -432,14 +444,8 @@
     }
 
     case Tlv::kRoute:
-    {
-        RouteTlv tlv;
-
-        tlv.Init();
-        Get<RouterTable>().FillRouteTlv(tlv);
-        SuccessOrExit(error = tlv.AppendTo(aMessage));
+        SuccessOrExit(error = Get<RouterTable>().AppendRouteTlv(aMessage, Tlv::kRoute));
         break;
-    }
 
     case Tlv::kEnhancedRoute:
         error = AppendEnhancedRoute(aMessage);
@@ -511,7 +517,7 @@
 {
     Error          error  = kErrorNone;
     Coap::Message *answer = nullptr;
-    AnswerTlv      answerTlv;
+    AnswerTlvValue answerTlvValue;
     uint16_t       queryId;
 
     answer = Get<Tmf::Agent>().AllocateAndInitConfirmablePostMessage(kUriDiagnosticGetAnswer);
@@ -526,8 +532,8 @@
 
     SuccessOrExit(error = AppendRequestedTlvs(aRequest, *answer));
 
-    answerTlv.Init(0, AnswerTlv::kIsLast);
-    SuccessOrExit(answer->Append(answerTlv));
+    answerTlvValue.Init(0, AnswerTlvValue::kIsLast);
+    SuccessOrExit(error = Tlv::Append<AnswerTlv>(*answer, answerTlvValue));
 
     error = Get<Tmf::Agent>().SendMessageAllowMulticastLoop(*answer, aDestination);
 
@@ -539,85 +545,24 @@
 
 #if OPENTHREAD_FTD
 
-Error Server::AllocateAnswer(Coap::Message *&aAnswer, AnswerInfo &aInfo)
+void Server::PrepareAndSendAnswers(const Ip6::Address &aDestination, const Coap::Message &aRequest)
 {
-    // Allocate an `Answer` message, adds it in `mAnswerQueue`,
-    // update the `aInfo.mFirstAnswer` if it is the first allocated
-    // messages, and appends `QueryIdTlv` to the message (if needed).
+    AnswerBuilder answerBuilder(GetInstance(), kAnswerTypeNetDiag);
 
-    Error error = kErrorNone;
-
-    aAnswer = Get<Tmf::Agent>().AllocateAndInitConfirmablePostMessage(kUriDiagnosticGetAnswer);
-    VerifyOrExit(aAnswer != nullptr, error = kErrorNoBufs);
-    IgnoreError(aAnswer->SetPriority(aInfo.mPriority));
-
-    mAnswerQueue.Enqueue(*aAnswer);
-
-    if (aInfo.mFirstAnswer == nullptr)
-    {
-        aInfo.mFirstAnswer = aAnswer;
-    }
-
-    if (aInfo.mHasQueryId)
-    {
-        SuccessOrExit(error = Tlv::Append<QueryIdTlv>(*aAnswer, aInfo.mQueryId));
-    }
+    SuccessOrExit(PrepareAnswers(aRequest, answerBuilder));
+    mAnswerSender.Send(answerBuilder, aDestination);
 
 exit:
-    return error;
+    return;
 }
 
-bool Server::IsLastAnswer(const Coap::Message &aAnswer) const
+Error Server::PrepareAnswers(const Coap::Message &aRequest, AnswerBuilder &aAnswerBuilder)
 {
-    // Indicates whether `aAnswer` is the last one associated with
-    // the same query.
-
-    bool      isLast = true;
-    AnswerTlv answerTlv;
-
-    // If there is no Answer TLV, we assume it is the last answer.
-
-    SuccessOrExit(Tlv::FindTlv(aAnswer, answerTlv));
-    isLast = answerTlv.IsLast();
-
-exit:
-    return isLast;
-}
-
-void Server::FreeAllRelatedAnswers(Coap::Message &aFirstAnswer)
-{
-    // This method dequeues and frees all answer messages related to
-    // same query as `aFirstAnswer`. Note that related answers are
-    // enqueued in order.
-
-    Coap::Message *answer = &aFirstAnswer;
-
-    while (answer != nullptr)
-    {
-        Coap::Message *next = IsLastAnswer(*answer) ? nullptr : answer->GetNextCoapMessage();
-
-        mAnswerQueue.DequeueAndFree(*answer);
-        answer = next;
-    }
-}
-
-void Server::PrepareAndSendAnswers(const Ip6::Address &aDestination, const Message &aRequest)
-{
-    Coap::Message      *answer;
     Error               error;
-    AnswerInfo          info;
     uint8_t             tlvType;
     TlvTypeListIterator iterator;
-    AnswerTlv           answerTlv;
 
-    if (Tlv::Find<QueryIdTlv>(aRequest, info.mQueryId) == kErrorNone)
-    {
-        info.mHasQueryId = true;
-    }
-
-    info.mPriority = aRequest.GetPriority();
-
-    SuccessOrExit(error = AllocateAnswer(answer, info));
+    SuccessOrExit(error = aAnswerBuilder.Start(aRequest));
 
     SuccessOrExit(error = iterator.InitForTypeListTlv(aRequest));
 
@@ -626,137 +571,51 @@
         switch (tlvType)
         {
         case ChildTlv::kType:
-            SuccessOrExit(error = AppendChildTableAsChildTlvs(answer, info));
+            SuccessOrExit(error = AppendChildTableAsChildTlvs(aAnswerBuilder));
             break;
         case ChildIp6AddressListTlv::kType:
-            SuccessOrExit(error = AppendChildTableIp6AddressList(answer, info));
+            SuccessOrExit(error = AppendChildTableIp6AddressList(aAnswerBuilder));
             break;
         case RouterNeighborTlv::kType:
-            SuccessOrExit(error = AppendRouterNeighborTlvs(answer, info));
+            SuccessOrExit(error = AppendRouterNeighborTlvs(aAnswerBuilder));
             break;
         default:
-            SuccessOrExit(error = AppendDiagTlv(tlvType, *answer));
+            SuccessOrExit(error = AppendDiagTlv(tlvType, aAnswerBuilder.GetAnswer()));
             break;
         }
 
-        SuccessOrExit(error = CheckAnswerLength(answer, info));
+        SuccessOrExit(error = aAnswerBuilder.CheckAnswerLength());
     }
 
-    answerTlv.Init(info.mAnswerIndex, AnswerTlv::kIsLast);
-    SuccessOrExit(error = answer->Append(answerTlv));
-
-    SendNextAnswer(*info.mFirstAnswer, aDestination);
-
-exit:
-    if ((error != kErrorNone) && (info.mFirstAnswer != nullptr))
-    {
-        FreeAllRelatedAnswers(*info.mFirstAnswer);
-    }
-}
-
-Error Server::CheckAnswerLength(Coap::Message *&aAnswer, AnswerInfo &aInfo)
-{
-    // This method checks the length of the `aAnswer` message and if it
-    // is above the threshold, it enqueues the message for transmission
-    // after appending an Answer TLV with the current index to the
-    // message. In this case, it will also allocate a new answer
-    // message.
-
-    Error     error = kErrorNone;
-    AnswerTlv answerTlv;
-
-    VerifyOrExit(aAnswer->GetLength() >= kAnswerMessageLengthThreshold);
-
-    answerTlv.Init(aInfo.mAnswerIndex++, AnswerTlv::kMoreToFollow);
-    SuccessOrExit(error = aAnswer->Append(answerTlv));
-
-    error = AllocateAnswer(aAnswer, aInfo);
+    SuccessOrExit(error = aAnswerBuilder.Finish());
 
 exit:
     return error;
 }
 
-void Server::SendNextAnswer(Coap::Message &aAnswer, const Ip6::Address &aDestination)
+Error Server::AppendChildTableAsChildTlvs(AnswerBuilder &aAnswerBuilder)
 {
-    // This method send the given next `aAnswer` associated with
-    // a query to the  `aDestination`.
-
-    Error          error      = kErrorNone;
-    Coap::Message *nextAnswer = IsLastAnswer(aAnswer) ? nullptr : aAnswer.GetNextCoapMessage();
-
-    mAnswerQueue.Dequeue(aAnswer);
-
-    // When sending the message, we pass `nextAnswer` as `aContext`
-    // to be used when invoking callback `HandleAnswerResponse()`.
-
-    error = Get<Tmf::Agent>().SendMessageAllowMulticastLoop(aAnswer, aDestination, HandleAnswerResponse, nextAnswer);
-
-    if (error != kErrorNone)
-    {
-        // If the `SendMessage()` fails, we `Free` the dequeued
-        // `aAnswer` and all the related next answers in the queue.
-
-        aAnswer.Free();
-
-        if (nextAnswer != nullptr)
-        {
-            FreeAllRelatedAnswers(*nextAnswer);
-        }
-    }
-}
-
-void Server::HandleAnswerResponse(void *aContext, Coap::Msg *aMsg, Error aResult)
-{
-    Coap::Message *nextAnswer = static_cast<Coap::Message *>(aContext);
-
-    VerifyOrExit(nextAnswer != nullptr);
-
-    nextAnswer->Get<Server>().HandleAnswerResponse(*nextAnswer, aMsg, aResult);
-
-exit:
-    return;
-}
-
-void Server::HandleAnswerResponse(Coap::Message &aNextAnswer, Coap::Msg *aResponse, Error aResult)
-{
-    Error error = aResult;
-
-    SuccessOrExit(error);
-    VerifyOrExit(aResponse != nullptr, error = kErrorDrop);
-    VerifyOrExit(aResponse->GetCode() == Coap::kCodeChanged, error = kErrorDrop);
-
-    SendNextAnswer(aNextAnswer, aResponse->mMessageInfo.GetPeerAddr());
-
-exit:
-    if (error != kErrorNone)
-    {
-        FreeAllRelatedAnswers(aNextAnswer);
-    }
-}
-
-Error Server::AppendChildTableAsChildTlvs(Coap::Message *&aAnswer, AnswerInfo &aInfo)
-{
-    Error    error = kErrorNone;
-    ChildTlv childTlv;
+    Error         error = kErrorNone;
+    ChildTlvValue childTlvValue;
 
     for (Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
     {
-        childTlv.InitFrom(child);
+        childTlvValue.InitFrom(child);
 
-        SuccessOrExit(error = childTlv.AppendTo(*aAnswer));
-        SuccessOrExit(error = CheckAnswerLength(aAnswer, aInfo));
+        SuccessOrExit(error = Tlv::Append<ChildTlv>(aAnswerBuilder.GetAnswer(), childTlvValue));
+        SuccessOrExit(error = aAnswerBuilder.CheckAnswerLength());
     }
 
-    error = Tlv::AppendEmpty<ChildTlv>(*aAnswer);
+    error = Tlv::AppendEmpty<ChildTlv>(aAnswerBuilder.GetAnswer());
 
 exit:
     return error;
 }
 
-Error Server::AppendRouterNeighborTlvs(Coap::Message *&aAnswer, AnswerInfo &aInfo)
+Error Server::AppendRouterNeighborTlvs(AnswerBuilder &aAnswerBuilder)
 {
-    Error             error = kErrorNone;
-    RouterNeighborTlv neighborTlv;
+    Error                  error = kErrorNone;
+    RouterNeighborTlvValue neighborTlvValue;
 
     for (Router &router : Get<RouterTable>())
     {
@@ -765,29 +624,29 @@
             continue;
         }
 
-        neighborTlv.InitFrom(router);
+        neighborTlvValue.InitFrom(router);
 
-        SuccessOrExit(error = neighborTlv.AppendTo(*aAnswer));
-        SuccessOrExit(error = CheckAnswerLength(aAnswer, aInfo));
+        SuccessOrExit(error = Tlv::Append<RouterNeighborTlv>(aAnswerBuilder.GetAnswer(), neighborTlvValue));
+        SuccessOrExit(error = aAnswerBuilder.CheckAnswerLength());
     }
 
-    error = Tlv::AppendEmpty<RouterNeighborTlv>(*aAnswer);
+    error = Tlv::AppendEmpty<RouterNeighborTlv>(aAnswerBuilder.GetAnswer());
 
 exit:
     return error;
 }
 
-Error Server::AppendChildTableIp6AddressList(Coap::Message *&aAnswer, AnswerInfo &aInfo)
+Error Server::AppendChildTableIp6AddressList(AnswerBuilder &aAnswerBuilder)
 {
     Error error = kErrorNone;
 
     for (const Child &child : Get<ChildTable>().Iterate(Child::kInStateValid))
     {
-        SuccessOrExit(error = AppendChildIp6AddressListTlv(*aAnswer, child));
-        SuccessOrExit(error = CheckAnswerLength(aAnswer, aInfo));
+        SuccessOrExit(error = AppendChildIp6AddressListTlv(aAnswerBuilder.GetAnswer(), child));
+        SuccessOrExit(error = aAnswerBuilder.CheckAnswerLength());
     }
 
-    error = Tlv::AppendEmpty<ChildIp6AddressListTlv>(*aAnswer);
+    error = Tlv::AppendEmpty<ChildIp6AddressListTlv>(aAnswerBuilder.GetAnswer());
 
 exit:
     return error;
@@ -919,8 +778,7 @@
 
     while (!mOffsetRange.IsEmpty())
     {
-        SuccessOrExit(error = mMessage->Read(mOffsetRange, aTlvType));
-        mOffsetRange.AdvanceOffset(sizeof(uint8_t));
+        SuccessOrExit(error = mMessage->ReadAndAdvance(mOffsetRange, aTlvType));
 
         if (!mProcessedTlvs.Has(aTlvType))
         {
@@ -943,7 +801,7 @@
 
 Client::Client(Instance &aInstance)
     : InstanceLocator(aInstance)
-    , mQueryId(Random::NonCrypto::GetUint16())
+    , mQueryId(Random::NonCrypto::Generate<uint16_t>())
 {
 }
 
@@ -1066,44 +924,32 @@
     return SendCommand(kUriDiagnosticReset, Message::kPriorityNormal, aDestination, aTlvTypes, aCount);
 }
 
-static void ParseRoute(const RouteTlv &aRouteTlv, otNetworkDiagRoute &aNetworkDiagRoute)
+void Client::GetRouteInfo(const RouteTlv::Data &aRouteTlvData, RouteInfo &aNetDiagRouteInfo)
 {
     uint8_t routeCount = 0;
 
-    for (uint8_t i = 0; i <= Mle::kMaxRouterId; ++i)
+    aNetDiagRouteInfo.mIdSequence = aRouteTlvData.GetRouterIdSequence();
+
+    for (const RouteTlv::Data::Entry &entry : aRouteTlvData.GetEntries())
     {
-        if (!aRouteTlv.IsRouterIdSet(i))
-        {
-            continue;
-        }
-        aNetworkDiagRoute.mRouteData[routeCount].mRouterId       = i;
-        aNetworkDiagRoute.mRouteData[routeCount].mRouteCost      = aRouteTlv.GetRouteCost(routeCount);
-        aNetworkDiagRoute.mRouteData[routeCount].mLinkQualityIn  = aRouteTlv.GetLinkQualityIn(routeCount);
-        aNetworkDiagRoute.mRouteData[routeCount].mLinkQualityOut = aRouteTlv.GetLinkQualityOut(routeCount);
-        ++routeCount;
+        aNetDiagRouteInfo.mRouteData[routeCount].mRouterId       = entry.GetRouterId();
+        aNetDiagRouteInfo.mRouteData[routeCount].mRouteCost      = entry.GetRouteCost();
+        aNetDiagRouteInfo.mRouteData[routeCount].mLinkQualityIn  = entry.GetLinkQualityIn();
+        aNetDiagRouteInfo.mRouteData[routeCount].mLinkQualityOut = entry.GetLinkQualityOut();
+        routeCount++;
     }
-    aNetworkDiagRoute.mRouteCount = routeCount;
-    aNetworkDiagRoute.mIdSequence = aRouteTlv.GetRouterIdSequence();
+
+    aNetDiagRouteInfo.mRouteCount = routeCount;
 }
 
-static Error ParseEnhancedRoute(const Message &aMessage, uint16_t aOffset, otNetworkDiagEnhRoute &aNetworkDiagEnhRoute)
+Error Client::ParseEnhancedRoute(EnhRoute &aEnhRoute, const Message &aMessage, OffsetRange aOffsetRange)
 {
-    Error            error;
-    OffsetRange      offsetRange;
-    Tlv              tlv;
-    Mle::RouterIdSet routerIdSet;
-    uint8_t          index;
+    Error             error;
+    Mle::RouterIdMask routerIdMask;
+    uint8_t           index;
 
-    SuccessOrExit(error = aMessage.Read(aOffset, tlv));
-
-    VerifyOrExit(!tlv.IsExtended(), error = kErrorParse);
-    VerifyOrExit(tlv.GetType() == Tlv::kEnhancedRoute, error = kErrorParse);
-
-    aOffset += sizeof(tlv);
-    offsetRange.Init(aOffset, tlv.GetLength());
-
-    SuccessOrExit(error = aMessage.Read(offsetRange, routerIdSet));
-    offsetRange.AdvanceOffset(sizeof(routerIdSet));
+    SuccessOrExit(error = routerIdMask.ReadMaskFrom(aMessage, aOffsetRange));
+    aOffsetRange.AdvanceOffset(Mle::RouterIdMask::kMaskSize);
 
     index = 0;
 
@@ -1111,21 +957,20 @@
     {
         EnhancedRouteTlvEntry entry;
 
-        if (!routerIdSet.Contains(routerId))
+        if (!routerIdMask.IsAllocated(routerId))
         {
             continue;
         }
 
-        SuccessOrExit(error = aMessage.Read(offsetRange, entry));
-        offsetRange.AdvanceOffset(sizeof(entry));
+        SuccessOrExit(error = aMessage.ReadAndAdvance(aOffsetRange, entry));
 
-        aNetworkDiagEnhRoute.mRouteData[index].mRouterId = routerId;
-        entry.Parse(aNetworkDiagEnhRoute.mRouteData[index]);
+        aEnhRoute.mRouteData[index].mRouterId = routerId;
+        entry.Parse(aEnhRoute.mRouteData[index]);
 
         index++;
     }
 
-    aNetworkDiagEnhRoute.mRouteCount = index;
+    aEnhRoute.mRouteCount = index;
 
 exit:
     return error;
@@ -1153,8 +998,7 @@
     {
         ChildTableTlvEntry entry;
 
-        SuccessOrExit(error = aMessage.Read(aOffsetRange, entry));
-        aOffsetRange.AdvanceOffset(sizeof(ChildTableTlvEntry));
+        SuccessOrExit(error = aMessage.ReadAndAdvance(aOffsetRange, entry));
 
         entry.Parse(aChildTable.mTable[aChildTable.mCount]);
         aChildTable.mCount++;
@@ -1170,8 +1014,7 @@
 
     while (aOffsetRange.Contains(sizeof(Ip6::Address)) && (aIp6Addrs.mCount < GetArrayLength(aIp6Addrs.mList)))
     {
-        IgnoreError(aMessage.Read(aOffsetRange, aIp6Addrs.mList[aIp6Addrs.mCount]));
-        aOffsetRange.AdvanceOffset(sizeof(Ip6::Address));
+        IgnoreError(aMessage.ReadAndAdvance(aOffsetRange, aIp6Addrs.mList[aIp6Addrs.mCount]));
 
         aIp6Addrs.mCount++;
     }
@@ -1185,186 +1028,22 @@
 
     while (offset < aMessage.GetLength())
     {
-        bool skipTlv = false;
-
         SuccessOrExit(error = tlvInfo.ParseFrom(aMessage, offset));
-
-        switch (tlvInfo.GetType())
-        {
-        case Tlv::kExtMacAddress:
-            SuccessOrExit(error = tlvInfo.Read<ExtMacAddressTlv>(aMessage, AsCoreType(&aDiagTlv.mData.mExtAddress)));
-            break;
-
-        case Tlv::kAddress16:
-            SuccessOrExit(error = tlvInfo.Read<Address16Tlv>(aMessage, aDiagTlv.mData.mAddr16));
-            break;
-
-        case Tlv::kMode:
-        {
-            uint8_t mode;
-
-            SuccessOrExit(error = tlvInfo.Read<ModeTlv>(aMessage, mode));
-            Mle::DeviceMode(mode).Get(aDiagTlv.mData.mMode);
-            break;
-        }
-
-        case Tlv::kTimeout:
-            SuccessOrExit(error = tlvInfo.Read<TimeoutTlv>(aMessage, aDiagTlv.mData.mTimeout));
-            break;
-
-        case Tlv::kConnectivity:
-        {
-            ConnectivityTlvValue tlvValue;
-
-            SuccessOrExit(error = tlvValue.ParseFrom(aMessage, tlvInfo.GetValueOffsetRange()));
-            tlvValue.GetConnectivity(AsCoreType(&aDiagTlv.mData.mConnectivity));
-            break;
-        }
-
-        case Tlv::kRoute:
-        {
-            RouteTlv routeTlv;
-            uint16_t bytesToRead = Min<uint16_t>(tlvInfo.GetSize(), sizeof(routeTlv));
-
-            VerifyOrExit(!tlvInfo.IsExtended(), error = kErrorParse);
-            SuccessOrExit(error = aMessage.Read(offset, &routeTlv, bytesToRead));
-            VerifyOrExit(routeTlv.IsValid(), error = kErrorParse);
-            ParseRoute(routeTlv, aDiagTlv.mData.mRoute);
-            break;
-        }
-
-        case Tlv::kEnhancedRoute:
-            SuccessOrExit(error = ParseEnhancedRoute(aMessage, offset, aDiagTlv.mData.mEnhRoute));
-            break;
-
-        case Tlv::kLeaderData:
-        {
-            LeaderDataTlvValue tlvValue;
-
-            SuccessOrExit(error = tlvInfo.Read<LeaderDataTlv>(aMessage, tlvValue));
-            tlvValue.Get(AsCoreType(&aDiagTlv.mData.mLeaderData));
-            break;
-        }
-
-        case Tlv::kNetworkData:
-            static_assert(sizeof(aDiagTlv.mData.mNetworkData.m8) >= NetworkData::NetworkData::kMaxSize,
-                          "NetworkData array in `otNetworkDiagTlv` is too small");
-
-            VerifyOrExit(tlvInfo.GetLength() <= NetworkData::NetworkData::kMaxSize, error = kErrorParse);
-            ReadDiagData(aDiagTlv.mData.mNetworkData, aMessage, tlvInfo);
-            break;
-
-        case Tlv::kIp6AddressList:
-            ParseIp6AddrList(aDiagTlv.mData.mIp6AddrList, aMessage, tlvInfo.GetValueOffsetRange());
-            break;
-
-        case Tlv::kMacCounters:
-        {
-            MacCountersTlv macCountersTlv;
-
-            SuccessOrExit(error = aMessage.Read(offset, macCountersTlv));
-            VerifyOrExit(macCountersTlv.IsValid(), error = kErrorParse);
-            macCountersTlv.Read(aDiagTlv.mData.mMacCounters);
-            break;
-        }
-
-        case Tlv::kMleCounters:
-        {
-            MleCountersTlv mleCoutersTlv;
-
-            SuccessOrExit(error = aMessage.Read(offset, mleCoutersTlv));
-            VerifyOrExit(mleCoutersTlv.IsValid(), error = kErrorParse);
-            mleCoutersTlv.Read(aDiagTlv.mData.mMleCounters);
-            break;
-        }
-
-        case Tlv::kBatteryLevel:
-            SuccessOrExit(error = tlvInfo.Read<BatteryLevelTlv>(aMessage, aDiagTlv.mData.mBatteryLevel));
-            break;
-
-        case Tlv::kSupplyVoltage:
-            SuccessOrExit(error = tlvInfo.Read<SupplyVoltageTlv>(aMessage, aDiagTlv.mData.mSupplyVoltage));
-            break;
-
-        case Tlv::kChildTable:
-            SuccessOrExit(error = ParseChildTable(aDiagTlv.mData.mChildTable, aMessage, tlvInfo.GetValueOffsetRange()));
-            break;
-
-        case Tlv::kChannelPages:
-            ReadDiagData(aDiagTlv.mData.mChannelPages, aMessage, tlvInfo);
-            break;
-
-        case Tlv::kMaxChildTimeout:
-            SuccessOrExit(error = tlvInfo.Read<MaxChildTimeoutTlv>(aMessage, aDiagTlv.mData.mMaxChildTimeout));
-            break;
-
-        case Tlv::kEui64:
-            SuccessOrExit(error = tlvInfo.Read<Eui64Tlv>(aMessage, AsCoreType(&aDiagTlv.mData.mEui64)));
-            break;
-
-        case Tlv::kVersion:
-            SuccessOrExit(error = tlvInfo.Read<VersionTlv>(aMessage, aDiagTlv.mData.mVersion));
-            break;
-
-        case Tlv::kVendorName:
-            SuccessOrExit(error = tlvInfo.Read<VendorNameTlv>(aMessage, aDiagTlv.mData.mVendorName));
-            break;
-
-        case Tlv::kVendorModel:
-            SuccessOrExit(error = tlvInfo.Read<VendorModelTlv>(aMessage, aDiagTlv.mData.mVendorModel));
-            break;
-
-        case Tlv::kVendorSwVersion:
-            SuccessOrExit(error = tlvInfo.Read<VendorSwVersionTlv>(aMessage, aDiagTlv.mData.mVendorSwVersion));
-            break;
-
-        case Tlv::kVendorAppUrl:
-            SuccessOrExit(error = tlvInfo.Read<VendorAppUrlTlv>(aMessage, aDiagTlv.mData.mVendorAppUrl));
-            break;
-
-        case Tlv::kThreadStackVersion:
-            SuccessOrExit(error = tlvInfo.Read<ThreadStackVersionTlv>(aMessage, aDiagTlv.mData.mThreadStackVersion));
-            break;
-
-        case Tlv::kNonPreferredChannels:
-            SuccessOrExit(error = MeshCoP::ChannelMaskTlv::ParseValue(aMessage, tlvInfo.GetValueOffsetRange(),
-                                                                      aDiagTlv.mData.mNonPreferredChannels));
-            break;
-
-        case Tlv::kBrState:
-        {
-            uint8_t state;
-
-            SuccessOrExit(error = tlvInfo.Read<BrStateTlv>(aMessage, state));
-            aDiagTlv.mData.mBrState = static_cast<BrState>(state);
-            break;
-        }
-
-        case Tlv::kBrIfAddrs:
-            ParseIp6AddrList(aDiagTlv.mData.mBrIfAddrList, aMessage, tlvInfo.GetValueOffsetRange());
-            break;
-
-        case Tlv::kBrLocalOmrPrefix:
-        case Tlv::kBrLocalOnlinkPrefix:
-        case Tlv::kBrFavoredOnLinkPrefix:
-        case Tlv::kBrDhcp6PdOmrPrefix:
-            SuccessOrExit(error = aMessage.Read(tlvInfo.GetValueOffsetRange(), aDiagTlv.mData.mBrPrefix));
-            break;
-
-        default:
-            // Skip unrecognized TLVs.
-            skipTlv = true;
-            break;
-        }
-
         offset += tlvInfo.GetSize();
 
-        if (!skipTlv)
+        error = ParseDiagTlv(aMessage, tlvInfo, aDiagTlv);
+
+        switch (error)
         {
-            // Exit if a TLV is recognized and parsed successfully.
-            aDiagTlv.mType = tlvInfo.GetType();
-            aIterator      = offset;
-            error          = kErrorNone;
+        case kErrorNotCapable:
+            // Skip over any unrecognized TLV.
+            break;
+
+        case kErrorNone:
+            aIterator = offset;
+            OT_FALL_THROUGH;
+
+        default:
             ExitNow();
         }
     }
@@ -1375,6 +1054,186 @@
     return error;
 }
 
+Error Client::ParseDiagTlv(const Message &aMessage, const Tlv::Info &aTlvInfo, DiagTlv &aDiagTlv)
+{
+    // Parses a Network Diagnostics TLV from `aMessage` using `aTlvInfo`
+    // If the TLV is unrecognized, returns `kErrorNotCapable`.
+
+    Error error = kErrorNone;
+
+    aDiagTlv.mType = aTlvInfo.GetType();
+
+    switch (aTlvInfo.GetType())
+    {
+    case Tlv::kExtMacAddress:
+        error = aTlvInfo.Read<ExtMacAddressTlv>(aMessage, AsCoreType(&aDiagTlv.mData.mExtAddress));
+        break;
+
+    case Tlv::kAddress16:
+        error = aTlvInfo.Read<Address16Tlv>(aMessage, aDiagTlv.mData.mAddr16);
+        break;
+
+    case Tlv::kMode:
+    {
+        uint8_t mode;
+
+        SuccessOrExit(error = aTlvInfo.Read<ModeTlv>(aMessage, mode));
+        Mle::DeviceMode(mode).Get(aDiagTlv.mData.mMode);
+        break;
+    }
+
+    case Tlv::kTimeout:
+        error = aTlvInfo.Read<TimeoutTlv>(aMessage, aDiagTlv.mData.mTimeout);
+        break;
+
+    case Tlv::kConnectivity:
+    {
+        ConnectivityTlvValue tlvValue;
+
+        SuccessOrExit(error = tlvValue.ParseFrom(aMessage, aTlvInfo.GetValueOffsetRange()));
+        tlvValue.GetConnectivity(AsCoreType(&aDiagTlv.mData.mConnectivity));
+        break;
+    }
+
+    case Tlv::kRoute:
+    {
+        RouteTlv::Data routeTlvData;
+
+        SuccessOrExit(error = routeTlvData.ParseFrom(aMessage, aTlvInfo.GetValueOffsetRange()));
+        GetRouteInfo(routeTlvData, aDiagTlv.mData.mRoute);
+        break;
+    }
+
+    case Tlv::kEnhancedRoute:
+        error = ParseEnhancedRoute(aDiagTlv.mData.mEnhRoute, aMessage, aTlvInfo.GetValueOffsetRange());
+        break;
+
+    case Tlv::kLeaderData:
+    {
+        LeaderDataTlvValue tlvValue;
+
+        SuccessOrExit(error = aTlvInfo.Read<LeaderDataTlv>(aMessage, tlvValue));
+        tlvValue.Get(AsCoreType(&aDiagTlv.mData.mLeaderData));
+        break;
+    }
+
+    case Tlv::kNetworkData:
+        static_assert(sizeof(aDiagTlv.mData.mNetworkData.m8) >= NetworkData::NetworkData::kMaxSize,
+                      "NetworkData array in `otNetworkDiagTlv` is too small");
+
+        VerifyOrExit(aTlvInfo.GetLength() <= NetworkData::NetworkData::kMaxSize, error = kErrorParse);
+        ReadDiagData(aDiagTlv.mData.mNetworkData, aMessage, aTlvInfo);
+        break;
+
+    case Tlv::kIp6AddressList:
+        ParseIp6AddrList(aDiagTlv.mData.mIp6AddrList, aMessage, aTlvInfo.GetValueOffsetRange());
+        break;
+
+    case Tlv::kMacCounters:
+    {
+        MacCountersTlvValue tlvValue;
+
+        SuccessOrExit(error = aTlvInfo.Read<MacCountersTlv>(aMessage, tlvValue));
+        tlvValue.Read(aDiagTlv.mData.mMacCounters);
+        break;
+    }
+
+    case Tlv::kMleCounters:
+    {
+        MleCountersTlvValue tlvValue;
+
+        SuccessOrExit(error = aTlvInfo.Read<MleCountersTlv>(aMessage, tlvValue));
+        tlvValue.Read(aDiagTlv.mData.mMleCounters);
+        break;
+    }
+
+    case Tlv::kBatteryLevel:
+        error = aTlvInfo.Read<BatteryLevelTlv>(aMessage, aDiagTlv.mData.mBatteryLevel);
+        break;
+
+    case Tlv::kSupplyVoltage:
+        error = aTlvInfo.Read<SupplyVoltageTlv>(aMessage, aDiagTlv.mData.mSupplyVoltage);
+        break;
+
+    case Tlv::kChildTable:
+        error = ParseChildTable(aDiagTlv.mData.mChildTable, aMessage, aTlvInfo.GetValueOffsetRange());
+        break;
+
+    case Tlv::kChannelPages:
+        ReadDiagData(aDiagTlv.mData.mChannelPages, aMessage, aTlvInfo);
+        break;
+
+    case Tlv::kMaxChildTimeout:
+        error = aTlvInfo.Read<MaxChildTimeoutTlv>(aMessage, aDiagTlv.mData.mMaxChildTimeout);
+        break;
+
+    case Tlv::kEui64:
+        error = aTlvInfo.Read<Eui64Tlv>(aMessage, AsCoreType(&aDiagTlv.mData.mEui64));
+        break;
+
+    case Tlv::kVersion:
+        error = aTlvInfo.Read<VersionTlv>(aMessage, aDiagTlv.mData.mVersion);
+        break;
+
+    case Tlv::kVendorName:
+        error = aTlvInfo.Read<VendorNameTlv>(aMessage, aDiagTlv.mData.mVendorName);
+        break;
+
+    case Tlv::kVendorModel:
+        error = aTlvInfo.Read<VendorModelTlv>(aMessage, aDiagTlv.mData.mVendorModel);
+        break;
+
+    case Tlv::kVendorSwVersion:
+        error = aTlvInfo.Read<VendorSwVersionTlv>(aMessage, aDiagTlv.mData.mVendorSwVersion);
+        break;
+
+    case Tlv::kVendorAppUrl:
+        error = aTlvInfo.Read<VendorAppUrlTlv>(aMessage, aDiagTlv.mData.mVendorAppUrl);
+        break;
+
+    case Tlv::kVendorOui:
+        error = AsCoreType(&aDiagTlv.mData.mVendorOui).ParseFrom(aMessage, aTlvInfo.GetValueOffsetRange());
+        break;
+
+    case Tlv::kThreadStackVersion:
+        error = aTlvInfo.Read<ThreadStackVersionTlv>(aMessage, aDiagTlv.mData.mThreadStackVersion);
+        break;
+
+    case Tlv::kNonPreferredChannels:
+        error = MeshCoP::ChannelMaskTlv::ParseValue(aMessage, aTlvInfo.GetValueOffsetRange(),
+                                                    aDiagTlv.mData.mNonPreferredChannels);
+        break;
+
+    case Tlv::kBrState:
+    {
+        uint8_t state;
+
+        SuccessOrExit(error = aTlvInfo.Read<BrStateTlv>(aMessage, state));
+        aDiagTlv.mData.mBrState = static_cast<BrState>(state);
+        break;
+    }
+
+    case Tlv::kBrIfAddrs:
+        ParseIp6AddrList(aDiagTlv.mData.mBrIfAddrList, aMessage, aTlvInfo.GetValueOffsetRange());
+        break;
+
+    case Tlv::kBrLocalOmrPrefix:
+    case Tlv::kBrLocalOnlinkPrefix:
+    case Tlv::kBrFavoredOnLinkPrefix:
+    case Tlv::kBrDhcp6PdOmrPrefix:
+        error = aMessage.Read(aTlvInfo.GetValueOffsetRange(), aDiagTlv.mData.mBrPrefix);
+        break;
+
+    default:
+        // Unrecognized TLV
+        error = kErrorNotCapable;
+        break;
+    }
+
+exit:
+    return error;
+}
+
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
 
 const char *Client::UriToString(Uri aUri)
@@ -1403,6 +1262,6 @@
 
 #endif // OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE
 
-} // namespace NetworkDiagnostic
+} // namespace NetDiag
 
 } // namespace ot
diff --git a/src/core/thread/network_diagnostic.hpp b/src/core/thread/net_diag.hpp
similarity index 85%
rename from src/core/thread/network_diagnostic.hpp
rename to src/core/thread/net_diag.hpp
index d73bc91..efaccc1 100644
--- a/src/core/thread/network_diagnostic.hpp
+++ b/src/core/thread/net_diag.hpp
@@ -31,8 +31,8 @@
  *   This file includes definitions for handle network diagnostic.
  */
 
-#ifndef OT_CORE_THREAD_NETWORK_DIAGNOSTIC_HPP_
-#define OT_CORE_THREAD_NETWORK_DIAGNOSTIC_HPP_
+#ifndef OT_CORE_THREAD_NET_DIAG_HPP_
+#define OT_CORE_THREAD_NET_DIAG_HPP_
 
 #include "openthread-core-config.h"
 
@@ -44,7 +44,8 @@
 #include "common/non_copyable.hpp"
 #include "common/numeric_limits.hpp"
 #include "net/udp6.hpp"
-#include "thread/network_diagnostic_tlvs.hpp"
+#include "thread/net_diag_tlvs.hpp"
+#include "thread/net_diag_types.hpp"
 #include "thread/tmf.hpp"
 #include "thread/uri_paths.hpp"
 
@@ -58,7 +59,7 @@
 class TcatAgent;
 }
 
-namespace NetworkDiagnostic {
+namespace NetDiag {
 
 /**
  * @addtogroup core-netdiag
@@ -79,6 +80,7 @@
     friend class Tmf::Agent;
     friend class MeshCoP::TcatAgent;
     friend class Client;
+    friend class AnswerSender;
 
 public:
     /**
@@ -119,8 +121,7 @@
     }
 
 private:
-    static constexpr uint16_t kMaxChildEntries              = 398;
-    static constexpr uint16_t kAnswerMessageLengthThreshold = 800;
+    static constexpr uint16_t kMaxChildEntries = 398;
 
     class TlvTypeListIterator
     {
@@ -139,57 +140,30 @@
         BitSet<NumericLimits<uint8_t>::kMax + 1> mProcessedTlvs;
     };
 
-#if OPENTHREAD_FTD
-    struct AnswerInfo
-    {
-        AnswerInfo(void)
-            : mAnswerIndex(0)
-            , mQueryId(0)
-            , mHasQueryId(false)
-            , mFirstAnswer(nullptr)
-        {
-        }
-
-        uint16_t          mAnswerIndex;
-        uint16_t          mQueryId;
-        bool              mHasQueryId;
-        Message::Priority mPriority;
-        Coap::Message    *mFirstAnswer;
-    };
-#endif
-
     Error AppendDiagTlv(uint8_t aTlvType, Message &aMessage);
     Error AppendIp6AddressList(Message &aMessage);
     Error AppendRequestedTlvs(const Message &aRequest, Message &aResponse);
 
 #if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
-    Error AppendRequestedTlvsForTcat(const Message &aRequest, Message &aResponse, OffsetRange &aOffsetRange);
+    Error AppendRequestedTlvsForTcat(const Message &aRequest, Message &aResponse, const OffsetRange &aOffsetRange);
 #endif
 
 #if OPENTHREAD_MTD
     void SendAnswer(const Ip6::Address &aDestination, const Message &aRequest);
 #elif OPENTHREAD_FTD
-    Error AllocateAnswer(Coap::Message *&aAnswer, AnswerInfo &aInfo);
-    Error CheckAnswerLength(Coap::Message *&aAnswer, AnswerInfo &aInfo);
-    bool  IsLastAnswer(const Coap::Message &aAnswer) const;
-    void  FreeAllRelatedAnswers(Coap::Message &aFirstAnswer);
-    void  PrepareAndSendAnswers(const Ip6::Address &aDestination, const Message &aRequest);
-    void  SendNextAnswer(Coap::Message &aAnswer, const Ip6::Address &aDestination);
+    void  PrepareAndSendAnswers(const Ip6::Address &aDestination, const Coap::Message &aRequest);
+    Error PrepareAnswers(const Coap::Message &aRequest, AnswerBuilder &aAnswerBuilder);
     Error AppendChildTable(Message &aMessage);
-    Error AppendChildTableAsChildTlvs(Coap::Message *&aAnswer, AnswerInfo &aInfo);
-    Error AppendRouterNeighborTlvs(Coap::Message *&aAnswer, AnswerInfo &aInfo);
-    Error AppendChildTableIp6AddressList(Coap::Message *&aAnswer, AnswerInfo &aInfo);
+    Error AppendChildTableAsChildTlvs(AnswerBuilder &aAnswerBuilder);
+    Error AppendRouterNeighborTlvs(AnswerBuilder &aAnswerBuilder);
+    Error AppendChildTableIp6AddressList(AnswerBuilder &aAnswerBuilder);
     Error AppendChildIp6AddressListTlv(Message &aAnswer, const Child &aChild);
     Error AppendEnhancedRoute(Message &aMessage);
-
 #if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
     Error AppendChildTableAsChildTlvs(Message &aMessage);
     Error AppendRouterNeighborTlvs(Message &aMessage);
     Error AppendChildTableIp6AddressList(Message &aMessage);
 #endif
-
-    static void HandleAnswerResponse(void *aContext, Coap::Msg *aMsg, Error aResult);
-    void        HandleAnswerResponse(Coap::Message &aNextAnswer, Coap::Msg *aResponse, Error aResult);
 #endif
 #if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE
     Error AppendBorderRouterIfAddrs(Message &aMessage);
@@ -199,7 +173,7 @@
     template <Uri kUri> void HandleTmf(Coap::Msg &aMsg);
 
 #if OPENTHREAD_FTD
-    Coap::MessageQueue mAnswerQueue;
+    AnswerSender mAnswerSender;
 #endif
     uint32_t                                    mNonPreferredChannels;
     Callback<NonPreferredChannelsResetCallback> mNonPreferredChannelsResetCallback;
@@ -223,6 +197,7 @@
     typedef otNetworkDiagIterator          Iterator;    ///< Iterator to go through TLVs in `GetNextDiagTlv()`.
     typedef otNetworkDiagTlv               DiagTlv;     ///< Parse info from a Network Diagnostic TLV.
     typedef otNetworkDiagChildEntry        ChildInfo;   ///< Parsed info for child table entry.
+    typedef otNetworkDiagEnhRoute          EnhRoute;    ///< Parsed info for Enhanced Route TLV.
     typedef otReceiveDiagnosticGetCallback GetCallback; ///< Diagnostic Get callback function pointer type.
 
     static constexpr Iterator kIteratorInit = OT_NETWORK_DIAGNOSTIC_ITERATOR_INIT; ///< Initializer for Iterator.
@@ -283,6 +258,7 @@
     typedef otNetworkDiagData        DiagData;
     typedef otNetworkDiagChildTable  ChildTable;
     typedef otNetworkDiagIp6AddrList Ip6AddrList;
+    typedef otNetworkDiagRoute       RouteInfo;
 
     Error SendCommand(Uri                   aUri,
                       Message::Priority     aPriority,
@@ -302,9 +278,12 @@
 
     template <Uri kUri> void HandleTmf(Coap::Msg &aMsg);
 
+    static Error ParseDiagTlv(const Message &aMessage, const Tlv::Info &aTlvInfo, DiagTlv &aDiagTlv);
     static void  ReadDiagData(DiagData &aDiagData, const Message &aMessage, const Tlv::Info &aTlvInfo);
     static Error ParseChildTable(ChildTable &aChildTable, const Message &aMessage, OffsetRange aOffsetRange);
     static void  ParseIp6AddrList(Ip6AddrList &aIp6Addrs, const Message &aMessage, OffsetRange aOffsetRange);
+    static Error ParseEnhancedRoute(EnhRoute &aEnhRoute, const Message &aMessage, OffsetRange aOffsetRange);
+    static void  GetRouteInfo(const RouteTlv::Data &aRouteTlvData, RouteInfo &aNetDiagRouteInfo);
 
 #if OT_SHOULD_LOG_AT(OT_LOG_LEVEL_INFO)
     static const char *UriToString(Uri aUri);
@@ -321,8 +300,8 @@
 /**
  * @}
  */
-} // namespace NetworkDiagnostic
+} // namespace NetDiag
 
 } // namespace ot
 
-#endif // OT_CORE_THREAD_NETWORK_DIAGNOSTIC_HPP_
+#endif // OT_CORE_THREAD_NET_DIAG_HPP_
diff --git a/src/core/thread/network_diagnostic_tlvs.cpp b/src/core/thread/net_diag_tlvs.cpp
similarity index 92%
rename from src/core/thread/network_diagnostic_tlvs.cpp
rename to src/core/thread/net_diag_tlvs.cpp
index 7afff3f..fb116f3 100644
--- a/src/core/thread/network_diagnostic_tlvs.cpp
+++ b/src/core/thread/net_diag_tlvs.cpp
@@ -31,12 +31,12 @@
  *   This file implements methods for generating and processing Network Diagnostics TLVs.
  */
 
-#include "network_diagnostic_tlvs.hpp"
+#include "net_diag_tlvs.hpp"
 
 #include "common/time.hpp"
 
 namespace ot {
-namespace NetworkDiagnostic {
+namespace NetDiag {
 
 //---------------------------------------------------------------------------------------------------------------------
 // ChildTableTlvEntry
@@ -102,6 +102,21 @@
 }
 
 //---------------------------------------------------------------------------------------------------------------------
+// RouteTlv
+
+Error RouteTlv::FindIn(const Message &aMessage, RouteTlv::Data &aData)
+{
+    Error       error;
+    OffsetRange offsetRange;
+
+    SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(aMessage, kType, offsetRange));
+    error = aData.ParseFrom(aMessage, offsetRange);
+
+exit:
+    return error;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
 // EnhancedRouteTlvEntry
 
 void EnhancedRouteTlvEntry::InitFrom(const Router &aRouter)
@@ -136,15 +151,12 @@
 #if OPENTHREAD_FTD
 
 //---------------------------------------------------------------------------------------------------------------------
-// ChildTlv
+// ChildTlvValue
 
-void ChildTlv::InitFrom(const Child &aChild)
+void ChildTlvValue::InitFrom(const Child &aChild)
 {
     Clear();
 
-    SetType(kChild);
-    SetLength(sizeof(*this) - sizeof(Tlv));
-
     mFlags |= aChild.IsRxOnWhenIdle() ? kFlagsRxOnWhenIdle : 0;
     mFlags |= aChild.IsFullThreadDevice() ? kFlagsFtd : 0;
     mFlags |= (aChild.GetNetworkDataType() == NetworkData::kFullSet) ? kFlagsFullNetdta : 0;
@@ -173,15 +185,12 @@
 }
 
 //---------------------------------------------------------------------------------------------------------------------
-// RouterNeighborTlv
+// RouterNeighborTlvValue
 
-void RouterNeighborTlv::InitFrom(const Router &aRouter)
+void RouterNeighborTlvValue::InitFrom(const Router &aRouter)
 {
     Clear();
 
-    SetType(kRouterNeighbor);
-    SetLength(sizeof(*this) - sizeof(Tlv));
-
     mFlags |= kFlagsTrackErrRate;
     mRloc16           = BigEndian::HostSwap16(aRouter.GetRloc16());
     mExtAddress       = aRouter.GetExtAddress();
@@ -197,24 +206,18 @@
 #endif // OPENTHREAD_FTD
 
 //---------------------------------------------------------------------------------------------------------------------
-// AnswerTlv
+// AnswerTlvValue
 
-void AnswerTlv::Init(uint16_t aIndex, IsLastFlag aIsLastFlag)
+void AnswerTlvValue::Init(uint16_t aIndex, IsLastFlag aIsLastFlag)
 {
-    SetType(kAnswer);
-    SetLength(sizeof(*this) - sizeof(Tlv));
-
     SetFlagsIndex((aIndex & kIndexMask) | (aIsLastFlag == kIsLast ? kIsLastFlag : 0));
 }
 
 //---------------------------------------------------------------------------------------------------------------------
-// MacCountersTlv
+// MacCountersTlvValue
 
-void MacCountersTlv::Init(const Mac::Counters &aMacCounters)
+void MacCountersTlvValue::InitFrom(const Mac::Counters &aMacCounters)
 {
-    SetType(kMacCounters);
-    SetLength(sizeof(*this) - sizeof(Tlv));
-
     mIfInUnknownProtos  = BigEndian::HostSwap32(aMacCounters.mRxOther);
     mIfInErrors         = BigEndian::HostSwap32(aMacCounters.mRxErrNoFrame + aMacCounters.mRxErrUnknownNeighbor +
                                                 aMacCounters.mRxErrInvalidSrcAddr + aMacCounters.mRxErrSec +
@@ -229,7 +232,7 @@
     mIfOutDiscards      = BigEndian::HostSwap32(aMacCounters.mTxErrBusyChannel);
 }
 
-void MacCountersTlv::Read(MacCounters &aDiagMacCounters) const
+void MacCountersTlvValue::Read(MacCounters &aDiagMacCounters) const
 {
     aDiagMacCounters.mIfInUnknownProtos  = BigEndian::HostSwap32(mIfInUnknownProtos);
     aDiagMacCounters.mIfInErrors         = BigEndian::HostSwap32(mIfInErrors);
@@ -243,13 +246,10 @@
 }
 
 //---------------------------------------------------------------------------------------------------------------------
-// MleCountersTlv
+// MleCountersTlvValue
 
-void MleCountersTlv::Init(const Mle::Counters &aMleCounters)
+void MleCountersTlvValue::InitFrom(const Mle::Counters &aMleCounters)
 {
-    SetType(kMleCounters);
-    SetLength(sizeof(*this) - sizeof(Tlv));
-
     mDisabledRole                  = BigEndian::HostSwap16(aMleCounters.mDisabledRole);
     mDetachedRole                  = BigEndian::HostSwap16(aMleCounters.mDetachedRole);
     mChildRole                     = BigEndian::HostSwap16(aMleCounters.mChildRole);
@@ -267,7 +267,7 @@
     mLeaderTime                    = BigEndian::HostSwap64(aMleCounters.mLeaderTime);
 }
 
-void MleCountersTlv::Read(MleCounters &aDiagMleCounters) const
+void MleCountersTlvValue::Read(MleCounters &aDiagMleCounters) const
 {
     aDiagMleCounters.mDisabledRole                  = BigEndian::HostSwap16(mDisabledRole);
     aDiagMleCounters.mDetachedRole                  = BigEndian::HostSwap16(mDetachedRole);
@@ -286,5 +286,5 @@
     aDiagMleCounters.mLeaderTime                    = BigEndian::HostSwap64(mLeaderTime);
 }
 
-} // namespace NetworkDiagnostic
+} // namespace NetDiag
 } // namespace ot
diff --git a/src/core/thread/network_diagnostic_tlvs.hpp b/src/core/thread/net_diag_tlvs.hpp
similarity index 91%
rename from src/core/thread/network_diagnostic_tlvs.hpp
rename to src/core/thread/net_diag_tlvs.hpp
index 2e54307..8855729 100644
--- a/src/core/thread/network_diagnostic_tlvs.hpp
+++ b/src/core/thread/net_diag_tlvs.hpp
@@ -31,8 +31,8 @@
  *   This file includes definitions for generating and processing Network Diagnostics TLVs.
  */
 
-#ifndef OT_CORE_THREAD_NETWORK_DIAGNOSTIC_TLVS_HPP_
-#define OT_CORE_THREAD_NETWORK_DIAGNOSTIC_TLVS_HPP_
+#ifndef OT_CORE_THREAD_NET_DIAG_TLVS_HPP_
+#define OT_CORE_THREAD_NET_DIAG_TLVS_HPP_
 
 #include "openthread-core-config.h"
 
@@ -54,7 +54,7 @@
 #include "thread/router.hpp"
 
 namespace ot {
-namespace NetworkDiagnostic {
+namespace NetDiag {
 
 /**
  * Implements Network Diagnostic TLV generation and parsing.
@@ -105,6 +105,7 @@
         kBrDhcp6PdOmrPrefix    = OT_NETWORK_DIAGNOSTIC_TLV_BR_DHCP6_PD_OMR_PREFIX,
         kBrLocalOnlinkPrefix   = OT_NETWORK_DIAGNOSTIC_TLV_BR_LOCAL_OL_PREFIX,
         kBrFavoredOnLinkPrefix = OT_NETWORK_DIAGNOSTIC_TLV_BR_FAVORED_OL_PREFIX,
+        kVendorOui             = OT_NETWORK_DIAGNOSTIC_TLV_VENDOR_OUI,
     };
 
     /**
@@ -234,6 +235,11 @@
 typedef StringTlvInfo<Tlv::kVendorAppUrl, Tlv::kMaxVendorAppUrlLength> VendorAppUrlTlv;
 
 /**
+ * Defines Vendor OUI TLV constants and types.
+ */
+typedef TlvInfo<Tlv::kVendorOui> VendorOuiTlv;
+
+/**
  * Defines Child IPv6 Address List TLV constants and types.
  */
 typedef TlvInfo<Tlv::kChildIp6AddressList> ChildIp6AddressListTlv;
@@ -296,21 +302,23 @@
 /**
  * Implements Route TLV generation and parsing.
  */
-OT_TOOL_PACKED_BEGIN
 class RouteTlv : public Mle::RouteTlv
 {
 public:
-    static constexpr uint8_t kType = ot::NetworkDiagnostic::Tlv::kRoute; ///< The TLV Type value.
+    static constexpr uint8_t kType = ot::NetDiag::Tlv::kRoute; ///< The TLV Type value.
 
     /**
-     * Initializes the TLV.
+     * Finds and parses a Route TLV in a given message.
+     *
+     * @param[in]  aMessage  The message to search within.
+     * @param[out] aData     A reference to a `Data` to populate upon success.
+     *
+     * @retval kErrorNone       Successfully found and parsed the Route TLV.
+     * @retval kErrorNotFound   Could not find a Route TLV in @p aMessage.
+     * @retval kErrorParse      Found the Route TLV but failed to parse it.
      */
-    void Init(void)
-    {
-        Mle::RouteTlv::Init();
-        ot::Tlv::SetType(kType);
-    }
-} OT_TOOL_PACKED_END;
+    static Error FindIn(const Message &aMessage, Data &aData);
+};
 
 /**
  * Represents a Leader Data TLV value.
@@ -328,31 +336,23 @@
 typedef otNetworkDiagMacCounters MacCounters;
 
 /**
- * Implements Mac Counters TLV generation and parsing.
+ * Implements Mac Counters TLV value generation and parsing.
  */
 OT_TOOL_PACKED_BEGIN
-class MacCountersTlv : public Tlv, public TlvInfo<Tlv::kMacCounters>
+class MacCountersTlvValue
 {
 public:
     /**
-     * Initializes the TLV.
+     * Initializes the TLV value.
      *
      * @param[in] aMacCounters    The MAC counters to initialize the TLV with.
      */
-    void Init(const Mac::Counters &aMacCounters);
+    void InitFrom(const Mac::Counters &aMacCounters);
 
     /**
-     * Indicates whether or not the TLV appears to be well-formed.
+     * Reads the counters from the TLV value.
      *
-     * @retval TRUE   If the TLV appears to be well-formed.
-     * @retval FALSE  If the TLV does not appear to be well-formed.
-     */
-    bool IsValid(void) const { return GetLength() >= sizeof(*this) - sizeof(Tlv); }
-
-    /**
-     * Reads the counters from TLV.
-     *
-     * @param[out] aDiagMacCounters   A reference to `NetworkDiagnostic::MacCounters` to populate.
+     * @param[out] aDiagMacCounters   A reference to `MacCounters` to populate.
      */
     void Read(MacCounters &aDiagMacCounters) const;
 
@@ -369,6 +369,11 @@
 } OT_TOOL_PACKED_END;
 
 /**
+ * Defines Mac Counters  TLV constants and types.
+ */
+typedef SimpleTlvInfo<Tlv::kMacCounters, MacCountersTlvValue> MacCountersTlv;
+
+/**
  * Implements Child Table TLV Entry generation and parsing.
  */
 OT_TOOL_PACKED_BEGIN
@@ -451,7 +456,7 @@
  * Implements Child TLV generation and parsing.
  */
 OT_TOOL_PACKED_BEGIN
-class ChildTlv : public Tlv, public TlvInfo<Tlv::kChild>, public Clearable<ChildTlv>
+class ChildTlvValue : public Clearable<ChildTlvValue>
 {
 public:
     static constexpr uint8_t kFlagsRxOnWhenIdle = 1 << 7; ///< Device mode - Rx-on when idle.
@@ -613,6 +618,11 @@
 } OT_TOOL_PACKED_END;
 
 /**
+ * Defines Child TLV constants and types.
+ */
+typedef SimpleTlvInfo<Tlv::kChild, ChildTlvValue> ChildTlv;
+
+/**
  * Implements Child IPv6 Address List Value generation and parsing.
  *
  * This TLV can use extended or normal format depending on the number of IPv6 addresses.
@@ -642,10 +652,10 @@
 } OT_TOOL_PACKED_END;
 
 /**
- * Implements Router Neighbor TLV generation and parsing.
+ * Implements Router Neighbor TLV Value generation and parsing.
  */
 OT_TOOL_PACKED_BEGIN
-class RouterNeighborTlv : public Tlv, public TlvInfo<Tlv::kRouterNeighbor>, public Clearable<RouterNeighborTlv>
+class RouterNeighborTlvValue : public Clearable<RouterNeighborTlvValue>
 {
 public:
     static constexpr uint8_t kFlagsTrackErrRate = 1 << 7; ///< Supports tracking error rates.
@@ -746,6 +756,11 @@
     uint16_t        mMessageErrorRate; // (IPv6) msg error rate (0x0000->0%, 0xffff->100%)
 } OT_TOOL_PACKED_END;
 
+/**
+ * Defines Router Neighbor TLV constants and types.
+ */
+typedef SimpleTlvInfo<Tlv::kRouterNeighbor, RouterNeighborTlvValue> RouterNeighborTlv;
+
 #endif // OPENTHREAD_FTD
 
 /**
@@ -802,10 +817,10 @@
 } OT_TOOL_PACKED_END;
 
 /**
- * Implements Answer TLV generation and parsing.
+ * Represents an Answer TLV value.
  */
 OT_TOOL_PACKED_BEGIN
-class AnswerTlv : public Tlv, public TlvInfo<Tlv::kAnswer>
+class AnswerTlvValue
 {
 public:
     enum IsLastFlag : uint8_t
@@ -815,7 +830,7 @@
     };
 
     /**
-     * Initializes the TLV.
+     * Initializes the TLV value.
      *
      * @param[in] aIndex       The index value.
      * @param[in] aIsLastFlag  Indicates the `IsLastFlag` value.
@@ -848,37 +863,33 @@
 } OT_TOOL_PACKED_END;
 
 /**
+ * Defines Answer TLV constants and types.
+ */
+typedef SimpleTlvInfo<Tlv::kAnswer, AnswerTlvValue> AnswerTlv;
+
+/**
  * Represents the MLE Counters.
  */
 typedef otNetworkDiagMleCounters MleCounters;
 
 /**
- * Implements MLE Counters TLV generation and parsing.
+ * Implements MLE Counters TLV value generation and parsing.
  */
 OT_TOOL_PACKED_BEGIN
-class MleCountersTlv : public Tlv, public TlvInfo<Tlv::kMleCounters>
+class MleCountersTlvValue
 {
 public:
     /**
-     * Initializes the TLV.
+     * Initializes the TLV value.
      *
      * @param[in] aMleCounters    The MLE counters to initialize the TLV with.
      */
-    void Init(const Mle::Counters &aMleCounters);
+    void InitFrom(const Mle::Counters &aMleCounters);
 
     /**
-     * Indicates whether or not the TLV appears to be well-formed.
+     * Reads the counters from TLV value
      *
-     * @retval TRUE   If the TLV appears to be well-formed.
-     * @retval FALSE  If the TLV does not appear to be well-formed.
-     */
-    bool IsValid(void) const { return GetLength() >= sizeof(*this) - sizeof(Tlv); }
-
-    /**
-     *
-     * Reads the counters from TLV.
-     *
-     * @param[out] aDiagMleCounters   A reference to `NetworkDiagnostic::MleCounters` to populate.
+     * @param[out] aDiagMleCounters   A reference to `NetDiag::MleCounters` to populate.
      */
     void Read(MleCounters &aDiagMleCounters) const;
 
@@ -900,10 +911,15 @@
     uint64_t mLeaderTime;                    // Milliseconds device has been in leader role.
 } OT_TOOL_PACKED_END;
 
-} // namespace NetworkDiagnostic
+/**
+ * Defines MLE Counters TLV constants and types.
+ */
+typedef SimpleTlvInfo<Tlv::kMleCounters, MleCountersTlvValue> MleCountersTlv;
 
-DefineCoreType(otNetworkDiagConnectivity, NetworkDiagnostic::Connectivity);
+} // namespace NetDiag
+
+DefineCoreType(otNetworkDiagConnectivity, NetDiag::Connectivity);
 
 } // namespace ot
 
-#endif // OT_CORE_THREAD_NETWORK_DIAGNOSTIC_TLVS_HPP_
+#endif // OT_CORE_THREAD_NET_DIAG_TLVS_HPP_
diff --git a/src/core/thread/net_diag_types.cpp b/src/core/thread/net_diag_types.cpp
new file mode 100644
index 0000000..94ec7eb
--- /dev/null
+++ b/src/core/thread/net_diag_types.cpp
@@ -0,0 +1,316 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file implements methods for Network Diagnostics helper types and classes.
+ */
+
+#include "net_diag_types.hpp"
+
+#include "instance/instance.hpp"
+
+namespace ot {
+namespace NetDiag {
+
+//---------------------------------------------------------------------------------------------------------------------
+// AnswerBuilder
+
+AnswerBuilder::AnswerBuilder(Instance &aInstance, AnswerType aType)
+    : InstanceLocator(aInstance)
+    , mAnswer(nullptr)
+    , mAnswerIndex(0)
+    , mQueryId(0)
+    , mType(aType)
+    , mPriority(Message::kPriorityNormal)
+    , mHasQueryId(false)
+{
+}
+
+AnswerBuilder::~AnswerBuilder(void) { mAnswers.DequeueAndFreeAll(); }
+
+Error AnswerBuilder::Start(const Coap::Message &aRequest)
+{
+    mAnswerIndex = 0;
+    mPriority    = aRequest.GetPriority();
+    mHasQueryId  = false;
+
+    switch (mType)
+    {
+    case kAnswerTypeNetDiag:
+        SuccessOrExit(Tlv::Find<QueryIdTlv>(aRequest, mQueryId));
+        break;
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+    case kAnswerTypeHistoryTracker:
+        SuccessOrExit(Tlv::Find<HistoryTracker::QueryIdTlv>(aRequest, mQueryId));
+        break;
+#endif
+    }
+
+    mHasQueryId = true;
+
+exit:
+    return Allocate();
+}
+
+Error AnswerBuilder::Finish(void) { return AppendAnswerTlv(AnswerTlvValue::kIsLast); }
+
+Error AnswerBuilder::Allocate(void)
+{
+    Error error = kErrorNone;
+    Uri   uri   = kUriDiagnosticGetAnswer;
+
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+    if (mType == kAnswerTypeHistoryTracker)
+    {
+        uri = kUriHistoryAnswer;
+    }
+#endif
+
+    mAnswer = Get<Tmf::Agent>().AllocateAndInitConfirmablePostMessage(uri);
+    VerifyOrExit(mAnswer != nullptr, error = kErrorNoBufs);
+
+    IgnoreError(mAnswer->SetPriority(mPriority));
+
+    mAnswers.Enqueue(*mAnswer);
+
+    VerifyOrExit(mHasQueryId);
+
+    switch (mType)
+    {
+    case kAnswerTypeNetDiag:
+        SuccessOrExit(error = Tlv::Append<QueryIdTlv>(*mAnswer, mQueryId));
+        break;
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+    case kAnswerTypeHistoryTracker:
+        SuccessOrExit(error = Tlv::Append<HistoryTracker::QueryIdTlv>(*mAnswer, mQueryId));
+        break;
+#endif
+    }
+
+exit:
+    return error;
+}
+
+Error AnswerBuilder::AppendAnswerTlv(AnswerTlvValue::IsLastFlag aFlag)
+{
+    Error          error = kErrorNone;
+    AnswerTlvValue value;
+
+    value.Init(mAnswerIndex++, aFlag);
+
+    switch (mType)
+    {
+    case kAnswerTypeNetDiag:
+        error = Tlv::Append<AnswerTlv>(*mAnswer, value);
+        break;
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+    case kAnswerTypeHistoryTracker:
+        error = Tlv::Append<HistoryTracker::AnswerTlv>(*mAnswer, value);
+        break;
+#endif
+    }
+
+    return error;
+}
+
+Error AnswerBuilder::CheckAnswerLength(void)
+{
+    Error error = kErrorNone;
+
+    VerifyOrExit(mAnswer->GetLength() >= kAnswerMessageLengthThreshold);
+
+    SuccessOrExit(error = AppendAnswerTlv(AnswerTlvValue::kMoreToFollow));
+
+    error = Allocate();
+
+exit:
+    return error;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// AnswerSender
+
+AnswerSender::AnswerSender(Instance &aInstance, AnswerType aType)
+    : InstanceLocator(aInstance)
+    , mType(aType)
+{
+}
+
+void AnswerSender::Send(AnswerBuilder &aAnswerBuilder, const Ip6::Address &aDestination)
+{
+    Coap::Message *firstAnswer = aAnswerBuilder.GetAnswers().GetHead();
+
+    VerifyOrExit(firstAnswer != nullptr);
+
+    mQueue.EnqueueAllFrom(aAnswerBuilder.GetAnswers());
+    SendNext(*firstAnswer, aDestination);
+
+exit:
+    return;
+}
+
+bool AnswerSender::IsLast(const Coap::Message &aAnswer) const
+{
+    // Indicates whether `aAnswer` is the last one associated with
+    // the same query.
+
+    bool           isLast = true;
+    Error          error  = kErrorNotFound;
+    AnswerTlvValue answerTlvValue;
+
+    switch (mType)
+    {
+    case kAnswerTypeNetDiag:
+        error = Tlv::Find<AnswerTlv>(aAnswer, answerTlvValue);
+        break;
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+    case kAnswerTypeHistoryTracker:
+        error = Tlv::Find<HistoryTracker::AnswerTlv>(aAnswer, answerTlvValue);
+        break;
+#endif
+    }
+
+    // If there is no Answer TLV, we assume it is the last answer.
+
+    SuccessOrExit(error);
+
+    isLast = answerTlvValue.IsLast();
+
+exit:
+    return isLast;
+}
+
+void AnswerSender::FreeAllRelated(Coap::Message &aAnswer)
+{
+    // This method dequeues and frees all answer messages related to
+    // same query as `aAnswer`. Note that related answers are always
+    // enqueued in order.
+
+    Coap::Message *answer = &aAnswer;
+
+    while (answer != nullptr)
+    {
+        Coap::Message *next = IsLast(*answer) ? nullptr : answer->GetNextCoapMessage();
+
+        mQueue.DequeueAndFree(*answer);
+        answer = next;
+    }
+}
+
+void AnswerSender::SendNext(Coap::Message &aAnswer, const Ip6::Address &aDestination)
+{
+    // This method sends the given next `aAnswer` associated with
+    // a query to the  `aDestination`.
+
+    Error                 error           = kErrorFailed;
+    Coap::Message        *nextAnswer      = IsLast(aAnswer) ? nullptr : aAnswer.GetNextCoapMessage();
+    Coap::ResponseHandler responseHandler = nullptr;
+
+    mQueue.Dequeue(aAnswer);
+
+    switch (mType)
+    {
+    case kAnswerTypeNetDiag:
+#if OPENTHREAD_FTD
+        responseHandler = AnswerSender::HandleResponse<kAnswerTypeNetDiag>;
+#endif
+        break;
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+    case kAnswerTypeHistoryTracker:
+        responseHandler = AnswerSender::HandleResponse<kAnswerTypeHistoryTracker>;
+        break;
+#endif
+    }
+
+    VerifyOrExit(responseHandler != nullptr);
+
+    // When sending the message, we pass `nextAnswer` as `aContext`
+    // to be used when invoking callback `responseHandler`
+
+    error = Get<Tmf::Agent>().SendMessageAllowMulticastLoop(aAnswer, aDestination, responseHandler, nextAnswer);
+
+exit:
+    if (error != kErrorNone)
+    {
+        // If the `SendMessage()` fails, we `Free` the dequeued
+        // `aAnswer` and all the related next answers in the queue.
+
+        aAnswer.Free();
+
+        if (nextAnswer != nullptr)
+        {
+            FreeAllRelated(*nextAnswer);
+        }
+    }
+}
+
+#if OPENTHREAD_FTD
+template <> AnswerSender &AnswerSender::GetSender<kAnswerTypeNetDiag>(Instance &aInstance)
+{
+    return aInstance.Get<Server>().mAnswerSender;
+}
+#endif
+
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+template <> AnswerSender &AnswerSender::GetSender<kAnswerTypeHistoryTracker>(Instance &aInstance)
+{
+    return aInstance.Get<HistoryTracker::Server>().mAnswerSender;
+}
+#endif
+
+template <AnswerType kType> void AnswerSender::HandleResponse(void *aContext, Coap::Msg *aMsg, Error aResult)
+{
+    Coap::Message *nextAnswer = static_cast<Coap::Message *>(aContext);
+
+    VerifyOrExit(nextAnswer != nullptr);
+    GetSender<kType>(nextAnswer->GetInstance()).HandleResponse(*nextAnswer, aMsg, aResult);
+
+exit:
+    return;
+}
+
+void AnswerSender::HandleResponse(Coap::Message &aNextAnswer, Coap::Msg *aResponse, Error aResult)
+{
+    Error error = aResult;
+
+    SuccessOrExit(error);
+    VerifyOrExit(aResponse != nullptr, error = kErrorDrop);
+    VerifyOrExit(aResponse->GetCode() == Coap::kCodeChanged, error = kErrorDrop);
+
+    SendNext(aNextAnswer, aResponse->mMessageInfo.GetPeerAddr());
+
+exit:
+    if (error != kErrorNone)
+    {
+        FreeAllRelated(aNextAnswer);
+    }
+}
+
+} // namespace NetDiag
+} // namespace ot
diff --git a/src/core/thread/net_diag_types.hpp b/src/core/thread/net_diag_types.hpp
new file mode 100644
index 0000000..54a22ce
--- /dev/null
+++ b/src/core/thread/net_diag_types.hpp
@@ -0,0 +1,207 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file includes definitions for Network Diagnostics helper types and classes.
+ */
+
+#ifndef OT_CORE_THREAD_NET_DIAG_TYPES_HPP_
+#define OT_CORE_THREAD_NET_DIAG_TYPES_HPP_
+
+#include "openthread-core-config.h"
+
+#include "net_diag_tlvs.hpp"
+#include "coap/coap_message.hpp"
+#include "common/error.hpp"
+#include "common/locator.hpp"
+#include "common/time.hpp"
+#include "thread/uri_paths.hpp"
+
+namespace ot {
+namespace NetDiag {
+
+/**
+ * Represents an Answer type (used in `AnswerBuilder`).
+ */
+enum AnswerType : uint8_t
+{
+    kAnswerTypeNetDiag, ///< A `NetDiag` answer.
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+    kAnswerTypeHistoryTracker, ///< A `HistoryTracker` answer.
+#endif
+};
+
+/**
+ * Represents an object for tracking and managing `NetDiag` or `HistoryTracker` answer messages.
+ *
+ * This class is used when the response to a query requires multiple CoAP answer messages. It manages the inclusion of
+ * the Query ID and the Answer TLV (providing message indexing and "more-to-follow" flags) in each allocated answer
+ * message, while maintaining all answer messages in a queue.
+ *
+ * The `AnswerType` determines the specific TLV types and the CoAP URI used for the allocated answer messages, either
+ * the Network Diagnostics TLVs or the History Tracker TLVs are used.
+ */
+class AnswerBuilder : public InstanceLocator
+{
+public:
+    /**
+     * Initializes the `AnswerBuilder`.
+     *
+     * @param[in] aInstance  The OpenThread instance.
+     * @param[in] aType      The `AnswerType`.
+     */
+    AnswerBuilder(Instance &aInstance, AnswerType aType);
+
+    /**
+     * Destructor for `AnswerBuilder`.
+     *
+     * Any remaining messages in the answers queue are freed.
+     */
+    ~AnswerBuilder(void);
+
+    /**
+     * Starts the building of answer messages based on a given request.
+     *
+     * This method searches for a Query ID TLV within @p aRequest. If present, the Query ID is captured and
+     * automatically included in all allocated answer messages. It also captures the priority from @p aRequest and
+     * uses it for all answer messages.
+     *
+     * @param[in] aRequest  The request message.
+     *
+     * @retval kErrorNone   Successfully started.
+     * @retval kErrorNoBufs Insufficient message buffers to allocate the first answer.
+     */
+    Error Start(const Coap::Message &aRequest);
+
+    /**
+     * Gets the current answer message.
+     *
+     * @returns The current answer message.
+     */
+    Coap::Message &GetAnswer(void) { return *mAnswer; }
+
+    /**
+     * Checks the current answer message length and allocates a new one if it exceeds the threshold.
+     *
+     * If the current answer message length is above the threshold, this method appends an Answer TLV with the
+     * "more to follow" flag set, and then allocates a new answer message, adding it to the answer queue.
+     *
+     * @retval kErrorNone   Successfully checked and handled (possibly allocating a new message).
+     * @retval kErrorNoBufs Insufficient message buffers to append Answer TLV or allocate a new message.
+     */
+    Error CheckAnswerLength(void);
+
+    /**
+     * Finishes the answer generation.
+     *
+     * This method appends an Answer TLV to the current answer message with the "is last" flag set.
+     *
+     * @retval kErrorNone   Successfully finished.
+     * @retval kErrorNoBufs Insufficient message buffers to append the Answer TLV.
+     */
+    Error Finish(void);
+
+    /**
+     * Gets the queue containing all answer messages.
+     *
+     * @returns The message queue containing all answer messages.
+     */
+    Coap::MessageQueue &GetAnswers(void) { return mAnswers; }
+
+private:
+    static constexpr uint16_t kAnswerMessageLengthThreshold = 800;
+
+    Error Allocate(void);
+    Error AppendAnswerTlv(AnswerTlvValue::IsLastFlag aFlag);
+
+    Coap::Message     *mAnswer;
+    Coap::MessageQueue mAnswers;
+    uint16_t           mAnswerIndex;
+    uint16_t           mQueryId;
+    AnswerType         mType;
+    Message::Priority  mPriority;
+    bool               mHasQueryId;
+};
+
+/**
+ * Represents an `AnswerSender` for sending Network Diagnostics or History Tracker answer messages.
+ *
+ * This class takes ownership of the answer messages generated by an `AnswerBuilder` and manages sending them to a
+ * destination address one by one. It handles confirmable CoAP POST messages and tracks the responses. If any message
+ * fails to send or receives an error response, all remaining related answers are automatically freed.
+ */
+class AnswerSender : public InstanceLocator
+{
+public:
+    /**
+     * Initializes the `AnswerSender`.
+     *
+     * @param[in] aInstance  The OpenThread instance.
+     * @param[in] aType      The `AnswerType` specifying either Network Diagnostics or History Tracker.
+     */
+    AnswerSender(Instance &aInstance, AnswerType aType);
+
+    /**
+     * Takes over all answer messages from @p aAnswerBuilder and starts sending them to @p aDestination.
+     *
+     * The `AnswerSender` enqueues all messages from the builder into its own queue. It then begins sending the first
+     * answer message in the queue.
+     *
+     * @param[in] aAnswerBuilder  The `AnswerBuilder` containing the generated answer messages.
+     * @param[in] aDestination    The destination IP address to send the answers to.
+     */
+    void Send(AnswerBuilder &aAnswerBuilder, const Ip6::Address &aDestination);
+
+private:
+    bool IsLast(const Coap::Message &aAnswer) const;
+    void FreeAllRelated(Coap::Message &aAnswer);
+    void SendNext(Coap::Message &aAnswer, const Ip6::Address &aDestination);
+    void HandleResponse(Coap::Message &aNextAnswer, Coap::Msg *aResponse, Error aResult);
+
+    template <AnswerType kType> static void          HandleResponse(void *aContext, Coap::Msg *aMsg, Error aResult);
+    template <AnswerType kType> static AnswerSender &GetSender(Instance &aInstance);
+
+    Coap::MessageQueue mQueue;
+    AnswerType         mType;
+};
+
+// Declare template specializations.
+
+#if OPENTHREAD_FTD
+template <> AnswerSender &AnswerSender::GetSender<kAnswerTypeNetDiag>(Instance &aInstance);
+#endif
+
+#if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
+template <> AnswerSender &AnswerSender::GetSender<kAnswerTypeHistoryTracker>(Instance &aInstance);
+#endif
+
+} // namespace NetDiag
+} // namespace ot
+
+#endif // OT_CORE_THREAD_NET_DIAG_TYPES_HPP_
diff --git a/src/core/thread/network_data.cpp b/src/core/thread/network_data.cpp
index e90daf2..94696a6 100644
--- a/src/core/thread/network_data.cpp
+++ b/src/core/thread/network_data.cpp
@@ -514,7 +514,7 @@
             case kAnyBrOrServer:
                 break;
             case kBrProvidingExternalIpConn:
-                matches = prefix.mOnMesh && (prefix.mDefaultRoute || prefix.mDp);
+                matches = prefix.mOnMesh && prefix.mDefaultRoute;
                 break;
             }
 
diff --git a/src/core/thread/network_data.hpp b/src/core/thread/network_data.hpp
index 05e5bfc..7555432 100644
--- a/src/core/thread/network_data.hpp
+++ b/src/core/thread/network_data.hpp
@@ -274,7 +274,6 @@
      *
      * - It has added at least one external route entry.
      * - It has added at least one prefix entry with default-route and on-mesh flags set.
-     * - It has added at least one domain prefix (domain and on-mesh flags set).
      *
      * Should be used when the RLOC16s are present in the Network Data (when the Network Data contains the
      * full set and not the stable subset).
@@ -292,7 +291,6 @@
      *
      * - It has added at least one external route entry.
      * - It has added at least one prefix entry with default-route and on-mesh flags set.
-     * - It has added at least one domain prefix (domain and on-mesh flags set).
      *
      * Should be used when the RLOC16s are present in the Network Data (when the Network Data contains the
      * full set and not the stable subset).
@@ -311,7 +309,6 @@
      *
      * - It has added at least one external route entry.
      * - It has added at least one prefix entry with default-route and on-mesh flags set.
-     * - It has added at least one domain prefix (domain and on-mesh flags set).
      *
      * Should be used when the RLOC16s are present in the Network Data (when the Network Data contains the
      * full set and not the stable subset).
diff --git a/src/core/thread/network_data_leader.cpp b/src/core/thread/network_data_leader.cpp
index 74fe96b..3c9fa47 100644
--- a/src/core/thread/network_data_leader.cpp
+++ b/src/core/thread/network_data_leader.cpp
@@ -57,8 +57,8 @@
 
 void Leader::Reset(void)
 {
-    mVersion       = Random::NonCrypto::GetUint8();
-    mStableVersion = Random::NonCrypto::GetUint8();
+    mVersion       = Random::NonCrypto::Generate<uint8_t>();
+    mStableVersion = Random::NonCrypto::Generate<uint8_t>();
     SetLength(0);
     SignalNetDataChanged();
 
@@ -586,8 +586,7 @@
             uint8_t             type;
             const MeshCoP::Tlv *subTlv;
 
-            IgnoreError(aMessage.Read(offsetRange, type));
-            offsetRange.AdvanceOffset(sizeof(type));
+            IgnoreError(aMessage.ReadAndAdvance(offsetRange, type));
 
             subTlv = FindCommissioningDataSubTlv(type);
 
diff --git a/src/core/thread/network_data_leader_ftd.cpp b/src/core/thread/network_data_leader_ftd.cpp
index 2629d18..e4c37c9 100644
--- a/src/core/thread/network_data_leader_ftd.cpp
+++ b/src/core/thread/network_data_leader_ftd.cpp
@@ -221,8 +221,8 @@
 
 template <> void Leader::HandleTmf<kUriServerData>(Coap::Msg &aMsg)
 {
-    ThreadNetworkDataTlv networkDataTlv;
-    uint16_t             rloc16;
+    uint16_t    rloc16;
+    OffsetRange offsetRange;
 
     VerifyOrExit(Get<Mle::Mle>().IsLeader() && !mWaitingForNetDataSync);
 
@@ -241,12 +241,16 @@
         ExitNow();
     }
 
-    if (Tlv::FindTlv(aMsg.mMessage, networkDataTlv) == kErrorNone)
+    if (Tlv::FindTlvValueOffsetRange(aMsg.mMessage, ThreadNetworkDataTlv::kType, offsetRange) == kErrorNone)
     {
-        VerifyOrExit(networkDataTlv.IsValid());
+        uint8_t bytes[kMaxSize];
+
+        VerifyOrExit(offsetRange.GetLength() <= kMaxSize);
+
+        aMsg.mMessage.ReadBytes(offsetRange, bytes);
 
         {
-            NetworkData networkData(GetInstance(), networkDataTlv.GetTlvs(), networkDataTlv.GetLength());
+            NetworkData networkData(GetInstance(), bytes, static_cast<uint8_t>(offsetRange.GetLength()));
 
             RegisterNetworkData(aMsg.mMessageInfo.GetPeerAddr().GetIid().GetLocator(), networkData);
         }
@@ -320,18 +324,11 @@
 
 void Leader::SendCommissioningSetResponse(const Coap::Msg &aMsg, MeshCoP::StateTlv::State aState)
 {
-    Coap::Message *message = Get<Tmf::Agent>().AllocateAndInitPriorityResponseFor(aMsg.mMessage);
-
-    VerifyOrExit(message != nullptr);
-    SuccessOrExit(Tlv::Append<MeshCoP::StateTlv>(*message, aState));
-
-    SuccessOrExit(Get<Tmf::Agent>().SendMessage(*message, aMsg.mMessageInfo));
-    message = nullptr; // `SendMessage` takes ownership on success
-
+    SuccessOrExit(Get<Tmf::Agent>().SendResponseWithStateTlv(aMsg, aState));
     LogInfo("Sent %s response", UriToString<kUriCommissionerSet>());
 
 exit:
-    FreeMessage(message);
+    return;
 }
 
 bool Leader::RlocMatch(uint16_t aFirstRloc16, uint16_t aSecondRloc16, MatchMode aMatchMode)
diff --git a/src/core/thread/network_data_notifier.cpp b/src/core/thread/network_data_notifier.cpp
index e1685a2..63b2189 100644
--- a/src/core/thread/network_data_notifier.cpp
+++ b/src/core/thread/network_data_notifier.cpp
@@ -191,8 +191,8 @@
 
     if (aNetworkData != nullptr)
     {
-        SuccessOrExit(error = Tlv::AppendTlv(*message, ThreadTlv::kThreadNetworkData, aNetworkData->GetBytes(),
-                                             aNetworkData->GetLength()));
+        SuccessOrExit(
+            error = Tlv::Append<ThreadNetworkDataTlv>(*message, aNetworkData->GetBytes(), aNetworkData->GetLength()));
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BORDER_ROUTER_SIGNAL_NETWORK_DATA_FULL
         Get<Leader>().CheckForNetDataGettingFull(*aNetworkData, aOldRloc16);
@@ -279,7 +279,7 @@
     uint16_t rloc16     = Get<Mle::Mle>().GetRloc16();
     uint8_t  activeRouterCount;
 
-    VerifyOrExit(Get<Mle::Mle>().IsRouterEligible());
+    VerifyOrExit(Get<Mle::Mle>().IsRouterRoleAllowed());
 
     // RouterUpgradeThreshold can be explicitly set to zero in some of
     // cert tests to disallow device to become router.
@@ -321,7 +321,7 @@
     VerifyOrExit(Get<Mle::Mle>().IsChild());
     VerifyOrExit(IsEligibleForRouterRoleUpgradeAsBorderRouter() && (mRouterRoleUpgradeTimeout == 0));
 
-    mRouterRoleUpgradeTimeout = Random::NonCrypto::GetUint8InRange(1, kRouterRoleUpgradeMaxTimeout + 1);
+    mRouterRoleUpgradeTimeout = Random::NonCrypto::GenerateInClosedRange<uint8_t>(1, kRouterRoleUpgradeMaxTimeout);
     Get<TimeTicker>().RegisterReceiver(TimeTicker::kNetworkDataNotifier);
 
 exit:
diff --git a/src/core/thread/network_data_notifier.hpp b/src/core/thread/network_data_notifier.hpp
index 79b4803..e8e1da9 100644
--- a/src/core/thread/network_data_notifier.hpp
+++ b/src/core/thread/network_data_notifier.hpp
@@ -105,7 +105,6 @@
      *
      * - It has added at least one external route entry.
      * - It has added at least one prefix entry with default-route and on-mesh flags set.
-     * - It has added at least one domain prefix (domain and on-mesh flags set).
      *
      * Does not check the current role of device.
      *
diff --git a/src/core/thread/network_data_publisher.cpp b/src/core/thread/network_data_publisher.cpp
index f880c87..35b7554 100644
--- a/src/core/thread/network_data_publisher.cpp
+++ b/src/core/thread/network_data_publisher.cpp
@@ -305,7 +305,7 @@
 
         if (aNumEntries < aDesiredNumEntries)
         {
-            mUpdateTime = now + Random::NonCrypto::GetUint32InRange(1, kMaxDelayToAdd);
+            mUpdateTime = now + Random::NonCrypto::GenerateInClosedRange<uint32_t>(1, kMaxDelayToAdd);
             SetState(kAdding);
             Get<Publisher>().GetTimer().FireAtIfEarlier(mUpdateTime);
             LogUpdateTime(now);
@@ -345,7 +345,7 @@
 
             if (aDesiredNumEntries > 0)
             {
-                mUpdateTime += Random::NonCrypto::GetUint32InRange(1, kMaxDelayToRemove);
+                mUpdateTime += Random::NonCrypto::GenerateInClosedRange<uint32_t>(1, kMaxDelayToRemove);
 
                 if (aNumPreferredEntries < aDesiredNumEntries)
                 {
diff --git a/src/core/thread/network_data_service.cpp b/src/core/thread/network_data_service.cpp
index dce8f14..a4e1b90 100644
--- a/src/core/thread/network_data_service.cpp
+++ b/src/core/thread/network_data_service.cpp
@@ -83,7 +83,7 @@
                 const Manager::DnsSrpAnycastServiceData *anycastData =
                     reinterpret_cast<const Manager::DnsSrpAnycastServiceData *>(mServiceTlv->GetServiceData());
 
-                Get<Mle::Mle>().GetServiceAloc(mServiceTlv->GetServiceId(), aInfo.mAnycastAddress);
+                Get<Mle::Mle>().ComposeServiceAloc(mServiceTlv->GetServiceId(), aInfo.mAnycastAddress);
                 aInfo.mSequenceNumber = anycastData->GetSequenceNumber();
                 aInfo.mRloc16         = mServerSubTlv->GetServer16();
                 aInfo.mVersion =
@@ -158,8 +158,7 @@
                 // contains a port number and use the RLOC as the
                 // IPv6 address.
 
-                aInfo.mSockAddr.GetAddress().SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(),
-                                                                 mServerSubTlv->GetServer16());
+                Get<Mle::Mle>().ComposeRloc(mServerSubTlv->GetServer16(), aInfo.mSockAddr.GetAddress());
                 aInfo.mSockAddr.SetPort(BigEndian::ReadUint16(mServerSubTlv->GetServerData()));
                 aInfo.mVersion = 0;
                 ExitNow();
@@ -382,7 +381,7 @@
 
     serviceData.InitFrom(bbrServiceNumber);
 
-    aConfig.mServer16 = Mle::kInvalidRloc16;
+    aConfig.MarkAsAbsent();
 
     while ((serviceTlv = Get<Leader>().FindNextThreadService(serviceTlv, serviceData,
                                                              NetworkData::kServicePrefixMatch)) != nullptr)
@@ -552,7 +551,7 @@
 Manager::ServiceAloc::ServiceAloc(void)
 {
     InitAsThreadOriginMeshLocal();
-    GetAddress().GetIid().SetToLocator(kNotInUse);
+    GetAddress().GetIid().InitAsLocator(kNotInUse);
 }
 
 #endif
diff --git a/src/core/thread/network_data_tlvs.hpp b/src/core/thread/network_data_tlvs.hpp
index 273c895..a9c8fea 100644
--- a/src/core/thread/network_data_tlvs.hpp
+++ b/src/core/thread/network_data_tlvs.hpp
@@ -656,7 +656,7 @@
      *
      * @param[out] aPrefix  An `Ip6::Prefix` to copy the Prefix from TLV into.
      */
-    void CopyPrefixTo(Ip6::Prefix &aPrefix) const { aPrefix.Set(GetPrefix(), GetPrefixLength()); }
+    void CopyPrefixTo(Ip6::Prefix &aPrefix) const { aPrefix.InitFrom(GetPrefix(), GetPrefixLength()); }
 
     /**
      * Indicates whether the Prefix from TLV is equal to a given `Ip6::Prefix`.
@@ -946,14 +946,6 @@
     bool IsNdDns(void) const { return (BigEndian::HostSwap16(mFlags) & kNdDnsFlag) != 0; }
 
     /**
-     * Indicates whether or not the Domain Prefix flag is set.
-     *
-     * @retval TRUE   If the Domain Prefix flag is set.
-     * @retval FALSE  If the Domain Prefix flag is not set.
-     */
-    bool IsDp(void) const { return (BigEndian::HostSwap16(mFlags) & kDpFlag) != 0; }
-
-    /**
      * Returns a pointer to the next BorderRouterEntry
      *
      * @returns A pointer to the next BorderRouterEntry.
@@ -999,7 +991,7 @@
     static constexpr uint16_t kDefaultRouteFlag = 1 << 9;
     static constexpr uint16_t kOnMeshFlag       = 1 << 8;
     static constexpr uint16_t kNdDnsFlag        = 1 << 7;
-    static constexpr uint16_t kDpFlag           = 1 << 6;
+    // Flag bit 6 (1 << 6) was previously kDpFlag (Domain Prefix)
 
     uint16_t mRloc;
     uint16_t mFlags;
@@ -1128,7 +1120,10 @@
      * @retval TRUE   If the TLV appears to be well-formed.
      * @retval FALSE  If the TLV does not appear to be well-formed.
      */
-    bool IsValid(void) const { return GetLength() >= sizeof(*this) - sizeof(NetworkDataTlv); }
+    bool IsValid(void) const
+    {
+        return (GetLength() >= sizeof(*this) - sizeof(NetworkDataTlv)) && (GetContextId() != 0);
+    }
 
     /**
      * Indicates whether or not the Compress flag is set.
diff --git a/src/core/thread/network_data_types.cpp b/src/core/thread/network_data_types.cpp
index b1176b0..71985a7 100644
--- a/src/core/thread/network_data_types.cpp
+++ b/src/core/thread/network_data_types.cpp
@@ -116,13 +116,6 @@
         flags |= BorderRouterEntry::kNdDnsFlag;
     }
 
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-    if (mDp)
-    {
-        flags |= BorderRouterEntry::kDpFlag;
-    }
-#endif
-
     flags |= (static_cast<uint16_t>(RoutePreferenceToValue(mPreference)) << BorderRouterEntry::kPreferenceOffset);
 
     return flags;
@@ -151,7 +144,7 @@
     mDefaultRoute = ((aFlags & BorderRouterEntry::kDefaultRouteFlag) != 0);
     mOnMesh       = ((aFlags & BorderRouterEntry::kOnMeshFlag) != 0);
     mNdDns        = ((aFlags & BorderRouterEntry::kNdDnsFlag) != 0);
-    mDp           = ((aFlags & BorderRouterEntry::kDpFlag) != 0);
+    mDp           = false;
     mPreference   = RoutePreferenceFromValue(static_cast<uint8_t>(aFlags >> BorderRouterEntry::kPreferenceOffset));
 }
 
diff --git a/src/core/thread/network_data_types.hpp b/src/core/thread/network_data_types.hpp
index 65a1844..e6f54b3 100644
--- a/src/core/thread/network_data_types.hpp
+++ b/src/core/thread/network_data_types.hpp
@@ -112,7 +112,6 @@
  *
  * - It has added at least one external route entry.
  * - It has added at least one prefix entry with default-route and on-mesh flags set.
- * - It has added at least one domain prefix (domain and on-mesh flags set).
  */
 enum BorderRouterFilter : uint8_t
 {
diff --git a/src/core/thread/peer.hpp b/src/core/thread/peer.hpp
index 9b2b4ff..81fe47f 100644
--- a/src/core/thread/peer.hpp
+++ b/src/core/thread/peer.hpp
@@ -81,7 +81,10 @@
      *
      * @returns The link local IPv6 address of the peer.
      */
-    void GetLinkLocalIp6Address(Ip6::Address &aIp6Address) const { aIp6Address.SetToLinkLocalAddress(GetExtAddress()); }
+    void GetLinkLocalIp6Address(Ip6::Address &aIp6Address) const
+    {
+        aIp6Address.InitAsLinkLocalAddress(GetExtAddress());
+    }
 
     /**
      * Generates a new challenge value to use during a child attach.
diff --git a/src/core/thread/peer_table.cpp b/src/core/thread/peer_table.cpp
index f4b19d6..bdda522 100644
--- a/src/core/thread/peer_table.cpp
+++ b/src/core/thread/peer_table.cpp
@@ -51,7 +51,7 @@
 {
     mItem = &Get<PeerTable>().mPeers[0];
 
-    if (!mItem->MatchesFilter(mFilter))
+    if (!mItem->Matches(mFilter))
     {
         Advance();
     }
@@ -65,7 +65,7 @@
     {
         mItem++;
         VerifyOrExit(mItem < &Get<PeerTable>().mPeers[Get<PeerTable>().kMaxPeers], mItem = nullptr);
-    } while (!mItem->MatchesFilter(mFilter));
+    } while (!mItem->Matches(mFilter));
 
 exit:
     return;
diff --git a/src/core/thread/radio_selector.cpp b/src/core/thread/radio_selector.cpp
index f0a0c19..0a9dde0 100644
--- a/src/core/thread/radio_selector.cpp
+++ b/src/core/thread/radio_selector.cpp
@@ -309,7 +309,7 @@
 
 #if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
     if (!selections.Contains(Mac::kRadioTypeTrel) && neighbor->GetSupportedRadioTypes().Contains(Mac::kRadioTypeTrel) &&
-        (Random::NonCrypto::GetUint8InRange(0, 100) < kTrelProbeProbability))
+        (Random::NonCrypto::GenerateUpToExcluding<uint8_t>(100) < kTrelProbeProbability))
     {
         aTxFrames.SetRequiredRadioTypes(selections);
         selections.Add(Mac::kRadioTypeTrel);
@@ -361,7 +361,7 @@
     String<kRadioPreferenceStringSize> preferenceString;
     bool                               isFirstEntry = true;
 
-    VerifyOrExit(Instance::GetLogLevel() >= aLogLevel);
+    VerifyOrExit(GetInstance().GetLogLevel() >= aLogLevel);
 
     for (Mac::RadioType radio : sRadioSelectionOrder)
     {
diff --git a/src/core/thread/router_table.cpp b/src/core/thread/router_table.cpp
index d708525..a4f3154 100644
--- a/src/core/thread/router_table.cpp
+++ b/src/core/thread/router_table.cpp
@@ -41,7 +41,7 @@
     , mRouters(aInstance)
     , mChangedTask(aInstance)
     , mRouterIdSequenceLastUpdated(0)
-    , mRouterIdSequence(Random::NonCrypto::GetUint8())
+    , mRouterIdSequence(Random::NonCrypto::Generate<uint8_t>())
     , mEvents(0)
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     , mMinRouterId(0)
@@ -61,10 +61,10 @@
     SignalTableChanged(events);
 }
 
-bool RouterTable::IsRouteTlvIdSequenceMoreRecent(const Mle::RouteTlv &aRouteTlv) const
+bool RouterTable::IsRouteTlvIdSequenceMoreRecent(const Mle::RouteTlv::Data &aRouteTlvData) const
 {
     return (GetActiveRouterCount() == 0) ||
-           SerialNumber::IsGreater(aRouteTlv.GetRouterIdSequence(), GetRouterIdSequence());
+           SerialNumber::IsGreater(aRouteTlvData.GetRouterIdSequence(), GetRouterIdSequence());
 }
 
 void RouterTable::ClearNeighbors(void)
@@ -168,7 +168,7 @@
             // selected ID with current entry in the list with
             // probably `1/numAvailable`.
 
-            if (Random::NonCrypto::GetUint8InRange(0, numAvailable) == 0)
+            if (Random::NonCrypto::GenerateUpToExcluding(numAvailable) == 0)
             {
                 selectedRouterId = routerId;
             }
@@ -317,7 +317,10 @@
     return FindRouterById(Mle::RouterIdFromRloc16(aRloc16));
 }
 
-const Router *RouterTable::FindNextHopOf(const Router &aRouter) const { return FindRouterById(aRouter.GetNextHop()); }
+const Router *RouterTable::FindNextHopTowards(const Router &aRouter) const
+{
+    return FindRouterById(aRouter.GetNextHop());
+}
 
 Router *RouterTable::FindRouter(const Mac::ExtAddress &aExtAddress)
 {
@@ -431,7 +434,7 @@
     }
 
     router  = FindRouterById(Mle::RouterIdFromRloc16(aDestRloc16));
-    nextHop = (router != nullptr) ? FindNextHopOf(*router) : nullptr;
+    nextHop = (router != nullptr) ? FindNextHopTowards(*router) : nullptr;
 
     if (Get<Mle::Mle>().IsChild())
     {
@@ -522,19 +525,27 @@
     return nextHopRloc16;
 }
 
-void RouterTable::UpdateRouterIdSet(uint8_t aRouterIdSequence, const Mle::RouterIdSet &aRouterIdSet)
+void RouterTable::UpdateRouterIdMask(const Mle::RouteTlv::Data &aRouteTlvData)
+{
+    Mle::RouterIdMask routerIdMask;
+
+    aRouteTlvData.DetermineRouterIdMask(routerIdMask);
+    UpdateRouterIdMask(routerIdMask);
+}
+
+void RouterTable::UpdateRouterIdMask(const Mle::RouterIdMask &aRouterIdMask)
 {
     bool shouldAdd = false;
 
-    mRouterIdSequence            = aRouterIdSequence;
+    mRouterIdSequence            = aRouterIdMask.GetSequence();
     mRouterIdSequenceLastUpdated = TimerMilli::GetNow();
 
     // Remove all previously allocated routers that are now removed in
-    // new `aRouterIdSet`.
+    // new `aRouterIdMask`.
 
     for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
     {
-        if (IsAllocated(routerId) == aRouterIdSet.Contains(routerId))
+        if (IsAllocated(routerId) == aRouterIdMask.IsAllocated(routerId))
         {
             continue;
         }
@@ -556,11 +567,11 @@
 
     VerifyOrExit(shouldAdd);
 
-    // Now add all new routers in `aRouterIdSet`.
+    // Now add all new routers in `aRouterIdMask`.
 
     for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
     {
-        if (!IsAllocated(routerId) && aRouterIdSet.Contains(routerId))
+        if (!IsAllocated(routerId) && aRouterIdMask.IsAllocated(routerId))
         {
             AddRouter(routerId);
         }
@@ -572,11 +583,12 @@
     return;
 }
 
-void RouterTable::UpdateRoutes(const Mle::RouteTlv &aRouteTlv, uint8_t aNeighborId)
+void RouterTable::UpdateRoutes(const Mle::RouteTlv::Data &aRouteTlvData, uint8_t aNeighborId)
 {
-    Router          *neighbor;
-    Mle::RouterIdSet finitePathCostIdSet;
-    uint8_t          linkCostToNeighbor;
+    Router                           *neighbor;
+    Mle::RouterIdMask                 finitePathCostIds;
+    uint8_t                           linkCostToNeighbor;
+    const Mle::RouteTlv::Data::Entry *matchingEntry;
 
     neighbor = FindRouterById(aNeighborId);
     VerifyOrExit(neighbor != nullptr);
@@ -586,86 +598,71 @@
     // cost changed from finite to infinite or vice versa to decide
     // whether to reset the  MLE Advertisement interval.
 
-    finitePathCostIdSet.Clear();
+    finitePathCostIds.Clear();
 
     for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
     {
         if (GetPathCost(Mle::Rloc16FromRouterId(routerId)) < Mle::kMaxRouteCost)
         {
-            finitePathCostIdSet.Add(routerId);
+            finitePathCostIds.Add(routerId);
         }
     }
 
     // Find the entry corresponding to our Router ID in the received
-    // `aRouteTlv` to get the `LinkQualityIn` from the perspective of
-    // neighbor. We use this to update our `LinkQualityOut` to the
+    // `aRouteTlvData` to get the `LinkQualityIn` from the perspective
+    // of neighbor. We use this to update our `LinkQualityOut` to the
     // neighbor.
 
-    for (uint8_t routerId = 0, index = 0; routerId <= Mle::kMaxRouterId;
-         index += aRouteTlv.IsRouterIdSet(routerId) ? 1 : 0, routerId++)
+    matchingEntry = aRouteTlvData.GetEntries().FindMatching(Mle::RouterIdFromRloc16(Get<Mle::Mle>().GetRloc16()));
+
+    if (matchingEntry != nullptr)
     {
-        if (!Get<Mle::Mle>().MatchesRouterId(routerId))
+        LinkQuality linkQuality = matchingEntry->GetLinkQualityIn();
+
+        if (neighbor->GetLinkQualityOut() != linkQuality)
         {
-            continue;
+            neighbor->SetLinkQualityOut(linkQuality);
+            SignalTableChanged(kEventLinkQualityOutChanged);
         }
 
-        if (aRouteTlv.IsRouterIdSet(routerId))
+        // If the `aRouteTlvData` indicates that the neighboring
+        // router claims to have no link to us (by setting its
+        // `GetLinkQualityOut()` towards us as `kLinkQuality0`),
+        // and we have previously established a link with it, and
+        // our two-way link quality to this router is at least
+        // `kLinkQuality2`, we schedule a unicast Advertisement to
+        // be sent to this neighbor. This helps expedite recovery
+        // from any temporary router link quality mismatch.
+        // Otherwise, the neighboring router will continue to
+        // advertise that it has no link to us until our next
+        // trickle timer-triggered Advertisement transmission
+        // (which can be up to 32 seconds later).
+
+        if (neighbor->IsStateValid() && (matchingEntry->GetLinkQualityOut() == kLinkQuality0) &&
+            (neighbor->GetTwoWayLinkQuality() >= kLinkQuality2))
         {
-            LinkQuality linkQuality = aRouteTlv.GetLinkQualityIn(index);
-
-            if (neighbor->GetLinkQualityOut() != linkQuality)
-            {
-                neighbor->SetLinkQualityOut(linkQuality);
-                SignalTableChanged(kEventLinkQualityOutChanged);
-            }
-
-            // If the `aRouteTlv` indicates that the neighboring
-            // router claims to have no link to us (by setting its
-            // `GetLinkQualityOut()` towards us as `kLinkQuality0`),
-            // and we have previously established a link with it, and
-            // our two-way link quality to this router is at least
-            // `kLinkQuality2`, we schedule a unicast Advertisement to
-            // be sent to this neighbor. This helps expedite recovery
-            // from any temporary router link quality mismatch.
-            // Otherwise, the neighboring router will continue to
-            // advertise that it has no link to us until our next
-            // trickle timer-triggered Advertisement transmission
-            // (which can be up to 32 seconds later).
-
-            if (neighbor->IsStateValid() && (aRouteTlv.GetLinkQualityOut(index) == kLinkQuality0) &&
-                (neighbor->GetTwoWayLinkQuality() >= kLinkQuality2))
-            {
-                Get<Mle::Mle>().ScheduleUnicastAdvertisementTo(*neighbor);
-            }
+            Get<Mle::Mle>().ScheduleUnicastAdvertisementTo(*neighbor);
         }
-
-        break;
     }
 
     linkCostToNeighbor = GetLinkCost(*neighbor);
 
-    for (uint8_t routerId = 0, index = 0; routerId <= Mle::kMaxRouterId;
-         index += aRouteTlv.IsRouterIdSet(routerId) ? 1 : 0, routerId++)
+    for (const Mle::RouteTlv::Data::Entry &entry : aRouteTlvData.GetEntries())
     {
         Router *router;
         Router *nextHop;
         uint8_t cost;
 
-        if (!aRouteTlv.IsRouterIdSet(routerId))
-        {
-            continue;
-        }
-
-        router = FindRouterById(routerId);
+        router = FindRouterById(entry.GetRouterId());
 
         if (router == nullptr || Get<Mle::Mle>().HasRloc16(router->GetRloc16()) || router == neighbor)
         {
             continue;
         }
 
-        nextHop = FindNextHopOf(*router);
+        nextHop = FindNextHopTowards(*router);
 
-        cost = aRouteTlv.GetRouteCost(index);
+        cost = entry.GetRouteCost();
         cost = (cost == 0) ? Mle::kMaxRouteCost : cost;
 
         if ((nextHop == nullptr) || (nextHop == neighbor))
@@ -701,7 +698,7 @@
 
     for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
     {
-        bool oldCostFinite = finitePathCostIdSet.Contains(routerId);
+        bool oldCostFinite = finitePathCostIds.IsAllocated(routerId);
         bool newCostFinite = (GetPathCost(Mle::Rloc16FromRouterId(routerId)) < Mle::kMaxRouteCost);
 
         if (newCostFinite != oldCostFinite)
@@ -715,28 +712,27 @@
     return;
 }
 
-void RouterTable::UpdateRouterOnFtdChild(const Mle::RouteTlv &aRouteTlv, uint8_t aParentId)
+void RouterTable::UpdateRouterOnFtdChild(const Mle::RouteTlv::Data &aRouteTlvData, uint8_t aParentId)
 {
-    for (uint8_t routerId = 0, index = 0; routerId <= Mle::kMaxRouterId;
-         index += aRouteTlv.IsRouterIdSet(routerId) ? 1 : 0, routerId++)
+    for (const Mle::RouteTlv::Data::Entry &entry : aRouteTlvData.GetEntries())
     {
         Router *router;
         uint8_t cost;
         uint8_t nextHopId;
 
-        if (!aRouteTlv.IsRouterIdSet(routerId) || (routerId == aParentId))
+        if (entry.GetRouterId() == aParentId)
         {
             continue;
         }
 
-        router = FindRouterById(routerId);
+        router = FindRouterById(entry.GetRouterId());
 
         if (router == nullptr)
         {
             continue;
         }
 
-        cost      = aRouteTlv.GetRouteCost(index);
+        cost      = entry.GetRouteCost();
         nextHopId = (cost == 0) ? Mle::kInvalidRouterId : aParentId;
 
         if (router->SetNextHopAndCost(nextHopId, cost))
@@ -746,18 +742,38 @@
     }
 }
 
-void RouterTable::FillRouteTlv(Mle::RouteTlv &aRouteTlv, const Neighbor *aNeighbor) const
+void RouterTable::GetRouterIdMask(Mle::RouterIdMask &aRouterIdMask) const
 {
-    uint8_t          routerIdSequence = mRouterIdSequence;
-    Mle::RouterIdSet routerIdSet;
-    uint8_t          routerIndex;
+    aRouterIdMask.Clear();
+    aRouterIdMask.SetSequence(GetRouterIdSequence());
 
-    mRouterIdMap.GetAsRouterIdSet(routerIdSet);
-
-    if ((aNeighbor != nullptr) && Mle::IsRouterRloc16(aNeighbor->GetRloc16()))
+    for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
     {
-        // Sending a Link Accept message that may require truncation
-        // of Route64 TLV.
+        if (IsAllocated(routerId))
+        {
+            aRouterIdMask.Add(routerId);
+        }
+    }
+}
+
+Error RouterTable::AppendRouteTlv(Message &aMessage, uint8_t aTlvType, uint16_t aDestRloc16) const
+{
+    Error             error;
+    Tlv::Bookmark     tlvBookmark;
+    Mle::RouterIdMask routerIdMask;
+#if OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+    bool isEven = true;
+#endif
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Determine the Router ID mask to use.
+
+    GetRouterIdMask(routerIdMask);
+
+    if (aDestRloc16 != Mle::kInvalidRloc16)
+    {
+        // If a valid destination RLOC16 is provided, we use a compact
+        // format for the Route TLV (used for Link Accept messages).
 
         uint8_t routerCount = mRouters.GetLength();
 
@@ -770,38 +786,45 @@
                     break;
                 }
 
-                if (Get<Mle::Mle>().MatchesRouterId(routerId) || (routerId == aNeighbor->GetRouterId()) ||
+                // The Route TLV must include entries for this device, the
+                // leader, and the destination router (the neighbor itself or
+                // its parent if it is a child).
+
+                if (Get<Mle::Mle>().MatchesRouterId(routerId) || (routerId == Mle::RouterIdFromRloc16(aDestRloc16)) ||
                     (routerId == Get<Mle::Mle>().GetLeaderId()))
                 {
-                    // Route64 TLV must contain this device and the
-                    // neighboring router to ensure that at least this
-                    // link can be established.
                     continue;
                 }
 
-                if (routerIdSet.Contains(routerId))
+                if (routerIdMask.IsAllocated(routerId))
                 {
-                    routerIdSet.Remove(routerId);
+                    routerIdMask.Remove(routerId);
                     routerCount--;
                 }
             }
 
             // Ensure that the neighbor will process the current
-            // Route64 TLV in a subsequent message exchange
-            routerIdSequence -= kLinkAcceptSequenceRollback;
+            // Route TLV in a subsequent message exchange
+            routerIdMask.SetSequence(GetRouterIdSequence() - kLinkAcceptSequenceRollback);
         }
     }
 
-    aRouteTlv.SetRouterIdSequence(routerIdSequence);
-    aRouteTlv.SetRouterIdMask(routerIdSet);
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Append TLV: Router ID Mask followed by Route Data Entries per
+    // allocated router in the mask
 
-    routerIndex = 0;
+    SuccessOrExit(error = Tlv::StartTlv(aMessage, aTlvType, tlvBookmark));
+
+    SuccessOrExit(error = aMessage.Append(routerIdMask));
 
     for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
     {
-        uint16_t routerRloc16;
+        uint16_t    routerRloc16;
+        LinkQuality lqIn;
+        LinkQuality lqOut;
+        uint8_t     routeCost;
 
-        if (!routerIdSet.Contains(routerId))
+        if (!routerIdMask.IsAllocated(routerId))
         {
             continue;
         }
@@ -810,29 +833,31 @@
 
         if (Get<Mle::Mle>().HasRloc16(routerRloc16))
         {
-            aRouteTlv.SetRouteData(routerIndex, kLinkQuality0, kLinkQuality0, 1);
+            lqIn      = kLinkQuality0;
+            lqOut     = kLinkQuality0;
+            routeCost = 1;
         }
         else
         {
             const Router *router = FindRouterById(routerId);
-            uint8_t       pathCost;
 
-            OT_ASSERT(router != nullptr);
-
-            pathCost = GetPathCost(routerRloc16);
-
-            if (pathCost >= Mle::kMaxRouteCost)
-            {
-                pathCost = 0;
-            }
-
-            aRouteTlv.SetRouteData(routerIndex, router->GetLinkQualityIn(), router->GetLinkQualityOut(), pathCost);
+            lqIn      = router->GetLinkQualityIn();
+            lqOut     = router->GetLinkQualityOut();
+            routeCost = GetPathCost(routerRloc16);
         }
 
-        routerIndex++;
+#if !OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+        SuccessOrExit(error = Mle::RouteTlv::AppendRouteDataEntry(aMessage, lqIn, lqOut, routeCost));
+#else
+        SuccessOrExit(error = Mle::RouteTlv::AppendRouteDataEntry(aMessage, lqIn, lqOut, routeCost, isEven));
+        isEven = !isEven;
+#endif
     }
 
-    aRouteTlv.SetRouteDataLength(routerIndex);
+    error = Tlv::EndTlv(aMessage, tlvBookmark);
+
+exit:
+    return error;
 }
 
 void RouterTable::HandleTimeTick(void)
@@ -873,19 +898,6 @@
 }
 #endif
 
-void RouterTable::RouterIdMap::GetAsRouterIdSet(Mle::RouterIdSet &aRouterIdSet) const
-{
-    aRouterIdSet.Clear();
-
-    for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
-    {
-        if (IsAllocated(routerId))
-        {
-            aRouterIdSet.Add(routerId);
-        }
-    }
-}
-
 void RouterTable::RouterIdMap::HandleTimeTick(void)
 {
     for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
@@ -917,7 +929,7 @@
     Get<HistoryTracker::Local>().RecordRouterTableChange();
 #endif
 
-    Get<Mle::Mle>().UpdateAdvertiseInterval();
+    Get<Mle::Mle>().HandleRouterTableEvent(mEvents);
 
     mEvents = 0;
 }
diff --git a/src/core/thread/router_table.hpp b/src/core/thread/router_table.hpp
index 2276432..824c92f 100644
--- a/src/core/thread/router_table.hpp
+++ b/src/core/thread/router_table.hpp
@@ -242,22 +242,22 @@
     const Router *FindRouterByRloc16(uint16_t aRloc16) const;
 
     /**
-     * Finds the router that is the next hop of a given router.
+     * Finds the router that is the next hop towards a given router.
      *
-     * @param[in]  aRouter  The router to find next hop of.
+     * @param[in]  aRouter  The destination router.
      *
      * @returns A pointer to the router or `nullptr` if the router could not be found.
      */
-    Router *FindNextHopOf(const Router &aRouter) { return AsNonConst(AsConst(this)->FindNextHopOf(aRouter)); }
+    Router *FindNextHopTowards(const Router &aRouter) { return AsNonConst(AsConst(this)->FindNextHopTowards(aRouter)); }
 
     /**
-     * Finds the router that is the next hop of a given router.
+     * Finds the router that is the next hop towards a given router.
      *
-     * @param[in]  aRouter  The router to find next hop of.
+     * @param[in]  aRouter  The destination router.
      *
      * @returns A pointer to the router or `nullptr` if the router could not be found.
      */
-    const Router *FindNextHopOf(const Router &aRouter) const;
+    const Router *FindNextHopTowards(const Router &aRouter) const;
 
     /**
      * Find the router for a given MAC Extended Address.
@@ -277,10 +277,7 @@
      * @retval FALSE if @p aNeighbor is not a `Router` in the router table
      *               (i.e. it can be the parent or parent candidate, or a `Child` of the child table).
      */
-    bool Contains(const Neighbor &aNeighbor) const
-    {
-        return mRouters.IsInArrayBuffer(&static_cast<const Router &>(aNeighbor));
-    }
+    bool Contains(const Neighbor &aNeighbor) const { return mRouters.IsInArrayBuffer(&aNeighbor); }
 
     /**
      * Retains diagnostic information for a given router.
@@ -312,12 +309,12 @@
      * Determines whether the Router ID Sequence in a received Route TLV is more recent than the current
      * Router ID Sequence being used by `RouterTable`.
      *
-     * @param[in] aRouteTlv   The Route TLV to compare.
+     * @param[in] aRouteTlvData   The Route TLV Data to compare.
      *
-     * @retval TRUE    The Router ID Sequence in @p aRouteTlv is more recent.
-     * @retval FALSE   The Router ID Sequence in @p aRouteTlv is not more recent.
+     * @retval TRUE    The Router ID Sequence in @p aRouteTlvData is more recent.
+     * @retval FALSE   The Router ID Sequence in @p aRouteTlvData is not more recent.
      */
-    bool IsRouteTlvIdSequenceMoreRecent(const Mle::RouteTlv &aRouteTlv) const;
+    bool IsRouteTlvIdSequenceMoreRecent(const Mle::RouteTlv::Data &aRouteTlvData) const;
 
     /**
      * Gets the number of router neighbors with `GetLinkQualityIn()` better than or equal to a given threshold.
@@ -339,49 +336,60 @@
     bool IsAllocated(uint8_t aRouterId) const { return mRouterIdMap.IsAllocated(aRouterId); }
 
     /**
-     * Updates the Router ID allocation set.
+     * Updates the allocated Router ID mask.
      *
-     * @param[in]  aRouterIdSequence  The Router ID Sequence.
-     * @param[in]  aRouterIdSet       The Router ID Set.
+     * @param[in]  aRouterIdMask      The Router ID Mask.
      */
-    void UpdateRouterIdSet(uint8_t aRouterIdSequence, const Mle::RouterIdSet &aRouterIdSet);
+    void UpdateRouterIdMask(const Mle::RouterIdMask &aRouterIdMask);
 
     /**
-     * Updates the routes based on a received `RouteTlv` from a neighboring router.
+     * Updates the allocated Router ID mask from a received `RouteTlv::Data`.
      *
-     * @param[in]  aRouteTlv    The received `RouteTlv`
-     * @param[in]  aNeighborId  The router ID of neighboring router from which @p aRouteTlv is received.
+     * @param[in]  aRouteTlvData      The received `RouteTlv::Data`.
      */
-    void UpdateRoutes(const Mle::RouteTlv &aRouteTlv, uint8_t aNeighborId);
+    void UpdateRouterIdMask(const Mle::RouteTlv::Data &aRouteTlvData);
 
     /**
-     * Updates the routes on an FTD child based on a received `RouteTlv` from the parent.
+     * Updates the routes based on a received `RouteTlv::Data` from a neighboring router.
      *
-     * MUST be called when device is an FTD child and @p aRouteTlv is received from its current parent.
+     * @param[in]  aRouteTlvData    The received `RouteTlv::Data`
+     * @param[in]  aNeighborId  The router ID of neighboring router from which @p aRouteTlvData is received.
+     */
+    void UpdateRoutes(const Mle::RouteTlv::Data &aRouteTlvData, uint8_t aNeighborId);
+
+    /**
+     * Updates the routes on an FTD child based on a received `RouteTlv::Data` from the parent.
      *
-     * @param[in]  aRouteTlv    The received `RouteTlv` from parent.
+     * MUST be called when device is an FTD child and @p aRouteTlvData is received from its current parent.
+     *
+     * @param[in]  aRouteTlvData    The received `RouteTlvData` from parent.
      * @param[in]  aParentId    The Router ID of parent.
      */
-    void UpdateRouterOnFtdChild(const Mle::RouteTlv &aRouteTlv, uint8_t aParentId);
+    void UpdateRouterOnFtdChild(const Mle::RouteTlv::Data &aRouteTlvData, uint8_t aParentId);
 
     /**
-     * Gets the allocated Router ID set.
+     * Gets the allocated Router ID Mask.
      *
-     * @param[out]  aRouterIdSet   A reference to output the allocated Router ID set.
+     * @param[out]  aRouterIdMask   A reference to output the allocated Router ID Mask.
      */
-    void GetRouterIdSet(Mle::RouterIdSet &aRouterIdSet) const { return mRouterIdMap.GetAsRouterIdSet(aRouterIdSet); }
+    void GetRouterIdMask(Mle::RouterIdMask &aRouterIdMask) const;
 
     /**
-     * Fills a Route TLV.
+     * Appends a Route TLV to a given message.
      *
-     * When @p aNeighbor is not `nullptr`, we limit the number of router entries to `kMaxRoutersInRouteTlvForLinkAccept`
-     * when populating `aRouteTlv`, so that the TLV can be appended in a Link Accept message. In this case, we ensure
-     * to include router entries associated with @p aNeighbor, leader, and this device itself.
+     * If @p aDestRloc16 is not `Mle::kInvalidRloc16`, a compact format is used for the Route TLV by limiting the
+     * number of router entries to `kMaxRoutersInRouteTlvForLinkAccept`. This is used for Link Accept messages. In this
+     * case, we ensure that entries for this device, the leader, and the destination router (itself or its parent if it
+     * is a child) are always included.
      *
-     * @param[out] aRouteTlv    A Route TLV to be filled.
-     * @param[in]  aNeighbor    A pointer to the receiver (in case TLV is for a Link Accept message).
+     * @param[in,out] aMessage      The message to append the Route TLV to.
+     * @param[in]     aTlvType      The TLV type to use (e.g., `Tlv::kRoute`).
+     * @param[in]     aDestRloc16   The destination RLOC16. Used to determine whether to use the compact format.
+     *
+     * @retval kErrorNone     Successfully appended the Route TLV.
+     * @retval kErrorNoBufs   Insufficient available buffers to append the TLV.
      */
-    void FillRouteTlv(Mle::RouteTlv &aRouteTlv, const Neighbor *aNeighbor = nullptr) const;
+    Error AppendRouteTlv(Message &aMessage, uint8_t aTlvType, uint16_t aDestRloc16 = Mle::kInvalidRloc16) const;
 
     /**
      * Updates the router table and must be called with a one second period.
@@ -462,7 +470,6 @@
         void    SetIndex(uint8_t aRouterId, uint8_t aIndex) { mIndexes[aRouterId] = kAllocatedFlag | aIndex; }
         bool    CanAllocate(uint8_t aRouterId) const { return (mIndexes[aRouterId] == 0); }
         void    Release(uint8_t aRouterId) { mIndexes[aRouterId] = kReuseDelay; }
-        void    GetAsRouterIdSet(Mle::RouterIdSet &aRouterIdSet) const;
         void    HandleTimeTick(void);
 
     private:
diff --git a/src/core/thread/thread_link_info.cpp b/src/core/thread/thread_link_info.cpp
index 9b1bf92..2e7ecd8 100644
--- a/src/core/thread/thread_link_info.cpp
+++ b/src/core/thread/thread_link_info.cpp
@@ -55,31 +55,23 @@
         mIsDstPanIdBroadcast = (dstPanId == Mac::kPanIdBroadcast);
     }
 
-    if (aFrame.GetSecurityEnabled())
-    {
-        uint8_t keyIdMode;
+    mLinkSecurity = aFrame.IsSecuredWith(Mac::RxFrame::kAllowKeyIdMode0 | Mac::RxFrame::kAllowKeyIdMode1);
+    mChannel      = aFrame.GetChannel();
+    mRss          = aFrame.GetRssi();
+    mLqi          = aFrame.GetLqi();
 
-        // MAC Frame Security was already validated at the MAC
-        // layer. As a result, `GetKeyIdMode()` will never return
-        // failure here.
-        IgnoreError(aFrame.GetKeyIdMode(keyIdMode));
-
-        mLinkSecurity = (keyIdMode == Mac::Frame::kKeyIdMode0) || (keyIdMode == Mac::Frame::kKeyIdMode1);
-    }
-    else
-    {
-        mLinkSecurity = false;
-    }
-    mChannel = aFrame.GetChannel();
-    mRss     = aFrame.GetRssi();
-    mLqi     = aFrame.GetLqi();
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
-    if (aFrame.GetTimeIe() != nullptr)
     {
-        mNetworkTimeOffset = aFrame.ComputeNetworkTimeOffset();
-        mTimeSyncSeq       = aFrame.ReadTimeSyncSeq();
+        const Mac::TimeIe *timeIe = aFrame.Find<Mac::TimeIe>();
+
+        if (timeIe != nullptr)
+        {
+            mNetworkTimeOffset = static_cast<int64_t>(timeIe->GetTime() - aFrame.GetTimestamp());
+            mTimeSyncSeq       = timeIe->GetSequence();
+        }
     }
 #endif
+
 #if OPENTHREAD_CONFIG_MULTI_RADIO
     mRadioType = static_cast<uint8_t>(aFrame.GetRadioType());
 #endif
diff --git a/src/core/thread/thread_tlvs.cpp b/src/core/thread/thread_tlvs.cpp
new file mode 100644
index 0000000..a0936e6
--- /dev/null
+++ b/src/core/thread/thread_tlvs.cpp
@@ -0,0 +1,76 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file implements methods for generating and processing Thread Network Layer TLVs.
+ */
+
+#include "thread_tlvs.hpp"
+
+namespace ot {
+
+#if OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2
+
+Error Ip6AddressesTlv::AppendTo(Message &aMessage, const Ip6::Address *aAddresses, uint16_t aNumAddresses)
+{
+    Error         error;
+    Tlv::Bookmark tlvBookmark;
+
+    SuccessOrExit(error = Tlv::StartTlv(aMessage, kType, tlvBookmark));
+    SuccessOrExit(error = aMessage.AppendBytes(aAddresses, aNumAddresses * sizeof(Ip6::Address)));
+    error = Tlv::EndTlv(aMessage, tlvBookmark);
+
+exit:
+    return error;
+}
+
+Error Ip6AddressesTlv::FindIn(const Message &aMessage, Mlr::AddressArray &aAddresses)
+{
+    Error       error;
+    OffsetRange offsetRange;
+
+    aAddresses.Clear();
+
+    SuccessOrExit(error = Tlv::FindTlvValueOffsetRange(aMessage, kType, offsetRange));
+
+    while (!offsetRange.IsEmpty())
+    {
+        Ip6::Address address;
+
+        SuccessOrExit(error = aMessage.ReadAndAdvance(offsetRange, address));
+        SuccessOrExit(error = aAddresses.AddUnique(address));
+    }
+
+exit:
+    return error;
+}
+
+#endif // OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2
+
+} // namespace ot
diff --git a/src/core/thread/thread_tlvs.hpp b/src/core/thread/thread_tlvs.hpp
index ef22048..726d2ec 100644
--- a/src/core/thread/thread_tlvs.hpp
+++ b/src/core/thread/thread_tlvs.hpp
@@ -42,6 +42,7 @@
 #include "meshcop/network_name.hpp"
 #include "net/ip6_address.hpp"
 #include "thread/mle_types.hpp"
+#include "thread/mlr_types.hpp"
 
 namespace ot {
 
@@ -137,148 +138,50 @@
 typedef UintTlvInfo<ThreadTlv::kStatus, uint8_t> ThreadStatusTlv;
 
 /**
- * Implements Router Mask TLV generation and parsing.
+ * Defines Router Mask TLV  constants and types.
  */
-class ThreadRouterMaskTlv : public ThreadTlv, public TlvInfo<ThreadTlv::kRouterMask>
-{
-public:
-    /**
-     * Initializes the TLV.
-     */
-    void Init(void)
-    {
-        SetType(kRouterMask);
-        SetLength(sizeof(*this) - sizeof(ThreadTlv));
-        mAssignedRouterIdMask.Clear();
-    }
-
-    /**
-     * Indicates whether or not the TLV appears to be well-formed.
-     *
-     * @retval TRUE   If the TLV appears to be well-formed.
-     * @retval FALSE  If the TLV does not appear to be well-formed.
-     */
-    bool IsValid(void) const { return GetLength() >= sizeof(*this) - sizeof(ThreadTlv); }
-
-    /**
-     * Returns the ID Sequence value.
-     *
-     * @returns The ID Sequence value.
-     */
-    uint8_t GetIdSequence(void) const { return mIdSequence; }
-
-    /**
-     * Sets the ID Sequence value.
-     *
-     * @param[in]  aSequence  The ID Sequence value.
-     */
-    void SetIdSequence(uint8_t aSequence) { mIdSequence = aSequence; }
-
-    /**
-     * Gets the Assigned Router ID Mask.
-     *
-     * @returns The Assigned Router ID Mask.
-     */
-    const Mle::RouterIdSet &GetAssignedRouterIdMask(void) const { return mAssignedRouterIdMask; }
-
-    /**
-     * Gets the Assigned Router ID Mask.
-     *
-     * @returns The Assigned Router ID Mask.
-     */
-    Mle::RouterIdSet &GetAssignedRouterIdMask(void) { return mAssignedRouterIdMask; }
-
-    /**
-     * Sets the Assigned Router ID Mask.
-     *
-     * @param[in]  aRouterIdSet A reference to the Assigned Router ID Mask.
-     */
-    void SetAssignedRouterIdMask(const Mle::RouterIdSet &aRouterIdSet) { mAssignedRouterIdMask = aRouterIdSet; }
-
-private:
-    uint8_t          mIdSequence;
-    Mle::RouterIdSet mAssignedRouterIdMask;
-};
+typedef SimpleTlvInfo<ThreadTlv::kRouterMask, Mle::RouterIdMask> ThreadRouterMaskTlv;
 
 /**
- * Implements Thread Network Data TLV generation and parsing.
+ * Defines Thread Network Data TLV constants and types.
  */
-OT_TOOL_PACKED_BEGIN
-class ThreadNetworkDataTlv : public ThreadTlv, public TlvInfo<ThreadTlv::kThreadNetworkData>
-{
-public:
-    /**
-     * Initializes the TLV.
-     */
-    void Init(void)
-    {
-        SetType(kThreadNetworkData);
-        SetLength(0);
-    }
-
-    /**
-     * Overrides same method of the base class
-     *
-     * @retval TRUE  the TLV appears to be well-formed.
-     */
-    bool IsValid(void) const { return true; }
-
-    /**
-     * Returns a pointer to the Network Data TLVs.
-     *
-     * @returns A pointer to the Network Data TLVs.
-     */
-    uint8_t *GetTlvs(void) { return mTlvs; }
-
-private:
-    static constexpr uint8_t kMaxSize = 255;
-
-    uint8_t mTlvs[kMaxSize];
-} OT_TOOL_PACKED_END;
+typedef TlvInfo<ThreadTlv::kThreadNetworkData> ThreadNetworkDataTlv;
 
 #if OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2
 
 /**
- * Implements IPv6 Addresses TLV generation and parsing.
+ * Defines IPv6 Addresses TLV constants and types and helper methods.
  */
-OT_TOOL_PACKED_BEGIN
-class Ip6AddressesTlv : public ThreadTlv, public TlvInfo<ThreadTlv::kIp6Addresses>
+class Ip6AddressesTlv : public TlvInfo<ThreadTlv::kIp6Addresses>
 {
 public:
-    // Thread 1.2.0 5.19.13 limits the number of IPv6 addresses to [1, 15].
-    static constexpr uint8_t kMinAddresses = 1;
-    static constexpr uint8_t kMaxAddresses = OT_IP6_MAX_MLR_ADDRESSES;
+    /**
+     * Appends an IPv6 Addresses TLV to a message.
+     *
+     * @param[in,out] aMessage       The message to append to.
+     * @param[in]     aAddresses     A pointer to an array of IPv6 addresses.
+     * @param[in]     aNumAddresses  The number of IPv6 addresses in the @p aAddresses array.
+     *
+     * @retval kErrorNone     Successfully appended the TLV.
+     * @retval kErrorNoBufs   Insufficient available buffers to grow the message.
+     */
+    static Error AppendTo(Message &aMessage, const Ip6::Address *aAddresses, uint16_t aNumAddresses);
 
     /**
-     * Initializes the TLV.
+     * Finds and parses the IPv6 Addresses TLV from a given message.
+     *
+     * @param[in]  aMessage    The message to parse.
+     * @param[out] aAddresses  An `AddressArray` to output the parsed IPv6 addresses.
+     *
+     * @retval kErrorNone       Successfully found and parsed the TLV.
+     * @retval kErrorNotFound   Could not find the TLV in the message.
+     * @retval kErrorParse      Failed to parse the TLV.
+     * @retval kErrorNoBufs     There are more addresses in the TLV than can fit in `aAddresses`.
      */
-    void Init(void) { SetType(kIp6Addresses); }
+    static Error FindIn(const Message &aMessage, Mlr::AddressArray &aAddresses);
 
-    /**
-     * Indicates whether or not the TLV appears to be well-formed.
-     *
-     * @retval TRUE   If the TLV appears to be well-formed.
-     * @retval FALSE  If the TLV does not appear to be well-formed.
-     */
-    bool IsValid(void) const
-    {
-        return GetLength() >= sizeof(Ip6::Address) * Ip6AddressesTlv::kMinAddresses &&
-               GetLength() <= sizeof(Ip6::Address) * Ip6AddressesTlv::kMaxAddresses &&
-               (GetLength() % sizeof(Ip6::Address)) == 0;
-    }
-
-    /**
-     * Returns a pointer to the IPv6 address entry.
-     *
-     * @param[in]  aIndex  The index into the IPv6 address list.
-     *
-     * @returns A reference to the IPv6 address.
-     */
-    const Ip6::Address &GetIp6Address(uint8_t aIndex) const
-    {
-        return *reinterpret_cast<const Ip6::Address *>(GetValue() + (aIndex * sizeof(Ip6::Address)));
-    }
-} OT_TOOL_PACKED_END;
+    Ip6AddressesTlv(void) = delete;
+};
 
 #endif // OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2
 
diff --git a/src/core/thread/time_sync_service.hpp b/src/core/thread/time_sync_service.hpp
index 1e0c01b..bd81c28 100644
--- a/src/core/thread/time_sync_service.hpp
+++ b/src/core/thread/time_sync_service.hpp
@@ -38,6 +38,11 @@
 
 #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
 
+#if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
+#error "TIME_SYNC feature is not supported under RADIO_LINK_TREL. " \
+       "TIME_SYNC is an experimental feature that only works on 15.4 radio."
+#endif
+
 #include <openthread/network_time.h>
 
 #include "common/as_core_type.hpp"
diff --git a/src/core/thread/tmf.cpp b/src/core/thread/tmf.cpp
index f24dd22..19543a8 100644
--- a/src/core/thread/tmf.cpp
+++ b/src/core/thread/tmf.cpp
@@ -123,20 +123,16 @@
         Case(kUriRelayRx, Agent);
 #endif
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE || (OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_DUA_ENABLE)
-        Case(kUriDuaRegistrationNotify, DuaManager);
-#endif
-
 #if OPENTHREAD_CONFIG_TMF_ANYCAST_LOCATOR_ENABLE
         Case(kUriAnycastLocate, AnycastLocator);
 #endif
 
-        Case(kUriDiagnosticGetRequest, NetworkDiagnostic::Server);
-        Case(kUriDiagnosticGetQuery, NetworkDiagnostic::Server);
-        Case(kUriDiagnosticReset, NetworkDiagnostic::Server);
+        Case(kUriDiagnosticGetRequest, NetDiag::Server);
+        Case(kUriDiagnosticGetQuery, NetDiag::Server);
+        Case(kUriDiagnosticReset, NetDiag::Server);
 
 #if OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE
-        Case(kUriDiagnosticGetAnswer, NetworkDiagnostic::Client);
+        Case(kUriDiagnosticGetAnswer, NetDiag::Client);
 #endif
 
 #if OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE && OPENTHREAD_CONFIG_HISTORY_TRACKER_SERVER_ENABLE
@@ -150,9 +146,6 @@
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
         Case(kUriMlr, BackboneRouter::Manager);
 #endif
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-        Case(kUriDuaRegistrationRequest, BackboneRouter::Manager);
-#endif
 #endif
 #if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
         Case(kUriTcatEnable, MeshCoP::TcatAgent);
@@ -173,10 +166,16 @@
 
 Error Agent::Filter(const Msg &aRxMsg) const
 {
-    return IsTmfMessage(aRxMsg.mMessageInfo.GetPeerAddr(), aRxMsg.mMessageInfo.GetSockAddr(),
-                        aRxMsg.mMessageInfo.GetSockPort())
-               ? kErrorNone
-               : kErrorNotTmf;
+    Error error = kErrorNone;
+
+    VerifyOrExit(IsTmfMessage(aRxMsg.mMessageInfo.GetPeerAddr(), aRxMsg.mMessageInfo.GetSockAddr(),
+                              aRxMsg.mMessageInfo.GetSockPort()),
+                 error = kErrorNotTmf);
+
+    VerifyOrExit(aRxMsg.mMessage.IsLinkSecurityEnabled(), error = kErrorSecurity);
+
+exit:
+    return error;
 }
 
 bool Agent::IsTmfMessage(const Ip6::Address &aSourceAddress, const Ip6::Address &aDestAddress, uint16_t aDestPort) const
@@ -248,7 +247,7 @@
 {
     Ip6::MessageInfo messageInfo;
 
-    messageInfo.GetPeerAddr().SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), aRloc16);
+    Get<Mle::Mle>().ComposeRloc(aRloc16, messageInfo.GetPeerAddr());
     PrepareMessageInfo(messageInfo);
 
     return SendMessage(aMessage, messageInfo, aHandler, aContext);
@@ -260,7 +259,7 @@
 {
     Ip6::MessageInfo messageInfo;
 
-    Get<Mle::Mle>().GetLeaderAloc(messageInfo.GetPeerAddr());
+    Get<Mle::Mle>().ComposeLeaderAloc(messageInfo.GetPeerAddr());
     PrepareMessageInfo(messageInfo);
 
     return SendMessage(aMessage, messageInfo, aHandler, aContext);
@@ -276,6 +275,22 @@
                                  : Get<Mle::Mle>().GetMeshLocalRloc());
 }
 
+Error Agent::SendResponseWithStateTlv(const Msg &aRequest, uint8_t aState)
+{
+    Error    error = kErrorNone;
+    Message *message;
+
+    message = AllocateAndInitPriorityResponseFor(aRequest.mMessage);
+    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
+
+    SuccessOrExit(error = Tlv::Append<MeshCoP::StateTlv>(*message, aState));
+    SuccessOrExit(error = SendMessage(*message, aRequest.mMessageInfo));
+
+exit:
+    FreeMessageOnError(message, error);
+    return error;
+}
+
 uint8_t Agent::PriorityToDscp(Message::Priority aPriority)
 {
     uint8_t dscp = Ip6::kDscpTmfNormalPriority;
diff --git a/src/core/thread/tmf.hpp b/src/core/thread/tmf.hpp
index 24fa49c..830fe91 100644
--- a/src/core/thread/tmf.hpp
+++ b/src/core/thread/tmf.hpp
@@ -224,6 +224,17 @@
     Error SendMessageToLeaderAloc(Message &aMessage, ResponseHandler aHandler, void *aContext);
 
     /**
+     * Sends a TMF response containing a State TLV.
+     *
+     * @param[in] aRequest  The incoming TMF request message.
+     * @param[in] aState    The state value to append in the State TLV.
+     *
+     * @retval kErrorNone     Successfully sent the response.
+     * @retval kErrorNoBufs   Insufficient available buffers to allocate the response.
+     */
+    Error SendResponseWithStateTlv(const Msg &aRequest, uint8_t aState);
+
+    /**
      * Converts a TMF message priority to IPv6 header DSCP value.
      *
      * @param[in] aPriority  The message priority to convert.
diff --git a/src/core/thread/uri_paths.cpp b/src/core/thread/uri_paths.cpp
index 1164a7c..2290235 100644
--- a/src/core/thread/uri_paths.cpp
+++ b/src/core/thread/uri_paths.cpp
@@ -59,9 +59,7 @@
     _("a/as", kUriAddressSolicit, "AddrSolicit")                  \
     _("a/sd", kUriServerData, "ServerData")                       \
     _("a/yl", kUriAnycastLocate, "AnycastLocate")                 \
-    _("b/ba", kUriBackboneAnswer, "BbAnswer")                     \
     _("b/bmr", kUriBackboneMlr, "BbMlr")                          \
-    _("b/bq", kUriBackboneQuery, "BbQuery")                       \
     _("c/ab", kUriAnnounceBegin, "AnnounceBegin")                 \
     _("c/ag", kUriActiveGet, "ActiveGet")                         \
     _("c/ar", kUriActiveReplace, "ActiveReplace")                 \
@@ -97,8 +95,6 @@
     _("d/dr", kUriDiagnosticReset, "DiagReset")                   \
     _("h/an", kUriHistoryAnswer, "HistAnswer")                    \
     _("h/qy", kUriHistoryQuery, "HistQuery")                      \
-    _("n/dn", kUriDuaRegistrationNotify, "DuaRegNotify")          \
-    _("n/dr", kUriDuaRegistrationRequest, "DuaRegRequest")        \
     _("n/mr", kUriMlr, "Mlr")
 
 // We use the X-Macro pattern here. The `UriEntryMapList` macro defines the
diff --git a/src/core/thread/uri_paths.hpp b/src/core/thread/uri_paths.hpp
index 46efd9a..166e472 100644
--- a/src/core/thread/uri_paths.hpp
+++ b/src/core/thread/uri_paths.hpp
@@ -45,55 +45,51 @@
  */
 enum Uri : uint8_t
 {
-    kUriAddressError,           ///< Address Error ("a/ae")
-    kUriAddressNotify,          ///< Address Notify ("a/an")
-    kUriAddressQuery,           ///< Address Query ("a/aq")
-    kUriAddressRelease,         ///< Address Release ("a/ar")
-    kUriAddressSolicit,         ///< Address Solicit ("a/as")
-    kUriServerData,             ///< Server Data Registration ("a/sd")
-    kUriAnycastLocate,          ///< Anycast Locate ("a/yl")
-    kUriBackboneAnswer,         ///< Backbone Answer / Backbone Notification ("b/ba")
-    kUriBackboneMlr,            ///< Backbone Multicast Listener Report ("b/bmr")
-    kUriBackboneQuery,          ///< Backbone Query ("b/bq")
-    kUriAnnounceBegin,          ///< Announce Begin ("c/ab")
-    kUriActiveGet,              ///< MGMT_ACTIVE_GET "c/ag"
-    kUriActiveReplace,          ///< MGMT_ACTIVE_REPLACE ("c/ar")
-    kUriActiveSet,              ///< MGMT_ACTIVE_SET ("c/as")
-    kUriCommissionerKeepAlive,  ///< Commissioner Keep Alive ("c/ca")
-    kUriCommissionerGet,        ///< MGMT_COMMISSIONER_GET ("c/cg")
-    kUriCommissionerPetition,   ///< Commissioner Petition ("c/cp")
-    kUriCommissionerSet,        ///< MGMT_COMMISSIONER_SET ("c/cs")
-    kUriDatasetChanged,         ///< MGMT_DATASET_CHANGED ("c/dc")
-    kUriEnergyReport,           ///< Energy Report ("c/er")
-    kUriEnergyScan,             ///< Energy Scan ("c/es")
-    kUriJoinerEntrust,          ///< Joiner Entrust  ("c/je")
-    kUriJoinerFinalize,         ///< Joiner Finalize ("c/jf")
-    kUriLeaderKeepAlive,        ///< Leader Keep Alive ("c/la")
-    kUriLeaderPetition,         ///< Leader Petition ("c/lp")
-    kUriEnrollerJoinerAccept,   ///< Enroller Joiner Accept ("c/nj")
-    kUriEnrollerKeepAlive,      ///< Enroller KeepAlive ("c/nk")
-    kUriEnrollerJoinerRelease,  ///< Enroller Joiner Release ("c/nl")
-    kUriEnrollerRegister,       ///< Enroller Register ("c/nr")
-    kUriEnrollerReportState,    ///< Report Admitter State (to enroller) ("c/ns")
-    kUriPanIdConflict,          ///< PAN ID Conflict ("c/pc")
-    kUriPendingGet,             ///< MGMT_PENDING_GET ("c/pg")
-    kUriPanIdQuery,             ///< PAN ID Query ("c/pq")
-    kUriPendingSet,             ///< MGMT_PENDING_SET ("c/ps")
-    kUriRelayRx,                ///< Relay RX ("c/rx")
-    kUriTcatEnable,             ///< TCAT Enable ("c/te")
-    kUriRelayTx,                ///< Relay TX ("c/tx")
-    kUriProxyRx,                ///< Proxy RX ("c/ur")
-    kUriProxyTx,                ///< Proxy TX ("c/ut")
-    kUriDiagnosticGetAnswer,    ///< Network Diagnostic Get Answer ("d/da")
-    kUriDiagnosticGetRequest,   ///< Network Diagnostic Get Request ("d/dg")
-    kUriDiagnosticGetQuery,     ///< Network Diagnostic Get Query ("d/dq")
-    kUriDiagnosticReset,        ///< Network Diagnostic Reset ("d/dr")
-    kUriHistoryAnswer,          ///< History Answer ("h/an")
-    kUriHistoryQuery,           ///< History Query ("h/qy")
-    kUriDuaRegistrationNotify,  ///< DUA Registration Notification ("n/dn")
-    kUriDuaRegistrationRequest, ///< DUA Registration Request ("n/dr")
-    kUriMlr,                    ///< Multicast Listener Registration ("n/mr")
-    kUriUnknown,                ///< Unknown URI
+    kUriAddressError,          ///< Address Error ("a/ae")
+    kUriAddressNotify,         ///< Address Notify ("a/an")
+    kUriAddressQuery,          ///< Address Query ("a/aq")
+    kUriAddressRelease,        ///< Address Release ("a/ar")
+    kUriAddressSolicit,        ///< Address Solicit ("a/as")
+    kUriServerData,            ///< Server Data Registration ("a/sd")
+    kUriAnycastLocate,         ///< Anycast Locate ("a/yl")
+    kUriBackboneMlr,           ///< Backbone Multicast Listener Report ("b/bmr")
+    kUriAnnounceBegin,         ///< Announce Begin ("c/ab")
+    kUriActiveGet,             ///< MGMT_ACTIVE_GET "c/ag"
+    kUriActiveReplace,         ///< MGMT_ACTIVE_REPLACE ("c/ar")
+    kUriActiveSet,             ///< MGMT_ACTIVE_SET ("c/as")
+    kUriCommissionerKeepAlive, ///< Commissioner Keep Alive ("c/ca")
+    kUriCommissionerGet,       ///< MGMT_COMMISSIONER_GET ("c/cg")
+    kUriCommissionerPetition,  ///< Commissioner Petition ("c/cp")
+    kUriCommissionerSet,       ///< MGMT_COMMISSIONER_SET ("c/cs")
+    kUriDatasetChanged,        ///< MGMT_DATASET_CHANGED ("c/dc")
+    kUriEnergyReport,          ///< Energy Report ("c/er")
+    kUriEnergyScan,            ///< Energy Scan ("c/es")
+    kUriJoinerEntrust,         ///< Joiner Entrust  ("c/je")
+    kUriJoinerFinalize,        ///< Joiner Finalize ("c/jf")
+    kUriLeaderKeepAlive,       ///< Leader Keep Alive ("c/la")
+    kUriLeaderPetition,        ///< Leader Petition ("c/lp")
+    kUriEnrollerJoinerAccept,  ///< Enroller Joiner Accept ("c/nj")
+    kUriEnrollerKeepAlive,     ///< Enroller KeepAlive ("c/nk")
+    kUriEnrollerJoinerRelease, ///< Enroller Joiner Release ("c/nl")
+    kUriEnrollerRegister,      ///< Enroller Register ("c/nr")
+    kUriEnrollerReportState,   ///< Report Admitter State (to enroller) ("c/ns")
+    kUriPanIdConflict,         ///< PAN ID Conflict ("c/pc")
+    kUriPendingGet,            ///< MGMT_PENDING_GET ("c/pg")
+    kUriPanIdQuery,            ///< PAN ID Query ("c/pq")
+    kUriPendingSet,            ///< MGMT_PENDING_SET ("c/ps")
+    kUriRelayRx,               ///< Relay RX ("c/rx")
+    kUriTcatEnable,            ///< TCAT Enable ("c/te")
+    kUriRelayTx,               ///< Relay TX ("c/tx")
+    kUriProxyRx,               ///< Proxy RX ("c/ur")
+    kUriProxyTx,               ///< Proxy TX ("c/ut")
+    kUriDiagnosticGetAnswer,   ///< Network Diagnostic Get Answer ("d/da")
+    kUriDiagnosticGetRequest,  ///< Network Diagnostic Get Request ("d/dg")
+    kUriDiagnosticGetQuery,    ///< Network Diagnostic Get Query ("d/dq")
+    kUriDiagnosticReset,       ///< Network Diagnostic Reset ("d/dr")
+    kUriHistoryAnswer,         ///< History Answer ("h/an")
+    kUriHistoryQuery,          ///< History Query ("h/qy")
+    kUriMlr,                   ///< Multicast Listener Registration ("n/mr")
+    kUriUnknown,               ///< Unknown URI
 };
 
 /**
@@ -131,9 +127,7 @@
 template <> const char *UriToString<kUriAddressSolicit>(void);
 template <> const char *UriToString<kUriServerData>(void);
 template <> const char *UriToString<kUriAnycastLocate>(void);
-template <> const char *UriToString<kUriBackboneAnswer>(void);
 template <> const char *UriToString<kUriBackboneMlr>(void);
-template <> const char *UriToString<kUriBackboneQuery>(void);
 template <> const char *UriToString<kUriAnnounceBegin>(void);
 template <> const char *UriToString<kUriActiveGet>(void);
 template <> const char *UriToString<kUriActiveReplace>(void);
@@ -167,8 +161,6 @@
 template <> const char *UriToString<kUriDiagnosticGetRequest>(void);
 template <> const char *UriToString<kUriDiagnosticGetQuery>(void);
 template <> const char *UriToString<kUriDiagnosticReset>(void);
-template <> const char *UriToString<kUriDuaRegistrationNotify>(void);
-template <> const char *UriToString<kUriDuaRegistrationRequest>(void);
 template <> const char *UriToString<kUriMlr>(void);
 
 } // namespace ot
diff --git a/src/core/thread/vendor_info.cpp b/src/core/thread/vendor_info.cpp
index 35e69c2..20a4ca3 100644
--- a/src/core/thread/vendor_info.cpp
+++ b/src/core/thread/vendor_info.cpp
@@ -37,6 +37,9 @@
 
 namespace ot {
 
+//----------------------------------------------------------------------------------------------------------------------
+// VendorInfo
+
 const char VendorInfo::kName[]      = OPENTHREAD_CONFIG_NET_DIAG_VENDOR_NAME;
 const char VendorInfo::kModel[]     = OPENTHREAD_CONFIG_NET_DIAG_VENDOR_MODEL;
 const char VendorInfo::kSwVersion[] = OPENTHREAD_CONFIG_NET_DIAG_VENDOR_SW_VERSION;
@@ -49,6 +52,19 @@
               "VENDOR_NAME MUST start with 'RD:' prefix for a reference device.");
 #endif
 
+static constexpr uint64_t kDefaultOuiConfig = OPENTHREAD_CONFIG_NET_DIAG_VENDOR_OUI;
+
+const otThreadVendorOui VendorInfo::kOui = {
+    OuiParser<kDefaultOuiConfig>::GetBitLength(),
+    {
+        OuiParser<kDefaultOuiConfig>::GetByte0(),
+        OuiParser<kDefaultOuiConfig>::GetByte1(),
+        OuiParser<kDefaultOuiConfig>::GetByte2(),
+        OuiParser<kDefaultOuiConfig>::GetByte3(),
+        OuiParser<kDefaultOuiConfig>::GetByte4(),
+    },
+};
+
 VendorInfo::VendorInfo(Instance &aInstance)
     : InstanceLocator(aInstance)
 {
@@ -57,11 +73,15 @@
     static_assert(sizeof(kSwVersion) <= sizeof(SwVersionStringType), "VENDOR_SW_VERSION is too long");
     static_assert(sizeof(kAppUrl) <= sizeof(AppUrlStringType), "VENDOR_APP_URL is too long");
 
+    static_assert(OuiParser<OPENTHREAD_CONFIG_NET_DIAG_VENDOR_OUI>::IsValid(),
+                  "VENDOR_OUI format is not valid - see the config documentation");
+
 #if OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE
     memcpy(mName, kName, sizeof(kName));
     memcpy(mModel, kModel, sizeof(kModel));
     memcpy(mSwVersion, kSwVersion, sizeof(kSwVersion));
     memcpy(mAppUrl, kAppUrl, sizeof(kAppUrl));
+    mOui = AsCoreType(&kOui);
 #endif
 }
 
@@ -110,6 +130,180 @@
 
 Error VendorInfo::SetAppUrl(const char *aAppUrl) { return StringCopy(mAppUrl, aAppUrl, kStringCheckUtf8Encoding); }
 
+Error VendorInfo::SetOui(const Oui &aOui)
+{
+    Error error = kErrorNone;
+
+    VerifyOrExit(aOui.IsValid(), error = kErrorInvalidArgs);
+
+    VerifyOrExit(mOui != aOui);
+    mOui.SetFrom(aOui);
+
+#if OPENTHREAD_CONFIG_BORDER_AGENT_ENABLE && OPENTHREAD_CONFIG_BORDER_AGENT_MESHCOP_SERVICE_ENABLE
+    Get<MeshCoP::BorderAgent::TxtData>().HandleVendorOuiChange();
+#endif
+
+exit:
+    return error;
+}
+
 #endif // OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE
 
+//----------------------------------------------------------------------------------------------------------------------
+// VendorInfo::Oui
+
+const uint8_t VendorInfo::Oui::kValidBitLengths[] = {24, 28, 36};
+
+bool VendorInfo::Oui::IsValid(void) const { return DoesArrayContain(kValidBitLengths, GetBitLength()); }
+
+void VendorInfo::Oui::SetFrom(const Oui &aOther)
+{
+    *this = aOther;
+    Tidy();
+}
+
+Error VendorInfo::Oui::SetBitLengthFromSize(uint16_t aSize)
+{
+    Error error = kErrorNone;
+
+    VerifyOrExit(IsValueInRange<uint16_t>(aSize, kMinSize, kMaxSize), error = kErrorInvalidArgs);
+
+    Clear();
+    mBitLength = kValidBitLengths[aSize - kMinSize];
+
+exit:
+    return error;
+}
+
+Error VendorInfo::Oui::SetFrom(const uint8_t *aData, uint16_t aSize)
+{
+    Error error;
+
+    SuccessOrExit(error = SetBitLengthFromSize(aSize));
+    memcpy(mBytes, aData, aSize);
+    Tidy();
+
+exit:
+    return error;
+}
+
+Error VendorInfo::Oui::ParseFrom(const Message &aMessage, OffsetRange aOffsetRange)
+{
+    Error error = kErrorParse;
+
+    aOffsetRange.ShrinkLength(kMaxSize);
+    SuccessOrExit(SetBitLengthFromSize(aOffsetRange.GetLength()));
+
+    SuccessOrExit(error = aMessage.Read(aOffsetRange.GetOffset(), mBytes, GetSize()));
+    Tidy();
+
+exit:
+    return error;
+}
+
+void VendorInfo::Oui::Tidy(void)
+{
+    uint8_t index;
+
+    if (!IsValid())
+    {
+        Clear();
+        ExitNow();
+    }
+
+    index = GetBitLength() / kBitsPerByte;
+
+    if (GetBitLength() > index * kBitsPerByte)
+    {
+        mBytes[index] &= kTopNibbleMask;
+        index++;
+    }
+
+    for (; index < kMaxSize; index++)
+    {
+        mBytes[index] = 0;
+    }
+
+exit:
+    return;
+}
+
+uint32_t VendorInfo::Oui::GetAsOui24(void) const
+{
+    uint32_t oui24;
+
+    if (!IsValid())
+    {
+        oui24 = VendorInfo::Oui::kUnspecified;
+        ExitNow();
+    }
+
+    oui24 = BigEndian::ReadUint24(GetBytes());
+
+exit:
+    return oui24;
+}
+
+bool VendorInfo::Oui::operator==(const Oui &aOther) const
+{
+    bool isEqual = false;
+    Oui  tidyOther;
+
+    VerifyOrExit(GetBitLength() == aOther.GetBitLength());
+
+    tidyOther = aOther;
+    tidyOther.Tidy();
+
+    VerifyOrExit(memcmp(GetBytes(), tidyOther.GetBytes(), GetSize()) == 0);
+    isEqual = true;
+
+exit:
+    return isEqual;
+}
+
+VendorInfo::Oui::InfoString VendorInfo::Oui::ToString(void) const
+{
+    InfoString string;
+
+    ToString(string);
+
+    return string;
+}
+
+void VendorInfo::Oui::ToString(char *aBuffer, uint16_t aSize) const
+{
+    StringWriter writer(aBuffer, aSize);
+    ToString(writer);
+}
+
+void VendorInfo::Oui::ToString(StringWriter &aWriter) const
+{
+    uint8_t index        = 0;
+    uint8_t numFullBytes = GetBitLength() / kBitsPerByte;
+
+    if (!IsValid())
+    {
+        aWriter.Append("unspecified");
+        ExitNow();
+    }
+
+    for (index = 0; index < numFullBytes; index++)
+    {
+        if (index != 0)
+        {
+            aWriter.Append("-");
+        }
+
+        aWriter.Append("%02X", mBytes[index]);
+    }
+
+    if (GetBitLength() > numFullBytes * kBitsPerByte)
+    {
+        aWriter.Append("-%1X", ReadBits<uint8_t, kTopNibbleMask>(mBytes[index]));
+    }
+
+exit:
+    return;
+}
+
 } // namespace ot
diff --git a/src/core/thread/vendor_info.hpp b/src/core/thread/vendor_info.hpp
index ccfe83b..dff4203 100644
--- a/src/core/thread/vendor_info.hpp
+++ b/src/core/thread/vendor_info.hpp
@@ -36,20 +36,183 @@
 
 #include "openthread-core-config.h"
 
+#include <openthread/netdiag.h>
+
+#include "common/as_core_type.hpp"
+#include "common/bit_utils.hpp"
+#include "common/clearable.hpp"
+#include "common/equatable.hpp"
 #include "common/error.hpp"
 #include "common/locator.hpp"
+#include "common/message.hpp"
 #include "common/non_copyable.hpp"
-#include "thread/network_diagnostic_tlvs.hpp"
+#include "common/string.hpp"
+#include "thread/net_diag_tlvs.hpp"
 
 namespace ot {
 
+class UnitTester;
+
 /**
  * Represents the vendor information.
  */
 class VendorInfo : public InstanceLocator, private NonCopyable
 {
+    friend class UnitTester;
+
 public:
     /**
+     * Represents a Thread Vendor OUI (Organizationally Unique Identifier).
+     */
+    class Oui : public otThreadVendorOui, public Clearable<Oui>, public Unequatable<Oui>
+    {
+        friend class UnitTester;
+
+    public:
+        /**
+         * The minimum size of the OUI in bytes.
+         */
+        static constexpr uint8_t kMinSize = 3;
+
+        /**
+         * The maximum size of the OUI in bytes.
+         */
+        static constexpr uint8_t kMaxSize = OT_THREAD_VENDOR_OUI_MAX_SIZE;
+
+        /**
+         * The unspecified Vendor OUI value.
+         */
+        static constexpr uint32_t kUnspecified = OT_THREAD_UNSPECIFIED_VENDOR_OUI;
+
+        /**
+         * The size of `InfoString` (used in `ToString()`).
+         */
+        static constexpr uint16_t kInfoStringSize = OT_THREAD_VENDOR_OUI_STRING_SIZE;
+
+        /**
+         * Defines the fixed-length `String` returned from `ToString()`.
+         */
+        typedef String<kInfoStringSize> InfoString;
+
+        /**
+         * Indicates whether or not the OUI value is valid.
+         *
+         * @retval TRUE   The OUI is valid.
+         * @retval FALSE  The OUI is not valid.
+         */
+        bool IsValid(void) const;
+
+        /**
+         * Returns the OUI prefix length in bits (24, 28, or 36)
+         *
+         * @returns The OUI prefix length in bits.
+         */
+        uint8_t GetBitLength(void) const { return mBitLength; }
+
+        /**
+         * Returns a pointer to the OUI bytes.
+         *
+         * @returns A pointer to the OUI bytes.
+         */
+        const uint8_t *GetBytes(void) const { return mBytes; }
+
+        /**
+         * Returns the OUI size in bytes.
+         *
+         * @returns The OUI size in bytes.
+         */
+        uint8_t GetSize(void) const { return BytesForBitSize(mBitLength); }
+
+        /**
+         * Sets the OUI from another OUI, tidying unused bits.
+         *
+         * @param[in] aOther  The other OUI to set from.
+         */
+        void SetFrom(const Oui &aOther);
+
+        /**
+         * Sets the OUI from a byte buffer.
+         *
+         * @param[in] aData  A pointer to the buffer.
+         * @param[in] aSize  The size of the buffer in bytes.
+         *
+         * @retval kErrorNone         Successfully set the OUI.
+         * @retval kErrorInvalidArgs  The @p aSize is not valid and not within valid range [kMinSize, kMaxSize].
+         */
+        Error SetFrom(const uint8_t *aData, uint16_t aSize);
+
+        /**
+         * Parses the OUI from a message.
+         *
+         * If @p aOffsetRange is longer than `kMaxSize`, the additional bytes beyond `kMaxSize` are ignored.
+         *
+         * @param[in] aMessage       The message to read from.
+         * @param[in] aOffsetRange   The offset range in @p aMessage to read from.
+         *
+         * @retval kErrorNone   Successfully parsed the OUI from @p aMessage.
+         * @retval kErrorParse  Failed to parse the OUI from @p aMessage.
+         */
+        Error ParseFrom(const Message &aMessage, OffsetRange aOffsetRange);
+
+        /**
+         * Returns the OUI as a 24-bit OUI value.
+         *
+         * @deprecated This method is deprecated.
+         *
+         * If the configured Vendor OUI has a prefix length greater than 24 bits, this method returns the most
+         * significant 24 bits (first 3 bytes) of the OUI.
+         *
+         * @returns The 24-bit OUI value, or `kUnspecified` if it is not valid.
+         */
+        uint32_t GetAsOui24(void) const;
+
+        /**
+         * Overloads operator== to check for equality with another OUI.
+         *
+         * @param[in] aOther  The other OUI to compare with.
+         *
+         * @retval TRUE   The OUIs are equal.
+         * @retval FALSE  The OUIs are not equal.
+         */
+        bool operator==(const Oui &aOther) const;
+
+        /**
+         * Converts the OUI to a human-readable string
+         *
+         * The generated string format is hyphen-separated uppercase hexadecimal bytes (e.g., "00-1A-2B" for a 24-bit
+         * OUI). For 28-bit and 36-bit OUIs, the trailing 4-bit nibble is appended as a single hexadecimal digit
+         * (e.g., "00-1A-2B-3" for a 28-bit OUI). If @p aOui is invalid, the string "unspecified" is returned.
+         *
+         * @returns An `InfoString` containing the string representation of the OUI.
+         */
+        InfoString ToString(void) const;
+
+        /**
+         * Converts the OUI to a human-readable string.
+         *
+         * The generated string format is hyphen-separated uppercase hexadecimal bytes (e.g., "00-1A-2B" for a 24-bit
+         * OUI). For 28-bit and 36-bit OUIs, the trailing 4-bit nibble is appended as a single hexadecimal digit
+         * (e.g., "00-1A-2B-3" for a 28-bit OUI). If @p aOui is invalid, the string "unspecified" is returned.
+         *
+         * If the resulting string does not fit in @p aBuffer (within its @p aSize characters), the string will be
+         * truncated but the outputted string is always null-terminated.
+         *
+         * @param[out] aBuffer  A pointer to a character buffer to output the string.
+         * @param[in]  aSize    The size of @p aBuffer in bytes.
+         */
+        void ToString(char *aBuffer, uint16_t aSize) const;
+
+    private:
+        static constexpr uint8_t kTopNibbleMask = 0xf0;
+
+        static const uint8_t kValidBitLengths[];
+
+        Error SetBitLengthFromSize(uint16_t aSize);
+        void  Tidy(void);
+        void  ToString(StringWriter &aWriter) const;
+    };
+
+    /**
      * Initializes the `VendorInfo`.
      *
      * @param[in] aInstance  The OpenThread instance.
@@ -130,33 +293,138 @@
      */
     Error SetAppUrl(const char *aAppUrl);
 
+    /**
+     * Returns the Vendor OUI.
+     *
+     * If the Vendor OUI is not set, the returned `Oui` object's `IsValid()` will return `false`.
+     *
+     * @returns The Vendor OUI.
+     */
+    const Oui &GetOui(void) const { return mOui; }
+
+    /**
+     * Sets the Vendor OUI.
+     *
+     * @param[in] aOui  The Vendor OUI.
+     *
+     * @retval kErrorNone         Successfully set the Vendor OUI.
+     * @retval kErrorInvalidArgs  @p aOui is not valid.
+     */
+    Error SetOui(const Oui &aOui);
+
 #else
     const char *GetName(void) const { return kName; }
     const char *GetModel(void) const { return kModel; }
     const char *GetSwVersion(void) const { return kSwVersion; }
     const char *GetAppUrl(void) const { return kAppUrl; }
+    const Oui  &GetOui(void) const { return static_cast<const Oui &>(kOui); }
 #endif // OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE
 
 private:
     // String buffer types (max size specified by corresponding TLV)
-    typedef NetworkDiagnostic::VendorNameTlv::StringType      NameStringType;
-    typedef NetworkDiagnostic::VendorModelTlv::StringType     ModelStringType;
-    typedef NetworkDiagnostic::VendorSwVersionTlv::StringType SwVersionStringType;
-    typedef NetworkDiagnostic::VendorAppUrlTlv::StringType    AppUrlStringType;
+    typedef NetDiag::VendorNameTlv::StringType      NameStringType;
+    typedef NetDiag::VendorModelTlv::StringType     ModelStringType;
+    typedef NetDiag::VendorSwVersionTlv::StringType SwVersionStringType;
+    typedef NetDiag::VendorAppUrlTlv::StringType    AppUrlStringType;
 
-    static const char kName[];
-    static const char kModel[];
-    static const char kSwVersion[];
-    static const char kAppUrl[];
+    template <uint64_t kOuiConfig> class OuiParser
+    {
+        // Helper template class to parse and validate the compile-time configured OUI.
+        // It supports both the legacy 24-bit OUI (implicit length of 24) and the new 48-bit
+        // format (explicit length in the high byte) to maintain backward compatibility.
+
+    public:
+        static constexpr bool IsSpecified(void) { return (kOuiConfig != VendorInfo::Oui::kUnspecified); }
+
+        static constexpr uint8_t GetBitLength(void)
+        {
+            return !IsSpecified() ? 0 : (IsShort24BitLayout() ? 24 : ReadBits(kLenMask));
+        }
+
+        static constexpr bool IsValid(void)
+        {
+            return !IsSpecified() || IsShort24BitLayout() || Is24Bit() || Is28Bit() || Is36Bit();
+        }
+
+        static constexpr uint8_t GetByte0(void)
+        {
+            return !IsSpecified() ? 0 : IsShort24BitLayout() ? ReadBits(kShortLayoutByte0Mask) : ReadBits(kByte0Mask);
+        }
+
+        static constexpr uint8_t GetByte1(void)
+        {
+            return !IsSpecified() ? 0 : IsShort24BitLayout() ? ReadBits(kShortLayoutByte1Mask) : ReadBits(kByte1Mask);
+        }
+
+        static constexpr uint8_t GetByte2(void)
+        {
+            return !IsSpecified() ? 0 : IsShort24BitLayout() ? ReadBits(kShortLayoutByte2Mask) : ReadBits(kByte2Mask);
+        }
+
+        static constexpr uint8_t GetByte3(void)
+        {
+            return !IsSpecified() ? 0 : IsShort24BitLayout() ? 0 : ReadBits(kByte3Mask);
+        }
+
+        static constexpr uint8_t GetByte4(void)
+        {
+            return !IsSpecified() ? 0 : IsShort24BitLayout() ? 0 : ReadBits(kByte4Mask);
+        }
+
+    private:
+        static constexpr uint64_t kMaxUint48            = 0xffffffffffffULL; // 6 bytes
+        static constexpr uint64_t kLenMask              = 0xff0000000000ULL;
+        static constexpr uint64_t kByte0Mask            = 0x00ff00000000ULL;
+        static constexpr uint64_t kByte1Mask            = 0x0000ff000000ULL;
+        static constexpr uint64_t kByte2Mask            = 0x000000ff0000ULL;
+        static constexpr uint64_t kByte3Mask            = 0x00000000ff00ULL;
+        static constexpr uint64_t kByte4Mask            = 0x0000000000ffULL;
+        static constexpr uint64_t kMaxUint24            = 0x000000ffffffULL; // 3 bytes
+        static constexpr uint64_t kShortLayoutByte0Mask = 0x000000ff0000ULL;
+        static constexpr uint64_t kShortLayoutByte1Mask = 0x00000000ff00ULL;
+        static constexpr uint64_t kShortLayoutByte2Mask = 0x0000000000ffULL;
+
+        static constexpr bool IsShort24BitLayout(void) { return (kOuiConfig <= kMaxUint24); }
+        static constexpr bool IsConfigValueValid(void) { return (kOuiConfig <= kMaxUint48); }
+
+        static constexpr bool Is24Bit(void)
+        {
+            return IsConfigValueValid() && (GetBitLength() == 24) && (GetByte3() == 0) && (GetByte4() == 0);
+        }
+
+        static constexpr bool Is28Bit(void)
+        {
+            return IsConfigValueValid() && GetBitLength() == 28 && ((GetByte3() & 0x0f) == 0) && (GetByte4() == 0);
+        }
+
+        static constexpr bool Is36Bit(void)
+        {
+            return IsConfigValueValid() && GetBitLength() == 36 && ((GetByte4() & 0x0f) == 0);
+        }
+
+        static constexpr uint8_t ReadBits(uint64_t aMask)
+        {
+            return static_cast<uint8_t>((kOuiConfig & aMask) >> BitOffsetOfMask(aMask));
+        }
+    };
+
+    static const char              kName[];
+    static const char              kModel[];
+    static const char              kSwVersion[];
+    static const char              kAppUrl[];
+    static const otThreadVendorOui kOui;
 
 #if OPENTHREAD_CONFIG_NET_DIAG_VENDOR_INFO_SET_API_ENABLE
     NameStringType      mName;
     ModelStringType     mModel;
     SwVersionStringType mSwVersion;
     AppUrlStringType    mAppUrl;
+    Oui                 mOui;
 #endif
 };
 
+DefineCoreType(otThreadVendorOui, VendorInfo::Oui);
+
 } // namespace ot
 
 #endif // OT_CORE_THREAD_VENDOR_INFO_HPP_
diff --git a/src/core/utils/channel_manager.cpp b/src/core/utils/channel_manager.cpp
index 8a79575..d46ab3f 100644
--- a/src/core/utils/channel_manager.cpp
+++ b/src/core/utils/channel_manager.cpp
@@ -100,7 +100,7 @@
     mState   = kStateChangeRequested;
     mChannel = aChannel;
 
-    mTimer.Start(1 + Random::NonCrypto::GetUint32InRange(0, kRequestStartJitterInterval));
+    mTimer.Start(1 + Random::NonCrypto::GenerateUpToExcluding(kRequestStartJitterInterval));
 
     Get<Notifier>().Signal(kEventChannelManagerNewChannelChanged);
 
diff --git a/src/core/utils/history_tracker_client.cpp b/src/core/utils/history_tracker_client.cpp
index 1dbf463..9e48099 100644
--- a/src/core/utils/history_tracker_client.cpp
+++ b/src/core/utils/history_tracker_client.cpp
@@ -133,9 +133,9 @@
 
 Error Client::ProcessAnswer(const Coap::Msg &aMsg)
 {
-    Error     error = kErrorFailed;
-    AnswerTlv answerTlv;
-    uint16_t  queryId;
+    Error          error = kErrorFailed;
+    AnswerTlvValue answerTlvValue;
+    uint16_t       queryId;
 
     VerifyOrExit(mActive);
     VerifyOrExit(Get<Mle::Mle>().IsRoutingLocator(aMsg.mMessageInfo.GetPeerAddr()));
@@ -144,9 +144,9 @@
     SuccessOrExit(Tlv::Find<QueryIdTlv>(aMsg.mMessage, queryId));
     VerifyOrExit(queryId == mQueryId);
 
-    SuccessOrExit(Tlv::FindTlv(aMsg.mMessage, answerTlv));
+    SuccessOrExit(Tlv::Find<AnswerTlv>(aMsg.mMessage, answerTlvValue));
 
-    if (answerTlv.GetIndex() != mAnswerIndex)
+    if (answerTlvValue.GetIndex() != mAnswerIndex)
     {
         Finalize(kErrorResponseTimeout);
         ExitNow();
diff --git a/src/core/utils/history_tracker_server.cpp b/src/core/utils/history_tracker_server.cpp
index 0c74b14..5e1b938 100644
--- a/src/core/utils/history_tracker_server.cpp
+++ b/src/core/utils/history_tracker_server.cpp
@@ -44,6 +44,7 @@
 
 Server::Server(Instance &aInstance)
     : InstanceLocator(aInstance)
+    , mAnswerSender(aInstance, NetDiag::kAnswerTypeHistoryTracker)
 {
 }
 
@@ -60,86 +61,16 @@
     PrepareAndSendAnswers(aMsg.mMessageInfo.GetPeerAddr(), aMsg.mMessage);
 }
 
-Error Server::AllocateAnswer(Coap::Message *&aAnswer, AnswerInfo &aInfo)
+void Server::PrepareAndSendAnswers(const Ip6::Address &aDestination, const Coap::Message &aRequest)
 {
-    // Allocates an `Answer` message, adds it to `mAnswerQueue`,
-    // updates the `aInfo.mFirstAnswer` if it is the first allocated
-    // messages, and appends `QueryIdTlv` to the message (if needed).
+    Error         error;
+    AnswerBuilder answerBuilder(GetInstance(), NetDiag::kAnswerTypeHistoryTracker);
+    OffsetRange   offsetRange;
+    Tlv::Info     tlvInfo;
+    RequestTlv    requestTlv;
+    TimeMilli     now = TimerMilli::GetNow();
 
-    Error error = kErrorNone;
-
-    aAnswer = Get<Tmf::Agent>().AllocateAndInitConfirmablePostMessage(kUriHistoryAnswer);
-    VerifyOrExit(aAnswer != nullptr, error = kErrorNoBufs);
-    IgnoreError(aAnswer->SetPriority(aInfo.mPriority));
-
-    mAnswerQueue.Enqueue(*aAnswer);
-
-    if (aInfo.mFirstAnswer == nullptr)
-    {
-        aInfo.mFirstAnswer = aAnswer;
-    }
-
-    if (aInfo.mHasQueryId)
-    {
-        SuccessOrExit(error = Tlv::Append<QueryIdTlv>(*aAnswer, aInfo.mQueryId));
-    }
-
-exit:
-    return error;
-}
-
-bool Server::IsLastAnswer(const Coap::Message &aAnswer) const
-{
-    // Indicates whether `aAnswer` is the last one associated with
-    // the same query.
-
-    bool      isLast = true;
-    AnswerTlv answerTlv;
-
-    // If there is no Answer TLV, we assume it is the last answer.
-
-    SuccessOrExit(Tlv::FindTlv(aAnswer, answerTlv));
-    isLast = answerTlv.IsLast();
-
-exit:
-    return isLast;
-}
-
-void Server::FreeAllRelatedAnswers(Coap::Message &aFirstAnswer)
-{
-    // Dequeues and frees all answer messages related to the same query
-    // as `aFirstAnswer`. Note that related answers are enqueued in
-    // order.
-
-    Coap::Message *answer = &aFirstAnswer;
-
-    while (answer != nullptr)
-    {
-        Coap::Message *next = IsLastAnswer(*answer) ? nullptr : answer->GetNextCoapMessage();
-
-        mAnswerQueue.DequeueAndFree(*answer);
-        answer = next;
-    }
-}
-
-void Server::PrepareAndSendAnswers(const Ip6::Address &aDestination, const Message &aRequest)
-{
-    Coap::Message *answer;
-    Error          error;
-    AnswerInfo     info;
-    OffsetRange    offsetRange;
-    Tlv::Info      tlvInfo;
-    RequestTlv     requestTlv;
-    AnswerTlv      answerTlv;
-
-    if (Tlv::Find<QueryIdTlv>(aRequest, info.mQueryId) == kErrorNone)
-    {
-        info.mHasQueryId = true;
-    }
-
-    info.mPriority = aRequest.GetPriority();
-
-    SuccessOrExit(error = AllocateAnswer(answer, info));
+    SuccessOrExit(error = answerBuilder.Start(aRequest));
 
     offsetRange.InitFromMessageOffsetToEnd(aRequest);
 
@@ -160,113 +91,33 @@
             switch (requestTlv.GetTlvType())
             {
             case Tlv::kNetworkInfo:
-                SuccessOrExit(error = AppendNetworkInfo(answer, info, requestTlv));
+                SuccessOrExit(error = AppendNetworkInfo(answerBuilder, requestTlv, now));
                 break;
 
             default:
                 break;
             }
 
-            SuccessOrExit(error = CheckAnswerLength(answer, info));
+            SuccessOrExit(error = answerBuilder.CheckAnswerLength());
         }
     }
 
-    answerTlv.Init(info.mAnswerIndex, /* aIsLast */ true);
-    SuccessOrExit(error = answer->Append(answerTlv));
+    SuccessOrExit(error = answerBuilder.Finish());
 
-    SendNextAnswer(*info.mFirstAnswer, aDestination);
-
-exit:
-    if ((error != kErrorNone) && (info.mFirstAnswer != nullptr))
-    {
-        FreeAllRelatedAnswers(*info.mFirstAnswer);
-    }
-}
-
-Error Server::CheckAnswerLength(Coap::Message *&aAnswer, AnswerInfo &aInfo)
-{
-    // Checks the length of the `aAnswer` message and if it is above
-    // the threshold, it enqueues the message for transmission after
-    // appending an Answer TLV with the current index to the message.
-    // In this case, it will also allocate a new answer message.
-
-    Error     error = kErrorNone;
-    AnswerTlv answerTlv;
-
-    VerifyOrExit(aAnswer->GetLength() >= kAnswerMessageLengthThreshold);
-
-    answerTlv.Init(aInfo.mAnswerIndex++, /* aIsLast */ false);
-    SuccessOrExit(error = aAnswer->Append(answerTlv));
-
-    error = AllocateAnswer(aAnswer, aInfo);
-
-exit:
-    return error;
-}
-
-void Server::SendNextAnswer(Coap::Message &aAnswer, const Ip6::Address &aDestination)
-{
-    Error          error      = kErrorNone;
-    Coap::Message *nextAnswer = IsLastAnswer(aAnswer) ? nullptr : aAnswer.GetNextCoapMessage();
-
-    mAnswerQueue.Dequeue(aAnswer);
-
-    // When sending the message, we pass `nextAnswer` as `aContext`
-    // to be used when invoking callback `HandleAnswerResponse()`.
-
-    error = Get<Tmf::Agent>().SendMessageAllowMulticastLoop(aAnswer, aDestination, HandleAnswerResponse, nextAnswer);
-
-    if (error != kErrorNone)
-    {
-        // If the `SendMessage()` fails, we `Free` the dequeued
-        // `aAnswer` and all the related next answers in the queue.
-
-        aAnswer.Free();
-
-        if (nextAnswer != nullptr)
-        {
-            FreeAllRelatedAnswers(*nextAnswer);
-        }
-    }
-}
-
-void Server::HandleAnswerResponse(void *aContext, Coap::Msg *aMsg, Error aResult)
-{
-    Coap::Message *nextAnswer = static_cast<Coap::Message *>(aContext);
-
-    VerifyOrExit(nextAnswer != nullptr);
-
-    nextAnswer->Get<Server>().HandleAnswerResponse(*nextAnswer, aMsg, aResult);
+    mAnswerSender.Send(answerBuilder, aDestination);
 
 exit:
     return;
 }
 
-void Server::HandleAnswerResponse(Coap::Message &aNextAnswer, Coap::Msg *aResponse, Error aResult)
-{
-    Error error = aResult;
-
-    SuccessOrExit(error);
-    VerifyOrExit(aResponse != nullptr, error = kErrorDrop);
-    VerifyOrExit(aResponse->GetCode() == Coap::kCodeChanged, error = kErrorDrop);
-
-    SendNextAnswer(aNextAnswer, aResponse->mMessageInfo.GetPeerAddr());
-
-exit:
-    if (error != kErrorNone)
-    {
-        FreeAllRelatedAnswers(aNextAnswer);
-    }
-}
-
-Error Server::AppendNetworkInfo(Coap::Message *&aAnswer, AnswerInfo &aInfo, const RequestTlv &aRequestTlv)
+Error Server::AppendNetworkInfo(AnswerBuilder &aAnswerBuilder, const RequestTlv &aRequestTlv, TimeMilli aNow)
 {
     Error    error = kErrorNone;
     Iterator iterator;
     uint32_t maxEntryAge = aRequestTlv.GetMaxEntryAge();
     uint16_t maxCount    = aRequestTlv.GetNumEntries();
 
-    iterator.Init(aInfo.mNow);
+    iterator.Init(aNow);
 
     for (uint16_t count = 0; (maxCount == 0) || (count < maxCount); count++)
     {
@@ -288,11 +139,11 @@
 
         networkInfoTlv.InitFrom(*networkInfo, entryAge);
 
-        SuccessOrExit(error = aAnswer->Append(networkInfoTlv));
-        SuccessOrExit(error = CheckAnswerLength(aAnswer, aInfo));
+        SuccessOrExit(error = aAnswerBuilder.GetAnswer().Append(networkInfoTlv));
+        SuccessOrExit(error = aAnswerBuilder.CheckAnswerLength());
     }
 
-    SuccessOrExit(error = Tlv::AppendEmpty<NetworkInfoTlv>(*aAnswer));
+    SuccessOrExit(error = Tlv::AppendEmpty<NetworkInfoTlv>(aAnswerBuilder.GetAnswer()));
 
 exit:
     return error;
diff --git a/src/core/utils/history_tracker_server.hpp b/src/core/utils/history_tracker_server.hpp
index 73e8b41..30dae9a 100644
--- a/src/core/utils/history_tracker_server.hpp
+++ b/src/core/utils/history_tracker_server.hpp
@@ -43,11 +43,13 @@
 #include "common/locator.hpp"
 #include "common/non_copyable.hpp"
 #include "common/timer.hpp"
+#include "thread/net_diag_types.hpp"
 #include "thread/tmf.hpp"
 #include "utils/history_tracker.hpp"
 #include "utils/history_tracker_tlvs.hpp"
 
 namespace ot {
+
 namespace HistoryTracker {
 
 /**
@@ -56,46 +58,20 @@
 class Server : public InstanceLocator
 {
     friend class Tmf::Agent;
+    friend class NetDiag::AnswerSender;
 
 public:
     explicit Server(Instance &aInstance);
 
 private:
-    static constexpr uint16_t kAnswerMessageLengthThreshold = 800;
+    typedef NetDiag::AnswerBuilder AnswerBuilder;
 
-    struct AnswerInfo
-    {
-        AnswerInfo(void)
-            : mNow(TimerMilli::GetNow())
-            , mAnswerIndex(0)
-            , mQueryId(0)
-            , mHasQueryId(false)
-            , mFirstAnswer(nullptr)
-        {
-        }
-
-        TimeMilli         mNow;
-        uint16_t          mAnswerIndex;
-        uint16_t          mQueryId;
-        bool              mHasQueryId;
-        Message::Priority mPriority;
-        Coap::Message    *mFirstAnswer;
-    };
-
-    Error AllocateAnswer(Coap::Message *&aAnswer, AnswerInfo &aInfo);
-    bool  IsLastAnswer(const Coap::Message &aAnswer) const;
-    void  FreeAllRelatedAnswers(Coap::Message &aFirstAnswer);
-    void  PrepareAndSendAnswers(const Ip6::Address &aDestination, const Message &aRequest);
-    Error CheckAnswerLength(Coap::Message *&aAnswer, AnswerInfo &aInfo);
-    void  SendNextAnswer(Coap::Message &aAnswer, const Ip6::Address &aDestination);
-    Error AppendNetworkInfo(Coap::Message *&aAnswer, AnswerInfo &aInfo, const RequestTlv &aRequestTlv);
-
-    static void HandleAnswerResponse(void *aContext, Coap::Msg *aMsg, Error aResult);
-    void        HandleAnswerResponse(Coap::Message &aNextAnswer, Coap::Msg *aResponse, Error aResult);
+    void  PrepareAndSendAnswers(const Ip6::Address &aDestination, const Coap::Message &aRequest);
+    Error AppendNetworkInfo(AnswerBuilder &aAnswerBuilder, const RequestTlv &aRequestTlv, TimeMilli aNow);
 
     template <Uri kUri> void HandleTmf(Coap::Msg &aMsg);
 
-    Coap::MessageQueue mAnswerQueue;
+    NetDiag::AnswerSender mAnswerSender;
 };
 
 DeclareTmfHandler(Server, kUriHistoryQuery);
diff --git a/src/core/utils/history_tracker_tlvs.cpp b/src/core/utils/history_tracker_tlvs.cpp
index 8f8745c..b991154 100644
--- a/src/core/utils/history_tracker_tlvs.cpp
+++ b/src/core/utils/history_tracker_tlvs.cpp
@@ -38,14 +38,6 @@
 namespace ot {
 namespace HistoryTracker {
 
-void AnswerTlv::Init(uint16_t aIndex, bool aIsLast)
-{
-    SetType(kAnswer);
-    SetLength(sizeof(*this) - sizeof(Tlv));
-
-    SetFlagsIndex((aIndex & kIndexMask) | (aIsLast ? kIsLastFlag : 0));
-}
-
 void RequestTlv::Init(uint8_t aTlvType, uint16_t aNumEntries, uint32_t aMaxEntryAge)
 {
     SetType(kRequest);
diff --git a/src/core/utils/history_tracker_tlvs.hpp b/src/core/utils/history_tracker_tlvs.hpp
index bb97422..c5d86cb 100644
--- a/src/core/utils/history_tracker_tlvs.hpp
+++ b/src/core/utils/history_tracker_tlvs.hpp
@@ -42,6 +42,7 @@
 
 #include "common/encoding.hpp"
 #include "common/tlvs.hpp"
+#include "thread/net_diag_tlvs.hpp"
 #include "utils/history_tracker.hpp"
 
 namespace ot {
@@ -72,44 +73,14 @@
 typedef UintTlvInfo<Tlv::kQueryId, uint16_t> QueryIdTlv;
 
 /**
- * Implements Answer TLV generation and parsing.
+ * Represents an Answer TLV value.
  */
-OT_TOOL_PACKED_BEGIN
-class AnswerTlv : public Tlv, public TlvInfo<Tlv::kAnswer>
-{
-public:
-    /**
-     * Initializes the TLV.
-     *
-     * @param[in] aIndex   The index value.
-     * @param[in] aIsLast  The "IsLast" flag value.
-     */
-    void Init(uint16_t aIndex, bool aIsLast);
+typedef NetDiag::AnswerTlvValue AnswerTlvValue;
 
-    /**
-     * Indicates whether or not the "IsLast" flag is set
-     *
-     * @retval TRUE   "IsLast" flag is set (this is the last answer for this query).
-     * @retval FALSE  "IsLast" flag is not set (more answer messages are expected for this query).
-     */
-    bool IsLast(void) const { return GetFlagsIndex() & kIsLastFlag; }
-
-    /**
-     * Gets the index.
-     *
-     * @returns The index.
-     */
-    uint16_t GetIndex(void) const { return GetFlagsIndex() & kIndexMask; }
-
-private:
-    static constexpr uint16_t kIsLastFlag = 1 << 15;
-    static constexpr uint16_t kIndexMask  = 0x7fff;
-
-    uint16_t GetFlagsIndex(void) const { return BigEndian::HostSwap16(mFlagsIndex); }
-    void     SetFlagsIndex(uint16_t aFlagsIndex) { mFlagsIndex = BigEndian::HostSwap16(aFlagsIndex); }
-
-    uint16_t mFlagsIndex;
-} OT_TOOL_PACKED_END;
+/**
+ * Defines Answer TLV constants and types.
+ */
+typedef SimpleTlvInfo<Tlv::kAnswer, AnswerTlvValue> AnswerTlv;
 
 /**
  * Implements Request TLV generation and parsing.
diff --git a/src/core/utils/jam_detector.cpp b/src/core/utils/jam_detector.cpp
index c77dd09..cb3fe56 100644
--- a/src/core/utils/jam_detector.cpp
+++ b/src/core/utils/jam_detector.cpp
@@ -188,7 +188,7 @@
         }
     }
 
-    mTimer.Start(mSampleInterval + Random::NonCrypto::GetUint32InRange(0, kMaxRandomDelay));
+    mTimer.Start(mSampleInterval + Random::NonCrypto::GenerateUpToExcluding(kMaxRandomDelay));
 
 exit:
     return;
diff --git a/src/core/utils/link_metrics_manager.cpp b/src/core/utils/link_metrics_manager.cpp
index aa64689..65b9f33 100644
--- a/src/core/utils/link_metrics_manager.cpp
+++ b/src/core/utils/link_metrics_manager.cpp
@@ -124,8 +124,8 @@
 // This method updates the Link Metrics Subject in the subject list. It adds new neighbors to the list.
 void LinkMetricsManager::UpdateSubjects(void)
 {
-    Neighbor::Info         neighborInfo;
-    otNeighborInfoIterator iterator = OT_NEIGHBOR_INFO_ITERATOR_INIT;
+    Neighbor::Info          neighborInfo;
+    NeighborTable::Iterator iterator = NeighborTable::kIteratorInit;
 
     while (Get<NeighborTable>().GetNextNeighborInfo(iterator, neighborInfo) == kErrorNone)
     {
@@ -286,7 +286,7 @@
     LinkMetrics::Metrics     metricsFlags;
 
     VerifyOrExit(neighbor != nullptr, error = kErrorUnknownNeighbor);
-    destination.SetToLinkLocalAddress(neighbor->GetExtAddress());
+    destination.InitAsLinkLocalAddress(neighbor->GetExtAddress());
 
     metricsFlags.Clear();
     metricsFlags.mLinkMargin = 1;
@@ -311,7 +311,7 @@
     LinkMetrics::EnhAckFlags enhAckFlags = LinkMetrics::kEnhAckClear;
 
     VerifyOrExit(neighbor != nullptr, error = kErrorUnknownNeighbor);
-    destination.SetToLinkLocalAddress(neighbor->GetExtAddress());
+    destination.InitAsLinkLocalAddress(neighbor->GetExtAddress());
 
     error = aInstance.Get<LinkMetrics::Initiator>().SendMgmtRequestEnhAckProbing(destination, enhAckFlags, nullptr);
 exit:
diff --git a/src/core/utils/mesh_diag.cpp b/src/core/utils/mesh_diag.cpp
index ae3ed8c..d5c2488 100644
--- a/src/core/utils/mesh_diag.cpp
+++ b/src/core/utils/mesh_diag.cpp
@@ -40,13 +40,19 @@
 namespace ot {
 namespace Utils {
 
-using namespace NetworkDiagnostic;
+using namespace NetDiag;
 
 RegisterLogModule("MeshDiag");
 
 //---------------------------------------------------------------------------------------------------------------------
 // MeshDiag
 
+const uint8_t MeshDiag::kDiscoverTopologyTlvs[] = {
+    Address16Tlv::kType,      ExtMacAddressTlv::kType, RouteTlv::kType, VersionTlv::kType,
+    Ip6AddressListTlv::kType, // Only if `mDiscoverIp6Addresses` in `DiscoverConfig`.
+    ChildTableTlv::kType,     // Only if `mDiscoverChildTable` in `DiscoverConfig`.
+};
+
 MeshDiag::MeshDiag(Instance &aInstance)
     : InstanceLocator(aInstance)
     , mState(kStateIdle)
@@ -64,45 +70,54 @@
 
 Error MeshDiag::DiscoverTopology(const DiscoverConfig &aConfig, DiscoverCallback aCallback, void *aContext)
 {
-    static constexpr uint8_t kMaxTlvsToRequest = 6;
-
     Error   error = kErrorNone;
-    uint8_t tlvs[kMaxTlvsToRequest];
-    uint8_t tlvsLength = 0;
+    TlvList tlvList;
+
+    if (aConfig.mExtraTlvTypesLength != 0)
+    {
+        // Verify that `mExtraTlvTypes` does not contain any of the required topology TLV types.
+        VerifyOrExit(aConfig.mExtraTlvTypes != nullptr, error = kErrorInvalidArgs);
+
+        for (uint8_t i = 0; i < aConfig.mExtraTlvTypesLength; i++)
+        {
+            VerifyOrExit(!DoesArrayContain(kDiscoverTopologyTlvs, aConfig.mExtraTlvTypes[i]),
+                         error = kErrorInvalidArgs);
+        }
+    }
 
     VerifyOrExit(Get<Mle::Mle>().IsAttached(), error = kErrorInvalidState);
     VerifyOrExit(mState == kStateIdle, error = kErrorBusy);
 
-    tlvs[tlvsLength++] = Address16Tlv::kType;
-    tlvs[tlvsLength++] = ExtMacAddressTlv::kType;
-    tlvs[tlvsLength++] = RouteTlv::kType;
-    tlvs[tlvsLength++] = VersionTlv::kType;
+    SuccessOrExit(error = tlvList.AddAll(kDiscoverTopologyTlvs, GetArrayLength(kDiscoverTopologyTlvs)));
 
-    if (aConfig.mDiscoverIp6Addresses)
+    if (!aConfig.mDiscoverIp6Addresses)
     {
-        tlvs[tlvsLength++] = Ip6AddressListTlv::kType;
+        tlvList.Remove(Ip6AddressListTlv::kType);
     }
 
-    if (aConfig.mDiscoverChildTable)
+    if (!aConfig.mDiscoverChildTable)
     {
-        tlvs[tlvsLength++] = ChildTableTlv::kType;
+        tlvList.Remove(ChildTableTlv::kType);
     }
 
-    Get<RouterTable>().GetRouterIdSet(mDiscover.mExpectedRouterIdSet);
+    SuccessOrExit(error = tlvList.AddAll(aConfig.mExtraTlvTypes, aConfig.mExtraTlvTypesLength));
+
+    Get<RouterTable>().GetRouterIdMask(mDiscover.mExpectedRouterIds);
 
     for (uint8_t routerId = 0; routerId <= Mle::kMaxRouterId; routerId++)
     {
         Ip6::Address destination;
 
-        if (!mDiscover.mExpectedRouterIdSet.Contains(routerId))
+        if (!mDiscover.mExpectedRouterIds.IsAllocated(routerId))
         {
             continue;
         }
 
-        destination.SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), Mle::Rloc16FromRouterId(routerId));
+        Get<Mle::Mle>().ComposeRloc(Mle::Rloc16FromRouterId(routerId), destination);
 
         SuccessOrExit(error = Get<Client>().SendCommand(kUriDiagnosticGetRequest, Message::kPriorityLow, destination,
-                                                        tlvs, tlvsLength, HandleDiagGetResponse, this));
+                                                        tlvList.GetArrayBuffer(), tlvList.GetLength(),
+                                                        HandleDiagGetResponse, this));
     }
 
     mDiscover.mCallback.Set(aCallback, aContext);
@@ -119,6 +134,7 @@
     RouterInfo      routerInfo;
     Ip6AddrIterator ip6AddrIterator;
     ChildIterator   childIterator;
+    TlvIterator     tlvIterator;
 
     SuccessOrExit(aResult);
     VerifyOrExit(aMsg != nullptr);
@@ -136,9 +152,12 @@
         routerInfo.mChildIterator = &childIterator;
     }
 
-    mDiscover.mExpectedRouterIdSet.Remove(routerInfo.mRouterId);
+    tlvIterator.InitFrom(aMsg->mMessage);
+    routerInfo.mTlvIterator = &tlvIterator;
 
-    if (mDiscover.mExpectedRouterIdSet.GetNumberOfAllocatedIds() == 0)
+    mDiscover.mExpectedRouterIds.Remove(routerInfo.mRouterId);
+
+    if (mDiscover.mExpectedRouterIds.DetermineAllocatedCount() == 0)
     {
         error  = kErrorNone;
         mState = kStateIdle;
@@ -165,7 +184,7 @@
     VerifyOrExit(Mle::IsRouterRloc16(aRloc16), error = kErrorInvalidArgs);
     VerifyOrExit(Get<RouterTable>().IsAllocated(Mle::RouterIdFromRloc16(aRloc16)), error = kErrorNotFound);
 
-    destination.SetToRoutingLocator(Get<Mle::Mle>().GetMeshLocalPrefix(), aRloc16);
+    Get<Mle::Mle>().ComposeRloc(aRloc16, destination);
 
     SuccessOrExit(error = Get<Client>().SendCommand(kUriDiagnosticGetQuery, Message::kPriorityNormal, destination,
                                                     aTlvs, aTlvsLength));
@@ -258,9 +277,9 @@
     // check whether it is from the intended sender and matches
     // the expected query ID and answer index.
 
-    Error     error = kErrorFailed;
-    AnswerTlv answerTlv;
-    uint16_t  queryId;
+    Error          error = kErrorFailed;
+    AnswerTlvValue answerTlvValue;
+    uint16_t       queryId;
 
     VerifyOrExit(Get<Mle::Mle>().IsRoutingLocator(aMessageInfo.GetPeerAddr()));
     VerifyOrExit(aMessageInfo.GetPeerAddr().GetIid().GetLocator() == aSenderRloc16);
@@ -268,9 +287,9 @@
     SuccessOrExit(Tlv::Find<QueryIdTlv>(aMessage, queryId));
     VerifyOrExit(queryId == mExpectedQueryId);
 
-    SuccessOrExit(Tlv::FindTlv(aMessage, answerTlv));
+    SuccessOrExit(Tlv::Find<AnswerTlv>(aMessage, answerTlvValue));
 
-    if (answerTlv.GetIndex() != mExpectedAnswerIndex)
+    if (answerTlvValue.GetIndex() != mExpectedAnswerIndex)
     {
         Finalize(kErrorResponseTimeout);
         ExitNow();
@@ -285,21 +304,20 @@
 
 bool MeshDiag::ProcessChildTableAnswer(Coap::Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
 {
-    bool       didProcess = false;
-    ChildTlv   childTlv;
-    ChildEntry entry;
-    uint16_t   offset;
+    bool          didProcess = false;
+    Tlv::Info     tlvInfo;
+    ChildTlvValue childTlvValue;
+    ChildEntry    entry;
 
     SuccessOrExit(ProcessMessage(aMessage, aMessageInfo, mQueryChildTable.mRouterRloc16));
 
     while (true)
     {
-        SuccessOrExit(Tlv::FindTlv(aMessage, childTlv, offset));
-        VerifyOrExit(!childTlv.IsExtended());
+        SuccessOrExit(tlvInfo.FindIn(aMessage, ChildTlv::kType));
 
         didProcess = true;
 
-        if (childTlv.GetLength() == 0)
+        if (tlvInfo.GetLength() == 0)
         {
             // We reached end of the list.
             mState = kStateIdle;
@@ -308,17 +326,16 @@
             ExitNow();
         }
 
-        VerifyOrExit(childTlv.GetLength() >= sizeof(ChildTlv) - sizeof(Tlv));
-        IgnoreError(aMessage.Read(offset, childTlv));
+        SuccessOrExit(tlvInfo.Read<ChildTlv>(aMessage, childTlvValue));
 
-        entry.SetFrom(childTlv);
+        entry.SetFrom(childTlvValue);
         mQueryChildTable.mCallback.InvokeIfSet(kErrorPending, &entry);
 
         // Make sure query operation is not canceled from the
         // callback.
         VerifyOrExit(mState == kStateQueryChildTable);
 
-        aMessage.SetOffset(static_cast<uint16_t>(offset + childTlv.GetSize()));
+        aMessage.SetOffset(tlvInfo.GetTlvOffsetRange().GetEndOffset());
     }
 
 exit:
@@ -327,21 +344,20 @@
 
 bool MeshDiag::ProcessRouterNeighborTableAnswer(Coap::Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
 {
-    bool                didProcess = false;
-    RouterNeighborTlv   neighborTlv;
-    RouterNeighborEntry entry;
-    uint16_t            offset;
+    bool                   didProcess = false;
+    Tlv::Info              tlvInfo;
+    RouterNeighborTlvValue neighborTlvValue;
+    RouterNeighborEntry    entry;
 
     SuccessOrExit(ProcessMessage(aMessage, aMessageInfo, mQueryRouterNeighborTable.mRouterRloc16));
 
     while (true)
     {
-        SuccessOrExit(Tlv::FindTlv(aMessage, neighborTlv, offset));
-        VerifyOrExit(!neighborTlv.IsExtended());
+        SuccessOrExit(tlvInfo.FindIn(aMessage, RouterNeighborTlv::kType));
 
         didProcess = true;
 
-        if (neighborTlv.GetLength() == 0)
+        if (tlvInfo.GetLength() == 0)
         {
             // We reached end of the list.
             mState = kStateIdle;
@@ -350,16 +366,16 @@
             ExitNow();
         }
 
-        VerifyOrExit(neighborTlv.GetLength() >= sizeof(RouterNeighborTlv) - sizeof(Tlv));
+        SuccessOrExit(tlvInfo.Read<RouterNeighborTlv>(aMessage, neighborTlvValue));
 
-        entry.SetFrom(neighborTlv);
+        entry.SetFrom(neighborTlvValue);
         mQueryRouterNeighborTable.mCallback.InvokeIfSet(kErrorPending, &entry);
 
         // Make sure query operation is not canceled from the
         // callback.
         VerifyOrExit(mState == kStateQueryRouterNeighborTable);
 
-        aMessage.SetOffset(static_cast<uint16_t>(offset + neighborTlv.GetSize()));
+        aMessage.SetOffset(tlvInfo.GetTlvOffsetRange().GetEndOffset());
     }
 
 exit:
@@ -393,8 +409,7 @@
         // Read the `ChildIp6AddressListTlvValue` (which contains the
         // child RLOC16) and then prepare the `Ip6AddrIterator`.
 
-        SuccessOrExit(aMessage.Read(offsetRange, tlvValue));
-        offsetRange.AdvanceOffset(sizeof(tlvValue));
+        SuccessOrExit(aMessage.ReadAndAdvance(offsetRange, tlvValue));
 
         ip6AddrIterator.mMessage     = &aMessage;
         ip6AddrIterator.mOffsetRange = offsetRange;
@@ -470,15 +485,15 @@
 
 Error MeshDiag::RouterInfo::ParseFrom(const Message &aMessage)
 {
-    Error     error = kErrorNone;
-    Mle::Mle &mle   = aMessage.Get<Mle::Mle>();
-    RouteTlv  routeTlv;
+    Error          error = kErrorNone;
+    Mle::Mle      &mle   = aMessage.Get<Mle::Mle>();
+    RouteTlv::Data routeTlvData;
 
     Clear();
 
     SuccessOrExit(error = Tlv::Find<Address16Tlv>(aMessage, mRloc16));
     SuccessOrExit(error = Tlv::Find<ExtMacAddressTlv>(aMessage, AsCoreType(&mExtAddress)));
-    SuccessOrExit(error = Tlv::FindTlv(aMessage, routeTlv));
+    SuccessOrExit(error = RouteTlv::FindIn(aMessage, routeTlvData));
 
     switch (error = Tlv::Find<VersionTlv>(aMessage, mVersion))
     {
@@ -498,13 +513,9 @@
     mIsLeader           = (mRouterId == mle.GetLeaderId());
     mIsBorderRouter     = aMessage.Get<NetworkData::Leader>().ContainsBorderRouterWithRloc(mRloc16);
 
-    for (uint8_t id = 0, index = 0; id <= Mle::kMaxRouterId; id++)
+    for (const RouteTlv::Data::Entry &entry : routeTlvData.GetEntries())
     {
-        if (routeTlv.IsRouterIdSet(id))
-        {
-            mLinkQualities[id] = routeTlv.GetLinkQualityIn(index);
-            index++;
-        }
+        mLinkQualities[entry.GetRouterId()] = entry.GetLinkQualityIn();
     }
 
 exit:
@@ -527,12 +538,12 @@
 
 Error MeshDiag::Ip6AddrIterator::GetNextAddress(Ip6::Address &aAddress)
 {
-    Error error = kErrorNone;
+    Error error = kErrorNotFound;
 
-    VerifyOrExit(mMessage != nullptr, error = kErrorNotFound);
+    VerifyOrExit(mMessage != nullptr);
+    SuccessOrExit(mMessage->ReadAndAdvance(mOffsetRange, aAddress));
 
-    VerifyOrExit(mMessage->Read(mOffsetRange, aAddress) == kErrorNone, error = kErrorNotFound);
-    mOffsetRange.AdvanceOffset(sizeof(Ip6::Address));
+    error = kErrorNone;
 
 exit:
     return error;
@@ -562,8 +573,7 @@
 
     VerifyOrExit(mMessage != nullptr);
 
-    SuccessOrExit(mMessage->Read(mOffsetRange, entry));
-    mOffsetRange.AdvanceOffset(sizeof(ChildTableTlvEntry));
+    SuccessOrExit(mMessage->ReadAndAdvance(mOffsetRange, entry));
 
     entry.Parse(info);
 
@@ -581,46 +591,134 @@
 //---------------------------------------------------------------------------------------------------------------------
 // MeshDiag::ChildEntry
 
-void MeshDiag::ChildEntry::SetFrom(const ChildTlv &aChildTlv)
+void MeshDiag::ChildEntry::SetFrom(const ChildTlvValue &aChildTlvValue)
 {
-    mRxOnWhenIdle        = (aChildTlv.GetFlags() & ChildTlv::kFlagsRxOnWhenIdle);
-    mDeviceTypeFtd       = (aChildTlv.GetFlags() & ChildTlv::kFlagsFtd);
-    mFullNetData         = (aChildTlv.GetFlags() & ChildTlv::kFlagsFullNetdta);
-    mCslSynchronized     = (aChildTlv.GetFlags() & ChildTlv::kFlagsCslSync);
-    mSupportsErrRate     = (aChildTlv.GetFlags() & ChildTlv::kFlagsTrackErrRate);
-    mRloc16              = aChildTlv.GetRloc16();
-    mExtAddress          = aChildTlv.GetExtAddress();
-    mVersion             = aChildTlv.GetVersion();
-    mTimeout             = aChildTlv.GetTimeout();
-    mAge                 = aChildTlv.GetAge();
-    mConnectionTime      = aChildTlv.GetConnectionTime();
-    mSupervisionInterval = aChildTlv.GetSupervisionInterval();
-    mLinkMargin          = aChildTlv.GetLinkMargin();
-    mAverageRssi         = aChildTlv.GetAverageRssi();
-    mLastRssi            = aChildTlv.GetLastRssi();
-    mFrameErrorRate      = aChildTlv.GetFrameErrorRate();
-    mMessageErrorRate    = aChildTlv.GetMessageErrorRate();
-    mQueuedMessageCount  = aChildTlv.GetQueuedMessageCount();
-    mCslPeriod           = aChildTlv.GetCslPeriod();
-    mCslTimeout          = aChildTlv.GetCslTimeout();
-    mCslChannel          = aChildTlv.GetCslChannel();
+    mRxOnWhenIdle        = (aChildTlvValue.GetFlags() & ChildTlvValue::kFlagsRxOnWhenIdle);
+    mDeviceTypeFtd       = (aChildTlvValue.GetFlags() & ChildTlvValue::kFlagsFtd);
+    mFullNetData         = (aChildTlvValue.GetFlags() & ChildTlvValue::kFlagsFullNetdta);
+    mCslSynchronized     = (aChildTlvValue.GetFlags() & ChildTlvValue::kFlagsCslSync);
+    mSupportsErrRate     = (aChildTlvValue.GetFlags() & ChildTlvValue::kFlagsTrackErrRate);
+    mRloc16              = aChildTlvValue.GetRloc16();
+    mExtAddress          = aChildTlvValue.GetExtAddress();
+    mVersion             = aChildTlvValue.GetVersion();
+    mTimeout             = aChildTlvValue.GetTimeout();
+    mAge                 = aChildTlvValue.GetAge();
+    mConnectionTime      = aChildTlvValue.GetConnectionTime();
+    mSupervisionInterval = aChildTlvValue.GetSupervisionInterval();
+    mLinkMargin          = aChildTlvValue.GetLinkMargin();
+    mAverageRssi         = aChildTlvValue.GetAverageRssi();
+    mLastRssi            = aChildTlvValue.GetLastRssi();
+    mFrameErrorRate      = aChildTlvValue.GetFrameErrorRate();
+    mMessageErrorRate    = aChildTlvValue.GetMessageErrorRate();
+    mQueuedMessageCount  = aChildTlvValue.GetQueuedMessageCount();
+    mCslPeriod           = aChildTlvValue.GetCslPeriod();
+    mCslTimeout          = aChildTlvValue.GetCslTimeout();
+    mCslChannel          = aChildTlvValue.GetCslChannel();
 }
 
 //---------------------------------------------------------------------------------------------------------------------
 // MeshDiag::RouterNeighborEntry
 
-void MeshDiag::RouterNeighborEntry::SetFrom(const RouterNeighborTlv &aTlv)
+void MeshDiag::RouterNeighborEntry::SetFrom(const RouterNeighborTlvValue &aTlvValue)
 {
-    mSupportsErrRate  = (aTlv.GetFlags() & RouterNeighborTlv::kFlagsTrackErrRate);
-    mRloc16           = aTlv.GetRloc16();
-    mExtAddress       = aTlv.GetExtAddress();
-    mVersion          = aTlv.GetVersion();
-    mConnectionTime   = aTlv.GetConnectionTime();
-    mLinkMargin       = aTlv.GetLinkMargin();
-    mAverageRssi      = aTlv.GetAverageRssi();
-    mLastRssi         = aTlv.GetLastRssi();
-    mFrameErrorRate   = aTlv.GetFrameErrorRate();
-    mMessageErrorRate = aTlv.GetMessageErrorRate();
+    mSupportsErrRate  = (aTlvValue.GetFlags() & RouterNeighborTlvValue::kFlagsTrackErrRate);
+    mRloc16           = aTlvValue.GetRloc16();
+    mExtAddress       = aTlvValue.GetExtAddress();
+    mVersion          = aTlvValue.GetVersion();
+    mConnectionTime   = aTlvValue.GetConnectionTime();
+    mLinkMargin       = aTlvValue.GetLinkMargin();
+    mAverageRssi      = aTlvValue.GetAverageRssi();
+    mLastRssi         = aTlvValue.GetLastRssi();
+    mFrameErrorRate   = aTlvValue.GetFrameErrorRate();
+    mMessageErrorRate = aTlvValue.GetMessageErrorRate();
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// MeshDiag::TlvIterator
+
+void MeshDiag::TlvIterator::InitFrom(const Message &aMessage)
+{
+    mMessage = &aMessage;
+    mIter    = aMessage.GetOffset();
+}
+
+Error MeshDiag::TlvIterator::GetNextTlvInfo(DiagTlvInfo &aTlvInfo)
+{
+    Error     error;
+    uint16_t  offset = mIter;
+    Tlv::Info tlvInfo;
+
+    VerifyOrExit(mMessage != nullptr, error = kErrorNotFound);
+
+    while (offset < mMessage->GetLength())
+    {
+        SuccessOrExit(error = tlvInfo.ParseFrom(*mMessage, offset));
+        offset += tlvInfo.GetSize();
+
+        if (DoesArrayContain(kDiscoverTopologyTlvs, tlvInfo.GetType()))
+        {
+            continue;
+        }
+
+        error = NetDiag::Client::ParseDiagTlv(*mMessage, tlvInfo, aTlvInfo);
+
+        switch (error)
+        {
+        case kErrorNotCapable:
+            // Skip over any unrecognized TLV.
+            break;
+
+        case kErrorNone:
+            mIter = offset;
+            OT_FALL_THROUGH;
+
+        default:
+            ExitNow();
+        }
+    }
+
+    error = kErrorNotFound;
+
+exit:
+    return error;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// MeshDiag::TlvList
+
+Error MeshDiag::TlvList::Add(uint8_t aTlvType)
+{
+    Error error = kErrorNone;
+
+    VerifyOrExit(!Contains(aTlvType));
+    error = PushBack(aTlvType);
+
+exit:
+    return error;
+}
+
+Error MeshDiag::TlvList::AddAll(const uint8_t *aTlvTypes, uint8_t aLength)
+{
+    Error error = kErrorNone;
+
+    for (uint8_t i = 0; i < aLength; i++)
+    {
+        SuccessOrExit(error = Add(aTlvTypes[i]));
+    }
+
+exit:
+    return error;
+}
+
+void MeshDiag::TlvList::Remove(uint8_t aTlvType)
+{
+    uint8_t *entry = Find(aTlvType);
+
+    VerifyOrExit(entry != nullptr);
+    Array::Remove(*entry);
+
+exit:
+    return;
 }
 
 } // namespace Utils
diff --git a/src/core/utils/mesh_diag.hpp b/src/core/utils/mesh_diag.hpp
index d20fd40..2f0d29e 100644
--- a/src/core/utils/mesh_diag.hpp
+++ b/src/core/utils/mesh_diag.hpp
@@ -49,8 +49,8 @@
 #include "common/message.hpp"
 #include "common/timer.hpp"
 #include "net/ip6_address.hpp"
-#include "thread/network_diagnostic.hpp"
-#include "thread/network_diagnostic_tlvs.hpp"
+#include "thread/net_diag.hpp"
+#include "thread/net_diag_tlvs.hpp"
 #include "thread/tmf.hpp"
 
 struct otMeshDiagIp6AddrIterator
@@ -61,6 +61,10 @@
 {
 };
 
+struct otMeshDiagTlvIterator
+{
+};
+
 namespace ot {
 namespace Utils {
 
@@ -69,12 +73,13 @@
  */
 class MeshDiag : public InstanceLocator
 {
-    friend class ot::NetworkDiagnostic::Client;
+    friend class ot::NetDiag::Client;
 
 public:
     static constexpr uint16_t kVersionUnknown = OT_MESH_DIAG_VERSION_UNKNOWN; ///< Unknown version.
 
     typedef otMeshDiagDiscoverConfig                   DiscoverConfig;              ///< Discovery configuration.
+    typedef otMeshDiagTlvInfo                          DiagTlvInfo;                 ///< Diagnostic TLV Info.
     typedef otMeshDiagDiscoverCallback                 DiscoverCallback;            ///< Discovery callback.
     typedef otMeshDiagQueryChildTableCallback          QueryChildTableCallback;     ///< Query Child Table callback.
     typedef otMeshDiagChildIp6AddrsCallback            ChildIp6AddrsCallback;       ///< Child IPv6 addresses callback.
@@ -150,6 +155,31 @@
     };
 
     /**
+     * Represents an iterator to iterate over extra/custom Network Diagnostic TLVs returned by a router.
+     */
+    class TlvIterator : public otMeshDiagTlvIterator
+    {
+        friend class MeshDiag;
+
+    public:
+        /**
+         * Iterates to the next extra Network Diagnostic TLV.
+         *
+         * @param[out] aTlvInfo  A reference to a `DiagTlvInfo` to return the next extra TLV info.
+         *
+         * @retval kErrorNone      Successfully retrieved the next extra TLV.
+         * @retval kErrorNotFound  No more extra TLVs.
+         */
+        Error GetNextTlvInfo(DiagTlvInfo &aTlvInfo);
+
+    private:
+        void InitFrom(const Message &aMessage);
+
+        const Message            *mMessage;
+        NetDiag::Client::Iterator mIter;
+    };
+
+    /**
      * Initializes the `MeshDiag` instance.
      *
      * @param[in] aInstance   The OpenThread instance.
@@ -242,11 +272,12 @@
     void Cancel(void);
 
 private:
-    typedef ot::NetworkDiagnostic::Tlv Tlv;
+    typedef ot::NetDiag::Tlv Tlv;
 
     static constexpr uint32_t kResponseTimeout    = OPENTHREAD_CONFIG_MESH_DIAG_RESPONSE_TIMEOUT;
     static constexpr uint32_t kMinResponseTimeout = 50;
     static constexpr uint32_t kMaxResponseTimeout = 10 * Time::kOneMinuteInMsec;
+    static constexpr uint16_t kMaxTlvTypes        = 32;
 
     enum State : uint8_t
     {
@@ -260,7 +291,7 @@
     struct DiscoverInfo
     {
         Callback<DiscoverCallback> mCallback;
-        Mle::RouterIdSet           mExpectedRouterIdSet;
+        Mle::RouterIdMask          mExpectedRouterIds;
     };
 
     struct QueryChildTableInfo
@@ -286,7 +317,7 @@
         friend class MeshDiag;
 
     private:
-        void SetFrom(const NetworkDiagnostic::ChildTlv &aChildTlv);
+        void SetFrom(const NetDiag::ChildTlvValue &aChildTlvValue);
     };
 
     class RouterNeighborEntry : public otMeshDiagRouterNeighborEntry
@@ -294,7 +325,16 @@
         friend class MeshDiag;
 
     private:
-        void SetFrom(const NetworkDiagnostic::RouterNeighborTlv &aTlv);
+        void SetFrom(const NetDiag::RouterNeighborTlvValue &aTlvValue);
+    };
+
+    class TlvList : public Array<uint8_t, kMaxTlvTypes>
+    {
+    public:
+        TlvList(void) = default;
+        Error Add(uint8_t aTlvType);
+        Error AddAll(const uint8_t *aTlvTypes, uint8_t aLength);
+        void  Remove(uint8_t aTlvType);
     };
 
     Error SendQuery(uint16_t aRloc16, const uint8_t *aTlvs, uint8_t aTlvsLength);
@@ -310,6 +350,8 @@
 
     using TimeoutTimer = TimerMilliIn<MeshDiag, &MeshDiag::HandleTimer>;
 
+    static const uint8_t kDiscoverTopologyTlvs[];
+
     State        mState;
     uint16_t     mExpectedQueryId;
     uint16_t     mExpectedAnswerIndex;
@@ -331,6 +373,7 @@
 DefineCoreType(otMeshDiagRouterInfo, Utils::MeshDiag::RouterInfo);
 DefineCoreType(otMeshDiagChildInfo, Utils::MeshDiag::ChildInfo);
 DefineCoreType(otMeshDiagChildIterator, Utils::MeshDiag::ChildIterator);
+DefineCoreType(otMeshDiagTlvIterator, Utils::MeshDiag::TlvIterator);
 
 } // namespace ot
 
diff --git a/src/core/utils/ping_sender.cpp b/src/core/utils/ping_sender.cpp
index 4a39d24..63f3d0a 100644
--- a/src/core/utils/ping_sender.cpp
+++ b/src/core/utils/ping_sender.cpp
@@ -185,15 +185,15 @@
                                                                 AsCoreType(aIcmpHeader));
 }
 
-void PingSender::HandleIcmpReceive(const Message           &aMessage,
-                                   const Ip6::MessageInfo  &aMessageInfo,
-                                   const Ip6::Icmp::Header &aIcmpHeader)
+void PingSender::HandleIcmpReceive(const Message          &aMessage,
+                                   const Ip6::MessageInfo &aMessageInfo,
+                                   const Ip6::Icmp6Header &aIcmpHeader)
 {
     Reply    reply;
     uint32_t timestamp;
 
     VerifyOrExit(mTimer.IsRunning());
-    VerifyOrExit(aIcmpHeader.GetType() == Ip6::Icmp::Header::kTypeEchoReply);
+    VerifyOrExit(aIcmpHeader.GetType() == Ip6::Icmp6Header::kTypeEchoReply);
     VerifyOrExit(aIcmpHeader.GetId() == mIdentifier);
 
     SuccessOrExit(aMessage.Read(aMessage.GetOffset(), timestamp));
diff --git a/src/core/utils/ping_sender.hpp b/src/core/utils/ping_sender.hpp
index 6a17362..218f991 100644
--- a/src/core/utils/ping_sender.hpp
+++ b/src/core/utils/ping_sender.hpp
@@ -160,9 +160,9 @@
                                   otMessage           *aMessage,
                                   const otMessageInfo *aMessageInfo,
                                   const otIcmp6Header *aIcmpHeader);
-    void        HandleIcmpReceive(const Message           &aMessage,
-                                  const Ip6::MessageInfo  &aMessageInfo,
-                                  const Ip6::Icmp::Header &aIcmpHeader);
+    void        HandleIcmpReceive(const Message          &aMessage,
+                                  const Ip6::MessageInfo &aMessageInfo,
+                                  const Ip6::Icmp6Header &aIcmpHeader);
 
     using PingTimer = TimerMilliIn<PingSender, &PingSender::HandleTimer>;
 
diff --git a/src/lib/spinel/logger.cpp b/src/lib/spinel/logger.cpp
index 97fb18c..deb9cf3 100644
--- a/src/lib/spinel/logger.cpp
+++ b/src/lib/spinel/logger.cpp
@@ -524,7 +524,7 @@
     case SPINEL_PROP_STREAM_DEBUG:
     {
         char          debugString[OPENTHREAD_LIB_SPINEL_NCP_LOG_MAX_SIZE + 1];
-        spinel_size_t stringLength = sizeof(debugString);
+        spinel_size_t stringLength = sizeof(debugString) - 1;
 
         unpacked = spinel_datatype_unpack_in_place(data, len, SPINEL_DATATYPE_DATA_S, debugString, &stringLength);
         assert(stringLength < sizeof(debugString));
diff --git a/src/lib/spinel/multi_frame_buffer.hpp b/src/lib/spinel/multi_frame_buffer.hpp
index 30050b3..a9af3c1 100644
--- a/src/lib/spinel/multi_frame_buffer.hpp
+++ b/src/lib/spinel/multi_frame_buffer.hpp
@@ -40,6 +40,7 @@
 
 #include <openthread/error.h>
 
+#include "common/code_utils.hpp"
 #include "lib/utils/endian.hpp"
 
 namespace ot {
@@ -179,6 +180,7 @@
      */
     MultiFrameBuffer(void)
         : FrameWritePointer()
+        , mBufferCleared(true)
     {
         Clear();
     }
@@ -193,6 +195,7 @@
         mWriteFrameStart = mBuffer;
         mWritePointer    = mBuffer + kHeaderSize;
         mRemainingLength = kSize - kHeaderSize;
+        mBufferCleared   = true;
 
         IgnoreError(SetSkipLength(0));
     }
@@ -348,6 +351,17 @@
 
         assert(aFrame == nullptr || (mBuffer <= aFrame && aFrame < GetArrayEnd(mBuffer)));
 
+        if (aFrame == nullptr)
+        {
+            mBufferCleared = false;
+        }
+        else if (mBufferCleared)
+        {
+            aLength = 0;
+            aFrame  = nullptr;
+            ExitNow(error = OT_ERROR_NOT_FOUND);
+        }
+
         aFrame = (aFrame == nullptr) ? mBuffer : aFrame + aLength;
 
         if (HasSavedFrame() && (aFrame != mWriteFrameStart))
@@ -365,6 +379,7 @@
             error   = OT_ERROR_NOT_FOUND;
         }
 
+    exit:
         return error;
     }
 
@@ -385,6 +400,7 @@
             mWritePointer -= len;
             mWriteFrameStart -= len;
             mRemainingLength += len;
+            mBufferCleared = true;
         }
     }
 
@@ -443,6 +459,7 @@
 
     uint8_t  mBuffer[kSize];
     uint8_t *mWriteFrameStart; // Pointer to start of current frame being written.
+    bool     mBufferCleared;   // Tracks if buffer was cleared during an active iteration.
 };
 
 } // namespace Spinel
diff --git a/src/lib/spinel/radio_spinel.cpp b/src/lib/spinel/radio_spinel.cpp
index 71a441d..fa83bf9 100644
--- a/src/lib/spinel/radio_spinel.cpp
+++ b/src/lib/spinel/radio_spinel.cpp
@@ -157,7 +157,7 @@
     if (sSupportsLogCrashDump)
     {
         LogDebg("RCP supports crash dump logging. Requesting crash dump.");
-        SuccessOrExit(error = Set(SPINEL_PROP_RCP_LOG_CRASH_DUMP, nullptr));
+        IgnoreReturnValue(Set(SPINEL_PROP_RCP_LOG_CRASH_DUMP, nullptr));
     }
 
     if (!aSkipRcpVersionCheck)
@@ -556,7 +556,7 @@
     else if (aKey == SPINEL_PROP_STREAM_DEBUG)
     {
         char         logStream[OPENTHREAD_CONFIG_NCP_SPINEL_LOG_MAX_SIZE + 1];
-        unsigned int len = sizeof(logStream);
+        unsigned int len = sizeof(logStream) - 1;
 
         unpacked = spinel_datatype_unpack_in_place(aBuffer, aLength, SPINEL_DATATYPE_DATA_S, logStream, &len);
         assert(len < sizeof(logStream));
@@ -1328,14 +1328,17 @@
 #if OPENTHREAD_SPINEL_CONFIG_RCP_RESTORATION_MAX_COUNT > 0
     do
     {
-        RecoverFromRcpFailure();
+        if (aKey != SPINEL_PROP_RCP_LOG_CRASH_DUMP)
+        {
+            RecoverFromRcpFailure();
+        }
 #endif
         va_start(mPropertyArgs, aFormat);
         error = RequestWithExpectedCommandV(SPINEL_CMD_PROP_VALUE_IS, SPINEL_CMD_PROP_VALUE_SET, aKey, aFormat,
                                             mPropertyArgs);
         va_end(mPropertyArgs);
 #if OPENTHREAD_SPINEL_CONFIG_RCP_RESTORATION_MAX_COUNT > 0
-    } while (mRcpFailure != kRcpFailureNone);
+    } while (aKey != SPINEL_PROP_RCP_LOG_CRASH_DUMP && mRcpFailure != kRcpFailureNone);
 #endif
 
     return error;
@@ -1399,12 +1402,19 @@
         if ((end <= now) || (GetSpinelDriver().GetSpinelInterface()->WaitForFrame(end - now) != OT_ERROR_NONE))
         {
             LogWarn("Wait for response timeout");
-            if (aHandleRcpTimeout)
+            // Skip RCP timeout handling for the non-essential crash dump property
+            if (aHandleRcpTimeout && mWaitingKey != SPINEL_PROP_RCP_LOG_CRASH_DUMP)
             {
                 HandleRcpTimeout();
             }
             ExitNow(mError = OT_ERROR_RESPONSE_TIMEOUT);
         }
+#if OPENTHREAD_SPINEL_CONFIG_RCP_RESTORATION_MAX_COUNT > 0
+        if (mRcpFailure != kRcpFailureNone)
+        {
+            ExitNow(mError = OT_ERROR_RESPONSE_TIMEOUT);
+        }
+#endif
     } while (mWaitingTid);
 
     LogIfFail("Error waiting response", mError);
@@ -1412,6 +1422,11 @@
     mWaitingKey = SPINEL_PROP_LAST_STATUS;
 
 exit:
+    if (mError != OT_ERROR_NONE && mWaitingTid != 0)
+    {
+        FreeTid(mWaitingTid);
+        mWaitingTid = 0;
+    }
     return mError;
 }
 
@@ -1612,15 +1627,15 @@
 #if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_TIME_SYNC_ENABLE
     if (mTransmitFrame->mInfo.mTxInfo.mIeInfo->mTimeIeOffset != 0)
     {
-        uint64_t netRadioTime = otPlatRadioGetNow(mInstance);
-        uint64_t netSyncTime;
-        uint8_t *timeIe = mTransmitFrame->mPsdu + mTransmitFrame->mInfo.mTxInfo.mIeInfo->mTimeIeOffset;
+        otRadioTime64 netRadioTime = otPlatRadioGetNow(mInstance);
+        otRadioTime64 netSyncTime;
+        uint8_t      *timeIe = mTransmitFrame->mPsdu + mTransmitFrame->mInfo.mTxInfo.mIeInfo->mTimeIeOffset;
 
         if (netRadioTime == UINT64_MAX)
         {
             // If we can't get the radio time, get the platform time
-            netSyncTime = static_cast<uint64_t>(static_cast<int64_t>(otPlatTimeGet()) +
-                                                mTransmitFrame->mInfo.mTxInfo.mIeInfo->mNetworkTimeOffset);
+            netSyncTime = static_cast<otRadioTime64>(static_cast<int64_t>(otPlatTimeGet()) +
+                                                     mTransmitFrame->mInfo.mTxInfo.mIeInfo->mNetworkTimeOffset);
         }
         else
         {
@@ -1629,16 +1644,16 @@
             // If supported, add a delay and transmit the network time at a precise moment
 #if !OPENTHREAD_MTD && OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
             transmitDelay                                  = kTxWaitUs / 10;
-            mTransmitFrame->mInfo.mTxInfo.mTxDelayBaseTime = static_cast<uint32_t>(netRadioTime);
+            mTransmitFrame->mInfo.mTxInfo.mTxDelayBaseTime = static_cast<otRadioTime32>(netRadioTime);
             mTransmitFrame->mInfo.mTxInfo.mTxDelay         = transmitDelay;
 #endif
-            netSyncTime = static_cast<uint64_t>(static_cast<int64_t>(netRadioTime) + transmitDelay +
-                                                mTransmitFrame->mInfo.mTxInfo.mIeInfo->mNetworkTimeOffset);
+            netSyncTime = static_cast<otRadioTime64>(static_cast<int64_t>(netRadioTime) + transmitDelay +
+                                                     mTransmitFrame->mInfo.mTxInfo.mIeInfo->mNetworkTimeOffset);
         }
 
         *(timeIe++) = mTransmitFrame->mInfo.mTxInfo.mIeInfo->mTimeSyncSeq;
 
-        for (uint8_t i = 0; i < sizeof(uint64_t); i++)
+        for (uint8_t i = 0; i < sizeof(otRadioTime64); i++)
         {
             *(timeIe++) = static_cast<uint8_t>(netSyncTime & 0xff);
             netSyncTime = netSyncTime >> 8;
@@ -1991,11 +2006,20 @@
 
 #if OPENTHREAD_SPINEL_CONFIG_RCP_RESTORATION_MAX_COUNT > 0
     mRcpFailure = kRcpFailureUnexpectedReset;
-#elif OPENTHREAD_SPINEL_CONFIG_ABORT_ON_UNEXPECTED_RCP_RESET_ENABLE
+#else
+    if (sSupportsLogCrashDump)
+    {
+        mWaitingTid = 0;
+        LogDebg("RCP supports crash dump logging. Requesting crash dump.");
+        IgnoreReturnValue(Set(SPINEL_PROP_RCP_LOG_CRASH_DUMP, nullptr));
+    }
+
+#if OPENTHREAD_SPINEL_CONFIG_ABORT_ON_UNEXPECTED_RCP_RESET_ENABLE
     abort();
 #else
     DieNow(OT_EXIT_RADIO_SPINEL_RESET);
 #endif
+#endif
 }
 
 void RadioSpinel::HandleRcpTimeout(void)
@@ -2044,6 +2068,14 @@
         DieNow(OT_EXIT_FAILURE);
     }
 
+    if (sSupportsLogCrashDump)
+    {
+        mWaitingTid = 0;
+        LogDebg("RCP supports crash dump logging. Requesting crash dump.");
+        IgnoreReturnValue(Set(SPINEL_PROP_RCP_LOG_CRASH_DUMP, nullptr));
+        mRcpFailure = kRcpFailureNone;
+    }
+
     LogWarn("Trying to recover (%d/%d)", mRcpFailureCount, kMaxFailureCount);
 
     mState = kStateDisabled;
@@ -2106,12 +2138,6 @@
 
     --mRcpFailureCount;
 
-    if (sSupportsLogCrashDump)
-    {
-        LogDebg("RCP supports crash dump logging. Requesting crash dump.");
-        SuccessOrDie(Set(SPINEL_PROP_RCP_LOG_CRASH_DUMP, nullptr));
-    }
-
     LogNote("RCP recovery is done");
 
 exit:
diff --git a/src/lib/spinel/spi_frame.hpp b/src/lib/spinel/spi_frame.hpp
index fa15ffe..43382ec 100644
--- a/src/lib/spinel/spi_frame.hpp
+++ b/src/lib/spinel/spi_frame.hpp
@@ -100,7 +100,7 @@
  *   This protocol can be used either unidirectionally or bidirectionally,
  *   determined by the behavior of the master and the slave.
  *
- *   If the the master notices "PATTERN" is not set correctly, the master
+ *   If the master notices "PATTERN" is not set correctly, the master
  *   should consider the transaction to have failed and try again after 10
  *   milliseconds, retrying up to 200 times.  After unsuccessfully trying
  *   200 times in a row, the master MAY take appropriate remedial action
diff --git a/src/lib/spinel/spinel_driver.cpp b/src/lib/spinel/spinel_driver.cpp
index e735c5b..31c8a96 100644
--- a/src/lib/spinel/spinel_driver.cpp
+++ b/src/lib/spinel/spinel_driver.cpp
@@ -152,7 +152,7 @@
     if (aSoftwareReset && (SendReset(SPINEL_RESET_STACK) == OT_ERROR_NONE) && (WaitResponse() == OT_ERROR_NONE))
     {
         VerifyOrExit(mIsCoprocessorReady, resetDone = false);
-        LogCrit("Software reset co-processor successfully");
+        LogInfo("Software reset co-processor successfully");
         ExitNow(resetDone = true);
     }
 
diff --git a/src/lib/spinel/spinel_prop_codec.cpp b/src/lib/spinel/spinel_prop_codec.cpp
index 9751d30..3e6e790 100644
--- a/src/lib/spinel/spinel_prop_codec.cpp
+++ b/src/lib/spinel/spinel_prop_codec.cpp
@@ -124,7 +124,23 @@
 
     SuccessOrExit(error = aDecoder.ReadUtf8(aHost.mHostName));
     SuccessOrExit(error = aDecoder.ReadUint16(aHost.mAddressesLength));
-    SuccessOrExit(error = aDecoder.ReadIp6Address(aHost.mAddresses));
+
+    if (aHost.mAddressesLength == 0)
+    {
+        aHost.mAddresses = nullptr;
+    }
+    else
+    {
+        SuccessOrExit(error = aDecoder.ReadIp6Address(aHost.mAddresses));
+
+        for (uint16_t i = 1; i < aHost.mAddressesLength; i++)
+        {
+            const otIp6Address *address;
+
+            SuccessOrExit(error = aDecoder.ReadIp6Address(address));
+        }
+    }
+
     SuccessOrExit(error = aDecoder.ReadUint32(aRequestId));
     SuccessOrExit(error = aDecoder.ReadData(aCallbackData, aCallbackDataLen));
 
diff --git a/src/lib/url/url.cpp b/src/lib/url/url.cpp
index 869c183..ee783c3 100644
--- a/src/lib/url/url.cpp
+++ b/src/lib/url/url.cpp
@@ -49,6 +49,7 @@
 {
     otError error = OT_ERROR_NONE;
     char   *url   = aUrl;
+    char   *query;
 
     mEnd      = aUrl + strlen(aUrl);
     mProtocol = aUrl;
@@ -59,15 +60,31 @@
     url += sizeof("://") - 1;
     mPath = url;
 
-    url = strstr(url, "?");
+    query = strchr(url, '?');
 
-    if (url != nullptr)
+    if (query != nullptr)
     {
-        mQuery = ++url;
+        char *end;
 
-        for (char *cur = strtok(url, "&"); cur != nullptr; cur = strtok(nullptr, "&"))
+        *query = '\0';
+        mQuery = query + 1;
+
+        end = strchr(const_cast<char *>(mQuery), '#');
+
+        if (end == nullptr)
         {
-            cur[-1] = '\0';
+            end = const_cast<char *>(mEnd);
+        }
+
+        if (end > mQuery && end[-1] != '&')
+        {
+            memmove(end + 1, end, mEnd - end + 1);
+            *end = '&';
+            mEnd = end + 1;
+        }
+        else
+        {
+            mEnd = end;
         }
     }
     else
@@ -97,21 +114,40 @@
 
     while (start < mEnd)
     {
-        const char *last = nullptr;
-
-        if (!strncmp(aName, start, len))
+        if (!strncmp(aName, start, len) && (start[len] == '=' || start[len] == '&' || start[len] == '\0'))
         {
+            const char *delimiter = start + len;
+
+            while (delimiter < mEnd && *delimiter != '&' && *delimiter != '\0')
+            {
+                delimiter++;
+            }
+
+            if (delimiter < mEnd && *delimiter == '&')
+            {
+                *const_cast<char *>(delimiter) = '\0';
+            }
+
             if (start[len] == '=')
             {
-                ExitNow(rval = &start[len + 1]);
+                rval = &start[len + 1];
             }
-            else if (start[len] == '\0')
+            else
             {
-                ExitNow(rval = &start[len]);
+                rval = &start[len];
             }
+            break;
         }
-        last  = start;
-        start = last + strlen(last) + 1;
+
+        while (start < mEnd && *start != '&' && *start != '\0')
+        {
+            start++;
+        }
+
+        if (start < mEnd)
+        {
+            start++;
+        }
     }
 
 exit:
@@ -202,5 +238,35 @@
     return error;
 }
 
+otError Url::Validate(const char **aUnusedParam) const
+{
+    otError     error = OT_ERROR_NONE;
+    const char *cur   = mQuery;
+
+    while (cur < mEnd)
+    {
+        if (*cur == '&')
+        {
+            const char *p = cur;
+
+            while (p > mQuery && p[-1] != '\0')
+            {
+                p--;
+            }
+
+            if (aUnusedParam != nullptr)
+            {
+                *aUnusedParam = p;
+            }
+
+            ExitNow(error = OT_ERROR_INVALID_ARGS);
+        }
+        cur++;
+    }
+
+exit:
+    return error;
+}
+
 } // namespace Url
 } // namespace ot
diff --git a/src/lib/url/url.hpp b/src/lib/url/url.hpp
index df1deab..ec0cc70 100644
--- a/src/lib/url/url.hpp
+++ b/src/lib/url/url.hpp
@@ -192,6 +192,16 @@
      * @retval OT_ERROR_INVALID_ARGS  The parameter value was not contain valid number (e.g., value out of range).
      */
     otError ParseInt8(const char *aName, int8_t &aValue) const;
+
+    /**
+     * Checks if all parameters are used.
+     *
+     * @param[out] aUnusedParam  A pointer to a string to store the name of the first unused parameter.
+     *
+     * @retval OT_ERROR_NONE          All parameters were used.
+     * @retval OT_ERROR_INVALID_ARGS  Some parameters were not used.
+     */
+    otError Validate(const char **aUnusedParam = nullptr) const;
 };
 
 } // namespace Url
diff --git a/src/ncp/ncp_base.cpp b/src/ncp/ncp_base.cpp
index 798b057..1fbdec9 100644
--- a/src/ncp/ncp_base.cpp
+++ b/src/ncp/ncp_base.cpp
@@ -298,7 +298,7 @@
 #if OPENTHREAD_CONFIG_TMF_NETDATA_SERVICE_ENABLE
     , mAllowLocalServerDataChange(false)
 #endif
-#if OPENTHREAD_FTD
+#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     , mPreferredRouteId(0)
 #endif
     , mCurCommandIid(0)
@@ -1709,8 +1709,8 @@
     SuccessOrExit(error = mDecoder.ReadUint8(channel));
 
     {
-        uint64_t now = otPlatRadioGetNow(mInstance);
-        uint32_t start;
+        otRadioTime64 now = otPlatRadioGetNow(mInstance);
+        uint32_t      start;
 
         VerifyOrExit(when > now && (when - now) < UINT32_MAX, error = OT_ERROR_INVALID_ARGS);
 
@@ -2151,10 +2151,6 @@
     SuccessOrExit(error = mEncoder.WriteUintPacked(SPINEL_CAP_THREAD_LINK_METRICS));
 #endif
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-    SuccessOrExit(error = mEncoder.WriteUintPacked(SPINEL_CAP_DUA));
-#endif
-
 #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     SuccessOrExit(error = mEncoder.WriteUintPacked(SPINEL_CAP_REFERENCE_DEVICE));
 #endif
@@ -2589,7 +2585,7 @@
 
 template <> otError NcpBase::HandlePropertyGet<SPINEL_PROP_DEBUG_NCP_LOG_LEVEL>(void)
 {
-    return mEncoder.WriteUint8(ConvertLogLevel(otLoggingGetLevel()));
+    return mEncoder.WriteUint8(ConvertLogLevel(otGetLogLevel(mInstance)));
 }
 
 #if OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE
@@ -2633,7 +2629,7 @@
         ExitNow(error = OT_ERROR_INVALID_ARGS);
     }
 
-    IgnoreError(otLoggingSetLevel(logLevel));
+    IgnoreError(otSetLogLevel(mInstance, logLevel));
 
 exit:
     return error;
@@ -2847,6 +2843,22 @@
 
 #if (OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_APP)
 
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+
+extern "C" void otPlatLogOutput(otInstance *aInstance, otLogLevel aLogLevel, const char *aLogLine)
+{
+    OT_UNUSED_VARIABLE(aInstance);
+
+    ot::Ncp::NcpBase *ncp = ot::Ncp::NcpBase::GetNcpInstance();
+
+    if (ncp != nullptr)
+    {
+        ncp->Log(aLogLevel, OT_LOG_REGION_CORE, aLogLine);
+    }
+}
+
+#else
+
 extern "C" void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
 {
     va_list           args;
@@ -2866,4 +2878,6 @@
     va_end(args);
 }
 
+#endif // OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+
 #endif // (OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_APP)
diff --git a/src/ncp/ncp_base.hpp b/src/ncp/ncp_base.hpp
index a0f4d35..d9aa796 100644
--- a/src/ncp/ncp_base.hpp
+++ b/src/ncp/ncp_base.hpp
@@ -816,8 +816,10 @@
 #if OPENTHREAD_CONFIG_MLE_STEERING_DATA_SET_OOB_ENABLE
     otExtAddress mSteeringDataAddress;
 #endif
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
     uint8_t mPreferredRouteId;
 #endif
+#endif
     uint8_t mCurCommandIid;
 
 #if OPENTHREAD_RADIO || OPENTHREAD_CONFIG_LINK_RAW_ENABLE
diff --git a/src/ncp/ncp_base_dispatcher.cpp b/src/ncp/ncp_base_dispatcher.cpp
index f025e95..bda4d2a 100644
--- a/src/ncp/ncp_base_dispatcher.cpp
+++ b/src/ncp/ncp_base_dispatcher.cpp
@@ -292,8 +292,10 @@
         OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_ROUTER_ROLE_ENABLED),
         OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_ROUTER_DOWNGRADE_THRESHOLD),
         OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_ROUTER_SELECTION_JITTER),
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
         OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_PREFERRED_ROUTER_ID),
 #endif
+#endif
         OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_NEIGHBOR_TABLE),
 #if OPENTHREAD_FTD
         OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_CHILD_COUNT_MAX),
@@ -333,9 +335,6 @@
 #if (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
         OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_DOMAIN_NAME),
 #endif
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-        OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_DUA_ID),
-#endif
 #endif // OPENTHREAD_FTD
 #if (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
         OT_NCP_GET_HANDLER_ENTRY(SPINEL_PROP_THREAD_BACKBONE_ROUTER_PRIMARY),
@@ -610,7 +609,9 @@
         OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_ROUTER_ROLE_ENABLED),
         OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_ROUTER_DOWNGRADE_THRESHOLD),
         OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_ROUTER_SELECTION_JITTER),
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
         OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_PREFERRED_ROUTER_ID),
+#endif
         OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_CHILD_COUNT_MAX),
 #endif
         OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_DISCOVERY_SCAN_JOINER_FLAG),
@@ -647,9 +648,6 @@
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_TMF_PROXY_MLR_ENABLE && OPENTHREAD_CONFIG_COMMISSIONER_ENABLE
         OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_MLR_REQUEST),
 #endif
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_DUA_ENABLE
-        OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_DUA_ID),
-#endif
 #if OPENTHREAD_FTD
 #if OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
         OT_NCP_SET_HANDLER_ENTRY(SPINEL_PROP_THREAD_BACKBONE_ROUTER_LOCAL_STATE),
diff --git a/src/ncp/ncp_base_ftd.cpp b/src/ncp/ncp_base_ftd.cpp
index bcc8d54..3cc9cbc 100644
--- a/src/ncp/ncp_base_ftd.cpp
+++ b/src/ncp/ncp_base_ftd.cpp
@@ -385,53 +385,6 @@
 }
 #endif
 
-#if OPENTHREAD_CONFIG_DUA_ENABLE
-template <> otError NcpBase::HandlePropertyGet<SPINEL_PROP_THREAD_DUA_ID>(void)
-{
-    const otIp6InterfaceIdentifier *iid   = otThreadGetFixedDuaInterfaceIdentifier(mInstance);
-    otError                         error = OT_ERROR_NONE;
-
-    if (iid == nullptr)
-    {
-        // send empty response
-    }
-    else
-    {
-        for (uint8_t i : iid->mFields.m8)
-        {
-            SuccessOrExit(error = mEncoder.WriteUint8(i));
-        }
-    }
-
-exit:
-    return error;
-}
-
-template <> otError NcpBase::HandlePropertySet<SPINEL_PROP_THREAD_DUA_ID>(void)
-{
-    otError error = OT_ERROR_NONE;
-
-    if (mDecoder.GetRemainingLength() == 0)
-    {
-        SuccessOrExit(error = otThreadSetFixedDuaInterfaceIdentifier(mInstance, nullptr));
-    }
-    else
-    {
-        otIp6InterfaceIdentifier iid;
-
-        for (uint8_t &i : iid.mFields.m8)
-        {
-            SuccessOrExit(error = mDecoder.ReadUint8(i));
-        }
-
-        SuccessOrExit(error = otThreadSetFixedDuaInterfaceIdentifier(mInstance, &iid));
-    }
-
-exit:
-    return error;
-}
-#endif // OPENTHREAD_CONFIG_DUA_ENABLE
-
 #if OPENTHREAD_CONFIG_BORDER_AGENT_EPHEMERAL_KEY_ENABLE
 
 template <> otError NcpBase::HandlePropertySet<SPINEL_PROP_BORDER_AGENT_EPHEMERAL_KEY_ENABLE>(void)
@@ -1218,6 +1171,8 @@
 }
 #endif // #if OPENTHREAD_CONFIG_MLE_STEERING_DATA_SET_OOB_ENABLE
 
+#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
+
 template <> otError NcpBase::HandlePropertyGet<SPINEL_PROP_THREAD_PREFERRED_ROUTER_ID>(void)
 {
     return mEncoder.WriteUint8(mPreferredRouteId);
@@ -1235,6 +1190,8 @@
     return error;
 }
 
+#endif
+
 template <> otError NcpBase::HandlePropertyRemove<SPINEL_PROP_THREAD_ACTIVE_ROUTER_IDS>(void)
 {
     otError error = OT_ERROR_NONE;
diff --git a/src/ncp/ncp_base_mtd.cpp b/src/ncp/ncp_base_mtd.cpp
index c92a8f7..cb372b3 100644
--- a/src/ncp/ncp_base_mtd.cpp
+++ b/src/ncp/ncp_base_mtd.cpp
@@ -129,11 +129,6 @@
         flags |= SPINEL_NET_FLAG_EXT_DNS;
     }
 
-    if (aConfig.mDp)
-    {
-        flags |= SPINEL_NET_FLAG_EXT_DP;
-    }
-
     return flags;
 }
 
@@ -329,7 +324,7 @@
 
     while (mDecoder.GetRemainingLengthInStruct())
     {
-        VerifyOrExit(addressesCount < Ip6AddressesTlv::kMaxAddresses, error = OT_ERROR_NO_BUFS);
+        VerifyOrExit(addressesCount < OT_IP6_MAX_MLR_ADDRESSES, error = OT_ERROR_NO_BUFS);
         SuccessOrExit(error = mDecoder.ReadIp6Address(addresses[addressesCount]));
         ++addressesCount;
     }
@@ -1026,7 +1021,7 @@
         (mDecoder.ReadUint8(flagsExtended) == OT_ERROR_NONE))
     {
         borderRouterConfig.mNdDns = ((flagsExtended & SPINEL_NET_FLAG_EXT_DNS) != 0);
-        borderRouterConfig.mDp    = ((flagsExtended & SPINEL_NET_FLAG_EXT_DP) != 0);
+        borderRouterConfig.mDp    = false;
     }
 
     error = otBorderRouterAddOnMeshPrefix(mInstance, &borderRouterConfig);
diff --git a/src/posix/main.c b/src/posix/main.c
index 88e373d..3fe5353 100644
--- a/src/posix/main.c
+++ b/src/posix/main.c
@@ -141,6 +141,7 @@
     OT_POSIX_OPT_DATA_PATH,
     OT_POSIX_OPT_RADIO_VERSION,
     OT_POSIX_OPT_REAL_TIME_SIGNAL,
+    OT_POSIX_OPT_SETTINGS_FILE,
 #if OPENTHREAD_CONFIG_PLATFORM_NETIF_ENABLE
     OT_POSIX_OPT_TUN_DEVICE,
 #endif
@@ -156,6 +157,7 @@
     {"persistent-interface", no_argument, NULL, OT_POSIX_OPT_PERSISTENT_INTERFACE},
     {"radio-version", no_argument, NULL, OT_POSIX_OPT_RADIO_VERSION},
     {"real-time-signal", required_argument, NULL, OT_POSIX_OPT_REAL_TIME_SIGNAL},
+    {"settings-file", required_argument, NULL, OT_POSIX_OPT_SETTINGS_FILE},
     {"time-speed", required_argument, NULL, OT_POSIX_OPT_TIME_SPEED},
 #if OPENTHREAD_CONFIG_PLATFORM_NETIF_ENABLE
     {"tun-device", required_argument, NULL, OT_POSIX_OPT_TUN_DEVICE},
@@ -170,6 +172,7 @@
             "    %s [Options] RadioURL [RadioURL]\n"
             "Options:\n"
             "        --data-path               Path of directory to store data.\n"
+            "        --settings-file           Fixed settings file base name (overrides EUI64-based naming).\n"
 #if OPENTHREAD_CONFIG_PLATFORM_NETIF_ENABLE
             "        --tun-device              POSIX TUN Device.\n"
 #endif
@@ -263,6 +266,9 @@
         case OT_POSIX_OPT_DATA_PATH:
             aConfig->mPlatformConfig.mDataPath = optarg;
             break;
+        case OT_POSIX_OPT_SETTINGS_FILE:
+            aConfig->mPlatformConfig.mSettingsFile = optarg;
+            break;
 #ifdef SIGRTMIN
         case OT_POSIX_OPT_REAL_TIME_SIGNAL:
             if (optarg[0] == '+')
diff --git a/src/posix/platform/CMakeLists.txt b/src/posix/platform/CMakeLists.txt
index 0fa97dc..d8a2374 100644
--- a/src/posix/platform/CMakeLists.txt
+++ b/src/posix/platform/CMakeLists.txt
@@ -51,6 +51,7 @@
     )
 endif()
 
+ot_option(OT_POSIX_INFRA_NETIF_LOST_EXIT OPENTHREAD_POSIX_CONFIG_EXIT_ON_INFRA_NETIF_LOST_ENABLE "exit on infrastructure network interface lost")
 
 option(OT_POSIX_INSTALL_EXTERNAL_ROUTES "Install External Routes as IPv6 routes" ON)
 if(OT_POSIX_INSTALL_EXTERNAL_ROUTES)
@@ -151,6 +152,7 @@
     spinel_manager.cpp
     spi_interface.cpp
     system.cpp
+    tcp.cpp
     tmp_storage.cpp
     trel.cpp
     udp.cpp
diff --git a/src/posix/platform/dhcp6_pd_socket.cpp b/src/posix/platform/dhcp6_pd_socket.cpp
index 8a3659b..d7cc9fc 100644
--- a/src/posix/platform/dhcp6_pd_socket.cpp
+++ b/src/posix/platform/dhcp6_pd_socket.cpp
@@ -255,7 +255,21 @@
         CopyIp6AddressTo(metadata.mAddress, &addr6.sin6_addr);
 
         bytesSent = sendto(mFd6, buffer, length, 0, reinterpret_cast<struct sockaddr *>(&addr6), sizeof(addr6));
-        VerifyOrExit(bytesSent == length);
+
+        if (bytesSent != static_cast<int>(length))
+        {
+            if (errno == EAGAIN || errno == EWOULDBLOCK)
+            {
+                // Socket send buffer full - retry when writable
+                ExitNow();
+            }
+
+            // Fatal send error - drop message; OT core retransmit timer will re-send after backoff
+            LogWarn("sendto() failed errno:%s - dropping message", strerror(errno));
+            otMessageQueueDequeue(&mTxQueue, message);
+            otMessageFree(message);
+            continue;
+        }
 
         otMessageQueueDequeue(&mTxQueue, message);
         otMessageFree(message);
diff --git a/src/posix/platform/firewall.cpp b/src/posix/platform/firewall.cpp
index 7e9d47e..7a0ad2d 100644
--- a/src/posix/platform/firewall.cpp
+++ b/src/posix/platform/firewall.cpp
@@ -96,10 +96,6 @@
     // 2. Update otbr-deny-src-swap
     while (otNetDataGetNextOnMeshPrefix(aInstance, &iterator, &config) == OT_ERROR_NONE)
     {
-        if (config.mDp)
-        {
-            continue;
-        }
         otIp6PrefixToString(&config.mPrefix, prefixBuf, sizeof(prefixBuf));
         SuccessOrExit(error = ipSetManager.AddToIpSet(kIngressDenySrcSwapIpSet, prefixBuf));
     }
diff --git a/src/posix/platform/hdlc_interface.cpp b/src/posix/platform/hdlc_interface.cpp
index afe0842..1807e6f 100644
--- a/src/posix/platform/hdlc_interface.cpp
+++ b/src/posix/platform/hdlc_interface.cpp
@@ -49,6 +49,7 @@
 #endif
 #include <stdarg.h>
 #include <stdlib.h>
+#include <sys/file.h>
 #include <sys/ioctl.h>
 #include <sys/resource.h>
 #include <sys/stat.h>
@@ -201,7 +202,11 @@
     {
         Decode(buffer, static_cast<uint16_t>(rval));
     }
-    else if ((rval < 0) && (errno != EAGAIN) && (errno != EINTR))
+    else if (rval == 0)
+    {
+        DieNowWithMessage("RCP device disconnected (EOF)", OT_EXIT_FAILURE);
+    }
+    else if ((errno != EAGAIN) && (errno != EWOULDBLOCK) && (errno != EINTR))
     {
         DieNow(OT_EXIT_ERROR_ERRNO);
     }
@@ -459,6 +464,26 @@
         ExitNow();
     }
 
+    if (aRadioUrl.HasParam("uart-exclusive"))
+    {
+        // Lock the device early to prevent concurrent access
+        if (flock(fd, LOCK_EX | LOCK_NB) == -1)
+        {
+            perror("flock uart failed, device already in use");
+            close(fd);
+            fd = -1;
+            ExitNow();
+        }
+
+#ifdef TIOCEXCL
+        // Set exclusive access mode if supported by the platform
+        if (ioctl(fd, TIOCEXCL) == -1)
+        {
+            LogWarn("ioctl(TIOCEXCL) failed: %s", strerror(errno));
+        }
+#endif
+    }
+
     if (isatty(fd))
     {
         struct termios tios;
@@ -660,9 +685,29 @@
 #if OPENTHREAD_POSIX_CONFIG_RCP_PTY_ENABLE
 int HdlcInterface::ForkPty(const Url::Url &aRadioUrl)
 {
-    int fd   = -1;
-    int pid  = -1;
-    int rval = -1;
+    int           fd            = -1;
+    int           pid           = -1;
+    int           rval          = -1;
+    constexpr int kMaxArguments = 32;
+    char         *argv[kMaxArguments + 1];
+    size_t        index = 0;
+
+    argv[index++] = const_cast<char *>(aRadioUrl.GetPath());
+
+    for (const char *arg = nullptr;
+         index < OT_ARRAY_LENGTH(argv) && (arg = aRadioUrl.GetValue("forkpty-arg", arg)) != nullptr;
+         argv[index++] = const_cast<char *>(arg))
+    {
+    }
+
+    if (index < OT_ARRAY_LENGTH(argv))
+    {
+        argv[index] = nullptr;
+    }
+    else
+    {
+        DieNowWithMessage("Too many arguments!", OT_EXIT_INVALID_ARGUMENTS);
+    }
 
     {
         struct termios tios;
@@ -676,27 +721,6 @@
 
     if (0 == pid)
     {
-        constexpr int kMaxArguments = 32;
-        char         *argv[kMaxArguments + 1];
-        size_t        index = 0;
-
-        argv[index++] = const_cast<char *>(aRadioUrl.GetPath());
-
-        for (const char *arg = nullptr;
-             index < OT_ARRAY_LENGTH(argv) && (arg = aRadioUrl.GetValue("forkpty-arg", arg)) != nullptr;
-             argv[index++] = const_cast<char *>(arg))
-        {
-        }
-
-        if (index < OT_ARRAY_LENGTH(argv))
-        {
-            argv[index] = nullptr;
-        }
-        else
-        {
-            DieNowWithMessage("Too many arguments!", OT_EXIT_INVALID_ARGUMENTS);
-        }
-
         VerifyOrDie((rval = execvp(argv[0], argv)) != -1, OT_EXIT_ERROR_ERRNO);
     }
     else
diff --git a/src/posix/platform/include/openthread/openthread-system.h b/src/posix/platform/include/openthread/openthread-system.h
index adc7caa..2c6afa3 100644
--- a/src/posix/platform/include/openthread/openthread-system.h
+++ b/src/posix/platform/include/openthread/openthread-system.h
@@ -96,6 +96,8 @@
     CoprocessorType mCoprocessorType;                 ///< The co-processor type. This field is used to pass
                                                       ///< the type to the app layer.
     const char *mDataPath;                            ///< Data path.
+    const char *mSettingsFile;                        ///< Fixed settings file base name. When set, this
+                                                      ///< overrides the default EUI64-based file naming.
 } otPlatformConfig;
 
 /**
diff --git a/src/posix/platform/ip6_utils.hpp b/src/posix/platform/ip6_utils.hpp
index 1d2f35d..86acb20 100644
--- a/src/posix/platform/ip6_utils.hpp
+++ b/src/posix/platform/ip6_utils.hpp
@@ -87,7 +87,7 @@
 }
 
 /**
- * Reads and set the the IPv6 address bytes from a given buffer.
+ * Reads and set the IPv6 address bytes from a given buffer.
  *
  * @param[in] aBuffer    A pointer to buffer to read from.
  * @param[out] aAddress  A reference to populate with the read IPv6 address.
diff --git a/src/posix/platform/logging.cpp b/src/posix/platform/logging.cpp
index 8dd2a5b..aac8bd1 100644
--- a/src/posix/platform/logging.cpp
+++ b/src/posix/platform/logging.cpp
@@ -36,40 +36,53 @@
 #include <openthread/platform/logging.h>
 
 #if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
-OT_TOOL_WEAK void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
-{
-    OT_UNUSED_VARIABLE(aLogRegion);
 
-    va_list args;
+static int ConvertLogLevelToSyslogLevel(otLogLevel aLogLevel)
+{
+    int level = LOG_DEBUG;
 
     switch (aLogLevel)
     {
     case OT_LOG_LEVEL_NONE:
-        aLogLevel = LOG_ALERT;
+        level = LOG_ALERT;
         break;
     case OT_LOG_LEVEL_CRIT:
-        aLogLevel = LOG_CRIT;
+        level = LOG_CRIT;
         break;
     case OT_LOG_LEVEL_WARN:
-        aLogLevel = LOG_WARNING;
+        level = LOG_WARNING;
         break;
     case OT_LOG_LEVEL_NOTE:
-        aLogLevel = LOG_NOTICE;
+        level = LOG_NOTICE;
         break;
     case OT_LOG_LEVEL_INFO:
-        aLogLevel = LOG_INFO;
+        level = LOG_INFO;
         break;
     case OT_LOG_LEVEL_DEBG:
-        aLogLevel = LOG_DEBUG;
+        level = LOG_DEBUG;
         break;
     default:
         assert(false);
-        aLogLevel = LOG_DEBUG;
         break;
     }
 
+    return level;
+}
+
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+OT_TOOL_WEAK void otPlatLogOutput(otInstance *, otLogLevel aLogLevel, const char *aLogLine)
+{
+    syslog(ConvertLogLevelToSyslogLevel(aLogLevel), "%s", aLogLine);
+}
+#else
+OT_TOOL_WEAK void otPlatLog(otLogLevel aLogLevel, otLogRegion, const char *aFormat, ...)
+{
+    va_list args;
+
     va_start(args, aFormat);
-    vsyslog(aLogLevel, aFormat, args);
+    vsyslog(ConvertLogLevelToSyslogLevel(aLogLevel), aFormat, args);
     va_end(args);
 }
+#endif
+
 #endif // OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
diff --git a/src/posix/platform/multicast_routing.cpp b/src/posix/platform/multicast_routing.cpp
index e9564a0..c47e726 100644
--- a/src/posix/platform/multicast_routing.cpp
+++ b/src/posix/platform/multicast_routing.cpp
@@ -279,8 +279,8 @@
     VerifyOrExit(mrt6msg->im6_mbz == 0);
     VerifyOrExit(mrt6msg->im6_msgtype == MRT6MSG_NOCACHE);
 
-    src.SetBytes(mrt6msg->im6_src.s6_addr);
-    dst.SetBytes(mrt6msg->im6_dst.s6_addr);
+    src.InitFrom(mrt6msg->im6_src.s6_addr);
+    dst.InitFrom(mrt6msg->im6_dst.s6_addr);
 
     error = AddMulticastForwardingCache(src, dst, static_cast<MifIndex>(mrt6msg->im6_mif));
 
diff --git a/src/posix/platform/netif.cpp b/src/posix/platform/netif.cpp
index 8ac42ca..829dc69 100644
--- a/src/posix/platform/netif.cpp
+++ b/src/posix/platform/netif.cpp
@@ -73,6 +73,7 @@
 #include <ifaddrs.h>
 #ifdef __linux__
 #include <linux/if_addr.h>
+#include <linux/if_addrlabel.h>
 #include <linux/if_link.h>
 #include <linux/if_tun.h>
 #include <linux/netlink.h>
@@ -205,7 +206,14 @@
 #endif // OPENTHREAD_POSIX_TUN_DEVICE
 
 #ifdef __linux__
-static uint32_t sNetlinkSequence = 0; ///< Netlink message sequence.
+static constexpr uint32_t kMeshLocalAddrLabel = 99;
+static uint32_t           sNetlinkSequence    = 0; ///< Netlink message sequence.
+static otMeshLocalPrefix  sLabeledMeshLocalPrefix;
+static bool               sIsMeshLocalPrefixLabeled = false;
+
+static void AddAddressLabel(const uint8_t *aAddress, uint8_t aPrefixLen, uint32_t aLabel);
+static void DeleteAddressLabel(const uint8_t *aAddress, uint8_t aPrefixLen);
+static void UpdateMeshLocalPrefixLabel(otInstance *aInstance);
 #endif
 
 #if OPENTHREAD_POSIX_CONFIG_INSTALL_OMR_ROUTES_ENABLE && defined(__linux__)
@@ -469,7 +477,7 @@
 
     AddRtAttr(&req.nh, sizeof(req), IFA_LOCAL, aAddressInfo.mAddress, sizeof(*aAddressInfo.mAddress));
 
-    if (!aAddressInfo.mPreferred || aAddressInfo.mMeshLocal || aAddressInfo.mScope == kLinkLocalScope)
+    if (!aAddressInfo.mPreferred || aAddressInfo.mScope == kLinkLocalScope)
     {
         struct ifa_cacheinfo cacheinfo;
 
@@ -772,6 +780,129 @@
     return error;
 }
 
+#ifdef __linux__
+static void AddAddressLabel(const uint8_t *aAddress, uint8_t aPrefixLen, uint32_t aLabel)
+{
+    constexpr unsigned int kBufSize = 128;
+    struct
+    {
+        struct nlmsghdr     header;
+        struct ifaddrlblmsg msg;
+        char                buf[kBufSize];
+    } req{};
+    unsigned int netifIdx = otSysGetThreadNetifIndex();
+    char         addrStrBuf[INET6_ADDRSTRLEN];
+
+    VerifyOrExit(netifIdx > 0);
+    VerifyOrExit(sNetlinkFd >= 0);
+
+    req.header.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL;
+
+    req.header.nlmsg_len  = NLMSG_LENGTH(sizeof(ifaddrlblmsg));
+    req.header.nlmsg_type = RTM_NEWADDRLABEL;
+    req.header.nlmsg_pid  = 0;
+    req.header.nlmsg_seq  = ++sNetlinkSequence;
+
+    req.msg.ifal_family    = AF_INET6;
+    req.msg.ifal_prefixlen = aPrefixLen;
+    req.msg.ifal_flags     = 0;
+    req.msg.ifal_index     = netifIdx;
+    req.msg.ifal_seq       = 0;
+
+    AddRtAttr(reinterpret_cast<nlmsghdr *>(&req), sizeof(req), IFAL_ADDRESS, aAddress, 16);
+    AddRtAttrUint32(&req.header, sizeof(req), IFAL_LABEL, aLabel);
+
+    inet_ntop(AF_INET6, aAddress, addrStrBuf, sizeof(addrStrBuf));
+
+    if (send(sNetlinkFd, &req, req.header.nlmsg_len, 0) < 0)
+    {
+        LogWarn("Failed to send request#%u to add address label %s/%u: %s", sNetlinkSequence, addrStrBuf, aPrefixLen,
+                strerror(errno));
+    }
+    else
+    {
+        LogInfo("Sent request#%u to add address label %s/%u with label %u", sNetlinkSequence, addrStrBuf, aPrefixLen,
+                aLabel);
+    }
+exit:
+    return;
+}
+
+static void DeleteAddressLabel(const uint8_t *aAddress, uint8_t aPrefixLen)
+{
+    constexpr unsigned int kBufSize = 128;
+    struct
+    {
+        struct nlmsghdr     header;
+        struct ifaddrlblmsg msg;
+        char                buf[kBufSize];
+    } req{};
+    unsigned int netifIdx = otSysGetThreadNetifIndex();
+    char         addrStrBuf[INET6_ADDRSTRLEN];
+
+    VerifyOrExit(netifIdx > 0);
+    VerifyOrExit(sNetlinkFd >= 0);
+
+    req.header.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
+
+    req.header.nlmsg_len  = NLMSG_LENGTH(sizeof(ifaddrlblmsg));
+    req.header.nlmsg_type = RTM_DELADDRLABEL;
+    req.header.nlmsg_pid  = 0;
+    req.header.nlmsg_seq  = ++sNetlinkSequence;
+
+    req.msg.ifal_family    = AF_INET6;
+    req.msg.ifal_prefixlen = aPrefixLen;
+    req.msg.ifal_flags     = 0;
+    req.msg.ifal_index     = netifIdx;
+    req.msg.ifal_seq       = 0;
+
+    AddRtAttr(reinterpret_cast<nlmsghdr *>(&req), sizeof(req), IFAL_ADDRESS, aAddress, 16);
+
+    inet_ntop(AF_INET6, aAddress, addrStrBuf, sizeof(addrStrBuf));
+
+    if (send(sNetlinkFd, &req, req.header.nlmsg_len, 0) < 0)
+    {
+        LogWarn("Failed to send request#%u to delete address label %s/%u: %s", sNetlinkSequence, addrStrBuf, aPrefixLen,
+                strerror(errno));
+    }
+    else
+    {
+        LogInfo("Sent request#%u to delete address label %s/%u", sNetlinkSequence, addrStrBuf, aPrefixLen);
+    }
+exit:
+    return;
+}
+
+static void UpdateMeshLocalPrefixLabel(otInstance *aInstance)
+{
+    const otMeshLocalPrefix *prefix = otIp6IsEnabled(aInstance) ? otThreadGetMeshLocalPrefix(aInstance) : nullptr;
+    otIp6Address             address;
+
+    VerifyOrExit(prefix == nullptr || !sIsMeshLocalPrefixLabeled ||
+                 memcmp(&sLabeledMeshLocalPrefix, prefix, sizeof(otMeshLocalPrefix)) != 0);
+
+    if (sIsMeshLocalPrefixLabeled)
+    {
+        memset(&address, 0, sizeof(address));
+        memcpy(address.mFields.m8, sLabeledMeshLocalPrefix.m8, sizeof(sLabeledMeshLocalPrefix));
+        DeleteAddressLabel(address.mFields.m8, 64);
+        sIsMeshLocalPrefixLabeled = false;
+    }
+
+    if (prefix != nullptr)
+    {
+        memset(&address, 0, sizeof(address));
+        memcpy(address.mFields.m8, prefix->m8, sizeof(*prefix));
+        AddAddressLabel(address.mFields.m8, 64, kMeshLocalAddrLabel);
+        sLabeledMeshLocalPrefix   = *prefix;
+        sIsMeshLocalPrefixLabeled = true;
+    }
+
+exit:
+    return;
+}
+#endif // __linux__
+
 #if OPENTHREAD_POSIX_CONFIG_INSTALL_OMR_ROUTES_ENABLE || OPENTHREAD_POSIX_CONFIG_INSTALL_EXTERNAL_ROUTES_ENABLE
 static otError AddRoute(const otIp6Prefix &aPrefix, uint32_t aPriority)
 {
@@ -1059,6 +1190,12 @@
     {
         UpdateLink(aInstance);
     }
+#ifdef __linux__
+    if ((OT_CHANGED_THREAD_NETIF_STATE | OT_CHANGED_THREAD_ML_ADDR) & aFlags)
+    {
+        UpdateMeshLocalPrefixLabel(aInstance);
+    }
+#endif
     if (OT_CHANGED_THREAD_NETDATA & aFlags)
     {
 #if OPENTHREAD_POSIX_CONFIG_INSTALL_OMR_ROUTES_ENABLE && defined(__linux__)
@@ -1182,7 +1319,7 @@
     otDumpInfoPlat("", data, static_cast<size_t>(length));
 #endif
 
-    raLength = length + (ra - data);
+    raLength = length - (ra - data);
     otPlatBorderRoutingProcessIcmp6Ra(aInstance, ra, raLength);
 
 exit:
diff --git a/src/posix/platform/platform-posix.h b/src/posix/platform/platform-posix.h
index 0e01c64..04a89e0 100644
--- a/src/posix/platform/platform-posix.h
+++ b/src/posix/platform/platform-posix.h
@@ -175,9 +175,10 @@
  *
  * @note This function is called before OpenThread instance is created.
  *
- * @param[in]  aDataPath   The data path to store setting files.
+ * @param[in]  aDataPath           The data path to store setting files.
+ * @param[in]  aSettingsFileName   The fixed settings file base name, or nullptr to use EUI-64 based naming.
  */
-void platformSettingsInit(const char *aDataPath);
+void platformSettingsInit(const char *aDataPath, const char *aSettingsFileName);
 
 /**
  * Initializes the logging service used by OpenThread.
diff --git a/src/posix/platform/radio.cpp b/src/posix/platform/radio.cpp
index 4a525e7..0804e1d 100644
--- a/src/posix/platform/radio.cpp
+++ b/src/posix/platform/radio.cpp
@@ -63,8 +63,7 @@
 const char Radio::kLogModuleName[] = "Radio";
 
 Radio::Radio(void)
-    : mRadioUrl(nullptr)
-    , mRadioSpinel()
+    : mRadioSpinel()
 #if OPENTHREAD_POSIX_CONFIG_RCP_CAPS_DIAG_ENABLE
     , mRcpCapsDiag(mRadioSpinel)
 #endif
@@ -80,8 +79,10 @@
 
 void Radio::Init(const char *aUrl)
 {
-    bool resetRadio;
-    bool skipCompatibilityCheck;
+    bool      resetRadio;
+    bool      skipCompatibilityCheck;
+    RadioUrl &radioUrl = SpinelManager::GetSpinelManager().GetRadioUrl();
+
 #if OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2 && OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE
     bool aEnableRcpTimeSync = true;
 #else
@@ -89,8 +90,8 @@
 #endif
     struct ot::Spinel::RadioSpinelCallbacks callbacks;
 
-    mRadioUrl.Init(aUrl);
-    VerifyOrDie(mRadioUrl.GetPath() != nullptr, OT_EXIT_INVALID_ARGUMENTS);
+    VerifyOrDie(radioUrl.GetPath() != nullptr, OT_EXIT_INVALID_ARGUMENTS);
+    OT_UNUSED_VARIABLE(aUrl);
 
     memset(&callbacks, 0, sizeof(callbacks));
     callbacks.mEnergyScanDone    = otPlatRadioEnergyScanDone;
@@ -105,8 +106,8 @@
     mTmpStorage.Init();
 #endif
 
-    resetRadio             = !mRadioUrl.HasParam("no-reset");
-    skipCompatibilityCheck = mRadioUrl.HasParam("skip-rcp-compatibility-check");
+    resetRadio             = !radioUrl.HasParam("no-reset");
+    skipCompatibilityCheck = radioUrl.HasParam("skip-rcp-compatibility-check");
 
 #if OPENTHREAD_SPINEL_CONFIG_COPROCESSOR_RESET_FAILURE_CALLBACK_ENABLE
     GetSpinelDriver().SetCoprocessorResetFailureCallback(platformCoprocessorResetFailed, this);
@@ -116,7 +117,7 @@
     mRadioSpinel.Init(skipCompatibilityCheck, resetRadio, &GetSpinelDriver(),
                       (skipCompatibilityCheck ? 0 : kRequiredRadioCaps), aEnableRcpTimeSync);
 
-    ProcessRadioUrl(mRadioUrl);
+    ProcessRadioUrl(radioUrl);
 }
 
 void Radio::Deinit(void)
@@ -201,17 +202,21 @@
 
 #if OPENTHREAD_POSIX_CONFIG_MAX_POWER_TABLE_ENABLE
     otError          error;
-    constexpr int8_t kPowerDefault = 30; // Default power 1 watt (30 dBm).
-    const char      *str           = nullptr;
-    char            *pSave         = nullptr;
-    uint8_t          channel       = ot::Radio::kChannelMin;
-    int8_t           power         = kPowerDefault;
-    const char      *maxPowerTable;
+    constexpr int8_t kPowerDefault     = 30; // Default power 1 watt (30 dBm).
+    char            *str               = nullptr;
+    char            *pSave             = nullptr;
+    uint8_t          channel           = ot::Radio::kChannelMin;
+    int8_t           power             = kPowerDefault;
+    const char      *maxPowerTable     = nullptr;
+    char            *maxPowerTableCopy = nullptr;
 
     VerifyOrExit((maxPowerTable = aRadioUrl.GetValue("max-power-table")) != nullptr);
 
-    for (str = strtok_r(const_cast<char *>(maxPowerTable), ",", &pSave);
-         str != nullptr && channel <= ot::Radio::kChannelMax; str = strtok_r(nullptr, ",", &pSave))
+    maxPowerTableCopy = strdup(maxPowerTable);
+    VerifyOrDie(maxPowerTableCopy != nullptr, OT_EXIT_FAILURE);
+
+    for (str = strtok_r(maxPowerTableCopy, ",", &pSave); str != nullptr && channel <= ot::Radio::kChannelMax;
+         str = strtok_r(nullptr, ",", &pSave))
     {
         power = static_cast<int8_t>(strtol(str, nullptr, 0));
         error = mRadioSpinel.SetChannelMaxTransmitPower(channel, power);
@@ -240,6 +245,10 @@
     VerifyOrDie(str == nullptr, OT_EXIT_INVALID_ARGUMENTS);
 
 exit:
+    if (maxPowerTableCopy != nullptr)
+    {
+        free(maxPowerTableCopy);
+    }
     return;
 #endif // OPENTHREAD_POSIX_CONFIG_MAX_POWER_TABLE_ENABLE
 }
@@ -969,7 +978,7 @@
     OT_UNUSED_VARIABLE(aInstance);
 }
 
-uint64_t otPlatRadioGetNow(otInstance *aInstance)
+otRadioTime64 otPlatRadioGetNow(otInstance *aInstance)
 {
     OT_UNUSED_VARIABLE(aInstance);
     return GetRadioSpinel().GetNow();
@@ -1088,7 +1097,7 @@
 }
 #endif
 
-otError otPlatRadioReceiveAt(otInstance *aInstance, uint8_t aChannel, uint32_t aStart, uint32_t aDuration)
+otError otPlatRadioReceiveAt(otInstance *aInstance, uint8_t aChannel, otRadioTime32 aStart, uint32_t aDuration)
 {
     OT_UNUSED_VARIABLE(aInstance);
     OT_UNUSED_VARIABLE(aChannel);
diff --git a/src/posix/platform/radio.hpp b/src/posix/platform/radio.hpp
index a24d269..aa24920 100644
--- a/src/posix/platform/radio.hpp
+++ b/src/posix/platform/radio.hpp
@@ -140,7 +140,6 @@
 #endif
         OT_RADIO_CAPS_ACK_TIMEOUT | OT_RADIO_CAPS_TRANSMIT_RETRIES | OT_RADIO_CAPS_CSMA_BACKOFF;
 
-    RadioUrl mRadioUrl;
 #if OPENTHREAD_SPINEL_CONFIG_VENDOR_HOOK_ENABLE
     Spinel::VendorRadioSpinel mRadioSpinel;
 #else
diff --git a/src/posix/platform/radio_url.cpp b/src/posix/platform/radio_url.cpp
index 0a0cf73..3de4872 100644
--- a/src/posix/platform/radio_url.cpp
+++ b/src/posix/platform/radio_url.cpp
@@ -85,6 +85,7 @@
     "    uart-flow-control              Enable flow control, disabled by default.\n"                 \
     "    uart-init-deassert             Deassert lines on init when flow control is disabled.\n"     \
     "    uart-reset                     Reset connection after hard resetting RCP(USB CDC ACM).\n"   \
+    "    uart-exclusive                 Lock uart device using flock / TIOCEXCL.\n"                  \
     "\n"
 #else
 #define OT_SPINEL_HDLC_RADIO_URL_HELP_BUS
@@ -148,8 +149,10 @@
 {
     if (aUrl != nullptr)
     {
-        VerifyOrDie(strnlen(aUrl, sizeof(mUrl)) < sizeof(mUrl), OT_EXIT_INVALID_ARGUMENTS);
-        strncpy(mUrl, aUrl, sizeof(mUrl) - 1);
+        size_t len = strlen(aUrl);
+
+        VerifyOrDie(len + 1 < sizeof(mUrl), OT_EXIT_INVALID_ARGUMENTS);
+        strcpy(mUrl, aUrl);
         SuccessOrDie(Url::Url::Init(mUrl));
     }
 }
diff --git a/src/posix/platform/settings.cpp b/src/posix/platform/settings.cpp
index 3ae37f1..9e65e2d 100644
--- a/src/posix/platform/settings.cpp
+++ b/src/posix/platform/settings.cpp
@@ -83,13 +83,23 @@
 static otError settingsFileInit(otInstance *aInstance)
 {
     char        fileBaseName[ot::Posix::SettingsFile::kMaxFileBaseNameSize];
-    const char *offset = getenv("PORT_OFFSET");
-    uint64_t    nodeId;
+    const char *settingsFile = ot::Posix::SettingsFile::GetSettingsFileName();
 
-    otPlatRadioGetIeeeEui64(aInstance, reinterpret_cast<uint8_t *>(&nodeId));
-    nodeId = ot::BigEndian::HostSwap64(nodeId);
+    if (settingsFile != nullptr)
+    {
+        snprintf(fileBaseName, sizeof(fileBaseName), "%s", settingsFile);
+    }
+    else
+    {
+        const char *offset = getenv("PORT_OFFSET");
+        uint64_t    nodeId;
 
-    snprintf(fileBaseName, sizeof(fileBaseName), "%s_%" PRIx64, offset == nullptr ? "0" : offset, nodeId);
+        otPlatRadioGetIeeeEui64(aInstance, reinterpret_cast<uint8_t *>(&nodeId));
+        nodeId = ot::BigEndian::HostSwap64(nodeId);
+
+        snprintf(fileBaseName, sizeof(fileBaseName), "%s_%" PRIx64, offset == nullptr ? "0" : offset, nodeId);
+    }
+
     VerifyOrDie(strlen(fileBaseName) < ot::Posix::SettingsFile::kMaxFileBaseNameSize, OT_EXIT_FAILURE);
 
     return sSettingsFile.Init(fileBaseName);
diff --git a/src/posix/platform/settings_file.cpp b/src/posix/platform/settings_file.cpp
index 512cdbf..7ac7219 100644
--- a/src/posix/platform/settings_file.cpp
+++ b/src/posix/platform/settings_file.cpp
@@ -46,12 +46,17 @@
 #include "common/debug.hpp"
 #include "posix/platform/settings_file.hpp"
 
-void platformSettingsInit(const char *aDataPath) { ot::Posix::SettingsFile::SetSettingsPath(aDataPath); }
+void platformSettingsInit(const char *aDataPath, const char *aSettingsFileName)
+{
+    ot::Posix::SettingsFile::SetSettingsPath(aDataPath);
+    ot::Posix::SettingsFile::SetSettingsFileName(aSettingsFileName);
+}
 
 namespace ot {
 namespace Posix {
 
-char SettingsFile::sSettingsPath[] = OPENTHREAD_CONFIG_POSIX_SETTINGS_PATH;
+char SettingsFile::sSettingsPath[]                         = OPENTHREAD_CONFIG_POSIX_SETTINGS_PATH;
+char SettingsFile::sSettingsFileName[kMaxFileBaseNameSize] = "";
 
 const char *SettingsFile::GetSettingsPath(void) { return sSettingsPath; }
 void        SettingsFile::SetSettingsPath(const char *aSettingsPath)
@@ -60,10 +65,27 @@
              aSettingsPath == nullptr ? OPENTHREAD_CONFIG_POSIX_SETTINGS_PATH : aSettingsPath);
 }
 
+const char *SettingsFile::GetSettingsFileName(void)
+{
+    return sSettingsFileName[0] != '\0' ? sSettingsFileName : nullptr;
+}
+void SettingsFile::SetSettingsFileName(const char *aSettingsFileName)
+{
+    if (aSettingsFileName != nullptr)
+    {
+        snprintf(sSettingsFileName, sizeof(sSettingsFileName), "%s", aSettingsFileName);
+    }
+    else
+    {
+        sSettingsFileName[0] = '\0';
+    }
+}
+
 otError SettingsFile::Init(const char *aSettingsFileBaseName)
 {
-    otError     error     = OT_ERROR_NONE;
-    const char *directory = GetSettingsPath();
+    otError     error           = OT_ERROR_NONE;
+    const char *directory       = GetSettingsPath();
+    off_t       lastValidOffset = 0;
 
     OT_ASSERT(strlen(directory) < kMaxFileBasePathNameSize);
     OT_ASSERT((aSettingsFileBaseName != nullptr) && strlen(aSettingsFileBaseName) < kMaxFileBaseNameSize);
@@ -89,6 +111,8 @@
 
     for (off_t size = lseek(mSettingsFd, 0, SEEK_END), offset = lseek(mSettingsFd, 0, SEEK_SET); offset < size;)
     {
+        lastValidOffset = offset;
+
         uint16_t key;
         uint16_t length;
         ssize_t  rval;
@@ -106,7 +130,20 @@
 exit:
     if (error == OT_ERROR_PARSE)
     {
-        VerifyOrDie(ftruncate(mSettingsFd, 0) == 0, OT_EXIT_ERROR_ERRNO);
+        off_t fileSize = lseek(mSettingsFd, 0, SEEK_END);
+
+        if (lastValidOffset > 0)
+        {
+            otLogCritPlat("Settings file corrupt at offset %jd of %jd bytes, truncating to preserve %jd bytes of "
+                          "valid entries",
+                          (intmax_t)lastValidOffset, (intmax_t)fileSize, (intmax_t)lastValidOffset);
+        }
+        else
+        {
+            otLogCritPlat("Settings file corrupt from start (%jd bytes), truncating entire file", (intmax_t)fileSize);
+        }
+
+        VerifyOrDie(ftruncate(mSettingsFd, lastValidOffset) == 0, OT_EXIT_ERROR_ERRNO);
     }
 
     return error;
@@ -368,6 +405,20 @@
     VerifyOrDie(0 == fsync(aFd), OT_EXIT_ERROR_ERRNO);
     VerifyOrDie(0 == rename(swapFile, dataFile), OT_EXIT_ERROR_ERRNO);
 
+    // Best-effort: sync the parent directory so that the rename metadata
+    // reaches stable storage. Without this, a power loss between rename()
+    // and the next journal commit can lose the rename, leaving a partially-
+    // written swap file that causes a parse error on next Init().
+    {
+        int dirFd = open(GetSettingsPath(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+
+        if (dirFd >= 0)
+        {
+            fsync(dirFd);
+            close(dirFd);
+        }
+    }
+
     mSettingsFd = aFd;
 }
 
diff --git a/src/posix/platform/settings_file.hpp b/src/posix/platform/settings_file.hpp
index ffa8b37..1ada6e9 100644
--- a/src/posix/platform/settings_file.hpp
+++ b/src/posix/platform/settings_file.hpp
@@ -56,6 +56,20 @@
      */
     static void SetSettingsPath(const char *aSettingsPath);
 
+    /**
+     * Gets the fixed settings file base name.
+     *
+     * @returns  The fixed settings file base name, or nullptr if not set.
+     */
+    static const char *GetSettingsFileName(void);
+
+    /**
+     * Sets the fixed settings file base name.
+     *
+     * @param[in]   aSettingsFileName   The settings file base name, or nullptr to clear.
+     */
+    static void SetSettingsFileName(const char *aSettingsFileName);
+
     SettingsFile(void)
         : mSettingsFd(-1)
     {
@@ -139,6 +153,7 @@
     void    SwapDiscard(int aFd);
 
     static char sSettingsPath[kMaxFileBasePathNameSize];
+    static char sSettingsFileName[kMaxFileBaseNameSize];
     char        mSettingsFileFullPathName[kMaxFileFullPathNameSize];
     int         mSettingsFd;
 };
diff --git a/src/posix/platform/spi_interface.cpp b/src/posix/platform/spi_interface.cpp
index 6c992b8..90ea3a9 100644
--- a/src/posix/platform/spi_interface.cpp
+++ b/src/posix/platform/spi_interface.cpp
@@ -412,8 +412,9 @@
     txFrame.SetHeaderAcceptLen(0);
     txFrame.SetHeaderDataLen(0);
 
-    // Sanity check.
-    if (mSpiSlaveDataLen > kMaxFrameSize)
+    // Sanity check. The header `data_len` carries the payload length only
+    // (it excludes header size), so the largest valid value is the MTU.
+    if (mSpiSlaveDataLen > kMaxFrameSize - kSpiFrameHeaderSize)
     {
         mSpiSlaveDataLen = 0;
     }
@@ -517,7 +518,8 @@
         slaveAcceptLen   = rxFrame.GetHeaderAcceptLen();
         mSpiSlaveDataLen = rxFrame.GetHeaderDataLen();
 
-        if (!rxFrame.IsValid() || (slaveAcceptLen > kMaxFrameSize) || (mSpiSlaveDataLen > kMaxFrameSize))
+        if (!rxFrame.IsValid() || (slaveAcceptLen > kMaxFrameSize - kSpiFrameHeaderSize) ||
+            (mSpiSlaveDataLen > kMaxFrameSize - kSpiFrameHeaderSize))
         {
             mInterfaceMetrics.mTransferredGarbageFrameCount++;
             mSpiTxRefusedCount++;
diff --git a/src/posix/platform/spinel_manager.hpp b/src/posix/platform/spinel_manager.hpp
index e2af9b4..f5702f2 100644
--- a/src/posix/platform/spinel_manager.hpp
+++ b/src/posix/platform/spinel_manager.hpp
@@ -91,6 +91,13 @@
         return *mSpinelInterface;
     }
 
+    /**
+     * Returns the radio URL.
+     *
+     * @returns The radio URL.
+     */
+    RadioUrl &GetRadioUrl(void) { return mUrl; }
+
 private:
 #if OPENTHREAD_POSIX_VIRTUAL_TIME
     void VirtualTimeInit(void);
diff --git a/src/posix/platform/system.cpp b/src/posix/platform/system.cpp
index 4201704..f6bddb5 100644
--- a/src/posix/platform/system.cpp
+++ b/src/posix/platform/system.cpp
@@ -59,6 +59,8 @@
 #include "posix/platform/mdns_socket.hpp"
 #include "posix/platform/radio_url.hpp"
 #include "posix/platform/spinel_driver_getter.hpp"
+#include "posix/platform/spinel_manager.hpp"
+#include "posix/platform/tcp.hpp"
 #include "posix/platform/udp.hpp"
 
 otInstance *gInstance = nullptr;
@@ -148,6 +150,10 @@
     ot::Posix::InfraNetif::Get().Init();
 #endif
 
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+    ot::Posix::Tcp::Get().Init();
+#endif
+
 #if OPENTHREAD_CONFIG_MULTICAST_DNS_ENABLE
     ot::Posix::MdnsSocket::Get().Init();
 #endif
@@ -168,6 +174,7 @@
     ot::Posix::Udp::Get().Init(aPlatformConfig->mInterfaceName);
 #endif
 #endif
+
 exit:
     return;
 }
@@ -180,7 +187,7 @@
 
 void platformInit(otPlatformConfig *aPlatformConfig)
 {
-    platformSettingsInit(aPlatformConfig->mDataPath);
+    platformSettingsInit(aPlatformConfig->mDataPath, aPlatformConfig->mSettingsFile);
 
 #if OPENTHREAD_POSIX_CONFIG_BACKTRACE_ENABLE
     platformBacktraceInit();
@@ -247,6 +254,10 @@
     ot::Posix::Udp::Get().SetUp();
 #endif
 
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+    ot::Posix::Tcp::Get().SetUp();
+#endif
+
 #if OPENTHREAD_CONFIG_MULTICAST_DNS_ENABLE
     ot::Posix::MdnsSocket::Get().SetUp();
 #endif
@@ -275,6 +286,16 @@
 
     platformInit(aPlatformConfig);
 
+    {
+        const char *unusedParam = nullptr;
+
+        if (ot::Posix::SpinelManager::GetSpinelManager().GetRadioUrl().Validate(&unusedParam) != OT_ERROR_NONE)
+        {
+            otLogCritPlat("Radio URL contains unused parameter: \"%s\"", unusedParam);
+            DieNow(OT_EXIT_INVALID_ARGUMENTS);
+        }
+    }
+
     gDryRun = aPlatformConfig->mDryRun;
     if (sCoprocessorType == OT_COPROCESSOR_RCP)
     {
@@ -299,6 +320,10 @@
     ot::Posix::Udp::Get().TearDown();
 #endif
 
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+    ot::Posix::Tcp::Get().TearDown();
+#endif
+
 #if OPENTHREAD_CONFIG_PLATFORM_NETIF_ENABLE
     platformNetifTearDown();
 #endif
@@ -330,6 +355,11 @@
 #if OPENTHREAD_CONFIG_PLATFORM_UDP_ENABLE
     ot::Posix::Udp::Get().Deinit();
 #endif
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+    ot::Posix::Tcp::Get().Deinit();
+#endif
+
 #if OPENTHREAD_CONFIG_PLATFORM_NETIF_ENABLE
     platformNetifDeinit();
 #endif
diff --git a/src/posix/platform/tcp.cpp b/src/posix/platform/tcp.cpp
new file mode 100644
index 0000000..fd50964
--- /dev/null
+++ b/src/posix/platform/tcp.cpp
@@ -0,0 +1,514 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ * @brief
+ *   This file implements the platform TCP support for POSIX.
+ */
+
+#ifdef __APPLE__
+#define __APPLE_USE_RFC_3542
+#endif
+
+#include "openthread-posix-config.h"
+#include "platform-posix.h"
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+#include <arpa/inet.h>
+#include <assert.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <net/if.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/select.h>
+#include <unistd.h>
+
+#include "common/code_utils.hpp"
+#include "posix/platform/infra_if.hpp"
+#include "posix/platform/ip6_utils.hpp"
+#include "posix/platform/mainloop.hpp"
+#include "posix/platform/tcp.hpp"
+#include "posix/platform/utils.hpp"
+
+#ifndef MSG_NOSIGNAL
+#define MSG_NOSIGNAL 0
+#endif
+
+using namespace ot::Posix::Ip6Utils;
+
+namespace {
+
+constexpr int kMaxListenBacklog     = 5;
+constexpr int kMaxReceiveBufferSize = 1500;
+
+int GetFd(otPlatTcpListener *aListener) { return aListener->mData.mDescriptor; }
+
+void SetFd(otPlatTcpListener *aListener, int aFd) { aListener->mData.mDescriptor = aFd; }
+
+int GetFd(otPlatTcpConnection *aConn) { return aConn->mData.mDescriptor; }
+
+void SetFd(otPlatTcpConnection *aConn, int aFd) { aConn->mData.mDescriptor = aFd; }
+
+void ConvertSockAddr(const otPlatTcpSockAddr &aIn, struct sockaddr_in6 &aOut)
+{
+    memset(&aOut, 0, sizeof(aOut));
+    aOut.sin6_family   = AF_INET6;
+    aOut.sin6_port     = htons(aIn.mSockAddr.mPort);
+    aOut.sin6_scope_id = aIn.mIfIndex;
+
+    if (IsIp6AddressUnspecified(aIn.mSockAddr.mAddress))
+    {
+        aOut.sin6_addr = in6addr_any;
+    }
+    else
+    {
+        CopyIp6AddressTo(aIn.mSockAddr.mAddress, &aOut.sin6_addr);
+    }
+}
+
+void SetNoSigPipe(int aFd)
+{
+#ifdef SO_NOSIGPIPE
+    int opt = 1;
+    setsockopt(aFd, SOL_SOCKET, SO_NOSIGPIPE, &opt, sizeof(opt));
+#else
+    OT_UNUSED_VARIABLE(aFd);
+#endif
+}
+
+void SetReuseAddr(int aFd)
+{
+    int opt = 1;
+
+    setsockopt(aFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
+}
+
+otError BindToInterface(int aFd, uint32_t aIfIndex)
+{
+    otError error = OT_ERROR_NONE;
+
+    VerifyOrExit(aIfIndex > 0);
+
+#ifdef __linux__
+    {
+        char ifName[IFNAMSIZ];
+
+        VerifyOrExit(if_indextoname(aIfIndex, ifName) != nullptr, error = OT_ERROR_FAILED);
+        VerifyOrExit(setsockopt(aFd, SOL_SOCKET, SO_BINDTODEVICE, ifName, strlen(ifName)) == 0,
+                     error = OT_ERROR_FAILED);
+    }
+#else  // __NetBSD__ || __FreeBSD__ || __APPLE__
+    VerifyOrExit(setsockopt(aFd, IPPROTO_IPV6, IPV6_BOUND_IF, &aIfIndex, sizeof(aIfIndex)) == 0,
+                 error = OT_ERROR_FAILED);
+#endif // __linux__
+
+exit:
+    return error;
+}
+
+} // namespace
+
+//---------------------------------------------------------------------------------------------------------------------
+// otPlatTcp APIs
+
+extern "C" otError otPlatTcpEnableListener(otPlatTcpListener *aListener, const otPlatTcpSockAddr *aLocalSockAddr)
+{
+    otError             error = OT_ERROR_NONE;
+    int                 fd    = -1;
+    struct sockaddr_in6 sin6;
+
+    ConvertSockAddr(*aLocalSockAddr, sin6);
+
+    fd = ot::Posix::SocketWithCloseExec(AF_INET6, SOCK_STREAM, IPPROTO_TCP, ot::Posix::kSocketNonBlock);
+    VerifyOrExit(fd >= 0, error = OT_ERROR_FAILED);
+
+    SetReuseAddr(fd);
+    SetNoSigPipe(fd);
+
+    SuccessOrExit(error = BindToInterface(fd, aLocalSockAddr->mIfIndex));
+
+    if (bind(fd, reinterpret_cast<struct sockaddr *>(&sin6), sizeof(sin6)) == -1)
+    {
+        error = (errno == EADDRINUSE) ? OT_ERROR_ALREADY : OT_ERROR_FAILED;
+        ExitNow();
+    }
+
+    if (listen(fd, kMaxListenBacklog) == -1)
+    {
+        ExitNow(error = OT_ERROR_FAILED);
+    }
+
+    SetFd(aListener, fd);
+    fd = -1;
+
+exit:
+    if (fd >= 0)
+    {
+        close(fd);
+    }
+
+    return error;
+}
+
+extern "C" void otPlatTcpDisableListener(otPlatTcpListener *aListener)
+{
+    int fd = GetFd(aListener);
+
+    VerifyOrExit(fd >= 0);
+
+    close(fd);
+    SetFd(aListener, -1);
+
+exit:
+    return;
+}
+
+extern "C" otError otPlatTcpConnect(otPlatTcpConnection     *aConn,
+                                    const otPlatTcpSockAddr *aPeerSockAddr,
+                                    const otPlatTcpSockAddr *aLocalSockAddr)
+{
+    otError             error = OT_ERROR_NONE;
+    int                 fd;
+    struct sockaddr_in6 peerSin6;
+    uint32_t            ifIndex;
+
+    fd = ot::Posix::SocketWithCloseExec(AF_INET6, SOCK_STREAM, IPPROTO_TCP, ot::Posix::kSocketNonBlock);
+    VerifyOrExit(fd >= 0, error = OT_ERROR_FAILED);
+
+    SetNoSigPipe(fd);
+
+    ifIndex = (aLocalSockAddr != nullptr && aLocalSockAddr->mIfIndex > 0) ? aLocalSockAddr->mIfIndex
+                                                                          : aPeerSockAddr->mIfIndex;
+    SuccessOrExit(error = BindToInterface(fd, ifIndex));
+
+    if (aLocalSockAddr != nullptr)
+    {
+        struct sockaddr_in6 localSin6;
+
+        ConvertSockAddr(*aLocalSockAddr, localSin6);
+
+        SetReuseAddr(fd);
+
+        if (bind(fd, reinterpret_cast<struct sockaddr *>(&localSin6), sizeof(localSin6)) == -1)
+        {
+            ExitNow(error = OT_ERROR_FAILED);
+        }
+    }
+
+    ConvertSockAddr(*aPeerSockAddr, peerSin6);
+
+    if (connect(fd, reinterpret_cast<struct sockaddr *>(&peerSin6), sizeof(peerSin6)) == -1)
+    {
+        if (errno != EINPROGRESS)
+        {
+            ExitNow(error = OT_ERROR_FAILED);
+        }
+    }
+
+    SetFd(aConn, fd);
+    fd = -1;
+
+exit:
+    if (fd >= 0)
+    {
+        close(fd);
+    }
+
+    return error;
+}
+
+extern "C" void otPlatTcpNotifyTxPending(otPlatTcpConnection *aConn)
+{
+    // No action requires since we use `otPlatTcpIsTxPending()`
+    // in `Update(Mainloop::Context &aContext)`.
+
+    OT_UNUSED_VARIABLE(aConn);
+}
+
+extern "C" uint16_t otPlatTcpSend(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength)
+{
+    uint16_t written = 0;
+    int      fd      = GetFd(aConn);
+    ssize_t  ret;
+
+    VerifyOrExit(fd >= 0);
+
+    ret = send(fd, aBuffer, aLength, MSG_NOSIGNAL);
+
+    if (ret > 0)
+    {
+        written = static_cast<uint16_t>(ret);
+    }
+
+exit:
+    return written;
+}
+
+extern "C" void otPlatTcpClose(otPlatTcpConnection *aConn)
+{
+    int fd = GetFd(aConn);
+
+    if (fd >= 0)
+    {
+        close(fd);
+        SetFd(aConn, -1);
+    }
+
+    otPlatTcpHandleDisconnected(aConn, OT_PLAT_TCP_DISCONNECT_REASON_CLOSED);
+}
+
+extern "C" void otPlatTcpAbort(otPlatTcpConnection *aConn)
+{
+    struct linger sl;
+    int           fd = GetFd(aConn);
+
+    VerifyOrExit(fd >= 0);
+
+    sl.l_onoff  = 1;
+    sl.l_linger = 0;
+    setsockopt(fd, SOL_SOCKET, SO_LINGER, &sl, sizeof(sl));
+
+    close(fd);
+    SetFd(aConn, -1);
+
+exit:
+    return;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// Posix::Tcp
+
+namespace ot {
+namespace Posix {
+
+const char Tcp::kLogModuleName[] = "Tcp";
+
+Tcp &Tcp::Get(void)
+{
+    static Tcp sInstance;
+
+    return sInstance;
+}
+
+void Tcp::Init(void) { LogDebg("Init"); }
+
+void Tcp::SetUp(void) { Mainloop::Manager::Get().Add(*this); }
+
+void Tcp::TearDown(void) { Mainloop::Manager::Get().Remove(*this); }
+
+void Tcp::Deinit(void) { LogDebg("Deinit"); }
+
+void Tcp::Update(Mainloop::Context &aContext)
+{
+    otPlatTcpListener   *listener = nullptr;
+    otPlatTcpConnection *conn     = nullptr;
+
+    while ((listener = otPlatTcpIterateListeners(gInstance, listener)) != nullptr)
+    {
+        Mainloop::AddToReadFdSet(GetFd(listener), aContext);
+    }
+
+    while ((conn = otPlatTcpIterateConnections(gInstance, conn)) != nullptr)
+    {
+        int fd = GetFd(conn);
+
+        if (otPlatTcpIsConnecting(conn))
+        {
+            Mainloop::AddToWriteFdSet(fd, aContext);
+            Mainloop::AddToErrorFdSet(fd, aContext);
+        }
+        else
+        {
+            Mainloop::AddToReadFdSet(fd, aContext);
+
+            if (otPlatTcpIsTxPending(conn))
+            {
+                Mainloop::AddToWriteFdSet(fd, aContext);
+            }
+        }
+    }
+}
+
+void Tcp::Process(const Mainloop::Context &aContext)
+{
+    otPlatTcpListener   *listener = nullptr;
+    otPlatTcpConnection *conn     = nullptr;
+
+    while ((listener = otPlatTcpIterateListeners(gInstance, listener)) != nullptr)
+    {
+        ProcessListener(listener, aContext);
+    }
+
+    while ((conn = otPlatTcpIterateConnections(gInstance, conn)) != nullptr)
+    {
+        ProcessConnection(conn, aContext);
+    }
+}
+
+void Tcp::ProcessListener(otPlatTcpListener *aListener, const Mainloop::Context &aContext)
+{
+    int                  fd = GetFd(aListener);
+    struct sockaddr_in6  peerAddr;
+    socklen_t            addrLen = sizeof(peerAddr);
+    int                  newFd;
+    otPlatTcpSockAddr    peerSockAddr;
+    otPlatTcpConnection *newConn;
+
+    VerifyOrExit(fd >= 0);
+
+    VerifyOrExit(Mainloop::IsFdReadable(fd, aContext));
+
+    newFd = accept(fd, reinterpret_cast<struct sockaddr *>(&peerAddr), &addrLen);
+    VerifyOrExit(newFd >= 0);
+
+    memset(&peerSockAddr, 0, sizeof(peerSockAddr));
+    peerSockAddr.mSockAddr.mPort = ntohs(peerAddr.sin6_port);
+    peerSockAddr.mIfIndex        = peerAddr.sin6_scope_id;
+    ReadIp6AddressFrom(&peerAddr.sin6_addr, peerSockAddr.mSockAddr.mAddress);
+
+    newConn = otPlatTcpAccept(aListener, &peerSockAddr);
+    VerifyOrExit(newConn != nullptr, close(newFd));
+
+    fcntl(newFd, F_SETFD, FD_CLOEXEC);
+    fcntl(newFd, F_SETFL, fcntl(newFd, F_GETFL, 0) | O_NONBLOCK);
+    SetNoSigPipe(newFd);
+    SetFd(newConn, newFd);
+
+    otPlatTcpHandleConnected(newConn);
+
+exit:
+    return;
+}
+
+void Tcp::ProcessConnection(otPlatTcpConnection *aConn, const Mainloop::Context &aContext)
+{
+    int fd = GetFd(aConn);
+
+    VerifyOrExit(fd >= 0);
+
+    if (otPlatTcpIsConnecting(aConn))
+    {
+        int       err = 0;
+        socklen_t len = sizeof(err);
+
+        VerifyOrExit(Mainloop::IsFdWritable(fd, aContext) || Mainloop::HasFdErrored(fd, aContext));
+
+        if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == 0 && err == 0)
+        {
+            otPlatTcpHandleConnected(aConn);
+        }
+        else
+        {
+            otPlatTcpDisconnectReason reason = OT_PLAT_TCP_DISCONNECT_REASON_ERROR;
+
+            if (err == ECONNREFUSED)
+            {
+                reason = OT_PLAT_TCP_DISCONNECT_REASON_REFUSED;
+            }
+            else if (err == ETIMEDOUT)
+            {
+                reason = OT_PLAT_TCP_DISCONNECT_REASON_TIMEOUT;
+            }
+
+            close(fd);
+            SetFd(aConn, -1);
+
+            otPlatTcpHandleDisconnected(aConn, reason);
+        }
+
+        ExitNow();
+    }
+
+    // Connection is already connected
+
+    if (Mainloop::IsFdReadable(fd, aContext))
+    {
+        uint8_t buffer[kMaxReceiveBufferSize];
+        ssize_t ret = recv(fd, buffer, sizeof(buffer), 0);
+
+        if (ret > 0)
+        {
+            otPlatTcpHandleReceive(aConn, buffer, static_cast<uint16_t>(ret));
+
+            // The connection may be closed or aborted from within the
+            // `otPlatTcpHandleReceive()` callback. We verify that the
+            // file descriptor is still valid before proceeding, to
+            // avoid checking `IsFdWritable()` and possibly invoking
+            // `otPlatTcpHandleTxReady()` on an aborted connection.
+            // Note that `otPlatTcpIterateConnections()` guarantees
+            // that `aConn` remains valid during iteration (it is not
+            // removed from the list).
+
+            VerifyOrExit(GetFd(aConn) >= 0);
+        }
+        else if (ret == 0)
+        {
+            close(fd);
+            SetFd(aConn, -1);
+
+            otPlatTcpHandleDisconnected(aConn, OT_PLAT_TCP_DISCONNECT_REASON_CLOSED);
+            ExitNow();
+        }
+        else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)
+        {
+            otPlatTcpDisconnectReason reason = OT_PLAT_TCP_DISCONNECT_REASON_ERROR;
+
+            if (errno == ECONNRESET)
+            {
+                reason = OT_PLAT_TCP_DISCONNECT_REASON_RESET;
+            }
+            else if (errno == ETIMEDOUT)
+            {
+                reason = OT_PLAT_TCP_DISCONNECT_REASON_TIMEOUT;
+            }
+
+            close(fd);
+            SetFd(aConn, -1);
+
+            otPlatTcpHandleDisconnected(aConn, reason);
+            ExitNow();
+        }
+    }
+
+    if (Mainloop::IsFdWritable(fd, aContext))
+    {
+        otPlatTcpHandleTxReady(aConn);
+    }
+
+exit:
+    return;
+}
+
+} // namespace Posix
+} // namespace ot
+
+#endif // #if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
diff --git a/src/posix/platform/tcp.hpp b/src/posix/platform/tcp.hpp
new file mode 100644
index 0000000..d83b7b4
--- /dev/null
+++ b/src/posix/platform/tcp.hpp
@@ -0,0 +1,69 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+#ifndef OT_POSIX_PLATFORM_TCP_HPP_
+#define OT_POSIX_PLATFORM_TCP_HPP_
+
+#include "openthread-posix-config.h"
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+#include <openthread/platform/tcp.h>
+
+#include "logger.hpp"
+#include "mainloop.hpp"
+
+#include "core/common/non_copyable.hpp"
+
+namespace ot {
+namespace Posix {
+
+class Tcp : public Mainloop::Source, public Logger<Tcp>, private NonCopyable
+{
+public:
+    static const char kLogModuleName[];
+
+    static Tcp &Get(void);
+
+    void Init(void);
+    void SetUp(void);
+    void TearDown(void);
+    void Deinit(void);
+    void Update(Mainloop::Context &aContext) override;
+    void Process(const Mainloop::Context &aContext) override;
+
+private:
+    void ProcessListener(otPlatTcpListener *aListener, const Mainloop::Context &aContext);
+    void ProcessConnection(otPlatTcpConnection *aConn, const Mainloop::Context &aContext);
+};
+
+} // namespace Posix
+} // namespace ot
+
+#endif // OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+#endif // OT_POSIX_PLATFORM_TCP_HPP_
diff --git a/src/posix/platform/trel.cpp b/src/posix/platform/trel.cpp
index 57b0da7..f2d2d8e 100644
--- a/src/posix/platform/trel.cpp
+++ b/src/posix/platform/trel.cpp
@@ -658,6 +658,15 @@
         ot::Posix::RadioUrl url(aTrelUrl);
 
         otSysTrelInit(url.GetPath());
+        {
+            const char *unusedParam = nullptr;
+
+            if (url.Validate(&unusedParam) != OT_ERROR_NONE)
+            {
+                otLogCritPlat("TREL radio URL contains unused parameter: \"%s\"", unusedParam);
+                DieNow(OT_EXIT_INVALID_ARGUMENTS);
+            }
+        }
     }
 }
 
diff --git a/src/posix/platform/udp.cpp b/src/posix/platform/udp.cpp
index c6901c7..deb08ed 100644
--- a/src/posix/platform/udp.cpp
+++ b/src/posix/platform/udp.cpp
@@ -293,7 +293,10 @@
 exit:
     if (error == OT_ERROR_FAILED)
     {
-        ot::Posix::Udp::LogCrit("Failed to bind UDP socket: %s", strerror(errno));
+        int err = errno;
+        ot::Posix::Udp::LogWarn("Failed to bind UDP socket to [%s]:%u: %s",
+                                Ip6AddressString(&aUdpSocket->mSockName.mAddress).AsCString(),
+                                aUdpSocket->mSockName.mPort, strerror(err));
     }
 
     return error;
@@ -420,12 +423,13 @@
 
     if (connect(fd, reinterpret_cast<struct sockaddr *>(&sin6), sizeof(sin6)) != 0)
     {
+        int err = errno;
 #ifdef __APPLE__
-        VerifyOrExit(errno == EAFNOSUPPORT && isDisconnect);
+        VerifyOrExit(err == EAFNOSUPPORT && isDisconnect);
 #endif
         ot::Posix::Udp::LogWarn("Failed to connect to [%s]:%u: %s",
                                 Ip6AddressString(&aUdpSocket->mPeerName.mAddress).AsCString(),
-                                aUdpSocket->mPeerName.mPort, strerror(errno));
+                                aUdpSocket->mPeerName.mPort, strerror(err));
         error = OT_ERROR_FAILED;
     }
 
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 485d7fb..3679756 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -28,11 +28,13 @@
 
 option(OT_BUILD_GTEST "enable gtest")
 
-if(OT_FTD AND BUILD_TESTING AND (NOT OT_PLATFORM STREQUAL "nexus"))
+if(OT_FTD AND BUILD_TESTING)
     target_compile_definitions(ot-config INTERFACE "OPENTHREAD_CONFIG_USE_STD_NEW=1")
-    add_subdirectory(unit)
-    if(OT_BUILD_GTEST)
-        add_subdirectory(gtest)
+    if(NOT OT_PLATFORM STREQUAL "nexus")
+        add_subdirectory(unit)
+        if(OT_BUILD_GTEST)
+            add_subdirectory(gtest)
+        endif()
     endif()
 endif()
 
diff --git a/tests/fuzz/CMakeLists.txt b/tests/fuzz/CMakeLists.txt
index 645f671..63a1f66 100644
--- a/tests/fuzz/CMakeLists.txt
+++ b/tests/fuzz/CMakeLists.txt
@@ -39,6 +39,7 @@
     -DOPENTHREAD_FTD=1
     -DOPENTHREAD_MTD=0
     -DOPENTHREAD_RADIO=0
+    -DOPENTHREAD_CONFIG_USE_STD_NEW=1
 )
 
 set(COMMON_LIBS
@@ -48,6 +49,7 @@
     ${OT_MBEDTLS}
     $ENV{LIB_FUZZING_ENGINE}
     ot-config
+    openthread-cli-ftd
 )
 
 target_compile_definitions(ot-config INTERFACE "OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE=1")
diff --git a/tests/fuzz/fuzz_cli.cpp b/tests/fuzz/fuzz_cli.cpp
index 42c37c3..dcde321 100644
--- a/tests/fuzz/fuzz_cli.cpp
+++ b/tests/fuzz/fuzz_cli.cpp
@@ -107,7 +107,7 @@
 
     Node &node = nexus.CreateNode();
 
-    node.GetInstance().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(node.GetInstance().SetLogLevel(kLogLevelInfo));
 
     otCliInit(&node.GetInstance(), CliOutput, nullptr);
 
diff --git a/tests/fuzz/fuzz_icmp6.cpp b/tests/fuzz/fuzz_icmp6.cpp
index ec5d8cf..99ed219 100644
--- a/tests/fuzz/fuzz_icmp6.cpp
+++ b/tests/fuzz/fuzz_icmp6.cpp
@@ -98,7 +98,7 @@
 
     Node &node = nexus.CreateNode();
 
-    node.GetInstance().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(node.GetInstance().SetLogLevel(kLogLevelInfo));
 
     node.GetInstance().Get<BorderRouter::InfraIf>().Init(/* aInfraIfIndex */ 1, /* aInfraIfIsRunning */ true);
     SuccessOrQuit(node.GetInstance().Get<BorderRouter::RoutingManager>().SetEnabled(true));
diff --git a/tests/fuzz/fuzz_ip6.cpp b/tests/fuzz/fuzz_ip6.cpp
index 46c48e0..5354a9c 100644
--- a/tests/fuzz/fuzz_ip6.cpp
+++ b/tests/fuzz/fuzz_ip6.cpp
@@ -110,7 +110,7 @@
 
     Node &node = nexus.CreateNode();
 
-    node.GetInstance().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(node.GetInstance().SetLogLevel(kLogLevelInfo));
 
     node.GetInstance().Get<BorderRouter::InfraIf>().Init(/* aInfraIfIndex */ 1, /* aInfraIfIsRunning */ true);
     SuccessOrQuit(node.GetInstance().Get<BorderRouter::RoutingManager>().SetEnabled(true));
diff --git a/tests/fuzz/fuzz_mdns.cpp b/tests/fuzz/fuzz_mdns.cpp
index b4b13bb..a54a177 100644
--- a/tests/fuzz/fuzz_mdns.cpp
+++ b/tests/fuzz/fuzz_mdns.cpp
@@ -109,7 +109,7 @@
 
     Node &node = nexus.CreateNode();
 
-    node.GetInstance().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(node.GetInstance().SetLogLevel(kLogLevelInfo));
 
     node.GetInstance().Get<BorderRouter::InfraIf>().Init(/* aInfraIfIndex */ 1, /* aInfraIfIsRunning */ true);
     SuccessOrQuit(node.GetInstance().Get<BorderRouter::RoutingManager>().SetEnabled(true));
diff --git a/tests/fuzz/fuzz_radio-one-node.cpp b/tests/fuzz/fuzz_radio-one-node.cpp
index 1c79948..9b50bf9 100644
--- a/tests/fuzz/fuzz_radio-one-node.cpp
+++ b/tests/fuzz/fuzz_radio-one-node.cpp
@@ -108,7 +108,7 @@
 
     Node &node = nexus.CreateNode();
 
-    node.GetInstance().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(node.GetInstance().SetLogLevel(kLogLevelInfo));
 
     node.GetInstance().Get<BorderRouter::InfraIf>().Init(/* aInfraIfIndex */ 1, /* aInfraIfIsRunning */ true);
     SuccessOrQuit(node.GetInstance().Get<BorderRouter::RoutingManager>().SetEnabled(true));
diff --git a/tests/fuzz/fuzz_trel.cpp b/tests/fuzz/fuzz_trel.cpp
index 8bdb63d..317d425 100644
--- a/tests/fuzz/fuzz_trel.cpp
+++ b/tests/fuzz/fuzz_trel.cpp
@@ -99,7 +99,7 @@
 
     Node &node = nexus.CreateNode();
 
-    node.GetInstance().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(node.GetInstance().SetLogLevel(kLogLevelInfo));
 
     node.GetInstance().Get<BorderRouter::InfraIf>().Init(/* aInfraIfIndex */ 1, /* aInfraIfIsRunning */ true);
     SuccessOrQuit(node.GetInstance().Get<BorderRouter::RoutingManager>().SetEnabled(true));
diff --git a/tests/fuzz/oss-fuzz-build b/tests/fuzz/oss-fuzz-build
index 2fdc8c5..bb91472 100755
--- a/tests/fuzz/oss-fuzz-build
+++ b/tests/fuzz/oss-fuzz-build
@@ -40,7 +40,6 @@
         -DCMAKE_CXX_FLAGS="${CXXFLAGS}" \
         -DOT_COMPILE_WARNING_AS_ERROR=ON \
         -DOT_FUZZ_TARGETS=ON \
-        -DOT_MULTIPLE_INSTANCE=ON \
         -DOT_PLATFORM=nexus \
         -DOT_THREAD_VERSION=1.4 \
         -DOT_APP_CLI=OFF \
diff --git a/tests/gtest/dataset_test.cpp b/tests/gtest/dataset_test.cpp
index bd78650..c2c4010 100644
--- a/tests/gtest/dataset_test.cpp
+++ b/tests/gtest/dataset_test.cpp
@@ -77,3 +77,88 @@
 
     fakePlatform.GoInMs(10000);
 }
+
+TEST(otDatasetIsValid, shouldReturnTrueForValidActiveDataset)
+{
+    FakePlatform             fakePlatform;
+    otOperationalDataset     dataset;
+    otOperationalDatasetTlvs datasetTlvs;
+
+    EXPECT_EQ(otDatasetCreateNewNetwork(FakePlatform::CurrentInstance(), &dataset), OT_ERROR_NONE);
+    otDatasetConvertToTlvs(&dataset, &datasetTlvs);
+
+    EXPECT_TRUE(otDatasetIsValid(&datasetTlvs, true));
+}
+
+TEST(otDatasetIsValid, shouldReturnFalseForIncompleteActiveDataset)
+{
+    FakePlatform             fakePlatform;
+    otOperationalDataset     dataset;
+    otOperationalDatasetTlvs datasetTlvs;
+
+    EXPECT_EQ(otDatasetCreateNewNetwork(FakePlatform::CurrentInstance(), &dataset), OT_ERROR_NONE);
+
+    // Remove a required field, e.g., Network Key
+    dataset.mComponents.mIsNetworkKeyPresent = false;
+
+    otDatasetConvertToTlvs(&dataset, &datasetTlvs);
+
+    EXPECT_FALSE(otDatasetIsValid(&datasetTlvs, true));
+}
+
+TEST(otDatasetIsValid, shouldReturnFalseForDuplicatedTlvs)
+{
+    FakePlatform             fakePlatform;
+    otOperationalDataset     dataset;
+    otOperationalDatasetTlvs datasetTlvs;
+
+    EXPECT_EQ(otDatasetCreateNewNetwork(FakePlatform::CurrentInstance(), &dataset), OT_ERROR_NONE);
+    otDatasetConvertToTlvs(&dataset, &datasetTlvs);
+
+    // Duplicate a TLV (e.g. Network Name TLV)
+    // TLV format: Type (1 byte), Length (1 byte), Value (n bytes)
+    datasetTlvs.mTlvs[datasetTlvs.mLength]     = OT_MESHCOP_TLV_NETWORKNAME; // Type: Network Name
+    datasetTlvs.mTlvs[datasetTlvs.mLength + 1] = 1;                          // Length: 1
+    datasetTlvs.mTlvs[datasetTlvs.mLength + 2] = 'A';
+    datasetTlvs.mLength += 3;
+
+    EXPECT_FALSE(otDatasetIsValid(&datasetTlvs, true));
+}
+
+TEST(otDatasetIsValid, shouldReturnTrueForValidPendingDataset)
+{
+    FakePlatform             fakePlatform;
+    otOperationalDataset     dataset;
+    otOperationalDatasetTlvs datasetTlvs;
+
+    EXPECT_EQ(otDatasetCreateNewNetwork(FakePlatform::CurrentInstance(), &dataset), OT_ERROR_NONE);
+
+    // Pending Dataset MUST contain Pending Timestamp and Delay Timer.
+    dataset.mPendingTimestamp.mSeconds             = 100;
+    dataset.mPendingTimestamp.mTicks               = 0;
+    dataset.mComponents.mIsPendingTimestampPresent = true;
+    dataset.mDelay                                 = 30000;
+    dataset.mComponents.mIsDelayPresent            = true;
+
+    otDatasetConvertToTlvs(&dataset, &datasetTlvs);
+
+    EXPECT_TRUE(otDatasetIsValid(&datasetTlvs, false));
+}
+
+TEST(otDatasetIsValid, shouldReturnFalseForIncompletePendingDataset)
+{
+    FakePlatform             fakePlatform;
+    otOperationalDataset     dataset;
+    otOperationalDatasetTlvs datasetTlvs;
+
+    EXPECT_EQ(otDatasetCreateNewNetwork(FakePlatform::CurrentInstance(), &dataset), OT_ERROR_NONE);
+
+    // Provide Pending Timestamp but omit Delay Timer.
+    dataset.mPendingTimestamp.mSeconds             = 100;
+    dataset.mPendingTimestamp.mTicks               = 0;
+    dataset.mComponents.mIsPendingTimestampPresent = true;
+
+    otDatasetConvertToTlvs(&dataset, &datasetTlvs);
+
+    EXPECT_FALSE(otDatasetIsValid(&datasetTlvs, false));
+}
diff --git a/tests/gtest/fake_platform.cpp b/tests/gtest/fake_platform.cpp
index 29a285e..4ef6026 100644
--- a/tests/gtest/fake_platform.cpp
+++ b/tests/gtest/fake_platform.cpp
@@ -385,7 +385,7 @@
 
 otError otPlatRadioReceive(otInstance *, uint8_t aChannel) { return FakePlatform::CurrentPlatform().Receive(aChannel); }
 
-otError otPlatRadioReceiveAt(otInstance *, uint8_t aChannel, uint32_t aStart, uint32_t aDuration)
+otError otPlatRadioReceiveAt(otInstance *, uint8_t aChannel, otRadioTime32 aStart, uint32_t aDuration)
 {
     return FakePlatform::CurrentPlatform().ReceiveAt(aChannel, aStart, aDuration);
 }
@@ -496,6 +496,8 @@
 
 void otPlatDiagAlarmCallback(otInstance *) {}
 
+OT_TOOL_WEAK void otPlatLogOutput(otInstance *, otLogLevel, const char *) {}
+
 OT_TOOL_WEAK void otPlatLog(otLogLevel, otLogRegion, const char *, ...) {}
 
 void *otPlatCAlloc(size_t aNum, size_t aSize) { return calloc(aNum, aSize); }
diff --git a/tests/gtest/radio_spinel_rcp_test.cpp b/tests/gtest/radio_spinel_rcp_test.cpp
index 272be46..9d3c53c 100644
--- a/tests/gtest/radio_spinel_rcp_test.cpp
+++ b/tests/gtest/radio_spinel_rcp_test.cpp
@@ -66,17 +66,17 @@
     txFrame.mPsdu = frameBuffer;
 
     {
-        Mac::TxFrame::Info frameInfo;
+        Mac::TxFrame::BuildInfo buildInfo;
 
-        frameInfo.mType    = Mac::Frame::kTypeData;
-        frameInfo.mVersion = Mac::Frame::kVersion2006;
-        frameInfo.mAddrs.mSource.SetShort(kSrcAddr);
-        frameInfo.mAddrs.mDestination.SetExtended(kDstAddr);
-        frameInfo.mPanIds.SetSource(kSrcPanId);
-        frameInfo.mPanIds.SetDestination(kDstPanId);
-        frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+        buildInfo.mType    = Mac::Frame::kTypeData;
+        buildInfo.mVersion = Mac::Frame::kVersion2006;
+        buildInfo.mAddrs.mSource.SetShort(kSrcAddr);
+        buildInfo.mAddrs.mDestination.SetExtended(kDstAddr);
+        buildInfo.mPanIds.SetSource(kSrcPanId);
+        buildInfo.mPanIds.SetDestination(kDstPanId);
+        buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
 
-        frameInfo.PrepareHeadersIn(txFrame);
+        buildInfo.PrepareHeadersIn(txFrame);
     }
 
     txFrame.mInfo.mTxInfo.mTxPower = kTxPower;
@@ -110,17 +110,17 @@
     txFrame.mPsdu = frameBuffer;
 
     {
-        Mac::TxFrame::Info frameInfo;
+        Mac::TxFrame::BuildInfo buildInfo;
 
-        frameInfo.mType    = Mac::Frame::kTypeData;
-        frameInfo.mVersion = Mac::Frame::kVersion2006;
-        frameInfo.mAddrs.mSource.SetShort(kSrcAddr);
-        frameInfo.mAddrs.mDestination.SetExtended(kDstAddr);
-        frameInfo.mPanIds.SetSource(kSrcPanId);
-        frameInfo.mPanIds.SetDestination(kDstPanId);
-        frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+        buildInfo.mType    = Mac::Frame::kTypeData;
+        buildInfo.mVersion = Mac::Frame::kVersion2006;
+        buildInfo.mAddrs.mSource.SetShort(kSrcAddr);
+        buildInfo.mAddrs.mDestination.SetExtended(kDstAddr);
+        buildInfo.mPanIds.SetSource(kSrcPanId);
+        buildInfo.mPanIds.SetDestination(kDstPanId);
+        buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
 
-        frameInfo.PrepareHeadersIn(txFrame);
+        buildInfo.PrepareHeadersIn(txFrame);
     }
 
     txFrame.mInfo.mTxInfo.mTxPower              = kTxPower;
@@ -156,17 +156,17 @@
     txFrame.mPsdu = frameBuffer;
 
     {
-        Mac::TxFrame::Info frameInfo;
+        Mac::TxFrame::BuildInfo buildInfo;
 
-        frameInfo.mType    = Mac::Frame::kTypeData;
-        frameInfo.mVersion = Mac::Frame::kVersion2006;
-        frameInfo.mAddrs.mSource.SetShort(kSrcAddr);
-        frameInfo.mAddrs.mDestination.SetExtended(kDstAddr);
-        frameInfo.mPanIds.SetSource(kSrcPanId);
-        frameInfo.mPanIds.SetDestination(kDstPanId);
-        frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+        buildInfo.mType    = Mac::Frame::kTypeData;
+        buildInfo.mVersion = Mac::Frame::kVersion2006;
+        buildInfo.mAddrs.mSource.SetShort(kSrcAddr);
+        buildInfo.mAddrs.mDestination.SetExtended(kDstAddr);
+        buildInfo.mPanIds.SetSource(kSrcPanId);
+        buildInfo.mPanIds.SetDestination(kDstPanId);
+        buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
 
-        frameInfo.PrepareHeadersIn(txFrame);
+        buildInfo.PrepareHeadersIn(txFrame);
     }
 
     txFrame.mInfo.mTxInfo.mCsmaCaEnabled = false;
@@ -212,17 +212,17 @@
     txFrame.mPsdu = frameBuffer;
 
     {
-        Mac::TxFrame::Info frameInfo;
+        Mac::TxFrame::BuildInfo buildInfo;
 
-        frameInfo.mType    = Mac::Frame::kTypeData;
-        frameInfo.mVersion = Mac::Frame::kVersion2006;
-        frameInfo.mAddrs.mSource.SetShort(kSrcAddr);
-        frameInfo.mAddrs.mDestination.SetExtended(kDstAddr);
-        frameInfo.mPanIds.SetSource(kSrcPanId);
-        frameInfo.mPanIds.SetDestination(kDstPanId);
-        frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+        buildInfo.mType    = Mac::Frame::kTypeData;
+        buildInfo.mVersion = Mac::Frame::kVersion2006;
+        buildInfo.mAddrs.mSource.SetShort(kSrcAddr);
+        buildInfo.mAddrs.mDestination.SetExtended(kDstAddr);
+        buildInfo.mPanIds.SetSource(kSrcPanId);
+        buildInfo.mPanIds.SetDestination(kDstPanId);
+        buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
 
-        frameInfo.PrepareHeadersIn(txFrame);
+        buildInfo.PrepareHeadersIn(txFrame);
     }
 
     txFrame.mInfo.mTxInfo.mCsmaCaEnabled   = true;
@@ -262,17 +262,17 @@
     txFrame.mPsdu = frameBuffer;
 
     {
-        Mac::TxFrame::Info frameInfo;
+        Mac::TxFrame::BuildInfo buildInfo;
 
-        frameInfo.mType    = Mac::Frame::kTypeData;
-        frameInfo.mVersion = Mac::Frame::kVersion2006;
-        frameInfo.mAddrs.mSource.SetShort(kSrcAddr);
-        frameInfo.mAddrs.mDestination.SetExtended(kDstAddr);
-        frameInfo.mPanIds.SetSource(kSrcPanId);
-        frameInfo.mPanIds.SetDestination(kDstPanId);
-        frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+        buildInfo.mType    = Mac::Frame::kTypeData;
+        buildInfo.mVersion = Mac::Frame::kVersion2006;
+        buildInfo.mAddrs.mSource.SetShort(kSrcAddr);
+        buildInfo.mAddrs.mDestination.SetExtended(kDstAddr);
+        buildInfo.mPanIds.SetSource(kSrcPanId);
+        buildInfo.mPanIds.SetDestination(kDstPanId);
+        buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
 
-        frameInfo.PrepareHeadersIn(txFrame);
+        buildInfo.PrepareHeadersIn(txFrame);
     }
 
     txFrame.mInfo.mTxInfo.mTxPower              = kTxPower;
@@ -338,17 +338,17 @@
     txFrame.mPsdu = frameBuffer;
 
     {
-        Mac::TxFrame::Info frameInfo;
+        Mac::TxFrame::BuildInfo buildInfo;
 
-        frameInfo.mType    = Mac::Frame::kTypeData;
-        frameInfo.mVersion = Mac::Frame::kVersion2006;
-        frameInfo.mAddrs.mSource.SetShort(kSrcAddr);
-        frameInfo.mAddrs.mDestination.SetExtended(kDstAddr);
-        frameInfo.mPanIds.SetSource(kSrcPanId);
-        frameInfo.mPanIds.SetDestination(kDstPanId);
-        frameInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
+        buildInfo.mType    = Mac::Frame::kTypeData;
+        buildInfo.mVersion = Mac::Frame::kVersion2006;
+        buildInfo.mAddrs.mSource.SetShort(kSrcAddr);
+        buildInfo.mAddrs.mDestination.SetExtended(kDstAddr);
+        buildInfo.mPanIds.SetSource(kSrcPanId);
+        buildInfo.mPanIds.SetDestination(kDstPanId);
+        buildInfo.mSecurityLevel = Mac::Frame::kSecurityEncMic32;
 
-        frameInfo.PrepareHeadersIn(txFrame);
+        buildInfo.PrepareHeadersIn(txFrame);
     }
 
     txFrame.mInfo.mTxInfo.mCsmaCaEnabled   = true;
diff --git a/tests/nexus/CMakeLists.txt b/tests/nexus/CMakeLists.txt
index 3815ab3..174c793 100644
--- a/tests/nexus/CMakeLists.txt
+++ b/tests/nexus/CMakeLists.txt
@@ -26,6 +26,10 @@
 #  POSSIBILITY OF SUCH DAMAGE.
 #
 
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_CXX_EXTENSIONS OFF)
+
 set(COMMON_INCLUDES
     ${PROJECT_SOURCE_DIR}/include
     ${PROJECT_SOURCE_DIR}/src
@@ -33,28 +37,93 @@
     ${PROJECT_SOURCE_DIR}/tests/nexus
     ${PROJECT_SOURCE_DIR}/tests/nexus/platform
     ${PROJECT_SOURCE_DIR}/examples/platforms/utils
+    ${CMAKE_CURRENT_BINARY_DIR}
 )
 
 set(COMMON_COMPILE_OPTIONS
     -DOPENTHREAD_FTD=1
     -DOPENTHREAD_MTD=0
     -DOPENTHREAD_RADIO=0
+    -DOPENTHREAD_CONFIG_USE_STD_NEW=1
 )
 
-add_library(ot-nexus-platform
+option(OT_NEXUS_BUILD_TESTS "Build Nexus test executables" ON)
+option(OT_NEXUS_GRPC "Enable Nexus gRPC" OFF)
+
+message(STATUS "OT_NEXUS_BUILD_TESTS=\"${OT_NEXUS_BUILD_TESTS}\"")
+message(STATUS "OT_NEXUS_GRPC=\"${OT_NEXUS_GRPC}\"")
+
+if(OT_NEXUS_GRPC AND NOT EMSCRIPTEN)
+    find_package(gRPC CONFIG REQUIRED)
+    find_package(Protobuf REQUIRED)
+
+    # Find gRPC C++ plugin
+    find_program(GRPC_CPP_PLUGIN grpc_cpp_plugin)
+    if(NOT GRPC_CPP_PLUGIN)
+        message(FATAL_ERROR "grpc_cpp_plugin not found! Please install protobuf-compiler-grpc")
+    endif()
+
+    # Generate simulation.pb.cc/h and simulation.grpc.pb.cc/h
+    set(PROTO_FILE platform/simulation.proto)
+    get_filename_component(PROTO_ABS_FILE ${PROTO_FILE} ABSOLUTE)
+    get_filename_component(PROTO_PATH ${PROTO_ABS_FILE} PATH)
+
+    set(SIMULATION_PROTO_SRCS
+        ${CMAKE_CURRENT_BINARY_DIR}/simulation.pb.cc
+        ${CMAKE_CURRENT_BINARY_DIR}/simulation.grpc.pb.cc
+    )
+    set(SIMULATION_PROTO_HDRS
+        ${CMAKE_CURRENT_BINARY_DIR}/simulation.pb.h
+        ${CMAKE_CURRENT_BINARY_DIR}/simulation.grpc.pb.h
+    )
+
+    add_custom_command(
+        OUTPUT ${SIMULATION_PROTO_SRCS} ${SIMULATION_PROTO_HDRS}
+        COMMAND ${Protobuf_PROTOC_EXECUTABLE}
+        ARGS --cpp_out ${CMAKE_CURRENT_BINARY_DIR}
+             --grpc_out ${CMAKE_CURRENT_BINARY_DIR}
+             --plugin=protoc-gen-grpc=${GRPC_CPP_PLUGIN}
+             -I ${PROTO_PATH}
+             ${PROTO_ABS_FILE}
+        DEPENDS ${PROTO_ABS_FILE}
+        COMMENT "Generating simulation.pb.cc/h and simulation.grpc.pb.cc/h from ${PROTO_FILE}"
+        VERBATIM
+    )
+endif()
+
+set(NEXUS_PLATFORM_SOURCES
     platform/nexus_alarm.cpp
     platform/nexus_core.cpp
+    platform/nexus_dns.cpp
     platform/nexus_infra_if.cpp
+    platform/nexus_logging.cpp
     platform/nexus_mdns.cpp
     platform/nexus_misc.cpp
     platform/nexus_node.cpp
     platform/nexus_pcap.cpp
     platform/nexus_radio.cpp
+    platform/nexus_radio_model.cpp
     platform/nexus_settings.cpp
+    platform/nexus_sim.cpp
     platform/nexus_trel.cpp
+    platform/nexus_udp.cpp
     ../../examples/platforms/utils/mac_frame.cpp
 )
 
+if(OT_NEXUS_GRPC)
+    list(APPEND NEXUS_PLATFORM_SOURCES ${SIMULATION_PROTO_SRCS} platform/nexus_grpc.cpp)
+endif()
+
+add_library(ot-nexus-platform
+    ${NEXUS_PLATFORM_SOURCES}
+)
+
+if(OT_NEXUS_GRPC)
+    set_source_files_properties(${SIMULATION_PROTO_SRCS} PROPERTIES
+        COMPILE_OPTIONS "-Wno-pedantic;-Wno-error"
+    )
+endif()
+
 target_include_directories(ot-nexus-platform
     PRIVATE
         ${COMMON_INCLUDES}
@@ -66,19 +135,40 @@
         ${OT_CFLAGS}
 )
 
+if(EMSCRIPTEN)
+    target_link_options(ot-nexus-platform PRIVATE "--bind")
+endif()
+
+if(OT_NEXUS_GRPC)
+    target_compile_definitions(ot-nexus-platform
+        PRIVATE
+            OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE=1
+    )
+endif()
+
 target_link_libraries(ot-nexus-platform
     PRIVATE
         ot-config
         ${OT_MBEDTLS}
 )
 
+if(OT_NEXUS_GRPC)
+    target_link_libraries(ot-nexus-platform
+        PUBLIC
+            protobuf::libprotobuf
+            gRPC::grpc++
+    )
+endif()
+
 set(COMMON_LIBS
+    openthread-cli-ftd
     ot-nexus-platform
     openthread-ftd
     ot-nexus-platform
     ${OT_MBEDTLS}
     ot-config
     openthread-ftd
+    openthread-cli-ftd
 )
 
 #----------------------------------------------------------------------------------------------------------------------
@@ -91,33 +181,39 @@
     # file `test_{name}.cpp`. The `labels` argument assigns categories to the
     # test, allowing us to run specific subsets using `ctest -L {label}`.
 
-    add_executable(nexus_${name}
-        test_${name}.cpp ${ARGN}
-    )
+    if(OT_NEXUS_BUILD_TESTS)
 
-    target_include_directories(nexus_${name}
-    PRIVATE
-        ${COMMON_INCLUDES}
-    )
+        add_executable(nexus_${name}
+            test_${name}.cpp ${ARGN}
+        )
 
-    target_link_libraries(nexus_${name}
-    PRIVATE
-        ${COMMON_LIBS}
-    )
+        target_include_directories(nexus_${name}
+        PRIVATE
+            ${COMMON_INCLUDES}
+        )
 
-    target_compile_options(nexus_${name}
-    PRIVATE
-        ${COMMON_COMPILE_OPTIONS}
-        ${OT_CFLAGS}
-    )
+        target_link_libraries(nexus_${name}
+        PRIVATE
+            ${COMMON_LIBS}
+        )
 
-    add_test(NAME nexus_${name} COMMAND nexus_${name})
+        target_compile_options(nexus_${name}
+        PRIVATE
+            ${COMMON_COMPILE_OPTIONS}
+            ${OT_CFLAGS}
+        )
 
-    set_tests_properties(nexus_${name} PROPERTIES LABELS "${labels}")
+        add_test(NAME nexus_${name} COMMAND nexus_${name})
+
+        set_tests_properties(nexus_${name} PROPERTIES LABELS "${labels}")
+
+    endif()
+
 endmacro()
 
 
 #----------------------------------------------------------------------------------------------------------------------
+
 # Cert tests
 ot_nexus_test(1_1_5_1_1 "cert;nexus")
 ot_nexus_test(1_1_5_1_2 "cert;nexus")
@@ -257,20 +353,158 @@
 ot_nexus_test(1_3_DBR_TC_1 "cert;nexus")
 ot_nexus_test(1_3_DBR_TC_2 "cert;nexus")
 ot_nexus_test(1_3_DBR_TC_3 "cert;nexus")
+ot_nexus_test(1_3_DBR_TC_6 "cert;nexus")
+ot_nexus_test(1_3_DBR_TC_7A "cert;nexus")
+ot_nexus_test(1_3_DBR_TC_7B "cert;nexus")
+ot_nexus_test(1_3_DBR_TC_7C "cert;nexus")
+ot_nexus_test(1_3_DBR_TC_8 "cert;nexus")
+ot_nexus_test(1_3_DBR_TC_10 "cert;nexus")
+ot_nexus_test(1_3_DPR_TC_1 "cert;nexus")
+ot_nexus_test(1_3_DPR_TC_2 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_1 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_2 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_3 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_4 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_5 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_6 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_8 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_11 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_12 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_13 "cert;nexus")
+ot_nexus_test(1_3_SRP_TC_15 "cert;nexus")
+ot_nexus_test(1_3_SRPC_TC_1 "cert;nexus")
+ot_nexus_test(1_3_SRPC_TC_4 "cert;nexus")
+ot_nexus_test(1_3_SRPC_TC_5 "cert;nexus")
+ot_nexus_test(1_3_SRPC_TC_7 "cert;nexus")
+ot_nexus_test(1_3_GEN_TC_1 "cert;nexus")
+ot_nexus_test(1_3_GEN_TC_2 "cert;nexus")
+ot_nexus_test(1_3_DIAG_TC_1 "cert;nexus")
+ot_nexus_test(1_3_DIAG_TC_2 "cert;nexus")
+ot_nexus_test(1_4_TREL_TC_1 "cert;nexus")
+ot_nexus_test(1_4_TREL_TC_2 "cert;nexus")
+ot_nexus_test(1_4_TREL_TC_3 "cert;nexus")
+ot_nexus_test(1_4_TREL_TC_4 "cert;nexus")
+ot_nexus_test(1_4_TREL_TC_5 "cert;nexus")
+ot_nexus_test(1_4_TREL_TC_6 "cert;nexus")
+ot_nexus_test(1_4_DNS_TC_1 "cert;nexus")
+ot_nexus_test(1_4_DNS_TC_3 "cert;nexus")
+ot_nexus_test(1_4_DNS_TC_5 "cert;nexus")
+ot_nexus_test(1_4_PIC_TC_1 "cert;nexus")
+ot_nexus_test(1_4_PIC_TC_3 "cert;nexus")
+ot_nexus_test(1_4_PIC_TC_4 "cert;nexus")
+ot_nexus_test(1_4_CS_TC_3 "cert;nexus")
 
 # Misc tests
+ot_nexus_test(announce_no_flap_on_unmergeable_partitions "core;nexus")
+ot_nexus_test(anycast "core;nexus")
+ot_nexus_test(anycast_locator "core;nexus")
+ot_nexus_test(br_upgrade_router_role "core;nexus")
 ot_nexus_test(border_admitter "core;nexus")
 ot_nexus_test(border_agent "core;nexus")
 ot_nexus_test(border_agent_tracker "core;nexus")
+ot_nexus_test(child_supervision "core;nexus")
+ot_nexus_test(coap_block "core;nexus")
+ot_nexus_test(coap_observe "core;nexus")
+ot_nexus_test(coaps "core;nexus")
+ot_nexus_test(compact_route_tlv "core;nexus")
+ot_nexus_test(dataset_updater "core;nexus")
 ot_nexus_test(discover_scan "core;nexus")
+ot_nexus_test(dnssd "core;nexus")
+ot_nexus_test(dns_client_config_auto_start "core;nexus")
+ot_nexus_test(dnssd_name_with_special_chars "core;nexus")
 ot_nexus_test(dtls "core;nexus")
+ot_nexus_test(fed_rx_only_link_establishment "core;nexus")
 ot_nexus_test(form_join "core;nexus")
+ot_nexus_test(history_tracker "core;nexus")
+ot_nexus_test(inform_previous_parent_on_reattach "core;nexus")
+ot_nexus_test(ipv6_fragmentation "core;nexus")
+ot_nexus_test(ipv6_recursion "core;nexus")
+ot_nexus_test(ipv6_source_selection "core;nexus")
+ot_nexus_test(key_rotation_guard_time "core;nexus")
+ot_nexus_test(leader_reboot_multiple_link_request "core;nexus")
+ot_nexus_test(log_override "core;nexus")
+ot_nexus_test(mac_scan "core;nexus")
+ot_nexus_test(mesh_diag "core;nexus")
+ot_nexus_test(mle_router_role_allowed "core;nexus")
+ot_nexus_test(mle_blocking_downgrade "core;nexus")
+ot_nexus_test(mle_msg_key_seq_jump "core;nexus")
+ot_nexus_test(mlr_manager "core;nexus")
 ot_nexus_test(nat64_translator "core;nexus")
+ot_nexus_test(netdata_publisher "core;nexus")
+ot_nexus_test(on_mesh_prefix "core;nexus")
+ot_nexus_test(pbbr_aloc "core;nexus")
+ot_nexus_test(ping_lla_src "core;nexus")
+ot_nexus_test(radio_filter "core;nexus")
+ot_nexus_test(reed_address_solicit_rejected "core;nexus")
+ot_nexus_test(reset "core;nexus")
+ot_nexus_test(retransmission_security "core;nexus")
+ot_nexus_test(router_downgrade_on_sec_policy_change "core;nexus")
+ot_nexus_test(router_multicast_link_request "core;nexus")
+ot_nexus_test(router_reattach "core;nexus")
+ot_nexus_test(router_reboot_multiple_link_request "core;nexus")
+ot_nexus_test(service "core;nexus")
+ot_nexus_test(srp_auto_start "core;nexus")
+ot_nexus_test(srp_client_change_lease "core;nexus")
+ot_nexus_test(srp_client_remove_host "core;nexus")
+ot_nexus_test(srp_client_save_server_info "core;nexus")
 ot_nexus_test(srp_lease "core;nexus")
+ot_nexus_test(srp_many_services_mtu_check "core;nexus")
+ot_nexus_test(srp_register_services_diff_lease "core;nexus")
+ot_nexus_test(srp_scale "core;nexus")
+ot_nexus_test(srp_server_anycast_mode "core;nexus")
+ot_nexus_test(srp_server_reboot_port "core;nexus")
+ot_nexus_test(srp_ttl "core;nexus")
+ot_nexus_test(tmf_origin "core;nexus")
+ot_nexus_test(zero_len_external_route "core;nexus")
+
+# CLI tests
+ot_nexus_test(cli_basic "core;cli;nexus")
 
 # Trel
 ot_nexus_test(trel "trel;nexus")
 
+# Long-routes
+ot_nexus_test(long_routes "long_routes;nexus")
+
+# Grpc
+if(OT_NEXUS_GRPC)
+    ot_nexus_test(grpc "core;nexus")
+    target_compile_definitions(nexus_grpc PRIVATE OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE=1)
+endif()
+
 # Large network
 ot_nexus_test(full_network_reset "core;large_network;nexus")
 ot_nexus_test(large_network "core;large_network;nexus")
+
+# Live Demo Persistent Server
+if(EMSCRIPTEN)
+    set(NEXUS_WASM_LINK_OPTIONS
+        "--bind"
+        "-sEXPORT_ES6=1"
+        "-sMODULARIZE=1"
+        "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
+        "-sSTACK_SIZE=4194304"
+        "-sALLOW_MEMORY_GROWTH=1"
+    )
+
+    add_executable(nexus_live_demo
+        platform/nexus_wasm.cpp
+    )
+    target_link_options(nexus_live_demo PRIVATE ${NEXUS_WASM_LINK_OPTIONS})
+    set_target_properties(nexus_live_demo PROPERTIES CXX_STANDARD 17)
+elseif(OT_NEXUS_GRPC)
+    add_executable(nexus_live_demo
+        platform/nexus_native.cpp
+    )
+endif()
+
+if(TARGET nexus_live_demo)
+    target_include_directories(nexus_live_demo PRIVATE ${COMMON_INCLUDES})
+    target_link_libraries(nexus_live_demo PRIVATE ${COMMON_LIBS})
+    target_compile_options(nexus_live_demo PRIVATE ${COMMON_COMPILE_OPTIONS} ${OT_CFLAGS})
+    if(EMSCRIPTEN)
+        add_test(NAME nexus_wasm_bindings COMMAND node ${CMAKE_CURRENT_SOURCE_DIR}/test_wasm_bindings.mjs ${CMAKE_CURRENT_BINARY_DIR}/nexus_live_demo.js)
+    elseif(OT_NEXUS_GRPC)
+        target_compile_definitions(nexus_live_demo PRIVATE OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE=1)
+    endif()
+endif()
diff --git a/tests/nexus/build.sh b/tests/nexus/build.sh
index c0937ef..8d2f43c 100755
--- a/tests/nexus/build.sh
+++ b/tests/nexus/build.sh
@@ -27,6 +27,21 @@
 #  POSSIBILITY OF SUCH DAMAGE.
 #
 
+display_usage()
+{
+    echo ""
+    echo "Nexus Build script"
+    echo ""
+    echo "Usage: $(basename "$0") [config]"
+    echo "    <config> can be:"
+    echo "        (no arg)        : Build OpenThread Nexus platform with default config"
+    echo "        trel            : Build OpenThread Nexus platform with TREL radio (no 15.4)"
+    echo "        wasm            : Build OpenThread Nexus platform for WebAssembly"
+    echo "        long_routes     : Build OpenThread Nexus platform with LONG_ROUTES feature enabled"
+    echo "        no_tests        : Build OpenThread Nexus platform without test executables"
+    echo ""
+}
+
 die()
 {
     echo " *** ERROR: " "$*"
@@ -44,14 +59,45 @@
     top_builddir=.
 fi
 
+if [ "$#" -gt 1 ]; then
+    display_usage
+    exit 1
+fi
+
+long_routes=OFF
+build_tests=ON
+
 case $1 in
     trel)
-        trel=ON
         fifteenfour=OFF
+        wasm=OFF
+        ;;
+    wasm)
+        fifteenfour=ON
+        wasm=ON
+        ;;
+    long_routes)
+        fifteenfour=ON
+        wasm=OFF
+        long_routes=ON
+        ;;
+    no_tests)
+        fifteenfour=ON
+        wasm=OFF
+        build_tests=OFF
+        ;;
+    "")
+        fifteenfour=ON
+        wasm=OFF
+        ;;
+    help | --help | -h)
+        display_usage
+        exit 0
         ;;
     *)
-        trel=OFF
-        fifteenfour=ON
+        echo "Error: Unknown configuration \"$1\""
+        display_usage
+        exit 1
         ;;
 esac
 
@@ -59,12 +105,27 @@
 echo "Building OpenThread Nexus test platform"
 echo "===================================================================================================="
 cd "${top_builddir}" || die "cd failed"
-cmake -GNinja -DOT_PLATFORM=nexus -DOT_COMPILE_WARNING_AS_ERROR=ON \
-    -DOT_MULTIPLE_INSTANCE=ON \
-    -DOT_THREAD_VERSION=1.4 -DOT_APP_CLI=OFF -DOT_APP_NCP=OFF -DOT_APP_RCP=OFF \
-    -DOT_15_4=${fifteenfour} -DOT_TREL=${trel} \
-    -DOT_PROJECT_CONFIG="${top_srcdir}/tests/nexus/openthread-core-nexus-config.h" \
-    "${top_srcdir}" || die
+
+CMAKE_ARGS=(
+    -GNinja
+    -DOT_PLATFORM=nexus
+    -DOT_COMPILE_WARNING_AS_ERROR=ON
+    -DOT_THREAD_VERSION=1.4
+    -DOT_APP_CLI=OFF
+    -DOT_APP_NCP=OFF
+    -DOT_APP_RCP=OFF
+    -DOT_15_4="${fifteenfour}"
+    -DOT_MLE_LONG_ROUTES="${long_routes}"
+    -DOT_NEXUS_BUILD_TESTS="${build_tests}"
+    -DOT_PROJECT_CONFIG="${top_srcdir}/tests/nexus/openthread-core-nexus-config.h"
+)
+
+if [ "${wasm}" = "ON" ]; then
+    emcmake cmake "${CMAKE_ARGS[@]}" "${top_srcdir}" || die
+else
+    cmake "${CMAKE_ARGS[@]}" -DOT_NEXUS_GRPC="${OT_NEXUS_GRPC:-OFF}" "${top_srcdir}" || die
+fi
+
 ninja || die
 
 exit 0
diff --git a/tests/nexus/openthread-core-nexus-config.h b/tests/nexus/openthread-core-nexus-config.h
index 16ada00..0de1947 100644
--- a/tests/nexus/openthread-core-nexus-config.h
+++ b/tests/nexus/openthread-core-nexus-config.h
@@ -53,12 +53,16 @@
 #define OPENTHREAD_CONFIG_BORDER_AGENT_TXT_DATA_PARSER_ENABLE 1
 #define OPENTHREAD_CONFIG_BORDER_ROUTER_ENABLE 1
 #define OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_ENABLE 1
-#define OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_CLIENT_ENABLE 0
+#define OPENTHREAD_CONFIG_BORDER_ROUTING_DHCP6_PD_CLIENT_ENABLE 1
 #define OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE 1
+#define OPENTHREAD_CONFIG_BORDER_ROUTING_TESTING_API_ENABLE 1
 #define OPENTHREAD_CONFIG_BORDER_ROUTING_USE_HEAP_ENABLE 1
 #define OPENTHREAD_CONFIG_CHANNEL_MANAGER_ENABLE 1
 #define OPENTHREAD_CONFIG_CHANNEL_MONITOR_ENABLE 1
+#define OPENTHREAD_CONFIG_CLI_PROMPT_ENABLE 1
 #define OPENTHREAD_CONFIG_COAP_API_ENABLE 1
+#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1
+#define OPENTHREAD_CONFIG_COAP_OBSERVE_API_ENABLE 1
 #define OPENTHREAD_CONFIG_COAP_SECURE_API_ENABLE 1
 #define OPENTHREAD_CONFIG_COMMISSIONER_ENABLE 1
 #define OPENTHREAD_CONFIG_COMMISSIONER_MAX_JOINER_ENTRIES 4
@@ -68,8 +72,11 @@
 #define OPENTHREAD_CONFIG_DHCP6_SERVER_ENABLE 1
 #define OPENTHREAD_CONFIG_DIAG_ENABLE 0
 #define OPENTHREAD_CONFIG_DNS_CLIENT_ENABLE 1
+#define OPENTHREAD_CONFIG_DNS_CLIENT_BIND_UDP_TO_THREAD_NETIF 1
 #define OPENTHREAD_CONFIG_DNS_DSO_ENABLE 0
-#define OPENTHREAD_CONFIG_DNSSD_DISCOVERY_PROXY_ENABLE 0
+#define OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE 1
+#define OPENTHREAD_CONFIG_DNS_UPSTREAM_QUERY_ENABLE 1
+#define OPENTHREAD_CONFIG_DNSSD_DISCOVERY_PROXY_ENABLE 1
 #define OPENTHREAD_CONFIG_DNSSD_SERVER_ENABLE 1
 #define OPENTHREAD_CONFIG_ECDSA_ENABLE 1
 #define OPENTHREAD_CONFIG_HEAP_EXTERNAL_ENABLE 1
@@ -78,19 +85,21 @@
 #define OPENTHREAD_CONFIG_IP6_MAX_EXT_MCAST_ADDRS 80
 #define OPENTHREAD_CONFIG_MAX_MULTICAST_LISTENERS 80
 #define OPENTHREAD_CONFIG_IP6_MAX_EXT_UCAST_ADDRS 8
+#define OPENTHREAD_CONFIG_IP6_FRAGMENTATION_ENABLE 1
 #define OPENTHREAD_CONFIG_IP6_SLAAC_ENABLE 1
 #define OPENTHREAD_CONFIG_IP6_SLAAC_NUM_ADDRESSES 4
 #define OPENTHREAD_CONFIG_JAM_DETECTION_ENABLE 1
 #define OPENTHREAD_CONFIG_JOINER_ENABLE 1
 #define OPENTHREAD_CONFIG_LOG_LEVEL OT_LOG_LEVEL_INFO
-
 #define OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE 1
 #define OPENTHREAD_CONFIG_LOG_LEVEL_INIT OT_LOG_LEVEL_CRIT
 #define OPENTHREAD_CONFIG_LOG_OUTPUT OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
+#define OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE 1
 #define OPENTHREAD_CONFIG_LOG_PLATFORM 0
 #define OPENTHREAD_CONFIG_LOG_PREPEND_LEVEL 1
 #define OPENTHREAD_CONFIG_LOG_PREPEND_UPTIME 0
 #define OPENTHREAD_CONFIG_LOG_SUFFIX ""
+#define OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE 1
 #define OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE 1
 #define OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE 1
 #define OPENTHREAD_CONFIG_MAC_FILTER_ENABLE 1
@@ -104,12 +113,15 @@
 #define OPENTHREAD_CONFIG_MLE_LINK_METRICS_INITIATOR_ENABLE 1
 #define OPENTHREAD_CONFIG_MLE_LINK_METRICS_SUBJECT_ENABLE 1
 #define OPENTHREAD_CONFIG_MLE_MAX_CHILDREN 128
+#define OPENTHREAD_CONFIG_MLE_LINK_REQUEST_MARGIN_MIN 5
+#define OPENTHREAD_CONFIG_MLE_PARTITION_MERGE_MARGIN_MIN 5
 #define OPENTHREAD_CONFIG_MLR_ENABLE 1
 #define OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE 1
 #define OPENTHREAD_CONFIG_MULTICAST_DNS_AUTO_ENABLE_ON_INFRA_IF 1
 #define OPENTHREAD_CONFIG_MULTICAST_DNS_ENABLE 1
 #define OPENTHREAD_CONFIG_MULTICAST_DNS_ENTRY_ITERATION_API_ENABLE 1
 #define OPENTHREAD_CONFIG_MULTICAST_DNS_PUBLIC_API_ENABLE 1
+#define OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE 1
 #define OPENTHREAD_CONFIG_NAT64_BORDER_ROUTING_ENABLE 1
 #define OPENTHREAD_CONFIG_NAT64_TRANSLATOR_ENABLE 1
 #define OPENTHREAD_CONFIG_NAT64_IDLE_TIMEOUT_SECONDS 600
@@ -117,10 +129,16 @@
 #define OPENTHREAD_CONFIG_NET_DIAG_VENDOR_MODEL "Nexus Simulation"
 #define OPENTHREAD_CONFIG_NET_DIAG_VENDOR_NAME "RD:OpenThread by Google Nest"
 #define OPENTHREAD_CONFIG_NET_DIAG_VENDOR_SW_VERSION "OT-simul-nexus"
+#define OPENTHREAD_CONFIG_NET_DIAG_VENDOR_OUI 0x020100
 #define OPENTHREAD_CONFIG_NETDATA_PUBLISHER_ENABLE 1
 #define OPENTHREAD_CONFIG_NUM_MESSAGE_BUFFERS 256
+#define OPENTHREAD_CONFIG_PARENT_SEARCH_BACKOFF_INTERVAL (10 * 60)
+#define OPENTHREAD_CONFIG_PLATFORM_DNS_ENABLE 1
 #define OPENTHREAD_CONFIG_PLATFORM_DNSSD_ALLOW_RUN_TIME_SELECTION 0
 #define OPENTHREAD_CONFIG_PLATFORM_DNSSD_ENABLE 0
+#ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+#define OPENTHREAD_CONFIG_PLATFORM_UDP_ENABLE 1
+#endif
 #define OPENTHREAD_CONFIG_PLATFORM_FLASH_API_ENABLE 0
 #define OPENTHREAD_CONFIG_PLATFORM_KEY_REFERENCES_ENABLE 0
 #define OPENTHREAD_CONFIG_PLATFORM_RADIO_COEX_ENABLE 0
@@ -130,7 +148,7 @@
 #define OPENTHREAD_CONFIG_SEEKER_ENABLE 1
 #define OPENTHREAD_CONFIG_SRP_CLIENT_AUTO_START_DEFAULT_MODE 0
 #define OPENTHREAD_CONFIG_SRP_CLIENT_ENABLE 1
-#define OPENTHREAD_CONFIG_SRP_SERVER_ADVERTISING_PROXY_ENABLE 0
+#define OPENTHREAD_CONFIG_SRP_SERVER_ADVERTISING_PROXY_ENABLE 1
 #define OPENTHREAD_CONFIG_SRP_SERVER_ENABLE 1
 #define OPENTHREAD_CONFIG_TCP_ENABLE 1
 #define OPENTHREAD_CONFIG_TLS_ENABLE 0
@@ -143,16 +161,27 @@
 #define OPENTHREAD_CONFIG_TMF_NETDATA_SERVICE_ENABLE 1
 #define OPENTHREAD_CONFIG_TMF_NETDIAG_CLIENT_ENABLE 1
 #define OPENTHREAD_CONFIG_TMF_SNOOP_CACHE_ENTRY_TIMEOUT 3
+#define OPENTHREAD_CONFIG_MAX_STATECHANGE_HANDLERS 4
+#define OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE 1
 #define OPENTHREAD_CONFIG_TREL_MANAGE_DNSSD_ENABLE 1
 #define OPENTHREAD_CONFIG_TREL_USE_HEAP_ENABLE 1
 #define OPENTHREAD_CONFIG_UPTIME_ENABLE 1
+#define OPENTHREAD_CONFIG_USE_STD_NEW 1
 #define OPENTHREAD_CONFIG_VERHOEFF_CHECKSUM_ENABLE 1
 
+/**
+ * @def OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+ *
+ * Define to 1 to enable gRPC support in Nexus simulation.
+ *
+ */
+#ifndef OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+#define OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE 0
+#endif
+
 // CLI configs
 #define OPENTHREAD_CONFIG_CLI_MAX_LINE_LENGTH 800
 #define OPENTHREAD_CONFIG_CLI_LOG_INPUT_OUTPUT_ENABLE 1
 #define OPENTHREAD_CONFIG_CLI_LOG_INPUT_OUTPUT_LEVEL OT_LOG_LEVEL_INFO
 
-#define OPENTHREAD_CONFIG_BORDER_ROUTING_TESTING_API_ENABLE 1
-
 #endif // OT_NEXUS_OPENTHREAD_CORE_NEXUS_CONFIG_H_
diff --git a/tests/nexus/platform/nexus_core.cpp b/tests/nexus/platform/nexus_core.cpp
index 0d38456..1116b20 100644
--- a/tests/nexus/platform/nexus_core.cpp
+++ b/tests/nexus/platform/nexus_core.cpp
@@ -27,12 +27,16 @@
  */
 
 #include "nexus_core.hpp"
+#include "nexus_sim.hpp"
 
 #include <cstdio>
 #include <cstdlib>
 
 #include "mac_frame.h"
 #include "nexus_node.hpp"
+#include "nexus_radio_model.hpp"
+#include "thread/child_table.hpp"
+#include "thread/mle.hpp"
 
 namespace ot {
 namespace Nexus {
@@ -43,10 +47,11 @@
 Core::Core(void)
     : mCurNodeId(0)
     , mPendingAction(false)
+    , mSaveNodeLogs(false)
     , mNow(0)
-    , mActiveNode(nullptr)
 {
     const char *pcapFile;
+    const char *saveLogs;
 
     VerifyOrQuit(!sInUse);
     sCore  = this;
@@ -60,6 +65,26 @@
     {
         mPcap.Open(pcapFile);
     }
+
+    saveLogs = getenv("OT_NEXUS_SAVE_LOGS");
+
+    if (saveLogs != nullptr)
+    {
+        static const char *kActivateStrings[] = {"1", "yes", "y", "true", "t", "on"};
+
+        bool activate = false;
+
+        for (const char *activateString : kActivateStrings)
+        {
+            if (StringMatch(saveLogs, activateString, kStringCaseInsensitiveMatch))
+            {
+                activate = true;
+                break;
+            }
+        }
+
+        mSaveNodeLogs = activate;
+    }
 }
 
 void Core::SaveTestInfo(const char *aFilename, Node *aLeaderNode)
@@ -147,7 +172,7 @@
         if (leaderNode->Get<Mle::Mle>().IsLeader())
         {
             Ip6::Address aloc;
-            leaderNode->Get<Mle::Mle>().GetLeaderAloc(aloc);
+            leaderNode->Get<Mle::Mle>().ComposeLeaderAloc(aloc);
             fprintf(file, "  \"leader_aloc\": \"%s\",\n", aloc.ToString().AsCString());
         }
     }
@@ -168,20 +193,37 @@
     }
     fprintf(file, "  },\n");
 
+    fprintf(file, "  \"ipaddrs\": {\n");
+    for (Node &node : mNodes)
+    {
+        bool first = true;
+
+        fprintf(file, "    \"%u\": [\n", node.GetInstance().GetId());
+
+        for (const Ip6::Netif::UnicastAddress &addr : node.Get<Ip6::Netif>().GetUnicastAddresses())
+        {
+            fprintf(file, "%s      \"%s\"", first ? "" : ",\n", addr.GetAddress().ToString().AsCString());
+            first = false;
+        }
+
+        for (const Ip6::Address &infraAddr : node.mInfraIf.GetAddresses())
+        {
+            fprintf(file, "%s      \"%s\"", first ? "" : ",\n", infraAddr.ToString().AsCString());
+            first = false;
+        }
+
+        fprintf(file, "\n    ]%s\n", (&node == tail) ? "" : ",");
+    }
+    fprintf(file, "  },\n");
+
     fprintf(file, "  \"ethaddrs\": {\n");
     for (Node &node : mNodes)
     {
         InfraIf::LinkLayerAddress mac;
 
         node.mInfraIf.GetLinkLayerAddress(mac);
-        fprintf(file, "    \"%u\": \"", node.GetInstance().GetId());
-
-        for (uint8_t i = 0; i < mac.mLength; i++)
-        {
-            fprintf(file, "%02x%s", mac.mAddress[i], (i + 1 == mac.mLength) ? "" : ":");
-        }
-
-        fprintf(file, "\"%s\n", (&node == tail) ? "" : ",");
+        fprintf(file, "    \"%u\": \"%s\"%s\n", node.GetInstance().GetId(), mac.ToString().AsCString(),
+                (&node == tail) ? "" : ",");
     }
     fprintf(file, "  },\n");
 
@@ -217,22 +259,15 @@
     }
     fprintf(file, "  },\n");
 
-    fprintf(file, "  \"ipaddrs\": {\n");
+    fprintf(file, "  \"trel_udp_ports\": {\n");
     for (Node &node : mNodes)
     {
-        bool first = true;
-
-        fprintf(file, "    \"%u\": [\n", node.GetInstance().GetId());
-        for (const Ip6::Netif::UnicastAddress &addr : node.Get<ThreadNetif>().GetUnicastAddresses())
-        {
-            if (!first)
-            {
-                fprintf(file, ",\n");
-            }
-            fprintf(file, "      \"%s\"", addr.GetAddress().ToString().AsCString());
-            first = false;
-        }
-        fprintf(file, "\n    ]%s\n", (&node == tail) ? "" : ",");
+#if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
+        fprintf(file, "    \"%u\": %u%s\n", node.GetInstance().GetId(), node.mTrel.mUdpPort,
+                (&node == tail) ? "" : ",");
+#else
+        fprintf(file, "    \"%u\": 0%s\n", node.GetInstance().GetId(), (&node == tail) ? "" : ",");
+#endif
     }
     fprintf(file, "  },\n");
 
@@ -240,7 +275,8 @@
     if (leaderNode != nullptr)
     {
         Ip6::Prefix prefix;
-        prefix.Set(leaderNode->Get<Mle::Mle>().GetMeshLocalPrefix());
+
+        prefix.InitFrom(leaderNode->Get<Mle::Mle>().GetMeshLocalPrefix());
         fprintf(file, "    \"mesh_local_prefix\": \"%s\"%s\n", prefix.ToString().AsCString(),
                 mTestVars.IsEmpty() ? "" : ",");
     }
@@ -262,12 +298,22 @@
 
 void Core::AddNetworkKey(const NetworkKey &aKey) { SuccessOrQuit(mNetworkKeys.PushBack(aKey)); }
 
-void Core::AddTestVar(const char *aName, const char *aValue)
+Core::TestVar &Core::NewTestVar(const char *aName)
 {
     TestVar *var = mTestVars.PushBack();
+
     VerifyOrQuit(var != nullptr);
     var->mName.Clear().Append("%s", aName);
-    var->mValue.Clear().Append("%s", aValue);
+    var->mValue.Clear();
+
+    return *var;
+}
+
+void Core::AddTestVar(const char *aName, const char *aValue) { NewTestVar(aName).mValue.Append("%s", aValue); }
+
+void Core::AddTestVar(const char *aName, uint32_t aUintValue)
+{
+    NewTestVar(aName).mValue.Append("%lu", ToUlong(aUintValue));
 }
 
 void Core::AddOmrPrefixTestVar(const char *aName, Node &aNode)
@@ -291,7 +337,60 @@
 #endif
 }
 
-Core::~Core(void) { sInUse = false; }
+Core::~Core(void)
+{
+    while (!mNodes.IsEmpty())
+    {
+        Node *node = mNodes.GetHead();
+
+        UpdateActiveInstance(&node->GetInstance());
+        mNodes.Pop();
+    }
+
+    UpdateActiveInstance(nullptr);
+    sInUse = false;
+}
+
+void Core::NotifyHeartbeat(void)
+{
+    for (Observer &observer : mObservers)
+    {
+        observer.OnHeartbeat(mNow);
+    }
+}
+
+void Core::NotifyDumpState(void)
+{
+    for (Observer &observer : mObservers)
+    {
+        observer.DumpState();
+    }
+}
+
+void Core::SetNodeEnabled(uint32_t aNodeId, bool aEnabled)
+{
+    Node *node = FindNodeById(aNodeId);
+
+    if (node != nullptr)
+    {
+        Log("Core::SetNodeEnabled: Node %u found. Setting Thread enabled=%d", aNodeId, aEnabled);
+
+        if (!aEnabled)
+        {
+            node->Get<ThreadNetif>().Down();
+            node->Get<Mle::Mle>().Stop();
+        }
+        else
+        {
+            node->Get<ThreadNetif>().Up();
+            SuccessOrQuit(node->Get<Mle::Mle>().Start());
+        }
+    }
+}
+
+Node *Core::FindNodeById(uint32_t aNodeId) { return mNodes.FindMatching(aNodeId); }
+
+Node *Core::FindNodeByExtAddress(const Mac::ExtAddress &aExtAddress) { return mNodes.FindMatching(aExtAddress); }
 
 Node &Core::CreateNode(void)
 {
@@ -302,18 +401,213 @@
 
     node->GetInstance().SetId(mCurNodeId++);
 
-    node->mInfraIf.Init(*node);
+    if (mSaveNodeLogs)
+    {
+        node->mLogging.Init(node->GetId());
+    }
+
+    node->mInfraIf.AfterInit();
+
     node->mMdns.Init(*node);
+#if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
+    node->mTrel.Init(*node);
+#endif
 
     mNodes.Push(*node);
 
     node->GetInstance().AfterInit();
 
-    otIp6SetReceiveCallback(&node->GetInstance(), Node::HandleIp6Receive, node);
+    node->Get<Ip6::Ip6>().SetReceiveCallback(Node::HandleIp6Receive, node);
+
+    node->Get<NeighborTable>().RegisterCallback(&Core::HandleNeighborTableChanged);
+    SuccessOrQuit(node->Get<Notifier>().RegisterCallback(&Core::HandleStateChanged, node));
+
+    for (Observer &observer : mObservers)
+    {
+        observer.OnNodeStateChanged(node);
+    }
 
     return *node;
 }
 
+void Core::HandleNeighborTableChanged(otNeighborTableEvent aEvent, const otNeighborTableEntryInfo *aInfo)
+{
+    NeighborTable::Event event = static_cast<NeighborTable::Event>(aEvent);
+
+    Core    &core     = Core::Get();
+    uint32_t srcId    = 0;
+    uint32_t dstId    = 0;
+    bool     foundSrc = false;
+    bool     foundDst = false;
+    bool     isActive;
+
+    const Mac::ExtAddress *extAddr = nullptr;
+
+    VerifyOrExit(!core.mObservers.IsEmpty());
+
+    Log("HandleNeighborTableChanged: Event %d", event);
+
+    {
+        Node &srcNode = Node::From(aInfo->mInstance);
+
+        srcId    = srcNode.GetId();
+        foundSrc = true;
+    }
+
+    switch (event)
+    {
+    case NeighborTable::kChildAdded:
+    case NeighborTable::kChildRemoved:
+        extAddr  = &AsCoreType(&aInfo->mInfo.mChild.mExtAddress);
+        isActive = (event == NeighborTable::kChildAdded);
+
+        if (event == NeighborTable::kChildRemoved)
+        {
+            Instance &instance = *static_cast<Instance *>(aInfo->mInstance);
+
+            if (instance.Get<RouterTable>().FindRouter(*extAddr) != nullptr)
+            {
+                Log("Suppressing CHILD_REMOVED event because neighbor is a valid router");
+                ExitNow();
+            }
+        }
+        break;
+
+    case NeighborTable::kRouterAdded:
+    case NeighborTable::kRouterRemoved:
+        extAddr  = &AsCoreType(&aInfo->mInfo.mRouter.mExtAddress);
+        isActive = (event == NeighborTable::kRouterAdded);
+        break;
+
+    default:
+        break;
+    }
+
+    if (extAddr != nullptr)
+    {
+        Node *node = core.GetNodes().FindMatching(*extAddr);
+
+        if (node != nullptr)
+        {
+            dstId    = node->GetInstance().GetId();
+            foundDst = true;
+        }
+    }
+
+    if (foundSrc && foundDst)
+    {
+        for (Observer &observer : core.mObservers)
+        {
+            observer.OnLinkUpdate(srcId, dstId, isActive);
+        }
+    }
+    else
+    {
+        Log("HandleNeighborTableChanged: Failed to find srcId or dstId. foundSrc: %d, foundDst: %d", foundSrc,
+            foundDst);
+    }
+
+exit:
+    return;
+}
+
+void Core::HandleStateChanged(otChangedFlags aFlags, void *aContext)
+{
+    OT_UNUSED_VARIABLE(aFlags);
+
+    Core &core = Core::Get();
+    Node *node = static_cast<Node *>(aContext);
+
+    VerifyOrExit(!core.mObservers.IsEmpty() && node != nullptr);
+
+    for (Observer &observer : core.mObservers)
+    {
+        observer.OnNodeStateChanged(node);
+    }
+
+    // Decoupled from flags to capture SED parent changes
+    switch (node->Get<Mle::Mle>().GetRole())
+    {
+    case Mle::kRoleChild:
+    {
+        Router::Info parentInfo;
+
+        if (node->Get<Mle::Mle>().GetParentInfo(parentInfo) == kErrorNone)
+        {
+            uint32_t srcId  = node->GetInstance().GetId();
+            uint32_t dstId  = 0xffff;
+            Node    *rxNode = core.mNodes.FindMatching(AsCoreType(&parentInfo.mExtAddress));
+
+            if (rxNode != nullptr)
+            {
+                dstId = rxNode->GetInstance().GetId();
+            }
+
+            if (dstId != 0xffff && dstId != node->GetLastParentId())
+            {
+                if (node->GetLastParentId() != 0xffff)
+                {
+                    for (Observer &observer : core.mObservers)
+                    {
+                        observer.OnLinkUpdate(srcId, node->GetLastParentId(), false);
+                    }
+                }
+                node->SetLastParentId(dstId);
+                for (Observer &observer : core.mObservers)
+                {
+                    observer.OnLinkUpdate(srcId, dstId, true);
+                }
+            }
+        }
+        break;
+    }
+    case Mle::kRoleDetached:
+    {
+        uint32_t srcId = node->GetInstance().GetId();
+
+        for (Node &rxNode : core.mNodes)
+        {
+            if (&rxNode == node)
+            {
+                continue;
+            }
+
+            if (node->Get<NeighborTable>().FindNeighbor(rxNode.Get<Mac::Mac>().GetExtAddress(),
+                                                        Neighbor::kInStateValid) != nullptr)
+            {
+                uint32_t dstId = rxNode.GetInstance().GetId();
+
+                for (Observer &observer : core.mObservers)
+                {
+                    observer.OnLinkUpdate(srcId, dstId, false);
+                    observer.OnLinkUpdate(dstId, srcId, false);
+                }
+            }
+        }
+
+        if (node->GetLastParentId() != 0xffff)
+        {
+            for (Observer &observer : core.mObservers)
+            {
+                observer.OnLinkUpdate(srcId, node->GetLastParentId(), false);
+            }
+            node->SetLastParentId(0xffff);
+        }
+        break;
+    }
+    case Mle::kRoleRouter:
+    case Mle::kRoleLeader:
+        node->SetLastParentId(0xffff);
+        break;
+
+    default:
+        break;
+    }
+
+exit:
+    return;
+}
+
 void Core::UpdateNextAlarmMilli(const Alarm &aAlarm)
 {
     if (aAlarm.mScheduled)
@@ -352,6 +646,35 @@
     }
 }
 
+bool Core::IsUiConnected(void) const
+{
+    bool connected = false;
+
+    for (const Observer &observer : mObservers)
+    {
+        if (observer.IsConnected())
+        {
+            connected = true;
+            break;
+        }
+    }
+
+    return connected;
+}
+
+void Core::Reset(void)
+{
+    mNodes.Clear();
+    mCurNodeId     = 0;
+    mNow           = 0;
+    mNextAlarmTime = NumericLimits<uint64_t>::kMax;
+
+    for (Observer &observer : mObservers)
+    {
+        observer.OnClearEvents();
+    }
+}
+
 void Core::AdvanceTime(uint32_t aDuration)
 {
     uint64_t targetTime = mNow + (static_cast<uint64_t>(aDuration) * 1000u);
@@ -379,15 +702,10 @@
 
 void Core::Process(Node &aNode)
 {
-    SetActiveNode(&aNode);
-
     otTaskletsProcess(&aNode.GetInstance());
 
     ProcessRadio(aNode);
     ProcessInfraIf(aNode);
-#if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
-    ProcessTrel(aNode);
-#endif
 
     if (aNode.mAlarmMilli.mScheduled && (GetNow() >= aNode.mAlarmMilli.mAlarmTime))
     {
@@ -400,8 +718,6 @@
         aNode.mAlarmMicro.mScheduled = false;
         otPlatAlarmMicroFired(&aNode.GetInstance());
     }
-
-    SetActiveNode(nullptr);
 }
 
 void Core::ProcessRadio(Node &aNode)
@@ -424,14 +740,47 @@
         dstPanId = Mac::kPanIdBroadcast;
     }
 
-    ackRequested                           = aNode.mRadio.mTxFrame.GetAckRequest();
-    aNode.mRadio.mRadioContext.mCslPresent = aNode.mRadio.mTxFrame.mInfo.mTxInfo.mCslPresent;
+    ackRequested = aNode.mRadio.mTxFrame.GetAckRequest();
 
     SuccessOrQuit(otMacFrameProcessTxSfd(&aNode.mRadio.mTxFrame, mNow, &aNode.mRadio.mRadioContext));
     static_cast<Radio::Frame &>(aNode.mRadio.mTxFrame).UpdateFcs();
 
     mPcap.WriteFrame(aNode.mRadio.mTxFrame, mNow);
 
+    if (!mObservers.IsEmpty())
+    {
+        uint32_t dstNodeId = 0xffff; // Default to broadcast / unknown
+
+        if (!dstAddr.IsBroadcast())
+        {
+            for (Node &rxNode : mNodes)
+            {
+                if (&rxNode == &aNode)
+                {
+                    continue;
+                }
+
+                if (rxNode.mRadio.Matches(dstAddr, dstPanId))
+                {
+                    dstNodeId = rxNode.GetInstance().GetId();
+                    break;
+                }
+            }
+        }
+
+        if (!dstAddr.IsBroadcast() && dstNodeId == 0xffff)
+        {
+            Log("ProcessRadio: Failed to resolve dstNodeId for unicast from node %u to %s", aNode.GetInstance().GetId(),
+                dstAddr.ToString().AsCString());
+        }
+
+        for (Observer &observer : mObservers)
+        {
+            observer.OnPacketEvent(aNode.GetInstance().GetId(), dstNodeId, aNode.mRadio.mTxFrame.GetPsdu(),
+                                   aNode.mRadio.mTxFrame.GetLength());
+        }
+    }
+
     otPlatRadioTxStarted(&aNode.GetInstance(), &aNode.mRadio.mTxFrame);
 
     for (Node &rxNode : mNodes)
@@ -452,8 +801,18 @@
             Radio::Frame rxFrame(aNode.mRadio.mTxFrame);
 
             rxFrame.mInfo.mRxInfo.mTimestamp = mNow;
-            rxFrame.mInfo.mRxInfo.mRssi      = kDefaultRxRssi;
-            rxFrame.mInfo.mRxInfo.mLqi       = kDefaultRxLqi;
+
+            int16_t localRssi = RadioModel::CalculateRssi(aNode, rxNode);
+
+            // Completely intercept and drop packets that dip below target receiver sensitivity
+            if (RadioModel::ShouldDropPacket(localRssi))
+            {
+                continue;
+            }
+
+            rxFrame.mInfo.mRxInfo.mRssi = ClampToInt8(localRssi);
+
+            rxFrame.mInfo.mRxInfo.mLqi = kDefaultRxLqi;
 
             if (matchesDst && !dstAddr.IsNone() && !dstAddr.IsBroadcast() && ackRequested)
             {
@@ -496,11 +855,8 @@
             uint8_t ackIeDataLength = 0;
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-            ackNode->mRadio.mRadioContext.mCslPresent =
-                (ackNode->mRadio.mRadioContext.mCslPeriod > 0) &&
-                otMacFrameSrcAddrMatchCslReceiverPeer(&aNode.mRadio.mTxFrame, &ackNode->mRadio.mRadioContext);
-
-            if (ackNode->mRadio.mRadioContext.mCslPresent)
+            if ((ackNode->mRadio.mRadioContext.mCslPeriod > 0) &&
+                otMacFrameSrcAddrMatchCslReceiverPeer(&aNode.mRadio.mTxFrame, &ackNode->mRadio.mRadioContext))
             {
                 ackIeDataLength = otMacFrameGenerateCslIeTemplate(ackIeData);
             }
@@ -535,9 +891,25 @@
         }
 
         ackFrame.UpdateFcs();
-        mPcap.WriteFrame(ackFrame, mNow);
 
-        otPlatRadioTxDone(&aNode.GetInstance(), &aNode.mRadio.mTxFrame, &ackFrame, kErrorNone);
+        {
+            int16_t ackRssi = RadioModel::CalculateRssi(*ackNode, aNode);
+
+            ackFrame.mInfo.mRxInfo.mRssi      = ClampToInt8(ackRssi);
+            ackFrame.mInfo.mRxInfo.mLqi       = kDefaultRxLqi;
+            ackFrame.mInfo.mRxInfo.mTimestamp = mNow;
+
+            mPcap.WriteFrame(ackFrame, mNow);
+
+            if (RadioModel::ShouldDropPacket(ackRssi))
+            {
+                otPlatRadioTxDone(&aNode.GetInstance(), &aNode.mRadio.mTxFrame, nullptr, kErrorNoAck);
+            }
+            else
+            {
+                otPlatRadioTxDone(&aNode.GetInstance(), &aNode.mRadio.mTxFrame, &ackFrame, kErrorNone);
+            }
+        }
     }
     else
     {
@@ -605,7 +977,10 @@
 
         if (!header.GetDestination().IsMulticast())
         {
-            targetNode = FindNodeByInfraIfAddress(header.GetDestination());
+            if (!IsThreadAddress(header.GetDestination()))
+            {
+                targetNode = FindNodeByAddress(header.GetDestination());
+            }
         }
 
         for (Node &rxNode : mNodes)
@@ -620,63 +995,40 @@
                 continue;
             }
 
-            rxNode.mInfraIf.Receive(aNode, *message);
+            rxNode.mInfraIf.Receive(*message);
         }
 
         message->Free();
     }
 }
 
+Node *Core::FindNodeByAddress(const Ip6::Address &aAddress)
+{
+    return mNodes.FindMatching(aAddress, Node::kAnyNetifAddress);
+}
+
+bool Core::IsThreadAddress(const Ip6::Address &aAddress)
+{
+    return mNodes.ContainsMatching(aAddress, Node::kThreadNetifAddress);
+}
+
+Node *Core::FindNodeByThreadAddress(const Ip6::Address &aAddress)
+{
+    return mNodes.FindMatching(aAddress, Node::kThreadNetifAddress);
+}
+
 Node *Core::FindNodeByInfraIfAddress(const Ip6::Address &aAddress)
 {
-    Node *matchedNode = nullptr;
-
-    for (Node &node : mNodes)
-    {
-        if (node.mInfraIf.HasAddress(aAddress))
-        {
-            matchedNode = &node;
-            break;
-        }
-    }
-
-    return matchedNode;
+    return mNodes.FindMatching(aAddress, Node::kInfraNetifAddress);
 }
 
-#if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
-
-void Core::ProcessTrel(Node &aNode)
-{
-    Ip6::SockAddr senderSockAddr;
-    Ip6::SockAddr rxNodeSockAddr;
-
-    aNode.GetTrelSockAddr(senderSockAddr);
-
-    for (Trel::PendingTx &pendingTx : aNode.mTrel.mPendingTxList)
-    {
-        for (Node &rxNode : mNodes)
-        {
-            rxNode.GetTrelSockAddr(rxNodeSockAddr);
-
-            if (pendingTx.mDestSockAddr == rxNodeSockAddr)
-            {
-                rxNode.mTrel.Receive(rxNode.GetInstance(), pendingTx.mPayloadData, senderSockAddr);
-                break;
-            }
-        }
-    }
-
-    aNode.mTrel.mPendingTxList.Free();
-}
-
-#endif // OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
-
 //---------------------------------------------------------------------------------------------------------------------
 
 Core::IcmpEchoResponseContext::IcmpEchoResponseContext(Node &aNode, uint16_t aIdentifier)
     : mNode(aNode)
     , mIdentifier(aIdentifier)
     , mResponseReceived(false)
+    , mExpectedSourceCheck(false)
 {
 }
 
@@ -688,19 +1040,27 @@
     OT_UNUSED_VARIABLE(aMessage);
 
     IcmpEchoResponseContext *context     = static_cast<IcmpEchoResponseContext *>(aContext);
-    const Ip6::Icmp::Header *header      = AsCoreTypePtr(aIcmpHeader);
+    const Ip6::Icmp6Header  *header      = AsCoreTypePtr(aIcmpHeader);
     const Ip6::MessageInfo  *messageInfo = AsCoreTypePtr(aMessageInfo);
 
     VerifyOrQuit(context != nullptr);
     VerifyOrQuit(header != nullptr);
     VerifyOrQuit(messageInfo != nullptr);
 
-    if ((header->GetType() == Ip6::Icmp::Header::kTypeEchoReply) && (header->GetId() == context->mIdentifier))
+    if ((header->GetType() == Ip6::Icmp6Header::kTypeEchoReply) && (header->GetId() == context->mIdentifier))
     {
         context->mResponseReceived = true;
 
         Log("Received Echo Reply on Node %u (%s) from %s", context->mNode.GetId(), context->mNode.GetName(),
             messageInfo->GetPeerAddr().ToString().AsCString());
+
+        if (context->mExpectedSourceCheck)
+        {
+            Log("Verifying source address: Expected %s, Actual %s", context->mExpectedSource.ToString().AsCString(),
+                messageInfo->GetSockAddr().ToString().AsCString());
+
+            VerifyOrQuit(messageInfo->GetSockAddr() == context->mExpectedSource);
+        }
     }
 }
 
@@ -719,9 +1079,80 @@
 
     aSender.SendEchoRequest(aDestination, kIdentifier, aPayloadSize, aHopLimit);
     AdvanceTime(aResponseTimeout);
-    VerifyOrQuit(icmpContext.mResponseReceived);
 
     SuccessOrQuit(aSender.Get<Ip6::Icmp>().UnregisterHandler(icmpHandler));
+
+    VerifyOrQuit(icmpContext.mResponseReceived);
+}
+
+void Core::SendAndVerifyEchoRequest(Node               &aSender,
+                                    const Ip6::Address &aExpectedSource,
+                                    const Ip6::Address &aDestination,
+                                    uint16_t            aPayloadSize,
+                                    uint8_t             aHopLimit,
+                                    uint32_t            aResponseTimeout,
+                                    bool                aForceSource)
+{
+    static constexpr uint16_t kIdentifier = 0x1234;
+
+    IcmpEchoResponseContext icmpContext(aSender, kIdentifier);
+    icmpContext.mExpectedSource      = aExpectedSource;
+    icmpContext.mExpectedSourceCheck = true;
+
+    Ip6::Icmp::Handler icmpHandler(HandleIcmpResponse, &icmpContext);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().RegisterHandler(icmpHandler));
+
+    aSender.SendEchoRequest(aDestination, kIdentifier, aPayloadSize, aHopLimit,
+                            aForceSource ? &aExpectedSource : nullptr);
+    AdvanceTime(aResponseTimeout);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().UnregisterHandler(icmpHandler));
+
+    VerifyOrQuit(icmpContext.mResponseReceived);
+}
+
+void Core::SendAndVerifyNoEchoResponse(Node               &aSender,
+                                       const Ip6::Address &aDestination,
+                                       uint16_t            aPayloadSize,
+                                       uint8_t             aHopLimit,
+                                       uint32_t            aResponseTimeout)
+{
+    static constexpr uint16_t kIdentifier = 0x1234;
+
+    IcmpEchoResponseContext icmpContext(aSender, kIdentifier);
+    Ip6::Icmp::Handler      icmpHandler(HandleIcmpResponse, &icmpContext);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().RegisterHandler(icmpHandler));
+
+    aSender.SendEchoRequest(aDestination, kIdentifier, aPayloadSize, aHopLimit);
+    AdvanceTime(aResponseTimeout);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().UnregisterHandler(icmpHandler));
+
+    VerifyOrQuit(!icmpContext.mResponseReceived);
+}
+
+void Core::SendAndVerifyNoEchoResponse(Node               &aSender,
+                                       const Ip6::Address &aSrcAddress,
+                                       const Ip6::Address &aDestination,
+                                       uint16_t            aPayloadSize,
+                                       uint8_t             aHopLimit,
+                                       uint32_t            aResponseTimeout)
+{
+    static constexpr uint16_t kIdentifier = 0x1234;
+
+    IcmpEchoResponseContext icmpContext(aSender, kIdentifier);
+    Ip6::Icmp::Handler      icmpHandler(HandleIcmpResponse, &icmpContext);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().RegisterHandler(icmpHandler));
+
+    aSender.SendEchoRequest(aDestination, kIdentifier, aPayloadSize, aHopLimit, &aSrcAddress);
+    AdvanceTime(aResponseTimeout);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().UnregisterHandler(icmpHandler));
+
+    VerifyOrQuit(!icmpContext.mResponseReceived);
 }
 
 } // namespace Nexus
diff --git a/tests/nexus/platform/nexus_core.hpp b/tests/nexus/platform/nexus_core.hpp
index 336dfdb..89bb64f 100644
--- a/tests/nexus/platform/nexus_core.hpp
+++ b/tests/nexus/platform/nexus_core.hpp
@@ -29,16 +29,20 @@
 #ifndef OT_NEXUS_PLATFORM_NEXUS_CORE_HPP_
 #define OT_NEXUS_PLATFORM_NEXUS_CORE_HPP_
 
+#include "openthread-core-config.h"
+
+#include <stdio.h>
+
+#include "nexus_alarm.hpp"
+#include "nexus_observer.hpp"
+#include "nexus_pcap.hpp"
+#include "nexus_radio.hpp"
+#include "nexus_utils.hpp"
 #include "common/array.hpp"
 #include "common/owning_list.hpp"
 #include "instance/instance.hpp"
 #include "thread/key_manager.hpp"
 
-#include "nexus_alarm.hpp"
-#include "nexus_pcap.hpp"
-#include "nexus_radio.hpp"
-#include "nexus_utils.hpp"
-
 namespace ot {
 namespace Nexus {
 
@@ -52,7 +56,15 @@
 
     static Core &Get(void) { return *sCore; }
 
-    Node             &CreateNode(void);
+    void AddObserver(Observer &aObserver) { mObservers.Push(aObserver); }
+    void RemoveObserver(Observer &aObserver) { IgnoreError(mObservers.Remove(aObserver)); }
+    void NotifyHeartbeat(void);
+    void NotifyDumpState(void);
+
+    Node &CreateNode(void);
+    Node *FindNodeById(uint32_t aNodeId);
+    Node *FindNodeByExtAddress(const Mac::ExtAddress &aExtAddress);
+
     LinkedList<Node> &GetNodes(void) { return mNodes; }
 
     TimeMilli GetNow(void) { return TimeMilli(static_cast<uint32_t>(mNow / 1000u)); }
@@ -60,12 +72,18 @@
     uint64_t  GetNowMicro64(void) const { return mNow; }
     void      AdvanceTime(uint32_t aDuration);
 
+    bool IsUiConnected(void) const;
+
+    void Reset(void);
+    void SetNodeEnabled(uint32_t aNodeId, bool aEnabled);
+
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Test specific helper methods
 
     void SaveTestInfo(const char *aFilename, Node *aLeaderNode = nullptr);
     void AddNetworkKey(const NetworkKey &aKey);
     void AddTestVar(const char *aName, const char *aValue);
+    void AddTestVar(const char *aName, uint32_t aValue);
     void AddOmrPrefixTestVar(const char *aName, Node &aNode);
     void SendAndVerifyEchoRequest(Node               &aSender,
                                   const Ip6::Address &aDestination,
@@ -73,16 +91,42 @@
                                   uint8_t             aHopLimit        = Ip6::kDefaultHopLimit,
                                   uint32_t            aResponseTimeout = 1000);
 
+    void SendAndVerifyEchoRequest(Node               &aSender,
+                                  const Ip6::Address &aExpectedSource,
+                                  const Ip6::Address &aDestination,
+                                  uint16_t            aPayloadSize     = 0,
+                                  uint8_t             aHopLimit        = Ip6::kDefaultHopLimit,
+                                  uint32_t            aResponseTimeout = 1000,
+                                  bool                aForceSource     = true);
+
+    void SendAndVerifyNoEchoResponse(Node               &aSender,
+                                     const Ip6::Address &aDestination,
+                                     uint16_t            aPayloadSize     = 0,
+                                     uint8_t             aHopLimit        = Ip6::kDefaultHopLimit,
+                                     uint32_t            aResponseTimeout = 1000);
+
+    void SendAndVerifyNoEchoResponse(Node               &aSender,
+                                     const Ip6::Address &aSrcAddress,
+                                     const Ip6::Address &aDestination,
+                                     uint16_t            aPayloadSize     = 0,
+                                     uint8_t             aHopLimit        = Ip6::kDefaultHopLimit,
+                                     uint32_t            aResponseTimeout = 1000);
+
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Used by platform implementation
 
-    void  SetActiveNode(Node *aNode) { mActiveNode = aNode; }
-    Node *GetActiveNode(void) { return mActiveNode; }
-
     void UpdateNextAlarmMilli(const Alarm &aAlarm);
     void UpdateNextAlarmMicro(const Alarm &aAlarm);
     void MarkPendingAction(void) { mPendingAction = true; }
 
+    Node *FindNodeByAddress(const Ip6::Address &aAddress);
+    bool  IsThreadAddress(const Ip6::Address &aAddress);
+    Node *FindNodeByThreadAddress(const Ip6::Address &aAddress);
+    Node *FindNodeByInfraIfAddress(const Ip6::Address &aAddress);
+
+    static void HandleNeighborTableChanged(otNeighborTableEvent aEvent, const otNeighborTableEntryInfo *aInfo);
+    static void HandleStateChanged(otChangedFlags aFlags, void *aContext);
+
 private:
     static constexpr int8_t  kDefaultRxRssi = -20;
     static constexpr uint8_t kDefaultRxLqi  = 255;
@@ -98,9 +142,11 @@
     {
         IcmpEchoResponseContext(Node &aNode, uint16_t aIdentifier);
 
-        Node    &mNode;
-        uint16_t mIdentifier;
-        bool     mResponseReceived;
+        Node        &mNode;
+        uint16_t     mIdentifier;
+        bool         mResponseReceived;
+        Ip6::Address mExpectedSource;
+        bool         mExpectedSourceCheck;
     };
 
     struct TestVar
@@ -109,14 +155,11 @@
         String<64> mValue;
     };
 
+    TestVar &NewTestVar(const char *aName);
+
     void Process(Node &aNode);
     void ProcessRadio(Node &aNode);
     void ProcessInfraIf(Node &aNode);
-#if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
-    void ProcessTrel(Node &aNode);
-#endif
-
-    Node *FindNodeByInfraIfAddress(const Ip6::Address &aAddress);
 
     static void HandleIcmpResponse(void                *aContext,
                                    otMessage           *aMessage,
@@ -132,12 +175,12 @@
     Array<TestVar, 128>   mTestVars;
     uint16_t              mCurNodeId;
     bool                  mPendingAction;
+    bool                  mSaveNodeLogs;
     uint64_t              mNow;
     uint64_t              mNextAlarmTime;
-    Node                 *mActiveNode;
-};
 
-void Log(const char *aFormat, ...) OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(1, 2);
+    LinkedList<Observer> mObservers;
+};
 
 } // namespace Nexus
 } // namespace ot
diff --git a/tests/nexus/platform/nexus_dns.cpp b/tests/nexus/platform/nexus_dns.cpp
new file mode 100644
index 0000000..e395c42
--- /dev/null
+++ b/tests/nexus/platform/nexus_dns.cpp
@@ -0,0 +1,214 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file implements the platform DNS APIs for Nexus.
+ */
+
+#include "nexus_dns.hpp"
+
+#include <openthread/platform/dns.h>
+
+#include "nexus_core.hpp"
+#include "nexus_node.hpp"
+#include "border_router/rx_ra_tracker.hpp"
+#include "common/as_core_type.hpp"
+#include "common/code_utils.hpp"
+#include "common/debug.hpp"
+#include "net/dns_types.hpp"
+
+namespace ot {
+namespace Nexus {
+
+bool UpstreamDns::IsGua(const Ip6::Prefix &aPrefix) const { return !aPrefix.IsLinkLocal() && !aPrefix.IsUniqueLocal(); }
+
+uint16_t UpstreamDns::CountGuaPrefixes(const Ip6::Address &aRouterAddress) const
+{
+    const BorderRouter::RxRaTracker  &rxRaTracker = Get<BorderRouter::RxRaTracker>();
+    BorderRouter::PrefixTableIterator iter;
+    BorderRouter::PrefixTableEntry    entry;
+    uint16_t                          count = 0;
+
+    rxRaTracker.InitIterator(iter);
+    while (rxRaTracker.GetNextPrefixTableEntry(iter, entry) == kErrorNone)
+    {
+        if (entry.mValidLifetime > 0 && IsGua(AsCoreType(&entry.mPrefix)) &&
+            AsCoreType(&entry.mRouter.mAddress) == aRouterAddress)
+        {
+            count++;
+        }
+    }
+
+    return count;
+}
+
+Error UpstreamDns::SelectUpstreamDnsServer(Ip6::Address &aSelectedAddress) const
+{
+    const BorderRouter::RxRaTracker  &rxRaTracker = Get<BorderRouter::RxRaTracker>();
+    BorderRouter::PrefixTableIterator iter;
+    BorderRouter::RdnssAddrEntry      entry;
+    bool                              found      = false;
+    bool                              bestHasGua = false;
+
+    rxRaTracker.InitIterator(iter);
+    while (rxRaTracker.GetNextRdnssAddrEntry(iter, entry) == kErrorNone)
+    {
+        if (!entry.mRouter.mIsReachable)
+        {
+            continue;
+        }
+
+        bool hasGua = (CountGuaPrefixes(AsCoreType(&entry.mRouter.mAddress)) > 0);
+
+        if (!found || (hasGua && !bestHasGua))
+        {
+            aSelectedAddress = AsCoreType(&entry.mAddress);
+            bestHasGua       = hasGua;
+            found            = true;
+        }
+    }
+
+    return found ? kErrorNone : kErrorNotFound;
+}
+
+UpstreamDns::UpstreamDns(Instance &aInstance)
+    : InstanceLocator(aInstance)
+{
+}
+
+void UpstreamDns::StartUpstreamQuery(UpstreamQueryTransaction &aTxn, const Message &aQuery)
+{
+    Error           error = kErrorNone;
+    Node           &node  = AsNode(&GetInstance());
+    Ip6::Address    serverAddress;
+    ot::Dns::Header dnsHeader;
+    PendingQuery   *pendingQuery;
+    Message        *message = nullptr;
+
+    // We use kDnsPort (53) as both source and destination port for upstream queries to simplify interception
+    // and response matching in this simulation environment.
+
+    SuccessOrExit(error = SelectUpstreamDnsServer(serverAddress));
+
+    SuccessOrExit(error = aQuery.Read(0, dnsHeader));
+
+    message = aQuery.Clone<kSameReservedHeader>();
+    VerifyOrExit(message != nullptr, error = kErrorNoBufs);
+
+    pendingQuery = PendingQuery::Allocate();
+    VerifyOrExit(pendingQuery != nullptr, error = kErrorNoBufs);
+
+    pendingQuery->mTxn           = &aTxn;
+    pendingQuery->mMessageId     = dnsHeader.GetMessageId();
+    pendingQuery->mServerAddress = serverAddress;
+    mPendingQueries.Push(*pendingQuery);
+
+    node.mInfraIf.SendUdp(node.mInfraIf.SelectSourceAddress(serverAddress), serverAddress, kDnsPort, kDnsPort,
+                          *message);
+    message = nullptr;
+
+exit:
+    if (error != kErrorNone)
+    {
+        if (message != nullptr)
+        {
+            message->Free();
+        }
+        otPlatDnsUpstreamQueryDone(&GetInstance(), &aTxn, nullptr);
+    }
+}
+
+void UpstreamDns::CancelUpstreamQuery(UpstreamQueryTransaction &aTxn)
+{
+    mPendingQueries.RemoveAndFreeAllMatching(&aTxn);
+}
+
+void UpstreamDns::Reset(void) { mPendingQueries.Clear(); }
+
+bool UpstreamDns::IsUpstreamQueryAvailable(void) const
+{
+    Ip6::Address address;
+    return SelectUpstreamDnsServer(address) == kErrorNone;
+}
+
+bool UpstreamDns::HandleUpstreamDnsResponse(const Ip6::Address &aSrcAddress, Message &aMessage)
+{
+    Instance              &instance = GetInstance();
+    Node                  &node     = AsNode(&instance);
+    ot::Dns::Header        dnsHeader;
+    OwnedPtr<PendingQuery> query;
+    bool                   handled = false;
+
+    SuccessOrExit(aMessage.Read(aMessage.GetOffset(), dnsHeader));
+
+    query = mPendingQueries.RemoveMatching(dnsHeader.GetMessageId(), aSrcAddress);
+
+    if (query != nullptr)
+    {
+        // Found matching query
+        // We use kTypeOther as it is appropriate for a payload-only message.
+        Message *response = node.Get<MessagePool>().Allocate(Message::kTypeOther);
+
+        if (response != nullptr)
+        {
+            if (response->AppendBytesFromMessage(aMessage, aMessage.GetOffset(),
+                                                 aMessage.GetLength() - aMessage.GetOffset()) != kErrorNone)
+            {
+                response->Free();
+                response = nullptr;
+            }
+        }
+
+        otPlatDnsUpstreamQueryDone(&instance, query->mTxn, response);
+        handled = true;
+    }
+
+exit:
+    return handled;
+}
+
+extern "C" bool otPlatDnsIsUpstreamQueryAvailable(otInstance *aInstance)
+{
+    return AsNode(aInstance).mUpstreamDns.IsUpstreamQueryAvailable();
+}
+
+extern "C" void otPlatDnsStartUpstreamQuery(otInstance             *aInstance,
+                                            otPlatDnsUpstreamQuery *aTxn,
+                                            const otMessage        *aQuery)
+{
+    AsNode(aInstance).mUpstreamDns.StartUpstreamQuery(AsCoreType(aTxn), AsCoreType(aQuery));
+}
+
+extern "C" void otPlatDnsCancelUpstreamQuery(otInstance *aInstance, otPlatDnsUpstreamQuery *aTxn)
+{
+    AsNode(aInstance).mUpstreamDns.CancelUpstreamQuery(AsCoreType(aTxn));
+}
+
+} // namespace Nexus
+} // namespace ot
diff --git a/tests/nexus/platform/nexus_dns.hpp b/tests/nexus/platform/nexus_dns.hpp
new file mode 100644
index 0000000..93a30bf
--- /dev/null
+++ b/tests/nexus/platform/nexus_dns.hpp
@@ -0,0 +1,87 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#ifndef OT_NEXUS_PLATFORM_NEXUS_DNS_HPP_
+#define OT_NEXUS_PLATFORM_NEXUS_DNS_HPP_
+
+#include <openthread/platform/dns.h>
+#include "common/heap_allocatable.hpp"
+#include "common/linked_list.hpp"
+#include "common/locator.hpp"
+#include "common/owning_list.hpp"
+#include "instance/instance.hpp"
+#include "net/dnssd_server.hpp"
+#include "net/ip6_address.hpp"
+
+namespace ot {
+namespace Nexus {
+
+class Node;
+
+class UpstreamDns : public InstanceLocator
+{
+public:
+    static constexpr uint16_t kDnsPort = 53;
+
+    typedef Dns::ServiceDiscovery::Server::UpstreamQueryTransaction UpstreamQueryTransaction;
+
+    struct PendingQuery : public Heap::Allocatable<PendingQuery>, public LinkedListEntry<PendingQuery>
+    {
+        bool Matches(const UpstreamQueryTransaction *aTxn) const { return mTxn == aTxn; }
+        bool Matches(uint16_t aMessageId, const Ip6::Address &aSrcAddr) const
+        {
+            return (mMessageId == aMessageId) && (mServerAddress == aSrcAddr);
+        }
+
+        UpstreamQueryTransaction *mTxn;
+        uint16_t                  mMessageId;
+        Ip6::Address              mServerAddress;
+        PendingQuery             *mNext;
+    };
+
+    explicit UpstreamDns(Instance &aInstance);
+    ~UpstreamDns(void) { Reset(); }
+
+    void StartUpstreamQuery(UpstreamQueryTransaction &aTxn, const Message &aQuery);
+    void CancelUpstreamQuery(UpstreamQueryTransaction &aTxn);
+    bool HandleUpstreamDnsResponse(const Ip6::Address &aSrcAddress, Message &aMessage);
+    bool IsUpstreamQueryAvailable(void) const;
+    void Reset(void);
+
+private:
+    bool     IsGua(const Ip6::Prefix &aPrefix) const;
+    uint16_t CountGuaPrefixes(const Ip6::Address &aRouterAddress) const;
+    Error    SelectUpstreamDnsServer(Ip6::Address &aSelectedAddress) const;
+
+    OwningList<PendingQuery> mPendingQueries;
+};
+
+} // namespace Nexus
+} // namespace ot
+
+#endif // OT_NEXUS_PLATFORM_NEXUS_DNS_HPP_
diff --git a/tests/nexus/platform/nexus_grpc.cpp b/tests/nexus/platform/nexus_grpc.cpp
new file mode 100644
index 0000000..ddceeda
--- /dev/null
+++ b/tests/nexus/platform/nexus_grpc.cpp
@@ -0,0 +1,596 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "nexus_grpc.hpp"
+#include "nexus_sim.hpp"
+
+#if OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+
+#include <cerrno>
+#include <chrono>
+#include <cstring>
+#include <mutex>
+#include <string>
+#include <thread>
+#include <vector>
+
+#include <grpcpp/grpcpp.h>
+#include <unistd.h>
+
+#include "nexus_logging.hpp"
+#include "nexus_node.hpp"
+#include "nexus_radio.hpp"
+#include "nexus_radio_model.hpp"
+#include "simulation.grpc.pb.h"
+#include "common/clearable.hpp"
+#include "common/code_utils.hpp"
+#include "mac/mac_frame.hpp"
+#include "thread/neighbor_table.hpp"
+
+using namespace std::chrono_literals;
+
+namespace ot {
+namespace Nexus {
+
+void GrpcServer::InitEvent(nexus::SimulationEvent &aEvent) { aEvent.set_timestamp_us(Core::Get().GetNowMicro64()); }
+
+nexus::NodeRole GrpcServer::GetEnhancedRole(Node *aNode)
+{
+    const Mle::Mle &mle  = aNode->Get<Mle::Mle>();
+    Mle::DeviceRole role = mle.GetRole();
+
+    switch (role)
+    {
+    case Mle::kRoleDisabled:
+        return nexus::ROLE_DISABLED;
+    case Mle::kRoleDetached:
+        return nexus::ROLE_DETACHED;
+    case Mle::kRoleLeader:
+        return nexus::ROLE_LEADER;
+    case Mle::kRoleRouter:
+        return nexus::ROLE_ROUTER;
+    case Mle::kRoleChild:
+        if (mle.IsFullThreadDevice())
+        {
+            return mle.IsRouterRoleAllowed() ? nexus::ROLE_REED : nexus::ROLE_FED;
+        }
+
+        return mle.IsRxOnWhenIdle() ? nexus::ROLE_MED : nexus::ROLE_SED;
+
+    default:
+        break;
+    }
+
+    return nexus::ROLE_UNKNOWN;
+}
+
+class GrpcServer::ServiceImpl final : public nexus::NexusService::Service
+{
+public:
+    ServiceImpl(GrpcServer *aServer)
+        : mServerPtr(aServer)
+    {
+    }
+
+    grpc::Status StreamEvents(grpc::ServerContext *aContext,
+                              const nexus::StreamRequest * /* aRequest */,
+                              grpc::ServerWriter<nexus::SimulationEvent> *aWriter) override
+    {
+        static constexpr uint32_t kPopEventTimeoutMs = 1000;
+        grpc::Status              status             = grpc::Status::OK;
+        auto                     *queue              = mServerPtr->AddEventQueue();
+
+        mServerPtr->ClientConnected();
+        aWriter->SendInitialMetadata();
+        mServerPtr->DumpState(queue);
+
+        while (!aContext->IsCancelled())
+        {
+            nexus::SimulationEvent event;
+
+            if (mServerPtr->PopEvent(queue, event, kPopEventTimeoutMs))
+            {
+                if (!aWriter->Write(event))
+                {
+                    break;
+                }
+            }
+            else
+            {
+                if (!mServerPtr->IsRunning())
+                {
+                    break;
+                }
+
+                {
+                    std::lock_guard<Simulation> lock(Simulation::Get());
+                    mServerPtr->InitEvent(event);
+                }
+                event.mutable_heartbeat();
+
+                if (!aWriter->Write(event))
+                {
+                    break;
+                }
+            }
+        }
+
+        mServerPtr->RemoveEventQueue(queue);
+        mServerPtr->ClientDisconnected();
+        return status;
+    }
+
+    grpc::Status Reset(grpc::ServerContext * /* aContext */,
+                       const nexus::ResetRequest * /* aRequest */,
+                       nexus::ResetResponse * /* aResponse */) override
+    {
+        Log("Reset requested. Signaling simulation loop to restart...");
+
+        Simulation::Get().RequestRestart();
+
+        return grpc::Status::OK;
+    }
+
+    grpc::Status SetSpeed(grpc::ServerContext * /* aContext */,
+                          const nexus::SetSpeedRequest *aRequest,
+                          nexus::SetSpeedResponse * /* aResponse */) override
+    {
+        float speedFactor = aRequest->speed_factor();
+
+        if (speedFactor < 0.0f)
+        {
+            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "Speed factor cannot be negative");
+        }
+
+        {
+            std::lock_guard<Simulation> lock(Simulation::Get());
+            Simulation::Get().SetSpeedFactor(speedFactor);
+        }
+
+        return grpc::Status::OK;
+    }
+
+    grpc::Status SetNodeState(grpc::ServerContext * /* aContext */,
+                              const nexus::SetNodeStateRequest *aRequest,
+                              nexus::SetNodeStateResponse * /* aResponse */) override
+    {
+        std::lock_guard<Simulation> lock(Simulation::Get());
+
+        Log("SetNodeState: Node %d -> %s", aRequest->node_id(), aRequest->enabled() ? "ENABLE" : "DISABLE");
+        Core::Get().SetNodeEnabled(aRequest->node_id(), aRequest->enabled());
+
+        return grpc::Status::OK;
+    }
+
+    grpc::Status SetNodePosition(grpc::ServerContext * /* aContext */,
+                                 const nexus::SetNodePositionRequest *aRequest,
+                                 nexus::SetNodePositionResponse * /* aResponse */) override
+    {
+        grpc::Status                status = grpc::Status::OK;
+        std::lock_guard<Simulation> lock(Simulation::Get());
+
+        Node *node = Core::Get().FindNodeById(aRequest->node_id());
+
+        VerifyOrExit(node != nullptr, status = grpc::Status(grpc::StatusCode::NOT_FOUND, "Node not found"));
+
+        node->SetPosition(aRequest->x(), aRequest->y());
+        mServerPtr->OnNodeStateChanged(node);
+
+    exit:
+        return status;
+    }
+
+    grpc::Status CreateNode(grpc::ServerContext * /* aContext */,
+                            const nexus::CreateNodeRequest *aRequest,
+                            nexus::CreateNodeResponse      *aResponse) override
+    {
+        std::lock_guard<Simulation> lock(Simulation::Get());
+
+        Log("CreateNode: x=%f, y=%f", aRequest->x(), aRequest->y());
+        Node &node = Core::Get().CreateNode();
+        node.SetPosition(aRequest->x(), aRequest->y());
+        mServerPtr->OnNodeStateChanged(&node);
+        aResponse->set_node_id(node.GetId());
+        Log("CreateNode: done, id=%u", node.GetId());
+
+        return grpc::Status::OK;
+    }
+
+    grpc::Status FormNetwork(grpc::ServerContext * /* aContext */,
+                             const nexus::FormNetworkRequest *aRequest,
+                             nexus::FormNetworkResponse * /* aResponse */) override
+    {
+        grpc::Status                status = grpc::Status::OK;
+        std::lock_guard<Simulation> lock(Simulation::Get());
+
+        Node *node = Core::Get().FindNodeById(aRequest->node_id());
+
+        VerifyOrExit(node != nullptr, status = grpc::Status(grpc::StatusCode::NOT_FOUND, "Node not found"));
+
+        node->Form();
+
+    exit:
+        return status;
+    }
+
+    grpc::Status JoinNetwork(grpc::ServerContext * /* aContext */,
+                             const nexus::JoinNetworkRequest *aRequest,
+                             nexus::JoinNetworkResponse * /* aResponse */) override
+    {
+        grpc::Status                status = grpc::Status::OK;
+        std::lock_guard<Simulation> lock(Simulation::Get());
+
+        Node *joiner = Core::Get().FindNodeById(aRequest->node_id());
+        Node *target = Core::Get().FindNodeById(aRequest->target_node_id());
+
+        VerifyOrExit(joiner != nullptr && target != nullptr,
+                     status = grpc::Status(grpc::StatusCode::NOT_FOUND, "Node not found"));
+
+        {
+            static constexpr Node::JoinMode kJoinModes[] = {
+                Node::kAsFtd, // JOIN_MODE_FTD
+                Node::kAsMed, // JOIN_MODE_MED
+                Node::kAsSed, // JOIN_MODE_SED
+                Node::kAsFed, // JOIN_MODE_FED
+            };
+
+            VerifyOrExit(static_cast<size_t>(aRequest->mode()) < GetArrayLength(kJoinModes),
+                         status = grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "Invalid join mode"));
+
+            Node::JoinMode mode = kJoinModes[aRequest->mode()];
+
+            joiner->Join(*target, mode);
+        }
+
+    exit:
+        return status;
+    }
+
+    grpc::Status GetRadioParameters(grpc::ServerContext * /* aContext */,
+                                    const nexus::GetRadioParametersRequest * /* aRequest */,
+                                    nexus::GetRadioParametersResponse *aResponse) override
+    {
+        aResponse->set_path_loss_constant(RadioModel::kPathLossConstant);
+        aResponse->set_path_loss_exponent(RadioModel::kPathLossExponent);
+        aResponse->set_radio_sensitivity(Radio::kRadioSensitivity);
+        aResponse->set_mle_link_request_margin_min(OPENTHREAD_CONFIG_MLE_LINK_REQUEST_MARGIN_MIN);
+
+        return grpc::Status::OK;
+    }
+
+private:
+    GrpcServer *mServerPtr;
+};
+
+GrpcServer::GrpcServer(const char *aExecutablePath)
+    : mRunning(true)
+    , mClientCount(0)
+    , mExecutablePath(aExecutablePath != nullptr ? aExecutablePath : "")
+{
+    mService = std::make_unique<ServiceImpl>(this);
+
+    grpc::ServerBuilder builder;
+    builder.AddListeningPort(kDefaultGrpcServerAddress, grpc::InsecureServerCredentials());
+    builder.RegisterService(mService.get());
+
+    mServer = builder.BuildAndStart();
+
+    if (mServer)
+    {
+        Log("gRPC Server started at %s", kDefaultGrpcServerAddress);
+        mThread = std::thread([this]() { mServer->Wait(); });
+        {
+            std::lock_guard<Simulation> lock(Simulation::Get());
+            Core::Get().AddObserver(*this);
+        }
+    }
+    else
+    {
+        Log("Failed to start gRPC server!");
+    }
+}
+
+GrpcServer::~GrpcServer()
+{
+    {
+        std::lock_guard<Simulation> lock(Simulation::Get());
+        Core::Get().RemoveObserver(*this);
+    }
+    mRunning = false;
+    mCv.notify_all();
+    if (mServer)
+    {
+        mServer->Shutdown();
+    }
+    if (mThread.joinable())
+    {
+        mThread.join();
+    }
+}
+
+void GrpcServer::ClientConnected(void)
+{
+    mClientCount++;
+    Log("UI connected!");
+}
+
+void GrpcServer::ClientDisconnected(void)
+{
+    if (mClientCount.fetch_sub(1) == 1)
+    {
+        Log("All UI clients disconnected.");
+    }
+}
+
+void GrpcServer::PushEvent(const nexus::SimulationEvent &aEvent, std::queue<nexus::SimulationEvent> *aTargetQueue)
+{
+    std::lock_guard<std::mutex> lock(mMutex);
+
+    if (aTargetQueue != nullptr)
+    {
+        if (aTargetQueue->size() >= kMaxQueueSize)
+        {
+            aTargetQueue->pop();
+        }
+        aTargetQueue->push(aEvent);
+    }
+    else
+    {
+        for (auto &queue : mClientQueues)
+        {
+            if (queue.size() >= kMaxQueueSize)
+            {
+                queue.pop();
+            }
+            queue.push(aEvent);
+        }
+    }
+    mCv.notify_all();
+}
+
+bool GrpcServer::PopEvent(std::queue<nexus::SimulationEvent> *aQueue,
+                          nexus::SimulationEvent             &aEvent,
+                          uint32_t                            aTimeoutMs)
+{
+    bool                         found = false;
+    std::unique_lock<std::mutex> lock(mMutex);
+
+    VerifyOrExit(mCv.wait_for(lock, std::chrono::milliseconds(aTimeoutMs),
+                              [this, aQueue] { return !aQueue->empty() || !mRunning; }));
+
+    VerifyOrExit(mRunning || !aQueue->empty());
+
+    aEvent = aQueue->front();
+    aQueue->pop();
+    found = true;
+
+exit:
+    return found;
+}
+
+std::queue<nexus::SimulationEvent> *GrpcServer::AddEventQueue(void)
+{
+    std::lock_guard<std::mutex> lock(mMutex);
+    mClientQueues.emplace_back();
+    return &mClientQueues.back();
+}
+
+void GrpcServer::RemoveEventQueue(std::queue<nexus::SimulationEvent> *aQueue)
+{
+    std::lock_guard<std::mutex> lock(mMutex);
+
+    for (auto it = mClientQueues.begin(); it != mClientQueues.end(); ++it)
+    {
+        if (&(*it) == aQueue)
+        {
+            mClientQueues.erase(it);
+            break;
+        }
+    }
+}
+
+void GrpcServer::ClearQueue(void)
+{
+    std::lock_guard<std::mutex> lock(mMutex);
+
+    for (auto &queue : mClientQueues)
+    {
+        std::queue<nexus::SimulationEvent> empty;
+        std::swap(queue, empty);
+    }
+}
+
+GrpcServer::PacketInfo GrpcServer::GetPacketInfo(const Mac::Frame &aFrame)
+{
+    PacketInfo info;
+
+    if (aFrame.IsAck())
+    {
+        info.mProtocol = "IEEE 802.15.4 ACK";
+        info.mSummary  = "ACK";
+    }
+    else if (aFrame.GetType() == Mac::Frame::kTypeData)
+    {
+        info.mProtocol = "IEEE 802.15.4 Data";
+        info.mSummary  = "Data";
+    }
+    else if (aFrame.IsMacCommand())
+    {
+        info.mProtocol = "IEEE 802.15.4 Command";
+        info.mSummary  = "Command";
+    }
+    else
+    {
+        info.mProtocol = "IEEE 802.15.4";
+        info.mSummary  = "Other";
+    }
+
+    if (aFrame.IsSequencePresent())
+    {
+        char buf[32];
+
+        snprintf(buf, sizeof(buf), " (seq=%u)", aFrame.GetSequence());
+        info.mSummary += buf;
+    }
+
+    return info;
+}
+
+void GrpcServer::PushPacketEvent(uint32_t                            aSenderId,
+                                 uint32_t                            aDestinationId,
+                                 const uint8_t                      *aData,
+                                 uint16_t                            aLen,
+                                 std::queue<nexus::SimulationEvent> *aTargetQueue)
+{
+    nexus::SimulationEvent event;
+    otRadioFrame           radioFrame;
+
+    InitEvent(event);
+
+    auto *packetEvent = event.mutable_packet_captured();
+    packetEvent->set_source_node_id(aSenderId);
+    packetEvent->set_destination_node_id(aDestinationId);
+    packetEvent->set_raw_payload(aData, aLen);
+
+    ClearAllBytes(radioFrame);
+    radioFrame.mPsdu   = const_cast<uint8_t *>(aData);
+    radioFrame.mLength = aLen;
+
+    {
+        const Mac::Frame &frame = static_cast<const Mac::Frame &>(radioFrame);
+        PacketInfo        info  = GetPacketInfo(frame);
+
+        packetEvent->set_protocol(info.mProtocol);
+        packetEvent->set_summary(info.mSummary);
+    }
+
+    PushEvent(event, aTargetQueue);
+}
+
+void GrpcServer::PushNodeEvent(Node *aNode, std::queue<nexus::SimulationEvent> *aTargetQueue)
+{
+    nexus::SimulationEvent event;
+    InitEvent(event);
+
+    auto *nodeEvent = event.mutable_node_update();
+    nodeEvent->set_node_id(aNode->GetId());
+    nodeEvent->set_role(GetEnhancedRole(aNode));
+    nodeEvent->set_rloc16(aNode->Get<Mle::Mle>().GetRloc16());
+    nodeEvent->set_x(aNode->GetPositionX());
+    nodeEvent->set_y(aNode->GetPositionY());
+
+    Log("PushNodeEvent: id=%u, role=%d, x=%f, y=%f", nodeEvent->node_id(), static_cast<int>(nodeEvent->role()),
+        nodeEvent->x(), nodeEvent->y());
+
+    PushEvent(event, aTargetQueue);
+}
+
+void GrpcServer::PushLinkUpdate(uint32_t                            aSrcId,
+                                uint32_t                            aDstId,
+                                bool                                aIsActive,
+                                std::queue<nexus::SimulationEvent> *aTargetQueue)
+{
+    Log("PushLinkUpdate: src=%u, dst=%u, active=%d", aSrcId, aDstId, aIsActive);
+
+    nexus::SimulationEvent event;
+    InitEvent(event);
+
+    auto *linkEvent = event.mutable_link_update();
+    linkEvent->set_source_node_id(aSrcId);
+    linkEvent->set_destination_node_id(aDstId);
+    linkEvent->set_is_active(aIsActive);
+
+    PushEvent(event, aTargetQueue);
+}
+
+void GrpcServer::OnHeartbeat(uint64_t aTimestampUs)
+{
+    nexus::SimulationEvent event;
+    event.set_timestamp_us(aTimestampUs);
+    event.mutable_heartbeat();
+    PushEvent(event);
+}
+
+void GrpcServer::DumpState(std::queue<nexus::SimulationEvent> *aQueue)
+{
+    std::lock_guard<Simulation> lock(Simulation::Get());
+
+    {
+        nexus::SimulationEvent event;
+        event.set_timestamp_us(Core::Get().GetNowMicro64());
+        event.mutable_heartbeat();
+        PushEvent(event, aQueue);
+    }
+
+    Log("DumpState: Starting sync...");
+
+    // 1. Sync Nodes
+    for (Node &node : Core::Get().GetNodes())
+    {
+        PushNodeEvent(&node, aQueue);
+    }
+
+    // 2. Sync Links
+    for (Node &node : Core::Get().GetNodes())
+    {
+        otNeighborInfoIterator iterator = OT_NEIGHBOR_INFO_ITERATOR_INIT;
+        Neighbor::Info         neighborInfo;
+
+        while (node.Get<NeighborTable>().GetNextNeighborInfo(iterator, neighborInfo) == kErrorNone)
+        {
+            Node *neighborNode =
+                Core::Get().GetNodes().FindMatching(static_cast<const Mac::ExtAddress &>(neighborInfo.mExtAddress));
+
+            if (neighborNode != nullptr)
+            {
+                PushLinkUpdate(node.GetId(), neighborNode->GetId(), true, aQueue);
+            }
+        }
+    }
+}
+
+void GrpcServer::OnNodeStateChanged(Node *aNode) { PushNodeEvent(aNode); }
+
+void GrpcServer::OnLinkUpdate(uint32_t aSrcId, uint32_t aDstId, bool aIsActive)
+{
+    PushLinkUpdate(aSrcId, aDstId, aIsActive);
+}
+
+void GrpcServer::OnPacketEvent(uint32_t aSenderId, uint32_t aDestinationId, const uint8_t *aData, uint16_t aLen)
+{
+    PushPacketEvent(aSenderId, aDestinationId, aData, aLen);
+}
+
+void GrpcServer::OnClearEvents(void) { ClearQueue(); }
+
+bool GrpcServer::IsConnected(void) const { return mClientCount > 0; }
+
+} // namespace Nexus
+} // namespace ot
+
+#endif // OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
diff --git a/tests/nexus/platform/nexus_grpc.hpp b/tests/nexus/platform/nexus_grpc.hpp
new file mode 100644
index 0000000..d466ca0
--- /dev/null
+++ b/tests/nexus/platform/nexus_grpc.hpp
@@ -0,0 +1,218 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#ifndef OT_NEXUS_PLATFORM_NEXUS_GRPC_HPP_
+#define OT_NEXUS_PLATFORM_NEXUS_GRPC_HPP_
+
+#include "openthread-core-config.h"
+
+#if OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+
+#include <atomic>
+#include <condition_variable>
+#include <list>
+#include <memory>
+#include <mutex>
+#include <queue>
+#include <string>
+#include <thread>
+
+#include "nexus_core.hpp"
+#include "simulation.pb.h"
+
+namespace grpc {
+class Server;
+} // namespace grpc
+
+namespace ot {
+namespace Nexus {
+
+constexpr char kDefaultGrpcServerAddress[] = "127.0.0.1:50052";
+
+class GrpcServer : public Observer
+{
+public:
+    /**
+     * Initializes the gRPC server.
+     *
+     * @param[in] aExecutablePath  The path to the executable (used for Reset RPC).
+     */
+    GrpcServer(const char *aExecutablePath = nullptr);
+
+    /**
+     * Destructor for the gRPC server.
+     */
+    ~GrpcServer() override;
+
+    // Observer implementation
+    void OnNodeStateChanged(Node *aNode) override;
+    void OnLinkUpdate(uint32_t aSrcId, uint32_t aDstId, bool aIsActive) override;
+    void OnPacketEvent(uint32_t aSenderId, uint32_t aDestinationId, const uint8_t *aData, uint16_t aLen) override;
+    void OnClearEvents(void) override;
+    void OnHeartbeat(uint64_t aTimestampUs) override;
+    void DumpState(void) override { DumpState(nullptr); }
+    void DumpState(std::queue<nexus::SimulationEvent> *aQueue);
+    bool IsConnected(void) const override;
+
+    /**
+     * Pushes a simulation event to the connected clients.
+     *
+     * @param[in] aEvent        The event to push.
+     * @param[in] aTargetQueue  An optional pointer to a specific client's event queue.
+     */
+    void PushEvent(const nexus::SimulationEvent &aEvent, std::queue<nexus::SimulationEvent> *aTargetQueue = nullptr);
+
+    /**
+     * Gets the executable path.
+     *
+     * @returns The executable path.
+     */
+    const std::string &GetExecutablePath(void) const { return mExecutablePath; }
+
+    /**
+     * Indicates whether or not the server is running.
+     *
+     * @retval TRUE   If the server is running.
+     * @retval FALSE  If the server is not running.
+     */
+    bool IsRunning(void) const { return mRunning; }
+
+private:
+    static constexpr size_t kMaxQueueSize = 1000;
+
+    class ServiceImpl;
+
+    struct PacketInfo
+    {
+        const char *mProtocol;
+        std::string mSummary;
+    };
+
+    static nexus::NodeRole GetEnhancedRole(Node *aNode);
+    static PacketInfo      GetPacketInfo(const Mac::Frame &aFrame);
+
+    /**
+     * Handles a new client connection.
+     */
+    void ClientConnected(void);
+
+    /**
+     * Handles a client disconnection.
+     */
+    void ClientDisconnected(void);
+
+    /**
+     * Pops an event from the event queue for a specific client.
+     *
+     * @param[in]  aQueue      A pointer to the client's event queue.
+     * @param[out] aEvent      A reference to store the popped event.
+     * @param[in]  aTimeoutMs  The timeout in milliseconds to wait for an event.
+     *
+     * @returns TRUE if an event was popped, FALSE otherwise.
+     */
+    bool PopEvent(std::queue<nexus::SimulationEvent> *aQueue, nexus::SimulationEvent &aEvent, uint32_t aTimeoutMs);
+
+    /**
+     * Adds an event queue for a new client.
+     *
+     * @returns A pointer to the newly created event queue.
+     */
+    std::queue<nexus::SimulationEvent> *AddEventQueue(void);
+
+    /**
+     * Removes an event queue for a disconnected client.
+     *
+     * @param[in] aQueue  A pointer to the event queue to remove.
+     */
+    void RemoveEventQueue(std::queue<nexus::SimulationEvent> *aQueue);
+
+    /**
+     * Initializes a simulation event with common fields (like timestamp).
+     *
+     * @param[out] aEvent  A reference to the event to initialize.
+     */
+    void InitEvent(nexus::SimulationEvent &aEvent);
+
+    /**
+     * Pushes a node update event.
+     *
+     * @param[in] aNode         The node that was updated.
+     * @param[in] aTargetQueue  An optional pointer to a specific client's event queue.
+     */
+    void PushNodeEvent(Node *aNode, std::queue<nexus::SimulationEvent> *aTargetQueue = nullptr);
+
+    /**
+     * Pushes a link update event.
+     *
+     * @param[in] aSrcId        The source node ID.
+     * @param[in] aDstId        The destination node ID.
+     * @param[in] aIsActive     TRUE if the link is active, FALSE otherwise.
+     * @param[in] aTargetQueue  An optional pointer to a specific client's event queue.
+     */
+    void PushLinkUpdate(uint32_t                            aSrcId,
+                        uint32_t                            aDstId,
+                        bool                                aIsActive,
+                        std::queue<nexus::SimulationEvent> *aTargetQueue = nullptr);
+
+    /**
+     * Pushes a packet capture event.
+     *
+     * @param[in] aSenderId       The sender node ID.
+     * @param[in] aDestinationId  The destination node ID.
+     * @param[in] aData           A pointer to the packet data.
+     * @param[in] aLen            The length of the packet data.
+     * @param[in] aTargetQueue    An optional pointer to a specific client's event queue.
+     */
+    void PushPacketEvent(uint32_t                            aSenderId,
+                         uint32_t                            aDestinationId,
+                         const uint8_t                      *aData,
+                         uint16_t                            aLen,
+                         std::queue<nexus::SimulationEvent> *aTargetQueue = nullptr);
+
+    /**
+     * Clears the event queue.
+     */
+    void ClearQueue(void);
+
+    std::unique_ptr<ServiceImpl>                  mService;
+    std::unique_ptr<grpc::Server>                 mServer;
+    std::thread                                   mThread;
+    std::mutex                                    mMutex;
+    std::condition_variable                       mCv;
+    std::list<std::queue<nexus::SimulationEvent>> mClientQueues;
+    std::atomic<bool>                             mRunning;
+    std::atomic<int>                              mClientCount;
+    std::string                                   mExecutablePath;
+};
+
+} // namespace Nexus
+} // namespace ot
+
+#endif // OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+
+#endif // OT_NEXUS_PLATFORM_NEXUS_GRPC_HPP_
diff --git a/tests/nexus/platform/nexus_infra_if.cpp b/tests/nexus/platform/nexus_infra_if.cpp
index cabd8a2..413ffe6 100644
--- a/tests/nexus/platform/nexus_infra_if.cpp
+++ b/tests/nexus/platform/nexus_infra_if.cpp
@@ -28,41 +28,36 @@
 
 #include "nexus_infra_if.hpp"
 
-#include "nexus_core.hpp"
+#include <openthread/platform/infra_if.h>
+
 #include "nexus_node.hpp"
 
 namespace ot {
 namespace Nexus {
 
-InfraIf::InfraIf(void)
-    : mNode(nullptr)
-    , mNodeId(0)
+InfraIf::InfraIf(Instance &aInstance)
+    : InstanceLocator(aInstance)
     , mIfIndex(0)
+    , mUdpHook(nullptr)
+    , mHasRioPrefix(false)
+    , mDhcp6PdListening(false)
+    , mIsDnsServer(false)
+    , mRaTimer(aInstance)
 {
 }
 
-void InfraIf::Init(Node &aNode)
+void InfraIf::AfterInit(void)
 {
     Ip6::Address             address;
     LinkLayerAddress         mac;
     Ip6::InterfaceIdentifier iid;
 
     mIfIndex = 1;
-    mNode    = &aNode;
-    mNodeId  = aNode.GetId();
 
     GetLinkLayerAddress(mac);
+    SuccessOrQuit(mac.ConvertToIid(iid));
 
-    iid.mFields.m8[0] = mac.mAddress[0] ^ 0x02;
-    iid.mFields.m8[1] = mac.mAddress[1];
-    iid.mFields.m8[2] = mac.mAddress[2];
-    iid.mFields.m8[3] = 0xff;
-    iid.mFields.m8[4] = 0xfe;
-    iid.mFields.m8[5] = mac.mAddress[3];
-    iid.mFields.m8[6] = mac.mAddress[4];
-    iid.mFields.m8[7] = mac.mAddress[5];
-
-    address.SetToLinkLocalAddress(iid);
+    address.InitAsLinkLocalAddress(iid);
 
     AddAddress(address);
 }
@@ -74,7 +69,7 @@
     VerifyOrExit(!HasAddress(aAddress));
 
     SuccessOrQuit(mAddresses.PushBack(aAddress));
-    mNode->mMdns.HandleHostAddressEvent(aAddress, /* aAdded */ true);
+    Get<Mdns>().HandleHostAddressEvent(aAddress, /* aAdded */ true);
 
 exit:
     return;
@@ -88,7 +83,7 @@
         {
             mAddresses[index] = *mAddresses.Back();
             mAddresses.PopBack();
-            mNode->mMdns.HandleHostAddressEvent(aAddress, /* aAdded */ false);
+            Get<Mdns>().HandleHostAddressEvent(aAddress, /* aAdded */ false);
             break;
         }
     }
@@ -97,7 +92,7 @@
 void InfraIf::RemoveAllAddresses(void)
 {
     mAddresses.Clear();
-    mNode->mMdns.HandleHostAddressRemoveAll();
+    Get<Mdns>().HandleHostAddressRemoveAll();
 }
 
 const Ip6::Address *InfraIf::FindAddress(const char *aPrefix) const
@@ -128,9 +123,37 @@
     return *matchedAddress;
 }
 
+const Ip6::Address &InfraIf::SelectSourceAddress(const Ip6::Address &aDestination) const
+{
+    const Ip6::Address *bestMatch = &GetLinkLocalAddress();
+
+    if (aDestination.IsLinkLocalUnicast())
+    {
+        // For link-local destinations, we always use the link-local address.
+        ExitNow();
+    }
+
+    for (const Ip6::Address &address : mAddresses)
+    {
+        if (address.IsMulticast())
+        {
+            continue;
+        }
+
+        if (address.GetScope() == aDestination.GetScope())
+        {
+            bestMatch = &address;
+            break;
+        }
+    }
+
+exit:
+    return *bestMatch;
+}
+
 void InfraIf::SendIcmp6Nd(const Ip6::Address &aDestAddress, const uint8_t *aBuffer, uint16_t aBufferLength)
 {
-    Message    *message = GetNode().Get<MessagePool>().Allocate(Message::kTypeIp6);
+    Message    *message = Get<MessagePool>().Allocate(Message::kTypeIp6);
     Ip6::Header ip6Header;
 
     VerifyOrQuit(message != nullptr);
@@ -152,19 +175,83 @@
     mPendingTxQueue.Enqueue(*message);
 }
 
+void InfraIf::SendRouterAdvertisement(const Ip6::Address &aDestination,
+                                      const Ip6::Prefix  *aPioPrefix,
+                                      const Ip6::Prefix  *aRioPrefix)
+{
+    Ip6::Nd::RouterAdvert::TxMessage ra;
+    Ip6::Nd::RouterAdvert::Header    header;
+    Ip6::Nd::Icmp6Packet             packet;
+
+    header.SetToDefault();
+    header.SetRouterLifetime(1800);
+    SuccessOrQuit(ra.Append(header));
+
+    if (aPioPrefix != nullptr)
+    {
+        SuccessOrQuit(ra.AppendPrefixInfoOption(*aPioPrefix, 1800, 1800,
+                                                Ip6::Nd::PrefixInfoOption::kOnLinkFlag |
+                                                    Ip6::Nd::PrefixInfoOption::kAutoConfigFlag));
+    }
+
+    if (aRioPrefix != nullptr)
+    {
+        SuccessOrQuit(ra.AppendRouteInfoOption(*aRioPrefix, 1800, NetworkData::kRoutePreferenceMedium));
+    }
+
+    // ETH_2 (Node 4) acts as DNS server
+    if (mIsDnsServer)
+    {
+        const Ip6::Address dnsServerAddr = GetLinkLocalAddress();
+        SuccessOrQuit(ra.AppendRecursiveDnsServerOption(&dnsServerAddr, 1, 1800));
+    }
+
+    ra.GetAsPacket(packet);
+
+    SendIcmp6Nd(aDestination, packet.GetBytes(), packet.GetLength());
+}
+
+void InfraIf::StartRouterAdvertisement(const Ip6::Prefix &aPioPrefix, const Ip6::Prefix *aRioPrefix)
+{
+    mPioPrefix = aPioPrefix;
+    if (aRioPrefix != nullptr)
+    {
+        mRioPrefix    = *aRioPrefix;
+        mHasRioPrefix = true;
+    }
+    else
+    {
+        mHasRioPrefix = false;
+    }
+
+    // Trigger initial RA immediately
+    SendPeriodicRouterAdvertisement();
+}
+
+void InfraIf::StopRouterAdvertisement(void) { mRaTimer.Stop(); }
+
+void InfraIf::HandleRaTimer(void) { SendPeriodicRouterAdvertisement(); }
+
+void InfraIf::SendPeriodicRouterAdvertisement(void)
+{
+    const uint32_t kRaInterval = 4000; // 4 seconds in milliseconds
+
+    SendRouterAdvertisement(Ip6::Address::GetLinkLocalAllNodesMulticast(), &mPioPrefix,
+                            mHasRioPrefix ? &mRioPrefix : nullptr);
+    mRaTimer.Start(kRaInterval);
+}
+
 void InfraIf::ProcessIcmp6Nd(const Ip6::Address &aSrcAddress, const uint8_t *aBuffer, uint16_t aBufferLength)
 {
     Ip6::Nd::Icmp6Packet packet;
 
-    OT_UNUSED_VARIABLE(aSrcAddress);
-
     packet.Init(aBuffer, aBufferLength);
 
-    VerifyOrExit(packet.GetLength() >= sizeof(Ip6::Icmp::Header));
+    VerifyOrExit(packet.GetLength() >= sizeof(Ip6::Icmp6Header));
 
-    switch (reinterpret_cast<const Ip6::Icmp::Header *>(packet.GetBytes())->GetType())
+    switch (reinterpret_cast<const Ip6::Icmp6Header *>(packet.GetBytes())->GetType())
     {
-    case Ip6::Icmp::Header::kTypeRouterAdvert:
+    case Ip6::Icmp6Header::kTypeRouterAdvert:
     {
         Ip6::Nd::RouterAdvert::RxMessage raMessage(packet);
 
@@ -180,9 +267,12 @@
         break;
     }
 
-    case Ip6::Icmp::Header::kTypeRouterSolicit:
-    case Ip6::Icmp::Header::kTypeNeighborAdvert:
-    case Ip6::Icmp::Header::kTypeNeighborSolicit:
+    case Ip6::Icmp6Header::kTypeRouterSolicit:
+        HandleRouterSolicitation(aSrcAddress);
+        break;
+
+    case Ip6::Icmp6Header::kTypeNeighborAdvert:
+    case Ip6::Icmp6Header::kTypeNeighborSolicit:
         // TODO: Handle other ND messages as needed for the simulation.
         break;
 
@@ -208,50 +298,45 @@
 
     address = AsCoreType(&prefix.mPrefix);
     GetLinkLayerAddress(mac);
-
-    address.mFields.m8[8]  = mac.mAddress[0] ^ 0x02;
-    address.mFields.m8[9]  = mac.mAddress[1];
-    address.mFields.m8[10] = mac.mAddress[2];
-    address.mFields.m8[11] = 0xff;
-    address.mFields.m8[12] = 0xfe;
-    address.mFields.m8[13] = mac.mAddress[3];
-    address.mFields.m8[14] = mac.mAddress[4];
-    address.mFields.m8[15] = mac.mAddress[5];
+    SuccessOrQuit(mac.ConvertToIid(address.GetIid()));
 
     if (aPio.GetValidLifetime() == 0)
     {
         if (HasAddress(address))
         {
             RemoveAddress(address);
-            Log("Node %lu (%s) removed address %s from RA (lifetime 0)", ToUlong(GetNode().GetId()),
-                GetNode().GetName(), address.ToString().AsCString());
+            Log("Node %lu (%s) removed address %s from RA (lifetime 0)", ToUlong(GetInstance().GetId()),
+                Get<Node>().GetName(), address.ToString().AsCString());
         }
         ExitNow();
     }
 
     AddAddress(address);
-    Log("Node %lu (%s) auto-configured address %s from RA", ToUlong(GetNode().GetId()), GetNode().GetName(),
+    Log("Node %lu (%s) auto-configured address %s from RA", ToUlong(GetInstance().GetId()), Get<Node>().GetName(),
         address.ToString().AsCString());
 
 exit:
     return;
 }
 
-void InfraIf::SendIp6(const Ip6::Address &aSrcAddress,
-                      const Ip6::Address &aDestAddress,
-                      const uint8_t      *aBuffer,
-                      uint16_t            aBufferLength)
+void InfraIf::HandleRouterSolicitation(const Ip6::Address &aSrcAddress)
 {
-    Message *message = GetNode().Get<MessagePool>().Allocate(Message::kTypeIp6);
+    if (mRaTimer.IsRunning())
+    {
+        const Ip6::Address &dest =
+            aSrcAddress.IsUnspecified() ? Ip6::Address::GetLinkLocalAllNodesMulticast() : aSrcAddress;
+        SendRouterAdvertisement(dest, &mPioPrefix, mHasRioPrefix ? &mRioPrefix : nullptr);
+    }
+}
 
-    VerifyOrQuit(message != nullptr);
+void InfraIf::SendIp6(const Ip6::Header &aHeader, OwnedPtr<Message> aMessagePtr)
+{
+    VerifyOrQuit(aMessagePtr != nullptr);
 
-    Log("InfraIf::SendIp6 from %s to %s (len:%u)", aSrcAddress.ToString().AsCString(),
-        aDestAddress.ToString().AsCString(), aBufferLength);
+    Log("InfraIf::SendIp6 from %s to %s (len:%u)", aHeader.GetSource().ToString().AsCString(),
+        aHeader.GetDestination().ToString().AsCString(), aMessagePtr->GetLength());
 
-    SuccessOrQuit(message->AppendBytes(aBuffer, aBufferLength));
-
-    mPendingTxQueue.Enqueue(*message);
+    mPendingTxQueue.Enqueue(*aMessagePtr.Release());
 }
 
 void InfraIf::SendEchoRequest(const Ip6::Address &aSrcAddress,
@@ -260,24 +345,24 @@
                               uint16_t            aPayloadSize,
                               uint8_t             aHopLimit)
 {
-    Message          *message;
-    Ip6::Header       ip6Header;
-    Ip6::Icmp::Header icmpHeader;
+    Message         *message;
+    Ip6::Header      ip6Header;
+    Ip6::Icmp6Header icmpHeader;
 
-    message = GetNode().Get<Ip6::Ip6>().NewMessage();
+    message = Get<Ip6::Ip6>().NewMessage();
     VerifyOrQuit(message != nullptr);
 
     ip6Header.Clear();
     ip6Header.InitVersionTrafficClassFlow();
-    ip6Header.SetPayloadLength(sizeof(Ip6::Icmp::Header) + aPayloadSize);
+    ip6Header.SetPayloadLength(sizeof(Ip6::Icmp6Header) + aPayloadSize);
     ip6Header.SetNextHeader(Ip6::kProtoIcmp6);
     ip6Header.SetHopLimit(aHopLimit);
     ip6Header.SetSource(aSrcAddress);
     ip6Header.SetDestination(aDestAddress);
 
     icmpHeader.Clear();
-    icmpHeader.SetType(Ip6::Icmp::Header::kTypeEchoRequest);
-    icmpHeader.SetCode(static_cast<Ip6::Icmp::Header::Code>(0));
+    icmpHeader.SetType(Ip6::Icmp6Header::kTypeEchoRequest);
+    icmpHeader.SetCode(static_cast<Ip6::Icmp6Header::Code>(0));
     icmpHeader.SetId(aIdentifier);
     icmpHeader.SetSequence(0);
 
@@ -295,28 +380,14 @@
                       const Ip6::Address &aDestAddress,
                       uint16_t            aSourcePort,
                       uint16_t            aDestPort,
-                      uint16_t            aPayloadSize)
-{
-    Message *message = GetNode().Get<Ip6::Ip6>().NewMessage();
-
-    VerifyOrQuit(message != nullptr);
-
-    SuccessOrQuit(message->IncreaseLength(aPayloadSize));
-    SendUdp(aSrcAddress, aDestAddress, aSourcePort, aDestPort, *message);
-}
-
-void InfraIf::SendUdp(const Ip6::Address &aSrcAddress,
-                      const Ip6::Address &aDestAddress,
-                      uint16_t            aSourcePort,
-                      uint16_t            aDestPort,
                       Message            &aPayload)
 {
-    Ip6::Header      ip6Header;
-    Ip6::Udp::Header udpHeader;
+    Ip6::Header    ip6Header;
+    Ip6::UdpHeader udpHeader;
 
     ip6Header.Clear();
     ip6Header.InitVersionTrafficClassFlow();
-    ip6Header.SetPayloadLength(sizeof(Ip6::Udp::Header) + aPayload.GetLength());
+    ip6Header.SetPayloadLength(sizeof(Ip6::UdpHeader) + aPayload.GetLength());
     ip6Header.SetNextHeader(Ip6::kProtoUdp);
     ip6Header.SetHopLimit(64);
     ip6Header.SetSource(aSrcAddress);
@@ -324,7 +395,7 @@
 
     udpHeader.SetSourcePort(aSourcePort);
     udpHeader.SetDestinationPort(aDestPort);
-    udpHeader.SetLength(sizeof(Ip6::Udp::Header) + aPayload.GetLength());
+    udpHeader.SetLength(sizeof(Ip6::UdpHeader) + aPayload.GetLength());
     udpHeader.SetChecksum(0);
 
     SuccessOrQuit(aPayload.Prepend(udpHeader));
@@ -338,51 +409,64 @@
         Message *loopbackMessage = aPayload.Clone<kNoReservedHeader>();
 
         VerifyOrQuit(loopbackMessage != nullptr);
-        Receive(GetNode(), *loopbackMessage);
+        Receive(*loopbackMessage);
         loopbackMessage->Free();
     }
 
     mPendingTxQueue.Enqueue(aPayload);
 }
 
-void InfraIf::Receive(Node &aSrcNode, Message &aMessage)
-{
-    Node        &node = GetNode();
-    Ip6::Headers headers;
+void InfraIf::SetDhcp6ListeningEnabled(bool aEnable) { mDhcp6PdListening = aEnable; }
 
-    Core::Get().SetActiveNode(&node);
+void InfraIf::SendDhcp6(Message &aMessage, const Ip6::Address &aDestAddress)
+{
+    SendUdp(SelectSourceAddress(aDestAddress), aDestAddress, Dhcp6::kDhcpClientPort, Dhcp6::kDhcpServerPort, aMessage);
+}
+
+void InfraIf::Receive(Message &aMessage)
+{
+    Ip6::Headers headers;
 
     aMessage.SetOffset(0);
     SuccessOrExit(headers.ParseFrom(aMessage));
 
+    if (mDhcp6PdListening && headers.IsUdp() && headers.GetDestinationPort() == Dhcp6::kDhcpClientPort)
+    {
+        Message *payload = aMessage.Clone<kNoReservedHeader>();
+
+        VerifyOrQuit(payload != nullptr);
+        payload->RemoveHeader(sizeof(Ip6::Header) + sizeof(Ip6::UdpHeader));
+        otPlatInfraIfDhcp6PdClientHandleReceived(&GetInstance(), payload, mIfIndex);
+        ExitNow();
+    }
+
     if (headers.IsIcmp6() && (headers.GetDestinationAddress() == Ip6::Address::GetLinkLocalAllNodesMulticast() ||
                               headers.GetDestinationAddress() == Ip6::Address::GetLinkLocalAllRoutersMulticast() ||
-                              node.mInfraIf.HasAddress(headers.GetDestinationAddress())))
+                              HasAddress(headers.GetDestinationAddress())))
     {
         switch (headers.GetIcmpHeader().GetType())
         {
-        case Ip6::Icmp::Header::kTypeRouterAdvert:
-        case Ip6::Icmp::Header::kTypeRouterSolicit:
-        case Ip6::Icmp::Header::kTypeNeighborAdvert:
-        case Ip6::Icmp::Header::kTypeNeighborSolicit:
+        case Ip6::Icmp6Header::kTypeRouterAdvert:
+        case Ip6::Icmp6Header::kTypeRouterSolicit:
+        case Ip6::Icmp6Header::kTypeNeighborAdvert:
+        case Ip6::Icmp6Header::kTypeNeighborSolicit:
         {
             Heap::Data payload;
             uint16_t   offset = sizeof(Ip6::Header);
 
             SuccessOrQuit(payload.SetFrom(aMessage, offset, aMessage.GetLength() - offset));
 
-            otPlatInfraIfRecvIcmp6Nd(&node.GetInstance(), mIfIndex,
-                                     reinterpret_cast<const otIp6Address *>(&headers.GetSourceAddress()),
-                                     payload.GetBytes(), payload.GetLength());
-            node.mInfraIf.ProcessIcmp6Nd(headers.GetSourceAddress(), payload.GetBytes(), payload.GetLength());
+            otPlatInfraIfRecvIcmp6Nd(&GetInstance(), mIfIndex, &headers.GetSourceAddress(), payload.GetBytes(),
+                                     payload.GetLength());
+            Get<InfraIf>().ProcessIcmp6Nd(headers.GetSourceAddress(), payload.GetBytes(), payload.GetLength());
             ExitNow();
         }
 
-        case Ip6::Icmp::Header::kTypeEchoRequest:
+        case Ip6::Icmp6Header::kTypeEchoRequest:
             HandleEchoRequest(headers.GetIp6Header(), aMessage);
             ExitNow();
 
-        case Ip6::Icmp::Header::kTypeEchoReply:
+        case Ip6::Icmp6Header::kTypeEchoReply:
             HandleEchoReply(headers.GetIp6Header(), aMessage);
             ExitNow();
 
@@ -393,39 +477,91 @@
 
     if (headers.IsUdp() && headers.GetDestinationPort() == Mdns::kUdpPort)
     {
-        if (headers.GetDestinationAddress().IsMulticast() || node.mInfraIf.HasAddress(headers.GetDestinationAddress()))
+        if (headers.GetDestinationAddress().IsMulticast() || HasAddress(headers.GetDestinationAddress()))
         {
             Mdns::AddressInfo senderAddress;
             Message          *payload = aMessage.Clone<kNoReservedHeader>();
 
             VerifyOrQuit(payload != nullptr);
-            payload->RemoveHeader(sizeof(Ip6::Header) + sizeof(Ip6::Udp::Header));
+            payload->RemoveHeader(sizeof(Ip6::Header) + sizeof(Ip6::UdpHeader));
 
             senderAddress.mAddress      = headers.GetSourceAddress();
             senderAddress.mPort         = headers.GetSourcePort();
             senderAddress.mInfraIfIndex = Mdns::kInfraIfIndex;
 
-            node.mMdns.Receive(node.GetInstance(), *payload, !headers.GetDestinationAddress().IsMulticast(),
-                               senderAddress);
+            Get<Mdns>().Receive(GetInstance(), *payload, !headers.GetDestinationAddress().IsMulticast(), senderAddress);
             payload->Free();
         }
         ExitNow();
     }
 
+#if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
+    if (headers.IsUdp() && headers.GetDestinationPort() == Get<Trel>().mUdpPort && Get<Trel>().mEnabled)
+    {
+        if (headers.GetDestinationAddress().IsMulticast() || HasAddress(headers.GetDestinationAddress()))
+        {
+            Ip6::SockAddr senderAddr;
+            Heap::Data    payload;
+            uint16_t      offset = sizeof(Ip6::Header) + sizeof(Ip6::UdpHeader);
+
+            senderAddr.SetAddress(headers.GetSourceAddress());
+            senderAddr.SetPort(headers.GetSourcePort());
+
+            SuccessOrQuit(
+                payload.SetFrom(aMessage, offset, headers.GetUdpHeader().GetLength() - sizeof(Ip6::UdpHeader)));
+            Get<Trel>().Receive(GetInstance(), payload, senderAddr);
+        }
+        ExitNow();
+    }
+#endif
+
+#if OPENTHREAD_CONFIG_DNS_UPSTREAM_QUERY_ENABLE
+    if (headers.IsUdp() && headers.GetDestinationPort() == UpstreamDns::kDnsPort)
+    {
+        aMessage.SetOffset(sizeof(Ip6::Header) + sizeof(Ip6::UdpHeader));
+        if (Get<UpstreamDns>().HandleUpstreamDnsResponse(headers.GetSourceAddress(), aMessage))
+        {
+            ExitNow();
+        }
+        aMessage.SetOffset(0);
+    }
+#endif
+
+    if (headers.IsUdp() && Get<Udp>().HandleReceive(aMessage, headers))
+    {
+        ExitNow();
+    }
+
+    if (headers.IsUdp() && mUdpHook != nullptr)
+    {
+        Ip6::MessageInfo messageInfo;
+        messageInfo.SetPeerAddr(headers.GetSourceAddress());
+        messageInfo.SetPeerPort(headers.GetSourcePort());
+        messageInfo.SetSockAddr(headers.GetDestinationAddress());
+        messageInfo.SetSockPort(headers.GetDestinationPort());
+
+        aMessage.SetOffset(sizeof(Ip6::Header) + sizeof(Ip6::UdpHeader));
+        if (mUdpHook(GetInstance(), aMessage, messageInfo))
+        {
+            ExitNow();
+        }
+        aMessage.SetOffset(0);
+    }
+
     {
         // We also deliver generic IPv6 packets to the stack if they are NOT ICMPv6 ND packets.
         // (ND packets were already delivered via otPlatInfraIfRecvIcmp6Nd above).
         OwnedPtr<Message> messagePtr;
         Ip6::Header       updatedHeader = headers.GetIp6Header();
 
-        VerifyOrExit(!node.mInfraIf.HasAddress(headers.GetSourceAddress()));
-        VerifyOrExit(!node.Get<NetworkData::Leader>().IsOnMesh(headers.GetSourceAddress()));
+        VerifyOrExit(!HasAddress(headers.GetSourceAddress()));
+        VerifyOrExit(!Get<NetworkData::Leader>().IsOnMesh(headers.GetSourceAddress()));
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_MULTICAST_ROUTING_ENABLE
         if (headers.GetDestinationAddress().IsMulticastLargerThanRealmLocal())
         {
-            VerifyOrExit(node.Get<BackboneRouter::Local>().IsPrimary());
-            VerifyOrExit(node.Get<BackboneRouter::MulticastListenersTable>().Has(headers.GetDestinationAddress()));
+            VerifyOrExit(Get<BackboneRouter::Local>().IsPrimary());
+            VerifyOrExit(Get<BackboneRouter::MulticastListenersTable>().Has(headers.GetDestinationAddress()));
         }
 #endif
 
@@ -439,22 +575,21 @@
         messagePtr->SetOrigin(Message::kOriginHostUntrusted);
         messagePtr->SetLoopbackToHostAllowed(false);
 
-        IgnoreError(node.Get<Ip6::Ip6>().SendRaw(messagePtr.PassOwnership()));
+        IgnoreError(Get<Ip6::Ip6>().SendRaw(messagePtr.PassOwnership()));
     }
 
 exit:
-    Core::Get().SetActiveNode(&aSrcNode);
+    return;
 }
 
 void InfraIf::HandleEchoRequest(const Ip6::Header &aHeader, Message &aMessage)
 {
-    Node             &node = GetNode();
-    Message          *replyMessage;
-    Ip6::Header       replyHeader;
-    Ip6::Icmp::Header replyIcmp;
-    uint16_t          payloadLen = aMessage.GetLength() - sizeof(Ip6::Header);
+    Message         *replyMessage;
+    Ip6::Header      replyHeader;
+    Ip6::Icmp6Header replyIcmp;
+    uint16_t         payloadLen = aMessage.GetLength() - sizeof(Ip6::Header);
 
-    replyMessage = node.Get<MessagePool>().Allocate(Message::kTypeIp6);
+    replyMessage = Get<MessagePool>().Allocate(Message::kTypeIp6);
     VerifyOrQuit(replyMessage != nullptr);
 
     SuccessOrQuit(replyMessage->SetLength(payloadLen));
@@ -469,7 +604,7 @@
     replyHeader.SetSource(aHeader.GetDestination());
     replyHeader.SetDestination(aHeader.GetSource());
 
-    replyIcmp.SetType(Ip6::Icmp::Header::kTypeEchoReply);
+    replyIcmp.SetType(Ip6::Icmp6Header::kTypeEchoReply);
     replyMessage->Write(0, replyIcmp);
 
     // Recalculate ICMPv6 checksum
@@ -484,7 +619,7 @@
 
 void InfraIf::HandleEchoReply(const Ip6::Header &aHeader, Message &aMessage)
 {
-    Ip6::Icmp::Header icmpHeader;
+    Ip6::Icmp6Header icmpHeader;
 
     SuccessOrQuit(aMessage.Read(sizeof(Ip6::Header), icmpHeader));
     mEchoReplyCallback.InvokeIfSet(aHeader.GetSource(), icmpHeader.GetId(), icmpHeader.GetSequence());
@@ -496,19 +631,7 @@
     ClearAllBytes(aLinkLayerAddress);
     aLinkLayerAddress.mLength     = 6;
     aLinkLayerAddress.mAddress[0] = 0x02;
-    aLinkLayerAddress.mAddress[5] = static_cast<uint8_t>(mNodeId);
-}
-
-Node &InfraIf::GetNode(void)
-{
-    OT_ASSERT(mNode != nullptr);
-    return *mNode;
-}
-
-const Node &InfraIf::GetNode(void) const
-{
-    OT_ASSERT(mNode != nullptr);
-    return *mNode;
+    BigEndian::WriteUint32(GetInstance().GetId(), &aLinkLayerAddress.mAddress[2]);
 }
 
 extern "C" {
@@ -547,11 +670,23 @@
 {
     OT_UNUSED_VARIABLE(aInfraIfIndex);
 
-    AsNode(aInstance).mInfraIf.GetLinkLayerAddress(*aLinkLayerAddress);
+    AsNode(aInstance).mInfraIf.GetLinkLayerAddress(AsCoreType(aLinkLayerAddress));
 
     return OT_ERROR_NONE;
 }
 
+void otPlatInfraIfDhcp6PdClientSetListeningEnabled(otInstance *aInstance, bool aEnable, uint32_t aInfraIfIndex)
+{
+    OT_UNUSED_VARIABLE(aInfraIfIndex);
+    AsNode(aInstance).mInfraIf.SetDhcp6ListeningEnabled(aEnable);
+}
+
+void otPlatInfraIfDhcp6PdClientSend(otInstance *aInstance, otMessage *aMessage, otIp6Address *aDest, uint32_t aIfIndex)
+{
+    OT_UNUSED_VARIABLE(aIfIndex);
+    AsNode(aInstance).mInfraIf.SendDhcp6(AsCoreType(aMessage), AsCoreType(aDest));
+}
+
 } // extern "C"
 
 } // namespace Nexus
diff --git a/tests/nexus/platform/nexus_infra_if.hpp b/tests/nexus/platform/nexus_infra_if.hpp
index b9a2c73..1b143ea 100644
--- a/tests/nexus/platform/nexus_infra_if.hpp
+++ b/tests/nexus/platform/nexus_infra_if.hpp
@@ -29,7 +29,6 @@
 #ifndef OT_NEXUS_PLATFORM_NEXUS_INFRA_IF_HPP_
 #define OT_NEXUS_PLATFORM_NEXUS_INFRA_IF_HPP_
 
-#include <openthread/platform/infra_if.h>
 #include "instance/instance.hpp"
 
 namespace ot {
@@ -37,14 +36,14 @@
 
 class Node;
 
-class InfraIf
+class InfraIf : public InstanceLocator
 {
 public:
-    typedef otPlatInfraIfLinkLayerAddress LinkLayerAddress;
+    using LinkLayerAddress = BorderRouter::InfraIf::LinkLayerAddress;
 
-    InfraIf(void);
+    explicit InfraIf(Instance &aInstance);
 
-    void Init(Node &aNode);
+    void AfterInit(void);
 
     bool IsInitialized(void) const { return mIfIndex != 0; }
 
@@ -59,11 +58,15 @@
     const Ip6::Address              &GetLinkLocalAddress(void) const { return mAddresses[0]; }
     const Heap::Array<Ip6::Address> &GetAddresses(void) const { return mAddresses; }
 
+    const Ip6::Address &SelectSourceAddress(const Ip6::Address &aDestination) const;
+
     void SendIcmp6Nd(const Ip6::Address &aDestAddress, const uint8_t *aBuffer, uint16_t aBufferLength);
-    void SendIp6(const Ip6::Address &aSrcAddress,
-                 const Ip6::Address &aDestAddress,
-                 const uint8_t      *aBuffer,
-                 uint16_t            aBufferLength);
+    void SendRouterAdvertisement(const Ip6::Address &aDestination,
+                                 const Ip6::Prefix  *aPioPrefix,
+                                 const Ip6::Prefix  *aRioPrefix);
+    void StartRouterAdvertisement(const Ip6::Prefix &aPioPrefix, const Ip6::Prefix *aRioPrefix = nullptr);
+    void StopRouterAdvertisement(void);
+    void SendIp6(const Ip6::Header &aHeader, OwnedPtr<Message> aMessagePtr);
     void SendEchoRequest(const Ip6::Address &aSrcAddress,
                          const Ip6::Address &aDestAddress,
                          uint16_t            aIdentifier,
@@ -73,36 +76,48 @@
                  const Ip6::Address &aDestAddress,
                  uint16_t            aSourcePort,
                  uint16_t            aDestPort,
-                 uint16_t            aPayloadSize);
-    void SendUdp(const Ip6::Address &aSrcAddress,
-                 const Ip6::Address &aDestAddress,
-                 uint16_t            aSourcePort,
-                 uint16_t            aDestPort,
                  Message            &aPayload);
 
-    void Receive(Node &aSrcNode, Message &aMessage);
+    void Receive(Message &aMessage);
     void GetLinkLayerAddress(LinkLayerAddress &aLinkLayerAddress) const;
 
     typedef void (*EchoReplyHandler)(void *aContext, const Ip6::Address &aSource, uint16_t aId, uint16_t aSequence);
 
     void SetEchoReplyHandler(EchoReplyHandler aHandler, void *aContext) { mEchoReplyCallback.Set(aHandler, aContext); }
 
-    Node       &GetNode(void);
-    const Node &GetNode(void) const;
+    void SetDhcp6ListeningEnabled(bool aEnable);
+    void SetIsDnsServer(bool aEnable) { mIsDnsServer = aEnable; }
+    void SendDhcp6(Message &aMessage, const Ip6::Address &aDestAddress);
+
+    typedef bool (*UdpHook)(Instance &aInstance, Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
+    void SetUdpHook(UdpHook aHook) { mUdpHook = aHook; }
 
     MessageQueue mPendingTxQueue;
 
 private:
     void ProcessIcmp6Nd(const Ip6::Address &aSrcAddress, const uint8_t *aBuffer, uint16_t aBufferLength);
+    void SendPeriodicRouterAdvertisement(void);
     void HandlePrefixInfoOption(const Ip6::Nd::PrefixInfoOption &aPio);
+    void HandleRouterSolicitation(const Ip6::Address &aSrcAddress);
     void HandleEchoRequest(const Ip6::Header &aHeader, Message &aMessage);
     void HandleEchoReply(const Ip6::Header &aHeader, Message &aMessage);
 
-    Node                      *mNode;
-    uint32_t                   mNodeId;
+    void HandleRaTimer(void);
+
     uint32_t                   mIfIndex;
     Heap::Array<Ip6::Address>  mAddresses;
     Callback<EchoReplyHandler> mEchoReplyCallback;
+    UdpHook                    mUdpHook;
+
+    Ip6::Prefix mPioPrefix;
+    Ip6::Prefix mRioPrefix;
+    bool        mHasRioPrefix;
+    bool        mDhcp6PdListening;
+    bool        mIsDnsServer;
+
+    using RaTimer = TimerMilliIn<InfraIf, &InfraIf::HandleRaTimer>;
+
+    RaTimer mRaTimer;
 };
 
 } // namespace Nexus
diff --git a/tests/nexus/platform/nexus_logging.cpp b/tests/nexus/platform/nexus_logging.cpp
new file mode 100644
index 0000000..9714187
--- /dev/null
+++ b/tests/nexus/platform/nexus_logging.cpp
@@ -0,0 +1,137 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+
+#include <openthread/platform/logging.h>
+
+#include "nexus_core.hpp"
+#include "nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static constexpr uint16_t kTimestampStringSize = 40;
+
+typedef String<kTimestampStringSize> TimestampString;
+
+static TimestampString GetTimestamp(TimeMilli aNow)
+{
+    TimestampString string;
+
+    UptimeToString(aNow.GetValue(), string, kUptimeStringIncludeMsec);
+    return string;
+}
+
+static TimestampString GetTimestamp(void) { return GetTimestamp(Core::Get().GetNow()); }
+
+void Log(const char *aFormat, ...)
+{
+    va_list args;
+
+    va_start(args, aFormat);
+
+    printf("%s ", GetTimestamp().AsCString());
+    vprintf(aFormat, args);
+    printf("\n");
+    fflush(stdout);
+
+    va_end(args);
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// Logging
+
+Logging::Logging(void)
+    : mLogFile(nullptr)
+{
+}
+
+Logging::~Logging(void)
+{
+    if (mLogFile != nullptr)
+    {
+        fclose(mLogFile);
+    }
+}
+
+void Logging::Init(uint32_t aId)
+{
+    String<32> fileName;
+
+    if (mLogFile != nullptr)
+    {
+        fclose(mLogFile);
+    }
+
+    fileName.Append("ot-logs%lu.log", ToUlong(aId));
+    mLogFile = fopen(fileName.AsCString(), "wt");
+
+    VerifyOrQuit(mLogFile != nullptr);
+
+    fprintf(mLogFile, "OpenThread logs\r\n");
+    fprintf(mLogFile, "- Platform: nexus\r\n");
+    fprintf(mLogFile, "- Node ID : %lu\r\n", ToUlong(aId));
+    fprintf(mLogFile, "\r\n");
+}
+
+void Logging::SaveLog(const char *aLogLine, TimeMilli aNow)
+{
+    VerifyOrExit(mLogFile != nullptr);
+    fprintf(mLogFile, "%s %s\r\n", GetTimestamp(aNow).AsCString(), aLogLine);
+
+exit:
+    return;
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+// otPlatLog
+
+extern "C" {
+
+void otPlatLogOutput(otInstance *aInstance, otLogLevel aLogLevel, const char *aLogLine)
+{
+    OT_UNUSED_VARIABLE(aLogLevel);
+
+    TimeMilli now = Core::Get().GetNow();
+
+    VerifyOrExit(aInstance != nullptr);
+
+    printf("<%03u> %s %s\n", AsNode(aInstance).GetId(), GetTimestamp(now).AsCString(), aLogLine);
+    fflush(stdout);
+
+    AsNode(aInstance).mLogging.SaveLog(aLogLine, now);
+
+exit:
+    return;
+}
+}
+
+} // namespace Nexus
+} // namespace ot
diff --git a/tests/nexus/platform/nexus_logging.hpp b/tests/nexus/platform/nexus_logging.hpp
new file mode 100644
index 0000000..e5de3ff
--- /dev/null
+++ b/tests/nexus/platform/nexus_logging.hpp
@@ -0,0 +1,58 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#ifndef OT_NEXUS_PLATFORM_NEXUS_LOGGING_HPP_
+#define OT_NEXUS_PLATFORM_NEXUS_LOGGING_HPP_
+
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "common/const_cast.hpp"
+#include "common/owning_list.hpp"
+#include "instance/instance.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void Log(const char *aFormat, ...) OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(1, 2);
+
+struct Logging
+{
+    Logging(void);
+    ~Logging(void);
+
+    void Init(uint32_t aId);
+    void SaveLog(const char *aLogLine, TimeMilli aNow);
+
+    FILE *mLogFile;
+};
+
+} // namespace Nexus
+} // namespace ot
+
+#endif // OT_NEXUS_PLATFORM_NEXUS_LOGGING_HPP_
diff --git a/tests/nexus/platform/nexus_misc.cpp b/tests/nexus/platform/nexus_misc.cpp
index e9e17b7..5df7537 100644
--- a/tests/nexus/platform/nexus_misc.cpp
+++ b/tests/nexus/platform/nexus_misc.cpp
@@ -30,7 +30,6 @@
 #include <stdlib.h>
 
 #include <openthread/platform/entropy.h>
-#include <openthread/platform/logging.h>
 #include <openthread/platform/misc.h>
 
 #include "nexus_core.hpp"
@@ -39,9 +38,6 @@
 namespace ot {
 namespace Nexus {
 
-static void LogVarArgs(Node *aActiveNode, const char *aFormat, va_list aArgs)
-    OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(2, 0);
-
 extern "C" {
 
 //---------------------------------------------------------------------------------------------------------------------
@@ -55,21 +51,6 @@
 }
 
 //---------------------------------------------------------------------------------------------------------------------
-// otPlatLog
-
-void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
-{
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-
-    va_list args;
-
-    va_start(args, aFormat);
-    LogVarArgs(Core::Get().GetActiveNode(), aFormat, args);
-    va_end(args);
-}
-
-//---------------------------------------------------------------------------------------------------------------------
 // Heap allocation APIs
 
 void *otPlatCAlloc(size_t aNum, size_t aSize)
@@ -133,33 +114,5 @@
 
 } // extern "C"
 
-//---------------------------------------------------------------------------------------------------------------------
-// Log related function
-
-void Log(const char *aFormat, ...)
-{
-    va_list args;
-
-    va_start(args, aFormat);
-    LogVarArgs(nullptr, aFormat, args);
-    va_end(args);
-}
-
-static void LogVarArgs(Node *aActiveNode, const char *aFormat, va_list aArgs)
-{
-    uint32_t now = Core::Get().GetNow().GetValue();
-
-    printf("%02u:%02u:%02u.%03u ", now / 3600000, (now / 60000) % 60, (now / 1000) % 60, now % 1000);
-
-    if (aActiveNode != nullptr)
-    {
-        printf("%03u ", aActiveNode->GetInstance().GetId());
-    }
-
-    vprintf(aFormat, aArgs);
-    printf("\n");
-    fflush(stdout);
-}
-
 } // namespace Nexus
 } // namespace ot
diff --git a/tests/nexus/platform/nexus_native.cpp b/tests/nexus/platform/nexus_native.cpp
new file mode 100644
index 0000000..1164033
--- /dev/null
+++ b/tests/nexus/platform/nexus_native.cpp
@@ -0,0 +1,230 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "openthread-core-config.h"
+
+#include <chrono>
+#include <csignal>
+#include <memory>
+#include <thread>
+#include <vector>
+
+#include <unistd.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_sim.hpp"
+#if OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+#include "platform/nexus_grpc.hpp"
+#endif
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static std::atomic<bool> sRunning(true);
+
+static void HandleSignal(int aSignal)
+{
+    OT_UNUSED_VARIABLE(aSignal);
+    sRunning = false;
+}
+
+static void WaitForPlay(Simulation &aSim)
+{
+    static constexpr uint32_t kWaitTimeoutMs      = 1000;
+    static constexpr float    kPlaySpeedThreshold = 0.1f;
+
+    Log("Waiting for Play command to start simulation...");
+
+    while (sRunning)
+    {
+        float speedFactor;
+
+        {
+            std::lock_guard<Simulation> lock(aSim);
+            speedFactor = aSim.GetSpeedFactor();
+        }
+
+        if (speedFactor >= kPlaySpeedThreshold)
+        {
+            break;
+        }
+
+        aSim.WaitSpeedChange(kWaitTimeoutMs);
+    }
+
+    if (sRunning)
+    {
+        Log("Play command received! Running simulation loop...");
+    }
+}
+
+static void HandleSimulationStep(Core       &aNexus,
+                                 Simulation &aSim,
+                                 uint32_t    aAdvanceTimeMs,
+                                 uint32_t   &aHeartbeatTimerRealTimeUs)
+{
+    std::lock_guard<Simulation> lock(aSim);
+    float                       speedFactor;
+
+    aNexus.AdvanceTime(aAdvanceTimeMs);
+    speedFactor = aSim.GetSpeedFactor();
+
+#if OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+    if (speedFactor > 0.0f)
+    {
+        static constexpr uint32_t kHeartbeatSyncIntervalUs = 100000; // 100ms real-time
+
+        uint64_t increment = static_cast<uint64_t>((static_cast<double>(aAdvanceTimeMs) * 1000.0) / speedFactor);
+        uint64_t newTotal  = static_cast<uint64_t>(aHeartbeatTimerRealTimeUs) + increment;
+
+        if (newTotal >= kHeartbeatSyncIntervalUs)
+        {
+            aNexus.NotifyHeartbeat();
+            aHeartbeatTimerRealTimeUs = static_cast<uint32_t>(newTotal % kHeartbeatSyncIntervalUs);
+        }
+        else
+        {
+            aHeartbeatTimerRealTimeUs = static_cast<uint32_t>(newTotal);
+        }
+    }
+#else
+    OT_UNUSED_VARIABLE(aHeartbeatTimerRealTimeUs);
+    OT_UNUSED_VARIABLE(speedFactor);
+#endif
+}
+
+static void SleepUntilIntendedTime(std::chrono::steady_clock::time_point aStartTime,
+                                   uint32_t                              aAdvanceTimeMs,
+                                   float                                 aSpeedFactor)
+{
+    std::chrono::steady_clock::time_point endTime = std::chrono::steady_clock::now();
+    auto     elapsedUs     = std::chrono::duration_cast<std::chrono::microseconds>(endTime - aStartTime).count();
+    uint64_t intendedSleep = static_cast<uint64_t>((aAdvanceTimeMs * 1000.0f) / aSpeedFactor);
+
+    if (intendedSleep > static_cast<uint64_t>(elapsedUs))
+    {
+        std::this_thread::sleep_for(std::chrono::microseconds(intendedSleep - elapsedUs));
+    }
+}
+
+void LiveDemo(int argc, char *argv[])
+{
+    static constexpr uint32_t kAdvanceTimeMs = 100;
+    static constexpr uint32_t kIdleSleepMs   = 100;
+
+    Core       nexus;
+    Simulation sim;
+#if OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+    auto grpcServer = std::make_unique<GrpcServer>(argv[0]);
+#endif
+
+    signal(SIGINT, HandleSignal);
+    signal(SIGTERM, HandleSignal);
+
+    Log("================================================================================");
+    Log("Starting OpenThread Nexus Simulator - Dynamic Topology");
+    Log("================================================================================");
+
+    WaitForPlay(sim);
+
+    uint32_t heartbeatTimerRealTimeUs = 0;
+
+    while (sRunning)
+    {
+        float                                 speedFactor;
+        bool                                  isUiConnected;
+        std::chrono::steady_clock::time_point startTime = std::chrono::steady_clock::now();
+
+        if (sim.IsRestartRequested())
+        {
+            break;
+        }
+
+        {
+            std::lock_guard<Simulation> lock(sim);
+            isUiConnected = nexus.IsUiConnected();
+            speedFactor   = sim.GetSpeedFactor();
+        }
+
+        if (speedFactor <= 0.0f)
+        {
+            sim.WaitSpeedChange(kAdvanceTimeMs);
+            continue;
+        }
+
+        if (isUiConnected)
+        {
+            HandleSimulationStep(nexus, sim, kAdvanceTimeMs, heartbeatTimerRealTimeUs);
+            SleepUntilIntendedTime(startTime, kAdvanceTimeMs, speedFactor);
+        }
+        else
+        {
+            sim.WaitSpeedChange(kIdleSleepMs);
+        }
+    }
+
+    if (sim.IsRestartRequested())
+    {
+        Log("Restarting process...");
+
+#if OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE
+        std::string execPath = grpcServer->GetExecutablePath();
+#else
+        std::string execPath = argv[0];
+#endif
+        if (execPath.empty())
+        {
+            execPath = argv[0];
+        }
+
+        std::vector<char *> args;
+        args.push_back(const_cast<char *>(execPath.data()));
+        for (int i = 1; i < argc; i++)
+        {
+            args.push_back(argv[i]);
+        }
+        args.push_back(nullptr);
+        execvp(args[0], args.data());
+
+        // If execvp returns, it failed
+        Log("execvp failed: %s", strerror(errno));
+        _exit(1);
+    }
+
+    Log("Simulation terminated gracefully.");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::LiveDemo(argc, argv);
+    return 0;
+}
diff --git a/tests/nexus/platform/nexus_node.cpp b/tests/nexus/platform/nexus_node.cpp
index e35174d..952b193 100644
--- a/tests/nexus/platform/nexus_node.cpp
+++ b/tests/nexus/platform/nexus_node.cpp
@@ -28,9 +28,23 @@
 
 #include "nexus_node.hpp"
 
+#include "nexus_utils.hpp"
+
 namespace ot {
 namespace Nexus {
 
+//----------------------------------------------------------------------------------------------------------------------
+
+Node::Node(void)
+    : Platform(static_cast<Instance &>(*this))
+    , mCliInterpreter(static_cast<Instance *>(this), HandleCliOutput, this)
+    , mX(0.0f)
+    , mY(0.0f)
+    , mLastParentId(0xffff)
+{
+    mCliInterpreter.SetPromptConfig(false);
+}
+
 void Node::Reset(void)
 {
     Instance *instance = &GetInstance();
@@ -40,10 +54,10 @@
     mAlarmMilli.Reset();
     mAlarmMicro.Reset();
     mMdns.Reset();
+    mUpstreamDns.Reset();
     mInfraIf.mPendingTxQueue.DequeueAndFreeAll();
     mPendingTasklet = false;
 
-    otIp6SetReceiveCallback(&GetInstance(), HandleIp6Receive, &GetInstance());
 #if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
     mTrel.Reset();
 #endif
@@ -53,6 +67,8 @@
     instance = new (instance) Instance();
     instance->SetId(id);
     instance->AfterInit();
+
+    instance->Get<Ip6::Ip6>().SetReceiveCallback(Node::HandleIp6Receive, this);
 }
 
 void Node::Form(void)
@@ -131,9 +147,6 @@
         messageInfo.SetSockAddr(*aSrcAddress);
     }
 
-    Log("Sending Echo Request from Node %lu (%s) to %s (payload-size:%u)", ToUlong(GetId()), GetName(),
-        aDestination.ToString().AsCString(), aPayloadSize);
-
     SuccessOrQuit(Get<Ip6::Icmp>().SendEchoRequest(*message, messageInfo, aIdentifier));
 }
 
@@ -141,48 +154,42 @@
 
 void Node::HandleIp6Receive(otMessage *aMessage, void *aContext)
 {
-    static_cast<Node *>(aContext)->HandleReceive(aMessage);
+    OwnedPtr<Message> messagePtr(AsCoreTypePtr(aMessage));
+
+    static_cast<Node *>(aContext)->HandleIp6Receive(messagePtr.PassOwnership());
 }
 
-void Node::HandleReceive(otMessage *aMessage)
+void Node::HandleIp6Receive(OwnedPtr<Message> aMessagePtr)
 {
-    uint16_t     length = otMessageGetLength(aMessage);
-    uint8_t      buffer[1500];
-    Ip6::Header *header;
+    Ip6::Header header;
 
-    VerifyOrExit(length <= sizeof(buffer));
-    VerifyOrQuit(otMessageRead(aMessage, 0, buffer, length) == length);
-
-    VerifyOrExit(length >= sizeof(Ip6::Header));
-    header = reinterpret_cast<Ip6::Header *>(buffer);
+    VerifyOrExit(aMessagePtr != nullptr);
+    SuccessOrExit(header.ParseFrom(*aMessagePtr));
 
     // Forward packets to InfraIf if they are intended for the backbone.
     // We avoid forwarding link-local and realm-local scope packets.
 
     VerifyOrExit(mInfraIf.IsInitialized());
-    VerifyOrExit(header->GetHopLimit() > 1);
 
-    header->SetHopLimit(header->GetHopLimit() - 1);
+    VerifyOrExit(header.GetHopLimit() > 1);
+    header.SetHopLimit(header.GetHopLimit() - 1);
+    aMessagePtr->Write(0, header);
 
-    VerifyOrExit(header->GetDestination().GetScope() > Ip6::Address::kRealmLocalScope);
+    VerifyOrExit(header.GetDestination().GetScope() > Ip6::Address::kRealmLocalScope);
 
-    Core::Get().SetActiveNode(this);
-
-    if (header->GetDestination().IsMulticast())
+    if (header.GetDestination().IsMulticast())
     {
         VerifyOrExit(Get<BackboneRouter::Local>().IsPrimary());
     }
 
-    VerifyOrExit(Get<NetworkData::Leader>().IsOnMesh(header->GetSource()));
+    VerifyOrExit(!header.GetSource().IsLinkLocalUnicastOrMulticast());
+    VerifyOrExit(!Get<Mle::Mle>().IsMeshLocalAddress(header.GetSource()));
+    VerifyOrExit(Get<NetworkData::Leader>().IsOnMesh(header.GetSource()));
 
-    // Only forward if source is NOT Link-Local and NOT Mesh-Local.
-    VerifyOrExit(!header->GetSource().IsLinkLocalUnicastOrMulticast());
-    VerifyOrExit(!Get<Mle::Mle>().IsMeshLocalAddress(header->GetSource()));
-
-    mInfraIf.SendIp6(header->GetSource(), header->GetDestination(), buffer, length);
+    mInfraIf.SendIp6(header, aMessagePtr.PassOwnership());
 
 exit:
-    otMessageFree(aMessage);
+    return;
 }
 
 #if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
@@ -234,5 +241,181 @@
     return *matchedAddress;
 }
 
+bool Node::Matches(const Ip6::Address &aAddress, AddressNetif aNetif) const
+{
+    bool matches = false;
+
+    switch (aNetif)
+    {
+    case kThreadNetifAddress:
+        matches = Get<ThreadNetif>().HasUnicastAddress(aAddress);
+        break;
+
+    case kInfraNetifAddress:
+        matches = mInfraIf.HasAddress(aAddress);
+        break;
+
+    case kAnyNetifAddress:
+        matches = Get<ThreadNetif>().HasUnicastAddress(aAddress) || mInfraIf.HasAddress(aAddress);
+        break;
+    }
+
+    return matches;
+}
+
+const char *Node::GetExtendedRoleString(void) const
+{
+    const char     *roleStr;
+    Mle::DeviceRole role = Get<Mle::Mle>().GetRole();
+
+    switch (role)
+    {
+    case Mle::kRoleDisabled:
+        roleStr = "Disabled";
+        break;
+    case Mle::kRoleDetached:
+        roleStr = "Detached";
+        break;
+    case Mle::kRoleLeader:
+        roleStr = "Leader";
+        break;
+    case Mle::kRoleRouter:
+        roleStr = "Router";
+        break;
+    case Mle::kRoleChild:
+        if (Get<Mle::Mle>().IsFullThreadDevice())
+        {
+            roleStr = Get<Mle::Mle>().IsRouterRoleAllowed() ? "REED" : "FED";
+        }
+        else
+        {
+            roleStr = Get<Mle::Mle>().IsRxOnWhenIdle() ? "MED" : "SED";
+        }
+        break;
+    default:
+        roleStr = "Unknown";
+        break;
+    }
+
+    return roleStr;
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// Cli
+
+Node::CliOutputLine &Node::CliOutputLine::operator=(const CliOutputLine &aOther)
+{
+    SuccessOrQuit(mLine.Set(aOther.mLine));
+    return *this;
+}
+
+Node::CliOutputLine &Node::CliOutputLine::operator=(CliOutputLine &&aOther)
+{
+    mLine.TakeFrom(aOther.mLine.Move());
+    return *this;
+}
+
+void Node::InputCli(const char *aFormat, ...)
+{
+    static constexpr uint16_t kMaxCommandSize = 256;
+
+    va_list      args;
+    char         command[kMaxCommandSize];
+    StringWriter writer(command, kMaxCommandSize);
+
+    va_start(args, aFormat);
+    writer.AppendVarArgs(aFormat, args);
+    VerifyOrExit(!writer.IsTruncated());
+
+    ClearCliOutput();
+    mCliInterpreter.ProcessLine(command);
+
+exit:
+    va_end(args);
+}
+
+int Node::HandleCliOutput(void *aContext, const char *aFormat, va_list aArguments)
+{
+    return static_cast<Node *>(aContext)->HandleCliOutput(aFormat, aArguments);
+}
+
+int Node::HandleCliOutput(const char *aFormat, va_list aArguments)
+{
+    uint16_t length;
+
+    // Generate output - determine number of chars written
+    length = mCliCurOutputLine.GetLength();
+
+    mCliCurOutputLine.AppendVarArgs(aFormat, aArguments);
+    VerifyOrQuit(!mCliCurOutputLine.IsTruncated());
+
+    length = mCliCurOutputLine.GetLength() - length;
+
+    // Search for `\n` and parse lines one by one
+
+    while (true)
+    {
+        char           lineString[CliOutputLine::kMaxLineSize];
+        char          *end;
+        CliOutputLine *lineEnrry;
+
+        SuccessOrQuit(StringCopy(lineString, mCliCurOutputLine.AsCString()));
+
+        end = AsNonConst(StringFind(lineString, '\n'));
+        VerifyOrExit(end != nullptr);
+
+        mCliCurOutputLine.Clear();
+        mCliCurOutputLine.Append("%s", end + 1);
+
+        *end = kNullChar;
+
+        if (end > &lineString[0])
+        {
+            end--;
+
+            if (*end == '\r')
+            {
+                *end = kNullChar;
+            }
+        }
+
+        // Push the full line into the `mCliOutputLines` array
+
+        lineEnrry = mCliOutputLines.PushBack();
+        VerifyOrQuit(lineEnrry != nullptr);
+
+        SuccessOrQuit(lineEnrry->mLine.Set(lineString));
+    }
+
+exit:
+    return static_cast<int>(length);
+}
+
+bool Node::IsCliOutputSuccess(void)
+{
+    bool isSuccess = false;
+
+    VerifyOrExit(mCliOutputLines.GetLength() > 0);
+    VerifyOrExit(mCliOutputLines.Back()->IsDone());
+    isSuccess = true;
+
+exit:
+    return isSuccess;
+}
+
+//----------------------------------------------------------------------------------------------------------------------
+
+void AllowLinkBetween(Node &aFirstNode, Node &aSecondNode)
+{
+    aFirstNode.AllowList(aSecondNode);
+    aSecondNode.AllowList(aFirstNode);
+}
+
+void UnallowLinkBetween(Node &aFirstNode, Node &aSecondNode)
+{
+    aFirstNode.UnallowList(aSecondNode);
+    aSecondNode.UnallowList(aFirstNode);
+}
+
 } // namespace Nexus
 } // namespace ot
diff --git a/tests/nexus/platform/nexus_node.hpp b/tests/nexus/platform/nexus_node.hpp
index 5f36e1b..91bff08 100644
--- a/tests/nexus/platform/nexus_node.hpp
+++ b/tests/nexus/platform/nexus_node.hpp
@@ -29,15 +29,19 @@
 #ifndef OT_NEXUS_PLATFORM_NEXUS_NODE_HPP_
 #define OT_NEXUS_PLATFORM_NEXUS_NODE_HPP_
 
+#include "cli/cli.hpp"
 #include "instance/instance.hpp"
 
 #include "nexus_alarm.hpp"
 #include "nexus_core.hpp"
+#include "nexus_dns.hpp"
 #include "nexus_infra_if.hpp"
+#include "nexus_logging.hpp"
 #include "nexus_mdns.hpp"
 #include "nexus_radio.hpp"
 #include "nexus_settings.hpp"
 #include "nexus_trel.hpp"
+#include "nexus_udp.hpp"
 #include "nexus_utils.hpp"
 
 namespace ot {
@@ -46,20 +50,26 @@
 class Platform
 {
 public:
-    Radio    mRadio;
-    Alarm    mAlarmMilli;
-    Alarm    mAlarmMicro;
-    Mdns     mMdns;
-    InfraIf  mInfraIf;
-    Settings mSettings;
+    Radio       mRadio;
+    Alarm       mAlarmMilli;
+    Alarm       mAlarmMicro;
+    Logging     mLogging;
+    Mdns        mMdns;
+    UpstreamDns mUpstreamDns;
+    InfraIf     mInfraIf;
+    Udp         mUdp;
+    Settings    mSettings;
 #if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
     Trel mTrel;
 #endif
     bool mPendingTasklet;
 
 protected:
-    Platform(void)
-        : mPendingTasklet(false)
+    explicit Platform(Instance &aInstance)
+        : mUpstreamDns(aInstance)
+        , mInfraIf(aInstance)
+        , mUdp(aInstance)
+        , mPendingTasklet(false)
     {
     }
 };
@@ -69,19 +79,23 @@
     friend class Heap::Allocatable<Node>;
 
 public:
-    // Defines the device role and Network Data request behavior
-    // during joining:
-    //
-    // - `kAsFtd`, `kAsFed`, and `kAsMed` all request full netdata.
-    // - `kAsSed` requests only stable netdata (default for SED).
-    // - `kAsSedWithFullNetData` explicitly request full netdata.
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Helper methods - tests
+
     enum JoinMode : uint8_t
     {
         kAsFtd,
         kAsFed,
         kAsMed,
-        kAsSed,
-        kAsSedWithFullNetData,
+        kAsSed,                // Request stable netdata (default for SED)
+        kAsSedWithFullNetData, // Request full netdata as SED
+    };
+
+    enum AddressNetif : uint8_t // Used in `const Ip6::Address &aAddress, AddressNetif aNetif)`
+    {
+        kThreadNetifAddress,
+        kInfraNetifAddress,
+        kAnyNetifAddress,
     };
 
     void Reset(void);
@@ -95,57 +109,99 @@
                          uint8_t             aHopLimit    = 64,
                          const Ip6::Address *aSrcAddress  = nullptr);
 
+    const Ip6::Address &FindMatchingAddress(const char *aPrefix);
+    const Ip6::Address &FindGlobalAddress(void);
+
+    bool Matches(const Ip6::Address &aAddress, AddressNetif aNetif) const;
+    bool Matches(uint32_t aId) const { return GetInstance().GetId() == aId; }
+    bool Matches(const Mac::ExtAddress &aExtAddress) const { return Get<Mac::Mac>().GetExtAddress() == aExtAddress; }
+
+    const char *GetExtendedRoleString(void) const;
+
 #if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
     void GetTrelSockAddr(Ip6::SockAddr &aSockAddr) const;
 #endif
 
-    // Finds and returns the address on device matching the given `aPrefix`.
-    // It requires a matching prefix to be found, otherwise it is treated as
-    // a test failure (emits error message and exits the program.)
-    const Ip6::Address &FindMatchingAddress(const char *aPrefix);
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Node Properties
 
-    /**
-     * Finds and returns a global scope address on the device.
-     *
-     * It requires a global scope address to be found, otherwise it is treated as a test failure (emits error message
-     * and exits the program).
-     *
-     * @returns A reference to the global scope address.
-     */
-    const Ip6::Address &FindGlobalAddress(void);
-
+    uint32_t    GetId(void) const { return GetInstance().GetId(); }
     void        SetName(const char *aName) { mName.Clear().Append("%s", aName); }
     void        SetName(const char *aPrefix, uint16_t aIndex);
     const char *GetName(void) const { return mName.AsCString(); }
+    void        SetPosition(float aX, float aY) { mX = aX, mY = aY; }
+    float       GetPositionX(void) const { return mX; }
+    float       GetPositionY(void) const { return mY; }
+    uint32_t    GetLastParentId(void) const { return mLastParentId; }
+    void        SetLastParentId(uint32_t aId) { mLastParentId = aId; }
 
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Helper methods - Access OpenThread Core components
 
-    template <typename Type> Type &Get(void)
+    template <typename Type> Type       &Get(void) { return Instance::Get<Type>(); }
+    template <typename Type> const Type &Get(void) const { return AsConst(AsNonConst(this)->Get<Type>()); }
+
+    Instance       &GetInstance(void) { return *this; }
+    const Instance &GetInstance(void) const { return *this; }
+
+    static Node &From(otInstance *aInstance)
     {
-        Core::Get().SetActiveNode(this);
-        return Instance::Get<Type>();
+        Instance *instance = static_cast<Instance *>(aInstance);
+        return *static_cast<Node *>(instance);
     }
 
-    Instance &GetInstance(void)
-    {
-        Core::Get().SetActiveNode(this);
-        return *this;
-    }
-
-    uint32_t GetId(void) { return GetInstance().GetId(); }
-
-    static Node &From(otInstance *aInstance) { return static_cast<Node &>(*aInstance); }
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Helper methods - Callbacks
 
     static void HandleIp6Receive(otMessage *aMessage, void *aContext);
-    void        HandleReceive(otMessage *aMessage);
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // CLI
+
+    class CliOutputLine
+    {
+        friend class Node;
+
+    public:
+        CliOutputLine(void) = default;
+        CliOutputLine(const CliOutputLine &aOther) { SuccessOrQuit(mLine.Set(aOther.mLine)); }
+        CliOutputLine(CliOutputLine &&aOther) { mLine.TakeFrom(aOther.mLine.Move()); }
+        CliOutputLine &operator=(const CliOutputLine &aOther);
+        CliOutputLine &operator=(CliOutputLine &&aOther);
+
+        const char *GetLine(void) const { return mLine.AsCString(); }
+
+        bool Matches(const char *aLine) const { return StringMatch(GetLine(), aLine); }
+        bool StartsWith(const char *aSubString) const { return StringStartsWith(GetLine(), aSubString); }
+        bool EndsWith(const char *aSubString) const { return StringEndsWith(GetLine(), aSubString); }
+        bool IsDone(void) const { return Matches("Done"); }
+
+    private:
+        static constexpr uint16_t kMaxLineSize = 256;
+
+        Heap::String mLine;
+    };
+
+    typedef Heap::Array<CliOutputLine, 8> CliOutputArray;
+
+    void                  InputCli(const char *aFormat, ...) OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(2, 3);
+    void                  ClearCliOutput(void) { mCliOutputLines.Free(), mCliCurOutputLine.Clear(); }
+    const CliOutputArray &GetCliOutputLines(void) { return mCliOutputLines; }
+    bool                  IsCliOutputSuccess(void);
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Platform components
 
     using Platform::mAlarmMicro;
     using Platform::mAlarmMilli;
     using Platform::mInfraIf;
+    using Platform::mLogging;
     using Platform::mMdns;
     using Platform::mPendingTasklet;
     using Platform::mRadio;
     using Platform::mSettings;
+    using Platform::mUdp;
+    using Platform::mUpstreamDns;
 #if OPENTHREAD_CONFIG_RADIO_LINK_TREL_ENABLE
     using Platform::mTrel;
 #endif
@@ -153,14 +209,37 @@
     Node *mNext;
 
 private:
-    Node(void) {}
+    Node(void);
+    void HandleIp6Receive(OwnedPtr<Message> aMessagePtr);
+
+    static int HandleCliOutput(void *aContext, const char *aFormat, va_list aArguments)
+        OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(2, 0);
+    int HandleCliOutput(const char *aFormat, va_list aArguments) OT_TOOL_PRINTF_STYLE_FORMAT_ARG_CHECK(2, 0);
+
+    CliOutputArray                      mCliOutputLines;
+    String<CliOutputLine::kMaxLineSize> mCliCurOutputLine;
+    Cli::Interpreter                    mCliInterpreter;
 
     String<32> mName;
+    float      mX;
+    float      mY;
+    uint32_t   mLastParentId;
 };
 
 inline Node &AsNode(otInstance *aInstance) { return Node::From(aInstance); }
 
+void AllowLinkBetween(Node &aFirstNode, Node &aSecondNode);
+void UnallowLinkBetween(Node &aFirstNode, Node &aSecondNode);
+
 } // namespace Nexus
+
+template <> inline Nexus::Node        &Instance::Get(void) { return Nexus::AsNode(this); }
+template <> inline Nexus::InfraIf     &Instance::Get(void) { return static_cast<Nexus::Node *>(this)->mInfraIf; }
+template <> inline Nexus::Udp         &Instance::Get(void) { return static_cast<Nexus::Node *>(this)->mUdp; }
+template <> inline Nexus::Trel        &Instance::Get(void) { return static_cast<Nexus::Node *>(this)->mTrel; }
+template <> inline Nexus::Mdns        &Instance::Get(void) { return static_cast<Nexus::Node *>(this)->mMdns; }
+template <> inline Nexus::UpstreamDns &Instance::Get(void) { return static_cast<Nexus::Node *>(this)->mUpstreamDns; }
+
 } // namespace ot
 
 #endif // OT_NEXUS_PLATFORM_NEXUS_NODE_HPP_
diff --git a/tests/nexus/platform/nexus_observer.hpp b/tests/nexus/platform/nexus_observer.hpp
new file mode 100644
index 0000000..431433b
--- /dev/null
+++ b/tests/nexus/platform/nexus_observer.hpp
@@ -0,0 +1,115 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file includes definitions for the Nexus simulation observer.
+ */
+
+#ifndef OT_NEXUS_PLATFORM_NEXUS_OBSERVER_HPP_
+#define OT_NEXUS_PLATFORM_NEXUS_OBSERVER_HPP_
+
+#include <stdint.h>
+
+#include "common/linked_list.hpp"
+
+namespace ot {
+namespace Nexus {
+
+class Node;
+
+/**
+ * Represents an observer for the Nexus simulation.
+ *
+ * This class defines the interface for receiving simulation events from the Nexus core.
+ */
+class Observer : public LinkedListEntry<Observer>
+{
+public:
+    virtual ~Observer(void) = default;
+
+    /**
+     * This method is called when a node state has changed.
+     *
+     * @param[in] aNode  A pointer to the node whose state has changed.
+     */
+    virtual void OnNodeStateChanged(Node *aNode) = 0;
+
+    /**
+     * This method is called when a link state between two nodes has been updated.
+     *
+     * @param[in] aSrcId    The source node ID.
+     * @param[in] aDstId    The destination node ID.
+     * @param[in] aIsActive TRUE if the link is active, FALSE otherwise.
+     */
+    virtual void OnLinkUpdate(uint32_t aSrcId, uint32_t aDstId, bool aIsActive) = 0;
+
+    /**
+     * This method is called when a packet event occurs.
+     *
+     * @param[in] aSenderId       The ID of the node that sent the packet.
+     * @param[in] aDestinationId  The ID of the destination node, or 0xffff for broadcasts or unresolved nodes.
+     * @param[in] aData           A pointer to the packet data.
+     * @param[in] aLen            The length of the packet data.
+     */
+    virtual void OnPacketEvent(uint32_t aSenderId, uint32_t aDestinationId, const uint8_t *aData, uint16_t aLen) = 0;
+
+    /**
+     * This method is called to clear all events.
+     */
+    virtual void OnClearEvents(void) = 0;
+
+    /**
+     * This method is called to notify a heartbeat with current simulation time.
+     *
+     * @param[in] aTimestampUs  The current simulation time in microseconds.
+     */
+    virtual void OnHeartbeat(uint64_t aTimestampUs) = 0;
+
+    /**
+     * This method is called to dump the current simulation state.
+     */
+    virtual void DumpState(void) = 0;
+
+    /**
+     * This method indicates whether or not the observer is connected.
+     *
+     * @retval TRUE   If the observer is connected.
+     * @retval FALSE  If the observer is not connected.
+     */
+    virtual bool IsConnected(void) const = 0;
+
+private:
+    friend class LinkedListEntry<Observer>;
+    Observer *mNext;
+};
+
+} // namespace Nexus
+} // namespace ot
+
+#endif // OT_NEXUS_PLATFORM_NEXUS_OBSERVER_HPP_
diff --git a/tests/nexus/platform/nexus_pcap.cpp b/tests/nexus/platform/nexus_pcap.cpp
index 871c8b8..13a6b27 100644
--- a/tests/nexus/platform/nexus_pcap.cpp
+++ b/tests/nexus/platform/nexus_pcap.cpp
@@ -27,6 +27,7 @@
  */
 
 #include "nexus_pcap.hpp"
+
 #include "nexus_utils.hpp"
 #include "common/clearable.hpp"
 #include "common/code_utils.hpp"
@@ -35,6 +36,8 @@
 namespace ot {
 namespace Nexus {
 
+#ifndef __EMSCRIPTEN__
+
 OT_TOOL_PACKED_BEGIN
 struct Epb
 {
@@ -202,11 +205,11 @@
     return;
 }
 
-void Pcap::WritePacket(const otPlatInfraIfLinkLayerAddress &aSrcAddr,
-                       const otPlatInfraIfLinkLayerAddress &aDstAddr,
-                       const uint8_t                       *aBuffer,
-                       uint16_t                             aLength,
-                       uint64_t                             aTimeUs)
+void Pcap::WritePacket(const InfraIf::LinkLayerAddress &aSrcAddr,
+                       const InfraIf::LinkLayerAddress &aDstAddr,
+                       const uint8_t                   *aBuffer,
+                       uint16_t                         aLength,
+                       uint64_t                         aTimeUs)
 {
     Epb epb;
 
@@ -227,10 +230,11 @@
     VerifyOrExit(mFile != nullptr);
 
     ClearAllBytes(ethHeader);
-    VerifyOrQuit(aDstAddr.mLength >= sizeof(ethHeader.mDst), "Destination MAC address length is less than expected");
-    memcpy(ethHeader.mDst, aDstAddr.mAddress, sizeof(ethHeader.mDst));
-    VerifyOrQuit(aSrcAddr.mLength >= sizeof(ethHeader.mSrc), "Source MAC address length is less than expected");
-    memcpy(ethHeader.mSrc, aSrcAddr.mAddress, sizeof(ethHeader.mSrc));
+    VerifyOrQuit(aDstAddr.GetLength() >= sizeof(ethHeader.mDst),
+                 "Destination MAC address length is less than expected");
+    memcpy(ethHeader.mDst, aDstAddr.GetBytes(), sizeof(ethHeader.mDst));
+    VerifyOrQuit(aSrcAddr.GetLength() >= sizeof(ethHeader.mSrc), "Source MAC address length is less than expected");
+    memcpy(ethHeader.mSrc, aSrcAddr.GetBytes(), sizeof(ethHeader.mSrc));
     ethHeader.mType = BigEndian::HostSwap16(kEtherTypeIPv6);
 
     packetLen        = sizeof(ethHeader) + aLength;
@@ -263,5 +267,27 @@
     return;
 }
 
+#else // __EMSCRIPTEN__
+
+Pcap::Pcap(void) {}
+
+Pcap::~Pcap(void) {}
+
+void Pcap::Open(const char *) {}
+
+void Pcap::Close(void) {}
+
+void Pcap::WriteFrame(const otRadioFrame &, uint64_t) {}
+
+void Pcap::WritePacket(const InfraIf::LinkLayerAddress &,
+                       const InfraIf::LinkLayerAddress &,
+                       const uint8_t *,
+                       uint16_t,
+                       uint64_t)
+{
+}
+
+#endif // __EMSCRIPTEN__
+
 } // namespace Nexus
 } // namespace ot
diff --git a/tests/nexus/platform/nexus_pcap.hpp b/tests/nexus/platform/nexus_pcap.hpp
index 8ee6b8f..ad9b71a 100644
--- a/tests/nexus/platform/nexus_pcap.hpp
+++ b/tests/nexus/platform/nexus_pcap.hpp
@@ -32,8 +32,8 @@
 #include <cstdint>
 #include <cstdio>
 
-#include <openthread/platform/infra_if.h>
-#include <openthread/platform/radio.h>
+#include "nexus_infra_if.hpp"
+#include "nexus_radio.hpp"
 
 namespace ot {
 namespace Nexus {
@@ -73,11 +73,11 @@
      * @param[in] aLength   The packet length.
      * @param[in] aTimeUs   The timestamp in microseconds.
      */
-    void WritePacket(const otPlatInfraIfLinkLayerAddress &aSrcAddr,
-                     const otPlatInfraIfLinkLayerAddress &aDstAddr,
-                     const uint8_t                       *aBuffer,
-                     uint16_t                             aLength,
-                     uint64_t                             aTimeUs);
+    void WritePacket(const InfraIf::LinkLayerAddress &aSrcAddr,
+                     const InfraIf::LinkLayerAddress &aDstAddr,
+                     const uint8_t                   *aBuffer,
+                     uint16_t                         aLength,
+                     uint64_t                         aTimeUs);
 
 private:
     static constexpr uint32_t kPcapngShbType            = 0x0a0d0d0a;
@@ -102,7 +102,9 @@
     static constexpr uint16_t kTapChannelLength = 3;
     static constexpr uint8_t  kTapChannelPage   = 0;
 
+#ifndef __EMSCRIPTEN__
     FILE *mFile;
+#endif
 };
 
 } // namespace Nexus
diff --git a/tests/nexus/platform/nexus_radio.cpp b/tests/nexus/platform/nexus_radio.cpp
index 72a3db2..c78a630 100644
--- a/tests/nexus/platform/nexus_radio.cpp
+++ b/tests/nexus/platform/nexus_radio.cpp
@@ -140,6 +140,8 @@
 
 otError otPlatRadioTransmit(otInstance *aInstance, otRadioFrame *aFrame)
 {
+    OT_UNUSED_VARIABLE(aFrame);
+
     Error  error = kErrorNone;
     Radio &radio = AsNode(aInstance).mRadio;
 
@@ -154,7 +156,7 @@
     return error;
 }
 
-uint64_t otPlatRadioGetNow(otInstance *aInstance)
+otRadioTime64 otPlatRadioGetNow(otInstance *aInstance)
 {
     OT_UNUSED_VARIABLE(aInstance);
 
@@ -330,7 +332,7 @@
     return kErrorNone;
 }
 
-void otPlatRadioUpdateCslSampleTime(otInstance *aInstance, uint32_t aCslSampleTime)
+void otPlatRadioUpdateCslSampleTime(otInstance *aInstance, otRadioTime32 aCslSampleTime)
 {
     AsNode(aInstance).mRadio.mRadioContext.mCslSampleTime = aCslSampleTime;
 }
diff --git a/tests/nexus/platform/nexus_radio_model.cpp b/tests/nexus/platform/nexus_radio_model.cpp
new file mode 100644
index 0000000..25e6162
--- /dev/null
+++ b/tests/nexus/platform/nexus_radio_model.cpp
@@ -0,0 +1,62 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "nexus_radio_model.hpp"
+
+#include "nexus_node.hpp"
+#include "common/num_utils.hpp"
+
+#include <cmath>
+
+namespace ot {
+namespace Nexus {
+
+int16_t RadioModel::CalculateRssi(const Node &aTxNode, const Node &aRxNode)
+{
+    // Simple path loss model
+    // RSSI = TxPower - PathLoss
+    // PathLoss = kPathLossExponent * log10(distance) + kPathLossConstant
+
+    double dx       = aTxNode.GetPositionX() - aRxNode.GetPositionX();
+    double dy       = aTxNode.GetPositionY() - aRxNode.GetPositionY();
+    double distance = std::hypot(dx, dy);
+
+    distance = Max(distance, 1.0);
+
+    // Assume TxPower = 0 dBm
+    // RSSI = - (kPathLossConstant + kPathLossExponent * std::log10(distance))
+
+    double rssi = -(kPathLossConstant + kPathLossExponent * std::log10(distance));
+
+    return static_cast<int16_t>(std::round(rssi));
+}
+
+bool RadioModel::ShouldDropPacket(int16_t aRssi) { return aRssi < Radio::kRadioSensitivity; }
+
+} // namespace Nexus
+} // namespace ot
diff --git a/tests/nexus/platform/nexus_radio_model.hpp b/tests/nexus/platform/nexus_radio_model.hpp
new file mode 100644
index 0000000..22a18fe
--- /dev/null
+++ b/tests/nexus/platform/nexus_radio_model.hpp
@@ -0,0 +1,75 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#ifndef OT_NEXUS_PLATFORM_NEXUS_RADIO_MODEL_HPP_
+#define OT_NEXUS_PLATFORM_NEXUS_RADIO_MODEL_HPP_
+
+#include <stdint.h>
+
+namespace ot {
+namespace Nexus {
+
+class Node;
+
+/**
+ * This class implements a radio model for RSSI calculation.
+ *
+ */
+class RadioModel
+{
+public:
+    static constexpr double kPathLossConstant = 40.0;
+    static constexpr double kPathLossExponent = 20.0;
+
+    RadioModel(void) = delete;
+
+    /**
+     * This static method calculates the RSSI between two nodes based on their distance.
+     *
+     * @param[in] aTxNode  The transmitter node.
+     * @param[in] aRxNode  The receiver node.
+     *
+     * @returns The calculated RSSI in dBm.
+     */
+    static int16_t CalculateRssi(const Node &aTxNode, const Node &aRxNode);
+
+    /**
+     * This static method determines if a packet should be dropped based on its RSSI.
+     *
+     * @param[in] aRssi  The RSSI of the packet.
+     *
+     * @retval true if the packet should be dropped.
+     * @retval false if the packet should not be dropped.
+     */
+    static bool ShouldDropPacket(int16_t aRssi);
+};
+
+} // namespace Nexus
+} // namespace ot
+
+#endif // OT_NEXUS_PLATFORM_NEXUS_RADIO_MODEL_HPP_
diff --git a/tests/nexus/platform/nexus_sim.cpp b/tests/nexus/platform/nexus_sim.cpp
new file mode 100644
index 0000000..7424049
--- /dev/null
+++ b/tests/nexus/platform/nexus_sim.cpp
@@ -0,0 +1,66 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "nexus_sim.hpp"
+
+namespace ot {
+namespace Nexus {
+
+Simulation *Simulation::sSimulation = nullptr;
+
+Simulation::Simulation(void)
+    : mSpeedFactor(0.0f)
+    , mRestartRequested(false)
+{
+    sSimulation = this;
+}
+
+void Simulation::SetSpeedFactor(float aFactor)
+{
+    mSpeedFactor = aFactor;
+    mCv.notify_all();
+}
+
+void Simulation::RequestRestart(void)
+{
+    mRestartRequested = true;
+    mCv.notify_all();
+}
+
+void Simulation::WaitSpeedChange(uint32_t aTimeoutMs)
+{
+    std::unique_lock<std::mutex> lock(mMutex);
+    WaitSpeedChange(lock, aTimeoutMs);
+}
+
+void Simulation::Lock(void) { mMutex.lock(); }
+
+void Simulation::Unlock(void) { mMutex.unlock(); }
+
+} // namespace Nexus
+} // namespace ot
diff --git a/tests/nexus/platform/nexus_sim.hpp b/tests/nexus/platform/nexus_sim.hpp
new file mode 100644
index 0000000..1592aa5
--- /dev/null
+++ b/tests/nexus/platform/nexus_sim.hpp
@@ -0,0 +1,128 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#ifndef OT_NEXUS_PLATFORM_NEXUS_SIM_HPP_
+#define OT_NEXUS_PLATFORM_NEXUS_SIM_HPP_
+
+#include <atomic>
+#include <condition_variable>
+#include <mutex>
+
+namespace ot {
+namespace Nexus {
+
+class Simulation
+{
+public:
+    Simulation(void);
+
+    /**
+     * Sets the simulation speed factor.
+     *
+     * The caller must hold the simulation lock.
+     *
+     * @param[in] aFactor  The speed factor (e.g., 1.0 for real-time, 0.0 for paused).
+     */
+    void SetSpeedFactor(float aFactor);
+
+    /**
+     * Gets the simulation speed factor.
+     *
+     * This method is thread-safe and does not require holding the simulation lock.
+     *
+     * @returns The simulation speed factor.
+     */
+    float GetSpeedFactor(void) const { return mSpeedFactor; }
+
+    /**
+     * Waits for the speed factor to change or a timeout.
+     *
+     * The caller must NOT hold the simulation lock.
+     *
+     * @param[in] aTimeoutMs  The timeout in milliseconds.
+     */
+    void WaitSpeedChange(uint32_t aTimeoutMs);
+
+    /**
+     * Waits for the speed factor to change or a timeout.
+     *
+     * The caller must hold the simulation lock and pass it as @p aLock.
+     * The @p aLock must be a Lockable type (e.g., std::unique_lock<Simulation>).
+     *
+     * @param[in] aLock       The simulation lock.
+     * @param[in] aTimeoutMs  The timeout in milliseconds.
+     */
+    template <typename LockType> void WaitSpeedChange(LockType &aLock, uint32_t aTimeoutMs)
+    {
+        float initialFactor = mSpeedFactor;
+        mCv.wait_for(aLock, std::chrono::milliseconds(aTimeoutMs),
+                     [this, initialFactor] { return mSpeedFactor != initialFactor || mRestartRequested; });
+    }
+
+    /**
+     * Requests a restart of the simulation.
+     */
+    void RequestRestart(void);
+
+    /**
+     * Indicates whether or not a restart has been requested.
+     *
+     * @retval TRUE   If a restart has been requested.
+     * @retval FALSE  If a restart has not been requested.
+     */
+    bool IsRestartRequested(void) const { return mRestartRequested; }
+
+    /**
+     * Locks the simulation.
+     */
+    void Lock(void);
+
+    /**
+     * Unlocks the simulation.
+     */
+    void Unlock(void);
+
+    // Methods to support `Lockable` concept and `std::lock_guard`
+    void lock(void) { Lock(); }
+    void unlock(void) { Unlock(); }
+
+    static Simulation &Get(void) { return *sSimulation; }
+
+private:
+    std::atomic<float>          mSpeedFactor;
+    std::atomic<bool>           mRestartRequested;
+    mutable std::mutex          mMutex;
+    std::condition_variable_any mCv;
+
+    static Simulation *sSimulation;
+};
+
+} // namespace Nexus
+} // namespace ot
+
+#endif // OT_NEXUS_PLATFORM_NEXUS_SIM_HPP_
diff --git a/tests/nexus/platform/nexus_trel.cpp b/tests/nexus/platform/nexus_trel.cpp
index 92cd285..a21054e 100644
--- a/tests/nexus/platform/nexus_trel.cpp
+++ b/tests/nexus/platform/nexus_trel.cpp
@@ -74,17 +74,19 @@
 uint16_t Trel::sLastUsedUdpPort = kUdpPortStart;
 
 Trel::Trel(void)
-    : mEnabled(false)
+    : mNode(nullptr)
+    , mEnabled(false)
     , mUdpPort(sLastUsedUdpPort++)
 {
     ClearAllBytes(mCounters);
 }
 
+void Trel::Init(Node &aNode) { mNode = &aNode; }
+
 void Trel::Reset(void)
 {
     mEnabled = false;
     ClearAllBytes(mCounters);
-    mPendingTxList.Free();
 }
 
 void Trel::Enable(uint16_t &aUdpPort)
@@ -93,21 +95,20 @@
     aUdpPort = mUdpPort;
 }
 
-void Trel::Disable(void)
-{
-    mEnabled = false;
-    mPendingTxList.Free();
-}
+void Trel::Disable(void) { mEnabled = false; }
 
 void Trel::Send(const uint8_t *aUdpPayload, uint16_t aUdpPayloadLen, const Ip6::SockAddr &aDestSockAddr)
 {
-    PendingTx *pendingTx = PendingTx::Allocate();
+    Message *message;
 
-    VerifyOrQuit(pendingTx != nullptr);
-    SuccessOrQuit(pendingTx->mPayloadData.SetFrom(aUdpPayload, aUdpPayloadLen));
-    pendingTx->mDestSockAddr = aDestSockAddr;
+    VerifyOrQuit(mNode != nullptr);
 
-    mPendingTxList.PushAfterTail(*pendingTx);
+    message = mNode->Get<MessagePool>().Allocate(Message::kTypeIp6);
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->AppendBytes(aUdpPayload, aUdpPayloadLen));
+
+    mNode->mInfraIf.SendUdp(mNode->mInfraIf.GetLinkLocalAddress(), aDestSockAddr.GetAddress(), mUdpPort,
+                            aDestSockAddr.GetPort(), *message);
 
     mCounters.mTxPackets++;
     mCounters.mTxBytes += aUdpPayloadLen;
diff --git a/tests/nexus/platform/nexus_trel.hpp b/tests/nexus/platform/nexus_trel.hpp
index df25bdf..25bcd87 100644
--- a/tests/nexus/platform/nexus_trel.hpp
+++ b/tests/nexus/platform/nexus_trel.hpp
@@ -36,13 +36,16 @@
 namespace ot {
 namespace Nexus {
 
+class Node;
+
 struct Trel
 {
-    static constexpr uint16_t kUdpPortStart = 49152;
+    static constexpr uint16_t kUdpPortStart = 19152;
 
     typedef otPlatTrelCounters Counters;
 
     Trel(void);
+    void Init(Node &aNode);
     void Reset(void);
     void Enable(uint16_t &aUdpPort);
     void Disable(void);
@@ -50,19 +53,12 @@
     void ResetCounters(void) { ClearAllBytes(mCounters); }
     void Receive(Instance &aInstance, Heap::Data &aPayloadData, const Ip6::SockAddr &aSenderAddr);
 
-    struct PendingTx : public Heap::Allocatable<PendingTx>, public LinkedListEntry<PendingTx>
-    {
-        PendingTx    *mNext;
-        Heap::Data    mPayloadData;
-        Ip6::SockAddr mDestSockAddr;
-    };
-
     static uint16_t sLastUsedUdpPort;
 
-    bool                  mEnabled;
-    uint16_t              mUdpPort;
-    OwningList<PendingTx> mPendingTxList;
-    Counters              mCounters;
+    Node    *mNode;
+    bool     mEnabled;
+    uint16_t mUdpPort;
+    Counters mCounters;
 };
 
 } // namespace Nexus
diff --git a/tests/nexus/platform/nexus_udp.cpp b/tests/nexus/platform/nexus_udp.cpp
new file mode 100644
index 0000000..6b327b8
--- /dev/null
+++ b/tests/nexus/platform/nexus_udp.cpp
@@ -0,0 +1,305 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "nexus_udp.hpp"
+
+#include <openthread/platform/udp.h>
+
+#include "nexus_core.hpp"
+#include "nexus_node.hpp"
+#include "common/logging.hpp"
+#include "net/checksum.hpp"
+#include "thread/network_data_leader.hpp"
+
+namespace ot {
+namespace Nexus {
+
+Udp::Udp(Instance &aInstance)
+    : InstanceLocator(aInstance)
+{
+}
+
+Error Udp::Socket(Ip6::Udp::SocketHandle &aSocket)
+{
+    OT_UNUSED_VARIABLE(aSocket);
+    return kErrorNone;
+}
+
+Error Udp::Close(Ip6::Udp::SocketHandle &aSocket)
+{
+    OT_UNUSED_VARIABLE(aSocket);
+    return kErrorNone;
+}
+
+Error Udp::Bind(Ip6::Udp::SocketHandle &aSocket)
+{
+    OT_UNUSED_VARIABLE(aSocket);
+    return kErrorNone;
+}
+
+Error Udp::BindToNetif(Ip6::Udp::SocketHandle &aSocket, Ip6::NetifIdentifier aNetifIdentifier)
+{
+    OT_UNUSED_VARIABLE(aSocket);
+    OT_UNUSED_VARIABLE(aNetifIdentifier);
+    return kErrorNone;
+}
+
+Error Udp::Connect(Ip6::Udp::SocketHandle &aSocket)
+{
+    OT_UNUSED_VARIABLE(aSocket);
+    return kErrorNone;
+}
+
+Error Udp::Send(Ip6::Udp::SocketHandle &aSocket, Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
+{
+    Error                error   = kErrorNone;
+    Ip6::Address         srcAddr = aMessageInfo.GetSockAddr();
+    Ip6::NetifIdentifier netifId = aSocket.GetNetifId();
+
+    if (netifId == Ip6::kNetifUnspecified)
+    {
+        if (aMessageInfo.IsHostInterface())
+        {
+            netifId = Ip6::kNetifBackbone;
+        }
+        else if (aMessageInfo.GetPeerAddr().IsMulticast())
+        {
+            netifId = Ip6::kNetifThreadHost;
+        }
+        else if (GetNode().Get<Mle::Mle>().IsAttached() &&
+                 GetNode().Get<NetworkData::Leader>().IsOnMesh(aMessageInfo.GetPeerAddr()))
+        {
+            // If we are attached and the peer is on-mesh, we use the Thread interface.
+            netifId = Ip6::kNetifThreadHost;
+        }
+        else if (Core::Get().FindNodeByInfraIfAddress(aMessageInfo.GetPeerAddr()) != nullptr)
+        {
+            netifId = Ip6::kNetifBackbone;
+        }
+        else
+        {
+            // For all other communication (e.g. unjoined nodes or external commissioners),
+            // we prefer using the Backbone interface for better reliability.
+            netifId = Ip6::kNetifBackbone;
+        }
+    }
+
+    if (srcAddr.IsUnspecified() && (netifId == Ip6::kNetifBackbone))
+    {
+        if (Core::Get().IsThreadAddress(aMessageInfo.GetPeerAddr()))
+        {
+            srcAddr = GetNode().Get<Mle::Mle>().GetLinkLocalAddress();
+        }
+        else
+        {
+            srcAddr = GetNode().mInfraIf.GetLinkLocalAddress();
+        }
+    }
+
+    if (netifId == Ip6::kNetifBackbone)
+    {
+        GetNode().mInfraIf.SendUdp(srcAddr, aMessageInfo.GetPeerAddr(), aMessageInfo.GetSockPort(),
+                                   aMessageInfo.GetPeerPort(), aMessage);
+    }
+    else
+    {
+        Ip6::MessageInfo messageInfo(aMessageInfo);
+
+        error = GetNode().Get<Ip6::Udp>().SendDatagram(aMessage, messageInfo);
+    }
+
+    return error;
+}
+
+Error Udp::JoinMulticastGroup(Ip6::Udp::SocketHandle &aSocket,
+                              Ip6::NetifIdentifier    aNetifIdentifier,
+                              const Ip6::Address     &aAddress)
+{
+    OT_UNUSED_VARIABLE(aSocket);
+    OT_UNUSED_VARIABLE(aNetifIdentifier);
+    OT_UNUSED_VARIABLE(aAddress);
+    return kErrorNone;
+}
+
+Error Udp::LeaveMulticastGroup(Ip6::Udp::SocketHandle &aSocket,
+                               Ip6::NetifIdentifier    aNetifIdentifier,
+                               const Ip6::Address     &aAddress)
+{
+    OT_UNUSED_VARIABLE(aSocket);
+    OT_UNUSED_VARIABLE(aNetifIdentifier);
+    OT_UNUSED_VARIABLE(aAddress);
+    return kErrorNone;
+}
+
+bool Udp::HandleReceive(const Message &aMessage, const Ip6::Headers &aHeaders)
+{
+    bool handled        = false;
+    bool isThreadSource = (Core::Get().IsThreadAddress(aHeaders.GetSourceAddress()));
+
+    if (!GetNode().mInfraIf.HasAddress(aHeaders.GetDestinationAddress()) &&
+        !GetNode().Get<ThreadNetif>().HasUnicastAddress(aHeaders.GetDestinationAddress()) &&
+        !aHeaders.GetDestinationAddress().IsMulticast())
+    {
+        ExitNow();
+    }
+
+    // If the node is attached and the source is on-mesh, we expect the packet
+    // via Radio. We drop it here to prevent duplicate delivery when the
+    // packet is forwarded to the Infra interface.
+    if (GetNode().Get<Mle::Mle>().IsAttached() &&
+        GetNode().Get<NetworkData::Leader>().IsOnMesh(aHeaders.GetSourceAddress()))
+    {
+        ExitNow();
+    }
+
+    for (Ip6::Udp::SocketHandle &socket : GetNode().Get<Ip6::Udp>().GetUdpSockets())
+    {
+        Ip6::MessageInfo messageInfo;
+
+        if (!socket.ShouldUsePlatformUdp())
+        {
+            continue;
+        }
+
+        if (socket.GetSockName().GetPort() != aHeaders.GetDestinationPort())
+        {
+            continue;
+        }
+
+        if (socket.GetSockName().GetAddress().IsUnspecified() ||
+            socket.GetSockName().GetAddress() == aHeaders.GetDestinationAddress())
+        {
+            // Found a matching socket.
+        }
+        else
+        {
+            continue;
+        }
+
+        if (socket.GetPeerName().GetPort() != 0)
+        {
+            if (socket.GetPeerName().GetPort() != aHeaders.GetSourcePort() ||
+                socket.GetPeerName().GetAddress() != aHeaders.GetSourceAddress())
+            {
+                continue;
+            }
+        }
+
+        if (socket.mHandler == nullptr)
+        {
+            continue;
+        }
+
+        messageInfo.SetPeerAddr(aHeaders.GetSourceAddress());
+        messageInfo.SetPeerPort(aHeaders.GetSourcePort());
+        messageInfo.SetSockAddr(aHeaders.GetDestinationAddress());
+        messageInfo.SetSockPort(aHeaders.GetDestinationPort());
+        messageInfo.SetHopLimit(aHeaders.GetIp6Header().GetHopLimit());
+        messageInfo.SetIsHostInterface(!isThreadSource);
+
+        {
+            Message *payload = aMessage.Clone<kNoReservedHeader>();
+
+            VerifyOrExit(payload != nullptr);
+            payload->RemoveHeader(sizeof(Ip6::Header) + sizeof(Ip6::UdpHeader));
+
+            socket.mHandler(socket.mContext, payload, &messageInfo);
+            payload->Free();
+        }
+
+        handled = true;
+        if (!aHeaders.GetDestinationAddress().IsMulticast())
+        {
+            break;
+        }
+    }
+
+exit:
+    return handled;
+}
+
+Node &Udp::GetNode(otUdpSocket *aUdpSocket) { return AsNode(static_cast<otInstance *>(aUdpSocket->mHandle)); }
+
+Node &Udp::GetNode(void) { return static_cast<Node &>(GetInstance()); }
+
+const Node &Udp::GetNode(void) const { return static_cast<const Node &>(GetInstance()); }
+
+} // namespace Nexus
+} // namespace ot
+
+extern "C" {
+
+otError otPlatUdpSocket(otUdpSocket *aUdpSocket)
+{
+    return ot::Nexus::Udp::GetNode(aUdpSocket).mUdp.Socket(ot::AsCoreType(aUdpSocket));
+}
+
+otError otPlatUdpClose(otUdpSocket *aUdpSocket)
+{
+    return ot::Nexus::Udp::GetNode(aUdpSocket).mUdp.Close(ot::AsCoreType(aUdpSocket));
+}
+
+otError otPlatUdpBind(otUdpSocket *aUdpSocket)
+{
+    return ot::Nexus::Udp::GetNode(aUdpSocket).mUdp.Bind(ot::AsCoreType(aUdpSocket));
+}
+
+otError otPlatUdpBindToNetif(otUdpSocket *aUdpSocket, otNetifIdentifier aNetifIdentifier)
+{
+    return ot::Nexus::Udp::GetNode(aUdpSocket)
+        .mUdp.BindToNetif(ot::AsCoreType(aUdpSocket), ot::MapEnum(aNetifIdentifier));
+}
+
+otError otPlatUdpConnect(otUdpSocket *aUdpSocket)
+{
+    return ot::Nexus::Udp::GetNode(aUdpSocket).mUdp.Connect(ot::AsCoreType(aUdpSocket));
+}
+
+otError otPlatUdpSend(otUdpSocket *aUdpSocket, otMessage *aMessage, const otMessageInfo *aMessageInfo)
+{
+    return ot::Nexus::Udp::GetNode(aUdpSocket)
+        .mUdp.Send(ot::AsCoreType(aUdpSocket), ot::AsCoreType(aMessage), ot::AsCoreType(aMessageInfo));
+}
+
+otError otPlatUdpJoinMulticastGroup(otUdpSocket        *aUdpSocket,
+                                    otNetifIdentifier   aNetifIdentifier,
+                                    const otIp6Address *aAddress)
+{
+    return ot::Nexus::Udp::GetNode(aUdpSocket)
+        .mUdp.JoinMulticastGroup(ot::AsCoreType(aUdpSocket), ot::MapEnum(aNetifIdentifier), ot::AsCoreType(aAddress));
+}
+
+otError otPlatUdpLeaveMulticastGroup(otUdpSocket        *aUdpSocket,
+                                     otNetifIdentifier   aNetifIdentifier,
+                                     const otIp6Address *aAddress)
+{
+    return ot::Nexus::Udp::GetNode(aUdpSocket)
+        .mUdp.LeaveMulticastGroup(ot::AsCoreType(aUdpSocket), ot::MapEnum(aNetifIdentifier), ot::AsCoreType(aAddress));
+}
+
+} // extern "C"
diff --git a/tests/nexus/platform/nexus_udp.hpp b/tests/nexus/platform/nexus_udp.hpp
new file mode 100644
index 0000000..d63d5c0
--- /dev/null
+++ b/tests/nexus/platform/nexus_udp.hpp
@@ -0,0 +1,70 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#ifndef OT_NEXUS_PLATFORM_NEXUS_UDP_HPP_
+#define OT_NEXUS_PLATFORM_NEXUS_UDP_HPP_
+
+#include "common/locator.hpp"
+#include "instance/instance.hpp"
+
+struct otUdpSocket;
+
+namespace ot {
+namespace Nexus {
+
+class Node;
+
+class Udp : public InstanceLocator
+{
+public:
+    explicit Udp(Instance &aInstance);
+
+    static Node &GetNode(otUdpSocket *aUdpSocket);
+
+    Error Socket(Ip6::Udp::SocketHandle &aSocket);
+    Error Close(Ip6::Udp::SocketHandle &aSocket);
+    Error Bind(Ip6::Udp::SocketHandle &aSocket);
+    Error BindToNetif(Ip6::Udp::SocketHandle &aSocket, Ip6::NetifIdentifier aNetifIdentifier);
+    Error Connect(Ip6::Udp::SocketHandle &aSocket);
+    Error Send(Ip6::Udp::SocketHandle &aSocket, Message &aMessage, const Ip6::MessageInfo &aMessageInfo);
+    Error JoinMulticastGroup(Ip6::Udp::SocketHandle &aSocket,
+                             Ip6::NetifIdentifier    aNetifIdentifier,
+                             const Ip6::Address     &aAddress);
+    Error LeaveMulticastGroup(Ip6::Udp::SocketHandle &aSocket,
+                              Ip6::NetifIdentifier    aNetifIdentifier,
+                              const Ip6::Address     &aAddress);
+
+    bool HandleReceive(const Message &aMessage, const Ip6::Headers &aHeaders);
+
+    Node       &GetNode(void);
+    const Node &GetNode(void) const;
+};
+} // namespace Nexus
+} // namespace ot
+
+#endif // OT_NEXUS_PLATFORM_NEXUS_UDP_HPP_
diff --git a/tests/nexus/platform/nexus_utils.hpp b/tests/nexus/platform/nexus_utils.hpp
index ebff2b2..833ec61 100644
--- a/tests/nexus/platform/nexus_utils.hpp
+++ b/tests/nexus/platform/nexus_utils.hpp
@@ -80,6 +80,11 @@
 namespace Nexus {
 
 /**
+ * Invalid Node ID.
+ */
+static constexpr uint32_t kInvalidNodeId = 0xffffffff;
+
+/**
  * CSL period constants in units of 10 symbols.
  */
 static constexpr uint16_t kCslPeriod500ms  = 500 * 1000 / OT_US_PER_TEN_SYMBOLS;
diff --git a/tests/nexus/platform/nexus_wasm.cpp b/tests/nexus/platform/nexus_wasm.cpp
new file mode 100644
index 0000000..4917685
--- /dev/null
+++ b/tests/nexus/platform/nexus_wasm.cpp
@@ -0,0 +1,296 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * @file
+ *   This file defines the WebAssembly bindings for the Nexus simulator.
+ */
+
+#include "openthread-core-config.h"
+
+#include <deque>
+#include <string>
+#include <vector>
+
+#include <emscripten.h>
+#include <emscripten/bind.h>
+#include <emscripten/val.h>
+
+#include <openthread/thread.h>
+#include <openthread/thread_ftd.h>
+
+#include "nexus_core.hpp"
+#include "nexus_node.hpp"
+#include "nexus_radio.hpp"
+#include "nexus_radio_model.hpp"
+#include "mac/mac.hpp"
+#include "thread/mle.hpp"
+#include "thread/neighbor_table.hpp"
+
+namespace ot {
+namespace Nexus {
+
+using namespace emscripten;
+
+/**
+ * This class manages simulation events and exposes them to the WASM environment.
+ *
+ * It implements the Observer interface to capture events from the simulator core.
+ */
+class WasmManager : public Observer
+{
+public:
+    static constexpr char kEventNodeStateChanged[] = "node_state_changed";
+    static constexpr char kEventLinkUpdate[]       = "link_update";
+    static constexpr char kEventPacketEvent[]      = "packet_event";
+    static constexpr char kEventHeartbeat[]        = "heartbeat";
+
+    /**
+     * Represents a simulation event to be consumed by JavaScript.
+     */
+    struct Event
+    {
+        std::string mType;
+        val         mData;
+    };
+
+    /**
+     * Returns the singleton instance of WasmManager.
+     *
+     * @returns The WasmManager instance.
+     */
+    static WasmManager &Get(void)
+    {
+        static WasmManager sManager;
+        return sManager;
+    }
+
+    /**
+     * Initializes the manager and attaches it as an observer to the core.
+     *
+     * This method is idempotent and ensures the manager is registered as a simulation observer.
+     */
+    void Init(void)
+    {
+        if (!mInitialized)
+        {
+            Core::Get().AddObserver(*this);
+            mInitialized = true;
+        }
+    }
+
+    /**
+     * Polls the next simulation event from the internal queue.
+     *
+     * The returned event object has the following structure in JavaScript:
+     * {
+     *   "type": string (e.g., "node_state_changed", "link_update", "packet_event"),
+     *   "data": object (type-specific payload)
+     * }
+     *
+     * @returns The event object or val::null() if the queue is empty.
+     */
+    val PollEvent(void)
+    {
+        val eventVal = val::null();
+
+        if (!mEventQueue.empty())
+        {
+            const Event &event = mEventQueue.front();
+
+            eventVal = val::object();
+            eventVal.set("type", event.mType);
+            eventVal.set("data", event.mData);
+            mEventQueue.pop_front();
+        }
+
+        return eventVal;
+    }
+
+    // Observer overrides
+    void OnNodeStateChanged(Node *aNode) override
+    {
+        val data = val::object();
+
+        data.set("id", aNode->GetId());
+        data.set("role", aNode->GetExtendedRoleString());
+        data.set("x", aNode->GetPositionX());
+        data.set("y", aNode->GetPositionY());
+        data.set("rloc16", aNode->Get<Mle::Mle>().GetRloc16());
+        data.set("extAddress", aNode->Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+
+        PushEvent(kEventNodeStateChanged, data);
+    }
+
+    void OnLinkUpdate(uint32_t aSrcId, uint32_t aDstId, bool aIsActive) override
+    {
+        val data = val::object();
+
+        data.set("srcId", aSrcId);
+        data.set("dstId", aDstId);
+        data.set("isActive", aIsActive);
+
+        PushEvent(kEventLinkUpdate, data);
+    }
+
+    void OnPacketEvent(uint32_t aSrcId, uint32_t aDstId, const uint8_t *aFrame, uint16_t aLength) override
+    {
+        val data = val::object();
+
+        data.set("srcId", aSrcId);
+        data.set("dstId", aDstId);
+        data.set("length", aLength);
+        data.set("frame", val(typed_memory_view(aLength, aFrame)).call<val>("slice"));
+
+        PushEvent(kEventPacketEvent, data);
+    }
+
+    void OnHeartbeat(uint64_t aTimestampUs) override
+    {
+        val data = val::object();
+
+        data.set("timestampUs", aTimestampUs);
+
+        PushEvent(kEventHeartbeat, data);
+    }
+
+    void OnClearEvents(void) override { mEventQueue.clear(); }
+
+    /**
+     * WasmManager does not participate in state dumping as events are streamed in real-time.
+     */
+    void DumpState(void) override {}
+
+    bool IsConnected(void) const override { return true; }
+
+private:
+    static constexpr size_t kMaxQueueSize = 1000;
+
+    void PushEvent(const std::string &aType, val aData)
+    {
+        if (mEventQueue.size() >= kMaxQueueSize)
+        {
+            mEventQueue.pop_front();
+        }
+        mEventQueue.push_back({aType, aData});
+    }
+
+    std::deque<Event> mEventQueue; // Internal event queue (should be polled regularly by JS to prevent growth)
+    bool              mInitialized = false;
+};
+
+// The global simulator core instance.
+Core gWasmCore;
+
+} // namespace Nexus
+} // namespace ot
+
+// Embind definitions
+EMSCRIPTEN_BINDINGS(nexus_simulator)
+{
+    using namespace ot::Nexus;
+
+    // Initialize WasmManager during module binding
+    WasmManager::Get().Init();
+
+    enum_<Node::JoinMode>("JoinMode")
+        .value("AsFtd", Node::kAsFtd)
+        .value("AsFed", Node::kAsFed)
+        .value("AsMed", Node::kAsMed)
+        .value("AsSed", Node::kAsSed)
+        .value("AsSedWithFullNetData", Node::kAsSedWithFullNetData);
+
+    function("pollEvent", optional_override([]() -> val { return WasmManager::Get().PollEvent(); }));
+
+    function("getRadioParameters", optional_override([]() -> val {
+                 val params = val::object();
+                 params.set("pathLossConstant", RadioModel::kPathLossConstant);
+                 params.set("pathLossExponent", RadioModel::kPathLossExponent);
+                 params.set("radioSensitivity", Radio::kRadioSensitivity);
+                 params.set("mleLinkRequestMarginMin", OPENTHREAD_CONFIG_MLE_LINK_REQUEST_MARGIN_MIN);
+                 return params;
+             }));
+
+    function("getNow", optional_override([]() -> uint32_t { return Core::Get().GetNow().GetValue(); }));
+
+    function("stepSimulation", optional_override([](uint32_t aElapsedMs) { Core::Get().AdvanceTime(aElapsedMs); }));
+
+    function("createNode", optional_override([](float aX, float aY) -> uint32_t {
+                 Node &node = Core::Get().CreateNode();
+                 node.SetPosition(aX, aY);
+                 WasmManager::Get().OnNodeStateChanged(&node);
+                 return node.GetId();
+             }));
+
+    function("setNodePosition", optional_override([](uint32_t aNodeId, float aX, float aY) {
+                 Node *node = Core::Get().FindNodeById(aNodeId);
+
+                 if (node != nullptr)
+                 {
+                     node->SetPosition(aX, aY);
+                 }
+             }));
+
+    function("getNodeId", optional_override([](std::string aExtAddress) -> uint32_t {
+                 ot::Mac::ExtAddress extAddr;
+                 uint32_t            id = kInvalidNodeId;
+
+                 if (extAddr.FromString(aExtAddress.c_str()) == ot::kErrorNone)
+                 {
+                     Node *node = Core::Get().GetNodes().FindMatching(extAddr);
+                     if (node != nullptr)
+                     {
+                         id = node->GetId();
+                     }
+                 }
+                 return id;
+             }));
+
+    function("setNodeEnabled",
+             optional_override([](uint32_t aNodeId, bool aEnabled) { Core::Get().SetNodeEnabled(aNodeId, aEnabled); }));
+
+    function("formNetwork", optional_override([](uint32_t aNodeId) {
+                 Node *node = Core::Get().FindNodeById(aNodeId);
+
+                 if (node != nullptr)
+                 {
+                     node->Form();
+                 }
+             }));
+
+    function("joinNetwork", optional_override([](uint32_t aNodeId, uint32_t aTargetNodeId, Node::JoinMode aJoinMode) {
+                 Core &core   = Core::Get();
+                 Node *node   = core.FindNodeById(aNodeId);
+                 Node *target = core.FindNodeById(aTargetNodeId);
+
+                 if (node != nullptr && target != nullptr)
+                 {
+                     node->Join(*target, aJoinMode);
+                 }
+             }));
+}
diff --git a/tests/nexus/platform/simulation.proto b/tests/nexus/platform/simulation.proto
new file mode 100644
index 0000000..ba5e421
--- /dev/null
+++ b/tests/nexus/platform/simulation.proto
@@ -0,0 +1,233 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package nexus;
+
+/**
+ * Represents an event occurring within the simulation.
+ */
+message SimulationEvent {
+  uint64 timestamp_us = 1; // Simulation time in microseconds
+  oneof event {
+    NodeUpdate node_update = 2;       // Node state or position update
+    LinkUpdate link_update = 3;       // Link connectivity change
+    PacketCaptured packet_captured = 4; // Radio packet capture
+    Heartbeat heartbeat = 5;           // Periodic keep-alive and clock sync
+  }
+}
+
+/**
+ * Periodic event sent to maintain the gRPC stream and sync simulation time.
+ */
+message Heartbeat {}
+
+/**
+ * Detailed Thread device roles.
+ */
+enum NodeRole {
+  ROLE_UNKNOWN = 0;
+  ROLE_DISABLED = 1;
+  ROLE_DETACHED = 2;
+  ROLE_CHILD = 3;
+  ROLE_ROUTER = 4;
+  ROLE_LEADER = 5;
+  ROLE_REED = 6; // Router Eligible End Device
+  ROLE_FED = 7;  // Full End Device
+  ROLE_MED = 8;  // Minimal End Device
+  ROLE_SED = 9;  // Sleepy End Device
+}
+
+/**
+ * Information about a node's current state and position.
+ */
+message NodeUpdate {
+  uint32 node_id = 1;
+  NodeRole role = 2;
+  uint32 rloc16 = 3;
+  float x = 4;
+  float y = 5;
+}
+
+/**
+ * Information about a radio link between two nodes.
+ */
+message LinkUpdate {
+  uint32 source_node_id = 1;
+  uint32 destination_node_id = 2;
+  bool is_active = 3;
+  float link_quality = 4; // e.g., RSSI or link quality index
+}
+
+/**
+ * Captured radio packet data.
+ */
+message PacketCaptured {
+  uint32 source_node_id = 1;
+  uint32 destination_node_id = 2;
+  string protocol = 3;      // Human-readable protocol name (e.g., "IEEE 802.15.4 ACK")
+  string summary = 4;       // Short summary of packet content
+  bytes raw_payload = 5;    // Raw PSDU data
+  string decoded_info = 6;  // Reserved for future use
+}
+
+message ResetRequest {}
+message ResetResponse {}
+
+/**
+ * Request to change simulation speed.
+ */
+message SetSpeedRequest {
+  float speed_factor = 1; // 1.0 for real-time, 0.0 for paused
+}
+message SetSpeedResponse {}
+
+/**
+ * Request to enable or disable a node's radio.
+ */
+message SetNodeStateRequest {
+  uint32 node_id = 1;
+  bool enabled = 2;
+}
+message SetNodeStateResponse {}
+
+/**
+ * Request to move a node in the simulation space.
+ */
+message SetNodePositionRequest {
+  uint32 node_id = 1;
+  float x = 2;
+  float y = 3;
+}
+message SetNodePositionResponse {}
+
+/**
+ * Request to create a new node at a given position.
+ */
+message CreateNodeRequest {
+  float x = 1;
+  float y = 2;
+}
+
+message CreateNodeResponse {
+  uint32 node_id = 1;
+}
+
+/**
+ * Request to form a new Thread network on the specified node.
+ */
+message FormNetworkRequest {
+  uint32 node_id = 1;
+}
+
+message FormNetworkResponse {}
+
+/**
+ * Thread device types for joining.
+ */
+enum JoinMode {
+  JOIN_MODE_FTD = 0;
+  JOIN_MODE_MED = 1;
+  JOIN_MODE_SED = 2;
+  JOIN_MODE_FED = 3;
+}
+
+/**
+ * Request for a node to join an existing network.
+ */
+message JoinNetworkRequest {
+  uint32 node_id = 1;
+  uint32 target_node_id = 2;
+  JoinMode mode = 3; 
+}
+
+message JoinNetworkResponse {}
+
+/**
+ * Nexus simulation control and monitoring service.
+ */
+service NexusService {
+  /**
+   * Streams simulation events to the client.
+   */
+  rpc StreamEvents(StreamRequest) returns (stream SimulationEvent);
+
+  /**
+   * Resets the simulation to the initial state.
+   */
+  rpc Reset(ResetRequest) returns (ResetResponse);
+
+  /**
+   * Sets the simulation speed factor.
+   */
+  rpc SetSpeed(SetSpeedRequest) returns (SetSpeedResponse);
+
+  /**
+   * Enables or disables a node.
+   */
+  rpc SetNodeState(SetNodeStateRequest) returns (SetNodeStateResponse);
+
+  /**
+   * Moves a node to a new position.
+   */
+  rpc SetNodePosition(SetNodePositionRequest) returns (SetNodePositionResponse);
+
+  /**
+   * Adds a new node to the simulation.
+   */
+  rpc CreateNode(CreateNodeRequest) returns (CreateNodeResponse);
+
+  /**
+   * Forms a network on a node.
+   */
+  rpc FormNetwork(FormNetworkRequest) returns (FormNetworkResponse);
+
+  /**
+   * Joins a node to a network.
+   */
+  rpc JoinNetwork(JoinNetworkRequest) returns (JoinNetworkResponse);
+
+  /**
+   * Retrieves the radio model parameters.
+   */
+  rpc GetRadioParameters(GetRadioParametersRequest) returns (GetRadioParametersResponse);
+}
+
+message GetRadioParametersRequest {}
+
+message GetRadioParametersResponse {
+  float path_loss_constant = 1;
+  float path_loss_exponent = 2;
+  float radio_sensitivity = 3;
+  int32 mle_link_request_margin_min = 4;
+}
+
+message StreamRequest {
+  bool live = 1; // Reserved for future use
+}
diff --git a/tests/nexus/run_nexus_tests.sh b/tests/nexus/run_nexus_tests.sh
index da3a513..c4afdbc 100755
--- a/tests/nexus/run_nexus_tests.sh
+++ b/tests/nexus/run_nexus_tests.sh
@@ -193,6 +193,52 @@
     "1_3_DBR_TC_1"
     "1_3_DBR_TC_2"
     "1_3_DBR_TC_3"
+    "1_3_DBR_TC_6"
+    "1_3_DBR_TC_7A"
+    "1_3_DBR_TC_7B"
+    "1_3_DBR_TC_7C"
+    "1_3_DBR_TC_8"
+    "1_3_DBR_TC_10"
+    "1_3_DPR_TC_1"
+    "1_3_DPR_TC_2"
+    "1_3_SRP_TC_1"
+    "1_3_SRP_TC_2"
+    "1_3_SRP_TC_3"
+    "1_3_SRP_TC_4"
+    "1_3_SRP_TC_5"
+    "1_3_SRP_TC_6"
+    "1_3_SRP_TC_8"
+    "1_3_SRP_TC_11"
+    "1_3_SRP_TC_12"
+    "1_3_SRP_TC_13"
+    "1_3_SRP_TC_15"
+    "1_3_SRPC_TC_1"
+    "1_3_SRPC_TC_4"
+    "1_3_SRPC_TC_5"
+    "1_3_SRPC_TC_7"
+    "1_3_GEN_TC_1"
+    "1_3_GEN_TC_2"
+    "1_3_DIAG_TC_1"
+    "1_3_DIAG_TC_2"
+    "1_4_TREL_TC_1"
+    "1_4_TREL_TC_2"
+    "1_4_TREL_TC_3"
+    "1_4_TREL_TC_4"
+    "1_4_TREL_TC_5"
+    "1_4_TREL_TC_6"
+    "1_4_DNS_TC_1"
+    "1_4_DNS_TC_3"
+    "1_4_DNS_TC_5"
+    "1_4_PIC_TC_1"
+    "1_4_PIC_TC_3"
+    "1_4_PIC_TC_4"
+    "1_4_CS_TC_3"
+    "srp_client_change_lease"
+    "inform_previous_parent_on_reattach"
+    "leader_reboot_multiple_link_request"
+    "router_reboot_multiple_link_request"
+    "pbbr_aloc"
+    "retransmission_security"
 )
 
 # Use provided arguments or the default test list
@@ -210,7 +256,7 @@
     # Strip 'nexus_' prefix if present
     test_full="${test_full#nexus_}"
 
-    local test_base="${test_full%_[AB]}"
+    local test_base="${test_full%_[ABC]}"
     local topology=""
     if [[ $test_full != "$test_base" ]]; then
         topology="${test_full##*_}"
@@ -285,6 +331,9 @@
         1_1_6_1_1 | 1_1_6_1_2 | 1_1_6_1_3 | 1_1_6_1_6 | 1_1_6_2_1 | 1_1_6_2_2 | 1_1_6_3_1 | 1_1_6_3_2 | 1_1_6_4_1 | 1_1_6_4_2 | 1_1_6_5_1 | 1_1_6_5_2 | 1_1_6_5_3 | 1_1_6_6_1 | 1_1_6_6_2 | 1_1_9_2_1 | 1_1_9_2_3 | 1_1_9_2_4 | 1_1_9_2_19)
             expanded_tests+=("${t}_A" "${t}_B")
             ;;
+        1_3_GEN_TC_1)
+            expanded_tests+=("${t}_A" "${t}_B" "${t}_C")
+            ;;
         *)
             expanded_tests+=("$t")
             ;;
diff --git a/tests/nexus/test_1_1_5_1_1.cpp b/tests/nexus/test_1_1_5_1_1.cpp
index aeac0cf..0c7c524 100644
--- a/tests/nexus/test_1_1_5_1_1.cpp
+++ b/tests/nexus/test_1_1_5_1_1.cpp
@@ -75,7 +75,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Leader forms network");
diff --git a/tests/nexus/test_1_1_5_1_10.cpp b/tests/nexus/test_1_1_5_1_10.cpp
index 030edc0..7a1bd76 100644
--- a/tests/nexus/test_1_1_5_1_10.cpp
+++ b/tests/nexus/test_1_1_5_1_10.cpp
@@ -95,7 +95,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Leader, Router_1, Router_2");
@@ -106,15 +106,9 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-
-    router1.AllowList(leader);
-    router1.AllowList(router2);
-
-    router2.AllowList(leader);
-    router2.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(router1, router2);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -140,11 +134,8 @@
      */
 
     /** Restricted topology for DUT. */
-    dut.AllowList(router1);
-    dut.AllowList(router2);
-
-    router1.AllowList(dut);
-    router2.AllowList(dut);
+    AllowLinkBetween(dut, router1);
+    AllowLinkBetween(dut, router2);
 
     SuccessOrQuit(dut.Get<Mac::Filter>().AddRssIn(router2.Get<Mac::Mac>().GetExtAddress(), kRssiLinkQuality2));
     SuccessOrQuit(router2.Get<Mac::Filter>().AddRssIn(dut.Get<Mac::Mac>().GetExtAddress(), kRssiLinkQuality2));
diff --git a/tests/nexus/test_1_1_5_1_11.cpp b/tests/nexus/test_1_1_5_1_11.cpp
index e5688a9..1956277 100644
--- a/tests/nexus/test_1_1_5_1_11.cpp
+++ b/tests/nexus/test_1_1_5_1_11.cpp
@@ -100,39 +100,11 @@
 
     nexus.AdvanceTime(0);
 
-    /**
-     * Use AllowList feature to restrict the topology.
-     */
-
-    /**
-     * Leader <-> REED_1
-     */
-    leader.AllowList(reed1);
-    reed1.AllowList(leader);
-
-    /**
-     * Leader <-> Router_2
-     */
-    leader.AllowList(router2);
-    router2.AllowList(leader);
-
-    /**
-     * REED_1 <-> DUT
-     */
-    reed1.AllowList(dut);
-    dut.AllowList(reed1);
-
-    /**
-     * Router_2 <-> DUT
-     */
-    router2.AllowList(dut);
-    dut.AllowList(router2);
-
-    /**
-     * REED_1 <-> Router_2 (to ensure they can see each other if needed, though not strictly required by topology)
-     */
-    reed1.AllowList(router2);
-    router2.AllowList(reed1);
+    AllowLinkBetween(leader, reed1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(reed1, dut);
+    AllowLinkBetween(router2, dut);
+    AllowLinkBetween(reed1, router2);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Leader, REED_1, Router_2");
diff --git a/tests/nexus/test_1_1_5_1_12.cpp b/tests/nexus/test_1_1_5_1_12.cpp
index aef59e5..748524b 100644
--- a/tests/nexus/test_1_1_5_1_12.cpp
+++ b/tests/nexus/test_1_1_5_1_12.cpp
@@ -78,16 +78,14 @@
     dut.SetName("DUT");
 
     // Leader <-> Router_2
-    leader.AllowList(router2);
-    router2.AllowList(leader);
+    AllowLinkBetween(leader, router2);
 
     // Leader <-> DUT
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Ensure topology is formed correctly.");
@@ -133,8 +131,7 @@
      * - Description: Harness enables communication between Router_1 (DUT) and Router_2.
      * - Pass Criteria: N/A
      */
-    dut.AllowList(router2);
-    router2.AllowList(dut);
+    AllowLinkBetween(dut, router2);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 4: The DUT and Router_2 automatically exchange unicast Link Request and unicast Link Accept messages.");
diff --git a/tests/nexus/test_1_1_5_1_13.cpp b/tests/nexus/test_1_1_5_1_13.cpp
index 8525e9f..4031491 100644
--- a/tests/nexus/test_1_1_5_1_13.cpp
+++ b/tests/nexus/test_1_1_5_1_13.cpp
@@ -82,9 +82,7 @@
 
     nexus.AdvanceTime(0);
 
-    // Use AllowList feature to restrict the topology.
-    leader.AllowList(router);
-    router.AllowList(leader);
+    AllowLinkBetween(leader, router);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -127,7 +125,7 @@
      * - Pass Criteria: N/A
      */
     router.Reset();
-    router.AllowList(leader);
+    AllowLinkBetween(router, leader);
     router.Get<ThreadNetif>().Up();
     SuccessOrQuit(router.Get<Mle::Mle>().Start());
 
diff --git a/tests/nexus/test_1_1_5_1_2.cpp b/tests/nexus/test_1_1_5_1_2.cpp
index 5ea4393..d61a8fe 100644
--- a/tests/nexus/test_1_1_5_1_2.cpp
+++ b/tests/nexus/test_1_1_5_1_2.cpp
@@ -98,15 +98,9 @@
 
     nexus.AdvanceTime(0);
 
-    // Use AllowList feature to restrict the topology.
-    leader.AllowList(router);
-    router.AllowList(leader);
-
-    router.AllowList(med);
-    med.AllowList(router);
-
-    router.AllowList(sed);
-    sed.AllowList(router);
+    AllowLinkBetween(leader, router);
+    AllowLinkBetween(router, med);
+    AllowLinkBetween(router, sed);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
diff --git a/tests/nexus/test_1_1_5_1_3.cpp b/tests/nexus/test_1_1_5_1_3.cpp
index e127f39..9d59e96 100644
--- a/tests/nexus/test_1_1_5_1_3.cpp
+++ b/tests/nexus/test_1_1_5_1_3.cpp
@@ -106,7 +106,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     /**
      * Step 1: Router_2
@@ -121,15 +121,9 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    leader.AllowList(router2);
-    router2.AllowList(leader);
-
-    router1.AllowList(router2);
-    router2.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(router1, router2);
 
     leader.Get<Mle::Mle>().SetPreferredLeaderPartitionId(kMaxPartitionId);
     leader.Form();
diff --git a/tests/nexus/test_1_1_5_1_4.cpp b/tests/nexus/test_1_1_5_1_4.cpp
index 42effe7..e78f4d3 100644
--- a/tests/nexus/test_1_1_5_1_4.cpp
+++ b/tests/nexus/test_1_1_5_1_4.cpp
@@ -95,7 +95,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     /**
      * Step 1: Router_2
@@ -117,15 +117,9 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    leader.AllowList(router2);
-    router2.AllowList(leader);
-
-    router1.AllowList(router2);
-    router2.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(router1, router2);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_1_5.cpp b/tests/nexus/test_1_1_5_1_5.cpp
index 0ab7eae..618d5e6 100644
--- a/tests/nexus/test_1_1_5_1_5.cpp
+++ b/tests/nexus/test_1_1_5_1_5.cpp
@@ -84,7 +84,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -95,9 +95,7 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_1_6.cpp b/tests/nexus/test_1_1_5_1_6.cpp
index bcc4124..b958770 100644
--- a/tests/nexus/test_1_1_5_1_6.cpp
+++ b/tests/nexus/test_1_1_5_1_6.cpp
@@ -72,13 +72,11 @@
     leader.SetName("LEADER");
     router.SetName("ROUTER");
 
-    // Use AllowList feature to restrict the topology
-    leader.AllowList(router);
-    router.AllowList(leader);
+    AllowLinkBetween(leader, router);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Verify topology is formed correctly");
diff --git a/tests/nexus/test_1_1_5_1_7.cpp b/tests/nexus/test_1_1_5_1_7.cpp
index 8b41ee0..184ebe4 100644
--- a/tests/nexus/test_1_1_5_1_7.cpp
+++ b/tests/nexus/test_1_1_5_1_7.cpp
@@ -102,20 +102,15 @@
     for (uint16_t i = 0; i < aCount; i++)
     {
         aNodes[i] = &aNexus.CreateNode();
-
-        String<16> name;
-
-        name.Append("%s_%u", aNamePrefix, i + 1);
-        aNodes[i]->SetName(name.AsCString());
+        aNodes[i]->SetName(aNamePrefix, i + 1);
     }
 }
 
-static void AllowListNodes(Node &aRouter, Node *aNodes[], uint16_t aCount)
+static void AllowLinkBetweenNodes(Node &aRouter, Node *aNodes[], uint16_t aCount)
 {
     for (uint16_t i = 0; i < aCount; i++)
     {
-        aRouter.AllowList(*aNodes[i]);
-        aNodes[i]->AllowList(aRouter);
+        AllowLinkBetween(aRouter, *aNodes[i]);
     }
 }
 
@@ -164,12 +159,9 @@
 
     nexus.AdvanceTime(0);
 
-    // Use AllowList feature to restrict the topology.
-    leader.AllowList(router);
-    router.AllowList(leader);
-
-    AllowListNodes(router, meds, kNumMeds);
-    AllowListNodes(router, seds, kNumSeds);
+    AllowLinkBetween(leader, router);
+    AllowLinkBetweenNodes(router, meds, kNumMeds);
+    AllowLinkBetweenNodes(router, seds, kNumSeds);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Leader, Router_1 (DUT), Children");
diff --git a/tests/nexus/test_1_1_5_1_8.cpp b/tests/nexus/test_1_1_5_1_8.cpp
index 7d383c0..ec7532c 100644
--- a/tests/nexus/test_1_1_5_1_8.cpp
+++ b/tests/nexus/test_1_1_5_1_8.cpp
@@ -92,23 +92,13 @@
     router3.SetName("ROUTER_3");
     dut.SetName("ROUTER_4");
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     nexus.AdvanceTime(0);
 
-    // Use AllowList feature to restrict the topology.
-    // L <-> R3
-    // R3 <-> R1
-    // R1 <-> R2
-    // DUT hears R1, R2, R3
-    leader.AllowList(router3);
-    router3.AllowList(leader);
-
-    router3.AllowList(router1);
-    router1.AllowList(router3);
-
-    router1.AllowList(router2);
-    router2.AllowList(router1);
+    AllowLinkBetween(leader, router3);
+    AllowLinkBetween(router3, router1);
+    AllowLinkBetween(router1, router2);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Leader, Router_1, Router_2, Router_3");
@@ -144,11 +134,8 @@
      *   a link quality of 3 (highest).
      * - Pass Criteria: N/A
      */
-    dut.AllowList(router2);
-    dut.AllowList(router3);
-
-    router2.AllowList(dut);
-    router3.AllowList(dut);
+    AllowLinkBetween(dut, router2);
+    AllowLinkBetween(dut, router3);
 
     // Harness configures the RSSI to prefer Router 3.
     // All values below enable Link Quality 3 (highest).
diff --git a/tests/nexus/test_1_1_5_1_9.cpp b/tests/nexus/test_1_1_5_1_9.cpp
index 2e0589d..2631d7d 100644
--- a/tests/nexus/test_1_1_5_1_9.cpp
+++ b/tests/nexus/test_1_1_5_1_9.cpp
@@ -104,7 +104,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     /**
      * Step 1: Leader, Router_1, REED_2, REED_1
@@ -114,17 +114,10 @@
      */
     Log("Step 1: Setup the topology without the DUT");
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    leader.AllowList(reed1);
-    reed1.AllowList(leader);
-
-    leader.AllowList(reed2);
-    reed2.AllowList(leader);
-
-    router1.AllowList(reed1);
-    reed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, reed1);
+    AllowLinkBetween(leader, reed2);
+    AllowLinkBetween(router1, reed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -164,10 +157,8 @@
     SuccessOrQuit(reed2.Get<Mac::Filter>().AddRssIn(leader.Get<Mac::Mac>().GetExtAddress(), kLq3Rssi));
 
     // Setup DUT connectivity (only to REEDs)
-    dut.AllowList(reed1);
-    reed1.AllowList(dut);
-    dut.AllowList(reed2);
-    reed2.AllowList(dut);
+    AllowLinkBetween(dut, reed1);
+    AllowLinkBetween(dut, reed2);
 
     SuccessOrQuit(dut.Get<Mac::Filter>().AddRssIn(reed1.Get<Mac::Mac>().GetExtAddress(), kLq3Rssi));
     SuccessOrQuit(dut.Get<Mac::Filter>().AddRssIn(reed2.Get<Mac::Mac>().GetExtAddress(), kLq3Rssi));
diff --git a/tests/nexus/test_1_1_5_2_1.cpp b/tests/nexus/test_1_1_5_2_1.cpp
index 0dabc59..6f66d2d 100644
--- a/tests/nexus/test_1_1_5_2_1.cpp
+++ b/tests/nexus/test_1_1_5_2_1.cpp
@@ -83,19 +83,13 @@
     reed1.SetName("REED_1");
     med1.SetName("MED_1");
 
-    // Establish topology using AllowList
-    dut.AllowList(leader);
-    leader.AllowList(dut);
-
-    dut.AllowList(reed1);
-    reed1.AllowList(dut);
-
-    reed1.AllowList(med1);
-    med1.AllowList(reed1);
+    AllowLinkBetween(dut, leader);
+    AllowLinkBetween(dut, reed1);
+    AllowLinkBetween(reed1, med1);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Router_1 (DUT)");
diff --git a/tests/nexus/test_1_1_5_2_3.cpp b/tests/nexus/test_1_1_5_2_3.cpp
index c1bcbcc..a9d1920 100644
--- a/tests/nexus/test_1_1_5_2_3.cpp
+++ b/tests/nexus/test_1_1_5_2_3.cpp
@@ -86,10 +86,12 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     leader.Get<Mle::Mle>().SetRouterUpgradeThreshold(kMaxRouters);
+    leader.Get<Mle::Mle>().SetLeaderUpgradeThreshold(kMaxRouters);
     leader.Get<Mle::Mle>().SetRouterDowngradeThreshold(kMaxRouters);
+
     for (uint8_t i = 0; i < kMaxRouters; i++)
     {
         routers[i]->Get<Mle::Mle>().SetRouterUpgradeThreshold(kMaxRouters);
@@ -101,12 +103,10 @@
     // Router 32 <-> Router 1
     for (uint8_t i = 0; i < kMaxRouters - 1; i++)
     {
-        leader.AllowList(*routers[i]);
-        routers[i]->AllowList(leader);
+        AllowLinkBetween(leader, *routers[i]);
     }
 
-    routers[kMaxRouters - 1]->AllowList(*routers[0]);
-    routers[0]->AllowList(*routers[kMaxRouters - 1]);
+    AllowLinkBetween(*routers[kMaxRouters - 1], *routers[0]);
 
     Log("---------------------------------------------------------------------------------------");
     /**
diff --git a/tests/nexus/test_1_1_5_2_4.cpp b/tests/nexus/test_1_1_5_2_4.cpp
index 6d19e5a..a9d2c62 100644
--- a/tests/nexus/test_1_1_5_2_4.cpp
+++ b/tests/nexus/test_1_1_5_2_4.cpp
@@ -107,22 +107,16 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
-    /** Use AllowList feature to restrict the topology. */
     for (uint16_t i = 0; i < kNumRouters; i++)
     {
-        leader.AllowList(*routers[i]);
-        routers[i]->AllowList(leader);
+        AllowLinkBetween(leader, *routers[i]);
     }
 
-    /** Router 15 and REED_1 (DUT) have a link */
-    routers[kNumRouters - 1]->AllowList(reed1);
-    reed1.AllowList(*routers[kNumRouters - 1]);
+    AllowLinkBetween(*routers[kNumRouters - 1], reed1);
 
-    /** REED_1 (DUT) and MED_1 have a link. */
-    reed1.AllowList(med1);
-    med1.AllowList(reed1);
+    AllowLinkBetween(reed1, med1);
 
     Log("Step 1: Ensure topology is formed correctly without the DUT.");
 
diff --git a/tests/nexus/test_1_1_5_2_5.cpp b/tests/nexus/test_1_1_5_2_5.cpp
index b76c1d6..81f6aad 100644
--- a/tests/nexus/test_1_1_5_2_5.cpp
+++ b/tests/nexus/test_1_1_5_2_5.cpp
@@ -99,36 +99,27 @@
     Node &reed1 = nexus.CreateNode();
 
     leader.SetName("LEADER");
+
     for (uint16_t i = 0; i < kRouterCount; i++)
     {
-        char name[16];
-        snprintf(name, sizeof(name), "ROUTER_%u", i + 1);
-        routers[i]->SetName(name);
+        routers[i]->SetName("ROUTER", i + 1);
     }
+
     br.SetName("BR");
     med1.SetName("MED_1");
     reed1.SetName("REED_1");
 
     nexus.AdvanceTime(0);
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
-    /**
-     * Use AllowList feature to restrict the topology.
-     */
     for (uint16_t i = 0; i < kRouterCount; i++)
     {
-        leader.AllowList(*routers[i]);
-        routers[i]->AllowList(leader);
+        AllowLinkBetween(leader, *routers[i]);
     }
 
-    leader.AllowList(br);
-    br.AllowList(leader);
-
-    leader.AllowList(med1);
-    med1.AllowList(leader);
-
-    routers[0]->AllowList(reed1);
-    reed1.AllowList(*routers[0]);
+    AllowLinkBetween(leader, br);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(*routers[0], reed1);
 
     Log("Step 1: Configure the Leader to be a DHCPv6 Border Router for prefix 2001::");
 
diff --git a/tests/nexus/test_1_1_5_2_6.cpp b/tests/nexus/test_1_1_5_2_6.cpp
index 4a7cd34..424cf23 100644
--- a/tests/nexus/test_1_1_5_2_6.cpp
+++ b/tests/nexus/test_1_1_5_2_6.cpp
@@ -101,16 +101,15 @@
     for (uint16_t i = 0; i < kInitialRouterCount - 2; i++)
     {
         routers[i] = &nexus.CreateNode();
-        char name[16];
-        snprintf(name, sizeof(name), "ROUTER_%u", i + 2);
-        routers[i]->SetName(name);
+        routers[i]->SetName("ROUTER", i + 2);
     }
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     leader.Get<Mle::Mle>().SetRouterUpgradeThreshold(kRouterUpgradeThreshold);
+    leader.Get<Mle::Mle>().SetLeaderUpgradeThreshold(kRouterUpgradeThreshold);
     leader.Get<Mle::Mle>().SetRouterDowngradeThreshold(kRouterDowngradeThreshold);
 
     for (uint16_t i = 0; i < kInitialRouterCount - 2; i++)
diff --git a/tests/nexus/test_1_1_5_2_7.cpp b/tests/nexus/test_1_1_5_2_7.cpp
index 4559b78..a8c2825 100644
--- a/tests/nexus/test_1_1_5_2_7.cpp
+++ b/tests/nexus/test_1_1_5_2_7.cpp
@@ -109,7 +109,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
diff --git a/tests/nexus/test_1_1_5_3_1.cpp b/tests/nexus/test_1_1_5_3_1.cpp
index 7e8d0a6..89381f3 100644
--- a/tests/nexus/test_1_1_5_3_1.cpp
+++ b/tests/nexus/test_1_1_5_3_1.cpp
@@ -83,7 +83,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Router_1 and Leader");
diff --git a/tests/nexus/test_1_1_5_3_10.cpp b/tests/nexus/test_1_1_5_3_10.cpp
index 4070369..3cf526f 100644
--- a/tests/nexus/test_1_1_5_3_10.cpp
+++ b/tests/nexus/test_1_1_5_3_10.cpp
@@ -119,7 +119,7 @@
     med1.SetName("MED_1");
 
     nexus.AdvanceTime(0);
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 1: Border Router");
 
@@ -135,10 +135,10 @@
     nexus.AdvanceTime(kFormNetworkTime);
     VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
 
-    leader.AllowList(br);
-    br.AllowList(leader);
+    AllowLinkBetween(leader, br);
 
     br.Join(leader);
+
     nexus.AdvanceTime(kAttachToRouterTime);
     VerifyOrQuit(br.Get<Mle::Mle>().IsRouter());
 
@@ -167,17 +167,10 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    leader.AllowList(dut);
-    dut.AllowList(leader);
-
-    router1.AllowList(dut);
-    dut.AllowList(router1);
-
-    dut.AllowList(med1);
-    med1.AllowList(dut);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, dut);
+    AllowLinkBetween(router1, dut);
+    AllowLinkBetween(dut, med1);
 
     router1.Join(leader);
     dut.Join(leader);
diff --git a/tests/nexus/test_1_1_5_3_11.cpp b/tests/nexus/test_1_1_5_3_11.cpp
index 9852ae2..d17e534 100644
--- a/tests/nexus/test_1_1_5_3_11.cpp
+++ b/tests/nexus/test_1_1_5_3_11.cpp
@@ -99,7 +99,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -110,12 +110,8 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(med1);
-    med1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, med1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_3_2.cpp b/tests/nexus/test_1_1_5_3_2.cpp
index d9e8c20..b574eb2 100644
--- a/tests/nexus/test_1_1_5_3_2.cpp
+++ b/tests/nexus/test_1_1_5_3_2.cpp
@@ -86,7 +86,7 @@
     dut.SetName("DUT");
     sed1.SetName("SED_1");
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -95,14 +95,9 @@
      */
     Log("Step 1: All");
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(dut);
-    dut.AllowList(router1);
-
-    dut.AllowList(sed1);
-    sed1.AllowList(dut);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, dut);
+    AllowLinkBetween(dut, sed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_3_3.cpp b/tests/nexus/test_1_1_5_3_3.cpp
index 5689e29..8bfdb77 100644
--- a/tests/nexus/test_1_1_5_3_3.cpp
+++ b/tests/nexus/test_1_1_5_3_3.cpp
@@ -109,7 +109,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -121,24 +121,19 @@
      */
 
     /** Link between Leader and Router 1 */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
 
     /** Link between Leader and Router 2 (DUT) */
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     /** Link between Leader and Router 3 */
-    leader.AllowList(router3);
-    router3.AllowList(leader);
+    AllowLinkBetween(leader, router3);
 
     /** Link between Router 2 (DUT) and Router 3 */
-    dut.AllowList(router3);
-    router3.AllowList(dut);
+    AllowLinkBetween(dut, router3);
 
     /** Link between Router 2 (DUT) and MED 1 */
-    dut.AllowList(med1);
-    med1.AllowList(dut);
+    AllowLinkBetween(dut, med1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_3_4.cpp b/tests/nexus/test_1_1_5_3_4.cpp
index 5eddadb..4457417 100644
--- a/tests/nexus/test_1_1_5_3_4.cpp
+++ b/tests/nexus/test_1_1_5_3_4.cpp
@@ -100,7 +100,7 @@
 
     Node *meds[] = {&med1, &med2, &med3, &med4};
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -111,21 +111,12 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    for (Node *med : meds)
-    {
-        leader.AllowList(*med);
-    }
-
-    router1.AllowList(leader);
-    router1.AllowList(sed1);
-
-    sed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, sed1);
 
     for (Node *med : meds)
     {
-        med->AllowList(leader);
+        AllowLinkBetween(leader, *med);
     }
 
     leader.Form();
diff --git a/tests/nexus/test_1_1_5_3_5.cpp b/tests/nexus/test_1_1_5_3_5.cpp
index 2e1cd29..0e32491 100644
--- a/tests/nexus/test_1_1_5_3_5.cpp
+++ b/tests/nexus/test_1_1_5_3_5.cpp
@@ -106,7 +106,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -117,17 +117,10 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(dut);
-    leader.AllowList(router2);
-
-    dut.AllowList(leader);
-    dut.AllowList(router2);
-    dut.AllowList(router3);
-
-    router2.AllowList(leader);
-    router2.AllowList(dut);
-
-    router3.AllowList(dut);
+    AllowLinkBetween(leader, dut);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(dut, router2);
+    AllowLinkBetween(dut, router3);
 
     /** Leader and Router 2 Link Quality 3 */
     SuccessOrQuit(leader.Get<Mac::Filter>().AddRssIn(router2.Get<Mac::Mac>().GetExtAddress(), kRssiLinkQuality3));
diff --git a/tests/nexus/test_1_1_5_3_6.cpp b/tests/nexus/test_1_1_5_3_6.cpp
index fda6f62..3d2f604 100644
--- a/tests/nexus/test_1_1_5_3_6.cpp
+++ b/tests/nexus/test_1_1_5_3_6.cpp
@@ -80,7 +80,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -92,15 +92,8 @@
      *   - Pass Criteria: N/A
      */
 
-    /**
-     * Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     * - Leader (DUT) and Router 1
-     * - Router 1 and Router 2
-     */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    router1.AllowList(router2);
-    router2.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, router2);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_3_7.cpp b/tests/nexus/test_1_1_5_3_7.cpp
index 131b6ba..08e1d10 100644
--- a/tests/nexus/test_1_1_5_3_7.cpp
+++ b/tests/nexus/test_1_1_5_3_7.cpp
@@ -104,7 +104,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -115,21 +115,14 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-    leader.AllowList(med2);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(leader, med2);
 
-    router1.AllowList(leader);
-    router1.AllowList(router2);
-    router1.AllowList(med1);
+    AllowLinkBetween(router1, router2);
+    AllowLinkBetween(router1, med1);
 
-    router2.AllowList(leader);
-    router2.AllowList(router1);
-    router2.AllowList(sed1);
-
-    med1.AllowList(router1);
-    med2.AllowList(leader);
-    sed1.AllowList(router2);
+    AllowLinkBetween(router2, sed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_3_8.cpp b/tests/nexus/test_1_1_5_3_8.cpp
index 282523c..83fe891 100644
--- a/tests/nexus/test_1_1_5_3_8.cpp
+++ b/tests/nexus/test_1_1_5_3_8.cpp
@@ -123,17 +123,11 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(br);
-    br.AllowList(leader);
-
-    leader.AllowList(med1);
-    med1.AllowList(leader);
-
-    leader.AllowList(med2);
-    med2.AllowList(leader);
+    AllowLinkBetween(leader, br);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(leader, med2);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Border Router");
diff --git a/tests/nexus/test_1_1_5_3_9.cpp b/tests/nexus/test_1_1_5_3_9.cpp
index 44982a4..892ebad 100644
--- a/tests/nexus/test_1_1_5_3_9.cpp
+++ b/tests/nexus/test_1_1_5_3_9.cpp
@@ -125,7 +125,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: Leader
@@ -164,14 +164,10 @@
      * - Pass Criteria: N/A
      */
     Log("Step 2: All");
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    leader.AllowList(dut);
-    dut.AllowList(leader);
-    leader.AllowList(router3);
-    router3.AllowList(leader);
-    dut.AllowList(sed1);
-    sed1.AllowList(dut);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, dut);
+    AllowLinkBetween(leader, router3);
+    AllowLinkBetween(dut, sed1);
 
     router1.Join(leader);
     dut.Join(leader);
diff --git a/tests/nexus/test_1_1_5_5_1.cpp b/tests/nexus/test_1_1_5_5_1.cpp
index 5a1f18b..3b4a68a 100644
--- a/tests/nexus/test_1_1_5_5_1.cpp
+++ b/tests/nexus/test_1_1_5_5_1.cpp
@@ -89,7 +89,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
diff --git a/tests/nexus/test_1_1_5_5_2.cpp b/tests/nexus/test_1_1_5_5_2.cpp
index 3d4b88c..2555077 100644
--- a/tests/nexus/test_1_1_5_5_2.cpp
+++ b/tests/nexus/test_1_1_5_5_2.cpp
@@ -105,7 +105,7 @@
     router1.SetName("ROUTER_1");
     med1.SetName("MED_1");
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -114,11 +114,8 @@
      */
     Log("Step 1: All");
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(med1);
-    med1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, med1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_5_3.cpp b/tests/nexus/test_1_1_5_5_3.cpp
index 07dd12e..61b1aa5 100644
--- a/tests/nexus/test_1_1_5_5_3.cpp
+++ b/tests/nexus/test_1_1_5_5_3.cpp
@@ -99,7 +99,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: All");
@@ -110,17 +110,11 @@
      *   - Pass Criteria: N/A
      */
 
-    leader.AllowList(router1);
-    leader.AllowList(router2);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
 
-    router1.AllowList(leader);
-    router1.AllowList(med2);
-
-    router2.AllowList(leader);
-    router2.AllowList(med3);
-
-    med2.AllowList(router1);
-    med3.AllowList(router2);
+    AllowLinkBetween(router1, med2);
+    AllowLinkBetween(router2, med3);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -328,8 +322,7 @@
      *           - RLOC16 TLV (optional)
      */
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
 
     nexus.AdvanceTime(kWaitPeriod);
 
diff --git a/tests/nexus/test_1_1_5_5_4_1.cpp b/tests/nexus/test_1_1_5_5_4_1.cpp
index c35cb5d..c883019 100644
--- a/tests/nexus/test_1_1_5_5_4_1.cpp
+++ b/tests/nexus/test_1_1_5_5_4_1.cpp
@@ -104,7 +104,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -115,18 +115,10 @@
      * - Pass Criteria: N/A.
      */
 
-    dut.AllowList(router1);
-    dut.AllowList(router2);
-
-    router1.AllowList(dut);
-    router1.AllowList(router3);
-
-    router2.AllowList(dut);
-    router2.AllowList(router4);
-
-    router3.AllowList(router1);
-
-    router4.AllowList(router2);
+    AllowLinkBetween(dut, router1);
+    AllowLinkBetween(dut, router2);
+    AllowLinkBetween(router1, router3);
+    AllowLinkBetween(router2, router4);
 
     dut.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_5_4_2.cpp b/tests/nexus/test_1_1_5_5_4_2.cpp
index 563c41f..f49b5b3 100644
--- a/tests/nexus/test_1_1_5_5_4_2.cpp
+++ b/tests/nexus/test_1_1_5_5_4_2.cpp
@@ -112,7 +112,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -121,15 +121,10 @@
      */
     Log("Step 1: All");
 
-    /** Use AllowList to specify links between nodes. */
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-    router1.AllowList(leader);
-    router1.AllowList(router3);
-    router2.AllowList(leader);
-    router2.AllowList(router4);
-    router3.AllowList(router1);
-    router4.AllowList(router2);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(router1, router3);
+    AllowLinkBetween(router2, router4);
 
     /** Set NETWORK_ID_TIMEOUT of Router_3 to 55 seconds. */
     router3.Get<Mle::Mle>().SetNetworkIdTimeout(kRouter3NetworkIdTimeout);
diff --git a/tests/nexus/test_1_1_5_5_5.cpp b/tests/nexus/test_1_1_5_5_5.cpp
index 389d529..3b2abd6 100644
--- a/tests/nexus/test_1_1_5_5_5.cpp
+++ b/tests/nexus/test_1_1_5_5_5.cpp
@@ -99,7 +99,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -110,19 +110,15 @@
      * - Pass Criteria: N/A
      */
 
-    /** Configure AllowList for specific links. */
     for (uint16_t i = 2; i <= 15; i++)
     {
-        leader.AllowList(*r[i]);
-        r[i]->AllowList(leader);
+        AllowLinkBetween(leader, *r[i]);
     }
 
-    r[1]->AllowList(*r[3]);
-    r[3]->AllowList(*r[1]);
+    AllowLinkBetween(*r[1], *r[3]);
 
     /** DUT links. */
-    dut.AllowList(*r[2]);
-    r[2]->AllowList(dut);
+    AllowLinkBetween(dut, *r[2]);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -181,8 +177,7 @@
      * - Pass Criteria: N/A
      */
     SuccessOrQuit(dut.Get<Mle::Mle>().SetRouterEligible(true));
-    dut.AllowList(*r[1]);
-    r[1]->AllowList(dut);
+    AllowLinkBetween(dut, *r[1]);
     nexus.AdvanceTime(kReattachTime);
 
     Log("---------------------------------------------------------------------------------------");
diff --git a/tests/nexus/test_1_1_5_5_7.cpp b/tests/nexus/test_1_1_5_5_7.cpp
index ffedd4a..8c61350 100644
--- a/tests/nexus/test_1_1_5_5_7.cpp
+++ b/tests/nexus/test_1_1_5_5_7.cpp
@@ -95,7 +95,7 @@
     router2.SetName("ROUTER_2");
     router3.SetName("ROUTER_3");
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -105,13 +105,9 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-    leader.AllowList(router3);
-
-    router1.AllowList(leader);
-    router2.AllowList(leader);
-    router3.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(leader, router3);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_6_1.cpp b/tests/nexus/test_1_1_5_6_1.cpp
index 15f5620..fb046ec 100644
--- a/tests/nexus/test_1_1_5_6_1.cpp
+++ b/tests/nexus/test_1_1_5_6_1.cpp
@@ -85,23 +85,13 @@
     const char *kPrefix3 = "2003::/64";
     const char *kPrefix4 = "2004::/64";
 
-    /**
-     * - Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     *   - Router 1 (DUT) and Leader
-     *   - Router 1 (DUT) and MED 1
-     *   - Router 1 (DUT) and SED 1
-     */
-    router1.AllowList(leader);
-    router1.AllowList(med1);
-    router1.AllowList(sed1);
-
-    leader.AllowList(router1);
-    med1.AllowList(router1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(router1, leader);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     /**
diff --git a/tests/nexus/test_1_1_5_6_2.cpp b/tests/nexus/test_1_1_5_6_2.cpp
index 4e6a5af..0ab97ba 100644
--- a/tests/nexus/test_1_1_5_6_2.cpp
+++ b/tests/nexus/test_1_1_5_6_2.cpp
@@ -88,23 +88,13 @@
     const char *kPrefix1 = "2001::/64";
     const char *kPrefix2 = "2002::/64";
 
-    /**
-     * - Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     *   - Leader (DUT) and Router 1
-     *   - Leader (DUT) and MED 1
-     *   - Leader (DUT) and SED 1
-     */
-    leader.AllowList(router1);
-    leader.AllowList(med1);
-    leader.AllowList(sed1);
-
-    router1.AllowList(leader);
-    med1.AllowList(leader);
-    sed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(leader, sed1);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     /**
diff --git a/tests/nexus/test_1_1_5_6_3.cpp b/tests/nexus/test_1_1_5_6_3.cpp
index 25ffbb0..3b2179d 100644
--- a/tests/nexus/test_1_1_5_6_3.cpp
+++ b/tests/nexus/test_1_1_5_6_3.cpp
@@ -96,7 +96,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 1: All");
 
@@ -106,15 +106,9 @@
      *   - Pass Criteria: N/A
      */
 
-    /** Use AllowList to specify links between nodes. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(med1);
-    med1.AllowList(router1);
-
-    router1.AllowList(sed1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_6_4.cpp b/tests/nexus/test_1_1_5_6_4.cpp
index 33e4fd4..874977f 100644
--- a/tests/nexus/test_1_1_5_6_4.cpp
+++ b/tests/nexus/test_1_1_5_6_4.cpp
@@ -98,7 +98,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -109,13 +109,9 @@
      * - Pass Criteria: N/A
      */
 
-    dut.AllowList(router1);
-    dut.AllowList(med1);
-    dut.AllowList(sed1);
-
-    router1.AllowList(dut);
-    med1.AllowList(dut);
-    sed1.AllowList(dut);
+    AllowLinkBetween(dut, router1);
+    AllowLinkBetween(dut, med1);
+    AllowLinkBetween(dut, sed1);
 
     dut.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_6_5.cpp b/tests/nexus/test_1_1_5_6_5.cpp
index bc11ad4..adfa836 100644
--- a/tests/nexus/test_1_1_5_6_5.cpp
+++ b/tests/nexus/test_1_1_5_6_5.cpp
@@ -88,7 +88,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -99,19 +99,9 @@
      * - Pass Criteria: N/A
      */
 
-    /**
-     * Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     * - Leader (DUT) and Router 1
-     * - Leader (DUT) and MED 1
-     * - Leader (DUT) and SED 1
-     */
-    leader.AllowList(router1);
-    leader.AllowList(med1);
-    leader.AllowList(sed1);
-
-    router1.AllowList(leader);
-    med1.AllowList(leader);
-    sed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(leader, sed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_5_6_6.cpp b/tests/nexus/test_1_1_5_6_6.cpp
index 4da54a2..6209455 100644
--- a/tests/nexus/test_1_1_5_6_6.cpp
+++ b/tests/nexus/test_1_1_5_6_6.cpp
@@ -96,23 +96,13 @@
     const char *kPrefix2 = "2002::/64";
     const char *kPrefix3 = "2003::/64";
 
-    /**
-     * - Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     * - Leader (DUT) and Router 1
-     * - Leader (DUT) and MED 1
-     * - Leader (DUT) and SED 1
-     */
-    leader.AllowList(router1);
-    leader.AllowList(med1);
-    leader.AllowList(sed1);
-
-    router1.AllowList(leader);
-    med1.AllowList(leader);
-    sed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(leader, sed1);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
diff --git a/tests/nexus/test_1_1_5_6_7.cpp b/tests/nexus/test_1_1_5_6_7.cpp
index f6ddfa6..cada9d6 100644
--- a/tests/nexus/test_1_1_5_6_7.cpp
+++ b/tests/nexus/test_1_1_5_6_7.cpp
@@ -126,7 +126,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -137,40 +137,23 @@
      * - Pass Criteria: N/A.
      */
 
-    leader.AllowList(r1);
-    leader.AllowList(r2);
-    leader.AllowList(r3);
-    leader.AllowList(r4);
-    leader.AllowList(r5);
-    leader.AllowList(r6);
-    leader.AllowList(r7);
-    leader.AllowList(r8);
-    leader.AllowList(r9);
-    leader.AllowList(r10);
-    leader.AllowList(r11);
-    leader.AllowList(r12);
-    leader.AllowList(r13);
-    leader.AllowList(r14);
-    leader.AllowList(r15);
+    AllowLinkBetween(leader, r1);
+    AllowLinkBetween(leader, r2);
+    AllowLinkBetween(leader, r3);
+    AllowLinkBetween(leader, r4);
+    AllowLinkBetween(leader, r5);
+    AllowLinkBetween(leader, r6);
+    AllowLinkBetween(leader, r7);
+    AllowLinkBetween(leader, r8);
+    AllowLinkBetween(leader, r9);
+    AllowLinkBetween(leader, r10);
+    AllowLinkBetween(leader, r11);
+    AllowLinkBetween(leader, r12);
+    AllowLinkBetween(leader, r13);
+    AllowLinkBetween(leader, r14);
+    AllowLinkBetween(leader, r15);
 
-    r1.AllowList(leader);
-    r1.AllowList(dut);
-    r2.AllowList(leader);
-    r3.AllowList(leader);
-    r4.AllowList(leader);
-    r5.AllowList(leader);
-    r6.AllowList(leader);
-    r7.AllowList(leader);
-    r8.AllowList(leader);
-    r9.AllowList(leader);
-    r10.AllowList(leader);
-    r11.AllowList(leader);
-    r12.AllowList(leader);
-    r13.AllowList(leader);
-    r14.AllowList(leader);
-    r15.AllowList(leader);
-
-    dut.AllowList(r1);
+    AllowLinkBetween(dut, r1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -221,8 +204,7 @@
      *   will fail because the DUT will go through (re) attachment when it emerges.
      * - Pass Criteria: N/A.
      */
-    dut.UnallowList(r1);
-    r1.UnallowList(dut);
+    UnallowLinkBetween(dut, r1);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 4: Leader");
@@ -272,8 +254,7 @@
      * - Pass Criteria: N/A.
      */
     nexus.AdvanceTime(kRfIsolationTime);
-    dut.AllowList(r1);
-    r1.AllowList(dut);
+    AllowLinkBetween(dut, r1);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 7: All");
diff --git a/tests/nexus/test_1_1_5_6_9.cpp b/tests/nexus/test_1_1_5_6_9.cpp
index e138472..9366e1c 100644
--- a/tests/nexus/test_1_1_5_6_9.cpp
+++ b/tests/nexus/test_1_1_5_6_9.cpp
@@ -118,19 +118,15 @@
      * - Router 1 (DUT) and MED 1
      * - Router 1 (DUT) and SED 1
      */
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-    router1.AllowList(leader);
-    router2.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
 
-    router1.AllowList(med1);
-    router1.AllowList(sed1);
-    med1.AllowList(router1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     med1.Get<Mle::Mle>().SetTimeout(kLargeTimeout);
     sed1.Get<Mle::Mle>().SetTimeout(kLargeTimeout);
@@ -325,7 +321,7 @@
     Log("---------------------------------------------------------------------------------------");
     /**
      * Step 10: Router_1 (DUT)
-     * - Description: Depending on the the DUT’s implementation, automatically sends new stable network data to SED_1
+     * - Description: Depending on the DUT’s implementation, automatically sends new stable network data to SED_1
      *   via a unicast MLE Child Update Request or MLE Data Response.
      * - Pass Criteria: The DUT MUST send EITHER a unicast MLE Child Update Request OR a unicast MLE Data Response to
      *   SED_1, which includes the following TLVs:
diff --git a/tests/nexus/test_1_1_5_7_1.cpp b/tests/nexus/test_1_1_5_7_1.cpp
index 41a1fc8..74dba3a 100644
--- a/tests/nexus/test_1_1_5_7_1.cpp
+++ b/tests/nexus/test_1_1_5_7_1.cpp
@@ -30,7 +30,7 @@
 
 #include "platform/nexus_core.hpp"
 #include "platform/nexus_node.hpp"
-#include "thread/network_diagnostic.hpp"
+#include "thread/net_diag.hpp"
 
 namespace ot {
 namespace Nexus {
@@ -90,7 +90,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -99,18 +99,11 @@
      */
     Log("Step 1: All");
 
-    /** Use AllowList to specify links between nodes. */
-    dut.AllowList(leader);
-    dut.AllowList(fed1);
-    dut.AllowList(med1);
-    dut.AllowList(sed1);
-    dut.AllowList(reed1);
-
-    leader.AllowList(dut);
-    fed1.AllowList(dut);
-    med1.AllowList(dut);
-    sed1.AllowList(dut);
-    reed1.AllowList(dut);
+    AllowLinkBetween(dut, leader);
+    AllowLinkBetween(dut, fed1);
+    AllowLinkBetween(dut, med1);
+    AllowLinkBetween(dut, sed1);
+    AllowLinkBetween(dut, reed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -162,15 +155,13 @@
     Log("Step 2: Leader");
 
     uint8_t tlvTypes2[] = {
-        NetworkDiagnostic::Tlv::kExtMacAddress, NetworkDiagnostic::Tlv::kAddress16,
-        NetworkDiagnostic::Tlv::kMode,          NetworkDiagnostic::Tlv::kConnectivity,
-        NetworkDiagnostic::Tlv::kRoute,         NetworkDiagnostic::Tlv::kLeaderData,
-        NetworkDiagnostic::Tlv::kNetworkData,   NetworkDiagnostic::Tlv::kIp6AddressList,
-        NetworkDiagnostic::Tlv::kChannelPages,
+        NetDiag::Tlv::kExtMacAddress, NetDiag::Tlv::kAddress16,      NetDiag::Tlv::kMode,
+        NetDiag::Tlv::kConnectivity,  NetDiag::Tlv::kRoute,          NetDiag::Tlv::kLeaderData,
+        NetDiag::Tlv::kNetworkData,   NetDiag::Tlv::kIp6AddressList, NetDiag::Tlv::kChannelPages,
     };
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(dutRloc, tlvTypes2, sizeof(tlvTypes2),
-                                                                            nullptr, nullptr));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticGet(dutRloc, tlvTypes2, sizeof(tlvTypes2), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     /**
@@ -187,10 +178,10 @@
      */
     Log("Step 3: Leader");
 
-    uint8_t tlvTypes3[] = {NetworkDiagnostic::Tlv::kMacCounters};
+    uint8_t tlvTypes3[] = {NetDiag::Tlv::kMacCounters};
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(dutRloc, tlvTypes3, sizeof(tlvTypes3),
-                                                                            nullptr, nullptr));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticGet(dutRloc, tlvTypes3, sizeof(tlvTypes3), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     /**
@@ -206,10 +197,10 @@
      */
     Log("Step 4: Leader");
 
-    uint8_t tlvTypes4[] = {NetworkDiagnostic::Tlv::kTimeout};
+    uint8_t tlvTypes4[] = {NetDiag::Tlv::kTimeout};
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(dutRloc, tlvTypes4, sizeof(tlvTypes4),
-                                                                            nullptr, nullptr));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticGet(dutRloc, tlvTypes4, sizeof(tlvTypes4), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     /**
@@ -227,10 +218,10 @@
      */
     Log("Step 5: Leader");
 
-    uint8_t tlvTypes5[] = {NetworkDiagnostic::Tlv::kBatteryLevel, NetworkDiagnostic::Tlv::kSupplyVoltage};
+    uint8_t tlvTypes5[] = {NetDiag::Tlv::kBatteryLevel, NetDiag::Tlv::kSupplyVoltage};
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(dutRloc, tlvTypes5, sizeof(tlvTypes5),
-                                                                            nullptr, nullptr));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticGet(dutRloc, tlvTypes5, sizeof(tlvTypes5), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     /**
@@ -248,10 +239,10 @@
      */
     Log("Step 6: Leader");
 
-    uint8_t tlvTypes6[] = {NetworkDiagnostic::Tlv::kChildTable};
+    uint8_t tlvTypes6[] = {NetDiag::Tlv::kChildTable};
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(dutRloc, tlvTypes6, sizeof(tlvTypes6),
-                                                                            nullptr, nullptr));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticGet(dutRloc, tlvTypes6, sizeof(tlvTypes6), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     /**
@@ -265,9 +256,9 @@
      */
     Log("Step 7: Leader");
 
-    uint8_t tlvTypes7[] = {NetworkDiagnostic::Tlv::kMacCounters};
+    uint8_t tlvTypes7[] = {NetDiag::Tlv::kMacCounters};
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticReset(dutRloc, tlvTypes7, sizeof(tlvTypes7)));
+    SuccessOrQuit(leader.Get<NetDiag::Client>().SendDiagnosticReset(dutRloc, tlvTypes7, sizeof(tlvTypes7)));
     nexus.AdvanceTime(kDiagResponseTime);
 
     /**
@@ -285,10 +276,10 @@
      */
     Log("Step 8: Leader");
 
-    uint8_t tlvTypes8[] = {NetworkDiagnostic::Tlv::kMacCounters};
+    uint8_t tlvTypes8[] = {NetDiag::Tlv::kMacCounters};
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(dutRloc, tlvTypes8, sizeof(tlvTypes8),
-                                                                            nullptr, nullptr));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticGet(dutRloc, tlvTypes8, sizeof(tlvTypes8), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     nexus.SaveTestInfo("test_1_1_5_7_1.json");
diff --git a/tests/nexus/test_1_1_5_7_2.cpp b/tests/nexus/test_1_1_5_7_2.cpp
index 543db7c..4910ffb 100644
--- a/tests/nexus/test_1_1_5_7_2.cpp
+++ b/tests/nexus/test_1_1_5_7_2.cpp
@@ -30,7 +30,7 @@
 
 #include "platform/nexus_core.hpp"
 #include "platform/nexus_node.hpp"
-#include "thread/network_diagnostic.hpp"
+#include "thread/net_diag.hpp"
 
 namespace ot {
 namespace Nexus {
@@ -74,17 +74,15 @@
  * Base diagnostic TLV types used in Steps 2 and 8.
  */
 static constexpr uint8_t kBaseDiagGetTlvs[] = {
-    NetworkDiagnostic::Tlv::kExtMacAddress, NetworkDiagnostic::Tlv::kAddress16,
-    NetworkDiagnostic::Tlv::kMode,          NetworkDiagnostic::Tlv::kConnectivity,
-    NetworkDiagnostic::Tlv::kRoute,         NetworkDiagnostic::Tlv::kLeaderData,
-    NetworkDiagnostic::Tlv::kNetworkData,   NetworkDiagnostic::Tlv::kIp6AddressList,
-    NetworkDiagnostic::Tlv::kChannelPages,
+    NetDiag::Tlv::kExtMacAddress, NetDiag::Tlv::kAddress16,      NetDiag::Tlv::kMode,
+    NetDiag::Tlv::kConnectivity,  NetDiag::Tlv::kRoute,          NetDiag::Tlv::kLeaderData,
+    NetDiag::Tlv::kNetworkData,   NetDiag::Tlv::kIp6AddressList, NetDiag::Tlv::kChannelPages,
 };
 
 /**
  * MAC Counters diagnostic TLV type used in Steps 3, 6, and 7.
  */
-static constexpr uint8_t kMacCountersTlv[] = {NetworkDiagnostic::Tlv::kMacCounters};
+static constexpr uint8_t kMacCountersTlv[] = {NetDiag::Tlv::kMacCounters};
 
 void Test5_7_2(void)
 {
@@ -123,7 +121,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -134,15 +132,12 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList to specify links between nodes. */
     for (uint8_t i = 0; i < kNumRouters; i++)
     {
-        leader.AllowList(*routers[i]);
-        routers[i]->AllowList(leader);
+        AllowLinkBetween(leader, *routers[i]);
     }
 
-    reed1.AllowList(*routers[0]);
-    routers[0]->AllowList(reed1);
+    AllowLinkBetween(reed1, *routers[0]);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -199,8 +194,8 @@
      *   - The presence of each TLV MUST be validated. Where possible, the value of the TLVs MUST be validated.
      */
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(
-        reedRloc, kBaseDiagGetTlvs, sizeof(kBaseDiagGetTlvs), nullptr, nullptr));
+    SuccessOrQuit(leader.Get<NetDiag::Client>().SendDiagnosticGet(reedRloc, kBaseDiagGetTlvs, sizeof(kBaseDiagGetTlvs),
+                                                                  nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     Log("---------------------------------------------------------------------------------------");
@@ -219,8 +214,8 @@
      *   - TLV Type 9 - MAC Counters MUST contain a list of MAC Counters.
      */
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(reedRloc, kMacCountersTlv,
-                                                                            sizeof(kMacCountersTlv), nullptr, nullptr));
+    SuccessOrQuit(leader.Get<NetDiag::Client>().SendDiagnosticGet(reedRloc, kMacCountersTlv, sizeof(kMacCountersTlv),
+                                                                  nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     Log("---------------------------------------------------------------------------------------");
@@ -239,10 +234,10 @@
      *     - The Timeout TLV MUST NOT be present.
      */
 
-    uint8_t tlvTypes4[] = {NetworkDiagnostic::Tlv::kTimeout, NetworkDiagnostic::Tlv::kChildTable};
+    uint8_t tlvTypes4[] = {NetDiag::Tlv::kTimeout, NetDiag::Tlv::kChildTable};
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(reedRloc, tlvTypes4, sizeof(tlvTypes4),
-                                                                            nullptr, nullptr));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticGet(reedRloc, tlvTypes4, sizeof(tlvTypes4), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     Log("---------------------------------------------------------------------------------------");
@@ -262,10 +257,10 @@
      *     - TLV Type 15 – Supply Voltage (optional)
      */
 
-    uint8_t tlvTypes5[] = {NetworkDiagnostic::Tlv::kBatteryLevel, NetworkDiagnostic::Tlv::kSupplyVoltage};
+    uint8_t tlvTypes5[] = {NetDiag::Tlv::kBatteryLevel, NetDiag::Tlv::kSupplyVoltage};
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(reedRloc, tlvTypes5, sizeof(tlvTypes5),
-                                                                            nullptr, nullptr));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticGet(reedRloc, tlvTypes5, sizeof(tlvTypes5), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     Log("---------------------------------------------------------------------------------------");
@@ -292,8 +287,8 @@
      *   - CoAP Response Code: 2.04 Changed
      */
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticReset(reedRloc, kMacCountersTlv,
-                                                                              sizeof(kMacCountersTlv)));
+    SuccessOrQuit(
+        leader.Get<NetDiag::Client>().SendDiagnosticReset(reedRloc, kMacCountersTlv, sizeof(kMacCountersTlv)));
     nexus.AdvanceTime(kDiagResponseTime);
 
     Log("---------------------------------------------------------------------------------------");
@@ -324,8 +319,8 @@
      *     step 3.
      */
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(reedRloc, kMacCountersTlv,
-                                                                            sizeof(kMacCountersTlv), nullptr, nullptr));
+    SuccessOrQuit(leader.Get<NetDiag::Client>().SendDiagnosticGet(reedRloc, kMacCountersTlv, sizeof(kMacCountersTlv),
+                                                                  nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
     Log("---------------------------------------------------------------------------------------");
@@ -359,7 +354,7 @@
      *   - The presence of each TLV MUST be validated. Where possible, the value of the TLVs MUST be validated.
      */
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(
+    SuccessOrQuit(leader.Get<NetDiag::Client>().SendDiagnosticGet(
         Ip6::Address::GetRealmLocalAllNodesMulticast(), kBaseDiagGetTlvs, sizeof(kBaseDiagGetTlvs), nullptr, nullptr));
     nexus.AdvanceTime(kDiagResponseTime);
 
diff --git a/tests/nexus/test_1_1_5_7_3.cpp b/tests/nexus/test_1_1_5_7_3.cpp
index 8e72ac6..5bd944b 100644
--- a/tests/nexus/test_1_1_5_7_3.cpp
+++ b/tests/nexus/test_1_1_5_7_3.cpp
@@ -31,7 +31,7 @@
 #include "mac/data_poll_sender.hpp"
 #include "platform/nexus_core.hpp"
 #include "platform/nexus_node.hpp"
-#include "thread/network_diagnostic.hpp"
+#include "thread/net_diag.hpp"
 
 namespace ot {
 namespace Nexus {
@@ -65,11 +65,10 @@
  * Diagnostic TLV types used in the query.
  */
 static constexpr uint8_t kDiagGetTlvs[] = {
-    NetworkDiagnostic::Tlv::kExtMacAddress, NetworkDiagnostic::Tlv::kAddress16,
-    NetworkDiagnostic::Tlv::kMode,          NetworkDiagnostic::Tlv::kConnectivity,
-    NetworkDiagnostic::Tlv::kRoute,         NetworkDiagnostic::Tlv::kLeaderData,
-    NetworkDiagnostic::Tlv::kNetworkData,   NetworkDiagnostic::Tlv::kIp6AddressList,
-    NetworkDiagnostic::Tlv::kChildTable,    NetworkDiagnostic::Tlv::kChannelPages,
+    NetDiag::Tlv::kExtMacAddress, NetDiag::Tlv::kAddress16,      NetDiag::Tlv::kMode,
+    NetDiag::Tlv::kConnectivity,  NetDiag::Tlv::kRoute,          NetDiag::Tlv::kLeaderData,
+    NetDiag::Tlv::kNetworkData,   NetDiag::Tlv::kIp6AddressList, NetDiag::Tlv::kChildTable,
+    NetDiag::Tlv::kChannelPages,
 };
 
 struct DiagGetContext
@@ -100,9 +99,9 @@
 
     Log("Diagnostic Answer from %s", AsCoreType(&aMessageInfo->mPeerAddr).ToString().AsCString());
 
-    SuccessOrQuit(Tlv::Find<NetworkDiagnostic::ExtMacAddressTlv>(*message, extAddress));
-    SuccessOrQuit(Tlv::Find<NetworkDiagnostic::Address16Tlv>(*message, shortAddress));
-    SuccessOrQuit(Tlv::Find<NetworkDiagnostic::ModeTlv>(*message, mode));
+    SuccessOrQuit(Tlv::Find<NetDiag::ExtMacAddressTlv>(*message, extAddress));
+    SuccessOrQuit(Tlv::Find<NetDiag::Address16Tlv>(*message, shortAddress));
+    SuccessOrQuit(Tlv::Find<NetDiag::ModeTlv>(*message, mode));
 }
 
 void Test5_7_3(void)
@@ -140,7 +139,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -151,18 +150,10 @@
      * - Pass Criteria: N/A.
      */
 
-    /** Use AllowList to specify links between nodes. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(fed1);
-    fed1.AllowList(router1);
-
-    router1.AllowList(med1);
-    med1.AllowList(router1);
-
-    router1.AllowList(sed1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, fed1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -213,7 +204,7 @@
      * - Pass Criteria: N/A.
      */
 
-    SuccessOrQuit(leader.Get<NetworkDiagnostic::Client>().SendDiagnosticGet(
+    SuccessOrQuit(leader.Get<NetDiag::Client>().SendDiagnosticGet(
         leader.Get<Mle::Mle>().GetRealmLocalAllThreadNodesAddress(), kDiagGetTlvs, sizeof(kDiagGetTlvs),
         HandleDiagnosticGetAnswer, &context));
 
diff --git a/tests/nexus/test_1_1_5_8_2.cpp b/tests/nexus/test_1_1_5_8_2.cpp
index 5bcb56f..041c853 100644
--- a/tests/nexus/test_1_1_5_8_2.cpp
+++ b/tests/nexus/test_1_1_5_8_2.cpp
@@ -82,7 +82,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Leader forms the network");
diff --git a/tests/nexus/test_1_1_5_8_3.cpp b/tests/nexus/test_1_1_5_8_3.cpp
index 85e13f0..e2eb15d 100644
--- a/tests/nexus/test_1_1_5_8_3.cpp
+++ b/tests/nexus/test_1_1_5_8_3.cpp
@@ -89,7 +89,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Leader forms network with KeySequenceCounter = 127");
diff --git a/tests/nexus/test_1_1_5_8_4.cpp b/tests/nexus/test_1_1_5_8_4.cpp
index 2625a06..35375e3 100644
--- a/tests/nexus/test_1_1_5_8_4.cpp
+++ b/tests/nexus/test_1_1_5_8_4.cpp
@@ -96,7 +96,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -107,12 +107,8 @@
      * - Pass Criteria: N/A.
      */
 
-    /** Use AllowList to specify links between nodes. */
-    leader.AllowList(commr1);
-    commr1.AllowList(leader);
-
-    leader.AllowList(commr2);
-    commr2.AllowList(leader);
+    AllowLinkBetween(leader, commr1);
+    AllowLinkBetween(leader, commr2);
 
     /** Partition is formed with all Security Policy TLV bits set to 1. */
     {
@@ -135,7 +131,7 @@
     VerifyOrQuit(commr1.Get<MeshCoP::Commissioner>().IsActive());
 
     /** Add a joiner on commr1 to enable joining on the Leader. */
-    SuccessOrQuit(commr1.Get<MeshCoP::Commissioner>().AddJoinerAny("123456", 100));
+    SuccessOrQuit(commr1.Get<MeshCoP::Commissioner>().AddJoinerAny("123456", 1000));
     nexus.AdvanceTime(kStabilizationTime);
 
     Log("---------------------------------------------------------------------------------------");
diff --git a/tests/nexus/test_1_1_6_1_1.cpp b/tests/nexus/test_1_1_6_1_1.cpp
index 16b262f..7673788 100644
--- a/tests/nexus/test_1_1_6_1_1.cpp
+++ b/tests/nexus/test_1_1_6_1_1.cpp
@@ -98,7 +98,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     if (aTopology == kTopologyA)
     {
diff --git a/tests/nexus/test_1_1_6_1_2.cpp b/tests/nexus/test_1_1_6_1_2.cpp
index d42a64c..0c312aa 100644
--- a/tests/nexus/test_1_1_6_1_2.cpp
+++ b/tests/nexus/test_1_1_6_1_2.cpp
@@ -96,7 +96,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -107,8 +107,7 @@
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
 
-    leader.AllowList(reed);
-    reed.AllowList(leader);
+    AllowLinkBetween(leader, reed);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -136,8 +135,7 @@
     Log("---------------------------------------------------------------------------------------");
     Log("Step 2: ED_1 / SED_1 (DUT)");
 
-    reed.AllowList(dut);
-    dut.AllowList(reed);
+    AllowLinkBetween(reed, dut);
 
     if (aTopology == kTopologyA)
     {
diff --git a/tests/nexus/test_1_1_6_1_3.cpp b/tests/nexus/test_1_1_6_1_3.cpp
index d33777f..de122e8 100644
--- a/tests/nexus/test_1_1_6_1_3.cpp
+++ b/tests/nexus/test_1_1_6_1_3.cpp
@@ -112,7 +112,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     if (aTopology == kTopologyA)
@@ -134,23 +134,12 @@
      *   - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-    leader.AllowList(router3);
-
-    router1.AllowList(leader);
-    router1.AllowList(router3);
-
-    router2.AllowList(leader);
-    router2.AllowList(dut);
-
-    router3.AllowList(leader);
-    router3.AllowList(router1);
-    router3.AllowList(dut);
-
-    dut.AllowList(router2);
-    dut.AllowList(router3);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(leader, router3);
+    AllowLinkBetween(router1, router3);
+    AllowLinkBetween(router2, dut);
+    AllowLinkBetween(router3, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_6_1_4.cpp b/tests/nexus/test_1_1_6_1_4.cpp
index be3ed50..e1b9397 100644
--- a/tests/nexus/test_1_1_6_1_4.cpp
+++ b/tests/nexus/test_1_1_6_1_4.cpp
@@ -121,7 +121,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -132,31 +132,12 @@
      * - Pass Criteria: N/A
      */
 
-    /**
-     * Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     * - Leader and Router 1
-     * - Leader and Router 2
-     * - Leader and Router 3
-     * - Router 1 and Router 3
-     * - Router 2 and SED 1 (DUT)
-     * - Router 3 and SED 1 (DUT)
-     */
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-    leader.AllowList(router3);
-
-    router1.AllowList(leader);
-    router1.AllowList(router3);
-
-    router2.AllowList(leader);
-    router2.AllowList(dut);
-
-    router3.AllowList(leader);
-    router3.AllowList(router1);
-    router3.AllowList(dut);
-
-    dut.AllowList(router2);
-    dut.AllowList(router3);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(leader, router3);
+    AllowLinkBetween(router1, router3);
+    AllowLinkBetween(router2, dut);
+    AllowLinkBetween(router3, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_6_1_5.cpp b/tests/nexus/test_1_1_6_1_5.cpp
index a22b500..f16dcd0 100644
--- a/tests/nexus/test_1_1_6_1_5.cpp
+++ b/tests/nexus/test_1_1_6_1_5.cpp
@@ -112,7 +112,7 @@
 
     nexus.AdvanceTime(kStartTime);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Setup the topology without the DUT. Ensure all routers and leader are sending MLE advertisements.");
@@ -124,12 +124,8 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    leader.AllowList(reed2);
-
-    router1.AllowList(leader);
-    reed2.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, reed2);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -155,11 +151,8 @@
      */
 
     /** Restricted topology for DUT. */
-    sed1.AllowList(router1);
-    sed1.AllowList(reed2);
-
-    router1.AllowList(sed1);
-    reed2.AllowList(sed1);
+    AllowLinkBetween(sed1, router1);
+    AllowLinkBetween(sed1, reed2);
 
     SuccessOrQuit(sed1.Get<Mac::Filter>().AddRssIn(reed2.Get<Mac::Mac>().GetExtAddress(), kRssiLinkQuality2));
     SuccessOrQuit(reed2.Get<Mac::Filter>().AddRssIn(sed1.Get<Mac::Mac>().GetExtAddress(), kRssiLinkQuality2));
diff --git a/tests/nexus/test_1_1_6_1_6.cpp b/tests/nexus/test_1_1_6_1_6.cpp
index c2080ac..04983bd 100644
--- a/tests/nexus/test_1_1_6_1_6.cpp
+++ b/tests/nexus/test_1_1_6_1_6.cpp
@@ -122,7 +122,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -132,11 +132,8 @@
      */
     Log("Step 1: All");
 
-    leader.AllowList(router1);
-    leader.AllowList(reed1);
-
-    router1.AllowList(leader);
-    reed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, reed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -158,11 +155,8 @@
      */
     Log("Step 2: Router_1");
 
-    dut.AllowList(router1);
-    dut.AllowList(reed1);
-
-    router1.AllowList(dut);
-    reed1.AllowList(dut);
+    AllowLinkBetween(dut, router1);
+    AllowLinkBetween(dut, reed1);
 
     SuccessOrQuit(dut.Get<Mac::Filter>().AddRssIn(router1.Get<Mac::Mac>().GetExtAddress(), kRssiLinkQuality1));
     SuccessOrQuit(router1.Get<Mac::Filter>().AddRssIn(dut.Get<Mac::Mac>().GetExtAddress(), kRssiLinkQuality1));
diff --git a/tests/nexus/test_1_1_6_1_7.cpp b/tests/nexus/test_1_1_6_1_7.cpp
index 798b2d0..b2aaf44 100644
--- a/tests/nexus/test_1_1_6_1_7.cpp
+++ b/tests/nexus/test_1_1_6_1_7.cpp
@@ -90,7 +90,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -102,18 +102,10 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to specify links between nodes. */
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-
-    router1.AllowList(leader);
-    router1.AllowList(router3);
-
-    router2.AllowList(leader);
-    router2.AllowList(router3);
-
-    router3.AllowList(router1);
-    router3.AllowList(router2);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(router1, router3);
+    AllowLinkBetween(router2, router3);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -139,8 +131,7 @@
      * - Pass Criteria:
      *   - The DUT MUST unicast MLE Child ID Request to the Leader.
      */
-    dut.AllowList(leader);
-    leader.AllowList(dut);
+    AllowLinkBetween(dut, leader);
 
     dut.Join(leader, Node::kAsFed);
     nexus.AdvanceTime(kAttachToLeaderTime);
@@ -159,13 +150,9 @@
      *     - Source Address TLV
      *     - Version TLV
      */
-    dut.AllowList(router1);
-    dut.AllowList(router2);
-    dut.AllowList(router3);
-
-    router1.AllowList(dut);
-    router2.AllowList(dut);
-    router3.AllowList(dut);
+    AllowLinkBetween(dut, router1);
+    AllowLinkBetween(dut, router2);
+    AllowLinkBetween(dut, router3);
 
     nexus.AdvanceTime(kLinkRequestTime);
 
diff --git a/tests/nexus/test_1_1_6_2_1.cpp b/tests/nexus/test_1_1_6_2_1.cpp
index 4f784fc..a67f4d6 100644
--- a/tests/nexus/test_1_1_6_2_1.cpp
+++ b/tests/nexus/test_1_1_6_2_1.cpp
@@ -89,7 +89,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -98,10 +98,8 @@
      */
     Log("Step 1: All");
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    router1.AllowList(dut);
-    dut.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, dut);
 
     leader.Form();
 
diff --git a/tests/nexus/test_1_1_6_2_2.cpp b/tests/nexus/test_1_1_6_2_2.cpp
index 6aac017..a10afb2 100644
--- a/tests/nexus/test_1_1_6_2_2.cpp
+++ b/tests/nexus/test_1_1_6_2_2.cpp
@@ -124,7 +124,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -134,14 +134,10 @@
      * - Description: Ensure topology is formed correctly. Ensure that the DUT successfully attached to Router_1.
      * - Pass Criteria: N/A
      */
-    leader.AllowList(router1);
-    leader.AllowList(router2);
-    router1.AllowList(leader);
-    router1.AllowList(router2);
-    router1.AllowList(dut);
-    router2.AllowList(leader);
-    router2.AllowList(router1);
-    dut.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(router1, router2);
+    AllowLinkBetween(router1, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_6_3_1.cpp b/tests/nexus/test_1_1_6_3_1.cpp
index 7c1ac95..360425e 100644
--- a/tests/nexus/test_1_1_6_3_1.cpp
+++ b/tests/nexus/test_1_1_6_3_1.cpp
@@ -127,7 +127,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     if (aTopology == kTopologyA)
@@ -148,10 +148,8 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    router1.AllowList(dut);
-    dut.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -228,8 +226,7 @@
          */
     }
 
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     nexus.AdvanceTime(kDetectParentLossTime);
 
diff --git a/tests/nexus/test_1_1_6_3_2.cpp b/tests/nexus/test_1_1_6_3_2.cpp
index 088f93a..73c9c72 100644
--- a/tests/nexus/test_1_1_6_3_2.cpp
+++ b/tests/nexus/test_1_1_6_3_2.cpp
@@ -100,16 +100,11 @@
         break;
     }
 
-    /**
-     * Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     * - Leader and SED 1 (DUT)
-     */
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -259,8 +254,7 @@
      *   RF isolation, the test will fail because the DUT will go through re-attachment when it emerges.)
      * - Pass Criteria: N/A
      */
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 10: MED_1 (DUT)");
diff --git a/tests/nexus/test_1_1_6_4_1.cpp b/tests/nexus/test_1_1_6_4_1.cpp
index 700d7ad..3023234 100644
--- a/tests/nexus/test_1_1_6_4_1.cpp
+++ b/tests/nexus/test_1_1_6_4_1.cpp
@@ -113,7 +113,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     if (aTopology == kTopologyA)
     {
@@ -134,8 +134,7 @@
      * - Description: Ensure topology is formed correctly.
      * - Pass Criteria: N/A
      */
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_6_4_2.cpp b/tests/nexus/test_1_1_6_4_2.cpp
index 1d42564..22483f7 100644
--- a/tests/nexus/test_1_1_6_4_2.cpp
+++ b/tests/nexus/test_1_1_6_4_2.cpp
@@ -115,7 +115,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -126,11 +126,8 @@
      * - Pass Criteria: N/A
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    router1.AllowList(dut);
-    dut.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_6_5_1.cpp b/tests/nexus/test_1_1_6_5_1.cpp
index 70f6808..e14c221 100644
--- a/tests/nexus/test_1_1_6_5_1.cpp
+++ b/tests/nexus/test_1_1_6_5_1.cpp
@@ -114,7 +114,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -123,8 +123,7 @@
      */
     Log("Step 1: All");
 
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_6_5_2.cpp b/tests/nexus/test_1_1_6_5_2.cpp
index 144b878..d4e8f81 100644
--- a/tests/nexus/test_1_1_6_5_2.cpp
+++ b/tests/nexus/test_1_1_6_5_2.cpp
@@ -112,7 +112,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     if (aTopology == kTopologyA)
@@ -133,10 +133,8 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    router1.AllowList(dut);
-    dut.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -232,8 +230,7 @@
     /**
      * Enable link between Leader and DUT so it can re-attach to Leader.
      */
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     /**
      * Wait for the DUT to realize synchronization failed and start a new attach process.
diff --git a/tests/nexus/test_1_1_6_5_3.cpp b/tests/nexus/test_1_1_6_5_3.cpp
index df3aa35..f80ae01 100644
--- a/tests/nexus/test_1_1_6_5_3.cpp
+++ b/tests/nexus/test_1_1_6_5_3.cpp
@@ -130,7 +130,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -139,10 +139,8 @@
      */
     Log("Step 1: All");
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    router1.AllowList(dut);
-    dut.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, dut);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_6_6_1.cpp b/tests/nexus/test_1_1_6_6_1.cpp
index 7ad4bbf..06ad8d1 100644
--- a/tests/nexus/test_1_1_6_6_1.cpp
+++ b/tests/nexus/test_1_1_6_6_1.cpp
@@ -110,7 +110,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     if (aTopology == kTopologyA)
     {
@@ -131,8 +131,7 @@
      * - Description: Harness instructs the device to form the network using thrKeySequenceCounter = 0.
      * - Pass Criteria: N/A
      */
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     leader.Get<KeyManager>().SetCurrentKeySequence(kInitialKeySequence,
                                                    KeyManager::kForceUpdate | KeyManager::kGuardTimerUnchanged);
diff --git a/tests/nexus/test_1_1_6_6_2.cpp b/tests/nexus/test_1_1_6_6_2.cpp
index fac5569..00daf5a 100644
--- a/tests/nexus/test_1_1_6_6_2.cpp
+++ b/tests/nexus/test_1_1_6_6_2.cpp
@@ -110,7 +110,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     if (aTopology == kTopologyA)
     {
@@ -131,8 +131,7 @@
      * - Description: Harness instructs the device to form the network using thrKeySequenceCounter = 0x7F (127).
      * - Pass Criteria: N/A
      */
-    leader.AllowList(dut);
-    dut.AllowList(leader);
+    AllowLinkBetween(leader, dut);
 
     leader.Form();
     leader.Get<KeyManager>().SetCurrentKeySequence(kInitialKeySequence,
diff --git a/tests/nexus/test_1_1_7_1_1.cpp b/tests/nexus/test_1_1_7_1_1.cpp
index a9f239a..222a717 100644
--- a/tests/nexus/test_1_1_7_1_1.cpp
+++ b/tests/nexus/test_1_1_7_1_1.cpp
@@ -83,18 +83,13 @@
     med1.SetName("MED_1");
     sed1.SetName("SED_1");
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    leader.AllowList(med1);
-    med1.AllowList(leader);
-
-    leader.AllowList(sed1);
-    sed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(leader, sed1);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: Leader (DUT)
diff --git a/tests/nexus/test_1_1_7_1_2.cpp b/tests/nexus/test_1_1_7_1_2.cpp
index d586d2c..05e0c5d 100644
--- a/tests/nexus/test_1_1_7_1_2.cpp
+++ b/tests/nexus/test_1_1_7_1_2.cpp
@@ -99,7 +99,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: Leader
@@ -126,9 +126,7 @@
      */
     Log("Step 2: Router_1 (DUT)");
 
-    /** Use AllowList feature to specify links between nodes. */
-    router1.AllowList(leader);
-    leader.AllowList(router1);
+    AllowLinkBetween(router1, leader);
 
     {
         NetworkData::OnMeshPrefixConfig config;
@@ -182,9 +180,7 @@
      */
     Log("Step 5: MED_1");
 
-    /** Use AllowList feature to specify links between nodes. */
-    med1.AllowList(router1);
-    router1.AllowList(med1);
+    AllowLinkBetween(med1, router1);
 
     /**
      * Step 6: Router_1 (DUT)
@@ -206,9 +202,7 @@
      */
     Log("Step 7: SED_1");
 
-    /** Use AllowList feature to specify links between nodes. */
-    sed1.AllowList(router1);
-    router1.AllowList(sed1);
+    AllowLinkBetween(sed1, router1);
 
     /**
      * Step 8: Router_1 (DUT)
diff --git a/tests/nexus/test_1_1_7_1_3.cpp b/tests/nexus/test_1_1_7_1_3.cpp
index 134a2d1..83f2490 100644
--- a/tests/nexus/test_1_1_7_1_3.cpp
+++ b/tests/nexus/test_1_1_7_1_3.cpp
@@ -87,19 +87,13 @@
     med1.SetName("MED_1");
     sed1.SetName("SED_1");
 
-    /** Use AllowList to specify links between nodes. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    leader.AllowList(med1);
-    med1.AllowList(leader);
-
-    leader.AllowList(sed1);
-    sed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(leader, sed1);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
diff --git a/tests/nexus/test_1_1_7_1_4.cpp b/tests/nexus/test_1_1_7_1_4.cpp
index 0dc1ec0..2a42348 100644
--- a/tests/nexus/test_1_1_7_1_4.cpp
+++ b/tests/nexus/test_1_1_7_1_4.cpp
@@ -89,19 +89,13 @@
     med1.SetName("MED_1");
     sed1.SetName("SED_1");
 
-    /** Use AllowList to specify links between nodes. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(med1);
-    med1.AllowList(router1);
-
-    router1.AllowList(sed1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
diff --git a/tests/nexus/test_1_1_7_1_5.cpp b/tests/nexus/test_1_1_7_1_5.cpp
index 11b93b9..3fb53bb 100644
--- a/tests/nexus/test_1_1_7_1_5.cpp
+++ b/tests/nexus/test_1_1_7_1_5.cpp
@@ -82,7 +82,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -91,14 +91,9 @@
      */
     Log("Step 1: All");
 
-    /** Use AllowList feature to restrict the topology. */
-    router1.AllowList(leader);
-    router1.AllowList(med1);
-    router1.AllowList(sed1);
-
-    leader.AllowList(router1);
-    med1.AllowList(router1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(router1, leader);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_7_1_6.cpp b/tests/nexus/test_1_1_7_1_6.cpp
index 9da6bb0..870aa8e 100644
--- a/tests/nexus/test_1_1_7_1_6.cpp
+++ b/tests/nexus/test_1_1_7_1_6.cpp
@@ -109,7 +109,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -120,15 +120,10 @@
      * - Pass Criteria: N/A.
      */
 
-    dut.AllowList(router1);
-    dut.AllowList(router2);
-    dut.AllowList(med1);
-    dut.AllowList(sed1);
-
-    router1.AllowList(dut);
-    router2.AllowList(dut);
-    med1.AllowList(dut);
-    sed1.AllowList(dut);
+    AllowLinkBetween(dut, router1);
+    AllowLinkBetween(dut, router2);
+    AllowLinkBetween(dut, med1);
+    AllowLinkBetween(dut, sed1);
 
     dut.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -330,8 +325,7 @@
      * - Pass Criteria: N/A.
      */
 
-    dut.AllowList(router1);
-    router1.AllowList(dut);
+    AllowLinkBetween(dut, router1);
     router1.Join(dut, Node::kAsFed);
     nexus.AdvanceTime(kAttachToRouterTime);
 
diff --git a/tests/nexus/test_1_1_7_1_7.cpp b/tests/nexus/test_1_1_7_1_7.cpp
index fd4f05a..b5c4e04 100644
--- a/tests/nexus/test_1_1_7_1_7.cpp
+++ b/tests/nexus/test_1_1_7_1_7.cpp
@@ -111,7 +111,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -122,18 +122,10 @@
      * - Pass Criteria: N/A.
      */
 
-    /** Use AllowList to specify links between nodes. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    leader.AllowList(router2);
-    router2.AllowList(leader);
-
-    leader.AllowList(med1);
-    med1.AllowList(leader);
-
-    leader.AllowList(sed1);
-    sed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(leader, sed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -282,8 +274,7 @@
 
     nexus.AdvanceTime(kStabilizationTime);
 
-    router2.UnallowList(leader);
-    leader.UnallowList(router2);
+    UnallowLinkBetween(router2, leader);
 
     nexus.AdvanceTime(kPartitionStartWaitTime);
 
@@ -327,8 +318,7 @@
      * - Pass Criteria: N/A.
      */
 
-    router2.AllowList(leader);
-    leader.AllowList(router2);
+    AllowLinkBetween(router2, leader);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 10: Router_2");
diff --git a/tests/nexus/test_1_1_7_1_8.cpp b/tests/nexus/test_1_1_7_1_8.cpp
index d070a6a..cabcf60 100644
--- a/tests/nexus/test_1_1_7_1_8.cpp
+++ b/tests/nexus/test_1_1_7_1_8.cpp
@@ -107,7 +107,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -118,12 +118,8 @@
      * - Pass Criteria: N/A.
      */
 
-    /** Use AllowList feature to specify links between nodes. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(fed1);
-    fed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, fed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_8_1_1.cpp b/tests/nexus/test_1_1_8_1_1.cpp
index 7bb284c..517da80 100644
--- a/tests/nexus/test_1_1_8_1_1.cpp
+++ b/tests/nexus/test_1_1_8_1_1.cpp
@@ -154,7 +154,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     // Remove any existing key log file.
     remove(kKeyLogFilename);
diff --git a/tests/nexus/test_1_1_8_1_2.cpp b/tests/nexus/test_1_1_8_1_2.cpp
index c672f6b..095b132 100644
--- a/tests/nexus/test_1_1_8_1_2.cpp
+++ b/tests/nexus/test_1_1_8_1_2.cpp
@@ -159,7 +159,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     // Remove any existing key log file.
     remove(kKeyLogFilename);
diff --git a/tests/nexus/test_1_1_8_1_6.cpp b/tests/nexus/test_1_1_8_1_6.cpp
index 078f931..47c0c52 100644
--- a/tests/nexus/test_1_1_8_1_6.cpp
+++ b/tests/nexus/test_1_1_8_1_6.cpp
@@ -154,7 +154,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     // Remove any existing key log file.
     remove(kKeyLogFilename);
diff --git a/tests/nexus/test_1_1_8_2_1.cpp b/tests/nexus/test_1_1_8_2_1.cpp
index 3d90394..e29c7b5 100644
--- a/tests/nexus/test_1_1_8_2_1.cpp
+++ b/tests/nexus/test_1_1_8_2_1.cpp
@@ -157,7 +157,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: All");
diff --git a/tests/nexus/test_1_1_8_2_2.cpp b/tests/nexus/test_1_1_8_2_2.cpp
index deec4cd..56431a4 100644
--- a/tests/nexus/test_1_1_8_2_2.cpp
+++ b/tests/nexus/test_1_1_8_2_2.cpp
@@ -157,7 +157,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
 #if OPENTHREAD_CONFIG_MBEDTLS_PROVIDES_SSL_KEY_EXPORT
     remove("test_1_1_8_2_2.keys");
diff --git a/tests/nexus/test_1_1_8_3_1.cpp b/tests/nexus/test_1_1_8_3_1.cpp
index 2b1608a..34329ba 100644
--- a/tests/nexus/test_1_1_8_3_1.cpp
+++ b/tests/nexus/test_1_1_8_3_1.cpp
@@ -87,7 +87,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
diff --git a/tests/nexus/test_1_1_9_2_1.cpp b/tests/nexus/test_1_1_9_2_1.cpp
index 3acf9f9..24b2bcc 100644
--- a/tests/nexus/test_1_1_9_2_1.cpp
+++ b/tests/nexus/test_1_1_9_2_1.cpp
@@ -99,7 +99,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -109,9 +109,7 @@
      * - Description: Ensure topology is formed correctly.
      * - Pass Criteria: N/A.
      */
-
-    leader.AllowList(commissioner);
-    commissioner.AllowList(leader);
+    AllowLinkBetween(leader, commissioner);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_9_2_10.cpp b/tests/nexus/test_1_1_9_2_10.cpp
index c0d80da..e6a29b7 100644
--- a/tests/nexus/test_1_1_9_2_10.cpp
+++ b/tests/nexus/test_1_1_9_2_10.cpp
@@ -170,7 +170,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -181,17 +181,10 @@
      * - Pass Criteria: N/A.
      */
 
-    commissioner.AllowList(leader);
-    leader.AllowList(commissioner);
-
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(med1);
-    med1.AllowList(router1);
-
-    router1.AllowList(sed1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(commissioner, leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     {
         MeshCoP::Dataset::Info datasetInfo;
@@ -486,19 +479,14 @@
      */
 
     router1.Get<Mac::Filter>().ClearAddresses();
-    router1.AllowList(med1);
-    router1.AllowList(sed1);
-
     med1.Get<Mac::Filter>().ClearAddresses();
-    med1.AllowList(router1);
-
     sed1.Get<Mac::Filter>().ClearAddresses();
-    sed1.AllowList(router1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     leader.Get<Mac::Filter>().ClearAddresses();
     commissioner.Get<Mac::Filter>().ClearAddresses();
-    commissioner.AllowList(leader);
-    leader.AllowList(commissioner);
+    AllowLinkBetween(leader, commissioner);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 14: Router_1");
@@ -570,8 +558,7 @@
 
     nexus.AdvanceTime(kIsolationTime - kDelayTimerTime);
 
-    router1.AllowList(leader);
-    leader.AllowList(router1);
+    AllowLinkBetween(router1, leader);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 19: Router_1");
diff --git a/tests/nexus/test_1_1_9_2_11.cpp b/tests/nexus/test_1_1_9_2_11.cpp
index 8a83018..610b8b2 100644
--- a/tests/nexus/test_1_1_9_2_11.cpp
+++ b/tests/nexus/test_1_1_9_2_11.cpp
@@ -167,7 +167,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -178,14 +178,10 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(commissioner);
-    leader.AllowList(router1);
-    commissioner.AllowList(leader);
-    router1.AllowList(leader);
-    router1.AllowList(med1);
-    router1.AllowList(sed1);
-    med1.AllowList(router1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(leader, commissioner);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     {
         MeshCoP::Dataset::Info datasetInfo;
diff --git a/tests/nexus/test_1_1_9_2_12.cpp b/tests/nexus/test_1_1_9_2_12.cpp
index 50eaaa8..e90a8e6 100644
--- a/tests/nexus/test_1_1_9_2_12.cpp
+++ b/tests/nexus/test_1_1_9_2_12.cpp
@@ -122,20 +122,12 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
-    /** Use AllowList feature to specify links between nodes. */
-    leader2.AllowList(med1);
-    med1.AllowList(leader2);
-
-    leader1.AllowList(router1);
-    router1.AllowList(leader1);
-
-    router1.AllowList(leader2);
-    leader2.AllowList(router1);
-
-    router1.AllowList(med1);
-    med1.AllowList(router1);
+    AllowLinkBetween(leader2, med1);
+    AllowLinkBetween(leader1, router1);
+    AllowLinkBetween(router1, leader2);
+    AllowLinkBetween(router1, med1);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
diff --git a/tests/nexus/test_1_1_9_2_13.cpp b/tests/nexus/test_1_1_9_2_13.cpp
index 7fd512a..27cf314 100644
--- a/tests/nexus/test_1_1_9_2_13.cpp
+++ b/tests/nexus/test_1_1_9_2_13.cpp
@@ -152,7 +152,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 1: Form topology and start a constant ICMPv6 Echo Request from Leader_2 to SED_2.");
 
@@ -162,24 +162,16 @@
      * - Pass Criteria: N/A.
      */
 
-    /** Set up AllowList for links. */
-    leader1.AllowList(commissioner);
-    commissioner.AllowList(leader1);
-
-    leader1.AllowList(router1);
-    router1.AllowList(leader1);
-
-    router1.AllowList(fed1);
-    fed1.AllowList(router1);
-
-    leader2.AllowList(sed2);
-    sed2.AllowList(leader2);
+    AllowLinkBetween(leader1, commissioner);
+    AllowLinkBetween(leader1, router1);
+    AllowLinkBetween(router1, fed1);
+    AllowLinkBetween(leader2, sed2);
 
     Ip6::Prefix        prefix;
     Ip6::NetworkPrefix networkPrefix;
 
     SuccessOrQuit(prefix.FromString("fd00:7d1:a11:1::/64"));
-    SuccessOrQuit(networkPrefix.SetFrom(prefix));
+    SuccessOrQuit(networkPrefix.InitFrom(prefix));
 
     /** Network 1 on Primary Channel. */
     {
diff --git a/tests/nexus/test_1_1_9_2_14.cpp b/tests/nexus/test_1_1_9_2_14.cpp
index 852bcea..fa937c4 100644
--- a/tests/nexus/test_1_1_9_2_14.cpp
+++ b/tests/nexus/test_1_1_9_2_14.cpp
@@ -106,7 +106,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -117,15 +117,9 @@
      *   - Pass Criteria: N/A
      */
 
-    leader1.AllowList(router1);
-    leader1.AllowList(commissioner);
-
-    router1.AllowList(leader1);
-    router1.AllowList(leader2);
-
-    commissioner.AllowList(leader1);
-
-    leader2.AllowList(router1);
+    AllowLinkBetween(leader1, router1);
+    AllowLinkBetween(leader1, commissioner);
+    AllowLinkBetween(router1, leader2);
 
     {
         MeshCoP::Dataset::Info datasetInfo;
diff --git a/tests/nexus/test_1_1_9_2_15.cpp b/tests/nexus/test_1_1_9_2_15.cpp
index 09e8fd7..ac2b17c 100644
--- a/tests/nexus/test_1_1_9_2_15.cpp
+++ b/tests/nexus/test_1_1_9_2_15.cpp
@@ -203,7 +203,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Commissioner, Leader, Router_1");
@@ -215,11 +215,8 @@
      * - Pass Criteria: N/A
      */
 
-    commissioner.AllowList(leader);
-    leader.AllowList(commissioner);
-
-    leader.AllowList(router1);
-    router1.AllowList(leader);
+    AllowLinkBetween(commissioner, leader);
+    AllowLinkBetween(leader, router1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -254,8 +251,7 @@
      * - Leader and Router 1
      * - Router 1 and Router 2
      */
-    dut.AllowList(router1);
-    router1.AllowList(dut);
+    AllowLinkBetween(dut, router1);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 3: Commissioner");
diff --git a/tests/nexus/test_1_1_9_2_16.cpp b/tests/nexus/test_1_1_9_2_16.cpp
index f1452cc..a0f3415 100644
--- a/tests/nexus/test_1_1_9_2_16.cpp
+++ b/tests/nexus/test_1_1_9_2_16.cpp
@@ -189,7 +189,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Commissioner, Leader, Router_1");
@@ -201,14 +201,9 @@
      * - Pass Criteria: N/A
      */
 
-    commissioner.AllowList(leader);
-    leader.AllowList(commissioner);
-
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(router2);
-    router2.AllowList(router1);
+    AllowLinkBetween(commissioner, leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, router2);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -277,7 +272,7 @@
             Ip6::NetworkPrefix networkPrefix;
 
             SuccessOrQuit(prefix.FromString(kMeshLocalPrefixStep3));
-            SuccessOrQuit(networkPrefix.SetFrom(prefix));
+            SuccessOrQuit(networkPrefix.InitFrom(prefix));
             SuccessOrQuit(Tlv::Append<MeshCoP::MeshLocalPrefixTlv>(*message, networkPrefix));
         }
         SuccessOrQuit(Tlv::Append<MeshCoP::DelayTimerTlv>(*message, kDelayTimerStep3));
@@ -430,7 +425,7 @@
             Ip6::NetworkPrefix networkPrefix;
 
             SuccessOrQuit(prefix.FromString(kMeshLocalPrefixStep11));
-            SuccessOrQuit(networkPrefix.SetFrom(prefix));
+            SuccessOrQuit(networkPrefix.InitFrom(prefix));
             SuccessOrQuit(Tlv::Append<MeshCoP::MeshLocalPrefixTlv>(*message, networkPrefix));
         }
         SuccessOrQuit(Tlv::Append<MeshCoP::NetworkNameTlv>(*message, kNetworkNameStep13));
diff --git a/tests/nexus/test_1_1_9_2_17.cpp b/tests/nexus/test_1_1_9_2_17.cpp
index a4522cb..deb1344 100644
--- a/tests/nexus/test_1_1_9_2_17.cpp
+++ b/tests/nexus/test_1_1_9_2_17.cpp
@@ -140,7 +140,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -152,18 +152,13 @@
      *   Advertisements on separate channels.
      */
 
-    leader1.AllowList(dut);
-    dut.AllowList(leader1);
-
-    leader2.AllowList(dut);
-    dut.AllowList(leader2);
+    AllowLinkBetween(leader1, dut);
 
     // Leader 1 <-> DUT is allowed.
     // Leader 2 <-> DUT is initially blocked by radio channel and different PAN ID, but here we also use AllowList.
     // We start with Leader 2 being reachable but on different channel.
     // Actually, to fully control connectivity as per spec, we'll unallow Leader 2 for now.
-    leader2.UnallowList(dut);
-    dut.UnallowList(leader2);
+    UnallowLinkBetween(leader2, dut);
 
     dut.Get<Mle::Mle>().SetTimeout(20);
 
@@ -237,8 +232,7 @@
     leader1.Get<Mle::Mle>().Stop();
     leader1.Get<ThreadNetif>().Down();
 
-    leader2.AllowList(dut);
-    dut.AllowList(leader2);
+    AllowLinkBetween(leader2, dut);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 3: DUT");
diff --git a/tests/nexus/test_1_1_9_2_18.cpp b/tests/nexus/test_1_1_9_2_18.cpp
index 7271f35..80f0057 100644
--- a/tests/nexus/test_1_1_9_2_18.cpp
+++ b/tests/nexus/test_1_1_9_2_18.cpp
@@ -142,7 +142,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -152,19 +152,10 @@
      * - Description: Ensure topology is formed correctly.
      * - Pass Criteria: N/A
      */
-
-    /** Use AllowList feature to specify links between nodes. */
-    commissioner.AllowList(leader);
-    leader.AllowList(commissioner);
-
-    router1.AllowList(leader);
-    leader.AllowList(router1);
-
-    router1.AllowList(med1);
-    med1.AllowList(router1);
-
-    router1.AllowList(sed1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(commissioner, leader);
+    AllowLinkBetween(router1, leader);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     MeshCoP::Dataset::Info datasetInfo;
     SuccessOrQuit(datasetInfo.GenerateRandom(leader.GetInstance()));
diff --git a/tests/nexus/test_1_1_9_2_19.cpp b/tests/nexus/test_1_1_9_2_19.cpp
index f1a3967..33b4bed 100644
--- a/tests/nexus/test_1_1_9_2_19.cpp
+++ b/tests/nexus/test_1_1_9_2_19.cpp
@@ -120,7 +120,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -130,8 +130,7 @@
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
 
-    leader.AllowList(commissioner);
-    commissioner.AllowList(leader);
+    AllowLinkBetween(leader, commissioner);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_9_2_2.cpp b/tests/nexus/test_1_1_9_2_2.cpp
index 822f186..a88799f 100644
--- a/tests/nexus/test_1_1_9_2_2.cpp
+++ b/tests/nexus/test_1_1_9_2_2.cpp
@@ -69,7 +69,7 @@
     MeshCoP::SteeringData steeringData;
 
     steeringData.SetToPermitAllJoiners();
-    SuccessOrQuit(Tlv::Append<MeshCoP::SteeringDataTlv>(aMessage, steeringData.GetData(), steeringData.GetLength()));
+    SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(aMessage, steeringData));
 }
 
 void Test9_2_2(void)
@@ -99,7 +99,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All
@@ -108,8 +108,7 @@
      */
     Log("Step 1: All");
 
-    leader.AllowList(commissioner);
-    commissioner.AllowList(leader);
+    AllowLinkBetween(leader, commissioner);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_9_2_3.cpp b/tests/nexus/test_1_1_9_2_3.cpp
index f0230eb..2acf24c 100644
--- a/tests/nexus/test_1_1_9_2_3.cpp
+++ b/tests/nexus/test_1_1_9_2_3.cpp
@@ -101,7 +101,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -112,8 +112,7 @@
      * - Pass Criteria: N/A.
      */
 
-    leader.AllowList(commissioner);
-    commissioner.AllowList(leader);
+    AllowLinkBetween(leader, commissioner);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -152,7 +151,7 @@
         MeshCoP::Dataset::Components components;
         Ip6::Address                 leaderAloc;
 
-        leader.Get<Mle::Mle>().GetLeaderAloc(leaderAloc);
+        leader.Get<Mle::Mle>().ComposeLeaderAloc(leaderAloc);
         components.Clear();
         SuccessOrQuit(
             commissioner.Get<MeshCoP::ActiveDatasetManager>().SendGetRequest(components, nullptr, 0, &leaderAloc));
@@ -199,7 +198,7 @@
         Ip6::Address                 leaderAloc;
         uint8_t tlvs[] = {MeshCoP::Tlv::kChannelMask, MeshCoP::Tlv::kMeshLocalPrefix, MeshCoP::Tlv::kNetworkName};
 
-        leader.Get<Mle::Mle>().GetLeaderAloc(leaderAloc);
+        leader.Get<Mle::Mle>().ComposeLeaderAloc(leaderAloc);
         components.Clear();
         SuccessOrQuit(commissioner.Get<MeshCoP::ActiveDatasetManager>().SendGetRequest(components, tlvs, sizeof(tlvs),
                                                                                        &leaderAloc));
@@ -247,7 +246,7 @@
         uint8_t tlvs[] = {MeshCoP::Tlv::kChannel, MeshCoP::Tlv::kMeshLocalPrefix, MeshCoP::Tlv::kNetworkName,
                           MeshCoP::Tlv::kScanDuration, MeshCoP::Tlv::kEnergyList};
 
-        leader.Get<Mle::Mle>().GetLeaderAloc(leaderAloc);
+        leader.Get<Mle::Mle>().ComposeLeaderAloc(leaderAloc);
         components.Clear();
         SuccessOrQuit(commissioner.Get<MeshCoP::ActiveDatasetManager>().SendGetRequest(components, tlvs, sizeof(tlvs),
                                                                                        &leaderAloc));
diff --git a/tests/nexus/test_1_1_9_2_4.cpp b/tests/nexus/test_1_1_9_2_4.cpp
index 8bf5ae2..cd2cf98 100644
--- a/tests/nexus/test_1_1_9_2_4.cpp
+++ b/tests/nexus/test_1_1_9_2_4.cpp
@@ -261,19 +261,18 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
 
     /**
      * Step 1: All
-     * - Description: Ensure topology is formed correctly.
+     * - Description: Ensure topology is formed correctly
      * - Pass Criteria: N/A.
      */
 
-    leader.AllowList(commissioner);
-    commissioner.AllowList(leader);
+    AllowLinkBetween(leader, commissioner);
 
     {
         MeshCoP::Dataset::Info datasetInfo;
diff --git a/tests/nexus/test_1_1_9_2_5.cpp b/tests/nexus/test_1_1_9_2_5.cpp
index 5c27cc0..fd68596 100644
--- a/tests/nexus/test_1_1_9_2_5.cpp
+++ b/tests/nexus/test_1_1_9_2_5.cpp
@@ -113,7 +113,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -124,8 +124,7 @@
      * - Pass Criteria: N/A.
      */
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_1_9_2_6.cpp b/tests/nexus/test_1_1_9_2_6.cpp
index cc5918c..db01d86 100644
--- a/tests/nexus/test_1_1_9_2_6.cpp
+++ b/tests/nexus/test_1_1_9_2_6.cpp
@@ -139,7 +139,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -150,14 +150,10 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(commissioner);
-    leader.AllowList(router1);
-    commissioner.AllowList(leader);
-    router1.AllowList(leader);
-    router1.AllowList(med1);
-    router1.AllowList(sed1);
-    med1.AllowList(router1);
-    sed1.AllowList(router1);
+    AllowLinkBetween(leader, commissioner);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
 
     {
         MeshCoP::Dataset::Info datasetInfo;
@@ -216,8 +212,7 @@
         {
             MeshCoP::SteeringData steeringData;
             steeringData.SetToPermitAllJoiners();
-            SuccessOrQuit(
-                Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+            SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
         }
 
         SuccessOrQuit(agent.SendMessageToLeaderAloc(*message));
diff --git a/tests/nexus/test_1_1_9_2_7.cpp b/tests/nexus/test_1_1_9_2_7.cpp
index a18274c..5a3c189 100644
--- a/tests/nexus/test_1_1_9_2_7.cpp
+++ b/tests/nexus/test_1_1_9_2_7.cpp
@@ -154,7 +154,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -165,8 +165,7 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(commissioner);
-    commissioner.AllowList(leader);
+    AllowLinkBetween(leader, commissioner);
 
     {
         MeshCoP::Dataset::Info datasetInfo;
@@ -212,8 +211,7 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(router);
-    router.AllowList(leader);
+    AllowLinkBetween(leader, router);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 3: Router");
diff --git a/tests/nexus/test_1_1_9_2_8.cpp b/tests/nexus/test_1_1_9_2_8.cpp
index 8c3711f..6b34a43 100644
--- a/tests/nexus/test_1_1_9_2_8.cpp
+++ b/tests/nexus/test_1_1_9_2_8.cpp
@@ -147,7 +147,7 @@
     med1.SetName("MED_1");
     sed1.SetName("SED_1");
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -158,17 +158,10 @@
      * - Pass Criteria: N/A.
      */
 
-    commissioner.AllowList(leader);
-    leader.AllowList(commissioner);
-
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    leader.AllowList(med1);
-    med1.AllowList(leader);
-
-    leader.AllowList(sed1);
-    sed1.AllowList(leader);
+    AllowLinkBetween(commissioner, leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, med1);
+    AllowLinkBetween(leader, sed1);
 
     {
         MeshCoP::Dataset::Info dataset;
@@ -339,9 +332,9 @@
      *     PANID: 0xAFCE).
      */
 
-    router1.AllowList(leader);
-    med1.AllowList(leader);
-    sed1.AllowList(leader);
+    AllowLinkBetween(router1, leader);
+    AllowLinkBetween(med1, leader);
+    AllowLinkBetween(sed1, leader);
 
     SuccessOrQuit(router1.Get<Mle::Mle>().SetDeviceMode(Mle::DeviceMode(Mle::DeviceMode::kModeRxOnWhenIdle |
                                                                         Mle::DeviceMode::kModeFullThreadDevice |
diff --git a/tests/nexus/test_1_1_9_2_9.cpp b/tests/nexus/test_1_1_9_2_9.cpp
index b2f2a57..d347d72 100644
--- a/tests/nexus/test_1_1_9_2_9.cpp
+++ b/tests/nexus/test_1_1_9_2_9.cpp
@@ -209,7 +209,7 @@
     router2.Get<Mle::Mle>().SetNetworkIdTimeout(70);
     router1.Get<Mle::Mle>().SetNetworkIdTimeout(120);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -220,14 +220,9 @@
      * - Pass Criteria: N/A
      */
 
-    commissioner.AllowList(leader);
-    leader.AllowList(commissioner);
-
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(router2);
-    router2.AllowList(router1);
+    AllowLinkBetween(commissioner, leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, router2);
 
     leader.Get<Mac::Mac>().SetPanId(kPanIdFace);
     leader.Form();
@@ -326,8 +321,7 @@
     Log("---------------------------------------------------------------------------------------");
     Log("Step 4: Leader");
 
-    router1.UnallowList(router2);
-    router2.UnallowList(router1);
+    UnallowLinkBetween(router1, router2);
 
     /**
      * Step 4: Leader
@@ -393,8 +387,7 @@
     Log("---------------------------------------------------------------------------------------");
     Log("Step 6: Router_1");
 
-    router1.AllowList(router2);
-    router2.AllowList(router1);
+    AllowLinkBetween(router1, router2);
 
     /**
      * Step 6: Router_1
@@ -464,10 +457,8 @@
      * - Pass Criteria: N/A
      */
 
-    leader.UnallowList(router1);
-    leader.UnallowList(router2);
-    router1.UnallowList(leader);
-    router2.UnallowList(leader);
+    UnallowLinkBetween(leader, router1);
+    UnallowLinkBetween(leader, router2);
 
     nexus.AdvanceTime(kRfIsolationTime);
 
@@ -681,8 +672,7 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
 
     nexus.AdvanceTime(300000); // 300s to ensure merge and dataset sync.
 
diff --git a/tests/nexus/test_1_2_BBR_TC_1.cpp b/tests/nexus/test_1_2_BBR_TC_1.cpp
index c9e6070..1a02a58 100644
--- a/tests/nexus/test_1_2_BBR_TC_1.cpp
+++ b/tests/nexus/test_1_2_BBR_TC_1.cpp
@@ -148,7 +148,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Topology formation - Leader, Router");
diff --git a/tests/nexus/test_1_2_BBR_TC_2.cpp b/tests/nexus/test_1_2_BBR_TC_2.cpp
index 05744fe..0673377 100644
--- a/tests/nexus/test_1_2_BBR_TC_2.cpp
+++ b/tests/nexus/test_1_2_BBR_TC_2.cpp
@@ -102,7 +102,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0
@@ -293,20 +293,16 @@
     Log("Step 10: Router_1 sends MLR.req to BR_1 (DUT)");
 
     {
-        Ip6::Address    ma1;
-        Coap::Message  *message;
-        Ip6AddressesTlv addressesTlv;
-        Ip6::Address    destAddr;
+        Ip6::Address   ma1;
+        Coap::Message *message;
+        Ip6::Address   destAddr;
 
         SuccessOrQuit(ma1.FromString(kMa1Address));
 
         message = router1.Get<Tmf::Agent>().AllocateAndInitConfirmablePostMessage(kUriMlr);
         VerifyOrQuit(message != nullptr);
 
-        addressesTlv.Init();
-        addressesTlv.SetLength(sizeof(Ip6::Address));
-        SuccessOrQuit(message->Append(addressesTlv));
-        SuccessOrQuit(message->Append(ma1));
+        SuccessOrQuit(Ip6AddressesTlv::AppendTo(*message, &ma1, 1));
 
         destAddr = br1.Get<Mle::Mle>().GetMeshLocalEid();
         SuccessOrQuit(router1.Get<Tmf::Agent>().SendMessageTo(*message, destAddr, nullptr, nullptr));
diff --git a/tests/nexus/test_1_2_BBR_TC_3.cpp b/tests/nexus/test_1_2_BBR_TC_3.cpp
index 7dcd3ea..1fb93e1 100644
--- a/tests/nexus/test_1_2_BBR_TC_3.cpp
+++ b/tests/nexus/test_1_2_BBR_TC_3.cpp
@@ -74,7 +74,9 @@
      * 5.11.3.1 Topology
      * - BR_1: BR device initially operating as the Primary BBR and Leader.
      * - BR_2: BR device initially operating as a Secondary BBR.
-     * - Host: Test bed BR device operating as a non-Thread IPv6 host. It is used to send out the mDNS queries.
+     * - Host_1 and Host_2 : Test bed devices operating as a non-Thread IPv6 hosts. They are used to send out the
+     *                       mDNS queries. Two hosts are used to be able to track their queries more easily during pkt
+     *                       verification.
      *
      * 5.11.3.2 Purpose & Description
      * The purpose of this test case is to verify that a BBR Function (both Primary and Secondary) can be discovered
@@ -88,29 +90,29 @@
      */
 
     Core  nexus;
-    Node &br1  = nexus.CreateNode();
-    Node &br2  = nexus.CreateNode();
-    Node &host = nexus.CreateNode();
+    Node &br1   = nexus.CreateNode();
+    Node &br2   = nexus.CreateNode();
+    Node &host1 = nexus.CreateNode();
+    Node &host2 = nexus.CreateNode();
 
     br1.SetName("BR_1");
     br2.SetName("BR_2");
-    host.SetName("HOST");
+    host1.SetName("HOST_1");
+    host2.SetName("HOST_2");
 
     br1.Form();
 
     {
         MeshCoP::Dataset::Info datasetInfo;
-        String<17>             xpanIdString;
 
         SuccessOrQuit(br1.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
-        nexus.AddTestVar("NETWORK_NAME", datasetInfo.mNetworkName.m8);
-        xpanIdString.AppendHexBytes(datasetInfo.mExtendedPanId.m8, sizeof(datasetInfo.mExtendedPanId.m8));
-        nexus.AddTestVar("XPAN_ID", xpanIdString.AsCString());
+        nexus.AddTestVar("NETWORK_NAME", datasetInfo.Get<MeshCoP::Dataset::kNetworkName>().GetAsCString());
+        nexus.AddTestVar("XPAN_ID", datasetInfo.Get<MeshCoP::Dataset::kExtendedPanId>().ToString().AsCString());
     }
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Topology formation - BR_1");
@@ -122,8 +124,7 @@
      * - Pass Criteria:
      *   - N/A
      */
-    br1.AllowList(br2);
-    br2.AllowList(br1);
+    AllowLinkBetween(br1, br2);
 
     br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
     br1.Get<BorderRouter::RoutingManager>().Init();
@@ -154,10 +155,15 @@
     nexus.AdvanceTime(kAttachToRouterTime);
     VerifyOrQuit(br2.Get<Mle::Mle>().IsRouter());
 
-    host.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
-    SuccessOrQuit(host.Get<BorderRouter::RoutingManager>().SetEnabled(false));
-    host.Get<Dns::Multicast::Core>().SetAutoEnableMode(false);
-    SuccessOrQuit(host.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    host1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    SuccessOrQuit(host1.Get<BorderRouter::RoutingManager>().SetEnabled(false));
+    host1.Get<Dns::Multicast::Core>().SetAutoEnableMode(false);
+    SuccessOrQuit(host1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    host2.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    SuccessOrQuit(host2.Get<BorderRouter::RoutingManager>().SetEnabled(false));
+    host2.Get<Dns::Multicast::Core>().SetAutoEnableMode(false);
+    SuccessOrQuit(host2.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
 
     nexus.AdvanceTime(kBbrSelectionTime);
     nexus.AdvanceTime(kStabilizationTime);
@@ -170,7 +176,7 @@
 
     /**
      * Step 1
-     * - Device: Host
+     * - Device: Host_1
      * - Description: Harness instructs the device to send an mDNS query (per P2).
      * - Pass Criteria:
      *   - N/A
@@ -178,16 +184,16 @@
     {
         Dns::Multicast::Core::Browser browser;
 
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
-        VerifyOrQuit(host.Get<Dns::Multicast::Core>().IsEnabled());
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+        VerifyOrQuit(host1.Get<Dns::Multicast::Core>().IsEnabled());
 
         ClearAllBytes(browser);
         browser.mServiceType  = "_meshcop._udp";
         browser.mInfraIfIndex = kInfraIfIndex;
         browser.mCallback     = HandleMdnsBrowse;
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().StartBrowser(browser));
         nexus.AdvanceTime(kStabilizationTime);
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().StopBrowser(browser));
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().StopBrowser(browser));
     }
 
     nexus.AddOmrPrefixTestVar("OMR_PREFIX_STEP_0", br1);
@@ -298,7 +304,7 @@
 
     /**
      * Step 5
-     * - Device: Host
+     * - Device: Host_1
      * - Description: Harness instructs the device to send an mDNS query.
      * - Pass Criteria:
      *   - N/A
@@ -306,15 +312,15 @@
     {
         Dns::Multicast::Core::Browser browser;
 
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
 
         ClearAllBytes(browser);
         browser.mServiceType  = "_meshcop._udp";
         browser.mInfraIfIndex = kInfraIfIndex;
         browser.mCallback     = HandleMdnsBrowse;
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().StartBrowser(browser));
         nexus.AdvanceTime(kStabilizationTime);
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().StopBrowser(browser));
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().StopBrowser(browser));
     }
 
     nexus.AddOmrPrefixTestVar("OMR_PREFIX_STEP_4", br1);
@@ -371,7 +377,7 @@
 
     /**
      * Step 9
-     * - Device: Host
+     * - Device: Host_2
      * - Description: Harness instructs the device to send an mDNS query.
      * - Pass Criteria:
      *   - N/A
@@ -379,15 +385,15 @@
     {
         Dns::Multicast::Core::Browser browser;
 
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+        SuccessOrQuit(host2.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
 
         ClearAllBytes(browser);
         browser.mServiceType  = "_meshcop._udp";
         browser.mInfraIfIndex = kInfraIfIndex;
         browser.mCallback     = HandleMdnsBrowse;
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        SuccessOrQuit(host2.Get<Dns::Multicast::Core>().StartBrowser(browser));
         nexus.AdvanceTime(kStabilizationTime);
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().StopBrowser(browser));
+        SuccessOrQuit(host2.Get<Dns::Multicast::Core>().StopBrowser(browser));
     }
 
     nexus.AddOmrPrefixTestVar("OMR_PREFIX_STEP_10", br2);
@@ -467,7 +473,7 @@
 
     /**
      * Step 12
-     * - Device: Host
+     * - Device: Host_1
      * - Description: Harness instructs the device to send an mDNS query.
      * - Pass Criteria:
      *   - N/A
@@ -475,15 +481,15 @@
     {
         Dns::Multicast::Core::Browser browser;
 
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
 
         ClearAllBytes(browser);
         browser.mServiceType  = "_meshcop._udp";
         browser.mInfraIfIndex = kInfraIfIndex;
         browser.mCallback     = HandleMdnsBrowse;
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().StartBrowser(browser));
         nexus.AdvanceTime(kStabilizationTime);
-        SuccessOrQuit(host.Get<Dns::Multicast::Core>().StopBrowser(browser));
+        SuccessOrQuit(host1.Get<Dns::Multicast::Core>().StopBrowser(browser));
     }
 
     nexus.AddOmrPrefixTestVar("OMR_PREFIX_STEP_11", br1);
diff --git a/tests/nexus/test_1_2_LP_5_2_1.cpp b/tests/nexus/test_1_2_LP_5_2_1.cpp
index 8dfea69..18dc9fa 100644
--- a/tests/nexus/test_1_2_LP_5_2_1.cpp
+++ b/tests/nexus/test_1_2_LP_5_2_1.cpp
@@ -125,28 +125,14 @@
     sed2.SetName("SED_2");
     sed3.SetName("SED_3");
 
-    /**
-     * - Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     *   - Leader (DUT) and Router 1
-     *   - Leader (DUT) and SED 1
-     *   - Leader (DUT) and SED 2
-     *   - Leader (DUT) and SED 3
-     */
-    dut.AllowList(router1);
-    router1.AllowList(dut);
-
-    dut.AllowList(sed1);
-    sed1.AllowList(dut);
-
-    dut.AllowList(sed2);
-    sed2.AllowList(dut);
-
-    dut.AllowList(sed3);
-    sed3.AllowList(dut);
+    AllowLinkBetween(dut, router1);
+    AllowLinkBetween(dut, sed1);
+    AllowLinkBetween(dut, sed2);
+    AllowLinkBetween(dut, sed3);
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1: All Devices
diff --git a/tests/nexus/test_1_2_LP_5_3_1.cpp b/tests/nexus/test_1_2_LP_5_3_1.cpp
index 4edcb1a..9f5ea2b 100644
--- a/tests/nexus/test_1_2_LP_5_3_1.cpp
+++ b/tests/nexus/test_1_2_LP_5_3_1.cpp
@@ -100,7 +100,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: All");
@@ -111,12 +111,8 @@
      * - Pass Criteria: N/A.
      */
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    leader.AllowList(ssed1);
-
-    router1.AllowList(leader);
-    ssed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, ssed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_5_3_2.cpp b/tests/nexus/test_1_2_LP_5_3_2.cpp
index 42b0476..e34ef1c 100644
--- a/tests/nexus/test_1_2_LP_5_3_2.cpp
+++ b/tests/nexus/test_1_2_LP_5_3_2.cpp
@@ -186,7 +186,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0: SSED_1
@@ -206,12 +206,8 @@
      */
     Log("Step 1: All");
 
-    /** Use AllowList feature to restrict the topology. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(ssed1);
-    ssed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, ssed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_5_3_3.cpp b/tests/nexus/test_1_2_LP_5_3_3.cpp
index 343708e..40b47b1 100644
--- a/tests/nexus/test_1_2_LP_5_3_3.cpp
+++ b/tests/nexus/test_1_2_LP_5_3_3.cpp
@@ -129,7 +129,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0: SSED_1
@@ -145,16 +145,8 @@
      */
     Log("Step 1: All");
 
-    /**
-     * Use AllowList to specify links between nodes. There is a link between the following node pairs:
-     * - Leader (DUT) and Router 1
-     * - Leader (DUT) and SSED 1
-     */
-    dut.AllowList(router1);
-    router1.AllowList(dut);
-
-    dut.AllowList(ssed1);
-    ssed1.AllowList(dut);
+    AllowLinkBetween(dut, router1);
+    AllowLinkBetween(dut, ssed1);
 
     dut.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_5_3_4.cpp b/tests/nexus/test_1_2_LP_5_3_4.cpp
index 3518340..8b4d288 100644
--- a/tests/nexus/test_1_2_LP_5_3_4.cpp
+++ b/tests/nexus/test_1_2_LP_5_3_4.cpp
@@ -96,7 +96,7 @@
     router1.SetName("ROUTER_1");
     ssed1.SetName("SSED_1");
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 0: SSED_1");
 
@@ -108,12 +108,8 @@
      * - Pass Criteria: N/A.
      */
 
-    leader.AllowList(router1);
-    leader.AllowList(ssed1);
-
-    router1.AllowList(leader);
-
-    ssed1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, ssed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_5_3_5.cpp b/tests/nexus/test_1_2_LP_5_3_5.cpp
index 868248b..851cfd3 100644
--- a/tests/nexus/test_1_2_LP_5_3_5.cpp
+++ b/tests/nexus/test_1_2_LP_5_3_5.cpp
@@ -141,7 +141,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 0: SSED_1-6");
 
@@ -170,13 +170,11 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(router1);
-    router1.AllowList(leader);
+    AllowLinkBetween(leader, router1);
 
     for (Node *ssed : sseds)
     {
-        router1.AllowList(*ssed);
-        ssed->AllowList(router1);
+        AllowLinkBetween(router1, *ssed);
     }
 
     leader.Form();
diff --git a/tests/nexus/test_1_2_LP_5_3_6.cpp b/tests/nexus/test_1_2_LP_5_3_6.cpp
index 00413e1..e14007b 100644
--- a/tests/nexus/test_1_2_LP_5_3_6.cpp
+++ b/tests/nexus/test_1_2_LP_5_3_6.cpp
@@ -120,7 +120,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0: SSED_1
@@ -136,11 +136,8 @@
      */
     Log("Step 1: All");
 
-    /** Use AllowList feature to specify links between nodes. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    router1.AllowList(ssed1);
-    ssed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, ssed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_5_3_7.cpp b/tests/nexus/test_1_2_LP_5_3_7.cpp
index 3f04cc4..74bd88b 100644
--- a/tests/nexus/test_1_2_LP_5_3_7.cpp
+++ b/tests/nexus/test_1_2_LP_5_3_7.cpp
@@ -116,7 +116,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: SSED_1: Preconditions: Set CSL Synchronized Timeout = 10s. Set CSL Period = 500ms.");
@@ -135,11 +135,8 @@
      *   Description: Topology formation: Leader, DUT, SSED_1.
      *   Pass Criteria: N/A.
      */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-
-    router1.AllowList(ssed1);
-    ssed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, ssed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_5_3_8.cpp b/tests/nexus/test_1_2_LP_5_3_8.cpp
index 209b888..6e5c3ac 100644
--- a/tests/nexus/test_1_2_LP_5_3_8.cpp
+++ b/tests/nexus/test_1_2_LP_5_3_8.cpp
@@ -151,7 +151,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: SSED_1");
@@ -174,11 +174,8 @@
      * - Pass Criteria: N/A.
      */
 
-    /** Use AllowList feature to specify links between nodes. */
-    leader.AllowList(router1);
-    router1.AllowList(leader);
-    router1.AllowList(ssed1);
-    ssed1.AllowList(router1);
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, ssed1);
 
     {
         MeshCoP::Dataset::Info datasetInfo;
diff --git a/tests/nexus/test_1_2_LP_7_1_1.cpp b/tests/nexus/test_1_2_LP_7_1_1.cpp
index e38c8d1..41a5765 100644
--- a/tests/nexus/test_1_2_LP_7_1_1.cpp
+++ b/tests/nexus/test_1_2_LP_7_1_1.cpp
@@ -143,7 +143,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 1: Topology formation: DUT, SED_1, SSED_1.");
 
@@ -153,10 +153,8 @@
      * - Pass Criteria: N/A.
      */
 
-    leader.AllowList(sed1);
-    leader.AllowList(ssed1);
-    sed1.AllowList(leader);
-    ssed1.AllowList(leader);
+    AllowLinkBetween(leader, sed1);
+    AllowLinkBetween(leader, ssed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_7_1_2.cpp b/tests/nexus/test_1_2_LP_7_1_2.cpp
index bcd35db..9f9fd09 100644
--- a/tests/nexus/test_1_2_LP_7_1_2.cpp
+++ b/tests/nexus/test_1_2_LP_7_1_2.cpp
@@ -134,7 +134,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Topology formation: DUT, SED_1, SSED_1.");
@@ -145,10 +145,8 @@
      * - Pass Criteria: N/A
      */
 
-    leader.AllowList(sed1);
-    leader.AllowList(ssed1);
-    sed1.AllowList(leader);
-    ssed1.AllowList(leader);
+    AllowLinkBetween(leader, sed1);
+    AllowLinkBetween(leader, ssed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_7_2_1.cpp b/tests/nexus/test_1_2_LP_7_2_1.cpp
index 0bbe348..af11b7c 100644
--- a/tests/nexus/test_1_2_LP_7_2_1.cpp
+++ b/tests/nexus/test_1_2_LP_7_2_1.cpp
@@ -172,7 +172,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0: SED_1
@@ -189,10 +189,8 @@
      * - Pass Criteria: N/A
      */
     Log("Step 1: Topology formation: DUT, SED_1, SSED_1.");
-    leader.AllowList(sed1);
-    leader.AllowList(ssed1);
-    sed1.AllowList(leader);
-    ssed1.AllowList(leader);
+    AllowLinkBetween(leader, sed1);
+    AllowLinkBetween(leader, ssed1);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_LP_7_2_2.cpp b/tests/nexus/test_1_2_LP_7_2_2.cpp
index a0f43f7..d0bfeae 100644
--- a/tests/nexus/test_1_2_LP_7_2_2.cpp
+++ b/tests/nexus/test_1_2_LP_7_2_2.cpp
@@ -173,7 +173,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     CallbackContext context;
 
@@ -201,18 +201,12 @@
      * - Pass Criteria: N/A.
      */
     Log("Step 1: Topology formation: DUT, SED_1, SED_2, SED_3, SSED_1, SSED_2, SSED_3.");
-    leader.AllowList(sed1);
-    leader.AllowList(sed2);
-    leader.AllowList(sed3);
-    leader.AllowList(ssed1);
-    leader.AllowList(ssed2);
-    leader.AllowList(ssed3);
-    sed1.AllowList(leader);
-    sed2.AllowList(leader);
-    sed3.AllowList(leader);
-    ssed1.AllowList(leader);
-    ssed2.AllowList(leader);
-    ssed3.AllowList(leader);
+    AllowLinkBetween(leader, sed1);
+    AllowLinkBetween(leader, sed2);
+    AllowLinkBetween(leader, sed3);
+    AllowLinkBetween(leader, ssed1);
+    AllowLinkBetween(leader, ssed2);
+    AllowLinkBetween(leader, ssed3);
 
     leader.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_1.cpp b/tests/nexus/test_1_2_MATN_TC_1.cpp
index 604c84e..6ec7103 100644
--- a/tests/nexus/test_1_2_MATN_TC_1.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_1.cpp
@@ -124,7 +124,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 0: Topology - BR_1 (DUT), Router");
 
@@ -135,8 +135,7 @@
      * - Pass Criteria:
      *   - N/A
      */
-    br1.AllowList(router);
-    router.AllowList(br1);
+    AllowLinkBetween(br1, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -152,9 +151,6 @@
 
     VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
 
-    host.mInfraIf.Init(host);
-    host.mInfraIf.AddAddress(host.mInfraIf.GetLinkLocalAddress());
-
     nexus.AdvanceTime(kStabilizationTime);
 
     // Add multicast variables to test info manually to ensure verify script sees them.
diff --git a/tests/nexus/test_1_2_MATN_TC_10.cpp b/tests/nexus/test_1_2_MATN_TC_10.cpp
index f387676..ac34ac9 100644
--- a/tests/nexus/test_1_2_MATN_TC_10.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_10.cpp
@@ -50,6 +50,11 @@
 static constexpr uint32_t kStabilizationTime = 10 * 1000;
 
 /**
+ * Time to advance for address resolution and ping reply, in milliseconds.
+ */
+static constexpr uint32_t kAddressResolutionTime = 20 * 1000;
+
+/**
  * Time to advance for the BBR selection to complete, in milliseconds.
  */
 static constexpr uint32_t kBbrSelectionTime = 10 * 1000;
@@ -121,7 +126,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Topology formation – BR_1, ROUTER, BR_2 (DUT)");
@@ -133,12 +138,9 @@
      * - Pass Criteria:
      *   - N/A
      */
-    br1.AllowList(router);
-    br1.AllowList(br2);
-    router.AllowList(br1);
-    router.AllowList(br2);
-    br2.AllowList(br1);
-    br2.AllowList(router);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(router, br2);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -181,9 +183,6 @@
         SuccessOrQuit(br2.Get<BackboneRouter::Local>().SetConfig(config));
     }
 
-    host.mInfraIf.Init(host);
-    host.mInfraIf.AddAddress(host.mInfraIf.GetLinkLocalAddress());
-
     {
         Ip6::Address hostGua;
         SuccessOrQuit(hostGua.FromString(kHostGua));
@@ -304,7 +303,7 @@
      * - Pass Criteria:
      *   - N/A
      */
-    nexus.AdvanceTime(kStabilizationTime);
+    nexus.AdvanceTime(kAddressResolutionTime);
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 8a: BR_1 powers down");
diff --git a/tests/nexus/test_1_2_MATN_TC_12.cpp b/tests/nexus/test_1_2_MATN_TC_12.cpp
index 1fe0697..675d29a 100644
--- a/tests/nexus/test_1_2_MATN_TC_12.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_12.cpp
@@ -121,7 +121,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 0: Topology formation – BR_1 (DUT), Router");
 
@@ -132,8 +132,7 @@
      * - Pass Criteria:
      *   - N/A
      */
-    br1.AllowList(router);
-    router.AllowList(br1);
+    AllowLinkBetween(br1, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_15.cpp b/tests/nexus/test_1_2_MATN_TC_15.cpp
index dfac021..977d289 100644
--- a/tests/nexus/test_1_2_MATN_TC_15.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_15.cpp
@@ -97,7 +97,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Topology formation – BR_1, BR_2, Router. Topology formation - TD (DUT). Boot the DUT. Configure the "
@@ -112,17 +112,10 @@
      *   - N/A
      */
 
-    br1.AllowList(br2);
-    br1.AllowList(router);
-
-    br2.AllowList(br1);
-    br2.AllowList(router);
-
-    router.AllowList(br1);
-    router.AllowList(br2);
-    router.AllowList(dut);
-
-    dut.AllowList(router);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
+    AllowLinkBetween(router, dut);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_16.cpp b/tests/nexus/test_1_2_MATN_TC_16.cpp
index 930d357..da61690 100644
--- a/tests/nexus/test_1_2_MATN_TC_16.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_16.cpp
@@ -132,7 +132,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0
@@ -143,8 +143,7 @@
      */
     Log("Step 0: Topology formation – BR_1 (DUT), Router");
 
-    br1.AllowList(router);
-    router.AllowList(br1);
+    AllowLinkBetween(br1, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_19.cpp b/tests/nexus/test_1_2_MATN_TC_19.cpp
index 675f4fe..6d7f2f5 100644
--- a/tests/nexus/test_1_2_MATN_TC_19.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_19.cpp
@@ -136,7 +136,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 0: Topology formation – BR_1, BR_2, Router, MTD");
 
@@ -148,14 +148,10 @@
      *   - N/A
      */
 
-    br1.AllowList(br2);
-    br1.AllowList(router);
-    br2.AllowList(br1);
-    br2.AllowList(router);
-    router.AllowList(br1);
-    router.AllowList(br2);
-    router.AllowList(mtd);
-    mtd.AllowList(router);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
+    AllowLinkBetween(router, mtd);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_2.cpp b/tests/nexus/test_1_2_MATN_TC_2.cpp
index dd6a4e9..47dfb8a 100644
--- a/tests/nexus/test_1_2_MATN_TC_2.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_2.cpp
@@ -115,7 +115,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0
@@ -124,12 +124,9 @@
      * - Pass Criteria:
      *   - N/A
      */
-    br1.AllowList(br2);
-    br1.AllowList(td);
-    br2.AllowList(br1);
-    br2.AllowList(td);
-    td.AllowList(br1);
-    td.AllowList(br2);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, td);
+    AllowLinkBetween(br2, td);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_20.cpp b/tests/nexus/test_1_2_MATN_TC_20.cpp
index 10927b8..d0a7deb 100644
--- a/tests/nexus/test_1_2_MATN_TC_20.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_20.cpp
@@ -101,7 +101,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 0: Topology formation - BR_1, BR_2. Topology formation - Router (DUT). The DUT is booted and added to "
         "the network. Topology formation - MED.");
@@ -115,18 +115,10 @@
      *   - N/A
      */
 
-    /** Use AllowList to specify links between nodes. */
-    br1.AllowList(br2);
-    br1.AllowList(router);
-
-    br2.AllowList(br1);
-    br2.AllowList(router);
-
-    router.AllowList(br1);
-    router.AllowList(br2);
-    router.AllowList(med);
-
-    med.AllowList(router);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
+    AllowLinkBetween(router, med);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_21.cpp b/tests/nexus/test_1_2_MATN_TC_21.cpp
index 2297cc9..faae499 100644
--- a/tests/nexus/test_1_2_MATN_TC_21.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_21.cpp
@@ -111,7 +111,7 @@
     Coap::Message *message = aSource.Get<Tmf::Agent>().AllocateAndInitPriorityConfirmablePostMessage(kUriMlr);
     VerifyOrQuit(message != nullptr);
 
-    SuccessOrQuit(Tlv::Append<Ip6AddressesTlv>(*message, static_cast<const uint8_t *>(aAddressesData), aDataLength));
+    SuccessOrQuit(Tlv::Append<Ip6AddressesTlv>(*message, aAddressesData, aDataLength));
 
     SuccessOrQuit(aSource.Get<Tmf::Agent>().SendMessageTo(*message, aDestination));
 }
@@ -165,7 +165,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0
@@ -176,12 +176,9 @@
      */
     Log("Step 0: Topology formation – BR_1, BR_2, Router");
 
-    br1.AllowList(br2);
-    br1.AllowList(router);
-    br2.AllowList(br1);
-    br2.AllowList(router);
-    router.AllowList(br1);
-    router.AllowList(br2);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -204,9 +201,6 @@
     SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().SetEnabled(true));
     br2.Get<BackboneRouter::Local>().SetEnabled(true);
 
-    host.mInfraIf.Init(host);
-    host.mInfraIf.AddAddress(host.mInfraIf.GetLinkLocalAddress());
-
     nexus.AdvanceTime(kStabilizationTime * 2);
 
     VerifyOrQuit(br1.Get<BackboneRouter::Local>().IsPrimary());
diff --git a/tests/nexus/test_1_2_MATN_TC_22.cpp b/tests/nexus/test_1_2_MATN_TC_22.cpp
index eb3d3e6..34c6cce 100644
--- a/tests/nexus/test_1_2_MATN_TC_22.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_22.cpp
@@ -111,7 +111,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0
@@ -123,10 +123,8 @@
      */
     Log("Step 0: Topology formation – BR_1. Topology addition – TD (DUT_Router and DUT_FED). DUTs register MA1.");
 
-    br1.AllowList(dut1);
-    dut1.AllowList(br1);
-    br1.AllowList(dut2);
-    dut2.AllowList(br1);
+    AllowLinkBetween(br1, dut1);
+    AllowLinkBetween(br1, dut2);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -157,11 +155,7 @@
     VerifyOrQuit(br1.Get<BackboneRouter::Local>().IsPrimary());
 
     nexus.AddTestVar("MA1", kMA1);
-    {
-        String<16> timeoutString;
-        timeoutString.Append("%lu", ToUlong(kMlrTimeoutMin));
-        nexus.AddTestVar("MLR_TIMEOUT_MIN", timeoutString.AsCString());
-    }
+    nexus.AddTestVar("MLR_TIMEOUT_MIN", kMlrTimeoutMin);
 
     SuccessOrQuit(dut1.Get<Ip6::Netif>().SubscribeExternalMulticast(ma1));
     SuccessOrQuit(dut2.Get<Ip6::Netif>().SubscribeExternalMulticast(ma1));
diff --git a/tests/nexus/test_1_2_MATN_TC_23.cpp b/tests/nexus/test_1_2_MATN_TC_23.cpp
index c9919e7..afced44 100644
--- a/tests/nexus/test_1_2_MATN_TC_23.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_23.cpp
@@ -97,7 +97,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0
@@ -108,12 +108,9 @@
      */
     Log("Step 0: Topology formation - BR_1, BR_2, TD (DUT)");
 
-    br1.AllowList(br2);
-    br1.AllowList(td);
-    br2.AllowList(br1);
-    br2.AllowList(td);
-    td.AllowList(br1);
-    td.AllowList(br2);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, td);
+    AllowLinkBetween(br2, td);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -143,12 +140,7 @@
     VerifyOrQuit(br2.Get<BackboneRouter::Local>().GetState() == BackboneRouter::Local::kStateSecondary);
 
     nexus.AddTestVar("MA1", kMulticastAddr1);
-    {
-        String<16> timeoutStr;
-
-        timeoutStr.Append("%u", kMlrTimeout);
-        nexus.AddTestVar("MLR_TIMEOUT", timeoutStr.AsCString());
-    }
+    nexus.AddTestVar("MLR_TIMEOUT", kMlrTimeout);
 
     /**
      * Step 1
diff --git a/tests/nexus/test_1_2_MATN_TC_26.cpp b/tests/nexus/test_1_2_MATN_TC_26.cpp
index c0d22df..0a1dff0 100644
--- a/tests/nexus/test_1_2_MATN_TC_26.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_26.cpp
@@ -101,7 +101,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Topology formation – BR_1. Topology formation – TD (DUT).");
@@ -114,8 +114,7 @@
      * - Pass Criteria:
      * - N/A
      */
-    br1.AllowList(td);
-    td.AllowList(br1);
+    AllowLinkBetween(br1, td);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -151,7 +150,7 @@
      * - Pass Criteria:
      * - N/A
      */
-    br1.Get<BackboneRouter::Manager>().ConfigNextMulticastListenerRegistrationResponse(kMlrNoResources);
+    br1.Get<BackboneRouter::Manager>().ConfigNextMulticastListenerRegistrationResponse(Mlr::kStatusNoResources);
 
     Log("Step 2: The DUT must be configured to request registration of Multicast address MA1.");
 
@@ -215,7 +214,7 @@
      * - Pass Criteria:
      * - N/A
      */
-    br1.Get<BackboneRouter::Manager>().ConfigNextMulticastListenerRegistrationResponse(kMlrGeneralFailure);
+    br1.Get<BackboneRouter::Manager>().ConfigNextMulticastListenerRegistrationResponse(Mlr::kStatusGeneralFailure);
 
     Log("Step 4 (cont.): Harness instructs the device to updates the network data (BBR Dataset).");
 
diff --git a/tests/nexus/test_1_2_MATN_TC_3.cpp b/tests/nexus/test_1_2_MATN_TC_3.cpp
index 606e6b3..dda430d 100644
--- a/tests/nexus/test_1_2_MATN_TC_3.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_3.cpp
@@ -121,7 +121,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Topology formation - DUT, BR_2, Router");
@@ -133,12 +133,9 @@
      * - Pass Criteria:
      *   - N/A
      */
-    br1.AllowList(br2);
-    br1.AllowList(router);
-    br2.AllowList(br1);
-    br2.AllowList(router);
-    router.AllowList(br1);
-    router.AllowList(br2);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -197,7 +194,7 @@
      * - Pass Criteria:
      *   - N/A
      */
-    SuccessOrQuit(router.Get<MlrManager>().RegisterMulticastListeners(&ma1, 1, &kTimeoutZero, nullptr, nullptr));
+    SuccessOrQuit(router.Get<Mlr::Manager>().RegisterMulticastListeners(&ma1, 1, &kTimeoutZero, nullptr, nullptr));
     nexus.AdvanceTime(kMlrRegistrationTime);
 
     Log("---------------------------------------------------------------------------------------");
diff --git a/tests/nexus/test_1_2_MATN_TC_4.cpp b/tests/nexus/test_1_2_MATN_TC_4.cpp
index 57fd182..d4f4647 100644
--- a/tests/nexus/test_1_2_MATN_TC_4.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_4.cpp
@@ -126,7 +126,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("Step 0: Topology formation - BR_1 (DUT). Boot DUT. Configure the MLR timeout value of the DUT to be "
         "MLR_TIMEOUT_MIN seconds.");
@@ -139,12 +139,10 @@
      * - Pass Criteria:
      *   - N/A
      */
-    br1.AllowList(br2);
-    br1.AllowList(router);
-    br2.AllowList(br1);
-    br2.AllowList(router);
-    router.AllowList(br1);
-    router.AllowList(br2);
+
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_5.cpp b/tests/nexus/test_1_2_MATN_TC_5.cpp
index 0029472..6a90fd7 100644
--- a/tests/nexus/test_1_2_MATN_TC_5.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_5.cpp
@@ -127,7 +127,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 0
@@ -139,12 +139,9 @@
      */
     Log("Step 0: Topology formation – BR_1 (DUT)");
 
-    br1.AllowList(br2);
-    br1.AllowList(router);
-    br2.AllowList(br1);
-    br2.AllowList(router);
-    router.AllowList(br1);
-    router.AllowList(br2);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -183,9 +180,6 @@
     SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().SetEnabled(true));
     br2.Get<BackboneRouter::Local>().SetEnabled(true);
 
-    host.mInfraIf.Init(host);
-    host.mInfraIf.AddAddress(host.mInfraIf.GetLinkLocalAddress());
-
     nexus.AdvanceTime(kStabilizationTime * 2);
 
     VerifyOrQuit(br1.Get<BackboneRouter::Local>().IsPrimary());
@@ -195,11 +189,7 @@
     // Add multicast variables to test info manually to ensure verify script sees them.
     nexus.AddTestVar("MA1", kMA1);
     nexus.AddTestVar("HOST_ULA", hostUla->ToString().AsCString());
-    {
-        String<16> timeoutString;
-        timeoutString.Append("%lu", ToUlong(kMlrTimeoutMin));
-        nexus.AddTestVar("MLR_TIMEOUT_MIN", timeoutString.AsCString());
-    }
+    nexus.AddTestVar("MLR_TIMEOUT_MIN", kMlrTimeoutMin);
 
     /**
      * Step 1
@@ -269,7 +259,13 @@
      *   - N/A
      */
     Log("Step 5: Host sends a UDP packet to the multicast address, MA1, port 5683.");
-    host.mInfraIf.SendUdp(*hostUla, ma1, kCoapPort, kCoapPort, kUdpPayloadSize);
+    {
+        Message *message = host.Get<Ip6::Ip6>().NewMessage();
+
+        VerifyOrQuit(message != nullptr);
+        SuccessOrQuit(message->SetLength(kUdpPayloadSize));
+        host.mInfraIf.SendUdp(*hostUla, ma1, kCoapPort, kCoapPort, *message);
+    }
     nexus.AdvanceTime(0);
 
     /**
diff --git a/tests/nexus/test_1_2_MATN_TC_7.cpp b/tests/nexus/test_1_2_MATN_TC_7.cpp
index 9c22b0d..52e61da 100644
--- a/tests/nexus/test_1_2_MATN_TC_7.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_7.cpp
@@ -132,7 +132,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Topology formation - BR_1, BR_2, ROUTER");
@@ -145,12 +145,9 @@
      *   - N/A
      */
 
-    br1.AllowList(br2);
-    br1.AllowList(router);
-    br2.AllowList(br1);
-    br2.AllowList(router);
-    router.AllowList(br1);
-    router.AllowList(br2);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_2_MATN_TC_9.cpp b/tests/nexus/test_1_2_MATN_TC_9.cpp
index 4416503..d830aa0 100644
--- a/tests/nexus/test_1_2_MATN_TC_9.cpp
+++ b/tests/nexus/test_1_2_MATN_TC_9.cpp
@@ -103,7 +103,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 0: Topology formation – BR_1, Router, BR_2 (DUT)");
@@ -115,12 +115,10 @@
      * - Pass Criteria:
      *   - N/A
      */
-    br1.AllowList(router);
-    br1.AllowList(br2);
-    router.AllowList(br1);
-    router.AllowList(br2);
-    br2.AllowList(br1);
-    br2.AllowList(router);
+
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router);
+    AllowLinkBetween(br2, router);
 
     br1.Form();
     nexus.AdvanceTime(kFormNetworkTime);
diff --git a/tests/nexus/test_1_3_DBR_TC_1.cpp b/tests/nexus/test_1_3_DBR_TC_1.cpp
index 48c4cee..b89b55a 100644
--- a/tests/nexus/test_1_3_DBR_TC_1.cpp
+++ b/tests/nexus/test_1_3_DBR_TC_1.cpp
@@ -104,7 +104,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Device: Eth 1, ED 1 Description (DBR-1.1): Enable.");
@@ -116,8 +116,6 @@
      * - Pass Criteria: N/A
      */
 
-    eth1.mInfraIf.Init(eth1);
-
     Log("---------------------------------------------------------------------------------------");
     Log("Step 2: Device: BR 1 (DUT) Description (DBR-1.1): Enable.");
 
diff --git a/tests/nexus/test_1_3_DBR_TC_10.cpp b/tests/nexus/test_1_3_DBR_TC_10.cpp
new file mode 100644
index 0000000..781940e
--- /dev/null
+++ b/tests/nexus/test_1_3_DBR_TC_10.cpp
@@ -0,0 +1,433 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 30 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 60 * 1000;
+
+/**
+ * Time to advance for the ping response, in milliseconds.
+ */
+static constexpr uint32_t kPingResponseTime = 5 * 1000;
+
+/**
+ * Time to advance for the Eth_1 configuration, in milliseconds.
+ */
+static constexpr uint32_t kEth1ConfigTime = 10 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Echo Request identifier.
+ */
+static constexpr uint16_t kEchoIdentifier = 0x1234;
+
+/**
+ * Echo Request payload size.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+/**
+ * Default Hop Limit for Echo Request.
+ */
+static constexpr uint8_t kDefaultHopLimit = 64;
+
+/**
+ * IPv6 GUA address for Eth_1.
+ */
+static const char kEth1Gua[] = "2001:db8:1::1";
+
+/**
+ * IPv6 GUA prefix GUA_1.
+ */
+static const char kGua1Prefix[] = "2001:db8:1::/64";
+
+static void AddOnMeshPrefix(Node &aNode, const Ip6::Prefix &aPrefix, bool aDefaultRoute)
+{
+    NetworkData::OnMeshPrefixConfig config;
+
+    config.Clear();
+    config.mPrefix       = aPrefix;
+    config.mStable       = true;
+    config.mSlaac        = true;
+    config.mPreferred    = true;
+    config.mOnMesh       = true;
+    config.mDefaultRoute = aDefaultRoute;
+    config.mPreference   = NetworkData::kRoutePreferenceMedium;
+    SuccessOrQuit(aNode.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+}
+
+void Test_1_3_DBR_TC_10(const char *aJsonFileName)
+{
+    /**
+     * 1.10. [1.3] [CERT] Reachability - Single BR - Configure OMR address - OMR Routing - Default routes
+     *
+     * 1.10.1. Purpose
+     *   To test the following:
+     *   - Thread End Device DUT accepts OMR prefix and configures OMR address
+     *   - Thread Router DUT accepts route to off-mesh prefix and routes packets to it
+     *   - Reachability of Thread Device DUT from infrastructure devices
+     *   - Thread Router DUT can process default route published by a BR and route packets based on it.
+     *   - Note: current test is not so elaborate yet for route determination/default-route. TBD: In the future,
+     *     another BR (BR_2) may be added and it can be checked the DUT Router picks the appropriate/best route to
+     *     either BR_1 or BR_2. For this, a new topology with two different AILs for BR_1 and BR_2 would be most useful.
+     *
+     * 1.10.2. Topology
+     *   - BR_1-Test bed border router device operating as a Thread Border Router and the Leader
+     *   - ED_1 - Device operating as a Thread End Device DUT
+     *   - Eth_1-Test bed border router device on an Adjacent Infrastructure Link
+     *   - Router 1-Device operating as a Thread Router DUT
+     *
+     * Spec Reference   | V1.3.0 Section
+     * -----------------|---------------
+     * Reachability     | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1     = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &ed1     = nexus.CreateNode();
+    Node &eth1    = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    router1.SetName("Router_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    /**
+     * Step 0
+     * - Device: Eth 1
+     * - Description (DBR-1.10): Harness enables device and configures Ethernet link with an on-link IPv6 GUA prefix
+     *   GUA_1. Eth_1 is configured to multicast ND RA. The ND RA contains a PIO option as follows: Prefix: GUA 1,
+     *   L=1 (on-link prefix), A=1 (SLAAC is enabled). Device has a global address "Eth_1 GUA" automatically
+     *   configured from prefix GUA_1.
+     * - Pass Criteria: N/A
+     */
+    Log("Step 0: Eth_1 configures Ethernet link with GUA_1 prefix and multicasts ND RA.");
+
+    {
+        Ip6::Address eth1Gua;
+        SuccessOrQuit(eth1Gua.FromString(kEth1Gua));
+        eth1.mInfraIf.AddAddress(eth1Gua);
+    }
+
+    {
+        Ip6::Prefix gua1;
+        SuccessOrQuit(gua1.FromString(kGua1Prefix));
+        eth1.mInfraIf.StartRouterAdvertisement(gua1);
+    }
+
+    nexus.AdvanceTime(kEth1ConfigTime);
+
+    /**
+     * Step 1
+     * - Device: BR_1
+     * - Description (DBR-1.10): Enable. Automatically, it configures an OMR prefix for the Thread Network and also
+     *   automatically advertises the external (default) route to GUA 1 using the ::/0 prefix in the Thread Network
+     *   and using a Has Route TLV.
+     * - Pass Criteria: N/A
+     */
+    Log("Step 1: BR_1 enables and configures OMR prefix and external route to GUA_1.");
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Ip6::Prefix omr1;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omr1));
+
+    /**
+     * Step 2
+     * - Device: Router 1, ED 1 (DUT)
+     * - Description (DBR-1.10): Enable. Automatically, topology is formed.
+     * - Pass Criteria: N/A
+     */
+    Log("Step 2: Router_1 and ED_1 enable and form topology.");
+
+    AllowLinkBetween(br1, router1);
+    AllowLinkBetween(router1, ed1);
+
+    router1.Join(br1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    ed1.Join(router1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 3
+     * - Device: ED 1 (DUT)
+     * - Description (DBR-1.10): Automatically configures an OMR address based on OMR prefix. Note: the verification
+     *   that the new address works is done in step 4.
+     * - Pass Criteria: N/A
+     */
+    Log("Step 3: ED_1 automatically configures OMR address.");
+
+    const Ip6::Address &ed1Omr = ed1.FindMatchingAddress(omr1.ToString().AsCString());
+    VerifyOrQuit(!ed1Omr.IsUnspecified());
+
+    /**
+     * Step 4
+     * - Device: Eth 1
+     * - Description (DBR-1.10): Harness instructs device to send ICMPv6 Echo Request to ED 1. IPv6 Source: Eth 1
+     *   GUA, IPv6 Destination: ED 1 OMR
+     * - Pass Criteria:
+     *   - Eth 1 receives an ICMPv6 Echo Reply from ED_1.
+     *   - IPv6 Source: ED_1 OMR
+     *   - IPv6 Destination: Eth_1 GUA
+     */
+    Log("Step 4: Eth_1 sends ICMPv6 Echo Request to ED_1 OMR.");
+
+    Ip6::Address eth1Gua;
+    SuccessOrQuit(eth1Gua.FromString(kEth1Gua));
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    /**
+     * Step 5
+     * - Device: Router 1 (DUT)
+     * - Description (DBR-1.10): Automatically routes the packets of step 4.
+     * - Pass Criteria: N/A (Already verified in step 4.)
+     */
+    Log("Step 5: Router_1 automatically routes the packets of step 4.");
+
+    /**
+     * Step 6
+     * - Device: ED_1
+     * - Description (DBR-1.10): Only in the topology where Router_1 is DUT: Harness instructs device to send ICMPv6
+     *   Echo Request to Eth_1. IPv6 Source: ED 1 OMR, IPv6 Destination: Eth 1 GUA
+     * - Pass Criteria:
+     *   - Only in the topology where Router_1 is DUT: ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+     *   - IPv6 Source: Eth 1 GUA
+     *   - IPv6 Destination: ED_1 OMR
+     */
+    Log("Step 6: ED_1 sends ICMPv6 Echo Request to Eth_1 GUA.");
+
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kDefaultHopLimit, &ed1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    /**
+     * Step 7
+     * - Device: Router_1 (DUT)
+     * - Description (DBR-1.10): Automatically routes the packets of step 6.
+     * - Pass Criteria: N/A (Already verified in step 6.)
+     */
+    Log("Step 7: Router_1 automatically routes the packets of step 6.");
+
+    /**
+     * Step 8
+     * - Device: Eth 1
+     * - Description (DBR-1.10): Only in the topology where Router _1 is DUT: Harness instructs device to send ICMPv6
+     *   Echo Request to Router 1. IPv6 Source: Eth_1 GUA, IPv6 Destination: Router_1 OMR
+     * - Pass Criteria:
+     *   - Only in the topology where Router_1 is DUT: Eth_1 receives an ICMPv6 Echo Reply from Router_1.
+     *   - IPv6 Source: Router 1 OMR
+     *   - IPv6 Destination: Eth 1 GUA
+     */
+    Log("Step 8: Eth_1 sends ICMPv6 Echo Request to Router_1 OMR.");
+
+    const Ip6::Address &router1Omr = router1.FindMatchingAddress(omr1.ToString().AsCString());
+    VerifyOrQuit(!router1Omr.IsUnspecified());
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, router1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    /**
+     * Step 9
+     * - Device: BR_1
+     * - Description (DBR-1.10): Harness configures device to: Stop advertising the external route to prefix GUA_1
+     *   by removing the Prefix TLV with ::/0 completely. Keep publishing the flag P_default = true in the Border
+     *   Router sub-TLV. This should normally happen automatically if the device is 1.4-compliant. Wait some time for
+     *   the Network Data change to propagate throughout the network. Note: this emulates a legacy BR that doesn't
+     *   advertise the default route using an external ::/0 route, but only uses the P_default flag (R flag) in the
+     *   Border Router TLV for this.
+     * - Pass Criteria:
+     *   - BR 1 reference device MUST publish its OMR prefix with Border Router TLV with P_default = true (R flag =
+     *     true).
+     *   - Note: although this is not a validation on the DUT, it could be useful as a sanity check. Pre-1.4 BRs may
+     *     behave differently; and it's important to use the correct value here.
+     */
+    Log("Step 9: BR_1 stops advertising ::/0 external route but keeps P_default=true in OMR prefix.");
+
+    // To achieve this, we disable RoutingManager and manually manage Network Data.
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(false));
+
+    // Wait for BR_1 to unpublish OMR prefix and ::/0.
+    nexus.AdvanceTime(kBrActionTime);
+
+    AddOnMeshPrefix(br1, omr1, true);
+    br1.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 10
+     * - Device: Eth 1
+     * - Description (DBR-1.10): Harness instructs device to send ICMPv6 Echo Request to ED_1. IPv6 Source: Eth 1
+     *   GUA, IPv6 Destination: ED 1 OMR
+     * - Pass Criteria:
+     *   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+     *   - IPv6 Source: ED_1 OMR
+     *   - IPv6 Destination: Eth 1 GUA
+     */
+    Log("Step 10: Eth_1 sends ICMPv6 Echo Request to ED_1 OMR.");
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    /**
+     * Step 11
+     * - Device: ED_1
+     * - Description (DBR-1.10): Only in the topology where Router_1 is DUT: Harness instructs device to send ICMPv6
+     *   Echo Request to Eth 1. IPv6 Source: ED_1 OMR, IPv6 Destination: Eth 1 GUA
+     * - Pass Criteria:
+     *   - Only in the topology where Router_1 is DUT: ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+     *   - IPv6 Source: Eth_1 GUA
+     *   - IPv6 Destination: ED 1 OMR
+     */
+    Log("Step 11: ED_1 sends ICMPv6 Echo Request to Eth_1 GUA.");
+
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kDefaultHopLimit, &ed1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    /**
+     * Step 12
+     * - Device: BR 1
+     * - Description (DBR-1.10): Harness configures device to publish for its OMR prefix: flag P_default = false in
+     *   the Border Router sub-TLV; and By setting a Prefix TLV with the zero-length prefix ::/0, which includes Has
+     *   Route TLV preference value (Prf) 11 (low). Wait some time for the Network Data change to propagate
+     *   throughout the network.
+     * - Pass Criteria:
+     *   - BR_1 reference device MUST publish its OMR prefix with Border Router TLV with P_default = false (R flag =
+     *     false).
+     *   - Note: although this is not a validation on the DUT, it could be useful as a sanity check. Pre-1.4 BRs may
+     *     behave differently; and it's important to use the correct value here.
+     */
+    Log("Step 12: BR_1 publishes OMR prefix with P_default=false and ::/0 external route with low preference.");
+
+    {
+        AddOnMeshPrefix(br1, omr1, false);
+
+        NetworkData::ExternalRouteConfig routeConfig;
+        routeConfig.Clear();
+        routeConfig.mPrefix     = Ip6::Prefix(); // ::/0
+        routeConfig.mStable     = true;
+        routeConfig.mPreference = NetworkData::kRoutePreferenceLow; // Prf = 11
+        SuccessOrQuit(br1.Get<NetworkData::Local>().AddHasRoutePrefix(routeConfig));
+
+        br1.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 13
+     * - Device: Eth 1
+     * - Description (DBR-1.10): Repeat Step 10.
+     * - Pass Criteria:
+     *   - Repeat Step 10.
+     */
+    Log("Step 13: Eth_1 sends ICMPv6 Echo Request to ED_1 OMR.");
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    /**
+     * Step 14
+     * - Device: ED 1
+     * - Description (DBR-1.10): Repeat Step 11.
+     * - Pass Criteria:
+     *   - Repeat Step 11.
+     */
+    Log("Step 14: ED_1 sends ICMPv6 Echo Request to Eth_1 GUA.");
+
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kDefaultHopLimit, &ed1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    {
+        nexus.AddTestVar("BR1", br1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+        nexus.AddTestVar("ROUTER1", router1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+        nexus.AddTestVar("ED1", ed1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+        nexus.AddTestVar("ETH1_GUA", kEth1Gua);
+        nexus.AddTestVar("ED1_OMR", ed1Omr.ToString().AsCString());
+        nexus.AddTestVar("ROUTER1_OMR", router1Omr.ToString().AsCString());
+        nexus.AddTestVar("OMR_PREFIX", omr1.ToString().AsCString());
+    }
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_DBR_TC_10((argc > 2) ? argv[2] : "test_1_3_DBR_TC_10.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DBR_TC_2.cpp b/tests/nexus/test_1_3_DBR_TC_2.cpp
index b911e4d..b402fac 100644
--- a/tests/nexus/test_1_3_DBR_TC_2.cpp
+++ b/tests/nexus/test_1_3_DBR_TC_2.cpp
@@ -111,7 +111,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Step 1: Device: Eth 1, BR 2 Description (DBR-1.2): Form topology. Wait for BR_2 to...");
@@ -124,9 +124,8 @@
      * - Pass Criteria: N/A
      */
 
-    eth1.mInfraIf.Init(eth1);
-
-    br2.AllowList(br1);
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(ed1, br1);
 
     br2.Form();
     nexus.AdvanceTime(kFormNetworkTime);
@@ -162,9 +161,6 @@
      * - Pass Criteria: N/A
      */
 
-    br1.AllowList(br2);
-    br1.AllowList(ed1);
-
     br1.Join(br2);
     nexus.AdvanceTime(kJoinNetworkTime);
 
@@ -216,7 +212,6 @@
      *     Network Data that is sent to the Child ED 1.
      */
 
-    ed1.AllowList(br1);
     ed1.Join(br1, Node::kAsFed);
     nexus.AdvanceTime(kJoinNetworkTime);
 
diff --git a/tests/nexus/test_1_3_DBR_TC_3.cpp b/tests/nexus/test_1_3_DBR_TC_3.cpp
index 001802e..49242f4 100644
--- a/tests/nexus/test_1_3_DBR_TC_3.cpp
+++ b/tests/nexus/test_1_3_DBR_TC_3.cpp
@@ -105,7 +105,7 @@
 
     nexus.AdvanceTime(0);
 
-    Instance::SetLogLevel(kLogLevelNote);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
 
     /**
      * Step 1
@@ -201,12 +201,10 @@
     SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix2));
     nexus.AddTestVar("OMR_PREFIX_2", omrPrefix2.ToString().AsCString());
 
-    br1.AllowList(ed1);
-    ed1.AllowList(br1);
+    AllowLinkBetween(br1, ed1);
     ed1.Join(br1, Node::kAsFed);
 
-    br2.AllowList(ed2);
-    ed2.AllowList(br2);
+    AllowLinkBetween(br2, ed2);
     ed2.Join(br2, Node::kAsFed);
 
     nexus.AdvanceTime(kJoinNetworkTime);
diff --git a/tests/nexus/test_1_3_DBR_TC_6.cpp b/tests/nexus/test_1_3_DBR_TC_6.cpp
new file mode 100644
index 0000000..1f295e1
--- /dev/null
+++ b/tests/nexus/test_1_3_DBR_TC_6.cpp
@@ -0,0 +1,276 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 30 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 60 * 1000;
+
+/**
+ * Time to advance for the ping response, in milliseconds.
+ */
+static constexpr uint32_t kPingResponseTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Echo Request identifier.
+ */
+static constexpr uint16_t kEchoIdentifier = 0x1234;
+
+/**
+ * Echo Request payload size.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+/**
+ * IPv6 GUA address for Eth_1.
+ */
+static const char kEth1Gua[] = "2001:db8:1::1";
+
+/**
+ * IPv6 GUA prefix GUA_1.
+ */
+static const char kGua1Prefix[] = "2001:db8:1::/64";
+
+void Test_1_3_DBR_TC_6(void)
+{
+    /**
+     * 1.6. [1.3] [CERT] Reachability - Multiple BRs - Single Thread - Single IPv6 Infrastructure
+     *
+     * 1.6.1. Purpose
+     *   To test the following:
+     *   - 1. Bi-directional reachability between single Thread Network and infrastructure devices
+     *   - 2. Multiple BRs
+     *   - 3. IPv6 infrastructure is already existing
+     *   - 4. DUT BR adopts existing OMR prefixes and doesn't advertise PIO
+     *
+     * 1.6.2. Topology
+     *   - 1. BR 1 (DUT) - Thread Border Router
+     *   - 2. BR 2-Test Bed border router device operating as a Thread Border Router and the Leader
+     *   - 3. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     *   - 4. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference | V1.3.0 Section
+     * ---------------|---------------
+     * Reachability   | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &br2  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0: Device: Eth 1 Description (DBR-1.6): Harness configures Ethernet link with an on-link IPv6 GUA prefix "
+        "GUA 1. Eth 1 is configured to multicast ND RAS.");
+
+    {
+        Ip6::Address eth1Gua;
+        SuccessOrQuit(eth1Gua.FromString(kEth1Gua));
+        eth1.mInfraIf.AddAddress(eth1Gua);
+    }
+
+    {
+        Ip6::Prefix gua1;
+        SuccessOrQuit(gua1.FromString(kGua1Prefix));
+        eth1.mInfraIf.StartRouterAdvertisement(gua1);
+    }
+
+    nexus.AdvanceTime(10 * 1000);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Device: Eth 1, BR 2 Description (DBR-1.6): Form topology. Wait for BR_2 to: 1. Register as border "
+        "router in Thread Network Data with an OMR prefix OMR_1 2. Send multicast ND RAS");
+
+    br2.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+
+    br2.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    br2.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Ip6::Prefix omr1;
+    SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omr1));
+
+    br2.mInfraIf.SendRouterAdvertisement(Ip6::Address::GetLinkLocalAllNodesMulticast(), nullptr, &omr1);
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Device: BR 1 (DUT) Description (DBR-1.6): Enable: switch on.");
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+
+    AllowLinkBetween(br1, br2);
+
+    br1.Join(br2);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2b: Device: ED 1 Description (DBR-1.6): Harness enables device.");
+
+    AllowLinkBetween(ed1, br1);
+
+    ed1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Device: BR 1 (DUT) Description (DBR-1.6): Automatically registers itself as a border router in the "
+        "Thread Network Data.");
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Device: BR 1 (DUT) Description (DBR-1.6): Automatically multicasts ND RAs on Adjacent Infrastructure "
+        "Link.");
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Device: Eth_1 Description (DBR-1.6): Harness instructs the device to send an ICMPv6 Echo Request to "
+        "ED 1 via BR 1 or BR 2. 1. IPv6 Source: Eth 1 GUA 2. IPv6 Destination: ED_1 OMR");
+
+    const Ip6::Address &ed1Omr = ed1.FindMatchingAddress(omr1.ToString().AsCString());
+    Ip6::Address        eth1Gua;
+
+    SuccessOrQuit(eth1Gua.FromString(kEth1Gua));
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Device: ED_1 Description (DBR-1.6): Harness instructs the device to send an ICMPv6 Echo Request to "
+        "Eth_1. 1. IPv6 Source: ED 1 OMR 2. IPv6 Destination: Eth_1 GUA");
+
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, 64, &ed1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Device: BR 2 Description (DBR-1.6): Harness disables the device.");
+
+    br2.Get<Mle::Mle>().Stop();
+    nexus.AdvanceTime(10 * 1000);
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Device: BR 1 (DUT) Description (DBR-1.6): Repeat Step 4");
+
+    {
+        Ip6::Nd::RouterSolicitHeader rs;
+        Ip6::Nd::Icmp6Packet         packet;
+
+        packet.Init(reinterpret_cast<const uint8_t *>(&rs), sizeof(rs));
+        eth1.mInfraIf.SendIcmp6Nd(Ip6::Address::GetLinkLocalAllRoutersMulticast(), packet.GetBytes(),
+                                  packet.GetLength());
+    }
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Device: Eth 1 Description (DBR-1.6): Repeat Step 5");
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: Device: ED 1 Description (DBR-1.6): Repeat Step 6");
+
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, 64, &ed1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    {
+        InfraIf::LinkLayerAddress addr;
+
+        br1.mInfraIf.GetLinkLayerAddress(addr);
+        nexus.AddTestVar("BR1_ETH", addr.ToString().AsCString());
+
+        eth1.mInfraIf.GetLinkLayerAddress(addr);
+        nexus.AddTestVar("ETH1_ETH", addr.ToString().AsCString());
+    }
+
+    nexus.AddTestVar("BR1", br1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+    nexus.AddTestVar("BR2", br2.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+    nexus.AddTestVar("ED1", ed1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+    nexus.AddTestVar("ETH1_GUA", kEth1Gua);
+    nexus.AddTestVar("ED1_OMR", ed1Omr.ToString().AsCString());
+    nexus.AddTestVar("OMR_PREFIX", omr1.ToString().AsCString());
+
+    nexus.SaveTestInfo("test_1_3_DBR_TC_6.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_3_DBR_TC_6();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DBR_TC_7A.cpp b/tests/nexus/test_1_3_DBR_TC_7A.cpp
new file mode 100644
index 0000000..555a011
--- /dev/null
+++ b/tests/nexus/test_1_3_DBR_TC_7A.cpp
@@ -0,0 +1,412 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for the ping response, in milliseconds.
+ */
+static constexpr uint32_t kPingResponseTime = 5 * 1000;
+
+/**
+ * Time to advance for waiting, in milliseconds.
+ */
+static constexpr uint32_t kWaitTime = 20 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Echo Request identifier.
+ */
+static constexpr uint16_t kEchoIdentifier = 0x1234;
+
+/**
+ * Echo Request payload size.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+/**
+ * IPv6 Hop Limit.
+ */
+static constexpr uint8_t kHopLimit = 64;
+
+/**
+ * Last byte of Eth 1 GUA address.
+ */
+static constexpr uint8_t kEth1GuaLastByte = 1;
+
+void Test_1_3_DBR_TC_7A(void)
+{
+    /**
+     * 1.7(a). [1.3] [CERT] Reachability - Multiple BRs - Single Thread / Single IPv6 Infrastructure - Presence of
+     *   non-OMR prefixes
+     *
+     * 1.7a.1. Purpose
+     * To test the following:
+     * 1. Bi-directional reachability between single Thread Network and infrastructure devices
+     * 2. DUT BR creates own OMR prefix when existing prefixes are not usable (e.g. no SLAAC, or deprecated)
+     *
+     * 1.7a.2. Topology
+     * 1. Eth 1-Adjacent Infrastructure Link Reference Device
+     * 2. BR 1 (DUT) - Border Router
+     * 3. BR 2-Border Router Reference Device
+     * 4. Rtr 1-Thread Router Reference Device and Leader
+     *
+     * Spec Reference | V1.1 Section | V1.3.0 Section
+     * ---------------|--------------|---------------
+     * Reachability   | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &eth1 = nexus.CreateNode();
+    Node &br1  = nexus.CreateNode();
+    Node &br2  = nexus.CreateNode();
+    Node &rtr1 = nexus.CreateNode();
+
+    eth1.SetName("Eth_1");
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    rtr1.SetName("RTR_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0: Device: Eth 1 Description (DBR-1.7a): Enable.");
+
+    /**
+     * Step 0
+     * - Device: Eth 1
+     * - Description (DBR-1.7a): Enable. Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA_1. Eth_1
+     *   is configured to multicast ND RAs.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    static const char kGua1Prefix[] = "2001:db8:1::/64";
+    Ip6::Prefix       gua1Prefix;
+    SuccessOrQuit(gua1Prefix.FromString(kGua1Prefix));
+
+    Ip6::Address gua1Address;
+    gua1Address                                      = AsCoreType(&gua1Prefix.mPrefix);
+    gua1Address.mFields.m8[sizeof(Ip6::Address) - 1] = kEth1GuaLastByte;
+    eth1.mInfraIf.AddAddress(gua1Address);
+    eth1.mInfraIf.StartRouterAdvertisement(gua1Prefix);
+    nexus.AddTestVar("GUA_1", kGua1Prefix);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0b: Device: Rtr 1 Description (DBR-1.7a): Enable. Becomes Leader.");
+
+    /**
+     * Step 0b
+     * - Device: Rtr 1
+     * - Description (DBR-1.7a): Enable. Becomes Leader.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    rtr1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(rtr1.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Device: Eth 1, BR 2, Rtr 1 Description (DBR-1.7a): Enable; connects to Rtr_1.");
+
+    /**
+     * Step 1
+     * - Device: Eth 1, BR 2, Rtr 1
+     * - Description (DBR-1.7a): Enable; connects to Rtr_1. Harness configures BR_2 Network Data with prefix PRE_1 as
+     *   below. fd12:3456:abcd:1234::/64 (ULA prefix) P_slaac = false (SLAAC disabled for prefix) P_on_mesh = true
+     *   P_stable = true P_preferred = true P_dhcp = false P_default = true P_preference = 01 (high) P_dp = false.
+     *   Note: this looks like an OMR prefix, but due to P_slaac = 0 it is not usable by Thread Devices for
+     *   connectivity. Note 1: the automatic creation of an OMR prefix as a BR would normally do, is disabled by the
+     *   Harness for BR_2. Instead, the administratively configured PRE_1 is used only. Note 2: to disable the
+     *   automatic OMR prefix creation as stated above, one method is (if no better method is available) to disable the
+     *   BR_2 border routing using \"br disable\" OT CLI command to stop the automatic prefix creation. Then using
+     *   \"netdata publish prefix\" the device could configure different PRE_1 prefixes as needed per test run. Form
+     *   topology (if not already formed). Wait for BR_2 to: Register as border router in Thread Network Data with
+     *   prefix PRE_1. Send multicast ND RAs on AIL.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br2.Join(rtr1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+    VerifyOrQuit(br2.Get<Mle::Mle>().IsRouter());
+
+    static const char kPre1Prefix[] = "fd12:3456:abcd:1234::/64";
+    nexus.AddTestVar("PRE_1", kPre1Prefix);
+
+    NetworkData::OnMeshPrefixConfig config;
+    config.Clear();
+    SuccessOrQuit(AsCoreType(&config.mPrefix).FromString(kPre1Prefix));
+    config.mPreference   = NetworkData::kRoutePreferenceHigh;
+    config.mPreferred    = true;
+    config.mSlaac        = false;
+    config.mDhcp         = false;
+    config.mDefaultRoute = true;
+    config.mOnMesh       = true;
+    config.mStable       = true;
+
+    SuccessOrQuit(br2.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    br2.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    br2.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    Ip6::Prefix pre1Prefix;
+    SuccessOrQuit(pre1Prefix.FromString(kPre1Prefix));
+    br2.mInfraIf.StartRouterAdvertisement(pre1Prefix);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Device: BR 1 (DUT) Description (DBR-1.7a): Enable: switch on.");
+
+    /**
+     * Step 2
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7a): Enable: switch on.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Join(rtr1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Device: BR 1 (DUT) Description (DBR-1.7a): Automatically registers itself as a border router.");
+
+    /**
+     * Step 3
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7a): Automatically registers itself as a border router in the Thread Network Data and
+     *   provides OMR prefix.
+     * - Pass Criteria:
+     *   - The DUT MUST register a new OMR Prefix (OMR_1) in the Thread Network Data, in a Prefix TLV.
+     *   - Flags in the Border Router sub-TLV MUST be:
+     *   - 1. P_preference = 11 (Low)
+     *   - 2. P_default=true
+     *   - 3. P_stable = true
+     *   - 4. P_on_mesh=true
+     *   - 5. P_preferred = true
+     *   - 6. P_slaac=true
+     *   - 7. P_dhcp=false
+     *   - 8. P_dp=false
+     *   - OMR_1 MUST be 64 bits long and start with 0xFD.
+     */
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Ip6::Prefix omr1;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omr1));
+    nexus.AddTestVar("OMR_1", omr1.ToString().AsCString());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Device: BR 1 (DUT) Description (DBR-1.7a): Automatically multicasts ND RAs on AIL.");
+
+    /**
+     * Step 4
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7a): Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+     * - Pass Criteria:
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *   - IPv6 destination MUST be ff02::1
+     *   - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *   - MUST contain a Route Information Option (RIO) with OMR_1.
+     *   - MUST contain a Route Information Option (RIO) with PRE_1.
+     */
+
+    // Time already advanced.
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Device: Eth 1 Description (DBR-1.7a): Harness instructs device to send Echo Request to Rtr 1.");
+
+    /**
+     * Step 5
+     * - Device: Eth 1
+     * - Description (DBR-1.7a): Harness instructs the device to send an ICMPv6 Echo Request to Rtr 1 via BR 1 or BR 2.
+     *   IPv6 Source: Eth 1 GUA IPv6 Destination: Rtr 1 OMR address
+     * - Pass Criteria:
+     *   - Eth 1 receives an ICMPv6 Echo Reply from Rtr_1.
+     *   - IPv6 Source: Rtr 1 OMR
+     *   - IPv6 Destination: Eth 1 GUA
+     */
+
+    const Ip6::Address &eth1Gua = eth1.mInfraIf.FindMatchingAddress(kGua1Prefix);
+    const Ip6::Address &rtr1Omr = rtr1.FindMatchingAddress(omr1.ToString().AsCString());
+
+    nexus.AddTestVar("ETH_1_GUA_ADDR", eth1Gua.ToString().AsCString());
+    nexus.AddTestVar("RTR_1_OMR_ADDR", rtr1Omr.ToString().AsCString());
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, rtr1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Device: Rtr 1 Description (DBR-1.7a): Harness instructs device to send Echo Request to Eth 1.");
+
+    /**
+     * Step 6
+     * - Device: Rtr 1
+     * - Description (DBR-1.7a): Harness instructs the device to send an ICMPv6 Echo Request to Eth_1. IPv6 Source: Rtr
+     *   1 OMR address. IPv6 Destination: Eth 1 GUA
+     * - Pass Criteria:
+     *   - Rtr_1 receives an ICMPv6 Echo Reply from Eth_1.
+     *   - IPv6 Source: Eth_1 GUA
+     *   - IPv6 Destination: Rtr 1 OMR address
+     */
+
+    rtr1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kHopLimit, &rtr1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Device: BR 2 Description (DBR-1.7a): Harness disables the device.");
+
+    /**
+     * Step 7
+     * - Device: BR 2
+     * - Description (DBR-1.7a): Harness disables the device
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br2.Get<Mle::Mle>().Stop();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7a: Device: N/A Description (DBR-1.7a): Harness waits -20 seconds.");
+
+    /**
+     * Step 7a
+     * - Device: N/A
+     * - Description (DBR-1.7a): Harness waits -20 seconds
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    nexus.AdvanceTime(kWaitTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7b: Device: Eth 1 Description (DBR-1.7a): Harness instructs device to send an ND RS message.");
+
+    /**
+     * Step 7b
+     * - Device: Eth 1
+     * - Description (DBR-1.7a): Harness instructs device to send an ND RS message to trigger the DUT to send a ND RA
+     *   for the next step. Note: in Linux, this is done with the command rdisc6v eth0. The output of the command is
+     *   captured in the test log.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    Ip6::Nd::RouterSolicitHeader rsHeader;
+    eth1.mInfraIf.SendIcmp6Nd(Ip6::Address::GetLinkLocalAllRoutersMulticast(),
+                              reinterpret_cast<const uint8_t *>(&rsHeader), sizeof(rsHeader));
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Device: BR 1 (DUT) Description (DBR-1.7a): Repeat Step 4.");
+
+    /**
+     * Step 8
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7a): Repeat Step 4 Note: since the prefix PRE_1 is still present in the Leader's Network
+     *   Data for some time, BR 1 will continue to advertise the route for PRE_1 as indicated in Step 4 criteria.
+     * - Pass Criteria:
+     *   - Repeat Step 4
+     */
+
+    // Time already advanced.
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Device: Eth 1 Description (DBR-1.7a): Repeat Step 5.");
+
+    /**
+     * Step 9
+     * - Device: Eth 1
+     * - Description (DBR-1.7a): Repeat Step 5
+     * - Pass Criteria:
+     *   - Repeat Step 5
+     */
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, rtr1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: Device: Rtr 1 Description (DBR-1.7a): Repeat Step 6.");
+
+    /**
+     * Step 10
+     * - Device: Rtr 1
+     * - Description (DBR-1.7a): Repeat Step 6
+     * - Pass Criteria:
+     *   - Repeat Step 6
+     */
+
+    rtr1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kHopLimit, &rtr1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    nexus.SaveTestInfo("test_1_3_DBR_TC_7A.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_3_DBR_TC_7A();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DBR_TC_7B.cpp b/tests/nexus/test_1_3_DBR_TC_7B.cpp
new file mode 100644
index 0000000..e68cbf1
--- /dev/null
+++ b/tests/nexus/test_1_3_DBR_TC_7B.cpp
@@ -0,0 +1,411 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for the ping response, in milliseconds.
+ */
+static constexpr uint32_t kPingResponseTime = 5 * 1000;
+
+/**
+ * Time to advance for waiting, in milliseconds.
+ */
+static constexpr uint32_t kWaitTime = 20 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Echo Request identifier.
+ */
+static constexpr uint16_t kEchoIdentifier = 0x1234;
+
+/**
+ * Echo Request payload size.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+/**
+ * IPv6 Hop Limit.
+ */
+static constexpr uint8_t kHopLimit = 64;
+
+/**
+ * Last byte of Eth 1 GUA address.
+ */
+static constexpr uint8_t kEth1GuaLastByte = 1;
+
+void Test_1_3_DBR_TC_7B(void)
+{
+    /**
+     * 1.7(b). [1.3] [CERT] Reachability - Multiple BRs - Single Thread / Single IPv6 Infrastructure - Presence of
+     *   non-OMR prefixes
+     *
+     * 1.7b.1. Purpose
+     * To test the following:
+     * 1. Bi-directional reachability between single Thread Network and infrastructure devices
+     * 2. DUT BR creates own OMR prefix when existing prefixes are not usable (e.g. no SLAAC, or deprecated)
+     *
+     * 1.7b.2. Topology
+     * 1. Eth 1-Adjacent Infrastructure Link Reference Device
+     * 2. BR 1 (DUT) - Border Router
+     * 3. BR 2-Border Router Reference Device
+     * 4. Rtr 1-Thread Router Reference Device and Leader
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * Reachability     | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &eth1 = nexus.CreateNode();
+    Node &br1  = nexus.CreateNode();
+    Node &br2  = nexus.CreateNode();
+    Node &rtr1 = nexus.CreateNode();
+
+    eth1.SetName("Eth_1");
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    rtr1.SetName("RTR_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0: Device: Eth 1 Description (DBR-1.7b): Enable.");
+
+    /**
+     * Step 0
+     * - Device: Eth 1
+     * - Description (DBR-1.7b): Enable. Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA_1. Eth_1
+     *   is configured to multicast ND RAS.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    static const char kGua1Prefix[] = "2001:db8:1::/64";
+    Ip6::Prefix       gua1Prefix;
+    SuccessOrQuit(gua1Prefix.FromString(kGua1Prefix));
+
+    Ip6::Address gua1Address;
+    gua1Address                                      = AsCoreType(&gua1Prefix.mPrefix);
+    gua1Address.mFields.m8[sizeof(Ip6::Address) - 1] = kEth1GuaLastByte;
+    eth1.mInfraIf.AddAddress(gua1Address);
+    eth1.mInfraIf.StartRouterAdvertisement(gua1Prefix);
+    nexus.AddTestVar("GUA_1", kGua1Prefix);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0b: Device: Rtr 1 Description (DBR-1.7b): Enable. Becomes Leader.");
+
+    /**
+     * Step 0b
+     * - Device: Rtr 1
+     * - Description (DBR-1.7b): Enable. Becomes Leader.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    rtr1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(rtr1.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Device: Eth 1, BR 2, Rtr 1 Description (DBR-1.7b): Enable; connects to Rtr_1.");
+
+    /**
+     * Step 1
+     * - Device: Eth 1, BR 2, Rtr 1
+     * - Description (DBR-1.7b): Enable; connects to Rtr_1. Harness configures BR_2 Network Data with prefix PRE_1 as
+     *   below. fd12:3456:abcd:1234::/64 (ULA prefix) P_slaac = true (SLAAC is enabled for prefix) P_on_mesh = true
+     *   P_stable = true P_preferred = false (prefix is deprecated) P_dhcp = false P_default = true P_preference = 01
+     *   (high) P_dp = false. Note: this looks like an OMR prefix, but due to deprecation it is not usable by (new)
+     *   Thread Devices for connectivity. Note 1: the automatic creation of an OMR prefix as a BR would normally do, is
+     *   disabled by the Harness for BR_2. Instead, the administratively configured PRE_1 is used only. Note 2: to
+     *   disable the automatic OMR prefix creation as stated above, one method is (if no better method is available) to
+     *   disable the BR_2 border routing using \"br disable\" OT CLI command to stop the automatic prefix creation. Then
+     *   using \"netdata publish prefix\" the device could configure different PRE_1 prefixes as needed per test run.
+     *   Form topology (if not already formed). Wait for BR_2 to: Register as border router in Thread Network Data with
+     *   prefix PRE_1. Send multicast ND RAs on AIL.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br2.Join(rtr1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+    VerifyOrQuit(br2.Get<Mle::Mle>().IsRouter());
+
+    static const char kPre1Prefix[] = "fd12:3456:abcd:1234::/64";
+    nexus.AddTestVar("PRE_1", kPre1Prefix);
+
+    NetworkData::OnMeshPrefixConfig config;
+    SuccessOrQuit(AsCoreType(&config.mPrefix).FromString(kPre1Prefix));
+    config.mPreference   = NetworkData::kRoutePreferenceHigh;
+    config.mPreferred    = false;
+    config.mSlaac        = true;
+    config.mDhcp         = false;
+    config.mDefaultRoute = true;
+    config.mOnMesh       = true;
+    config.mStable       = true;
+
+    SuccessOrQuit(br2.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    br2.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    br2.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    Ip6::Prefix pre1Prefix;
+    SuccessOrQuit(pre1Prefix.FromString(kPre1Prefix));
+    br2.mInfraIf.StartRouterAdvertisement(pre1Prefix);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Device: BR 1 (DUT) Description (DBR-1.7b): Enable: switch on.");
+
+    /**
+     * Step 2
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7b): Enable: switch on.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Join(rtr1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Device: BR 1 (DUT) Description (DBR-1.7b): Automatically registers itself as a border router.");
+
+    /**
+     * Step 3
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7b): Automatically registers itself as a border router in the Thread Network Data and
+     *   provides OMR prefix.
+     * - Pass Criteria:
+     *   - The DUT MUST register a new OMR Prefix (OMR_1) in the Thread Network Data, in a Prefix TLV.
+     *   - Flags in the Border Router sub-TLV MUST be:
+     *   - 9. P_preference = 11 (Low)
+     *   - 10. P_default = true
+     *   - 11. P_stable = true
+     *   - 12. P_on_mesh = true
+     *   - 13. P_preferred = true
+     *   - 14. P_slaac = true
+     *   - 15. P_dhcp = false
+     *   - 16. P_dp = false
+     *   - OMR_1 MUST be 64 bits long and start with 0xFD.
+     */
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Ip6::Prefix omr1;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omr1));
+    nexus.AddTestVar("OMR_1", omr1.ToString().AsCString());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Device: BR 1 (DUT) Description (DBR-1.7b): Automatically multicasts ND RAs on AIL.");
+
+    /**
+     * Step 4
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7b): Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+     * - Pass Criteria:
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *   - IPv6 destination MUST be ff02::1
+     *   - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *   - MUST contain a Route Information Option (RIO) with OMR_1.
+     *   - MUST contain a Route Information Option (RIO) with PRE_1.
+     */
+
+    // Time already advanced.
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Device: Eth 1 Description (DBR-1.7b): Harness instructs device to send Echo Request to Rtr 1.");
+
+    /**
+     * Step 5
+     * - Device: Eth 1
+     * - Description (DBR-1.7b): Harness instructs the device to send an ICMPv6 Echo Request to Rtr 1 via BR_1 or BR_2.
+     *   IPv6 Source: Eth 1 GUA IPv6 Destination: Rtr 1 OMR address
+     * - Pass Criteria:
+     *   - Eth_1 receives an ICMPv6 Echo Reply from Rtr_1.
+     *   - IPv6 Source: Rtr_1 OMR
+     *   - IPv6 Destination: Eth 1 GUA
+     */
+
+    const Ip6::Address &eth1Gua = eth1.mInfraIf.FindMatchingAddress(kGua1Prefix);
+    const Ip6::Address &rtr1Omr = rtr1.FindMatchingAddress(omr1.ToString().AsCString());
+
+    nexus.AddTestVar("ETH_1_GUA_ADDR", eth1Gua.ToString().AsCString());
+    nexus.AddTestVar("RTR_1_OMR_ADDR", rtr1Omr.ToString().AsCString());
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, rtr1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Device: Rtr 1 Description (DBR-1.7b): Harness instructs device to send Echo Request to Eth 1.");
+
+    /**
+     * Step 6
+     * - Device: Rtr 1
+     * - Description (DBR-1.7b): Harness instructs the device to send an ICMPv6 Echo Request to Eth 1. IPv6 Source:
+     *   Rtr_1 OMR address IPv6 Destination: Eth_1 GUA
+     * - Pass Criteria:
+     *   - Rtr_1 receives an ICMPv6 Echo Reply from Eth_1.
+     *   - IPv6 Source: Eth 1 GUA
+     *   - IPv6 Destination: Rtr_1 OMR address
+     */
+
+    rtr1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kHopLimit, &rtr1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Device: BR_2 Description (DBR-1.7b): Harness disables the device.");
+
+    /**
+     * Step 7
+     * - Device: BR_2
+     * - Description (DBR-1.7b): Harness disables the device
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br2.Get<Mle::Mle>().Stop();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7a: Device: N/A Description (DBR-1.7b): Harness waits -20 seconds.");
+
+    /**
+     * Step 7a
+     * - Device: N/A
+     * - Description (DBR-1.7b): Harness waits -20 seconds
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    nexus.AdvanceTime(kWaitTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7b: Device: Eth 1 Description (DBR-1.7b): Harness instructs device to send an ND RS message.");
+
+    /**
+     * Step 7b
+     * - Device: Eth 1
+     * - Description (DBR-1.7b): Harness instructs the device to send an ND RS message to trigger the DUT to send a ND
+     *   RA for the next step. Note: in Linux, this is done with the command rdisc6v eth0. The output of the command is
+     *   captured in the test log.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    Ip6::Nd::RouterSolicitHeader rsHeader;
+    eth1.mInfraIf.SendIcmp6Nd(Ip6::Address::GetLinkLocalAllRoutersMulticast(),
+                              reinterpret_cast<const uint8_t *>(&rsHeader), sizeof(rsHeader));
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Device: BR 1 (DUT) Description (DBR-1.7b): Repeat Step 4.");
+
+    /**
+     * Step 8
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7b): Repeat Step 4. Note: since the prefix PRE_1 is still present in the Leader's Network
+     *   Data for some time, BR_1 will continue to advertise the route for PRE_1 as indicated in Step 4 criteria.
+     * - Pass Criteria:
+     *   - Repeat Step 4
+     */
+
+    // Time already advanced.
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Device: Eth 1 Description (DBR-1.7b): Repeat Step 5.");
+
+    /**
+     * Step 9
+     * - Device: Eth 1
+     * - Description (DBR-1.7b): Repeat Step 5
+     * - Pass Criteria:
+     *   - Repeat Step 5
+     */
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, rtr1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: Device: Rtr_1 Description (DBR-1.7b): Repeat Step 6.");
+
+    /**
+     * Step 10
+     * - Device: Rtr_1
+     * - Description (DBR-1.7b): Repeat Step 6
+     * - Pass Criteria:
+     *   - Repeat Step 6
+     */
+
+    rtr1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kHopLimit, &rtr1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    nexus.SaveTestInfo("test_1_3_DBR_TC_7B.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_3_DBR_TC_7B();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DBR_TC_7C.cpp b/tests/nexus/test_1_3_DBR_TC_7C.cpp
new file mode 100644
index 0000000..b99d21c
--- /dev/null
+++ b/tests/nexus/test_1_3_DBR_TC_7C.cpp
@@ -0,0 +1,420 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for the ping response, in milliseconds.
+ */
+static constexpr uint32_t kPingResponseTime = 5 * 1000;
+
+/**
+ * Time to advance for waiting, in milliseconds.
+ */
+static constexpr uint32_t kWaitTime = 20 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Echo Request identifier.
+ */
+static constexpr uint16_t kEchoIdentifier = 0x1234;
+
+/**
+ * Echo Request payload size.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+/**
+ * IPv6 Hop Limit.
+ */
+static constexpr uint8_t kHopLimit = 64;
+
+/**
+ * Last byte of Eth 1 GUA address.
+ */
+static constexpr uint8_t kEth1GuaLastByte = 1;
+
+/**
+ * Ethernet 1 GUA prefix.
+ */
+static constexpr char kGua1Prefix[] = "2001:db8:1::/64";
+
+/**
+ * Border Router 2 Network Data prefix.
+ */
+static constexpr char kPre1Prefix[] = "fd12:3456:abcd:1234::/64";
+
+void Test_1_3_DBR_TC_7C(void)
+{
+    /**
+     * 1.7(c). [1.3] [CERT] Reachability - Multiple BRs - Single Thread / Single IPv6 Infrastructure - Presence of
+     *   non-OMR prefixes
+     *
+     * 1.7c.1. Purpose
+     * To test the following:
+     * 1. Bi-directional reachability between single Thread Network and infrastructure devices
+     * 2. DUT BR creates own OMR prefix when existing prefixes are not usable (e.g. no SLAAC, or deprecated)
+     *
+     * 1.7c.2. Topology
+     * 1. Eth 1-Adjacent Infrastructure Link Reference Device
+     * 2. BR 1 (DUT) - Border Router
+     * 3. BR 2-Border Router Reference Device
+     * 4. Rtr 1-Thread Router Reference Device and Leader
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * Reachability     | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &eth1 = nexus.CreateNode();
+    Node &br1  = nexus.CreateNode();
+    Node &br2  = nexus.CreateNode();
+    Node &rtr1 = nexus.CreateNode();
+
+    eth1.SetName("Eth_1");
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    rtr1.SetName("RTR_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0: Device: Eth 1 Description (DBR-1.7c): Enable.");
+
+    /**
+     * Step 0
+     * - Device: Eth 1
+     * - Description (DBR-1.7c): Enable. Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA_1. Eth_1
+     *   is configured to multicast ND RAs.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    Ip6::Prefix gua1Prefix;
+    SuccessOrQuit(gua1Prefix.FromString(kGua1Prefix));
+
+    Ip6::Address gua1Address;
+    gua1Address                                      = AsCoreType(&gua1Prefix.mPrefix);
+    gua1Address.mFields.m8[sizeof(Ip6::Address) - 1] = kEth1GuaLastByte;
+    eth1.mInfraIf.AddAddress(gua1Address);
+    eth1.mInfraIf.StartRouterAdvertisement(gua1Prefix);
+    nexus.AddTestVar("GUA_1", kGua1Prefix);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0b: Device: Rtr 1 Description (DBR-1.7c): Enable. Becomes Leader.");
+
+    /**
+     * Step 0b
+     * - Device: Rtr 1
+     * - Description (DBR-1.7c): Enable. Becomes Leader.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    rtr1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(rtr1.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Device: Eth 1, BR 2, Rtr 1 Description (DBR-1.7c): Enable; connects to Rtr_1.");
+
+    /**
+     * Step 1
+     * - Device: Eth 1, BR 2, Rtr 1
+     * - Description (DBR-1.7c): Enable; connects to Rtr_1. Harness configures BR_2 Network Data with prefix PRE_1 as
+     *   below. fd12:3456:abcd:1234::/64 (ULA prefix) P_slaac = true (SLAAC is enabled for prefix) P_on_mesh = false
+     *   (not an on-mesh prefix) P_stable = true P_preferred = true P_dhcp = false P_default = true P_preference = 00
+     *   (medium) P_dp = false. Note: this looks almost like an OMR prefix, but due to having P_on_mesh = false it is
+     *   not a valid OMR prefix and would not be usable by (new) Thread Devices for connectivity. Note 1: the
+     *   automatic creation of an OMR prefix as a BR would normally do, is disabled by the Harness for BR_2. Instead,
+     *   the administratively configured PRE_1 is used only. Note 2: to disable the automatic OMR prefix creation as
+     *   stated above, one method is (if no better method is available) to disable the BR_2 border routing using "br
+     *   disable" OT CLI command to stop the automatic prefix creation. Then using "netdata publish prefix" the
+     *   device could configure different PRE_1 prefixes as needed per test run. Form topology (if not already
+     *   formed). Wait for BR_2 to: Register as border router in Thread Network Data with prefix PRE_1. Send multicast
+     *   ND RAs on AIL.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br2.Join(rtr1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    nexus.AddTestVar("PRE_1", kPre1Prefix);
+
+    NetworkData::OnMeshPrefixConfig config;
+    SuccessOrQuit(AsCoreType(&config.mPrefix).FromString(kPre1Prefix));
+    config.mPreference   = NetworkData::kRoutePreferenceMedium;
+    config.mPreferred    = true;
+    config.mSlaac        = true;
+    config.mDhcp         = false;
+    config.mDefaultRoute = true;
+    config.mOnMesh       = false;
+    config.mStable       = true;
+
+    SuccessOrQuit(br2.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    br2.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    br2.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    Ip6::Prefix pre1Prefix;
+    SuccessOrQuit(pre1Prefix.FromString(kPre1Prefix));
+    br2.mInfraIf.StartRouterAdvertisement(pre1Prefix);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Device: BR 1 (DUT) Description (DBR-1.7c): Enable: switch on.");
+
+    /**
+     * Step 2
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7c): Enable: switch on.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Join(rtr1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Device: BR 1 (DUT) Description (DBR-1.7c): Automatically registers itself as a border router.");
+
+    /**
+     * Step 3
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7c): Automatically registers itself as a border router in the Thread Network Data and
+     *   provides OMR prefix.
+     * - Pass Criteria:
+     *   - The DUT MUST register a new OMR Prefix (OMR_1) in the Thread Network Data, in a Prefix TLV.
+     *   - Flags in the Border Router sub-TLV MUST be:
+     *   - 17. P_preference = 11 (Low)
+     *   - 18. P_default = true
+     *   - 19. P_stable = true
+     *   - 20. P_on_mesh = true
+     *   - 21. P_preferred = true
+     *   - 22. P_slaac = true
+     *   - 23. P_dhcp = false
+     *   - 24. P_dp = false
+     *   - OMR_1 MUST be 64 bits long and start with 0xFD.
+     */
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Ip6::Prefix omr1;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omr1));
+    nexus.AddTestVar("OMR_1", omr1.ToString().AsCString());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Device: BR 1 (DUT) Description (DBR-1.7c): Automatically multicasts ND RAs on AIL.");
+
+    /**
+     * Step 4
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7c): Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+     * - Pass Criteria:
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *   - IPv6 destination MUST be ff02::1
+     *   - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *   - MUST contain a Route Information Option (RIO) with OMR_1.
+     *   - MUST NOT contain a Route Information Option (RIO) with PRE_1. (Note: reason is that it is not an on-mesh
+     *     prefix.)
+     */
+
+    // Time already advanced.
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Device: Eth 1 Description (DBR-1.7c): Harness instructs device to send Echo Request to Rtr 1.");
+
+    /**
+     * Step 5
+     * - Device: Eth 1
+     * - Description (DBR-1.7c): Harness instructs the device to send an ICMPv6 Echo Request to Rtr 1 via BR_1 or BR 2.
+     *   IPv6 Source: Eth 1 GUA IPv6 Destination: Rtr 1 OMR address.
+     * - Pass Criteria:
+     *   - Eth 1 receives an ICMPv6 Echo Reply from Rtr 1.
+     *   - IPv6 Source: Rtr_1 OMR
+     *   - IPv6 Destination: Eth_1 GUA
+     */
+
+    const Ip6::Address &eth1Gua = eth1.mInfraIf.FindMatchingAddress(kGua1Prefix);
+    const Ip6::Address &rtr1Omr = rtr1.FindMatchingAddress(omr1.ToString().AsCString());
+
+    nexus.AddTestVar("ETH_1_GUA_ADDR", eth1Gua.ToString().AsCString());
+    nexus.AddTestVar("RTR_1_OMR_ADDR", rtr1Omr.ToString().AsCString());
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, rtr1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Device: Rtr 1 Description (DBR-1.7c): Harness instructs device to send Echo Request to Eth 1.");
+
+    /**
+     * Step 6
+     * - Device: Rtr 1
+     * - Description (DBR-1.7c): Harness instructs the device to send an ICMPv6 Echo Request to Eth 1. IPv6 Source:
+     * Rtr_1 OMR address IPv6 Destination: Eth 1 GUA
+     * - Pass Criteria:
+     *   - Rtr 1 receives an ICMPv6 Echo Reply from Eth 1.
+     *   - IPv6 Source: Eth 1 GUA
+     *   - IPv6 Destination: Rtr_1 OMR address
+     */
+
+    rtr1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kHopLimit, &rtr1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Device: BR 2 Description (DBR-1.7c): Harness disables the device.");
+
+    /**
+     * Step 7
+     * - Device: BR 2
+     * - Description (DBR-1.7c): Harness disables the device
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br2.Get<Mle::Mle>().Stop();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7a: Device: N/A Description (DBR-1.7c): Harness waits -20 seconds.");
+
+    /**
+     * Step 7a
+     * - Device: N/A
+     * - Description (DBR-1.7c): Harness waits -20 seconds
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    nexus.AdvanceTime(kWaitTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7b: Device: Eth 1 Description (DBR-1.7c): Harness instructs device to send an ND RS message.");
+
+    /**
+     * Step 7b
+     * - Device: Eth 1
+     * - Description (DBR-1.7c): Harness instructs the device to send an ND RS message to trigger the DUT to send a ND
+     *   RA for the next step. Note: in Linux, this is done with the command rdisc6v eth0. The output of the command is
+     *   captured in the test log.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    Ip6::Nd::RouterSolicitHeader rsHeader;
+    eth1.mInfraIf.SendIcmp6Nd(Ip6::Address::GetLinkLocalAllRoutersMulticast(),
+                              reinterpret_cast<const uint8_t *>(&rsHeader), sizeof(rsHeader));
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Device: BR 1 (DUT) Description (DBR-1.7c): Repeat Step 4.");
+
+    /**
+     * Step 8
+     * - Device: BR 1 (DUT)
+     * - Description (DBR-1.7c): Repeat Step 4. Note: since the prefix PRE_1 is still present in the Leader's Network
+     *   Data for some time, BR 1 will continue to advertise the route for PRE 1 as indicated in Step 4 criteria.
+     * - Pass Criteria:
+     *   - Repeat Step 4
+     */
+
+    // Time already advanced.
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Device: Eth_1 Description (DBR-1.7c): Repeat Step 5.");
+
+    /**
+     * Step 9
+     * - Device: Eth_1
+     * - Description (DBR-1.7c): Repeat Step 5
+     * - Pass Criteria:
+     *   - Repeat Step 5
+     */
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, rtr1Omr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: Device: Rtr_1 Description (DBR-1.7c): Repeat Step 6.");
+
+    /**
+     * Step 10
+     * - Device: Rtr_1
+     * - Description (DBR-1.7c): Repeat Step 6
+     * - Pass Criteria:
+     *   - Repeat Step 6
+     */
+
+    rtr1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kHopLimit, &rtr1Omr);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    nexus.SaveTestInfo("test_1_3_DBR_TC_7C.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_3_DBR_TC_7C();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DBR_TC_8.cpp b/tests/nexus/test_1_3_DBR_TC_8.cpp
new file mode 100644
index 0000000..a59832c
--- /dev/null
+++ b/tests/nexus/test_1_3_DBR_TC_8.cpp
@@ -0,0 +1,939 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 20 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 300 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 100 * 1000;
+
+/**
+ * Longer time to advance for network stabilization and address configuration.
+ */
+static constexpr uint32_t kLongActionTime = 500 * 1000;
+
+/**
+ * Time to advance for the ping response, in milliseconds.
+ */
+static constexpr uint32_t kPingResponseTime = 100 * 1000;
+
+/**
+ * Default Hop Limit for Echo Request.
+ */
+static constexpr uint8_t kDefaultHopLimit = 64;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Echo Request identifier.
+ */
+static constexpr uint16_t kEchoIdentifier = 0x1234;
+
+/**
+ * Echo Request payload size.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+/**
+ * Max number of seconds to wait for a condition.
+ */
+static constexpr uint32_t kMaxWaitTime = 120;
+
+/**
+ * OMR prefix 3 string (numerically very low to be "winning").
+ */
+static constexpr char kOmr3PrefixStr[] = "fd00:3::/64";
+
+/**
+ * OMR prefix 4 string (numerically very high to be "losing" if same preference).
+ */
+static constexpr char kOmr4PrefixStr[] = "fdff:4::/64";
+
+/**
+ * Infrastructure GUA address string for Eth_1.
+ */
+static constexpr char kEth1GuaAddrStr[] = "2001:db8:1::1";
+
+/**
+ * Infrastructure GUA prefix string.
+ */
+static constexpr char kGua1PrefixStr[] = "2001:db8:1::/64";
+
+static void DumpNetworkData(Node &aNode)
+{
+    NetworkData::Iterator            iterator = NetworkData::kIteratorInit;
+    NetworkData::OnMeshPrefixConfig  prefixConfig;
+    NetworkData::ExternalRouteConfig routeConfig;
+
+    Log("Network Data for %s (Version: %d):", aNode.GetName(),
+        aNode.Get<NetworkData::Leader>().GetVersion(NetworkData::kFullSet));
+    while (aNode.Get<NetworkData::Leader>().GetNext(iterator, prefixConfig) == kErrorNone)
+    {
+        Log("  Prefix: %s (pref:%d, preferred:%s)", prefixConfig.GetPrefix().ToString().AsCString(),
+            prefixConfig.mPreference, ToYesNo(prefixConfig.mPreferred));
+    }
+
+    iterator = NetworkData::kIteratorInit;
+    while (aNode.Get<NetworkData::Leader>().GetNext(iterator, routeConfig) == kErrorNone)
+    {
+        Log("  Route: %s (pref:%d)", routeConfig.GetPrefix().ToString().AsCString(), routeConfig.mPreference);
+    }
+}
+
+static bool HasNetDataPrefix(Node &aNode, const Ip6::Prefix &aPrefix)
+{
+    return aNode.Get<NetworkData::Leader>().ContainsOmrPrefix(aPrefix);
+}
+
+static bool HasNetDataRoute(Node &aNode, const char *aPrefixStr)
+{
+    bool                             hasRoute = false;
+    NetworkData::Iterator            iterator = NetworkData::kIteratorInit;
+    NetworkData::ExternalRouteConfig routeConfig;
+    Ip6::Prefix                      target;
+
+    SuccessOrQuit(target.FromString(aPrefixStr));
+
+    while (aNode.Get<NetworkData::Leader>().GetNext(iterator, routeConfig) == kErrorNone)
+    {
+        if (AsCoreType(&routeConfig.mPrefix) == target)
+        {
+            hasRoute = true;
+            break;
+        }
+    }
+
+    return hasRoute;
+}
+
+static void WaitForPrefix(Core &aNexus, Node &aNode, const Ip6::Prefix &aPrefix, bool aPresent)
+{
+    for (uint32_t i = 0; i < kMaxWaitTime; i++)
+    {
+        if (HasNetDataPrefix(aNode, aPrefix) == aPresent)
+        {
+            break;
+        }
+        aNexus.AdvanceTime(1000);
+    }
+    VerifyOrQuit(HasNetDataPrefix(aNode, aPrefix) == aPresent);
+}
+
+static void WaitForRoute(Core &aNexus, Node &aNode, const char *aPrefixStr, bool aPresent)
+{
+    for (uint32_t i = 0; i < kMaxWaitTime; i++)
+    {
+        if (HasNetDataRoute(aNode, aPrefixStr) == aPresent)
+        {
+            break;
+        }
+        aNexus.AdvanceTime(1000);
+    }
+    VerifyOrQuit(HasNetDataRoute(aNode, aPrefixStr) == aPresent);
+}
+
+static void PublishOmrPrefix(Node                        &aNode,
+                             const Ip6::Prefix           &aPrefix,
+                             NetworkData::RoutePreference aPreference,
+                             bool                         aIsPreferred)
+{
+    NetworkData::OnMeshPrefixConfig config;
+    config.Clear();
+    config.GetPrefix() = aPrefix;
+    config.mPreference = aPreference;
+    config.mSlaac      = true;
+    config.mOnMesh     = true;
+    config.mStable     = true;
+    config.mPreferred  = aIsPreferred;
+    SuccessOrQuit(aNode.Get<NetworkData::Publisher>().PublishOnMeshPrefix(config, NetworkData::Publisher::kFromUser));
+}
+
+void Test_1_3_DBR_TC_8(void)
+{
+    /**
+     * 1.8. [1.3] [CERT] Reachability - Multiple BRs - Single Thread / Single IPv6 Infrastructure - OMR prefix selection
+     *
+     * 1.8.1. Purpose
+     * - To test the following:
+     *   - Maintain bi-directional reachability between Thread devices and infrastructure devices during prefix changes
+     *   - DUT BR should observe proper OMR prefix selection when testbed-BR advertises various other prefixes:
+     *     - Testbed advertises a winning prefix with equal preference, DUT should withdraw or deprecate its OMR prefix
+     *     - Testbed advertises a numerically higher, non-winning prefix with equal preference, DUT should not change
+     *       behavior.
+     *     - Testbed advertises a numerically higher prefix with higher preference, i.e. winning OMR prefix, DUT should
+     *       withdraw its OMR prefix.
+     *     - Testbed withdraws winning OMR prefix, DUT should advertise its prefix with 'Low' preference again
+     *     - Testbed OMR prefix becomes deprecated, DUT should advertise its prefix with 'Low' preference again
+     *
+     * 1.8.2. Topology
+     * - Eth_1 - Adjacent Infrastructure Link Reference Device
+     * - BR_1 (DUT) - Border Router
+     * - BR_2 - Border Router Reference Device and Leader
+     * - ED_1 - Thread Reference Device (End Device) attached to BR_1
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * Reachability     | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &eth1 = nexus.CreateNode();
+    Node &br1  = nexus.CreateNode();
+    Node &br2  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+
+    eth1.SetName("Eth_1");
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    ed1.SetName("ED_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 0
+     * - Device: Eth_1
+     * - Description (DBR-1.8):
+     *   - Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA_1. Eth_1 is configured to multicast ND
+     *     RAs.
+     *   - Automatically configures a global address “Eth_1 GUA”.
+     * - Pass Criteria
+     *   - N/A
+     */
+    Log("Step 0: Eth_1 configured with GUA_1.");
+
+    Ip6::Address eth1Gua;
+    SuccessOrQuit(eth1Gua.FromString(kEth1GuaAddrStr));
+    eth1.mInfraIf.AddAddress(eth1Gua);
+
+    Ip6::Prefix gua1;
+    SuccessOrQuit(gua1.FromString(kGua1PrefixStr));
+    eth1.mInfraIf.StartRouterAdvertisement(gua1);
+
+    nexus.AddTestVar("ETH_1_GUA_ADDR", kEth1GuaAddrStr);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 1
+     * - Device: Eth_1, BR_2, ED_1, ED_2
+     * - Description (DBR-1.8):
+     *   - Enable; BR_2 automatically configures an OMR prefix for the mesh, OMR_1. Harness records the value of OMR_1
+     *     and saves it for verification use.
+     *   - Note: the OT command ‘br omrprefix’ should provide the prefix from BR_2.
+     *   - Form topology. Wait for BR_2 to:
+     *     - Become Leader
+     *     - Register as border router in Thread Network Data with its OMR prefix OMR_1
+     *     - Send multicast ND RAs on AIL
+     *     - Automatically advertise a route to GUA_1 in the Thread Network Data, using a Prefix TLV:
+     *       - Prefix = ::/0
+     *       - Domain ID = <self-selected value>
+     *       - Has Route sub-TLV
+     * - Pass Criteria
+     *   - N/A
+     */
+    Log("Step 1: BR_2 becomes Leader and registers OMR_1.");
+
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, ed1);
+
+    br2.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    br2.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br2.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    nexus.AdvanceTime(kBrActionTime);
+    WaitForRoute(nexus, br2, "::/0", true);
+
+    Ip6::Prefix omr1;
+    SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omr1));
+    nexus.AddTestVar("OMR_1_PREFIX", omr1.ToString().AsCString());
+    Log("OMR_1_PREFIX: %s", omr1.ToString().AsCString());
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 2
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8): Enable: switch on.
+     * - Pass Criteria
+     *   - N/A
+     */
+    Log("Step 2: Enable BR_1 (DUT).");
+
+    br1.Join(br2);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 3
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically registers itself as a border router in the Thread Network Data.
+     * - Pass Criteria:
+     *   - The DUT MUST register a route to GUA_1 in the Thread Network Data as follows:
+     *     - Prefix TLV: Prefix = ::/0
+     *     - Domain ID = <same value as that of BR_2 in step 1>
+     *     - Has Route sub-TLV
+     *       - R_preference = 00 (medium) or 11 (low)
+     *   - DUT MUST NOT register an OMR prefix (e.g. OMR_1, or other) in Thread Network Data.
+     */
+    Log("Step 3: BR_1 (DUT) registers route to GUA_1.");
+
+    nexus.AdvanceTime(kBrActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 4
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically announces the route to OMR_1 on the AIL.
+     * - Pass Criteria:
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *     - IPv6 destination MUST be ff02::1
+     *     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *     - MUST contain a Route Information Option (RIO) with OMR_1.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low)
+     */
+    Log("Step 4: BR_1 (DUT) announces OMR_1 on AIL.");
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 5
+     * - Device: BR_2
+     * - Description (DBR-1.8):
+     *   - Harness instructs device to remove the existing OMR prefix (OMR_1) being advertised and send out new network
+     *     data without any prefix.
+     *   - Note: for 1.3.x cert, this could be implemented by 'br disable' or 'netdata unpublish <OMR_1>' on BR_2.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 5: BR_2 removes OMR_1.");
+
+    SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().SetEnabled(false));
+    WaitForPrefix(nexus, br2, omr1, false);
+
+    nexus.AdvanceTime(kBrActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 6
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically chooses a new OMR prefix OMR_2 and includes it in the Network Data.
+     * - Pass Criteria:
+     *   - The DUT MUST register a new OMR prefix (OMR_2) in the Thread Network Data using Prefix TLV as follows:
+     *     - Prefix = OMR_2
+     *       - Border Router sub-TLV
+     *         - P_preference = 11 (Low)
+     *         - P_default = true
+     *         - P_stable = true
+     *         - P_on_mesh = true
+     *         - P_preferred = true
+     *         - P_slaac = true
+     *         - P_dhcp = false
+     *         - P_dp = false
+     *   - OMR_2 MUST be 64 bits long and start with 0xFD.
+     *   - OMR_2 MUST differ from OMR_1.
+     *   - Also MUST contain a Prefix TLV as follows:
+     *     - Prefix = ::/0
+     *     - Domain ID <same value as BR_2 Domain ID>
+     *     - Has Route TLV
+     *       - R_preference = 00 (medium) or 11 (low)
+     *   - Note: one possible way to check this is to verify the contents of the SVR_DATA.ntf message sent by DUT to
+     *     Leader.
+     */
+    Log("Step 6: BR_1 (DUT) chooses OMR_2 and registers in Network Data.");
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Ip6::Prefix omr2;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omr2));
+    VerifyOrQuit(omr2 != omr1);
+    nexus.AddTestVar("OMR_2_PREFIX", omr2.ToString().AsCString());
+    Log("OMR_2_PREFIX: %s", omr2.ToString().AsCString());
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 7
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+     * - Pass Criteria:
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *     - IPv6 destination MUST be ff02::1
+     *     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *     - MUST contain a Route Information Option (RIO) with OMR_2.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low).
+     *     - MUST NOT contain a Route Information Option (RIO) with OMR_1.
+     */
+    Log("Step 7: BR_1 (DUT) multicasts ND RA with OMR_2, no OMR_1.");
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 8
+     * - Device: Eth_1
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to send an ICMPv6 Echo Request to ED_1 via either one of BR_1 or BR_2.
+     *     - IPv6 Source: Eth_1 GUA
+     *     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+     * - Pass Criteria:
+     *   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+     *   - IPv6 Source: ED_1 OMR (using prefix OMR_2)
+     *   - IPv6 Destination: Eth_1 GUA
+     */
+    Log("Step 8: Eth_1 pings ED_1 OMR_2.");
+
+    ed1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+    const Ip6::Address &ed1Omr2 = ed1.FindMatchingAddress(omr2.ToString().AsCString());
+    nexus.AddTestVar("ED_1_OMR_2_ADDR", ed1Omr2.ToString().AsCString());
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr2, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 9
+     * - Device: ED_1
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+     *     - IPv6 Source: ED_1 OMR address (using prefix OMR_2)
+     *     - IPv6 Destination: Eth_1 GUA
+     * - Pass Criteria:
+     *   - ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+     *     - IPv6 Source: Eth_1 GUA
+     *     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+     */
+    Log("Step 9: ED_1 OMR_2 pings Eth_1 GUA.");
+
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kDefaultHopLimit, &ed1Omr2);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 10
+     * - Device: BR_2
+     * - Description (DBR-1.8):
+     *   - Harness instructs BR_2 to add an OMR prefix OMR_3 numerically lower than OMR_2, but with same preference
+     *     "Low".
+     *   - Note: can use OT command 'netdata publish prefix' for this.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: BR_2 adds OMR_3.");
+
+    Ip6::Prefix omr3;
+    SuccessOrQuit(omr3.FromString(kOmr3PrefixStr));
+
+    PublishOmrPrefix(br2, omr3, NetworkData::kRoutePreferenceLow, true);
+
+    nexus.AdvanceTime(kLongActionTime);
+
+    WaitForPrefix(nexus, br2, omr3, true);
+    DumpNetworkData(br2);
+
+    nexus.AddTestVar("OMR_3_PREFIX", kOmr3PrefixStr);
+    Log("OMR_3_PREFIX: %s", kOmr3PrefixStr);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 11
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically withdraws its own OMR prefix OMR_2 from the Thread Network Data.
+     * - Pass Criteria:
+     *   - The DUT MUST register new Network Data:
+     *   - MUST NOT contain any of the OMR prefixes OMR_1, OMR_2, or OMR_3 in a Prefix TLV.
+     *   - MUST contain a Prefix TLV:
+     *     - Prefix = ::/0
+     *     - Domain ID <same value as BR_2 Domain ID>
+     *     - Has Route TLV
+     *       - R_preference = 00 (medium) or 11 (low)
+     */
+    Log("Step 11: BR_1 (DUT) withdraws OMR_2 from Network Data.");
+
+    WaitForPrefix(nexus, br2, omr2, false);
+    nexus.AdvanceTime(kLongActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 12
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically multicasts ND RAs on Adjacent Infrastructure Link including OMR_2/OMR_3.
+     *   - Note: the reason that OMR_2 is still included initially, is that OMR_2 has been created by BR_1 originally
+     *     and as seen by BR_1, it stays in a deprecated state for at most OMR_ADDR_DEPRECATION_TIME
+     * - Pass Criteria:
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *     - IPv6 destination MUST be ff02::1
+     *     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *     - MUST NOT contain a Route Information Option (RIO) with OMR_1.
+     *     - MUST contain a Route Information Option (RIO) with OMR_2.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low)
+     *     - MUST contain a Route Information Option (RIO) with OMR_3.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low)
+     */
+    Log("Step 12: BR_1 (DUT) multicasts ND RA with OMR_2 and OMR_3.");
+
+    nexus.AdvanceTime(kBrActionTime);
+    DumpNetworkData(br1);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 13
+     * - Device: Eth_1
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to send an ICMPv6 Echo Request to ED_1 via BR_1 or BR_2.
+     *     - IPv6 Source: Eth_1 GUA
+     *     - IPv6 Destination: ED_1 OMR address (using prefix OMR_3)
+     * - Pass Criteria:
+     *   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+     *     - IPv6 Source: ED_1 OMR (using prefix OMR_3)
+     *     - IPv6 Destination: Eth_1 GUA
+     */
+    Log("Step 13: Eth_1 pings ED_1 OMR_3.");
+
+    const Ip6::Address &ed1Omr3 = ed1.FindMatchingAddress(kOmr3PrefixStr);
+    nexus.AddTestVar("ED_1_OMR_3_ADDR", ed1Omr3.ToString().AsCString());
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr3, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 14
+     * - Device: ED_1
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+     *     - IPv6 Source: ED_1 OMR address (using prefix OMR_3)
+     *     - IPv6 Destination: Eth_1 GUA
+     * - Pass Criteria:
+     *   - ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+     *     - IPv6 Source: Eth_1 GUA
+     *     - IPv6 Destination: ED_1 OMR address (using prefix OMR_3)
+     */
+    Log("Step 14: ED_1 OMR_3 pings Eth_1 GUA.");
+
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kDefaultHopLimit, &ed1Omr3);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 15
+     * - Device: BR_2
+     * - Description (DBR-1.8):
+     *   - Harness instructs device to remove existing OMR prefix (OMR_3) that is being advertised and sends out new
+     *     network data without the prefix OMR_3.
+     *   - Note: can use OT command 'netdata unpublish <prefix>' for this.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 15: BR_2 removes OMR_3.");
+
+    SuccessOrQuit(br2.Get<NetworkData::Publisher>().UnpublishPrefix(omr3));
+    WaitForPrefix(nexus, br2, omr3, false);
+
+    nexus.AdvanceTime(kLongActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 16
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically starts advertising its own OMR prefix OMR_2 again.
+     * - Pass Criteria:
+     *   - The DUT MUST register its OMR prefix OMR_2 in the Thread Network Data. Flags in the Border Router sub-TLV
+     *     MUST be:
+     *     - P_preference = 11 (Low)
+     *     - P_default = true
+     *     - P_stable = true
+     *     - P_on_mesh = true
+     *     - P_preferred = true
+     *     - P_slaac = true
+     *     - P_dhcp = false
+     *     - P_dp = false
+     *   - OMR_2 MUST be 64 bits long and start with 0xFD.
+     *   - OMR_2 MUST be equal to the OMR_2 values as used in previous test steps.
+     */
+    Log("Step 16: BR_1 (DUT) re-registers OMR_2.");
+
+    WaitForPrefix(nexus, br2, omr2, true);
+
+    nexus.AdvanceTime(kBrActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 17
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically multicasts ND RAs on Adjacent Infrastructure Link with OMR_2.
+     *   - Note: the reason that OMR_3 is not advertised, is that OMR_3 was withdrawn and was not originally created by
+     *     BR_1. So BR_1 has no responsibility to advertise this deprecated prefix.
+     * - Pass Criteria:
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *     - IPv6 destination MUST be ff02::1
+     *     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *     - MUST NOT contain a Route Information Option (RIO) with OMR_1.
+     *     - MUST contain a Route Information Option (RIO) with OMR_2.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low)
+     *     - MUST NOT contain a Route Information Option (RIO) with OMR_3.
+     */
+    Log("Step 17: BR_1 (DUT) multicasts ND RA with OMR_2, no OMR_3.");
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 18
+     * - Device: BR_2
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to add new OMR prefix (OMR_4) numerically higher than OMR_2, but with same
+     *     preference "Low".
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 18: BR_2 adds OMR_4.");
+
+    Ip6::Prefix omr4;
+    SuccessOrQuit(omr4.FromString(kOmr4PrefixStr));
+
+    PublishOmrPrefix(br2, omr4, NetworkData::kRoutePreferenceLow, true);
+
+    WaitForPrefix(nexus, br2, omr4, true);
+
+    nexus.AdvanceTime(kLongActionTime);
+    nexus.AddTestVar("OMR_4_PREFIX", kOmr4PrefixStr);
+    Log("OMR_4_PREFIX: %s", kOmr4PrefixStr);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 19
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - No change in behavior for advertising its OMR prefix, OMR_2.
+     *   - Automatically starts advertising a route for the new OMR_4 prefix.
+     * - Pass Criteria:
+     *   - The DUT MUST NOT send a Network Data update with any one of the following prefixes in a Prefix TLV:
+     *     - OMR_1, OMR_2, OMR_3, OMR_4
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *     - IPv6 destination MUST be ff02::1
+     *     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *     - MUST contain a Route Information Option (RIO) with OMR_2.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low).
+     *     - MUST contain a Route Information Option (RIO) with OMR_4.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low).
+     *     - MUST NOT contain RIO with any one of OMR_1, OMR_3.
+     */
+    Log("Step 19: BR_1 (DUT) continues OMR_2, adds OMR_4 on AIL.");
+
+    nexus.AdvanceTime(kBrActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 20
+     * - Device: BR_2
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to update the numerically higher OMR_4 with a higher preference value 00
+     *     ('Medium').
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 20: BR_2 updates OMR_4 to Medium pref.");
+
+    PublishOmrPrefix(br2, omr4, NetworkData::kRoutePreferenceMedium, true);
+
+    nexus.AdvanceTime(kLongActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 21
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8): Automatically withdraws its current OMR prefix OMR_2.
+     * - Pass Criteria:
+     *   - The DUT MUST send a Network Data update. In the new network data:
+     *     - the following prefixes MUST NOT be present in a Prefix TLV: OMR_1, OMR_2, OMR_3
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *     - IPv6 destination MUST be ff02::1
+     *     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *     - MUST contain a Route Information Option (RIO) with OMR_2:
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low).
+     *     - MUST contain a Route Information Option (RIO) with OMR_4:
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low).
+     *     - MUST NOT contain a Route Information Option (RIO) with OMR_1 or OMR_3.
+     */
+    Log("Step 21: BR_1 (DUT) withdraws OMR_2.");
+
+    WaitForPrefix(nexus, br2, omr2, false);
+
+    nexus.AdvanceTime(kBrActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 22
+     * - Device: BR_2
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to update the prefix OMR_4 to a deprecated state by setting P_preferred =
+     *     'false'.
+     *   - Note: can use OT command 'netdata publish prefix' for this where the flags do not contain the 'p' (Preferred)
+     *     flag.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 22: BR_2 deprecates OMR_4.");
+
+    PublishOmrPrefix(br2, omr4, NetworkData::kRoutePreferenceMedium, false);
+
+    nexus.AdvanceTime(kLongActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 23
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8): Automatically starts advertising its own OMR prefix OMR_2 again.
+     * - Pass Criteria:
+     *   - The DUT MUST register its OMR prefix OMR_2 in the Thread Network Data. Flags in the Border Router sub-TLV
+     *     MUST be:
+     *     - P_preference = 11 (Low)
+     *     - P_default = true
+     *     - P_stable = true
+     *     - P_on_mesh = true
+     *     - P_preferred = true
+     *     - P_slaac = true
+     *     - P_dhcp = false
+     *     - P_dp = false
+     *   - OMR_2 MUST be 64 bits long and start with 0xFD.
+     *   - OMR_2 MUST be equal to the OMR_2 values as used in previous test steps.
+     */
+    Log("Step 23: BR_1 (DUT) re-registers OMR_2.");
+
+    WaitForPrefix(nexus, br2, omr2, true);
+
+    nexus.AdvanceTime(kBrActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 24
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+     *   - Note: even though OMR_4 is deprecated, it is still valid so it is advertised on the AIL in a RIO.
+     * - Pass Criteria:
+     *   - The DUT MUST multicast ND RAs on the infrastructure link:
+     *     - IPv6 destination MUST be ff02::1
+     *     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+     *     - MUST NOT contain a Route Information Option (RIO) with OMR_1.
+     *     - MUST contain a Route Information Option (RIO) with OMR_2.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low)
+     *     - MUST NOT contain a Route Information Option (RIO) with OMR_3.
+     *     - MUST contain a Route Information Option (RIO) with OMR_4.
+     *       - "Prf" bits MUST be 00 (medium) or 11 (low)
+     */
+    Log("Step 24: BR_1 (DUT) multicasts ND RA with OMR_2 and OMR_4.");
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 25
+     * - Device: Eth_1
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to send an ICMPv6 Echo Request to ED_1 via BR_1.
+     *     - IPv6 Source: Eth_1 GUA
+     *     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+     * - Pass Criteria:
+     *   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+     *     - IPv6 Source: ED_1 OMR (using prefix OMR_2)
+     *     - IPv6 Destination: Eth_1 GUA
+     */
+    Log("Step 25: Eth_1 pings ED_1 OMR_2.");
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr2, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 26
+     * - Device: ED_1
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+     *     - IPv6 Source: ED_1 OMR address (using prefix OMR_2)
+     *     - IPv6 Destination: Eth_1 GUA
+     * - Pass Criteria:
+     *   - ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+     *     - IPv6 Source: Eth_1 GUA
+     *     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+     */
+    Log("Step 26: ED_1 OMR_2 pings Eth_1 GUA.");
+
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kDefaultHopLimit, &ed1Omr2);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 27
+     * - Device: BR_2
+     * - Description (DBR-1.8):
+     *   - Harness disables the BR function of the device. Or if that is not possible, switch it off.
+     *   - Note: disabling BR may use 'br disable' CLI command.
+     *   - Note: in case of switching off BR_2, the OMR prefix OMR_4 that was advertised by BR_2 remains active for some
+     *     time. The Leader timeout of Leader BR_2 does not happen yet during the below steps of this test.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 27: BR_2 disabled.");
+
+    SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().SetEnabled(false));
+    nexus.AdvanceTime(kBrActionTime);
+    DumpNetworkData(br2);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 28
+     * - Device: Eth_1
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to send an ICMPv6 Echo Request to ED_1 via BR_1.
+     *     - IPv6 Source: Eth_1 GUA
+     *     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+     * - Pass Criteria:
+     *   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+     *     - IPv6 Source: ED_1 OMR (using prefix OMR_2)
+     *     - IPv6 Destination: Eth_1 GUA
+     */
+    Log("Step 28: Eth_1 pings ED_1 OMR_2.");
+
+    eth1.mInfraIf.SendEchoRequest(eth1Gua, ed1Omr2, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 29
+     * - Device: ED_1
+     * - Description (DBR-1.8):
+     *   - Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+     *     - IPv6 Source: ED_1 OMR address (using prefix OMR_4)
+     *     - IPv6 Destination: Eth_1 GUA.
+     *   - Note: ED_1 is forced here to use a source address based on prefix OMR_4, even though that prefix is
+     *     deprecated. In OT CLI this can most likely be achieved using the command:
+     *     - ping -I <ED_1-OMR_4-address> <Eth_1-GUA>
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 29: ED_1 pings Eth_1 using OMR_4.");
+
+    const Ip6::Address &ed1Omr4 = ed1.FindMatchingAddress(kOmr4PrefixStr);
+    nexus.AddTestVar("ED_1_OMR_4_ADDR", ed1Omr4.ToString().AsCString());
+    ed1.SendEchoRequest(eth1Gua, kEchoIdentifier, kEchoPayloadSize, kDefaultHopLimit, &ed1Omr4);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 30
+     * - Device: Eth_1
+     * - Description (DBR-1.8):
+     *   - Automatically replies to the ICMPv6 Echo Request with Echo Reply, that is sent to BR_1.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 30: Eth_1 replies to OMR_4.");
+
+    nexus.AdvanceTime(kPingResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 31
+     * - Device: BR_1 (DUT)
+     * - Description (DBR-1.8):
+     *   - Automatically attempts to deliver the Echo Reply to a node on the mesh.
+     * - Pass Criteria:
+     *   - The DUT MUST attempt to deliver the packet to a node on the mesh, either:
+     *     - 1. BR_1 sends a multicast Address Query for ED_1 OMR_4 based address into the mesh;
+     *     - 2. or ED_1 receives the Echo Reply from Eth_1 as follows:
+     *       - IPv6 Source: Eth_1 GUA
+     *       - IPv6 Destination: ED_1 OMR address (using prefix OMR_4)
+     */
+    Log("Step 31: BR_1 delivers Echo Reply for OMR_4.");
+
+    nexus.AdvanceTime(kPingResponseTime);
+
+    nexus.SaveTestInfo("test_1_3_DBR_TC_8.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_3_DBR_TC_8();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DIAG_TC_1.cpp b/tests/nexus/test_1_3_DIAG_TC_1.cpp
new file mode 100644
index 0000000..482573d
--- /dev/null
+++ b/tests/nexus/test_1_3_DIAG_TC_1.cpp
@@ -0,0 +1,412 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/net_diag.hpp"
+#include "utils/mesh_diag.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 15 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the diagnostic response to be received.
+ */
+static constexpr uint32_t kDiagResponseTime = 5 * 1000;
+
+/**
+ * MLE Timeout for SED_1 in seconds.
+ */
+static constexpr uint32_t kSed1Timeout = 312;
+
+static void SaveOmr(Core &aNexus, const char *aName, Node &aNode)
+{
+    Ip6::Prefix omrPrefix;
+    IgnoreError(omrPrefix.FromString("fd00:db8::/64"));
+    for (const Ip6::Netif::UnicastAddress &addr : aNode.Get<Ip6::Netif>().GetUnicastAddresses())
+    {
+        if (addr.GetAddress().MatchesPrefix(omrPrefix))
+        {
+            aNexus.AddTestVar(aName, addr.GetAddress().ToString().AsCString());
+            return;
+        }
+    }
+    aNexus.AddTestVar(aName, "");
+}
+
+void TestDiagTc1(const char *aJsonFileName)
+{
+    /**
+     * 4.1. [1.4] [CERT] Get Diagnostics and Child Information - Router
+     *
+     * 4.1.2. Topology
+     * - Router_1 (DUT) – Thread Router or Thread Border Router (BR)
+     * - FED_1 – FED reference device (can be any Thread version)
+     * - MED_1 – MED reference device (can be any Thread version)
+     * - SED_1 – SED reference device (can be any Thread version)
+     * - REED_1 – REED reference device, configured to not upgrade to Router role (can be any Thread version)
+     * - Leader – Thread (BR or non-BR) reference device configured as Leader (Thread v1.3.x device).
+     *
+     * Spec Reference   | Section
+     * -----------------|---------
+     * Thread 1.4       | 4.1
+     */
+
+    Core nexus;
+
+    Node &leader  = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &fed1    = nexus.CreateNode();
+    Node &med1    = nexus.CreateNode();
+    Node &sed1    = nexus.CreateNode();
+    Node &reed1   = nexus.CreateNode();
+
+    leader.SetName("Leader");
+    router1.SetName("Router_1");
+    fed1.SetName("FED_1");
+    med1.SetName("MED_1");
+    sed1.SetName("SED_1");
+    reed1.SetName("REED_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    AllowLinkBetween(router1, leader);
+    AllowLinkBetween(router1, fed1);
+    AllowLinkBetween(router1, med1);
+    AllowLinkBetween(router1, sed1);
+    AllowLinkBetween(router1, reed1);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Enable the devices in order. Leader configures its Network Data with an OMR prefix.");
+
+    /**
+     * Step 1
+     * - Device: Leader, Router_1 (DUT)
+     * - Description (DIAG-4.1): Enable the devices in order. The remaining (child) devices are not yet activated.
+     *   Leader configures its Network Data with an OMR prefix. Note: the OMR prefix can be added using an OT CLI
+     *   command such as: "prefix add 2001:dead:beef:cafe::/64 paros med"
+     * - Pass Criteria:
+     *   - Single Thread Network is formed with Leader and the DUT
+     */
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router1.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+
+    NetworkData::OnMeshPrefixConfig omrPrefixConfig;
+    SuccessOrQuit(omrPrefixConfig.GetPrefix().FromString("fd00:db8::/64"));
+    omrPrefixConfig.mPreferred    = true;
+    omrPrefixConfig.mSlaac        = true;
+    omrPrefixConfig.mDhcp         = false;
+    omrPrefixConfig.mDefaultRoute = true;
+    omrPrefixConfig.mOnMesh       = true;
+    omrPrefixConfig.mStable       = true;
+    omrPrefixConfig.mPreference   = NetworkData::kRoutePreferenceMedium;
+    SuccessOrQuit(leader.Get<NetworkData::Local>().AddOnMeshPrefix(omrPrefixConfig));
+    leader.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(30000); // Wait for network data propagation
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Leader sends DIAG_GET.req to DUT's RLOC for multiple Diagnostic TLV types.");
+
+    /**
+     * Step 2
+     * - Device: Leader
+     * - Description (DIAG-4.1): Harness instructs device to send DIAG_GET.req to DUT's RLOC for the following
+     *   Diagnostic TLV types: Type 19: Max Child Timeout TLV, Type 23: EUI-64 TLV, Type 24: Version TLV, Type 25:
+     *   Vendor Name TLV, Type 26: Vendor Model TLV, Type 27: Vendor SW Version TLV, Type 28: Thread Stack Version TLV
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Ip6::Address dutRloc = router1.Get<Mle::Mle>().GetMeshLocalRloc();
+
+    uint8_t tlvTypesStep2[] = {
+        NetDiag::Tlv::kMaxChildTimeout,    NetDiag::Tlv::kEui64,       NetDiag::Tlv::kVersion,
+        NetDiag::Tlv::kVendorName,         NetDiag::Tlv::kVendorModel, NetDiag::Tlv::kVendorSwVersion,
+        NetDiag::Tlv::kThreadStackVersion,
+    };
+    SuccessOrQuit(leader.Get<NetDiag::Client>().SendDiagnosticGet(dutRloc, tlvTypesStep2, sizeof(tlvTypesStep2),
+                                                                  nullptr, nullptr));
+
+    /**
+     * Step 3
+     * - Device: Router_1 (DUT)
+     * - Description (DIAG-4.1): Automatically responds with DIAG_GET.rsp containing the requested Diagnostic TLVs.
+     * - Pass Criteria:
+     *   - The DUT MUST respond with DIAG_GET.rsp
+     *   - The presence of each TLV (as requested in step 2) MUST be validated, with the exception of Max Child Timeout
+     *     TLV (Type 19) which MAY be present.
+     *   - Value of Max Child Timeout TLV (Type 19), if present, MUST be '0' (zero).
+     *   - Value of EUI-64 TLV (Type 23) MUST be non-zero.
+     *   - Value of Version TLV (Type 24) MUST be >= '5' .
+     *   - Value of Vendor Name TLV (Type 25) MUST have length >= 1.
+     *   - Value of Vendor Model TLV (Type 26) MUST have length >= 1.
+     *   - Value of Vendor SW Version TLV (Type 27) MUST have length >= 5.
+     *   - Value of Thread Stack Version TLV (Type 28) MUST have length >= 5.
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Enable devices FED_1, MED_1, SED_1, REED_1 with 1 second delay between each.");
+
+    /**
+     * Step 4
+     * - Device: FED_1, MED_1, SED_1, REED_1
+     * - Description (DIAG-4.1): Enable devices (any order is ok) with 1 second delay between each device enablement.
+     *   SED_1 MUST attach using a specified Supervision Interval TLV with value '129'. (This is the OT default.) Note:
+     *   this 1 second delay is only added to test role time diagnostic values, retrieved in the next step. Note: the
+     *   total time of attaching the devices as Children is expected to be not longer than ~ 10 seconds. If longer,
+     *   failures may result on some checks like "Age" in step 8.
+     * - Pass Criteria:
+     *   - Single Thread Network is formed between all devices. Wait until all reference devices attached.
+     *   - DUT MUST allow all end devices to attach quickly (if still in REED role, the DUT will automatically upgrade
+     *     to Router role first).
+     */
+    // C1: device SED_1 is configured with MLE timeout 312.
+    sed1.Get<Mle::Mle>().SetTimeout(kSed1Timeout);
+
+    // REED_1 configured to not upgrade to Router role.
+    SuccessOrQuit(reed1.Get<Mle::Mle>().SetRouterEligible(false));
+
+    fed1.Join(router1, Node::kAsFed);
+    nexus.AdvanceTime(1000);
+    med1.Join(router1, Node::kAsMed);
+    nexus.AdvanceTime(1000);
+    sed1.Join(router1, Node::kAsSed);
+    nexus.AdvanceTime(1000);
+    reed1.Join(router1, Node::kAsFed);
+
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(fed1.Get<Mle::Mle>().IsAttached());
+    VerifyOrQuit(med1.Get<Mle::Mle>().IsAttached());
+    VerifyOrQuit(sed1.Get<Mle::Mle>().IsAttached());
+    VerifyOrQuit(reed1.Get<Mle::Mle>().IsAttached());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Leader sends DIAG_GET.req unicast to DUT's RLOC for Max Child Timeout and MLE Counters TLVs.");
+
+    /**
+     * Step 5
+     * - Device: Leader
+     * - Description (DIAG-4.1): Harness instructs device to send DIAG_GET.req unicast to DUT's RLOC for the following
+     *   Diagnostic TLV types: Type 19: Max Child Timeout TLV, Type 34: MLE Counters TLV Note: this step must be
+     *   performed as soon as possible after the previous step.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    uint8_t tlvTypesStep5[] = {NetDiag::Tlv::kMaxChildTimeout, NetDiag::Tlv::kMleCounters};
+    SuccessOrQuit(leader.Get<NetDiag::Client>().SendDiagnosticGet(dutRloc, tlvTypesStep5, sizeof(tlvTypesStep5),
+                                                                  nullptr, nullptr));
+
+    /**
+     * Step 6
+     * - Device: Router_1 (DUT)
+     * - Description (DIAG-4.1): Automatically responds with DIAG_GET.rsp containing the requested Diagnostic TLVs.
+     * - Pass Criteria:
+     *   - The DUT MUST respond with DIAG_GET.rsp
+     *   - Presence of each TLV (as requested in step 5) MUST be validated.
+     *   - Value of Max Child Timeout TLV (Type 19) MUST be '312'.
+     *   - In the MLE Counters TLV (Type 34): Radio Disabled Counter MUST be '0', Detached Role Counter MUST be '1',
+     *     Child Role Counter MUST be '1', Router Role Counter MUST be '1', Leader Role Counter MUST be '0', Attach
+     *     Attempts Counter MUST be >= 1, Partition ID Changes Counter MUST be '1', New Parent Counter MUST be '0',
+     *     Total Tracking Time MUST be > 4000, Child Role Time MUST be >= 1, Router Role Time MUST be > 3000, Leader
+     *     Role Time MUST be '0' Note: tuning above time conditions can still be done later (TBD), after the script has
+     *     been run a few times and tighter values can be determined based on the Pcap file.
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Leader sends DIAG_GET.qry unicast to DUT's RLOC for Child TLV.");
+
+    /**
+     * Step 7
+     * - Device: Leader
+     * - Description (DIAG-4.1): Harness instructs device to send DIAG_GET.qry unicast to DUT's RLOC for the following
+     *   Diagnostic TLV types: Type 29: Child TLV Note: this can done using the OT CLI command “meshdiag childtable”
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    // Force traffic from children to reset Age before Query
+    Node *nodes[] = {&fed1, &med1, &sed1, &reed1};
+    for (Node *node : nodes)
+    {
+        node->SendEchoRequest(router1.Get<Mle::Mle>().GetMeshLocalRloc());
+    }
+    nexus.AdvanceTime(2000);
+
+    SuccessOrQuit(leader.Get<Utils::MeshDiag>().QueryChildTable(router1.Get<Mle::Mle>().GetRloc16(), nullptr, nullptr));
+
+    /**
+     * Step 8
+     * - Device: Router_1 (DUT)
+     * - Description (DIAG-4.1): Automatically responds with DIAG_GET.ans unicast with Child information for all its
+     *   children.
+     * - Pass Criteria:
+     *   - The DUT MUST respond with DIAG_GET.rsp
+     *   - The response MUST contain four (4) times a Child TLV (type 29) with following fields per TLV. The order is
+     *     not important.
+     *   - Child TLV for FED_1: R == 1, D == 1, N == 1, C == 0, E MAY be 0 or 1, RLOC16 == <RLOC16 of FED_1>, Extended
+     *     Address == <Extended Address of FED_1>, Thread Version == <Thread Version of FED_1>, Timeout == <configured
+     *     Child Timeout value of FED_1>, Age MUST be <= 20, Connection Time MUST be <= 20, Supervision Interval == 0,
+     *     Link Margin MUST be > 10 and MUST be < 120, Average RSSI MUST be < 0 and MUST be > -120, Last RSSI MUST be <
+     *     0 and MUST be > -120, Frame Error Rate (In case E == 0, field MUST be ‘0’, In case E == 1, field MUST be <
+     *     0x8000), Message Error Rate (In case E == 0, field MUST be ‘0’, In case E == 1, field MUST be < 0x8000),
+     *     Queued Message Count == 0, CSL Period == 0, CSL Timeout == 0, CSL Channel == 0
+     *   - Child TLV for MED_1: R == 1, D == 0, N MAY be 0 or 1, C == 0, E MUST be same value as ‘E’ for FED_1, RLOC16
+     *     == <RLOC16 of MED_1>, Extended Address == <Extended Address of MED_1>, Thread Version == <Thread Version of
+     *     MED_1>, Timeout == <configured Child Timeout value of MED_1>, Age MUST be <= 20, Connection Time MUST be <=
+     *     20, Supervision Interval == 0, Link Margin MUST be > 10 and MUST be < 120, Average RSSI MUST be < 0 and MUST
+     *     be > -120, Last RSSI MUST be < 0 and MUST be > -120, Frame Error Rate (In case E == 0, field MUST be ‘0’, In
+     *     case E == 1, field MUST be < 0x8000), Message Error Rate (In case E == 0, field MUST be ‘0’, In case E == 1,
+     *     field MUST be < 0x8000), Queued Message Count == 0, CSL Period == 0, CSL Timeout == 0, CSL Channel == 0
+     *   - Child TLV for SED_1: R == 0, D == 0, N MAY be 0 or 1, C == 0, E MUST be same value as ‘E’ for FED_1, RLOC16
+     *     == <RLOC16 of SED_1>, Extended Address == <Extended Address of SED_1>, Thread Version == <Thread Version of
+     *     SED_1>, Timeout == 312, Age MUST be <= 20, Connection Time MUST be <= 20, Supervision Interval == 129, Link
+     *     Margin MUST be > 10 and MUST be < 120, Average RSSI MUST be < 0 and MUST be > -120, Last RSSI MUST be < 0
+     *     and MUST be > -120, Frame Error Rate (In case E == 0, field MUST be ‘0’, In case E == 1, field MUST be <
+     *     0x8000), Message Error Rate (In case E == 0, field MUST be ‘0’, In case E == 1, field MUST be < 0x8000),
+     *     Queued Message Count MUST be < 10, CSL Period == 0, CSL Timeout == 0, CSL Channel == 0
+     *   - Child TLV for REED_1: R == 1, D == 1, N == 1, C == 0, E MUST be same value as ‘E’ for FED_1, RLOC16 ==
+     *     <RLOC16 of REED_1>, Extended Address == <Extended Address of REED_1>, Thread Version == <Thread Version of
+     *     REED_1>, Timeout == <configured Child Timeout value of REED_1>, Age MUST be <= 20, Connection Time MUST be
+     *     <= 20, Supervision Interval == 0, Link Margin MUST be > 10 and MUST be < 120, Average RSSI MUST be < 0 and
+     *     MUST be > -120, Last RSSI MUST be < 0 and MUST be > -120, Frame Error Rate (In case E == 0, field MUST be
+     *     ‘0’, In case E == 1, field MUST be < 0x8000), Message Error Rate (In case E == 0, field MUST be ‘0’, In case
+     *     E == 1, field MUST be < 0x8000), Queued Message Count == 0, CSL Period == 0, CSL Timeout == 0, CSL Channel
+     *     == 0
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Leader sends DIAG_GET.qry unicast to DUT's RLOC for Child IPv6 Address List TLV.");
+
+    /**
+     * Step 9
+     * - Device: Leader
+     * - Description (DIAG-4.1): Harness instructs device to send DIAG_GET.qry unicast to DUT's RLOC for the following
+     *   Diagnostic TLV types: Type 30: Child IPv6 Address List TLV Note: this can done using the OT CLI command
+     *   “meshdiag childip6”.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    SuccessOrQuit(
+        leader.Get<Utils::MeshDiag>().QueryChildrenIp6Addrs(router1.Get<Mle::Mle>().GetRloc16(), nullptr, nullptr));
+
+    /**
+     * Step 10
+     * - Device: Router_1 (DUT)
+     * - Description (DIAG-4.1): Automatically responds with DIAG_GET.ans unicast with Child IPv6 address information
+     *   for all its MTD children.
+     * - Pass Criteria:
+     *   - The DUT MUST respond with DIAG_GET.rsp
+     *   - The response MUST contain four (2) times a Child IPv6 Address TLV (type 30) with following fields per TLV.
+     *     The order is not important.
+     *   - Child IPv6 TLV for MED_1: RLOC16 == <RLOC16 of MED_1>, The IPv6 Address N fields MUST include: ML-EID of
+     *     MED_1, OMR Address of MED_1, The IPv6 Address N fields MUST NOT include an RLOC.
+     *   - Child IPv6 TLV for SED_1: RLOC16 == <RLOC16 of SED_1>, The IPv6 Address N fields MUST include: ML-EID of
+     *     SED_1, OMR Address of SED_1, The IPv6 Address N fields MUST NOT include an RLOC.
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 11: Leader sends DIAG_GET.qry unicast to DUT's RLOC for Router Neighbor TLV.");
+
+    /**
+     * Step 11
+     * - Device: Leader
+     * - Description (DIAG-4.1): Harness instructs device to send DIAG_GET.qry unicast to DUT's RLOC for the following
+     *   Diagnostic TLV types: Type 31: Router Neighbor TLV Note: this can done using the OT CLI command “meshdiag
+     *   routerneighbortable”.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    SuccessOrQuit(
+        leader.Get<Utils::MeshDiag>().QueryRouterNeighborTable(router1.Get<Mle::Mle>().GetRloc16(), nullptr, nullptr));
+
+    /**
+     * Step 12
+     * - Device: Router_1 (DUT)
+     * - Description (DIAG-4.1): Automatically responds with DIAG_GET.ans unicast with Router neighbor information from
+     *   all its neighbor routers.
+     * - Pass Criteria:
+     *   - The DUT MUST respond with DIAG_GET.rsp
+     *   - THe Response MUST contain one (1) Router Neighbor TLV, with the following values:
+     *   - Leader's Router Neighbor TLV: E MAY be 0 or 1, RLOC16 == <RLOC16 of Leader>, Extended Address == <Extended
+     *     Address of Leader>, Thread Version == <Thread Version of Leader>, Connection Time MUST be > 4, Link Margin
+     *     MUST be > 10 and MUST be < 120, Average RSSI MUST be < 0 and MUST be > -120, Last RSSI MUST be < 0 and MUST
+     *     be > -120, Frame Error Rate (In case E == 0, field MUST be ‘0’, In case E == 1, field MUST be < 0x8000),
+     *     Message Error Rate (In case E == 0, field MUST be ‘0’, In case E == 1, field MUST be < 0x8000)
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    nexus.AdvanceTime(120 * 1000); // Wait for addresses to stabilize
+
+    SaveOmr(nexus, "MED_1_OMR", med1);
+    SaveOmr(nexus, "SED_1_OMR", sed1);
+
+    router1.Get<Mle::Mle>().Stop();
+    leader.Get<Mle::Mle>().Stop();
+    fed1.Get<Mle::Mle>().Stop();
+    med1.Get<Mle::Mle>().Stop();
+    sed1.Get<Mle::Mle>().Stop();
+    reed1.Get<Mle::Mle>().Stop();
+    nexus.AdvanceTime(1000);
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    const char *jsonFile = (argc > 1) ? argv[argc - 1] : "test_1_3_DIAG_TC_1.json";
+
+    ot::Nexus::TestDiagTc1(jsonFile);
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DIAG_TC_2.cpp b/tests/nexus/test_1_3_DIAG_TC_2.cpp
new file mode 100644
index 0000000..b2ea5cd
--- /dev/null
+++ b/tests/nexus/test_1_3_DIAG_TC_2.cpp
@@ -0,0 +1,255 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/net_diag.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 15 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the diagnostic response to be received.
+ */
+static constexpr uint32_t kDiagResponseTime = 5 * 1000;
+
+/**
+ * Time to wait in Step 4, in milliseconds.
+ */
+static constexpr uint32_t kWaitTime = 4 * 1000;
+
+void TestDiagTc2(const char *aJsonFile)
+{
+    /**
+     * 4.2. [1.4] [CERT] Get Diagnostics – End Device
+     *
+     * 4.2.2. Topology
+     * - Leader_1
+     * - Router_1
+     * - TD_1 (DUT)
+     *
+     * Spec Reference   | Section
+     * -----------------|---------
+     * Thread 1.4       | 4.2
+     */
+
+    Core nexus;
+
+    Node &leader1 = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &td1     = nexus.CreateNode();
+
+    leader1.SetName("Leader_1");
+    router1.SetName("Router_1");
+    td1.SetName("TD_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    AllowLinkBetween(leader1, router1);
+    AllowLinkBetween(router1, td1);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Enable the devices in order. Leader configures its Network Data with an OMR prefix.");
+
+    /**
+     * Step 1
+     * - Device: Leader, Router_1
+     * - Description (DIAG-4.2): Enable the devices in order. The DUT is not yet activated. Leader
+     *   configures its Network Data with an OMR prefix. Note: the OMR prefix can be added using an
+     *   OT CLI command such as: "prefix add 2001:dead:beef:cafe::/64 paros med"
+     * - Pass Criteria:
+     *   - Single Thread Network is formed with Leader and Router_1.
+     */
+    leader1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader1.Get<Mle::Mle>().IsLeader());
+
+    router1.Join(leader1);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+
+    NetworkData::OnMeshPrefixConfig omrPrefixConfig;
+    SuccessOrQuit(omrPrefixConfig.GetPrefix().FromString("2001:dead:beef:cafe::/64"));
+    omrPrefixConfig.mPreferred    = true;
+    omrPrefixConfig.mSlaac        = true;
+    omrPrefixConfig.mDhcp         = false;
+    omrPrefixConfig.mDefaultRoute = true;
+    omrPrefixConfig.mOnMesh       = true;
+    omrPrefixConfig.mStable       = true;
+    omrPrefixConfig.mPreference   = NetworkData::kRoutePreferenceMedium;
+    SuccessOrQuit(leader1.Get<NetworkData::Local>().AddOnMeshPrefix(omrPrefixConfig));
+    leader1.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(30 * 1000); // Wait for network data propagation
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1b: TD_1 (DUT) Enable.");
+
+    /**
+     * Step 1b
+     * - Device: TD_1 (DUT)
+     * - Description (DIAG-4.2): Enable.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    td1.Join(router1, Node::kAsMed);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(td1.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Leader instructs device to send DIAG_GET.req to DUT's RLOC for Diagnostic TLV types.");
+
+    /**
+     * Step 2
+     * - Device: Leader
+     * - Description (DIAG-4.2): Harness instructs device to send DIAG_GET.req to DUT's RLOC for the
+     *   following Diagnostic TLV types: Type 19: Max Child Timeout TLV, Type 23: EUI-64 TLV, Type
+     *   24: Version TLV, Type 25: Vendor Name TLV, Type 26: Vendor Model TLV, Type 27: Vendor SW
+     *   Version TLV, Type 28: Thread Stack Version TLV
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Ip6::Address td1Rloc = td1.Get<Mle::Mle>().GetMeshLocalRloc();
+
+    uint8_t tlvTypesStep2[] = {
+        NetDiag::Tlv::kMaxChildTimeout,    NetDiag::Tlv::kEui64,       NetDiag::Tlv::kVersion,
+        NetDiag::Tlv::kVendorName,         NetDiag::Tlv::kVendorModel, NetDiag::Tlv::kVendorSwVersion,
+        NetDiag::Tlv::kThreadStackVersion,
+    };
+    SuccessOrQuit(leader1.Get<NetDiag::Client>().SendDiagnosticGet(td1Rloc, tlvTypesStep2, sizeof(tlvTypesStep2),
+                                                                   nullptr, nullptr));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: TD_1 (DUT) Automatically responds either with 1. DIAG_GET.rsp or 2. CoAP response 4.04.");
+
+    /**
+     * Step 3
+     * - Device: TD_1 (DUT)
+     * - Description (DIAG-4.2): Automatically responds either with 1. DIAG_GET.rsp containing the
+     *   requested Diagnostic TLVs, or a particular subset of those TLVs; 2. CoAP response 4.04 Not
+     *   Found in case no diagnostics are supported at all.
+     * - Pass Criteria:
+     *   - For case 1, the CoAP status code MUST be 2.04 Changed.
+     *   - Each TLV (as requested in step 2) that is present in the response MUST be validated. Any
+     *     number of TLVs MAY be present, from 0 to 7, depending on DUT's diagnostics support level.
+     *   - The Max Child Timeout TLV (Type 19) is not expected to be present. However, if present it
+     *     MUST be '0' (zero).
+     *   - Value of EUI-64 TLV (Type 23) MUST be non-zero.
+     *   - Value of Version TLV (Type 24) MUST be >= '5'.
+     *   - Value of Vendor Name TLV (Type 25) MUST have length >= 1.
+     *   - Value of Vendor Model TLV (Type 26) MUST have length >= 1.
+     *   - Value of Vendor SW Version TLV (Type 27) MUST have length >= 5.
+     *   - Value of Thread Stack Version TLV (Type 28) MUST have length >= 5.
+     *   - For case 2, the CoAP status code is 4.04 and TLVs MUST NOT be present.
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Harness waits for 4 seconds.");
+
+    /**
+     * Step 4
+     * - Device: N/A
+     * - Description (DIAG-4.2): Harness waits for 4 seconds. Note: this is done only to influence
+     *   diagnostic Time fields that are verified in later steps.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    nexus.AdvanceTime(kWaitTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Leader instructs device to send DIAG_GET.req unicast to DUT's RLOC for MLE Counters TLV.");
+
+    /**
+     * Step 4
+     * - Device: Leader
+     * - Description (DIAG-4.2): Harness instructs device to send DIAG_GET.req unicast to DUT's RLOC
+     *   for the following Diagnostic TLV types: Type 34: MLE Counters TLV
+     * - Pass Criteria:
+     *   - N/A
+     */
+    uint8_t tlvTypesStep4[] = {NetDiag::Tlv::kMleCounters};
+    SuccessOrQuit(leader1.Get<NetDiag::Client>().SendDiagnosticGet(td1Rloc, tlvTypesStep4, sizeof(tlvTypesStep4),
+                                                                   nullptr, nullptr));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: TD_1 (DUT) Automatically responds with DIAG_GET.rsp containing the requested Diagnostic TLVs.");
+
+    /**
+     * Step 5
+     * - Device: TD_1 (DUT)
+     * - Description (DIAG-4.2): Automatically responds with DIAG_GET.rsp containing the requested
+     *   Diagnostic TLVs.
+     * - Pass Criteria:
+     *   - The DUT must send DIAG_GET.rsp
+     *   - The presence of each TLV (as requested in the previous step) MUST be validated.
+     *   - In the MLE Counters TLV (Type 34):
+     *   - Radio Disabled Counter MUST be '0'
+     *   - Detached Role Counter MUST be '1'
+     *   - Child Role Counter MUST be '1'
+     *   - Router Role Counter MUST be '0'
+     *   - Leader Role Counter MUST be '0'
+     *   - Attach Attempts Counter MUST be >= 1
+     *   - Partition ID Changes Counter MUST be '1'
+     *   - New Parent Counter MUST be '0'
+     *   - Total Tracking Time MUST be > 4000
+     *   - Child Role Time MUST be >= 1
+     *   - Router Role Time MUST be '0'
+     *   - Leader Role Time MUST be '0'
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    nexus.SaveTestInfo(aJsonFile);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    const char *jsonFile = (argc > 1) ? argv[argc - 1] : "test_1_3_DIAG_TC_2.json";
+
+    ot::Nexus::TestDiagTc2(jsonFile);
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DPR_TC_1.cpp b/tests/nexus/test_1_3_DPR_TC_1.cpp
new file mode 100644
index 0000000..44ff96a
--- /dev/null
+++ b/tests/nexus/test_1_3_DPR_TC_1.cpp
@@ -0,0 +1,368 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+#include "net/mdns.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to wait for SRP registration to complete.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 5 * 1000;
+
+/**
+ * Time to wait for DNS response.
+ */
+static constexpr uint32_t kDnsResponseTime = 20 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Service and Host names.
+ */
+static const char kInfraServiceType[]   = "_infra-test._udp";
+static const char kInfraHostName[]      = "host-test-eth";
+static const char kDiscoveryProxyType[] = "_infra-test._udp.default.service.arpa.";
+
+struct ServiceInfo
+{
+    const char *mInstanceName;
+    uint16_t    mPort;
+    const char *mTxtData;
+};
+
+static const ServiceInfo kInfraServices[] = {
+    {"service-test-1", 55551, "\x0Adummy=abcd\x0Fthread-test=a49"},
+    {"service-test-2", 55552, "\x04a=2\x12thread-test=b50_test"},
+    {"service-test-3", 55553, "\x0Ba_dummy=42\x0Cthread-test="},
+    {"service-test-4", 55554, "\x0Dthread-test=1\x15ignorethis=thread-test="},
+    {"service-test-5", 55555,
+     "\x0B"
+     "a88=thread-\x10THREAD-TEST=C51\x14ignorethis=thread-test"},
+};
+
+static void RegisterMdnsServices(Node &aEth1)
+{
+    Dns::Multicast::Core &mdns = aEth1.Get<Dns::Multicast::Core>();
+
+    SuccessOrQuit(mdns.SetEnabled(true, kInfraIfIndex));
+
+    {
+        Dns::Multicast::Core::Host host;
+
+        ClearAllBytes(host);
+        host.mHostName        = kInfraHostName;
+        host.mAddresses       = &aEth1.mInfraIf.GetAddresses()[1]; // ULA address
+        host.mAddressesLength = 1;
+        SuccessOrQuit(mdns.RegisterHost(host, 0, nullptr));
+    }
+
+    for (const ServiceInfo &info : kInfraServices)
+    {
+        Dns::Multicast::Core::Service service;
+
+        ClearAllBytes(service);
+        service.mServiceInstance = info.mInstanceName;
+        service.mServiceType     = kInfraServiceType;
+        service.mHostName        = kInfraHostName;
+        service.mPort            = info.mPort;
+        service.mTxtData         = reinterpret_cast<const uint8_t *>(info.mTxtData);
+        service.mTxtDataLength   = static_cast<uint16_t>(strlen(info.mTxtData));
+        SuccessOrQuit(mdns.RegisterService(service, 0, nullptr));
+    }
+}
+
+void Test_1_3_DPR_TC_1(const char *aJsonFileName)
+{
+    /**
+     * 5.1. [1.3] [CERT] Service discovery of services on Thread and Infrastructure - Single BR
+     *
+     * 5.1.1 Purpose
+     * To test the following:
+     * 1. Discovery proxy on the BR to perform mDNS query on the infrastructure link in the case where it doesn’t have
+     *   any answer.
+     * 2. Respond to unicast DNS queries made by a Thread Device on the Thread interface.
+     * 3. Thread Device can discover services advertised by infrastructure device (mDNS) via BR DUT.
+     * 4. Single BR case
+     *
+     * 5.1.2 Topology
+     * - BR_1 (DUT) - Thread Border Router, and the Leader
+     * - ED_1 - Test Bed device operating as a Thread End Device, attached to BR_1
+     * - Eth_1 - Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference      | V1.1 Section | V1.3.0 Section
+     * --------------------|--------------|---------------
+     * Discovery Proxy     | N/A          | 5.1
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    Srp::Client::Service service;
+    Dns::TxtEntry        txtEntry;
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Device: BR_1 (DUT) Description (DPR-5.1): Enable");
+
+    /**
+     * Step 1
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.1): Enable
+     * - Pass Criteria: N/A
+     */
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Device: Eth_1, ED_1 Description (DPR-5.1): Enable");
+
+    /**
+     * Step 2
+     * - Device: Eth_1, ED_1
+     * - Description (DPR-5.1): Enable
+     * - Pass Criteria: N/A
+     */
+
+    ed1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+    VerifyOrQuit(ed1.Get<Mle::Mle>().IsAttached());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Device: BR_1 (DUT) Description (DPR-5.1): Automatically adds its SRP Server information...");
+
+    /**
+     * Step 3
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.1): Automatically adds its SRP Server information in the Thread Network Data.
+     *   Automatically configures an OMR prefix on the Thread Network. Automatically configures a ULA prefix on the AIL.
+     * - Pass Criteria:
+     *   - The DUT’s SRP Server information MUST appear in the Thread Network Data.
+     *   - It MUST be a DNS/SRP Unicast Dataset, or DNS/SRP Anycast Dataset, or both.
+     */
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Ip6::Prefix omrPrefix;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+    nexus.AddTestVar("OMR_PREFIX", omrPrefix.ToString().AsCString());
+
+    Ip6::Prefix pre1Prefix;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOnLinkPrefix(pre1Prefix));
+    nexus.AddTestVar("PRE_1_PREFIX", pre1Prefix.ToString().AsCString());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Device: Eth_1 Description (DPR-5.1): Harness instructs device to advertise N=5 services...");
+
+    /**
+     * Step 4
+     * - Device: Eth_1
+     * - Description (DPR-5.1): Harness instructs device to advertise N=5 services using mDNS.
+     * - Pass Criteria: N/A
+     */
+
+    RegisterMdnsServices(eth1);
+    nexus.AdvanceTime(kDnsResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Device: ED_1 Description (DPR-5.1): Harness instructs the device to send an SRP Update...");
+
+    /**
+     * Step 5
+     * - Device: ED_1
+     * - Description (DPR-5.1): Harness instructs the device to send an SRP Update to DUT as follows:
+     *   $ORIGIN default.service.arpa. service-test-6._thread-test._udp ( SRV 0 0 55551 host-test-1 TXT
+     *   thread-te=trick=value ) host-test-1 AAAA <OMR address of ED_1>
+     * - Pass Criteria: N/A
+     */
+
+    ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName("host-test-1"));
+    SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+    ClearAllBytes(service);
+    service.mName         = "_thread-test._udp";
+    service.mInstanceName = "service-test-6";
+    service.mPort         = 55551;
+
+    txtEntry.mKey         = "thread-te";
+    txtEntry.mValue       = reinterpret_cast<const uint8_t *>("trick=value");
+    txtEntry.mValueLength = 11;
+
+    service.mTxtEntries    = &txtEntry;
+    service.mNumTxtEntries = 1;
+
+    SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Device: BR_1 (DUT) Description (DPR-5.1): Automatically sends SRP Update Response with SUCCESS");
+
+    /**
+     * Step 6
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.1): Automatically sends SRP Update Response with “SUCCESS”.
+     * - Pass Criteria:
+     *   - The DUT MUST send SRP Update Response with status 0 ( “NoError” ).
+     */
+
+    VerifyOrQuit(ed1.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Device: ED_1 Description (DPR-5.1): Harness instructs the device to unicast DNS query QType=PTR...");
+
+    /**
+     * Step 7
+     * - Device: ED_1
+     * - Description (DPR-5.1): Harness instructs the device to unicast DNS query QType=PTR to the DUT for services
+     *   of type “_infra-test._udp.default.service.arpa.”
+     * - Pass Criteria: N/A
+     */
+
+    const Ip6::Address      &br1Mleid = br1.Get<Mle::Mle>().GetMeshLocalEid();
+    Dns::Client::QueryConfig queryConfig;
+
+    queryConfig.Clear();
+    AsCoreType(&queryConfig.mServerSockAddr.mAddress) = br1Mleid;
+    queryConfig.mServerSockAddr.mPort                 = Dns::ServiceDiscovery::Server::kPort;
+
+    SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kDiscoveryProxyType, nullptr, nullptr, &queryConfig));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Device: BR_1 (DUT) Description (DPR-5.1): Automatically MAY send mDNS query...");
+
+    /**
+     * Step 8
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.1): Automatically MAY send mDNS query on the infrastructure link.
+     * - Pass Criteria: N/A
+     */
+
+    nexus.AdvanceTime(1000);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Device: Eth_1 Description (DPR-5.1): In case DUT queried in step 8, it automatically sends mDNS...");
+
+    /**
+     * Step 9
+     * - Device: Eth_1
+     * - Description (DPR-5.1): In case DUT queried in step 8, it automatically sends mDNS response with its active
+     *   services.
+     * - Pass Criteria: N/A
+     */
+
+    nexus.AdvanceTime(kDnsResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: Device: BR_1 (DUT) Description (DPR-5.1): Automatically sends a DNS response to ED_1");
+
+    /**
+     * Step 10
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.1): Automatically sends a DNS response to ED_1.
+     * - Pass Criteria: N/A (validation of response in the next step)
+     */
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 11: Device: ED_1 Description (DPR-5.1): Receives the DNS response with at least one of the N services");
+
+    /**
+     * Step 11
+     * - Device: ED_1
+     * - Description (DPR-5.1): Receives the DNS response with at least one of the N services.
+     * - Pass Criteria:
+     *   - The DUT must send a valid DNS Response, containing in the Answer records section at least one of the services
+     *     advertised by Eth_1, specifically the below where N is a number out of 1, 2, 3, 4, 5:
+     *     $ORIGIN default.service.arpa. _infra-test._udp ( PTR service-test-N._infra-test._udp )
+     *   - It MAY contain in the Additional records section one or more of the below services identified by number N
+     *     plus the host (AAAA) record:
+     *     $ORIGIN default.service.arpa. service-test-1._infra-test._udp ( SRV 0 0 55551 host-test-eth TXT
+     *     dummy=abcd;thread-test=a49 ) ... host-test-eth AAAA <ULA address of Eth_1>
+     *   - Response MUST NOT contain any AAAA record with a link-local address.
+     *   - Response MUST NOT contain “service-test-6” records.
+     *   - Response MUST NOT contain any “host-test-1” records.
+     */
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    const char *jsonFile = (argc > 1) ? argv[argc - 1] : "test_1_3_DPR_TC_1.json";
+
+    ot::Nexus::Test_1_3_DPR_TC_1(jsonFile);
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_DPR_TC_2.cpp b/tests/nexus/test_1_3_DPR_TC_2.cpp
new file mode 100644
index 0000000..695c12e
--- /dev/null
+++ b/tests/nexus/test_1_3_DPR_TC_2.cpp
@@ -0,0 +1,481 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 30 * 1000;
+
+/**
+ * Time to wait for SRP registration to complete.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to wait for DNS response.
+ */
+static constexpr uint32_t kDnsResponseTime = 20 * 1000;
+
+/**
+ * Time to wait for mDNS query.
+ */
+static constexpr uint32_t kMdnsQueryTime = 1 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Service Port.
+ */
+static constexpr uint16_t kServicePort = 55555;
+
+/**
+ * SRP Service Weight.
+ */
+static constexpr uint16_t kSrpWeight = 4;
+
+void Test_1_3_DPR_TC_2(void)
+{
+    /**
+     * 5.2. 1.3 CERT Service discovery of services on Thread and Infrastructure - Multiple BRs - Multiple Thread -
+     *   Single infrastructure
+     *
+     * 5.2.1. Purpose
+     *   To test the following:
+     *   - 1. Discovery of on-mesh services between multiple Thread networks attached via an adjacent
+     *     infrastructure link.
+     *   - 2. BR Discovery Proxy function can discover services advertised by another BR’s Advertising Proxy
+     *     function, and vice-versa.
+     *   - 3. Test both PTR and SRV queries from Thread Network, and finding service in other Network.
+     *
+     * 5.2.2. Topology
+     *   - BR_1 (DUT) - Thread Border Router, and Leader
+     *   - BR_2 - Test Bed border router device operating as a Thread Border Router and Leader of an adjacent
+     *     Thread network
+     *   - ED_1 - Test Bed device operating as a Thread End Device, attached to BR_1
+     *   - ED_2 - Test Bed device operating as a Thread End Device, attached to BR_2
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * Discovery Proxy  | N/A          | 5.2
+     */
+
+    Core nexus;
+
+    Node &br1 = nexus.CreateNode();
+    Node &br2 = nexus.CreateNode();
+    Node &ed1 = nexus.CreateNode();
+    Node &ed2 = nexus.CreateNode();
+
+    Srp::Client::Service service1;
+    Srp::Client::Service service2;
+    Ip6::Address         ed1Addresses[2];
+
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    ed1.SetName("ED_1");
+    ed2.SetName("ED_2");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    {
+        Ip6::Prefix        prefix;
+        Ip6::NetworkPrefix networkPrefix;
+
+        SuccessOrQuit(prefix.FromString("fd00:db8::/64"));
+        SuccessOrQuit(networkPrefix.InitFrom(prefix));
+        br1.Get<Mle::Mle>().SetMeshLocalPrefix(networkPrefix);
+        br2.Get<Mle::Mle>().SetMeshLocalPrefix(networkPrefix);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Device: BR_1 (DUT), ED_1 Description (DPR-5.2): Enable");
+
+    /**
+     * Step 1
+     * - Device: BR_1 (DUT), ED_1
+     * - Description (DPR-5.2): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    AllowLinkBetween(br1, ed1);
+
+    br1.Form();
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        SuccessOrQuit(br1.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
+        nexus.AddNetworkKey(datasetInfo.Get<MeshCoP::Dataset::kNetworkKey>());
+    }
+
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsLeader());
+
+    ed1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+    VerifyOrQuit(ed1.Get<Mle::Mle>().IsAttached());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Device: BR_1 (DUT) Description (DPR-5.2): Automatically adds its SRP Server information...");
+
+    /**
+     * Step 2
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.2): Automatically adds its SRP Server information in the Thread Network Data of first
+     *     Thread network.
+     * - Pass Criteria:
+     *   - The DUT’s SRP Server information MUST appear in the Thread Network Data of the first Thread network.
+     */
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Device: ED_1 Description (DPR-5.2): Harness instructs the device to send SRP Update...");
+
+    /**
+     * Step 3
+     * - Device: ED_1
+     * - Description (DPR-5.2): Harness instructs the device to send SRP Update $ORIGIN default.service.arpa.
+     *     service-test-1._thread-type-1._udp ( SRV 0 4 55555 host-test-1 ) host-test-1 AAAA <ML-EID address of ED_1>
+     *     AAAA <OMR address of ED_1> Note: normally an SRP client would only register the OMR address in this case.
+     *     For testing purposes, both OMR/ML-EID are registered which deviates from usual client behavior.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName("host-test-1"));
+
+    ed1Addresses[0] = ed1.Get<Mle::Mle>().GetMeshLocalEid();
+    ed1Addresses[1] = ed1.FindGlobalAddress();
+    SuccessOrQuit(ed1.Get<Srp::Client>().SetHostAddresses(ed1Addresses, 2));
+
+    ClearAllBytes(service1);
+    service1.mName         = "_thread-type-1._udp";
+    service1.mInstanceName = "service-test-1";
+    service1.mPort         = kServicePort;
+    service1.mPriority     = 0;
+    service1.mWeight       = kSrpWeight;
+    SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service1));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Device: BR_1 (DUT) Description (DPR-5.2): Automatically sends SRP Update Response");
+
+    /**
+     * Step 4
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.2): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+     */
+
+    VerifyOrQuit(ed1.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Device: BR_2, ED_2 Description (DPR-5.2): Enable");
+
+    /**
+     * Step 5
+     * - Device: BR_2, ED_2
+     * - Description (DPR-5.2): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    AllowLinkBetween(br2, ed2);
+
+    UnallowLinkBetween(br1, br2);
+    br1.UnallowList(ed2);
+    ed1.UnallowList(br2);
+    ed1.UnallowList(ed2);
+
+    br2.Form();
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        SuccessOrQuit(br2.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
+        nexus.AddNetworkKey(datasetInfo.Get<MeshCoP::Dataset::kNetworkKey>());
+    }
+
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(br2.Get<Mle::Mle>().IsLeader());
+
+    ed2.Join(br2, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+    VerifyOrQuit(ed2.Get<Mle::Mle>().IsAttached());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Device: BR_2 Description (DPR-5.2): Automatically adds its SRP Server information...");
+
+    /**
+     * Step 6
+     * - Device: BR_2
+     * - Description (DPR-5.2): Automatically adds its SRP Server information in the Thread Network Data of the
+     *     second Thread network.
+     * - Pass Criteria:
+     *   - BR_2’s SRP Server information MUST appear in the Thread Network Data of the second Thread network.
+     */
+
+    br2.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br2.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br2.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    SuccessOrQuit(br2.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br2.Get<Srp::Server>().SetEnabled(true);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Device: ED_2 Description (DPR-5.2): Harness instructs the device to send SRP Update...");
+
+    /**
+     * Step 7
+     * - Device: ED_2
+     * - Description (DPR-5.2): Harness instructs the device to send SRP Update $ORIGIN default.service.arpa.
+     *     service-test-2._thread-type-2._udp ( SRV 0 0 55555 host-test-2 ) host-test-2 AAAA <OMR address of ED_2>
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    ed2.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(ed2.Get<Srp::Client>().SetHostName("host-test-2"));
+    SuccessOrQuit(ed2.Get<Srp::Client>().EnableAutoHostAddress());
+
+    ClearAllBytes(service2);
+    service2.mName         = "_thread-type-2._udp";
+    service2.mInstanceName = "service-test-2";
+    service2.mPort         = kServicePort;
+    service2.mPriority     = 0;
+    service2.mWeight       = 0;
+    SuccessOrQuit(ed2.Get<Srp::Client>().AddService(service2));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Device: BR_2 Description (DPR-5.2): Automatically sends SRP Update Response");
+
+    /**
+     * Step 8
+     * - Device: BR_2
+     * - Description (DPR-5.2): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - SRP Update Response is valid and reports NoError (RCODE=0).
+     */
+
+    VerifyOrQuit(ed2.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Device: ED_1 Description (DPR-5.2): Harness instructs the device to unicast DNS query...");
+
+    /**
+     * Step 9
+     * - Device: ED_1
+     * - Description (DPR-5.2): Harness instructs the device to unicast DNS query QType=PTR to the DUT for all
+     *     services of type “_thread-type-2._udp.default.service.arpa.”.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    {
+        Dns::Client::QueryConfig queryConfig;
+        queryConfig.Clear();
+        AsCoreType(&queryConfig.mServerSockAddr.mAddress) = br1.Get<Mle::Mle>().GetMeshLocalEid();
+        queryConfig.mServerSockAddr.mPort                 = Dns::ServiceDiscovery::Server::kPort;
+
+        SuccessOrQuit(
+            ed1.Get<Dns::Client>().Browse("_thread-type-2._udp.default.service.arpa.", nullptr, nullptr, &queryConfig));
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: Device: BR_1 (DUT) Description (DPR-5.2): Automatically sends DNS Response");
+
+    /**
+     * Step 10
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.2): Automatically sends DNS Response. Note: during this step, DUT performs mDNS query to
+     *     BR_2 which responds with service-test-2. This isn’t shown in detail.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response, containing service-test-2 as Answer record: $ORIGIN
+     *     default.service.arpa. _thread-type-2._udp ( PTR service-test-2._thread-type-2._udp )
+     *   - It MAY contain in the Additional records section: $ORIGIN default.service.arpa.
+     *     service-test-2._thread-type-2._udp ( SRV 0 0 55555 host-test-2 ) host-test-2 AAAA <OMR address of ED_2>
+     */
+
+    nexus.AdvanceTime(kDnsResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 11: Device: ED_2 Description (DPR-5.2): Harness instructs the device to unicast DNS query...");
+
+    /**
+     * Step 11
+     * - Device: ED_2
+     * - Description (DPR-5.2): Harness instructs the device to unicast DNS query QType=PTR to BR_2 for all services
+     *     of type “_thread-type-1._udp.default.service.arpa.”.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    {
+        Dns::Client::QueryConfig queryConfig;
+        queryConfig.Clear();
+        AsCoreType(&queryConfig.mServerSockAddr.mAddress) = br2.Get<Mle::Mle>().GetMeshLocalEid();
+        queryConfig.mServerSockAddr.mPort                 = Dns::ServiceDiscovery::Server::kPort;
+
+        SuccessOrQuit(
+            ed2.Get<Dns::Client>().Browse("_thread-type-1._udp.default.service.arpa.", nullptr, nullptr, &queryConfig));
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 12: Device: BR_2 Description (DPR-5.2): Automatically sends mDNS query");
+
+    /**
+     * Step 12
+     * - Device: BR_2
+     * - Description (DPR-5.2): Automatically sends mDNS query.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    nexus.AdvanceTime(kMdnsQueryTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 13: Device: BR_1 (DUT) Description (DPR-5.2): Automatically responds to mDNS query...");
+
+    /**
+     * Step 13
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.2): Automatically responds to mDNS query with “service-test-1”.
+     * - Pass Criteria:
+     *   - N/A (verified in step 14)
+     */
+
+    nexus.AdvanceTime(kDnsResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 14: Device: BR_2 Description (DPR-5.2): Automatically sends a DNS response to ED_2");
+
+    /**
+     * Step 14
+     * - Device: BR_2
+     * - Description (DPR-5.2): Automatically sends a DNS response to ED_2.
+     * - Pass Criteria:
+     *   - The DNS Response is received at ED_2, is valid and contains only service-test-1 in the Answers section:
+     *     $ORIGIN default.service.arpa. _thread-type-1._udp ( PTR service-test-1._thread-type-1._udp )
+     *   - It MAY contain in the Additional records section: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-type-1._udp ( SRV 0 4 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+     *   - Also it MAY contain in the Additional records section, but only if the above Additional records are also
+     *     included: $ORIGIN default.service.arpa. host-test-1 AAAA <ML-EID address of ED_1>
+     */
+
+    // Note: verification of DNS response from BR_2 to ED_2 is handled in Step 14.
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 15: Device: ED_1 Description (DPR-5.2): Harness instructs the device to unicast DNS query...");
+
+    /**
+     * Step 15
+     * - Device: ED_1
+     * - Description (DPR-5.2): Harness instructs the device to unicast DNS query QType=SRV to the DUT for service
+     *     “service-test-2._thread-type-2._udp.default.service.arpa.”.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    {
+        Dns::Client::QueryConfig queryConfig;
+        queryConfig.Clear();
+        AsCoreType(&queryConfig.mServerSockAddr.mAddress) = br1.Get<Mle::Mle>().GetMeshLocalEid();
+        queryConfig.mServerSockAddr.mPort                 = Dns::ServiceDiscovery::Server::kPort;
+
+        SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(
+            "service-test-2", "_thread-type-2._udp.default.service.arpa.", nullptr, &queryConfig));
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 16: Device: BR_1 (DUT) Description (DPR-5.2): Automatically sends DNS Response");
+
+    /**
+     * Step 16
+     * - Device: BR_1 (DUT)
+     * - Description (DPR-5.2): Automatically sends DNS Response. Note: during this step, DUT MAY perform mDNS query
+     *     to BR_2 which responds with service-test-2. This isn’t shown in detail.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response is valid, containing service-test-2 answer record: $ORIGIN
+     *     default.service.arpa. service-test-2._thread-type-2._udp ( SRV 0 0 55555 host-test-2 )
+     *   - It MAY contain in the Additional records section: host-test-2 AAAA <OMR address of ED_2>
+     */
+
+    nexus.AdvanceTime(kDnsResponseTime);
+
+    nexus.AddTestVar("ED_1_OMR", ed1.FindGlobalAddress().ToString().AsCString());
+    nexus.AddTestVar("ED_2_OMR", ed2.FindGlobalAddress().ToString().AsCString());
+
+    nexus.SaveTestInfo("test_1_3_DPR_TC_2.json", &br1);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_3_DPR_TC_2();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_GEN_TC_1.cpp b/tests/nexus/test_1_3_GEN_TC_1.cpp
new file mode 100644
index 0000000..1437802
--- /dev/null
+++ b/tests/nexus/test_1_3_GEN_TC_1.cpp
@@ -0,0 +1,400 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the discovery scan to complete.
+ */
+static constexpr uint32_t kDiscoveryTime = 5 * 1000;
+
+/**
+ * Time to advance for the diagnostic response to be received.
+ */
+static constexpr uint32_t kDiagResponseTime = 5 * 1000;
+
+/**
+ * Data poll period for SED, in milliseconds.
+ */
+static constexpr uint32_t kPollPeriod = 500;
+
+enum Topology
+{
+    kTopologyA, // FED
+    kTopologyB, // MED
+    kTopologyC, // SED
+};
+
+void Test_1_3_GEN_TC_1(Topology aTopology, const char *aJsonFileName)
+{
+    /**
+     * 9.1. [1.3] [CERT] Thread Version is '4' or higher
+     *
+     * 9.1.1. Purpose
+     * Verify that the Thread Version TLV uses the value '4' or higher. '4' is required for Thread 1.3.x devices. '5'
+     *   is required for Thread 1.4.x devices.
+     *
+     * 9.1.2. Topology
+     * - BR_1: Thread Border Router DUT
+     * - Router_1: Thread Router DUT
+     * - ED_1: Thread End Device DUT, including FEDs, MEDs and SEDs
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * Thread Version   | 5.18.2.1     | 5.18.2.1
+     */
+
+    Core nexus;
+
+    Node &br1     = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &ed1     = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    router1.SetName("Router_1");
+    ed1.SetName("ED_1");
+
+    AllowLinkBetween(br1, router1);
+    AllowLinkBetween(router1, ed1);
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    switch (aTopology)
+    {
+    case kTopologyA:
+        Log("Topology A: ED_1 as FED");
+        break;
+    case kTopologyB:
+        Log("Topology B: ED_1 as MED");
+        break;
+    case kTopologyC:
+        Log("Topology C: ED_1 as SED");
+        break;
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: BR_1 Enable");
+
+    /**
+     * - Step 1
+     *   - Device: BR_1
+     *   - Description (GEN-9.1): Enable
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = BR_1: The DUT MUST become Leader of the Thread Network.
+     */
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Router_1 Enable");
+
+    /**
+     * - Step 2
+     *   - Device: Router_1
+     *   - Description (GEN-9.1): Enable
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - N/A
+     */
+    router1.Join(br1);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Router_1 Automatically sends MLE Parent Request multicast.");
+
+    /**
+     * - Step 3
+     *   - Device: Router_1
+     *   - Description (GEN-9.1): Automatically sends MLE Parent Request multicast.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = Router: The DUT MUST sends MLE Parent Request multicast, with MLE Version TLV with value >= 4.
+     * - Step 4
+     *   - Device: BR_1
+     *   - Description (GEN-9.1): Automatically sends MLE Parent Response unicast.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = BR_1: The DUT MUST sends MLE Parent Response unicast, with MLE Version TLV with value >= 4.
+     * - Step 5
+     *   - Device: Router_1
+     *   - Description (GEN-9.1): Automatically completes attachment procedure and joins the network.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = Router: The DUT MUST send MLE Child ID Request message with MLE Version TLV with value >= 4.
+     */
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: ED_1 Enable, sends MLE Parent Request multicast.");
+
+    /**
+     * - Step 6
+     *   - Device: ED_1
+     *   - Description (GEN-9.1): Automatically sends MLE Parent Request multicast.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = ED_1: The DUT MUST sends MLE Parent Request multicast with MLE Version TLV with value >= 4.
+     */
+    switch (aTopology)
+    {
+    case kTopologyA:
+        ed1.Join(router1, Node::kAsFed);
+        break;
+    case kTopologyB:
+        ed1.Join(router1, Node::kAsMed);
+        break;
+    case kTopologyC:
+        ed1.Join(router1, Node::kAsSed);
+        SuccessOrQuit(ed1.Get<DataPollSender>().SetExternalPollPeriod(kPollPeriod));
+        break;
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Router_1 Automatically sends MLE Parent Response unicast.");
+
+    /**
+     * - Step 7
+     *   - Device: Router_1
+     *   - Description (GEN-9.1): Automatically sends MLE Parent Response unicast.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = Router_1: The DUT MUST sends MLE Parent Response unicast with MLE Version TLV with value >= 4.
+     * - Step 8
+     *   - Device: ED_1
+     *   - Description (GEN-9.1): Automatically completes attachment procedure and joins the network.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = ED_1: The DUT MUST send MLE Child ID Request message with MLE Version TLV with value >= 4.
+     */
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(ed1.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Router_1 Harness instructs device to send MLE Discovery Request multicast.");
+
+    /**
+     * - Step 9
+     *   - Device: Router_1
+     *   - Description (GEN-9.1): Note: only performed if BR_1 is DUT. Harness instructs device to send MLE Discovery
+     *     Request multicast.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - N/A
+     */
+    SuccessOrQuit(router1.Get<Mle::DiscoverScanner>().Discover(
+        Mac::ChannelMask(1 << br1.Get<Mac::Mac>().GetPanChannel()), 0xffff, /* aJoiner */ false, /* aFilter */ false,
+        /* aFilterIndexes */ nullptr, nullptr, nullptr));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: BR_1 Automatically responds with MLE Discovery Response unicast.");
+
+    /**
+     * - Step 10
+     *   - Device: BR_1
+     *   - Description (GEN-9.1): Note: only performed if BR_1 is DUT. Automatically responds with MLE Discovery
+     *     Response unicast.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = BR_1: The DUT MUST send MLE Discovery Response with MLE Discovery Response TLV with a value >= 4
+     *       of the 'Ver' field bits.
+     */
+    nexus.AdvanceTime(kDiscoveryTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 11: ED_1 Harness instructs device to send MLE Discovery Request multicast.");
+
+    /**
+     * - Step 11
+     *   - Device: ED_1
+     *   - Description (GEN-9.1): Note: only performed if Router_1 is DUT. Harness instructs device to send MLE
+     *     Discovery Request multicast.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - N/A
+     */
+    SuccessOrQuit(ed1.Get<Mle::DiscoverScanner>().Discover(
+        Mac::ChannelMask(1 << router1.Get<Mac::Mac>().GetPanChannel()), 0xffff, /* aJoiner */ false,
+        /* aFilter */ false, /* aFilterIndexes */ nullptr, nullptr, nullptr));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 12: Router_1 Automatically responds with MLE Discovery Response unicast.");
+
+    /**
+     * - Step 12
+     *   - Device: Router_1
+     *   - Description (GEN-9.1): Note: only performed if Router_1 is DUT. Automatically responds with MLE Discovery
+     *     Response unicast.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = Router_1: The DUT MUST send MLE Discovery Response with MLE Discovery Response TLV with a value
+     *       >= 4 of the 'Ver' field bits.
+     */
+    nexus.AdvanceTime(kDiscoveryTime);
+
+    // Diagnostics for v1.4 devices
+#if OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_4
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 13: Router_1 Harness instructs device to send TMF Get Diagnostic Request unicast to the DUT.");
+
+    /**
+     * - Step 13
+     *   - Device: Router_1 [1.4]
+     *   - Description (GEN-9.1): Note: only performed if BR_1 is DUT and >= v1.4 Device. Harness instructs device to
+     *     send TMF Get Diagnostic Request (DIAG_GET.req) unicast to the DUT, requesting Diagnostic Version TLV
+     *     (Type 24)
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - N/A
+     */
+    Ip6::Address br1Rloc    = br1.Get<Mle::Mle>().GetMeshLocalRloc();
+    uint8_t      tlvTypes[] = {NetDiag::Tlv::kVersion};
+    SuccessOrQuit(
+        router1.Get<NetDiag::Client>().SendDiagnosticGet(br1Rloc, tlvTypes, sizeof(tlvTypes), nullptr, nullptr));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 14: BR_1 Automatically responds with Get Diagnostic Response unicast to Router_1.");
+
+    /**
+     * - Step 14
+     *   - Device: BR_1 [1.4]
+     *   - Description (GEN-9.1): Note: only performed if BR_1 is DUT and >= v1.4 Device. Automatically responds with
+     *     Get Diagnostic Response (DIAG_GET.rsp) unicast to Router_1.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = BR_1 and >= v1.4 Device: The DUT MUST send DIAG_GET.RSP with Version TLV (Type 24)
+     *       with value >= 5.
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 15: BR_1 Harness instructs device to send TMF Get Diagnostic Request unicast to the DUT.");
+
+    /**
+     * - Step 15
+     *   - Device: BR_1 [1.4]
+     *   - Description (GEN-9.1): Note: only performed if Router_1 is DUT and >= v1.4 Device. Harness instructs device
+     *     to send TMF Get Diagnostic Request (DIAG_GET.req) unicast to the DUT, requesting Diagnostic Version TLV (Type
+     *     24)
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - N/A
+     */
+    Ip6::Address router1Rloc = router1.Get<Mle::Mle>().GetMeshLocalRloc();
+    SuccessOrQuit(
+        br1.Get<NetDiag::Client>().SendDiagnosticGet(router1Rloc, tlvTypes, sizeof(tlvTypes), nullptr, nullptr));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 16: Router_1 Automatically responds with Get Diagnostic Response unicast to the DUT.");
+
+    /**
+     * - Step 16
+     *   - Device: Router_1 [1.4]
+     *   - Description (GEN-9.1): Note: only performed if Router_1 is DUT and >= v1.4 Device. Automatically responds
+     *     with Get Diagnostic Response (DIAG_GET.rsp) unicast to the DUT.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = Router_1 and >= v1.4 device: The DUT MUST send DIAG_GET.RSP with Version TLV (Type 24) with value
+     *       >= 5.
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 17: Router_1 Harness instructs device to send TMF Get Diagnostic Request unicast to the DUT.");
+
+    /**
+     * - Step 17
+     *   - Device: Router_1 [1.4]
+     *   - Description (GEN-9.1): Note: only performed if ED_1 is DUT and >= v1.4 Device. Harness instructs device to
+     *     send TMF Get Diagnostic Request (DIAG_GET.req) unicast to the DUT, requesting Diagnostic Version TLV
+     *     (Type 24)
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - N/A
+     */
+    Ip6::Address ed1Rloc = ed1.Get<Mle::Mle>().GetMeshLocalRloc();
+    SuccessOrQuit(
+        router1.Get<NetDiag::Client>().SendDiagnosticGet(ed1Rloc, tlvTypes, sizeof(tlvTypes), nullptr, nullptr));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 18: ED_1 Optionally responds with Get Diagnostic Response unicast to Router_1.");
+
+    /**
+     * - Step 18
+     *   - Device: ED_1 [1.4]
+     *   - Description (GEN-9.1): Note: only performed if ED_1 is DUT and >= v1.4 Device. Optionally responds with Get
+     *     Diagnostic Response (DIAG_GET.rsp) unicast to Router_1.
+     *   - Pass Criteria (only applies if Device == DUT):
+     *     - For DUT = ED_1 and >= v1.4 device: The DUT MUST respond in either of below two ways:
+     *     - 1. DIAG_GET.rsp Response is successfully received, and MUST contain the Version TLV (Type 24)
+     *       with value >= 5.
+     *     - 2. CoAP error response is received with code 4.04 Not Found.
+     *     - Note: in case 2, the DUT does not support the optional diagnostics request.
+     */
+    nexus.AdvanceTime(kDiagResponseTime);
+#endif // OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_4
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    if (argc < 2)
+    {
+        // If no argument, run all topologies.
+        ot::Nexus::Test_1_3_GEN_TC_1(ot::Nexus::kTopologyA, "test_1_3_GEN_TC_1_A.json");
+        ot::Nexus::Test_1_3_GEN_TC_1(ot::Nexus::kTopologyB, "test_1_3_GEN_TC_1_B.json");
+        ot::Nexus::Test_1_3_GEN_TC_1(ot::Nexus::kTopologyC, "test_1_3_GEN_TC_1_C.json");
+    }
+    else if (strcmp(argv[1], "A") == 0)
+    {
+        ot::Nexus::Test_1_3_GEN_TC_1(ot::Nexus::kTopologyA, (argc > 2) ? argv[2] : "test_1_3_GEN_TC_1_A.json");
+    }
+    else if (strcmp(argv[1], "B") == 0)
+    {
+        ot::Nexus::Test_1_3_GEN_TC_1(ot::Nexus::kTopologyB, (argc > 2) ? argv[2] : "test_1_3_GEN_TC_1_B.json");
+    }
+    else if (strcmp(argv[1], "C") == 0)
+    {
+        ot::Nexus::Test_1_3_GEN_TC_1(ot::Nexus::kTopologyC, (argc > 2) ? argv[2] : "test_1_3_GEN_TC_1_C.json");
+    }
+    else
+    {
+        fprintf(stderr, "Error: Invalid topology '%s'. Must be 'A', 'B', or 'C'.\n", argv[1]);
+        return 1;
+    }
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_GEN_TC_2.cpp b/tests/nexus/test_1_3_GEN_TC_2.cpp
new file mode 100644
index 0000000..9189a59
--- /dev/null
+++ b/tests/nexus/test_1_3_GEN_TC_2.cpp
@@ -0,0 +1,296 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 20 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Active Timestamp components.
+ */
+static constexpr uint64_t kActiveTimestampSeconds = 0x666af1;
+static constexpr uint16_t kActiveTimestampTicks   = 0x1880;
+
+/**
+ * Service name parameters.
+ */
+static const char kMeshCoPServiceType[] = "_meshcop._udp";
+
+void Test_1_3_GEN_TC_2(const char *aJsonFileName)
+{
+    /**
+     * 9.2. [1.3] [CERT] Border Router mDNS discovery of v1.3/v1.4 TXT records
+     *
+     * 9.2.1. Purpose
+     *   - This test verifies that the Border Router DUT implements TXT records that were added in the v1.3 / v1.4
+     *     specifications: “omr”, “xa”, and “id”. It also verifies changed rules in the v1.4.0 specification on use of
+     *     vendor-specific records. It also validates, to the extent possible, that BR vendors provide correct values
+     *     for TXT record keys.
+     *
+     * 9.2.2. Topology
+     *   - BR_1: Thread Border Router DUT and Leader
+     *   - Eth_1: Reference device
+     *
+     * Spec Reference | V1.3.0 Section
+     * ---------------|---------------
+     * Thread 1.3.0   | 9.2
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    eth1.SetName("Eth_1");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    br1.mSettings.Wipe();
+    eth1.mSettings.Wipe();
+    br1.Reset();
+    eth1.Reset();
+
+    for (uint8_t i = 0; i < 2; i++)
+    {
+        Log("---------------------------------------------------------------------------------------");
+        Log("Iteration %u", i);
+
+        /**
+         * Step 0
+         *   - Device: BR_1 (DUT) / Harness
+         *   - Description (GEN-9.2): Harness configures DUT with Active Operational Dataset, either via THCI or via
+         *     the vendor specified setup method for the BR. This includes the following: Network Name: "Thread Cert
+         *     9.2", Active Timestamp (uint64): 0x000000666AF13100. Note: this timestamp can be set using the OT CLI
+         *     command "dataset activetimestamp 1718284593"
+         */
+        Log("Step 0: Harness configures DUT with Active Operational Dataset.");
+        {
+            MeshCoP::Dataset::Info datasetInfo;
+            SuccessOrQuit(br1.Get<MeshCoP::ActiveDatasetManager>().CreateNewNetwork(datasetInfo));
+
+            datasetInfo.mActiveTimestamp.mSeconds             = kActiveTimestampSeconds;
+            datasetInfo.mActiveTimestamp.mTicks               = kActiveTimestampTicks;
+            datasetInfo.mActiveTimestamp.mAuthoritative       = false;
+            datasetInfo.mComponents.mIsActiveTimestampPresent = true;
+
+            {
+                MeshCoP::NetworkName networkName;
+                SuccessOrQuit(networkName.Set("Thread Cert 9.2"));
+                datasetInfo.Set<MeshCoP::Dataset::kNetworkName>(networkName);
+            }
+
+            br1.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
+
+            SuccessOrQuit(br1.Get<MeshCoP::BorderAgent::Manager>().SetServiceBaseName("OpenThread "));
+
+            {
+                uint8_t vendorTxtData[] = {
+                    14,  'v', 'n', '=', 'N', 'e', 'x', 'u', 's', 'V', 'e', 'n', 'd', 'o', 'r', 13,   'm',  'n',
+                    '=', 'N', 'e', 'x', 'u', 's', 'M', 'o', 'd', 'e', 'l', 6,   'v', 'o', '=', 0x00, 0x00, 0x01,
+                };
+                br1.Get<MeshCoP::BorderAgent::TxtData>().SetVendorData(vendorTxtData, sizeof(vendorTxtData));
+            }
+
+            br1.Get<MeshCoP::BorderAgent::Manager>().SetEnabled(true);
+        }
+
+        /**
+         * Step 1
+         *   - Device: BR_1 (DUT)
+         *   - Description (GEN-9.2): Form topology. DUT becomes Leader of its Thread Network. It configures an OMR ULA
+         *     prefix OMR_1 and sends the prefix OMR_1 in ND RA multicast messages on the AIL. Harness records the
+         *     value of OMR_1 for validation in future test steps.
+         */
+        Log("Step 1: Form topology. DUT becomes Leader. Configures OMR prefix.");
+        br1.Get<ThreadNetif>().Up();
+        SuccessOrQuit(br1.Get<Mle::Mle>().Start());
+
+        br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+        br1.Get<BorderRouter::RoutingManager>().Init();
+        SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+        nexus.AdvanceTime(kFormNetworkTime);
+
+        // Wait for BR to advertise prefixes and for verification script to catch RAs
+        nexus.AdvanceTime(kBrActionTime);
+
+        VerifyOrQuit(br1.Get<Mle::Mle>().IsLeader());
+
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        /**
+         * Step 2
+         *   - Device: Eth_1
+         *   - Description (GEN-9.2): Harness instructs the device to send via mDNS a DNS-SD QType=PTR query for
+         *     services “_meshcop._udp.local”.
+         */
+        Log("Step 2: Eth_1 sends mDNS PTR query for _meshcop._udp.local.");
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const Dns::Multicast::Core::BrowseResult *) {};
+        browser.mServiceType  = kMeshCoPServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+
+        /**
+         * Step 3
+         *   - Device: BR_1 (DUT)
+         *   - Description (GEN-9.2): Automatically responds with mDNS response with PTR records, and optionally
+         *     Additional Records (SRV/TXT).
+         */
+        Log("Step 3: BR_1 (DUT) responds with mDNS response with PTR records.");
+
+        /**
+         * Step 4
+         *   - Device: Eth_1
+         *   - Description (GEN-9.2): Harness instructs the device to send via mDNS a query QType=TXT, with the DNS
+         *     name set to the service instance name that is included in the PTR record in step 3.
+         */
+        Log("Step 4: Eth_1 sends mDNS TXT query for the service instance.");
+        {
+            Dns::Multicast::Core::TxtResolver resolver;
+            char                              serviceInstance[64];
+
+            ClearAllBytes(resolver);
+            snprintf(serviceInstance, sizeof(serviceInstance), "OpenThread%s",
+                     br1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+
+            Log("Querying TXT for instance: %s", serviceInstance);
+
+            resolver.mCallback = [](otInstance *, const Dns::Multicast::Core::TxtResult *aResult) {
+                Log("TXT Callback: instance=%s", aResult->mServiceInstance);
+            };
+            resolver.mServiceType     = kMeshCoPServiceType;
+            resolver.mInfraIfIndex    = kInfraIfIndex;
+            resolver.mServiceInstance = serviceInstance;
+
+            SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartTxtResolver(resolver));
+            nexus.AdvanceTime(kDnsQueryTime);
+            SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopTxtResolver(resolver));
+        }
+
+        /**
+         * Step 5
+         *   - Device: BR_1 (DUT)
+         *   - Description (GEN-9.2): Automatically responds with mDNS response with TXT record.
+         */
+        Log("Step 5: BR_1 (DUT) responds with mDNS response with TXT record.");
+
+        /**
+         * Step 6
+         *   - Device: BR_1 (DUT) / Harness
+         *   - Description (GEN-9.2): Verify that the DUT responded with the right data in the TXT record keys. This
+         *     uses the TXT record found in step 5.
+         */
+        Log("Step 6: Verify TXT record keys.");
+
+        {
+            char varName[64];
+            snprintf(varName, sizeof(varName), "BR_1_EXT_ADDR_%u", i);
+            nexus.AddTestVar(varName, br1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+
+            Ip6::Prefix                   omrPrefix;
+            BorderRouter::RoutePreference preference;
+            if (br1.Get<BorderRouter::RoutingManager>().GetFavoredOmrPrefix(omrPrefix, preference) == kErrorNone)
+            {
+                snprintf(varName, sizeof(varName), "BR_1_OMR_PREFIX_%u", i);
+                nexus.AddTestVar(varName, omrPrefix.ToString().AsCString());
+                Log("Iteration %u OMR Prefix: %s", i, omrPrefix.ToString().AsCString());
+            }
+        }
+
+        if (i == 0)
+        {
+            /**
+             * Step 7
+             *   - Device: BR_1 (DUT)
+             *   - Description (GEN-9.2): Factory reset the device.
+             */
+            Log("Step 7: Factory reset the device.");
+            SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(false));
+            br1.mSettings.Wipe();
+            br1.Reset();
+
+            SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+
+            // Wait for reset and restart
+            nexus.AdvanceTime(20 * 1000);
+        }
+        else
+        {
+            /**
+             * Step 8
+             *   - Device: BR_1 (DUT)
+             *   - Description (GEN-9.2): Repeat steps 0 to 6 again.
+             */
+            Log("Step 8: Repeat steps 0 to 6 again.");
+        }
+    }
+
+    Log("All steps completed.");
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_GEN_TC_2((argc > 2) ? argv[2] : "test_1_3_gen_tc_2.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRPC_TC_1.cpp b/tests/nexus/test_1_3_SRPC_TC_1.cpp
new file mode 100644
index 0000000..390006e
--- /dev/null
+++ b/tests/nexus/test_1_3_SRPC_TC_1.cpp
@@ -0,0 +1,417 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the network to stabilize after routers have attached.
+ */
+static constexpr uint32_t kStabilizationTime = 10 * 1000;
+
+/**
+ * Time to wait for SRP registration to complete.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 5 * 1000;
+
+/**
+ * Time to wait for SRP renewal.
+ */
+static constexpr uint32_t kSrpRenewalTime = 30 * 1000;
+
+/**
+ * SRP Lease time in seconds.
+ */
+static constexpr uint32_t kSrpLease = 30;
+
+/**
+ * SRP Key Lease time in seconds.
+ */
+static constexpr uint32_t kSrpKeyLease = 600;
+
+/**
+ * SRP service port.
+ */
+static constexpr uint16_t kSrpServicePort = 33333;
+
+/**
+ * Time to wait for SRP server switch.
+ */
+static constexpr uint32_t kSrpServerSwitchTime = 300 * 1000;
+
+void Test_1_3_SRPC_TC_1(const char *aJsonFileName)
+{
+    Srp::Client::Service service;
+
+    /**
+     * 3.1. [1.3] [CERT] [COMPONENT] Re-register service with active SRP server - Multiple BRs - No AIL
+     *
+     * 3.1.1. Purpose
+     * To test the following:
+     * - DNS/SRP Unicast Dataset(s) offered in the Thread network
+     * - Detects unresponsiveness of the SRP server and sends SRP update to another active SRP server
+     * - Detects a second, numerically lower, SRP server and DUT stays with its current SRP server and does not switch
+     *   to the numerically lowest SRP server
+     * - Registered service is automatically re-registered with active SRP server before timeout
+     * - In the absence of an OMR prefix, mesh-local (ML-EID) address will be used by SRP client.
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Client       | N/A          | 2.3.2
+     */
+
+    Core nexus;
+
+    Node &br1     = nexus.CreateNode();
+    Node &br2     = nexus.CreateNode();
+    Node &ed1     = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    ed1.SetName("ED_1");
+    router1.SetName("Router_1");
+
+    // Set Mesh-Local IID to be deterministic and ensure BR_1 has a numerically lower ML-EID than BR_2.
+    {
+        Ip6::InterfaceIdentifier iid;
+
+        iid.Clear();
+        iid.mFields.m8[7] = 1;
+        SuccessOrQuit(br1.Get<Mle::Mle>().SetMeshLocalIid(iid));
+
+        iid.mFields.m8[7] = 2;
+        SuccessOrQuit(br2.Get<Mle::Mle>().SetMeshLocalIid(iid));
+    }
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0: Router 1 Enable. Automatically, becomes Leader.");
+
+    /**
+     * Step 0
+     * - Device: Router 1
+     * - Description (SRPC-3.1): Enable. Automatically, becomes Leader.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    router1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: BR_1 Enable: Harness configures the device with a numerically lower ML-EID.");
+
+    /**
+     * Step 1
+     * - Device: BR_1
+     * - Description (SRPC-3.1): Enable: Harness configures the device with a numerically lower ML-EID address than
+     *   BR_2 will have. Note: due to absence of adjacent infrastructure link (AIL), no OMR address is configured by
+     *   the BR.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    AllowLinkBetween(br1, br2);
+    AllowLinkBetween(br1, router1);
+    AllowLinkBetween(br2, router1);
+    AllowLinkBetween(router1, ed1);
+
+    br1.Join(router1);
+    nexus.AdvanceTime(kAttachToRouterTime * 2);
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: ED 1 (DUT) Enable; automatically connects to Router_1.");
+
+    /**
+     * Step 2
+     * - Device: ED 1 (DUT)
+     * - Description (SRPC-3.1): Enable; automatically connects to Router_1.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    ed1.Join(router1, Node::kAsFed);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(ed1.Get<Mle::Mle>().IsAttached());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: BR 1 Automatically adds its SRP Server information in the Thread Network Data.");
+
+    /**
+     * Step 3
+     * - Device: BR 1
+     * - Description (SRPC-3.1): Automatically adds its SRP Server information in the Thread Network Data by using a
+     *   DNS/SRP Unicast Dataset, indicating its ML-EID address.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: ED 1 (DUT) Harness instructs DUT to add a service to the device.");
+
+    /**
+     * Step 4
+     * - Device: ED 1 (DUT)
+     * - Description (SRPC-3.1): Harness instructs DUT to add a service to the device: $ORIGIN default.service.arpa.
+     *   service-test-1._thread-test._udp ( SRV 33333 host-test-1 ) host-test-1 AAAA <ML-EID address of ED_1> with the
+     *   following options: Update Lease Option Lease: 30 seconds Key Lease: 10 minutes
+     * - Pass Criteria:
+     *   - The DUT ED_1 MUST send SRP Update to BR_1 containing the service-test-1.
+     *   - The IPv6 address in the AAAA record of the SRP Update MUST be an ML-EID, specifically the ML-EID of the DUT.
+     *   - It MUST NOT contain any other IPv6 addresses in AAAA records.
+     */
+
+    ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName("host-test-1"));
+    SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+    ed1.Get<Srp::Client>().SetLeaseInterval(kSrpLease);
+    ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease);
+
+    ClearAllBytes(service);
+    service.mName         = "_thread-test._udp";
+    service.mInstanceName = "service-test-1";
+    service.mPort         = kSrpServicePort;
+    SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: BR_1 Automatically sends DNS Response (Rcode=0, NoError).");
+
+    /**
+     * Step 5
+     * - Device: BR_1
+     * - Description (SRPC-3.1): Automatically sends DNS Response (Rcode=0, NoError).
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    VerifyOrQuit(ed1.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: BR 2 Enable. Harness configures device with a numerically higher ML-EID.");
+
+    /**
+     * Step 6
+     * - Device: BR 2
+     * - Description (SRPC-3.1): Enable. Harness configures device with a numerically higher ML-EID address than BR 1.
+     *   This ensures that the BR 2 SRP service will be less preferred to the DUT. Note: due to absence of adjacent
+     *   infrastructure link (AIL), no OMR address is configured by the BR.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br2.Join(router1);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(br2.Get<Mle::Mle>().IsRouter());
+
+    /** Verify BR_1 has a numerically lower ML-EID than BR_2. */
+    VerifyOrQuit(br1.Get<Mle::Mle>().GetMeshLocalEid() < br2.Get<Mle::Mle>().GetMeshLocalEid());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: BR 2 Harness instructs the device to add its SRP Server information.");
+
+    /**
+     * Step 7
+     * - Device: BR 2
+     * - Description (SRPC-3.1): Harness instructs the device to add its SRP Server information in the Thread Network
+     *   Data using a DNS/SRP Unicast Dataset. Note: normally BR_2 does not add this information, since an SRP server
+     *   is already advertised on the Thread Network. The Harness overrides this behavior.
+     * - Pass Criteria:
+     *   - The DUT MUST NOT send SRP Update to BR_2.
+     */
+
+    SuccessOrQuit(br2.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br2.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(30 * 1000);
+
+    nexus.AddTestVar("BR_2_SRP_PORT", br2.Get<Srp::Server>().GetPort());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: BR 1 Harness deactivates the device.");
+
+    /**
+     * Step 8
+     * - Device: BR 1
+     * - Description (SRPC-3.1): Harness deactivates the device.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Get<Srp::Server>().SetEnabled(false);
+    br1.Get<Mle::Mle>().Stop();
+    br1.Get<ThreadNetif>().Down();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8b: Harness waits 30 seconds. Within this time, the next step must happen.");
+
+    /**
+     * Step 8b
+     * - Device: N/A
+     * - Description (SRPC-3.1): Harness waits 30 seconds. Within this time, the next step must happen.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    nexus.AdvanceTime(kSrpRenewalTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: ED 1 (DUT) Automatically sends SRP Update to BR_1 to renew service.");
+
+    /**
+     * Step 9
+     * - Device: ED 1 (DUT)
+     * - Description (SRPC-3.1): Automatically sends SRP Update to BR_1 to renew service.
+     * - Pass Criteria:
+     *   - The DUT MUST send SRP Update to BR_1 as in step 4.
+     */
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: BR 1 Does not respond.");
+
+    /**
+     * Step 10
+     * - Device: BR 1
+     * - Description (SRPC-3.1): Does not respond.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 11: ED 1 (DUT) Automatically sends SRP Update to BR_2");
+
+    /**
+     * Step 11
+     * - Device: ED 1 (DUT)
+     * - Description (SRPC-3.1): Automatically sends SRP Update to BR_2
+     * - Pass Criteria:
+     *   - The DUT MUST send SRP Update like in step 4 but now to BR_2
+     */
+
+    nexus.AdvanceTime(kSrpServerSwitchTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 12: BR 2 Automatically sends SRP Update Response (Rcode=0, NoError).");
+
+    /**
+     * Step 12
+     * - Device: BR 2
+     * - Description (SRPC-3.1): Automatically sends SRP Update Response (Rcode=0, NoError).
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    VerifyOrQuit(!AsConst(br2.Get<Srp::Server>()).GetHosts().IsEmpty());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 13: BR_1 Harness re-enables the device.");
+
+    /**
+     * Step 13
+     * - Device: BR_1
+     * - Description (SRPC-3.1): Harness re-enables the device.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Get<ThreadNetif>().Up();
+    SuccessOrQuit(br1.Get<Mle::Mle>().Start());
+    br1.Join(router1);
+    nexus.AdvanceTime(kAttachToRouterTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 14: BR_1 Harness instructs the device to add its SRP Server information.");
+
+    /**
+     * Step 14
+     * - Device: BR_1
+     * - Description (SRPC-3.1): Harness instructs the device to add its SRP Server information in the Thread Network
+     *   Data using a DNS/SRP Unicast Dataset. Note: normally BR_1 does not add this information, since an SRP server
+     *   is already advertised on the Thread Network. The Harness overrides this behavior.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicastForceAdd));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 15: ED 1 (DUT) Does not send SRP Update to BR_1, but stays with its current server.");
+
+    /**
+     * Step 15
+     * - Device: ED 1 (DUT)
+     * - Description (SRPC-3.1): Does not send SRP Update to BR_1, but stays with its current SRP server BR 2..
+     * - Pass Criteria:
+     *   - The DUT MUST NOT send SRP Update to BR_1.
+     */
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRPC_TC_1((argc > 2) ? argv[2] : "test_1_3_SRPC_TC_1.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRPC_TC_4.cpp b/tests/nexus/test_1_3_SRPC_TC_4.cpp
new file mode 100644
index 0000000..47d669c
--- /dev/null
+++ b/tests/nexus/test_1_3_SRPC_TC_4.cpp
@@ -0,0 +1,347 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 10 * 1000;
+
+/**
+ * Time to wait for SRP registration to complete.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 5 * 1000;
+
+/**
+ * Time to advance for DNS query processing.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP Lease time in seconds.
+ */
+static constexpr uint32_t kSrpLease5m = 300;
+static constexpr uint32_t kSrpLease3m = 180;
+
+/**
+ * SRP Key Lease time in seconds.
+ */
+static constexpr uint32_t kSrpKeyLease10m = 600;
+
+/**
+ * SRP service port.
+ */
+static constexpr uint16_t kSrpServicePort1 = 55555;
+static constexpr uint16_t kSrpServicePort2 = 55556;
+
+/**
+ * SRP service and host names.
+ */
+static const char kSrpServiceType[]     = "_thread-test._udp";
+static const char kSrpFullServiceType[] = "_thread-test._udp.default.service.arpa.";
+static const char kSrpInstanceName1[]   = "service-test-1";
+static const char kSrpInstanceName2[]   = "service-test-2";
+static const char kSrpHostName1[]       = "host-test-1";
+
+void Test_1_3_SRPC_TC_4(const char *aJsonFileName)
+{
+    Srp::Client::Service service1;
+    Srp::Client::Service service2;
+
+    /**
+     * 3.4. [1.3] [CERT] [COMPONENT] Remove one service only by SRP client
+     *
+     * 3.4.1. Purpose
+     * To test the following:
+     * - SRP client needs to remove one service while other service remains registered.
+     * - Correct usage of SRP Update to remove 1 service.
+     * - Note: a Thread Product DUT must skip this test. Thread Product DUTs do not have a test case equivalent to this
+     *   one, since they would then need to be instructed to register 2 services and then remove 1 service again. This
+     *   seems unlikely to be practical.
+     *
+     * 3.4.2. Topology
+     * - BR_1 - Test Bed border router device operating as a Thread Border Router, and the Leader
+     * - Eth_1 - Test Bed border router device on an Adjacent Infrastructure Link
+     * - TD_1 (DUT) - Thread End Device or Thread Router Component DUT
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Client       | N/A          | 2.3.2
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+    Node &td1  = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    eth1.SetName("Eth_1");
+    td1.SetName("TD_1");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Device: BR_1 Description (SRPC-3.4): Enable");
+
+    /**
+     * Step 1
+     * - Device: BR_1
+     * - Description (SRPC-3.4): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Device: TD_1 (DUT) Description (SRPC-3.4): Enable");
+
+    /**
+     * Step 2
+     * - Device: TD_1 (DUT)
+     * - Description (SRPC-3.4): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    td1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Device: BR 1 Description (SRPC-3.4): Automatically adds its SRP Server information");
+
+    /**
+     * Step 3
+     * - Device: BR 1
+     * - Description (SRPC-3.4): Automatically adds its SRP Server information in the DNSSRP/ Unicast Dataset in the
+     *   Thread Network Data. Automatically configures an OMR prefix in Thread Network Data.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /** Add test variables for Python script. */
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("TD_1_MLEID_ADDR", td1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("TD_1_OMR_ADDR", td1.FindGlobalAddress().ToString().AsCString());
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Device: TD 1 (DUT) Description (SRPC-3.4): Harness instructs device to register a new service");
+
+    /**
+     * Step 4
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.4): Harness instructs device to register a new service: $ORIGIN default.service.arpa.
+     *   service-test-1. thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of TD_1> with
+     *   the following options: Update Lease Option Lease: 5 minutes Key Lease: 10 minutes.
+     * - Pass Criteria:
+     *   - N/A (see step 5 instead)
+     */
+
+    td1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(td1.Get<Srp::Client>().SetHostName(kSrpHostName1));
+    SuccessOrQuit(td1.Get<Srp::Client>().EnableAutoHostAddress());
+    td1.Get<Srp::Client>().SetLeaseInterval(kSrpLease5m);
+    td1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease10m);
+
+    ClearAllBytes(service1);
+    service1.mName         = kSrpServiceType;
+    service1.mInstanceName = kSrpInstanceName1;
+    service1.mPort         = kSrpServicePort1;
+    service1.mPriority     = 1;
+    service1.mWeight       = 1;
+    SuccessOrQuit(td1.Get<Srp::Client>().AddService(service1));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Device: BR 1 Description (SRPC-3.4): Automatically responds 'SUCCESS'");
+
+    /**
+     * Step 5
+     * - Device: BR 1
+     * - Description (SRPC-3.4): Automatically responds 'SUCCESS'.
+     * - Pass Criteria:
+     *   - MUST respond 'SUCCESS'.
+     *   - Note: this verification step is done on the BR 1 response to ensure that the DUT in step 4 sent a correct
+     *     SRP Update. This is a quick way to verify the Update was correct.
+     */
+
+    VerifyOrQuit(!AsConst(br1.Get<Srp::Server>()).GetHosts().IsEmpty());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Device: TD 1 (DUT) Description (SRPC-3.4): Harness instructs device to register a new, second "
+        "service");
+
+    /**
+     * Step 6
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.4): Harness instructs device to register a new, second service: $ORIGIN
+     *   default.service.arpa. service-test-2._thread-test._udp ( SRV 1 1 55556 host-test-1 ) host-test-1 AAAA <OMR
+     *   address of TD 1> with the following options: Update Lease Option Lease: 3 minutes Key Lease: 10 minutes
+     * - Pass Criteria:
+     *   - N/A (see step 7 instead)
+     */
+
+    td1.Get<Srp::Client>().SetLeaseInterval(kSrpLease3m);
+
+    ClearAllBytes(service2);
+    service2.mName         = kSrpServiceType;
+    service2.mInstanceName = kSrpInstanceName2;
+    service2.mPort         = kSrpServicePort2;
+    service2.mPriority     = 1;
+    service2.mWeight       = 1;
+    SuccessOrQuit(td1.Get<Srp::Client>().AddService(service2));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Device: BR 1 Description (SRPC-3.4): Automatically responds SUCCESS");
+
+    /**
+     * Step 7
+     * - Device: BR 1
+     * - Description (SRPC-3.4): Automatically responds SUCCESS.
+     * - Pass Criteria:
+     *   - MUST respond 'SUCCESS'.
+     *   - Note: this verification step is done on the BR_1 response to ensure that the DUT in step 6 sent a correct
+     *     SRP Update. This is a quick way to verify the Update was correct.
+     */
+
+    VerifyOrQuit(td1.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Device: TD 1 (DUT) Description (SRPC-3.4): Harness instructs device to remove its first service");
+
+    /**
+     * Step 8
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.4): Harness instructs device to remove its first service.
+     * - Pass Criteria:
+     *   - The DUT MUST send an SRP Update message such that the Service Discovery Instruction contains a single Delete
+     *     an RR from an RRset ([RFC 2136], Section 2.5.4) update that deletes named records: $ORIGIN
+     *     default.service.arpa. service-test-1._thread-test. udp ANY <ttl=0>
+     *   - Note: if this is difficult to verify here then instead the SRP server response in step 11 can be used as
+     *     validation that the correct service was removed.
+     */
+
+    SuccessOrQuit(td1.Get<Srp::Client>().RemoveService(service1));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Device: BR_1 Description (SRPC-3.4): Automatically responds 'SUCCESS'");
+
+    /**
+     * Step 9
+     * - Device: BR_1
+     * - Description (SRPC-3.4): Automatically responds 'SUCCESS'.
+     * - Pass Criteria:
+     *   - MUST respond 'SUCCESS'.
+     */
+
+    VerifyOrQuit(td1.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: Device: TD 1 (DUT) Description (SRPC-3.4): Harness instructs device via TH API to send unicast DNS "
+        "query QType PTR");
+
+    /**
+     * Step 10
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.4): Harness instructs device via TH API to send unicast DNS query QType PTR to BR_1 for
+     *   any services of type_thread-test._udp.default.service.arpa.". Note: this is done to validate that the SRP
+     *   server removed the first service and kept the second service.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    SuccessOrQuit(td1.Get<Dns::Client>().Browse(kSrpFullServiceType, nullptr, nullptr));
+
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 11: Device: BR_1 Description (SRPC-3.4): Responds with the second service only");
+
+    /**
+     * Step 11
+     * - Device: BR_1
+     * - Description (SRPC-3.4): Responds with the second service only, not the first service.
+     * - Pass Criteria:
+     *   - MUST respond with the following service DNS-SD response to requesting client in the Answer records section:
+     *     $ORIGIN default.service.arpa. _thread-test._udp ( PTR service-test-2._thread-test._udp )
+     *   - DUT MUST NOT respond with further records in the Answer records section.
+     *   - Response MAY contain in the Additional records section: $ORIGIN default.service.arpa.
+     *     service-test-2._thread-test._udp ( SRV 1 1 55556 host-test-1 ) host-test-1 AAAA <OMR address of TD_1>
+     */
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRPC_TC_4((argc > 2) ? argv[2] : "test_1_3_SRPC_TC_4.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRPC_TC_5.cpp b/tests/nexus/test_1_3_SRPC_TC_5.cpp
new file mode 100644
index 0000000..e13105c
--- /dev/null
+++ b/tests/nexus/test_1_3_SRPC_TC_5.cpp
@@ -0,0 +1,571 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 15 * 1000;
+
+/**
+ * Time to wait for SRP registration to complete.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP Lease time in seconds.
+ */
+static constexpr uint32_t kSrpLease5m = 300;
+
+/**
+ * SRP Key Lease time in seconds.
+ */
+static constexpr uint32_t kSrpKeyLease10m = 600;
+
+/**
+ * SRP service ports.
+ */
+static constexpr uint16_t kSrpServicePort1 = 55551;
+static constexpr uint16_t kSrpServicePort2 = 55552;
+static constexpr uint16_t kSrpServicePort3 = 55553;
+static constexpr uint16_t kSrpServicePort4 = 55554;
+static constexpr uint16_t kSrpServicePort5 = 55555;
+
+/**
+ * SRP service and host names.
+ */
+static const char kSrpServiceType[]     = "_thread-test._udp";
+static const char kSrpFullServiceType[] = "_thread-test._udp.default.service.arpa.";
+static const char kSrpHostName[]        = "host-test-2";
+
+/**
+ * Expected values for "thread-test" key in TXT records.
+ */
+static const char *const kExpectedTxtValues[] = {
+    "a49", "b50_test", "", "1", "C51",
+};
+
+/**
+ * Test context to track progress.
+ */
+struct TestContext
+{
+    Node   *mNode;
+    uint8_t mBrowseCount;
+    uint8_t mResolveCount;
+    uint8_t mUdpCount;
+};
+
+void HandleResolveResponse(otError aError, const otDnsServiceResponse *aResponse, void *aContext)
+{
+    TestContext                        *context  = static_cast<TestContext *>(aContext);
+    const Dns::Client::ServiceResponse &response = *static_cast<const Dns::Client::ServiceResponse *>(aResponse);
+    Dns::Client::ServiceInfo            serviceInfo;
+    Ip6::Address                        address;
+    uint32_t                            ttl;
+    Dns::Name::Buffer                   hostName;
+    static constexpr uint16_t           kMaxTxtDataSize = 254;
+    uint8_t                             txtData[kMaxTxtDataSize];
+    Ip6::MessageInfo                    messageInfo;
+    Message                            *message;
+
+    if (aError != OT_ERROR_NONE)
+    {
+        Log("HandleResolveResponse error: %s", ErrorToString(aError));
+        SuccessOrQuit(aError);
+    }
+
+    serviceInfo.mHostNameBuffer     = hostName;
+    serviceInfo.mHostNameBufferSize = sizeof(hostName);
+    serviceInfo.mTxtData            = txtData;
+    serviceInfo.mTxtDataSize        = sizeof(txtData);
+
+    SuccessOrQuit(response.GetServiceInfo(serviceInfo));
+
+    context->mResolveCount++;
+
+    /**
+     * Step 7
+     * - Device: TD_1 (DUT)
+     * - Description (SRPC-3.5): Harness receives each of the N service responses via API from the DUT Component.
+     *   For each service, it uses the API to extract the TXT record key "thread-test" and compares against the
+     *   expected value.
+     * - Pass Criteria:
+     *   - The DUT MUST return the correct values for the "thread-test" key as defined below for each of the
+     *     respective N services:
+     *     - 1. a49
+     *     - 2. b50 test
+     *     - 3. (empty string of 0 characters)
+     *     - 4. 1
+     *     - 5. C51
+     */
+    Log("Step 7: Verify TXT record key 'thread-test' for resolved service on port %u", serviceInfo.mPort);
+
+    Dns::TxtEntry::Iterator iterator;
+    Dns::TxtEntry           entry;
+    bool                    found = false;
+
+    iterator.Init(serviceInfo.mTxtData, serviceInfo.mTxtDataSize);
+
+    while (iterator.GetNextEntry(entry) == kErrorNone)
+    {
+        if (entry.MatchesKey("thread-test"))
+        {
+            uint8_t index = 0;
+
+            switch (serviceInfo.mPort)
+            {
+            case kSrpServicePort1:
+                index = 0;
+                break;
+            case kSrpServicePort2:
+                index = 1;
+                break;
+            case kSrpServicePort3:
+                index = 2;
+                break;
+            case kSrpServicePort4:
+                index = 3;
+                break;
+            case kSrpServicePort5:
+                index = 4;
+                break;
+            default:
+                VerifyOrQuit(false, "Unknown port");
+                break;
+            }
+
+            if (entry.mValueLength == strlen(kExpectedTxtValues[index]))
+            {
+                if (entry.mValueLength == 0 || memcmp(entry.mValue, kExpectedTxtValues[index], entry.mValueLength) == 0)
+                {
+                    found = true;
+                    break;
+                }
+            }
+        }
+    }
+    VerifyOrQuit(found, "thread-test key not found or value mismatch");
+
+    /**
+     * Step 8
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.5): Harness uses API to resolve for each of the N services the IPv6 address and port of
+     *   the service. For each returned/resolved service, Harness uses the API to send a UDP packet to the service's
+     *   IPv6 address at the respective returned port number.
+     * - Pass Criteria:
+     *   - The DUT MUST transmit 5 UDP packets to ED_2, to the following port numbers, which may be in any order:
+     *     - 1. 55551
+     *     - 2. 55552
+     *     - 3. 55553
+     *     - 4. 55554
+     *     - 5. 55555
+     *   - The DUT MAY send to either the ML-EID or OMR address of ED 2.
+     */
+    Log("Step 8: Resolve service address and send UDP packet to port %u", serviceInfo.mPort);
+
+    {
+        bool hasMeshLocal = false;
+        for (uint16_t i = 0; response.GetHostAddress(hostName, i, address, ttl) == kErrorNone; i++)
+        {
+            if (context->mNode->Get<Mle::Mle>().IsMeshLocalAddress(address))
+            {
+                hasMeshLocal = true;
+                break;
+            }
+        }
+
+        if (!hasMeshLocal)
+        {
+            // If no mesh-local address was found, get the first address.
+            // This will fail and quit if there are no addresses at all.
+            SuccessOrQuit(response.GetHostAddress(hostName, 0, address, ttl));
+        }
+    }
+
+    messageInfo.SetPeerAddr(address);
+    messageInfo.SetPeerPort(serviceInfo.mPort);
+
+    message = context->mNode->Get<Ip6::Udp>().NewMessage();
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->AppendBytes("hello", 5));
+
+    SuccessOrQuit(context->mNode->Get<Ip6::Udp>().SendDatagram(*message, messageInfo));
+
+    context->mUdpCount++;
+}
+
+void HandleBrowseResponse(otError aError, const otDnsBrowseResponse *aResponse, void *aContext)
+{
+    TestContext                       *context  = static_cast<TestContext *>(aContext);
+    const Dns::Client::BrowseResponse &response = *static_cast<const Dns::Client::BrowseResponse *>(aResponse);
+    Dns::Name::LabelBuffer             label;
+
+    if (aError != OT_ERROR_NONE)
+    {
+        Log("HandleBrowseResponse error: %s", ErrorToString(aError));
+        SuccessOrQuit(aError);
+    }
+
+    for (uint16_t index = 0; response.GetServiceInstance(index, label, sizeof(label)) == kErrorNone; index++)
+    {
+        context->mBrowseCount++;
+
+        Log("Step 5/6: Found service instance %s, starting resolve", label);
+
+        SuccessOrQuit(context->mNode->Get<Dns::Client>().ResolveService(label, kSrpFullServiceType,
+                                                                        HandleResolveResponse, context));
+    }
+}
+
+void Test_1_3_SRPC_TC_5(const char *aJsonFileName)
+{
+    /**
+     * 3.5. [1.3] [CERT] [COMPONENT] DNS-SD client discovers services
+     *
+     * 3.5.1. Purpose
+     * To test the following:
+     * - DNS-SD client discovers services and sends out correctly formatted DNS query
+     * - Client handling multiple service responses
+     * - Client handling of both mesh-local discovered services and off-mesh services
+     * - Note: Thread Product DUTs must skip this test.
+     *
+     * 3.5.2. Topology
+     * - BR 1-Border Router Reference Device and Leader
+     * - TD_1 (DUT)-Thread End Device or Thread Router DUT (Component DUT)
+     * - ED 2-Thread Reference End Device
+     * - Eth 1-Host Reference Device on AIL
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Client       | N/A          | 2.3.2
+     */
+
+    Core         nexus;
+    Ip6::Address addresses[3];
+
+    Node &br1  = nexus.CreateNode();
+    Node &td1  = nexus.CreateNode();
+    Node &ed2  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    td1.SetName("TD_1");
+    ed2.SetName("ED_2");
+    eth1.SetName("Eth_1");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Device: BR 1. ED 2, Eth 1 Description (SRPC-3.5): Enable");
+
+    /**
+     * Step 1
+     * - Device: BR 1. ED 2, Eth 1
+     * - Description (SRPC-3.5): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    ed2.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Device: BR 1 Description (SRPC-3.5): Automatically adds its SRP Server information");
+
+    /**
+     * Step 2
+     * - Device: BR 1
+     * - Description (SRPC-3.5): Automatically adds its SRP Server information in the DNS/SRP Unicast Dataset in the
+     *   Thread Network Data. Automatically configures an OMR prefix in Thread Network Data.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Device: ED 2 Description (SRPC-3.5): Harness instructs device to register multiple N=5 services");
+
+    /**
+     * Step 3
+     * - Device: ED 2
+     * - Description (SRPC-3.5): Harness instructs device to register multiple N=5 services, each with TXT record per
+     *   DNS-SD key/value formatting:
+     *   - service-test-1. thread-test._udp ( SRV 8 8 55551 host-test-2 TXT dummy=abcd; thread-test=a49 )
+     *   - service-test-2._thread-test._udp ( SRV 8 1 55552 host-test-2 TXT a=2; thread-test=b50_test )
+     *   - service-test-3. thread-test._udp ( SRV 1  55553 host-test-2 TXT a_dummy=42; thread-test= )
+     *   - service-test-4. thread-test._udp ( SRV 1 1 55554 host-test-2 TXT thread-test=1;ignorethis; thread-test= )
+     *   - service-test-5. thread-test.udp ( SRV 2 2 55555 host-test-2 TXT a=88;thread- THREAD-TEST=C51; ignorethis;
+     *     thread-test )
+     *   - host-test-2 AAAA <non-routable address 2001::1234>
+     *   - host-test-2 AAAA <OMR address of ED 2>
+     *   - host-test-2 AAAA <ML-EID address of ED_2>
+     *   - with the following options: Update Lease Option Lease: 5 minutes Key Lease: 10 minutes.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    ed2.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+    addresses[0] = ed2.FindGlobalAddress();
+    addresses[1] = ed2.Get<Mle::Mle>().GetMeshLocalEid();
+    SuccessOrQuit(addresses[2].FromString("2001::1234"));
+    SuccessOrQuit(ed2.Get<Srp::Client>().SetHostAddresses(addresses, 3));
+
+    SuccessOrQuit(ed2.Get<Srp::Client>().SetHostName(kSrpHostName));
+    ed2.Get<Srp::Client>().SetLeaseInterval(kSrpLease5m);
+    ed2.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease10m);
+
+    Srp::Client::Service services[5];
+    Dns::TxtEntry        txtEntries[5][5];
+
+    ClearAllBytes(services);
+    ClearAllBytes(txtEntries);
+
+    // Service 1
+    services[0].mName             = kSrpServiceType;
+    services[0].mInstanceName     = "service-test-1";
+    services[0].mPort             = kSrpServicePort1;
+    services[0].mPriority         = 8;
+    services[0].mWeight           = 8;
+    txtEntries[0][0].mKey         = "dummy";
+    txtEntries[0][0].mValue       = reinterpret_cast<const uint8_t *>("abcd");
+    txtEntries[0][0].mValueLength = 4;
+    txtEntries[0][1].mKey         = "thread-test";
+    txtEntries[0][1].mValue       = reinterpret_cast<const uint8_t *>("a49");
+    txtEntries[0][1].mValueLength = 3;
+    services[0].mTxtEntries       = txtEntries[0];
+    services[0].mNumTxtEntries    = 2;
+    SuccessOrQuit(services[0].Init());
+    SuccessOrQuit(ed2.Get<Srp::Client>().AddService(services[0]));
+
+    // Service 2
+    services[1].mName             = kSrpServiceType;
+    services[1].mInstanceName     = "service-test-2";
+    services[1].mPort             = kSrpServicePort2;
+    services[1].mPriority         = 8;
+    services[1].mWeight           = 1;
+    txtEntries[1][0].mKey         = "a";
+    txtEntries[1][0].mValue       = reinterpret_cast<const uint8_t *>("2");
+    txtEntries[1][0].mValueLength = 1;
+    txtEntries[1][1].mKey         = "thread-test";
+    txtEntries[1][1].mValue       = reinterpret_cast<const uint8_t *>("b50_test");
+    txtEntries[1][1].mValueLength = 8;
+    services[1].mTxtEntries       = txtEntries[1];
+    services[1].mNumTxtEntries    = 2;
+    SuccessOrQuit(services[1].Init());
+    SuccessOrQuit(ed2.Get<Srp::Client>().AddService(services[1]));
+
+    // Service 3
+    services[2].mName             = kSrpServiceType;
+    services[2].mInstanceName     = "service-test-3";
+    services[2].mPort             = kSrpServicePort3;
+    services[2].mPriority         = 1;
+    services[2].mWeight           = 0;
+    txtEntries[2][0].mKey         = "a_dummy";
+    txtEntries[2][0].mValue       = reinterpret_cast<const uint8_t *>("42");
+    txtEntries[2][0].mValueLength = 2;
+    txtEntries[2][1].mKey         = "thread-test";
+    txtEntries[2][1].mValue       = reinterpret_cast<const uint8_t *>("");
+    txtEntries[2][1].mValueLength = 0;
+    services[2].mTxtEntries       = txtEntries[2];
+    services[2].mNumTxtEntries    = 2;
+    SuccessOrQuit(services[2].Init());
+    SuccessOrQuit(ed2.Get<Srp::Client>().AddService(services[2]));
+
+    // Service 4
+    services[3].mName             = kSrpServiceType;
+    services[3].mInstanceName     = "service-test-4";
+    services[3].mPort             = kSrpServicePort4;
+    services[3].mPriority         = 1;
+    services[3].mWeight           = 1;
+    txtEntries[3][0].mKey         = "thread-test";
+    txtEntries[3][0].mValue       = reinterpret_cast<const uint8_t *>("1");
+    txtEntries[3][0].mValueLength = 1;
+    txtEntries[3][1].mKey         = "ignorethis";
+    txtEntries[3][1].mValue       = nullptr;
+    txtEntries[3][1].mValueLength = 0;
+    txtEntries[3][2].mKey         = "thread-test";
+    txtEntries[3][2].mValue       = reinterpret_cast<const uint8_t *>("");
+    txtEntries[3][2].mValueLength = 0;
+    services[3].mTxtEntries       = txtEntries[3];
+    services[3].mNumTxtEntries    = 3;
+    SuccessOrQuit(services[3].Init());
+    SuccessOrQuit(ed2.Get<Srp::Client>().AddService(services[3]));
+
+    // Service 5
+    services[4].mName             = kSrpServiceType;
+    services[4].mInstanceName     = "service-test-5";
+    services[4].mPort             = kSrpServicePort5;
+    services[4].mPriority         = 2;
+    services[4].mWeight           = 2;
+    txtEntries[4][0].mKey         = "a";
+    txtEntries[4][0].mValue       = reinterpret_cast<const uint8_t *>("88");
+    txtEntries[4][0].mValueLength = 2;
+    txtEntries[4][1].mKey         = "thread-";
+    txtEntries[4][1].mValue       = nullptr;
+    txtEntries[4][1].mValueLength = 0;
+    txtEntries[4][2].mKey         = "THREAD-TEST";
+    txtEntries[4][2].mValue       = reinterpret_cast<const uint8_t *>("C51");
+    txtEntries[4][2].mValueLength = 3;
+    txtEntries[4][3].mKey         = "ignorethis";
+    txtEntries[4][3].mValue       = nullptr;
+    txtEntries[4][3].mValueLength = 0;
+    txtEntries[4][4].mKey         = "thread-test";
+    txtEntries[4][4].mValue       = nullptr;
+    txtEntries[4][4].mValueLength = 0;
+    services[4].mTxtEntries       = txtEntries[4];
+    services[4].mNumTxtEntries    = 5;
+    SuccessOrQuit(services[4].Init());
+    SuccessOrQuit(ed2.Get<Srp::Client>().AddService(services[4]));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+    {
+        Srp::Client::ItemState state = ed2.Get<Srp::Client>().GetHostInfo().GetState();
+        VerifyOrQuit(state == Srp::Client::kRegistered || state == Srp::Client::kToRefresh ||
+                     state == Srp::Client::kRefreshing);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Device: TD 1 (DUT) Description (SRPC-3.5): Enable");
+
+    /**
+     * Step 4
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.5): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    td1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Device: TD_1 (DUT) Description (SRPC-3.5): Harness instructs device to query for any services");
+
+    /**
+     * Step 5
+     * - Device: TD_1 (DUT)
+     * - Description (SRPC-3.5): Harness instructs device to query for any services of type
+     *   "_thread-test._udp.default.service.arpa." using unicast DNS query QType=PTR
+     * - Pass Criteria:
+     *   - The DUT MUST send a correctly formatted DNS query.
+     */
+
+    TestContext context;
+    context.mNode         = &td1;
+    context.mBrowseCount  = 0;
+    context.mResolveCount = 0;
+    context.mUdpCount     = 0;
+
+    Dns::Client::QueryConfig queryConfig = td1.Get<Dns::Client>().GetDefaultConfig();
+
+    AsCoreType(&queryConfig.mServerSockAddr).SetAddress(br1.Get<Mle::Mle>().GetMeshLocalEid());
+    queryConfig.mServerSockAddr.mPort = 53;
+    td1.Get<Dns::Client>().SetDefaultConfig(queryConfig);
+
+    SuccessOrQuit(td1.Get<Dns::Client>().Browse(kSrpFullServiceType, HandleBrowseResponse, &context));
+
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Device: BR 1 Description (SRPC-3.5): Automatically responds with all N services");
+
+    /**
+     * Step 6
+     * - Device: BR 1
+     * - Description (SRPC-3.5): Automatically responds with all N services.
+     * - Pass Criteria:
+     *   - Responds with DNS answer with all N services as indicated in step 3 in the Answer records section.
+     *   - DNS response Rcode MUST be 'NoError' (0).
+     *   - DNS response MUST contain N=5 answer records.
+     */
+
+    nexus.AdvanceTime(kDnsQueryTime * 2);
+
+    // Verify all steps completed
+    VerifyOrQuit(context.mBrowseCount == 5);
+    VerifyOrQuit(context.mResolveCount == 5);
+    VerifyOrQuit(context.mUdpCount == 5);
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("TD_1_MLEID_ADDR", td1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_2_MLEID_ADDR", ed2.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_2_OMR_ADDR", ed2.FindGlobalAddress().ToString().AsCString());
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRPC_TC_5((argc > 2) ? argv[2] : "test_1_3_SRPC_TC_5.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRPC_TC_7.cpp b/tests/nexus/test_1_3_SRPC_TC_7.cpp
new file mode 100644
index 0000000..da5ee8f
--- /dev/null
+++ b/tests/nexus/test_1_3_SRPC_TC_7.cpp
@@ -0,0 +1,244 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 10 * 1000;
+
+/**
+ * Time to wait for SRP registration to complete.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP Lease time in seconds.
+ */
+static constexpr uint32_t kSrpLease1h = 3600;
+
+/**
+ * SRP Key Lease time in seconds.
+ */
+static constexpr uint32_t kSrpKeyLease1d = 86400;
+
+/**
+ * SRP service port.
+ */
+static constexpr uint16_t kSrpServicePort = 33333;
+
+/**
+ * SRP service and host names.
+ */
+static const char kSrpServiceType[]  = "_thread-test._udp";
+static const char kSrpInstanceName[] = "service-test-1";
+static const char kSrpHostName[]     = "host-test-1";
+
+void Test_1_3_SRPC_TC_7(const char *aJsonFileName)
+{
+    /**
+     * 3.7. [1.3] [CERT] [COMPONENT] Thread Device Reboots - Re-registers service with same KEY
+     *
+     * 3.7.1. Purpose
+     * To verify that the Thread Component DUT:
+     * - Is able to re-register a service again after reboot.
+     * - Uses the same key for signing the SRP Update, as indicated in the KEY record.
+     *
+     * 3.7.2. Topology
+     * - BR 1 Border Router Reference Device and Leader
+     * - Router 1-Thread Router reference device
+     * - TD_1 (DUT)-Any Thread Device (FTD or MTD): Thread Component DUT
+     * - Eth 1-IPv6 host reference device on the AIL
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Client       | N/A          | 2.3.2
+     */
+
+    Core nexus;
+
+    Node &br1     = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &td1     = nexus.CreateNode();
+    Node &eth1    = nexus.CreateNode();
+
+    Srp::Client::Service service;
+
+    br1.SetName("BR_1");
+    router1.SetName("Router_1");
+    td1.SetName("TD_1");
+    eth1.SetName("Eth_1");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("Step 1: Enable & form topology");
+
+    /**
+     * Step 1
+     * - Device: Eth 1, BR 1, Router 1, TD 1
+     * - Description (SRPC-3.7): Enable & form topology
+     * - Pass Criteria:
+     *   - Single Thread Network is formed
+     */
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    router1.Join(br1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    td1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("TD_1_MLEID_ADDR", td1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("TD_1_OMR_ADDR", td1.FindGlobalAddress().ToString().AsCString());
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    Log("Step 2: Harness instructs the DUT to register the service");
+
+    /**
+     * Step 2
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.7): Harness instructs the DUT to register the service: $ORIGIN default.service.arpa.
+     *   service-test-1._thread-test._udp ( SRV 33333 host-test-1 ) host-test-1 AAAA <OMR address of TD_1> with the
+     *   following options: Update Lease Option Lease: 60 minutes Key Lease: 1 day
+     * - Pass Criteria:
+     *   - The DUT MUST send an SRP Update to BR_1
+     *   - BR_1 MUST respond Rcode=0 (NoError).
+     */
+
+    td1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(td1.Get<Srp::Client>().SetHostName(kSrpHostName));
+    SuccessOrQuit(td1.Get<Srp::Client>().EnableAutoHostAddress());
+    td1.Get<Srp::Client>().SetLeaseInterval(kSrpLease1h);
+    td1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease1d);
+
+    {
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        SuccessOrQuit(td1.Get<Srp::Client>().AddService(service));
+    }
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    VerifyOrQuit(td1.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("Step 3: Harness instructs device to reboot.");
+
+    /**
+     * Step 3
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.7): Harness instructs device to reboot.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    td1.Reset();
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("Step 4: (Repeat step 2)");
+
+    /**
+     * Step 4
+     * - Device: TD 1 (DUT)
+     * - Description (SRPC-3.7): (Repeat step 2)
+     * - Pass Criteria:
+     *   - The DUT MUST send an SRP Update to BR_1
+     *   - The KEY record in the SRP Update MUST have an equal value to the KEY record that was sent in step 2
+     *   - BR_1 MUST respond Rcode=0 (NoError).
+     */
+
+    td1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    td1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(td1.Get<Srp::Client>().SetHostName(kSrpHostName));
+    SuccessOrQuit(td1.Get<Srp::Client>().EnableAutoHostAddress());
+    td1.Get<Srp::Client>().SetLeaseInterval(kSrpLease1h);
+    td1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease1d);
+
+    {
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        SuccessOrQuit(td1.Get<Srp::Client>().AddService(service));
+    }
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    VerifyOrQuit(td1.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRPC_TC_7((argc > 2) ? argv[2] : "test_1_3_SRPC_TC_7.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_1.cpp b/tests/nexus/test_1_3_SRP_TC_1.cpp
new file mode 100644
index 0000000..a50e142
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_1.cpp
@@ -0,0 +1,585 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]     = "_thread-test._udp";
+static const char         kSrpInstanceName[]    = "service-test-1";
+static const char         kSrpHostName[]        = "host-test-1";
+static const char         kSrpFullServiceType[] = "_thread-test._udp.default.service.arpa.";
+static const char         kSrpFullHostName[]    = "host-test-1.default.service.arpa.";
+static constexpr uint16_t kSrpServicePort       = 55555;
+static constexpr uint16_t kSrpUpdatedPort       = 55556;
+
+void Test_1_3_SRP_TC_1(const char *aJsonFileName)
+{
+    /**
+     * 2.1. [1.3] [CERT] Register Single Service
+     *
+     * 2.1.1. Purpose
+     * To test the following:
+     * - 1. Handle SRP Update from Thread Device.
+     * - 2. Respond to unicast DNS queries on Thread interface.
+     * - 3. Respond to mDNS queries on infrastructure interface (by Advertising Proxy).
+     *
+     * 2.1.2. Topology
+     * - BR 1 (DUT) - Thread Border Router and the Leader
+     * - ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference | V1.1 Section | V1.3.0 Section
+     * ---------------|--------------|---------------
+     * SRP Server     | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    Srp::Client::Service service;
+    Ip6::Address         hostAddrs[2];
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    br1.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.1): Enable: power on.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) power on.");
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     *   Device: Eth 1, ED 1
+     *   Description (SRP-2.1): Enable
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth 1, ED 1 Enable.");
+    ed1.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    // Wait for BR to advertise prefixes and ED to get an OMR address
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.1): Automatically adds its SRP Server information in the Thread Network Data.
+     *   Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data. This can be verified as
+     *     follows (Harness team can choose method which works best:)
+     *   - DUT sends MLE Child ID Response message to ED_1 in step 2, where the message includes MLE
+     *     Network Data TLV if the Child ED_1 requested this. This network data is then verified.
+     *   - DUT sends MLE Data Response in step 1, 2 or 3 (per [ThreadSpec] Section 5.15.3)
+     *   - Specifically, Thread Network Data MUST include a DNS-SD Unicast Dataset as per [ThreadSpec] 14.3.2.2:
+     *   - S_service_data Length MUST be 1 or 19
+     *   - (Note: Field "IPv6 address" does not need to be verified in this step. Reason: if this field
+     *     would be wrong, step 4 would fail anyhow.)
+     */
+    Log("Step 3: BR 1 (DUT) adds its SRP Server information in the Thread Network Data.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 4
+     *   Device: ED 1
+     *   Description (SRP-2.1): Harness instructs the device to send SRP Update to the IPv6 address of the
+     *     node as indicated in the received DNS/SRP Unicast Dataset, to register a service: $ORIGIN
+     *     default.service.arpa. _thread-test._udp PTR service-test-1._thread-test._udp
+     *     service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 ) host-test-1 AAAA <ML-EID address of ED_1>
+     *     AAAA <OMR address of ED_1> Note: the above notation is defined at the top of this document. Note:
+     *     normally an SRP client would only register the OMR address in this case. For testing purposes,
+     *     both OMR/ML-EID are registered which deviates from usual client behavior.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED 1 sends SRP Update to register a service.");
+    {
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+
+        hostAddrs[0] = ed1.Get<Mle::Mle>().GetMeshLocalEid();
+
+        {
+            Ip6::Prefix omrPrefix;
+            SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+            hostAddrs[1] = ed1.FindMatchingAddress(omrPrefix.ToString().AsCString());
+        }
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostAddresses(hostAddrs, 2));
+
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 5
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.1): Automatically sends SRP Update Response.
+     *   Pass Criteria:
+     *   - The SRP Update Response MUST be valid; RCODE=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) sends SRP Update Response.");
+
+    /**
+     * Step 6
+     *   Device: ED 1
+     *   Description (SRP-2.1): Harness instructs the device to unicast a UDP DNS query QType PTR to the DUT
+     *     for services of type _thread-test_udp.default.service.arpa..
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED 1 sends unicast DNS query QType PTR.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.1): Automatically sends DNS response.
+     *   Pass Criteria:
+     *   - The DNS Response MUST be valid and contain answer record: $ORIGIN default.service.arpa.
+     *     _thread-test._udp PTR service-test-1._thread-test._udp
+     *   - It MAY also contain Additional section records: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 ) host-test-1 AAAA <ML-EID address of ED_1>
+     *     AAAA <OMR address of ED_1>
+     */
+    Log("Step 7: BR 1 (DUT) sends DNS response.");
+
+    /**
+     * Step 7b
+     *   Device: ED 1
+     *   Description (SRP-2.1): Harness instructs the device to unicast a UDP DNS query QTYPE SRV QNAME
+     *     service-test-1._thread-test._udp .default.service.arpa.
+     *   Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 )
+     */
+    Log("Step 7b: ED 1 sends unicast DNS query QTYPE SRV.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstanceName, kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7c
+     *   Device: ED 1
+     *   Description (SRP-2.1): Harness instructs the device to unicast a UDP DNS query QTYPE AAAA QNAME
+     *     host-test-1.default.service.arpa.
+     *   Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record: $ORIGIN default.service.arpa.
+     *     host-test-1 AAAA <ML-EID address of ED_1> AAAA <OMR address of ED_1>
+     */
+    Log("Step 7c: ED 1 sends unicast DNS query QTYPE AAAA.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().ResolveAddress(kSrpFullHostName, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 8
+     *   Device: Eth 1
+     *   Description (SRP-2.1): Harness instructs the device to send a mDNS query QType=PTR to the DUT for
+     *     services of type _thread-test_udp.local..
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: Eth 1 sends mDNS query QType=PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 9
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.1): Automatically sends mDNS Response. Note: the first two pass conditions on the
+     *     right are added to ensure the DUT does not propagate the mDNS onto its Thread Interface.
+     *   Pass Criteria:
+     *   - The DUT MUST NOT transmit an IPv6 multicast packet on the Thread Network to any of the addresses
+     *     FF0X::FB, where X is any hex digit.
+     *   - The DUT MUST NOT transmit any MPL multicast message on the Thread Network.
+     *   - The mDNS Response MUST be valid and contain answer record: _thread-test._udp.local PTR
+     *     ( service-test-1._thread-test._udp.local. )
+     *   - It MAY also contain Additional section records: service-test-1._thread-test._udp.local.
+     *     ( SRV 0 0 55555 host-test-1.local. ) host-test-1.local. AAAA <OMR address of ED_1>
+     *   - The mDNS Response MAY contain Additional section record: host-test-1.local. AAAA <ML-EID address of ED_1>
+     */
+    Log("Step 9: BR 1 (DUT) sends mDNS Response.");
+
+    /**
+     * Step 9b
+     *   Device: Eth_1
+     *   Description (SRP-2.1): Harness instructs the device to send mDNS query: QTYPE SRV QNAME
+     *     service-test-1._thread-test._udp.local
+     *   Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record: service-test-1._thread-test._udp.local
+     *     ( SRV 0 0 55555 host-test-1.local )
+     */
+    Log("Step 9b: Eth 1 sends mDNS query QTYPE SRV.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+        nexus.AdvanceTime(1000);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 9c
+     *   Device: Eth 1
+     *   Description (SRP-2.1): Harness instructs the device to send mDNS query: QTYPE AAAA QNAME host-test-1.local
+     *   Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record: host-test-1.local AAAA <OMR address of ED_1>
+     *   - mDNS Response MAY contain an additional Answer section record: host-test-1.local. AAAA
+     *     <ML-EID address of ED_1>
+     */
+    Log("Step 9c: Eth 1 sends mDNS query QTYPE AAAA.");
+    {
+        Dns::Multicast::Core::AddressResolver resolver;
+
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+        nexus.AdvanceTime(1000);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        ClearAllBytes(resolver);
+        resolver.mCallback     = [](otInstance *, const otPlatDnssdAddressResult *) {};
+        resolver.mHostName     = kSrpHostName;
+        resolver.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartIp6AddressResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopIp6AddressResolver(resolver));
+    }
+
+    /**
+     * Step 10
+     *   Device: ED 1
+     *   Description (SRP-2.1): Harness instructs the device to send SRP Update to the IPv6 address of the node
+     *     as indicated in the received DNS/SRP Unicast Dataset to update the service parameters and address:
+     *     Note: this invokes a "delete RRset" operation and an "add RRset" operation in the same SRP Update,
+     *     per 2.2.5.5.2 of [draft-srp-13]. $ORIGIN default.service.arpa. _thread-test._udp PTR
+     *     service-test-1._thread-test._udp service-test-1._thread-test._udp ( SRV 0 0 55556 host-test-1 )
+     *     host-test-1 AAAA <ML-EID address of ED_1> Note: normally an SRP client would only register the OMR
+     *     address in this case. For testing purposes, only the ML-EID is registered which deviates from
+     *     usual client behavior.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: ED 1 sends SRP Update to update the service parameters and address.");
+    {
+        ed1.Get<Srp::Client>().ClearHostAndServices();
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+
+        hostAddrs[0] = ed1.Get<Mle::Mle>().GetMeshLocalEid();
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostAddresses(hostAddrs, 1));
+
+        // Update existing service
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpUpdatedPort;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 11
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.1): Automatically sends SRP Update Response.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 11: BR 1 (DUT) sends SRP Update Response.");
+
+    /**
+     * Step 12
+     *   Device: ED 1
+     *   Description (SRP-2.1): Harness instructs the device to unicast a UDP DNS query QType PTR to the DUT
+     *     for services of type '_thread-test._udp.default.service.arpa'.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 12: ED 1 sends unicast DNS query QType PTR.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 13
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.1): Automatically sends DNS response with the updated service information.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response and contain answer record: $ORIGIN default.service.arpa.
+     *     _thread-test._udp PTR service-test-1._thread-test._udp
+     *   - It MAY contain Additional section records: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 0 0 55556 host-test-1 ) host-test-1 AAAA <ML-EID address of ED_1>
+     *   - The DNS Response MUST NOT contain an AAAA record with OMR address of ED_1.
+     */
+    Log("Step 13: BR 1 (DUT) sends DNS response.");
+
+    /**
+     * Step 13b
+     *   Device: ED 1
+     *   Description (SRP-2.1): Harness instructs the device to unicast a UDP DNS query QTYPE SRV QNAME
+     *     service-test-1._thread-test._udp default.service.arpa.
+     *   Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 and contain in the Answer section: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 0 0 55556 host-test-1 )
+     */
+    Log("Step 13b: ED 1 sends unicast DNS query QTYPE SRV.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstanceName, kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 13c
+     *   Device: ED 1
+     *   Description (SRP-2.1): Harness instructs the device to unicast a UDP DNS query QTYPE=AAAA QNAME
+     *     host-test-1.default.service.arpa
+     *   Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 and in the Answer section it MAY contain the record: $ORIGIN
+     *     default.service.arpa. host-test-1 AAAA <ML-EID address of ED_1>
+     *   - DNS Response MUST NOT contain an AAAA record with OMR address of ED_1.
+     */
+    Log("Step 13c: ED 1 sends unicast DNS query QTYPE=AAAA.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().ResolveAddress(kSrpFullHostName, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 14
+     *   Device: Eth_1
+     *   Description (SRP-2.1): Harness instructs the device to clear its mDNS cache and then send a mDNS query
+     *     QType=PTR to the DUT for services of type _thread-test._udp.local..
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 14: Eth 1 sends mDNS query QType=PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+        nexus.AdvanceTime(1000);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 15
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.1): Automatically sends mDNS Response.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response and contain answer record: _thread-test._udp.local PTR
+     *     ( service-test-1._thread-test._udp.local. )
+     *   - It MAY contain Additional section record: service-test-1._thread-test._udp.local.
+     *     ( SRV 0 0 55556 host-test-1.local. )
+     *   - It MAY contain in the Additional section records: host-test-1 AAAA <ML-EID address of ED_1>
+     *   - The mDNS Response MUST NOT contain any answer records like; <any-string> AAAA <OMR address of ED_1>,
+     *     <any-string> KEY <some-value>
+     */
+    Log("Step 15: BR 1 (DUT) sends mDNS Response.");
+
+    /**
+     * Step 15b
+     *   Device: Eth_1
+     *   Description (SRP-2.1): Harness instructs the device to send mDNS query QTYPE SRV QNAME
+     *     service-test-1._thread-test._udp.local
+     *   Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record: service-test-1._thread-test._udp.local
+     *     ( SRV 0 0 55556 host-test-1.local )
+     */
+    Log("Step 15b: Eth 1 sends mDNS query QTYPE SRV.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+        nexus.AdvanceTime(1000);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 15c
+     *   Device: Eth_1
+     *   Description (SRP-2.1): Harness instructs the device to send mDNS query QTYPE AAAA QNAME host-test-1.local
+     *   Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 and MAY contain Answer section record: host-test-1.local. AAAA
+     *     <ML-EID address of ED_1>
+     *   - The mDNS Response MUST NOT contain any answer records like; host-test-1.local. AAAA
+     *     <OMR address of ED_1>
+     */
+    Log("Step 15c: Eth 1 sends mDNS query QTYPE AAAA.");
+    {
+        Dns::Multicast::Core::AddressResolver resolver;
+
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+        nexus.AdvanceTime(1000);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        ClearAllBytes(resolver);
+        resolver.mCallback     = [](otInstance *, const otPlatDnssdAddressResult *) {};
+        resolver.mHostName     = kSrpHostName;
+        resolver.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartIp6AddressResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopIp6AddressResolver(resolver));
+    }
+
+    Log("All steps completed.");
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_1_MLEID_ADDR", ed1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+
+    {
+        Ip6::Prefix omrPrefix;
+        SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+        nexus.AddTestVar("ED_1_OMR_ADDR",
+                         ed1.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+    }
+
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_1((argc > 2) ? argv[2] : "test_1_3_SRP_TC_1.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_11.cpp b/tests/nexus/test_1_3_SRP_TC_11.cpp
new file mode 100644
index 0000000..5203735
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_11.cpp
@@ -0,0 +1,524 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]   = "_thread-test._udp";
+static const char         kSrpInstanceName[]  = "service-test-1";
+static const char         kSrpHostName[]      = "host-test-1";
+static constexpr uint16_t kSrpServicePort     = 1500;
+static constexpr uint8_t  kSrpServicePriority = 8;
+static constexpr uint8_t  kSrpServiceWeight   = 8;
+static constexpr uint32_t kSrpLease           = 300; // 5 minutes
+static constexpr uint32_t kSrpKeyLease        = 600; // 10 minutes
+
+void Test_1_3_SRP_TC_11(const char *aJsonFileName)
+{
+    Srp::Client::Service service;
+
+    /**
+     * 2.11. [1.3] [CERT] Recovery after reboot
+     *
+     * 2.11.1. Purpose
+     * To test the following:
+     * - 1. Handle SRP update after reboot of Thread device.
+     * - 2. Handle SRP update after reboot of Border Router
+     * - 3. Respond to mDNS queries on infrastructure interface after reboot of infrastructure device.
+     *
+     * 2.11.2. Topology
+     * - 1. BR_1 (DUT) - Thread Border Router, and the Leader
+     * - 2. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - 3. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Server       | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) Enable");
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    MeshCoP::Dataset::Info datasetInfo;
+    SuccessOrQuit(br1.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
+
+    /**
+     * Step 2
+     * - Device: Eth 1, ED 1
+     * - Description (SRP-2.11): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth 1, ED 1 Enable");
+    ed1.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    // Wait for BR to advertise prefixes and ED to get an OMR address
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically adds SRP Server information in the Thread Network Data.
+     * - Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data. This MUST be a
+     *     DNS/SRP Unicast Dataset, or a DNS/SRP Anycast Dataset, or both.
+     *   - In case of Anycast Dataset: record the SRP-SN value for later use.
+     */
+    Log("Step 3: BR 1 (DUT) Automatically adds SRP Server information in the Thread Network Data.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    nexus.AddTestVar("BR_1_SRP_PORT_1", br1.Get<Srp::Server>().GetPort());
+
+    /**
+     * Step 4
+     * - Device: ED_1
+     * - Description (SRP-2.11): Harness instructs the device to send SRP Update: $ORIGIN
+     *   default.service.arpa. service-test-1._thread-test._udp ( SRV 8 1500 host-test-1 ) host-test-1
+     *   AAAA <OMR address of ED_1> KEY <public key of ED_1> with the following options: Update Lease
+     *   Option, Lease: 5 minutes, Key Lease: 10 minutes
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED_1 send SRP Update");
+    {
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ed1.Get<Srp::Client>().SetLeaseInterval(kSrpLease);
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease);
+
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        service.mPriority     = kSrpServicePriority;
+        service.mWeight       = kSrpServiceWeight;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 5
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) Automatically sends SRP Update Response.");
+
+    /**
+     * Step 6
+     * - Device: Eth_1
+     * - Description (SRP-2.11): Harness instructs the device to send mDNS query QType SRV for
+     *   "service-test-1. thread-test, udp.local.".
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: Eth_1 send mDNS query QType SRV");
+    {
+        eth1.Reset();
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+        nexus.AdvanceTime(kJoinNetworkTime);
+
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 7
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError); and contains
+     *     service-test-1 answer record: SORIGIN local. service-test-1. thread-test._udp ( SRV 8 8 1500
+     *     host-test-1 )
+     */
+    Log("Step 7: BR 1 (DUT) Automatically sends mDNS Response.");
+
+    /**
+     * Step 8
+     * - Device: ED 1
+     * - Description (SRP-2.11): Harness instructs the device to reboot/reset. Note: ED 1 MUST
+     *   persist its public/private keypair that is used for signing SRP Updates. So it is not a
+     *   factory-reset.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: ED 1 reboot/reset");
+    ed1.Reset();
+    ed1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    /**
+     * Step 9
+     * - Device: ED 1
+     * - Description (SRP-2.11): Harness instructs the device to send SRP Update, as in step 4.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 9: ED 1 send SRP Update");
+    {
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ed1.Get<Srp::Client>().SetLeaseInterval(kSrpLease);
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease);
+
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        service.mPriority     = kSrpServicePriority;
+        service.mWeight       = kSrpServiceWeight;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 10
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 10: BR 1 (DUT) Automatically sends SRP Update Response.");
+
+    /**
+     * Step 11
+     * - Device: Eth_1
+     * - Description (SRP-2.11): Harness instructs the device to send mDNS query QType=SRV for
+     *   "service-test-1._thread-test_udp.local.".
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 11: Eth_1 send mDNS query QType=SRV");
+    {
+        eth1.Reset();
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+        nexus.AdvanceTime(kJoinNetworkTime);
+
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 12
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError); and contains
+     *     service-test-1 answer record: SORIGIN local. service-test-1. thread-test._udp ( SRV 1500
+     *     host-test-1 )
+     */
+    Log("Step 12: BR 1 (DUT) Automatically sends mDNS Response.");
+
+    /**
+     * Step 13
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): User is instructed to reboot the border router (DUT). Note: In case
+     *   DUT uses an Anycast Dataset, this will include a different SRP-SN (compared to the previous
+     *   SRP-SN recorded in step 3). This will then trigger re-registration in the next step. In
+     *   case DUT uses a Unicast Dataset, this will include a different port number (compared to the
+     *   previous port value used in step 3). This will then trigger re-registration.
+     * - Pass Criteria:
+     *   - After reboot, the DUT's SRP Server information MUST appear in the Thread Network Data.
+     *     This MUST be a DNS/SRP Unicast Dataset, or DNS/SRP Anycast Dataset, or both.
+     *   - For an Anycast Dataset, the SRP-SN SHOULD be different but MAY be equal (in case DUT
+     *     persistently stores data, only).
+     *   - For a Unicast Dataset, the port number MUST differ.
+     */
+    Log("Step 13: BR 1 (DUT) reboot");
+    {
+        br1.Reset();
+        br1.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
+        br1.Get<ThreadNetif>().Up();
+        SuccessOrQuit(br1.Get<Mle::Mle>().Start());
+        SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+        br1.Get<Srp::Server>().SetEnabled(true);
+        br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+        br1.Get<BorderRouter::RoutingManager>().Init();
+        SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+        nexus.AdvanceTime(kFormNetworkTime);
+        nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+        nexus.AddTestVar("BR_1_SRP_PORT_2", br1.Get<Srp::Server>().GetPort());
+    }
+
+    /**
+     * Step 14
+     * - Device: ED_1
+     * - Description (SRP-2.11): Automatically re-registers using SRP Update, as in step 4.
+     * - Pass Criteria:
+     *   - MUST automatically re-register with SRP Update as in step 4.
+     */
+    Log("Step 14: ED_1 Automatically re-registers using SRP Update");
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 15
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 15: BR 1 (DUT) Automatically sends SRP Update Response.");
+
+    /**
+     * Step 16
+     * - Device: Eth_1
+     * - Description (SRP-2.11): Harness instructs the device to send mDNS query QType SRV for
+     *   "service-test-1. thread-test_udp.local.".
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 16: Eth_1 send mDNS query QType SRV");
+    {
+        eth1.Reset();
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+        nexus.AdvanceTime(kJoinNetworkTime);
+
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 17
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError); and contains
+     *     service-test-1 answer record: SORIGIN local. service-test-1._thread-test._udp ( SRV 8 8 1500
+     *     host-test-1 )
+     */
+    Log("Step 17: BR 1 (DUT) Automatically sends mDNS Response.");
+
+    /**
+     * Step 18
+     * - Device: Eth 1
+     * - Description (SRP-2.11): Harness instructs the device to reset
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 18: Eth 1 reset");
+    eth1.Reset();
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    /**
+     * Step 19
+     * - Device: ED 1
+     * - Description (SRP-2.11): Harness instructs the device to send SRP Update, as in step 4.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 19: ED 1 send SRP Update");
+    {
+        // ED_1 will automatically refresh when lease is close to expiry,
+        // but here we manually trigger an update by clearing and re-adding if needed,
+        // or just wait for next registration if the test implies another update.
+        // The spec says "Harness instructs the device to send SRP Update, as in step 4".
+        ed1.Get<Srp::Client>().ClearHostAndServices();
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ed1.Get<Srp::Client>().SetLeaseInterval(kSrpLease);
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease);
+
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        service.mPriority     = kSrpServicePriority;
+        service.mWeight       = kSrpServiceWeight;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 20
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 20: BR 1 (DUT) Automatically sends SRP Update Response.");
+
+    /**
+     * Step 21
+     * - Device: Eth 1
+     * - Description (SRP-2.11): Harness instructs the device to send mDNS query QType SRV for
+     *   "service-test-1. thread-test._udp.local.".
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 21: Eth 1 send mDNS query QType SRV");
+    {
+        eth1.Reset();
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+        nexus.AdvanceTime(kJoinNetworkTime);
+
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 22
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.11): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError); and contains
+     *     service-test-1. SORIGIN local. service-test-1. thread-test._udp ( SRV 8 8 1500 host-test-1 )
+     */
+    Log("Step 22: BR 1 (DUT) Automatically sends mDNS Response.");
+
+    Log("All steps completed.");
+
+    {
+        Ip6::Prefix omrPrefix;
+        SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+        nexus.AddTestVar("ED_1_OMR", ed1.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+    }
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_11((argc > 2) ? argv[2] : "test_1_3_SRP_TC_11.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_12.cpp b/tests/nexus/test_1_3_SRP_TC_12.cpp
new file mode 100644
index 0000000..e3100ac
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_12.cpp
@@ -0,0 +1,315 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the DUT to withdraw its Unicast Dataset.
+ */
+static constexpr uint32_t kWithdrawTime = 35 * 1000;
+
+/**
+ * SRP Anycast sequence numbers.
+ */
+static constexpr uint8_t kBr1AnycastSeqNum = 100;
+static constexpr uint8_t kBr2AnycastSeqNum = 255;
+
+/**
+ * SRP Version.
+ */
+static constexpr uint8_t kSrpVersion = 1;
+
+/**
+ * Fake SRP Server addresses and ports.
+ */
+static const char *const  kBr2UnicastAddr = "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff";
+static constexpr uint16_t kBr2UnicastPort = 55555;
+static const char *const  kBr3UnicastAddr = "fd80::1";
+static constexpr uint16_t kBr3UnicastPort = 55556;
+
+void Test_1_3_SRP_TC_12(const char *aJsonFileName)
+{
+    /**
+     * 2.12. [1.3] [CERT] SRP Service Advertisement - Multiple BRs
+     *
+     * 2.12.1. Purpose
+     * To test the following:
+     * - 1. DNS/SRP service advertisement by all BRs in the Thread Network.
+     * - 2. DUT must advertise its own DNS/SRP service initially.
+     * - 3. DUT as Leader integrates all the advertised DNS/SRP services.
+     *
+     * 2.12.2. Topology
+     * - BR 1 (DUT) - Thread Border Router, and the Leader
+     * - BR 2 - Border Router
+     * - BR 3 - Border Router
+     *
+     * Spec Reference | V1.1 Section | V1.3.0 Section
+     * ---------------|--------------|---------------
+     * SRP Server     | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1 = nexus.CreateNode();
+    Node &br2 = nexus.CreateNode();
+    Node &br3 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    br3.SetName("BR_3");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.12): Enable; switch on.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) Enable; switch on.");
+    br1.Form();
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAnycastModeSequenceNumber(kBr1AnycastSeqNum));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.12): Automatically assumes the Leader role Automatically adds SRP Server
+     *     information in the Thread Network Data. Automatically registers itself as a border router
+     *     in the Thread Network Data.
+     *   Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data. It MUST advertise at
+     *     least one Dataset of the below:
+     *     - 1. DNS/SRP Anycast Dataset
+     *     - 2. DNS/SRP Unicast Dataset
+     *   - Specifically, in case of DNS/SRP Unicast Dataset as per [ThreadSpec] 14.3.2.2: S_service_data
+     *     Length MUST be 1 or 19
+     *   - Specifically, in case of DNS/SRP Anycast Dataset as per [ThreadSpec] 14.3.2.1: S_service_data
+     *     Length MUST be 2 S_server_data Length MUST be 0.
+     */
+    Log("Step 2: BR 1 (DUT) adds SRP Server information in the Thread Network Data.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 3
+     *   Device: BR 2
+     *   Description (SRP-2.12): Enable.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 3: BR 2 Enable.");
+    br2.Join(br1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    /**
+     * Step 4
+     *   Device: BR_2
+     *   Description (SRP-2.12): Harness instructs device to add its SRP Server information in the
+     *     Thread Network Data with a (faked) high numerical address: DNS/SRP Unicast Dataset Service
+     *     TLV S_service_data Length = 1 S service data: 0x5D Server TLV S_server_data Length = 18
+     *     S_server_16: (default RLOC of itself) S server data: Address: fdffffffffffffffffff::
+     *     S_server data: Port: <auto-selected by BR_2> Automatically registers itself as a border
+     *     router in the Thread Network Data.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: BR 2 adds its SRP Server information with a (faked) high numerical address.");
+    {
+        Ip6::Address br2Addr;
+        SuccessOrQuit(br2Addr.FromString(kBr2UnicastAddr));
+        SuccessOrQuit(br2.Get<NetworkData::Service::Manager>().AddDnsSrpUnicastServiceWithAddrInServerData(
+            br2Addr, kBr2UnicastPort, kSrpVersion));
+        br2.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 5
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.12): Automatically integrates BR_2's SRP Server information in the Thread
+     *     Network Data. Note: BR 1 will not withdraw its own Unicast Dataset if it advertised that
+     *     type, because that has a numerically lower IPv6 address w.r.t. the BR 2 Dataset.
+     *   Pass Criteria:
+     *   - At least 2 Datasets MUST be present in in the Thread Network Data:
+     *     - 1. BR 1 (Anycast and/or Unicast) Dataset as in Step 2.
+     *     - 2. BR 2 Unicast Dataset as defined in Step 4.
+     */
+    Log("Step 5: BR 1 (DUT) integrates BR 2's SRP Server information.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 6
+     *   Device: BR 3
+     *   Description (SRP-2.12): Enable.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: BR 3 Enable.");
+    br3.Join(br1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    /**
+     * Step 7
+     *   Device: BR 3
+     *   Description (SRP-2.12): Harness instructs device to add its SRP Server information in the
+     *     Thread Network Data with a (faked) low numerical address: DNS/SRP Unicast Dataset Service
+     *     TLV S_service_data Length = 19 S_service data: 0x5D S_service_data: Address: fd80:::1
+     *     S_service_data: Port: <auto-selected by BR 3> Server TLV: S_server 16: (default own RLOC
+     *     address) Automatically registers itself as a border router in the Thread Network Data.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 7: BR 3 adds its SRP Server information with a (faked) low numerical address.");
+    {
+        Ip6::Address br3Addr;
+        SuccessOrQuit(br3Addr.FromString(kBr3UnicastAddr));
+        SuccessOrQuit(br3.Get<NetworkData::Service::Manager>().AddDnsSrpUnicastServiceWithAddrInServiceData(
+            br3Addr, kBr3UnicastPort, kSrpVersion));
+        br3.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 8
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.12): Automatically integrates BR 3's SRP Server information in the Thread
+     *     Network Data and withdraws its own Unicast Dataset if it had any. Note: for a Thread 1.3
+     *     DUT, the reason to withdraw is that there are more than NETDATA NUM_UNICAST_DNS SRP DATASET
+     *     ENTRIES (2) Unicast Datasets in the network data, so above target. Note: for a Thread 1.4
+     *     DUT, the reason to withdraw is that there are is a Unicast Dataset of a more preferred
+     *     type (the one added by BR_3) than its own automatically added type.
+     *   Pass Criteria:
+     *   - The DUT now includes 3 or 4 Datasets in its Thread Network Data. (2 unicast, 1 Anycast,
+     *     optionally 1 Anycast of its own)
+     *   - If the DUT advertised a Unicast Dataset, it MUST withdraw it. So after some time (up to 32
+     *     seconds) the DUT MUST NOT advertise any Unicast Dataset in the Thread Network Data that
+     *     Includes the DUT's RLOC in a Server TLV. Includes the DUT's ML-EID in a Service TLV as
+     *     address.
+     *   - Thread Network Data MUST contain Unicast Datasets of BR_2 and BR_3 as specified above in
+     *     step 4 and 7.
+     */
+    Log("Step 8: BR 1 (DUT) integrates BR 3's SRP Server information and withdraws its own Unicast Dataset.");
+    nexus.AdvanceTime(kWithdrawTime);
+
+    /**
+     * Step 9
+     *   Device: BR 2
+     *   Description (SRP-2.12): Harness instructs device to register an additional Dataset with the
+     *     Leader, as follows: DNS/SRP Anycast Dataset S_service_data Length = 2 Service TLV S service
+     *     data: 0x5C S_service data: SRP-SN: 255 Server TLV S_server_16: (default RLOC of itself)
+     *     Note: an OT-BR reference device can only be configured with one DNS/SRP dataset when using
+     *     the regular CLI commands. To force the above addition, the commands: service add 44978 5cff
+     *     netdata register may be used. Note that the first command requires an OT CLI update
+     *     provided by PR #10377. If this PR is not merged yet into the reference device code, as a
+     *     workaround the argument 00' can be added temporarily.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 9: BR 2 adds an additional Anycast Dataset.");
+    {
+        SuccessOrQuit(br2.Get<NetworkData::Service::Manager>().AddDnsSrpAnycastService(kBr2AnycastSeqNum, kSrpVersion));
+        br2.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 10
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.12): Automatically integrates BR_2's new Anycast Dataset and MAY withdraw
+     *     its own Anycast Dataset if it had any. Whether it withdraws or not depends on its
+     *     advertised SRP-SN.
+     *   Pass Criteria:
+     *   - Thread Network Data MUST contain Unicast Datasets of BR_2 and BR 3 as specified above in
+     *     step 8.
+     *   - Thread Network Data MUST NOT contain Unicast Dataset of BR 1.
+     *   - Thread Network Data MUST contain Anycast Dataset of BR_2 defined in step 9
+     *   - In case the DUT advertised Anycast Dataset in step 2, the Test Harness now identifies the
+     *     SRP-SN (final byte of service data) that the DUT advertised in step 2, and: If SRP-SN <=
+     *     128, then DUT MUST withdraw its Anycast Dataset. If SRP-SN >= 129, then DUT MUST NOT
+     *     withdraw its Anycast Dataset.
+     */
+    Log("Step 10: BR 1 (DUT) integrates BR 2's new Anycast Dataset.");
+    nexus.AdvanceTime(kWithdrawTime);
+
+    Log("All steps completed.");
+
+    nexus.AddTestVar("BR_2_UNICAST_PORT", kBr2UnicastPort);
+    nexus.AddTestVar("BR_3_UNICAST_PORT", kBr3UnicastPort);
+    nexus.AddTestVar("BR_1_ANYCAST_SEQ", kBr1AnycastSeqNum);
+    nexus.AddTestVar("BR_2_ANYCAST_SEQ", kBr2AnycastSeqNum);
+
+    nexus.AddTestVar("BR_2_UNICAST_ADDR", kBr2UnicastAddr);
+    nexus.AddTestVar("BR_3_UNICAST_ADDR", kBr3UnicastAddr);
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_12((argc > 2) ? argv[2] : "test_1_3_SRP_TC_12.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_13.cpp b/tests/nexus/test_1_3_SRP_TC_13.cpp
new file mode 100644
index 0000000..0cc779a
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_13.cpp
@@ -0,0 +1,363 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]  = "_thread-test._udp";
+static const char         kSrpInstanceName[] = "service-test-1";
+static const char         kSrpHostName[]     = "host-test-1";
+static const char         kSrpFullHostName[] = "host-test-1.default.service.arpa.";
+static constexpr uint16_t kSrpServicePort    = 55555;
+static constexpr uint32_t kSrpLease          = 300;     // 5 minutes
+static constexpr uint32_t kSrpKeyLease       = 2419200; // 4 weeks
+
+void Test_1_3_SRP_TC_13(const char *aJsonFileName)
+{
+    /**
+     * 2.13. [1.3] [CERT] Thread Device address update
+     *
+     * 2.13.1. Purpose
+     * To test the following:
+     * - 1. Handle SRP update with change in IPv6 address(es) of the Thread Device
+     *
+     * 2.13.2. Topology
+     * - 1. BR 1 (DUT) - Thread Border Router, and the Leader
+     * - 2. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - 3. Eth 1-Test bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Server       | N/A          | 1.3
+     */
+
+    Core                 nexus;
+    Srp::Client::Service service;
+    Ip6::Address         addresses[2];
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.13): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) Enable");
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     * - Device: Eth 1, ED 1
+     * - Description (SRP-2.13): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth 1, ED 1 Enable");
+    ed1.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.13): Automatically adds its SRP Server information in the Thread Network Data.
+     *   Automatically adds an OMR Prefix in the Network Data.
+     * - Pass Criteria:
+     *   - The DUT MUST register an OMR Prefix in the Thread Network Data.
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data.
+     *   - It MUST be a DNS/SRP Unicast Dataset, or DNS/SRP Anycast Dataset, or both.
+     */
+    Log("Step 3: BR 1 (DUT) SRP Server information and OMR Prefix in Network Data");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    Ip6::Prefix omrPrefix;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_1_MLEID_ADDR", ed1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    /**
+     * Step 4
+     * - Device: ED_1
+     * - Description (SRP-2.13): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+     *   _thread-test. udp PTR service-test-1._thread-test._udp service-test-1. thread-test._udp. ( SRV 55555
+     *   host-test-1 ) host-test-1 AAAA address_1 with the following options: Update Lease Option, Lease: 5 minutes,
+     *   Key Lease: 4 weeks and where address_1 is the OMR address of ED_1.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED_1 send SRP Update with address_1");
+    Ip6::Address address1 = ed1.FindMatchingAddress(omrPrefix.ToString().AsCString());
+    nexus.AddTestVar("ADDRESS_1", address1.ToString().AsCString());
+    ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+    SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+    ed1.Get<Srp::Client>().SetLeaseInterval(kSrpLease);
+    ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease);
+
+    ClearAllBytes(service);
+    service.mName         = kSrpServiceType;
+    service.mInstanceName = kSrpInstanceName;
+    service.mPort         = kSrpServicePort;
+    SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 5
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.13): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) Automatically sends SRP Update Response");
+
+    /**
+     * Step 6
+     * - Device: ED 1
+     * - Description (SRP-2.13): Harness instructs the device to send unicast QType=AAAA record DNS query for
+     *   host-test-1.default.service.arpa.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED 1 send DNS query for host-test-1");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveAddress(kSrpFullHostName, nullptr, nullptr));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.13): Automatically send DNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response containing: $ORIGIN default.service.arpa. host-test-1 AAAA address_1
+     */
+    Log("Step 7: BR 1 (DUT) Automatically send DNS Response");
+
+    /**
+     * Step 8
+     * - Device: ED 1
+     * - Description (SRP-2.13): Harness instructs the device to send SRP update with an address change: $ORIGIN
+     *   default.service.arpa. _thread-test. udp PTR service-test-1._thread-test._udp service-test-1. thread-test._udp (
+     *   SRV 55555 host-test-1 ) host-test-1 AAAA address 2 host-test-1 AAAA address_3 with the following options:
+     *   Update Lease Option, Lease: 5 minutes, Key Lease: 4 weeks where address_2 is the ML-EID address of ED 1. And
+     *   address_3 is a new Harness-configured address that has the OMR prefix. Note: using the OT command srp client
+     *   host address address 2 address 3, these two addresses can be set for the SRP registration. Note: the OMR
+     *   prefix can be obtained automatically by the Harness by querying netdata on ED_1.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: ED 1 send SRP update with address_2 and address_3");
+    Ip6::Address address2 = ed1.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address address3 = address1;
+    address3.mFields.m8[15] ^= 0xff; // Change the last byte to make it a "new" address
+    nexus.AddTestVar("ADDRESS_2", address2.ToString().AsCString());
+    nexus.AddTestVar("ADDRESS_3", address3.ToString().AsCString());
+    addresses[0] = address2;
+    addresses[1] = address3;
+    SuccessOrQuit(ed1.Get<Srp::Client>().SetHostAddresses(addresses, 2));
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 9
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.13): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 9: BR 1 (DUT) Automatically sends SRP Update Response");
+
+    /**
+     * Step 10
+     * - Device: ED_1
+     * - Description (SRP-2.13): Harness instructs the device to send QType=AAAA record unicast DNS query for
+     *   host-test-1.default.service.arpa.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: ED 1 send DNS query for host-test-1");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveAddress(kSrpFullHostName, nullptr, nullptr));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 11
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.13): Automatically sends DNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response which contains the answer records: $ORIGIN default.service.arpa.
+     *     host-test-1 AAAA address_2 host-test-1 AAAA address_3
+     *   - The DNS Response MUST NOT contain address_1.
+     */
+    Log("Step 11: BR 1 (DUT) Automatically sends DNS Response");
+
+    /**
+     * Step 12
+     * - Device: Eth_1
+     * - Description (SRP-2.13): Harness instructs the device to send QType=AAAA record multicast mDNS query for
+     *   host-test-1.local.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 12: Eth 1 send mDNS query for host-test-1.local");
+    {
+        Dns::Multicast::Core::AddressResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback     = [](otInstance *, const otPlatDnssdAddressResult *) {};
+        resolver.mHostName     = kSrpHostName;
+        resolver.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartIp6AddressResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopIp6AddressResolver(resolver));
+    }
+
+    /**
+     * Step 13
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.13): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response which contains at least one address: $ORIGIN local. Host-test-1 AAAA
+     *     address_3
+     *   - The response MAY further include the ML-EID address: $ORIGIN local. Host-test-1 AAAA address_2
+     *   - mDNS Response MUST NOT contain address_1.
+     */
+    Log("Step 13: BR 1 (DUT) Automatically sends mDNS Response");
+
+    /**
+     * Step 14
+     * - Device: Eth_1
+     * - Description (SRP-2.13): Harness instructs the device to send QType KEY record multicast mDNS query for
+     *   host-test-1.local.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 14: Eth 1 send mDNS query for KEY record");
+    {
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        Dns::Multicast::Core::RecordQuerier querier;
+        ClearAllBytes(querier);
+        querier.mCallback     = [](otInstance *, const otPlatDnssdRecordResult *) {};
+        querier.mFirstLabel   = kSrpHostName;
+        querier.mRecordType   = Dns::ResourceRecord::kTypeKey;
+        querier.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartRecordQuerier(querier));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopRecordQuerier(querier));
+    }
+
+    /**
+     * Step 15
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.13): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response, with RCODE=0.
+     *   - It MAY contain either 0 Answer records, or it MAY contain 1 Answer record as follows: $ORIGIN local.
+     *     Host-test-1 KEY some public key
+     *   - The value some public key MAY be a dummy key value, for example zero.
+     */
+    Log("Step 15: BR 1 (DUT) Automatically sends mDNS Response");
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_13((argc > 2) ? argv[2] : "test_1_3_SRP_TC_13.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_15.cpp b/tests/nexus/test_1_3_SRP_TC_15.cpp
new file mode 100644
index 0000000..a2266e2
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_15.cpp
@@ -0,0 +1,650 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]     = "_test-type._tcp";
+static const char         kSrpInstanceName[]    = "service-test-1";
+static const char         kSrpHostName[]        = "host-test-1";
+static const char         kSrpFullServiceType[] = "_test-type._tcp.default.service.arpa.";
+static const char         kSrpSubtype1[]        = "_test-subtype";
+static const char         kSrpSubtype2[]        = "_other-subtype";
+static const char         kSrpSubtypeInvalid[]  = "_test-subtypee";
+static constexpr uint16_t kSrpServicePort       = 55555;
+
+static const char *const kSubTypes1[] = {kSrpSubtype1, nullptr};
+static const char *const kSubTypes2[] = {kSrpSubtype1, kSrpSubtype2, nullptr};
+static const char *const kSubTypes3[] = {kSrpSubtype2, nullptr};
+
+void Test_1_3_SRP_TC_15(const char *aJsonFileName)
+{
+    Srp::Client::Service service;
+
+    /**
+     * 2.15. [1.3] [CERT] Validation of SRP subtypes
+     *
+     * 2.15.1 Purpose
+     * To test the following:
+     * - 1. Handle service that includes additional subtypes of the basic service type.
+     * - 2. Respond to the DNS queries for the basic service type.
+     * - 3. Respond to the DNS queries for the service subtype.
+     * - 4. Handle addition of new subtype to the service.
+     * - 5. Handle removal of a subtype from the service.
+     * - 6. Test service types using 'top' in <Service> part of name.
+     *
+     * 2.15.2 Topology
+     * - 3. BR 1 (DUT) - Thread Border Router, and the Leader
+     * - 4. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - 5. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Server       | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    br1.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Enable
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) Enable");
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     *   Device: Eth 1, ED 1
+     *   Description (SRP-2.15): Enable
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth 1, ED 1 Enable");
+    ed1.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    // Wait for BR to advertise prefixes and ED to get an OMR address
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically adds SRP Server information in the Thread Network Data.
+     *   Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data.
+     */
+    Log("Step 3: BR 1 (DUT) adds SRP Server information in Network Data");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 4
+     *   Device: ED 1
+     *   Description (SRP-2.15): Harness instructs the device to send SRP Update to the DUT that includes a subtype:
+     *     $ORIGIN default.service.arpa. _test-subtype._sub._test-type._tcp ( PTR service-test-1._test-type._tcp)
+     *     _test-type._tcp ( PTR service-test-1._test-type._tcp) service-test-1._test-type._tcp ( SRV 8 55555
+     *     host-test-1) host-test-1 AAAA <OMR address of ED 1>
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED 1 sends SRP Update with a subtype");
+    {
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service);
+        service.mName          = kSrpServiceType;
+        service.mInstanceName  = kSrpInstanceName;
+        service.mPort          = kSrpServicePort;
+        service.mSubTypeLabels = kSubTypes1;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 5
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends SRP Update Response.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) sends SRP Update Response");
+
+    /**
+     * Step 6
+     *   Device: ED 1
+     *   Description (SRP-2.15): Harness instructs the device to unicast DNS query QType = PTR for
+     *     "_test-type._tcp.default.service.arpa" service type to the DUT.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED 1 sends unicast DNS query QType PTR for service type");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.15): Automatically sends DNS response with service-test-1.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response and contain service-test-1 as follows in the Answer section:
+     *     $ORIGIN default.service.arpa. _test-type._tcp ( PTR service-test-1. test-type._tcp)
+     *   - It MAY also contain in the Additional records section: $ORIGIN default.service.arpa.
+     *     service-test-1._test-type._tcp ( SRV 8 8 55555 host-test-1)
+     *   - host-test-1 AAAA <OMR address of ED 1>
+     */
+    Log("Step 7: BR 1 (DUT) sends DNS response with service-test-1");
+
+    /**
+     * Step 8
+     *   Device: Eth 1
+     *   Description (SRP-2.15): Harness instructs the device to send mDNS query QType PTR for _test-type._tcp.local."
+     *     service type.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: Eth 1 sends mDNS query QType PTR for service type");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 9
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response with RCODE=0 (NoError) and contain service-test-1 as follows in the
+     *     Answer section: $ORIGIN local. _test-type._tcp ( PTR service-test-1. test-type._tcp)
+     *   - It MAY also contain in the Additional records section: $ORIGIN local. Service-test-1._test-type. tcp ( SRV 8
+     *     55555 host-test-1)
+     *   - host-test-1 AAAA <OMR address of ED_1>
+     */
+    Log("Step 9: BR 1 (DUT) sends mDNS Response with service-test-1");
+
+    /**
+     * Step 10
+     *   Device: ED 1
+     *   Description (SRP-2.15): Harness instructs the device to unicast DNS query QType PTR for
+     *     _test-subtype._sub._test-type._tcp.default.service.arpa" service type to the DUT.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: ED 1 sends unicast DNS query QType PTR for service subtype");
+    {
+        char subServiceType[Dns::Name::kMaxNameSize];
+        snprintf(subServiceType, sizeof(subServiceType), "%s._sub.%s", kSrpSubtype1, kSrpFullServiceType);
+        SuccessOrQuit(ed1.Get<Dns::Client>().Browse(subServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 11
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.15): Automatically sends DNS response with service-test-1.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response and contain service-test-1 as follows in the Answer records section:
+     *     $ORIGIN default.service.arpa. _test-subtype._sub._test-type._tcp ( PTR service-test-1._test-type._tcp)
+     *   - It MAY also contain in the Additional records section: $ORIGIN default.service.arpa.
+     *     service-test-1._test-type. tcp ( SRV 55555 host-test-1)
+     *   - host-test-1 AAAA <OMR address of ED_1>
+     */
+    Log("Step 11: BR 1 (DUT) sends DNS response with service-test-1");
+
+    /**
+     * Step 12
+     *   Device: Eth 1
+     *   Description (SRP-2.15): Harness instructs the device to mDNS query QType PTR for
+     *     "_test-subtype._sub._test-type._tcp.local" service type.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 12: Eth 1 sends mDNS query QType PTR for service subtype");
+    {
+        char subServiceType[Dns::Name::kMaxNameSize];
+        snprintf(subServiceType, sizeof(subServiceType), "%s._sub.%s", kSrpSubtype1, kSrpServiceType);
+
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = subServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 13
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response with RCODE=0 (NoError) and contain service-test-1 as follows in the
+     *     Answer records: $ORIGIN local. _test-subtype._sub._test-type._tcp ( PTR service-test-1._test-type._tcp)
+     *   - It MAY also contain in the Additional records section: $ORIGIN local. Service-test-1. test-type._tcp ( SRV 8
+     *     55555 host-test-1)
+     *   - host-test-1 AAAA OMR address of ED_1>
+     */
+    Log("Step 13: BR 1 (DUT) sends mDNS Response with service-test-1");
+
+    /**
+     * Step 14
+     *   Device: ED 1
+     *   Description (SRP-2.15): Harness instructs the device to send SRP Update to the DUT to add a new subtype to the
+     *     existing service: $ORIGIN default.service.arpa. _other-subtype._sub._test-type._tcp ( PTR service-test-1.
+     *     test-type._tcp) _test-subtype._sub._test-type._tcp ( PTR service-test-1._test-type._tcp) _test-type._tcp (
+     * PTR service-test-1. test-type._tcp) service-test-1. test-type._tcp ( SRV 8 8 55555 host-test-1) host-test-1 AAAA
+     *     OMR address of ED_1>
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 14: ED 1 adds a new subtype to the existing service");
+    {
+        SuccessOrQuit(ed1.Get<Srp::Client>().ClearService(service));
+        service.mName          = kSrpServiceType;
+        service.mInstanceName  = kSrpInstanceName;
+        service.mPort          = kSrpServicePort;
+        service.mSubTypeLabels = kSubTypes2;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 15
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends SRP Update Response.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+     */
+    Log("Step 15: BR 1 (DUT) sends SRP Update Response");
+
+    /**
+     * Step 16
+     *   Device: Eth_1
+     *   Description (SRP-2.15): Harness instructs the device to mDNS query QType PTR for
+     *     "_other-subtype._sub._test-type._tcp.local" service type, to check that the new subtype is present.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 16: Eth 1 sends mDNS query QType PTR for the new subtype");
+    {
+        char subServiceType[Dns::Name::kMaxNameSize];
+        snprintf(subServiceType, sizeof(subServiceType), "%s._sub.%s", kSrpSubtype2, kSrpServiceType);
+
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = subServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 17
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response with RCODE=0 (NoError) and contain service-test-1 as follows in the
+     *     Answer records section: $ORIGIN local. _other-subtype._sub._test-type._tcp ( PTR
+     *     service-test-1._test-type._tcp)
+     *   - It MAY also contain in the Additional records section: $ORIGIN local. Service-test-1. test-type._tcp ( SRV
+     *     55555 host-test-1)
+     *   - host-test-1 AAAA OMR address of ED_1>
+     */
+    Log("Step 17: BR 1 (DUT) sends mDNS Response with service-test-1");
+
+    /**
+     * Step 18
+     *   Device: Eth_1
+     *   Description (SRP-2.15): Harness instructs the device to mDNS query QType=PTR for
+     *     "_test-subtype._sub_test-type._tcp.local" service type, to check that the originally registered subtype is
+     *     still present.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 18: Eth 1 sends mDNS query QType PTR for the original subtype");
+    {
+        char subServiceType[Dns::Name::kMaxNameSize];
+        snprintf(subServiceType, sizeof(subServiceType), "%s._sub.%s", kSrpSubtype1, kSrpServiceType);
+
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = subServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 19
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+     *   Pass Criteria:
+     *   - Repeat Step 13
+     */
+    Log("Step 19: BR 1 (DUT) sends mDNS Response (Repeat Step 13)");
+
+    /**
+     * Step 20
+     *   Device: Eth_1
+     *   Description (SRP-2.15): Harness instructs the device to send mDNS query QType PTR for _test-type._tcp.local."
+     *     service type, to check that the query for the parent type also works after new subtype registration.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 20: Eth 1 sends mDNS query QType PTR for the parent service type");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 21
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+     *   Pass Criteria:
+     *   - Repeat Step 9
+     */
+    Log("Step 21: BR 1 (DUT) sends mDNS Response (Repeat Step 9)");
+
+    /**
+     * Step 22
+     *   Device: Eth 1
+     *   Description (SRP-2.15): Harness instructs the device to send mDNS query QType PTR for non-existent subtype
+     *     (name with extra character added): "_test-subtypee._sub._test-type_tcp.local." service type, to check that no
+     *     service answer is given by the DUT.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 22: Eth 1 sends mDNS query QType PTR for a non-existent subtype");
+    {
+        char subServiceType[Dns::Name::kMaxNameSize];
+        snprintf(subServiceType, sizeof(subServiceType), "%s._sub.%s", kSrpSubtypeInvalid, kSrpServiceType);
+
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = subServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 23
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.15): Does not send an mDNS Response.
+     *   Pass Criteria:
+     *   - The DUT MUST NOT send an mDNS response.
+     */
+    Log("Step 23: BR 1 (DUT) does not send an mDNS Response");
+
+    /**
+     * Step 24
+     *   Device: ED 1
+     *   Description (SRP-2.15): Harness instructs the device to send SRP Update to the DUT to re-register (i.e.,
+     *     update), its service with the subtype "_test-subtype" removed from the service: $ORIGIN
+     *     default.service.arpa. _other-subtype._sub._test-type._tcp ( PTR service-test-1._test-type._tcp)
+     *     _test-type._tcp ( PTR service-test-1._test-type._tcp) service-test-1._test-type._tcp ( SRV 55555 host-test-1)
+     *     host-test-1 AAAA OMR address of ED_1> Note: such an update would automatically delete any other,
+     *     pre-existing subtypes on the SRP server for this service.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 24: ED 1 removes a subtype from the service");
+    {
+        SuccessOrQuit(ed1.Get<Srp::Client>().ClearService(service));
+        service.mName          = kSrpServiceType;
+        service.mInstanceName  = kSrpInstanceName;
+        service.mPort          = kSrpServicePort;
+        service.mSubTypeLabels = kSubTypes3;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 25
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends SRP Update Response.
+     *   Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+     */
+    Log("Step 25: BR 1 (DUT) sends SRP Update Response");
+
+    /**
+     * Step 26
+     *   Device: Eth 1
+     *   Description (SRP-2.15): Harness instructs the device to send mDNS query QType PTR for
+     *     _other-subtype._sub._test-type._tcp.local" service type, to check that this subtype is still present.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 26: Eth 1 sends mDNS query QType PTR for the remaining subtype");
+    {
+        char subServiceType[Dns::Name::kMaxNameSize];
+        snprintf(subServiceType, sizeof(subServiceType), "%s._sub.%s", kSrpSubtype2, kSrpServiceType);
+
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = subServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 27
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+     *   Pass Criteria:
+     *   - Repeat Step 17
+     */
+    Log("Step 27: BR 1 (DUT) sends mDNS Response (Repeat Step 17)");
+
+    /**
+     * Step 28
+     *   Device: Eth 1
+     *   Description (SRP-2.15): Harness instructs the device to send mDNS QType=PTR query for
+     *     _test-subtype._sub._test-type._tcp.local." service type to check that the removed subtype is no longer
+     * present. Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 28: Eth 1 sends mDNS query QType PTR for the removed subtype");
+    {
+        char subServiceType[Dns::Name::kMaxNameSize];
+        snprintf(subServiceType, sizeof(subServiceType), "%s._sub.%s", kSrpSubtype1, kSrpServiceType);
+
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = subServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 29
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Does not send an mDNS Response.
+     *   Pass Criteria:
+     *   - The DUT MUST NOT send an mDNS response.
+     */
+    Log("Step 29: BR 1 (DUT) does not send an mDNS Response");
+
+    /**
+     * Step 30
+     *   Device: ED 1
+     *   Description (SRP-2.15): Harness instructs the device to send SRP Update to the DUT which isn't a valid SRP
+     *     Update. This invalid update attempts to add a subtype to the service but is missing the Service Description
+     *     Instruction (SRV record): $ORIGIN default.service.arpa. _test-subtype-2._sub._test-type._tcp ( PTR
+     *     service-test-1._test-type._tcp) _test-type._tcp ( PTR service-test-1._test-type._tcp) host-test-1 AAAA OMR
+     *     address of ED_1> Note (TBD): sending an invalid SRP Update of this form is not supported by current reference
+     *     devices. As a future extension, the Harness should construct the required DNS Update message and send it
+     *     using the UDP-send API.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 30: ED 1 sends an invalid SRP Update (skipped)");
+
+    /**
+     * Step 31
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.15): Rejects the invalid SRP Update.
+     *   Pass Criteria:
+     *   - N/A (skipping for 1.3.0 cert due to API issue - TBD)
+     *   - (Future requirement TBD: MUST send SRP Update Response with RCODE=5, REFUSED.)
+     */
+    Log("Step 31: BR 1 (DUT) rejects the invalid SRP Update (skipped)");
+
+    Log("All steps completed.");
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_1_MLEID_ADDR", ed1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+
+    {
+        Ip6::Prefix omrPrefix;
+        SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+        nexus.AddTestVar("ED_1_OMR_ADDR",
+                         ed1.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+    }
+
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_15((argc > 2) ? argv[2] : "test_1_3_SRP_TC_15.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_2.cpp b/tests/nexus/test_1_3_SRP_TC_2.cpp
new file mode 100644
index 0000000..6378a7f
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_2.cpp
@@ -0,0 +1,606 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]  = "_thread-test._udp";
+static const char         kSrpInstance1[]    = "service-test-1";
+static const char         kSrpInstance2[]    = "service-test-2";
+static const char         kSrpHost1[]        = "host-test-1";
+static const char         kSrpHost2[]        = "host-test-2";
+static constexpr uint16_t kSrpServicePort333 = 33333;
+static constexpr uint16_t kSrpServicePort444 = 44444;
+
+void Test_1_3_SRP_TC_2(const char *aJsonFileName)
+{
+    Srp::Client::Service service1;
+    Srp::Client::Service service2;
+
+    /**
+     * 2.2. [1.3] [CERT] Name Conflicts - Single Thread Network
+     *
+     * 2.2.1. Purpose
+     * To test the following:
+     * - 1. Handle name conflict in Host Description record.
+     * - 2. Handle name conflict in Service Description record.
+     * - 3. Verify that original service is seen when discovering, and conflicting
+     *   service is not seen on the AIL.
+     *
+     * 2.2.2. Topology
+     * - 1. BR_1 (DUT) - Border Router and the Leader.
+     * - 2. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - 3. ED 2-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - 4. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * 2.2.3. Initial Conditions
+     * - BR_1 (DUT) should automatically start SRP server capabilities at the
+     *   beginning of the test.
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &ed2  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    ed2.SetName("ED_2");
+    eth1.SetName("Eth_1");
+
+    br1.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.2): Enable: switch on.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) switch on.");
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     * - Device: ED 1, ED 2, Eth 1
+     * - Description (SRP-2.2): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: ED 1, ED 2, Eth 1 Enable.");
+    ed1.Join(br1, Node::kAsFed);
+    ed2.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    // Wait for BR to advertise prefixes and EDs to get OMR addresses
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.2): Automatically adds its SRP Server information in the
+     *   Thread Network Data.
+     * - Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data.
+     *     This can be verified as specified in test case 2.1 step 3.
+     *   - Specifically, it MUST include a DNS-SD Unicast Dataset.
+     */
+    Log("Step 3: BR 1 (DUT) adds its SRP Server information in the Thread Network Data.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 4
+     * - Device: ED 1
+     * - Description (SRP-2.2): Harness instructs the device to send SRP Update with
+     *   initial service registration: $ORIGIN default.service.arpa.
+     *   service-test-1._thread-test._udp ( SRV 0 0 33333 host-test-1 ) host-test-1
+     *   AAAA <OMR address of ED_1>
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED 1 sends SRP Update with initial service registration.");
+    {
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHost1));
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service1);
+        service1.mName          = kSrpServiceType;
+        service1.mInstanceName  = kSrpInstance1;
+        service1.mPort          = kSrpServicePort333;
+        service1.mTxtEntries    = nullptr;
+        service1.mNumTxtEntries = 0;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service1));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 5
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.2): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) sends SRP Update Response (NoError).");
+
+    /**
+     * Step 6
+     * - Device: ED 2
+     * - Description (SRP-2.2): Harness instructs the device to send SRP Update with
+     *   same-name (conflicting) service registration: $ORIGIN
+     *   default.service.arpa. service-test-1._thread-test._udp ( SRV 0 0 33333
+     *   host-test-2 ) host-test-2 AAAA <OMR address of ED_2>
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED 2 sends SRP Update with same-name (conflicting) service registration.");
+    {
+        ed2.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        SuccessOrQuit(ed2.Get<Srp::Client>().SetHostName(kSrpHost2));
+
+        SuccessOrQuit(ed2.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service2);
+        service2.mName         = kSrpServiceType;
+        service2.mInstanceName = kSrpInstance1; // Conflict
+        service2.mPort         = kSrpServicePort333;
+        SuccessOrQuit(ed2.Get<Srp::Client>().AddService(service2));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 7
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.2): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=6 (YXDOMAIN).
+     */
+    Log("Step 7: BR 1 (DUT) sends SRP Update Response (YXDOMAIN).");
+
+    /**
+     * Step 8
+     * - Device: Eth 1
+     * - Description (SRP-2.2): Harness instructs device to perform mDNS query QType
+     *   PTR for any services of type _thread-test._udp.local..
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: Eth 1 performs mDNS query QType PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 9
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.2): Automatically sends mDNS response with initial
+     *   registered service.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response and contain answer record:
+     *     _thread-test._udp.local PTR ( service-test-1._thread-test._udp.local. )
+     *   - It MAY also contain in the Additional records section:
+     *     service-test-1._thread-test._udp.local. ( SRV 0 0 33333 host-test-1.local. )
+     *   - It MAY also contain in the Additional records section: host-test-1.local.
+     *     AAAA <OMR address of ED_1>
+     *   - The mDNS Response MUST NOT contain any answer record or additional
+     *     record like: <any-string> AAAA <OMR address of ED_2>
+     *   - The mDNS Response MUST NOT contain any answer record or additional
+     *     record like: host-test-1.local. AAAA <any non-OMR address>
+     *   - The mDNS Response MUST NOT contain any answer record or additional
+     *     record like: service-test-2._thread-test._udp.local. SRV <any data>
+     *   - The mDNS Response MUST NOT contain any answer record or additional
+     *     record like: <any-name> PTR service-test-2._thread-test_udp.local.
+     */
+    Log("Step 9: BR 1 (DUT) sends mDNS response with initial registered service.");
+
+    /**
+     * Step 9b
+     * - Device: Eth 1
+     * - Description (SRP-2.2): Harness instructs the device to send mDNS query
+     *   QTYPE SRV QNAME service-test-1._thread-test._udp.local
+     * - Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record:
+     *     service-test-1._thread-test._udp.local ( SRV 0 0 33333 host-test-1.local )
+     */
+    Log("Step 9b: Eth 1 sends mDNS query QTYPE SRV.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstance1;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 9c
+     * - Device: Eth 1
+     * - Description (SRP-2.2): Harness instructs the device to send mDNS query
+     *   QTYPE AAAA QNAME host-test-1.local
+     * - Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record:
+     *     host-test-1.local AAAA <OMR address of ED_1>
+     */
+    Log("Step 9c: Eth 1 sends mDNS query QTYPE AAAA.");
+    {
+        Dns::Multicast::Core::AddressResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback     = [](otInstance *, const otPlatDnssdAddressResult *) {};
+        resolver.mHostName     = kSrpHost1;
+        resolver.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartIp6AddressResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopIp6AddressResolver(resolver));
+    }
+
+    /**
+     * Step 10
+     * - Device: ED 2
+     * - Description (SRP-2.2): Harness instructs the device to send SRP Update with
+     *   same-hostname (conflicting) service registration: $ORIGIN
+     *   default.service.arpa. service-test-2._thread-test._udp ( SRV 0 0 33333
+     *   host-test-1 ) host-test-1 AAAA <OMR address of ED_2>
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: ED 2 sends SRP Update with same-hostname (conflicting) service registration.");
+    {
+        ed2.Get<Srp::Client>().ClearHostAndServices();
+        SuccessOrQuit(ed2.Get<Srp::Client>().SetHostName(kSrpHost1)); // Conflict
+
+        SuccessOrQuit(ed2.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service2);
+        service2.mName          = kSrpServiceType;
+        service2.mInstanceName  = kSrpInstance2;
+        service2.mPort          = kSrpServicePort333;
+        service2.mTxtEntries    = nullptr;
+        service2.mNumTxtEntries = 0;
+        SuccessOrQuit(ed2.Get<Srp::Client>().AddService(service2));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 11
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.2): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=6 (YXDOMAIN).
+     */
+    Log("Step 11: BR 1 (DUT) sends SRP Update Response (YXDOMAIN).");
+
+    /**
+     * Step 12
+     * - Device: Eth 1
+     * - Description (SRP-2.2): Repeat Step 8
+     * - Pass Criteria:
+     *   - Repeat Step 8
+     */
+    Log("Step 12: Eth 1 repeats mDNS query QType PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 13
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.2): Repeat Step 9 (including 9b, 9c, etc)
+     * - Pass Criteria:
+     *   - Repeat Step 9 (including 9b, 9c, etc)
+     */
+    Log("Step 13: BR 1 (DUT) repeats mDNS response verification.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstance1;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+    {
+        Dns::Multicast::Core::AddressResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback     = [](otInstance *, const otPlatDnssdAddressResult *) {};
+        resolver.mHostName     = kSrpHost1;
+        resolver.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartIp6AddressResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopIp6AddressResolver(resolver));
+    }
+
+    /**
+     * Step 14
+     * - Device: ED 2
+     * - Description (SRP-2.2): Harness instructs the device to send SRP Update with
+     *   non-conflicting service registration: $ORIGIN default.service.arpa.
+     *   service-test-2._thread-test._udp ( SRV 1 0 44444 host-test-2 ) host-test-2
+     *   AAAA <OMR address of ED_2>
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 14: ED 2 sends SRP Update with non-conflicting service registration.");
+    {
+        ed2.Get<Srp::Client>().ClearHostAndServices();
+        SuccessOrQuit(ed2.Get<Srp::Client>().SetHostName(kSrpHost2));
+
+        SuccessOrQuit(ed2.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service2);
+        service2.mName          = kSrpServiceType;
+        service2.mInstanceName  = kSrpInstance2;
+        service2.mPort          = kSrpServicePort444;
+        service2.mPriority      = 1;
+        service2.mTxtEntries    = nullptr;
+        service2.mNumTxtEntries = 0;
+        SuccessOrQuit(ed2.Get<Srp::Client>().AddService(service2));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 15
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.2): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 15: BR 1 (DUT) sends SRP Update Response (NoError).");
+
+    /**
+     * Step 16
+     * - Device: Eth 1
+     * - Description (SRP-2.2): Harness instructs the device to perform mDNS query
+     *   QType PTR for any services of type _thread-test_udp.local..
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 16: Eth 1 performs mDNS query QType PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 17
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.2): Automatically sends mDNS response with both
+     *   registered services.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response and MUST contain 2 answer
+     *     records: _thread-test._udp.local ( PTR service-test-1._thread-test._udp.local )
+     *     AND _thread-test._udp.local ( PTR service-test-2._thread-test._udp.local )
+     *   - It MAY also contain in the Additional records section:
+     *     service-test-1._thread-test._udp.local. ( SRV 0 0 33333 host-test-1.local. )
+     *   - It MAY also contain in the Additional records section:
+     *     service-test-2._thread-test._udp.local. ( SRV 1 0 44444 host-test-2.local. )
+     *   - It MAY also contain in the Additional records section: host-test-1.local.
+     *     AAAA <OMR address of ED_1>
+     *   - It MAY also contain in the Additional records section: host-test-2.local.
+     *     AAAA <OMR address of ED_2>
+     *   - The response MAY be sent as two separate mDNS response messages,
+     *     splitting over the 2 services.
+     */
+    Log("Step 17: BR 1 (DUT) sends mDNS response with both registered services.");
+
+    /**
+     * Step 17b
+     * - Device: Eth 1
+     * - Description (SRP-2.2): Harness instructs the device to send mDNS query
+     *   QTYPE=SRV QNAME service-test-1._thread-test._udp.local
+     * - Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record:
+     *     service-test-1._thread-test._udp.local ( SRV 0 0 33333 host-test-1.local )
+     */
+    Log("Step 17b: Eth 1 sends mDNS query QTYPE=SRV QNAME service-test-1.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstance1;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 17c
+     * - Device: Eth_1
+     * - Description (SRP-2.2): Harness instructs the device to send mDNS query
+     *   QTYPE SRV QNAME service-test-2._thread-test_udp.local
+     * - Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record:
+     *     service-test-2._thread-test._udp.local ( SRV 1 0 44444 host-test-2.local )
+     */
+    Log("Step 17c: Eth 1 sends mDNS query QTYPE=SRV QNAME service-test-2.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstance2;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 17d
+     * - Device: Eth 1
+     * - Description (SRP-2.2): Harness instructs the device to send mDNS query
+     *   QTYPE=AAAA QNAME host-test-1.local
+     * - Pass Criteria:
+     *   - The DUT MUST respond RCODE=0 with Answer section record:
+     *     host-test-1.local AAAA <OMR address of ED_1>
+     */
+    Log("Step 17d: Eth 1 sends mDNS query QTYPE=AAAA QNAME host-test-1.");
+    {
+        Dns::Multicast::Core::AddressResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback     = [](otInstance *, const otPlatDnssdAddressResult *) {};
+        resolver.mHostName     = kSrpHost1;
+        resolver.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartIp6AddressResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopIp6AddressResolver(resolver));
+    }
+
+    /**
+     * Step 17e
+     * - Device: Eth_1
+     * - Description (SRP-2.2): Harness instructs the device to send mDNS query
+     *   QTYPE AAAA QNAME host-test-2.local
+     * - Pass Criteria:
+     *   - THE DUT MUST respond RCODE=0 with Answer section record:
+     *     host-test-2.local AAAA <OMR address of ED_2>
+     */
+    Log("Step 17e: Eth 1 sends mDNS query QTYPE=AAAA QNAME host-test-2.");
+    {
+        Dns::Multicast::Core::AddressResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback     = [](otInstance *, const otPlatDnssdAddressResult *) {};
+        resolver.mHostName     = kSrpHost2;
+        resolver.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartIp6AddressResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopIp6AddressResolver(resolver));
+    }
+
+    Log("All steps completed.");
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_1_MLEID_ADDR", ed1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_2_MLEID_ADDR", ed2.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    Ip6::Prefix omrPrefix;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+    nexus.AddTestVar("ED_1_OMR_ADDR", ed1.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+    nexus.AddTestVar("ED_2_OMR_ADDR", ed2.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_2((argc > 2) ? argv[2] : "test_1_3_SRP_TC_2.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_3.cpp b/tests/nexus/test_1_3_SRP_TC_3.cpp
new file mode 100644
index 0000000..9c897dd
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_3.cpp
@@ -0,0 +1,575 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]     = "_thread-test._udp";
+static const char         kSrpInstance1[]       = "service-test-1";
+static const char         kSrpHost1[]           = "host-test-1";
+static const char         kSrpFullServiceType[] = "_thread-test._udp.default.service.arpa.";
+static constexpr uint16_t kSrpServicePort       = 55555;
+static constexpr uint32_t kSrpLease             = 30;
+static constexpr uint32_t kSrpKeyLease          = 600;
+static constexpr uint32_t kWaitTime15s          = 15 * 1000;
+static constexpr uint32_t kWaitTime16s          = 16 * 1000;
+
+void Test_1_3_SRP_TC_3(const char *aJsonFileName)
+{
+    Srp::Client::Service service;
+
+    /**
+     * 2.3. [1.3] [CERT] Service Instance Lease - Renewal and Automatic Service/Host Removal
+     *
+     * 2.3.1. Purpose
+     * To test the following:
+     * - Handle lease renewal before lease expiration.
+     * - Handle service/host lease expiration and removal of the service/host.
+     *
+     * 2.3.2. Topology
+     * - 1. BR_1 (DUT)-Thread Border Router and the Leader
+     * - 2. ED_1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - 3. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference           | V1.3.0 Section
+     * -------------------------|---------------
+     * DNS-SD Unicast Dataset   | 14.3.2.2
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    br1.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Enable: switch on.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) switch on.");
+    {
+        Srp::Server::LeaseConfig leaseConfig;
+        br1.Get<Srp::Server>().GetLeaseConfig(leaseConfig);
+        leaseConfig.mMaxLease = kSrpLease;
+        leaseConfig.mMinLease = kSrpLease;
+        SuccessOrQuit(br1.Get<Srp::Server>().SetLeaseConfig(leaseConfig));
+    }
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     * - Device: Eth 1, ED 1
+     * - Description (SRP-2.3): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth 1, ED 1 Enable.");
+    ed1.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    // Wait for BR to advertise prefixes and EDs to get OMR addresses
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Automatically adds SRP Server information in the Thread Network Data. Automatically
+     *   provides an OMR prefix to the Thread Network.
+     * - Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data. This can be verified as specified
+     *     in test case 2.1 step 3.
+     *   - Specifically, it MUST include a DNS-SD Unicast Dataset as per [ThreadSpec] 14.3.2.2.
+     *   - S_service_data Length MUST be 1 or 19
+     */
+    Log("Step 3: BR 1 (DUT) adds SRP Server information in the Thread Network Data.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 4
+     * - Device: ED 1
+     * - Description (SRP-2.3): Harness instructs the device to send SRP Update to DUT: $ORIGIN default.service.arpa.
+     *   service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1> with the
+     *   following options: Update Lease Option, Lease: 30 seconds, Key Lease: 10 minutes. Harness
+     *   instructs/configures device to avoid automatic renewal or resending of this SRP Update.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED 1 sends SRP Update to DUT.");
+    {
+        ed1.Get<Srp::Client>().SetLeaseInterval(kSrpLease);
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease);
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHost1));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service);
+        service.mName          = kSrpServiceType;
+        service.mInstanceName  = kSrpInstance1;
+        service.mPort          = kSrpServicePort;
+        service.mTxtEntries    = nullptr;
+        service.mNumTxtEntries = 0;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+    ed1.Get<Srp::Client>().Stop();
+
+    /**
+     * Step 5
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) sends SRP Update Response.");
+
+    /**
+     * Step 6
+     * - Device: ED 1
+     * - Description (SRP-2.3): Harness instructs the device to unicast DNS query QType SRV to the DUT for service
+     *   "service-test-1._thread-test._udp.default.service.arpa"
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED 1 sends unicast DNS query QType SRV.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstance1, kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Automatically sends DNS response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response and contain in Answers section: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 )
+     *   - The DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+     *     <OMR address of ED_1>
+     */
+    Log("Step 7: BR 1 (DUT) sends DNS response.");
+
+    /**
+     * Step 8
+     * - Device: Eth_1
+     * - Description (SRP-2.3): Harness instructs the device to send mDNS query QType SRV for
+     *   "service-test-1._thread-test._udp.local."
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: Eth 1 sends mDNS query QType SRV.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstance1;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 9
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response and contain in Answers section: $ORIGIN local.
+     *     Service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 )
+     *   - The DNS Response MAY contain in Additional records section: $ORIGIN local. Host-test-1 AAAA <OMR address of
+     *     ED_1>
+     */
+    Log("Step 9: BR 1 (DUT) sends mDNS Response.");
+
+    /**
+     * Step 10
+     * - Device: N/A
+     * - Description (SRP-2.3): Harness waits for 15 seconds.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: Harness waits for 15 seconds.");
+    nexus.AdvanceTime(kWaitTime15s);
+
+    /**
+     * Step 11
+     * - Device: ED 1
+     * - Description (SRP-2.3): Harness instructs the device to send SRP Update to the DUT to renew the service lease:
+     *   $ORIGIN default.service.arpa. service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 ) host-test-1 AAAA
+     *   <OMR address of ED_1> with the following options: Update Lease Option, Lease: 30 seconds, Key Lease: 10
+     *   minutes. Harness instructs/configures the device to avoid automatic renewal or resending of this SRP Update.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 11: ED 1 sends SRP Update to renew the service lease.");
+    {
+        ed1.Get<Srp::Client>().ClearHostAndServices();
+        ed1.Get<Srp::Client>().SetLeaseInterval(kSrpLease);
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease);
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHost1));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service);
+        service.mName          = kSrpServiceType;
+        service.mInstanceName  = kSrpInstance1;
+        service.mPort          = kSrpServicePort;
+        service.mTxtEntries    = nullptr;
+        service.mNumTxtEntries = 0;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+    ed1.Get<Srp::Client>().Stop();
+
+    /**
+     * Step 12
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 12: BR 1 (DUT) sends SRP Update Response.");
+
+    /**
+     * Step 13
+     * - Device: N/A
+     * - Description (SRP-2.3): Harness waits for 16 seconds.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 13: Harness waits for 16 seconds.");
+    nexus.AdvanceTime(kWaitTime16s);
+
+    /**
+     * Step 14
+     * - Device: ED 1
+     * - Description (SRP-2.3): Harness instructs the device to unicast DNS query QType SRV to the DUT for service
+     *   "service-test-1._thread-test._udp.default.service.arpa"
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 14: ED 1 sends unicast DNS query QType SRV.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstance1, kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 15
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Automatically sends DNS response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response and contain answer record: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 )
+     *   - The DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+     *     <OMR address of ED_1>
+     */
+    Log("Step 15: BR 1 (DUT) sends DNS response.");
+
+    /**
+     * Step 16
+     * - Device: Eth 1
+     * - Description (SRP-2.3): Harness instructs the device to send mDNS query QType PTR for any services of type
+     *   "_thread-test._udp.local."
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 16: Eth 1 sends mDNS query QType PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 17
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response and contain answer record: $ORIGIN local. _thread-test._udp ( PTR
+     *     service-test-1._thread-test._udp )
+     *   - THe DNS Response MAY contain in Additional records section: $ORIGIN local. Service-test-1_thread-test._udp (
+     *     SRV 0 0 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+     */
+    Log("Step 17: BR 1 (DUT) sends mDNS Response.");
+
+    /**
+     * Step 18
+     * - Device: N/A
+     * - Description (SRP-2.3): Harness waits 15 seconds for the service lease to expire. Note: Both service and host
+     *   entries should expire now, after >= 31 seconds after last registration update. But the KEY record registration
+     *   remains active for both names.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 18: Harness waits 15 seconds for the service lease to expire.");
+    nexus.AdvanceTime(kWaitTime15s);
+
+    /**
+     * Step 19
+     * - Device: ED 1
+     * - Description (SRP-2.3): Harness instructs the device to unicast DNS query QType SRV to the DUT for service
+     *   instance name "service-test-1._thread-test._udp.default.service.arpa"
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 19: ED 1 sends unicast DNS query QType SRV.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstance1, kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 20
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Automatically sends DNS response but no matching SRV records. Note: only the key lease
+     *   is still active on the service instance name. The DUT's response may vary as shown on the right, depending on
+     *   implementation.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response and MUST be one of the below:
+     *   - 1. RCODE=0 (NoError) with 0 answer records.
+     *   - 2. RCODE=3 (NxDomain) with 0 answer records.
+     */
+    Log("Step 20: BR 1 (DUT) sends DNS response (no SRV).");
+
+    /**
+     * Step 21
+     * - Device: ED 1
+     * - Description (SRP-2.3): (Reserved for TBD future unicast DNS query QTYPE=ANY to the DUT for records for name
+     *   "host-test-1.default.service.arpa".)
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 21: ED 1 reserved step.");
+
+    /**
+     * Step 22
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): (Reserved for TBD future: Automatically sends DNS response.) Note: because the key
+     *   lease is still active, the KEY record is optionally returned in response to the "ANY" query.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 22: BR 1 (DUT) reserved step.");
+
+    /**
+     * Step 23
+     * - Device: Eth 1
+     * - Description (SRP-2.3): Harness instructs device to send mDNS query QType=SRV for
+     *   "service-test-1._thread-test_udp.local."
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 23: Eth 1 sends mDNS query QType SRV.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstance1;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 24
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Does not respond to mDNS query, or automatically responds NSEC, or no-error-no-answer.
+     * - Pass Criteria:
+     *   - The DUT MUST perform one of below options:
+     *   - 1. Sends RCODE=0 (NoError) and NSEC Answer record.
+     *   - 2. MUST NOT send any mDNS response.
+     *   - 3. Sends RCODE=0 (NoError) and 0 Answer records.
+     */
+    Log("Step 24: BR 1 (DUT) mDNS verification for Step 23.");
+
+    /**
+     * Step 25
+     * - Device: Eth_1
+     * - Description (SRP-2.3): Harness instructs the device to send mDNS query QType=PTR for any services of type
+     *   "_thread-test._udp.local."
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 25: Eth 1 sends mDNS query QType PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 26
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Does not respond to mDNS query, or automatically responds NSEC, or no-error-no-answer.
+     * - Pass Criteria:
+     *   - The DUT MUST perform one of below options:
+     *   - 1. Sends RCODE=0 (NoError) and NSEC Answer record.
+     *   - 2. MUST NOT send any mDNS response.
+     *   - 3. Sends RCODE=0 (NoError) and 0 Answer records.
+     */
+    Log("Step 26: BR 1 (DUT) mDNS verification for Step 25.");
+
+    /**
+     * Step 27
+     * - Device: Eth 1
+     * - Description (SRP-2.3): Harness instructs device to send mDNS query for Qtype AAAA for name "host-test-1.local."
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 27: Eth 1 sends mDNS query for QType AAAA.");
+    {
+        Dns::Multicast::Core::AddressResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback     = [](otInstance *, const otPlatDnssdAddressResult *) {};
+        resolver.mHostName     = kSrpHost1;
+        resolver.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartIp6AddressResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopIp6AddressResolver(resolver));
+    }
+
+    /**
+     * Step 28
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.3): Does not respond to mDNS query, or automatically responds NSEC, or no-error-no-answer.
+     *   Note: this indicates the DUT is authoritative for the host name, but has no AAAA records to answer for the
+     *   name.
+     * - Pass Criteria:
+     *   - The DUT MUST perform one of below options:
+     *   - 1. Sends RCODE=0 (NoError) and NSEC Answer record.
+     *   - 2. MUST NOT send any mDNS response.
+     *   - 3. Sends RCODE=0 (NoError) and 0 Answer records.
+     */
+    Log("Step 28: BR 1 (DUT) mDNS verification for Step 27.");
+
+    Log("All steps completed.");
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_1_MLEID_ADDR", ed1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    Ip6::Prefix omrPrefix;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+    nexus.AddTestVar("ED_1_OMR_ADDR", ed1.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_3((argc > 2) ? argv[2] : "test_1_3_SRP_TC_3.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_4.cpp b/tests/nexus/test_1_3_SRP_TC_4.cpp
new file mode 100644
index 0000000..c4ffeb6
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_4.cpp
@@ -0,0 +1,482 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]     = "_thread-test._udp";
+static const char         kSrpInstanceName1[]   = "service-test-1";
+static const char         kSrpInstanceName3[]   = "service-test-3";
+static const char         kSrpHostName1[]       = "host-test-1";
+static const char         kSrpHostName2[]       = "host-test-2";
+static const char         kSrpFullServiceType[] = "_thread-test._udp.default.service.arpa.";
+static constexpr uint16_t kSrpServicePort       = 55555;
+
+/**
+ * Lease intervals in seconds.
+ */
+static constexpr uint32_t kLease30s    = 30;
+static constexpr uint32_t kLease10s    = 10;
+static constexpr uint32_t kKeyLease60s = 60;
+static constexpr uint32_t kKeyLease30s = 30;
+
+/**
+ * Wait times in milliseconds.
+ */
+static constexpr uint32_t kWait30s = 30 * 1000;
+static constexpr uint32_t kWait35s = 35 * 1000;
+
+static void RegisterSrpService(Node                 &aNode,
+                               Srp::Client::Service &aService,
+                               const char           *aHostName,
+                               const char           *aInstanceName,
+                               uint32_t              aLease,
+                               uint32_t              aKeyLease,
+                               uint16_t              aWeight,
+                               uint16_t              aPriority)
+{
+    aNode.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(aNode.Get<Srp::Client>().SetHostName(aHostName));
+    SuccessOrQuit(aNode.Get<Srp::Client>().EnableAutoHostAddress());
+    aNode.Get<Srp::Client>().SetLeaseInterval(aLease);
+    aNode.Get<Srp::Client>().SetKeyLeaseInterval(aKeyLease);
+
+    ClearAllBytes(aService);
+    aService.mName         = kSrpServiceType;
+    aService.mInstanceName = aInstanceName;
+    aService.mPort         = kSrpServicePort;
+    aService.mWeight       = aWeight;
+    aService.mPriority     = aPriority;
+    SuccessOrQuit(aNode.Get<Srp::Client>().AddService(aService));
+}
+
+void Test_1_3_SRP_TC_4(const char *aJsonFileName)
+{
+    Srp::Client::Service          service;
+    Dns::Multicast::Core::Browser browser;
+
+    /**
+     * 2.4. [1.3] [CERT] Key Lease
+     *
+     * 2.4.1. Purpose
+     * To test the following:
+     * - 1. Key lease handling on SRP server on DUT.
+     * - 2. Verify that a service instance name cannot be claimed even after the service instance lease expired, if the
+     *   key lease is still active.
+     *
+     * 2.4.2. Topology
+     * - BR 1 (DUT) - Thread Border Router and the Leader
+     * - ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - ED 2-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Server       | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &ed2  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    ed2.SetName("ED_2");
+    eth1.SetName("Eth_1");
+
+    br1.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.4): Enable: switch on.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR_1 (DUT) enable.");
+    {
+        Srp::Server::LeaseConfig leaseConfig;
+        br1.Get<Srp::Server>().GetLeaseConfig(leaseConfig);
+        leaseConfig.mMinLease = kLease30s;
+        SuccessOrQuit(br1.Get<Srp::Server>().SetLeaseConfig(leaseConfig));
+    }
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     * - Device: Eth 1, ED 1, ED 2
+     * - Description (SRP-2.4): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth 1, ED 1, ED 2 enable.");
+    ed1.Join(br1, Node::kAsFed);
+    ed2.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    // Wait for BR to advertise prefixes and EDs to get an OMR address
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.4): Automatically adds its SRP Server information in the Thread Network Data.
+     * - Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data: it MUST contain a DNS/SRP Unicast
+     *     Dataset, or a DNS/SRP Anycast Dataset, or both.
+     */
+    Log("Step 3: BR_1 (DUT) adds its SRP Server information in the Thread Network Data.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 4
+     * - Device: ED 1
+     * - Description (SRP-2.4): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+     *   service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1> with the
+     *   following options: Update Lease Option Lease: 30 seconds Key Lease: 60 seconds
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED 1 sends SRP Update.");
+    RegisterSrpService(ed1, service, kSrpHostName1, kSrpInstanceName1, kLease30s, kKeyLease60s, 1, 1);
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 5
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.4): Automatically sends SRP Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Response with RCODE=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) sends SRP Response.");
+
+    /**
+     * Step 6
+     * - Device: ED_1
+     * - Description (SRP-2.4): Harness instructs the device to unicast DNS query QType=SRV for
+     *   "service-test-1._thread-test_udp.default.service.arpa".
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED 1 sends unicast DNS query QType=SRV.");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstanceName1, kSrpFullServiceType, nullptr, nullptr));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.4): Automatically sends DNS response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response containing answer record: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 )
+     *   - The DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+     *     <OMR address of ED_1>
+     */
+    Log("Step 7: BR 1 (DUT) sends DNS response.");
+
+    /**
+     * Step 8
+     * - Device: ED 1
+     * - Description (SRP-2.4): Harness instructs the device to send unicast DNS query QType=PTR for any services of
+     *   type _thread-test_udp.default.service.arpa.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: ED 1 sends unicast DNS query QType=PTR.");
+    SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kSrpFullServiceType, nullptr, nullptr));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 9
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.4): Automatically sends DNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response containing answer record: $ORIGIN default.service.arpa.
+     *     _thread-test._udp ( PTR service-test-1._thread-test._udp )
+     *   - The DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+     */
+    Log("Step 9: BR 1 (DUT) sends DNS Response.");
+
+    /**
+     * Step 10
+     * - Device: N/A
+     * - Description (SRP-2.4): Harness waits for 35 seconds. Note: during this time there must be no automatic
+     *   reregistration of the service done by ED 1. This can be implemented on (OpenThread) ED_1 by either disabling
+     *   automatic service reregistration or by internally removing the service without doing the deregistration
+     *   request.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: Wait 35 seconds.");
+    ed1.Get<Srp::Client>().ClearHostAndServices();
+    nexus.AdvanceTime(kWait35s);
+
+    /**
+     * Step 11
+     * - Device: ED 2
+     * - Description (SRP-2.4): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+     *   service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-2 ) host-test-2 AAAA <OMR address of ED_2> with the
+     *   following options: Update Lease Option Lease: 30 seconds Key Lease: 30 seconds
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 11: ED 2 sends SRP Update.");
+    RegisterSrpService(ed2, service, kSrpHostName2, kSrpInstanceName1, kLease30s, kKeyLease30s, 0, 0);
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 12
+     * - Device: BR 1. (DUT)
+     * - Description (SRP-2.4): Automatically sends SRP Update Response, denying the registration because the key lease
+     *   for the expired service 'service-test-1' is still active. Note: spec for this is 3.2.5.2, 3.3.3, 3.3.5 and 5.1
+     *   of [draft-srp-23].
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response with RCODE=6 (YXDOMAIN).
+     */
+    Log("Step 12: BR 1 (DUT) sends SRP Update Response (YXDOMAIN).");
+
+    /**
+     * Step 13
+     * - Device: N/A
+     * - Description (SRP-2.4): Harness waits for 30 seconds. Note: during this time there must be no automatic
+     *   reregistration of the service done by ED 2. This can be implemented on (Open Thread) ED_2 by either disabling
+     *   automatic service reregistration or by internally removing the service without doing the deregistration
+     *   request.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 13: Wait 30 seconds.");
+    ed2.Get<Srp::Client>().ClearHostAndServices();
+    nexus.AdvanceTime(kWait30s);
+
+    /**
+     * Step 14
+     * - Device: ED 2
+     * - Description (SRP-2.4): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+     *   service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_2> Update
+     *   Lease Option Lease: 10 seconds Key Lease: 60 seconds Note: ED 2 here uses the hostname of ED 1 to verify that
+     *   the name host-test-1 has become available again, after key expiry thereaf. Note: the requested Lease time of
+     *   10 seconds will be automatically adjusted by the SRP Server to 30 seconds (the minimum allowed).
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 14: ED 2 sends SRP Update with host-test-1.");
+    RegisterSrpService(ed2, service, kSrpHostName1, kSrpInstanceName1, kLease10s, kKeyLease60s, 1, 1);
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 15
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.4): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Response with RCODE = 0 (NoError).
+     */
+    Log("Step 15: BR 1 (DUT) sends SRP Response.");
+
+    /**
+     * Step 16
+     * - Device: Eth_1
+     * - Description (SRP-2.4): Harness instructs the device to send mDNS query QType PTR for any services of type
+     *   "_thread-test._udp.local."
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 16: Eth 1 sends mDNS query QType PTR.");
+    {
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 17
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.4): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response containing only answer record: $ORIGIN local. _thread-test._udp (
+     *     PTR service-test-1._thread-test._udp )
+     *   - The mDNS Response MAY contain in Additional records section: $ORIGIN local.
+     *     Service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_2>
+     */
+    Log("Step 17: BR 1 (DUT) sends mDNS Response.");
+
+    /**
+     * Step 18
+     * - Device: N/A
+     * - Description (SRP-2.4): Harness waits for 35 seconds, for the service lease to expire.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 18: Wait 35 seconds.");
+    ed2.Get<Srp::Client>().ClearHostAndServices();
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    nexus.AdvanceTime(kWait35s);
+
+    /**
+     * Step 19
+     * - Device: Eth_1
+     * - Description (SRP-2.4): Harness instructs the device to send mDNS query QType PTR for any services of type
+     *   "_thread-test_udp.local."
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 19: Eth 1 sends mDNS query QType PTR.");
+    ClearAllBytes(browser);
+    browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+    browser.mServiceType  = kSrpServiceType;
+    browser.mInfraIfIndex = kInfraIfIndex;
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 20
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.4): Does not send mDNS Response, since the service with matching type has expired. Note:
+     *   although the key leases on service-test-1 and host-test-1 are still active now, the generic service pointer is
+     *   expired and therefore no result to the PTR query is given. There is no record with the generic name stored,
+     *   anymore.
+     * - Pass Criteria:
+     *   - The DUT MUST NOT send an mDNS Response.
+     */
+    Log("Step 20: BR 1 (DUT) should not send mDNS Response.");
+
+    /**
+     * Step 21
+     * - Device: ED 1
+     * - Description (SRP-2.4): Harness instructs the device to send SRP Update with different service name but
+     *   conflicting host name: $ORIGIN default.service.arpa. service-test-3._thread-test._udp ( SRV 0 0 55555
+     *   host-test-1 ) host-test-1 AAAA <OMR address of ED_1> With the following options: Update Lease Option Lease:
+     *   30 seconds Key Lease: 30 seconds
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 21: ED 1 sends SRP Update with conflicting host name.");
+    RegisterSrpService(ed1, service, kSrpHostName1, kSrpInstanceName3, kLease30s, kKeyLease30s, 0, 0);
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 22
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.4): Automatically sends SRP Update Response, denying the registration because host
+     *   'host-test-1' is already claimed by ED_2 for its key lease time.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response with RCODE = 6 (YXDOMAIN).
+     */
+    Log("Step 22: BR 1 (DUT) sends SRP Update Response (YXDOMAIN).");
+
+    Log("All steps completed.");
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_1_MLEID_ADDR", ed1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_2_MLEID_ADDR", ed2.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    {
+        Ip6::Prefix omrPrefix;
+        SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+        nexus.AddTestVar("ED_1_OMR_ADDR",
+                         ed1.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+        nexus.AddTestVar("ED_2_OMR_ADDR",
+                         ed2.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+    }
+
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_4((argc > 2) ? argv[2] : "test_1_3_SRP_TC_4.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_5.cpp b/tests/nexus/test_1_3_SRP_TC_5.cpp
new file mode 100644
index 0000000..33ea2e9
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_5.cpp
@@ -0,0 +1,397 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 10 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]      = "_thread-test._udp";
+static const char         kSrpInstanceName[]     = "service test 1";
+static const char         kSrpHostName[]         = "host-test-1";
+static const char         kDnsQueryServiceType[] = "_thread-test._udp.default.service.arpa.";
+static constexpr uint16_t kSrpServicePort        = 1500;
+
+void Test_1_3_SRP_TC_5(const char *aJsonFileName)
+{
+    Srp::Client::Service service;
+
+    /**
+     * 2.5. [1.3] [CERT] KEY record inclusion/omission
+     *
+     * 2.5.1. Purpose
+     * To test the following:
+     * - 1. Handle Service Description Instructions that include the KEY RR.
+     * - 2. Handle Service Description Instructions that omit the KEY RR.
+     * - 3. Report error on Host Description Instructions that omit the KEY RR.
+     *
+     * 2.5.2. Topology
+     * - BR_1 (DUT) - Thread Border Router and the Leader
+     * - ED_1 - Test Bed device operating as a Thread End Device, attached to BR_1
+     * - Eth_1 - Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Server       | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    br1.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.5): Enable: switch on.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR_1 (DUT) Enable: switch on.");
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     * - Device: Eth_1, ED_1
+     * - Description (SRP-2.5): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth_1, ED_1 Enable.");
+    ed1.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    /**
+     * Wait for BR to advertise prefixes and ED to get an OMR address
+     */
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.5): Automatically adds SRP Server information in the Thread Network Data; and configures
+     *   OMR prefix.
+     * - Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data, as DNS/SRP Unicast Dataset, or
+     *     DNS/SRP Anycast Dataset, or both.
+     *   - ULA OMR prefix MUST appear in the Thread Network Data.
+     */
+    Log("Step 3: BR_1 (DUT) Automatically adds SRP Server information in the Thread Network Data.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 4
+     * - Device: ED_1
+     * - Description (SRP-2.5): Harness instructs the device to send SRP Update to the DUT with service KEY records
+     *   enabled:
+     *   $ORIGIN default.service.arpa.
+     *   _thread-test._udp PTR service test 1._thread-test._udp
+     *   service test 1._thread-test._udp ( SRV 0 0 1500 host-test-1 ) KEY <public key of ED_1>
+     *   host-test-1 AAAA <ML-EID address of ED_1> AAAA <OMR address of ED_1> KEY <public key of ED_1>
+     *   with the following options: Update Lease Option Lease: 20 minutes Key Lease: 20 minutes
+     *   Note: normally a client will not register the ML-EID address in this situation. For testing purposes, the
+     *   harness will configure/force this.
+     *   Note: including service KEY records is not typically done by default. Reference device is configured for
+     *   this.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED_1 sends SRP Update to the DUT with service KEY records enabled.");
+    {
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+
+        ed1.Get<Srp::Client>().SetServiceKeyRecordEnabled(true);
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 5
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.5): Automatically sends success SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+     */
+    Log("Step 5: BR_1 (DUT) Automatically sends success SRP Update Response.");
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 6
+     * - Device: ED_1
+     * - Description (SRP-2.5): Harness instructs the device to unicast DNS QType=PTR query to the DUT for all
+     *   services of type _thread-test._udp.local..
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED_1 unicast DNS QType=PTR query to the DUT.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kDnsQueryServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.5): Automatically sends DNS response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response containing Answer record: $ORIGIN default.service.arpa.
+     *     _thread-test._udp PTR service test 1._thread-test._udp
+     *   - It MAY contain in the Additional records section: $ORIGIN default.service.arpa.
+     *     service test 1._thread-test._udp ( SRV 0 0 1500 host-test-1 ) host-test-1 AAAA <ML-EID address of ED_1>
+     *     AAAA <OMR address of ED_1>
+     *   - The DNS Response MUST NOT contain any RRType=KEY records.
+     */
+    Log("Step 7: BR_1 (DUT) Automatically sends DNS response.");
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 8
+     * - Device: Eth_1
+     * - Description (SRP-2.5): Harness instructs the device to send mDNS QType=PTR query for services of type
+     *   _thread-test._udp.local.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: Eth_1 send mDNS QType=PTR query.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        IgnoreError(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 9
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.5): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response containing Answer record: $ORIGIN local.
+     *     _thread-test._udp PTR service test 1._thread-test._udp
+     *   - It MAY contain in the Additional records section: Service test 1._thread-test._udp ( SRV 0 0 1500
+     *     host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+     *   - The DNS Response MUST NOT contain any RRType=KEY records.
+     *   - The DNS Response MAY also contain in the Additional records section: host-test-1.local AAAA
+     *     <ML-EID address of ED_1>
+     */
+    Log("Step 9: BR_1 (DUT) Automatically sends mDNS Response.");
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 10
+     * - Device: N/A
+     * - Description (SRP-2.5): Repeat Steps 4 through 9 with KEY record omitted from Service Description
+     *   Instruction in step 4, i.e. the following record is omitted in step 4: service test 1._thread-test._udp KEY
+     *   <pubkey of ED_1>. The host KEY records are kept.
+     * - Pass Criteria:
+     *   - Same as Steps 4 through 9.
+     */
+    Log("Step 10: Repeat Steps 4 through 9 with KEY record omitted from Service Description Instruction.");
+
+    /**
+     * Implementation of Step 10 (Repeat 4-9)
+     */
+    Log("Step 10.4: ED_1 sends SRP Update with service KEY record disabled.");
+    {
+        ed1.Get<Srp::Client>().SetServiceKeyRecordEnabled(false);
+        IgnoreError(ed1.Get<Srp::Client>().ClearService(service));
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("Step 10.5: BR_1 (DUT) Automatically sends success SRP Update Response.");
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("Step 10.6: ED_1 unicast DNS QType=PTR query.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kDnsQueryServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    Log("Step 10.7: BR_1 (DUT) Automatically sends DNS response.");
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    Log("Step 10.8: Eth_1 send mDNS QType=PTR query.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime * 2);
+        IgnoreError(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    Log("Step 10.9: BR_1 (DUT) Automatically sends mDNS Response.");
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 11
+     * - Device: ED_1
+     * - Description (SRP-2.5): Repeat Step 4 with service KEY records omitted, while keeping host KEY records.
+     * - Pass Criteria:
+     *   - Repeat Step 4
+     */
+    Log("Step 11: Repeat Step 4 with service KEY records omitted.");
+    /**
+     * This seems redundant with Step 10's repeat of Step 4, but I will follow the spec.
+     */
+    {
+        ed1.Get<Srp::Client>().SetServiceKeyRecordEnabled(false);
+        IgnoreError(ed1.Get<Srp::Client>().ClearService(service));
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 12
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.5): Automatically sends success SRP Update Response.
+     * - Pass Criteria:
+     *   - Repeat Step 5
+     */
+    Log("Step 12: BR_1 (DUT) Automatically sends success SRP Update Response.");
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 13
+     * - Device: ED_1
+     * - Description (SRP-2.5): Repeat Step 4 with all KEY records omitted. Note: currently (2022-07) this is not yet
+     *   supported in reference devices. It could be implemented using a "UDP send" API with a manually constructed
+     *   sequence of bytes that forms the invalid SRP Update message. To be considered for the future.
+     * - Pass Criteria:
+     *   - N/A (TBD to be added in the future)
+     */
+    Log("Step 13: ED_1 Repeat Step 4 with all KEY records omitted.");
+    {
+        ed1.Get<Srp::Client>().SetServiceKeyRecordEnabled(false);
+        ed1.Get<Srp::Client>().SetHostKeyRecordEnabled(false);
+        IgnoreError(ed1.Get<Srp::Client>().ClearService(service));
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 14
+     * - Device: BR_1 (DUT)
+     * - Description (SRP-2.5): Automatically responds with error SRP Update Response.
+     * - Pass Criteria:
+     *   - N/A (TBD-add in the future verify that RCODE=5, Refused)
+     */
+    Log("Step 14: BR_1 (DUT) Automatically responds with error SRP Update Response.");
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("All steps completed.");
+
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("ED_1_MLEID_ADDR", ed1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_5((argc > 2) ? argv[2] : "test_1_3_SRP_TC_5.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_6.cpp b/tests/nexus/test_1_3_SRP_TC_6.cpp
new file mode 100644
index 0000000..c072e73
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_6.cpp
@@ -0,0 +1,389 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]     = "_thread-test._udp";
+static const char         kSrpInstanceName[]    = "service-test-1";
+static const char         kSrpHostName[]        = "host-test-1";
+static const char         kSrpFullServiceType[] = "_thread-test._udp.default.service.arpa.";
+static constexpr uint16_t kSrpServicePort       = 55555;
+static constexpr uint32_t kLease                = 20 * 60; // 20 minutes
+static constexpr uint32_t kKeyLease             = 20 * 60; // 20 minutes
+
+void Test_1_3_SRP_TC_6(const char *aJsonFileName)
+{
+    Srp::Client::Service service;
+
+    /**
+     * 2.6. [1.3] [CERT] Name Compression
+     *
+     * 2.6.1. Purpose
+     * To test the following:
+     * - 1. Handle RRs without name compression.
+     * - 2. Handle RRs with name compression
+     *
+     * 2.6.2. Topology
+     * - 1. BR 1 (DUT) - Thread Border Router and the Leader
+     * - 2. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - 3. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference | V1.1 Section | V1.3.0 Section
+     * ---------------|--------------|---------------
+     * SRP Server     | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    br1.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.6): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) Enable.");
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     * - Device: Eth 1. ED 1
+     * - Description (SRP-2.6): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth 1, ED 1 Enable.");
+    ed1.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    // Wait for BR to advertise prefixes and ED to get an OMR address
+    nexus.AdvanceTime(kBrActionTime);
+
+    /**
+     * Step 3
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.6): Automatically adds SRP Server information in the Thread Network Data. Automatically
+     *   adds OMR prefix to the Thread Network Data.
+     * - Pass Criteria:
+     *   - The DUT's SRP Server information MUST appear in the Thread Network Data.
+     */
+    Log("Step 3: BR 1 (DUT) adds SRP Server information in the Thread Network Data.");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 4
+     * - Device: ED 1
+     * - Description (SRP-2.6): Harness instructs the device to send SRP Update without name compression: $ORIGIN
+     *   default.service.arpa. _thread-test. udp PTR service-test-1._thread-test._udp
+     *   service-test-1._thread-test._udp ( SRV 55555 host-test-1) host-test-1 AAAA OMR address of ED 1> with the
+     *   following options: Update Lease Option Lease: 20 minutes Key Lease: 20 minutes
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED 1 sends SRP Update without name compression.");
+    {
+        SuccessOrQuit(ed1.SetDnsNameCompressionEnabled(false));
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+        ed1.Get<Srp::Client>().SetLeaseInterval(kLease);
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kKeyLease);
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 5
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.6): Automatically sends SRP Update Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid SRP Update Response with Rcode=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) sends SRP Update Response.");
+
+    /**
+     * Step 6
+     * - Device: ED 1
+     * - Description (SRP-2.6): Harness instructs the device to unicast DNS QType PTR query to the DUT for all
+     *   services of type \"_thread-test._udp.local.\".
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED 1 sends unicast DNS QType PTR query.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.6): Automatically sends DNS response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid DNS Response with RCODE=0 (NoError), containing in the Answers section: $ORIGIN
+     *     default.service.arpa. _thread-test. udp PTR service-test-1. thread-test._udp
+     *   - It MAY contain in the Additional records section: $ORIGIN local. Service-test-1_thread-test._udp ( SRV 0
+     *     55555 host-test-1) testAAAA <OMR address of ED_1> host--1
+     */
+    Log("Step 7: BR 1 (DUT) sends DNS response.");
+
+    /**
+     * Step 8
+     * - Device: Eth_1
+     * - Description (SRP-2.6): Hamess instructs the device to send mDNS query QType=PTR query for all services of
+     *   type \"_thread-test._udp.local\"..
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: Eth 1 sends mDNS query QType=PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 9
+     * - Device: BR 1 (DUT)
+     * - Description (SRP-2.6): Automatically sends mDNS Response.
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response with Rcode=0 (NoError), containing in the Answers section: $ORIGIN
+     *     local. _thread-test. udp PTR service-test-1._thread-test._udp
+     *   - It MAY contain in the Additional records section: $ORIGIN local. Service-test-1. thread-test._udp SRV 55555
+     *     host-test-1) host-test-1 AAAA OMR address of ED_1>
+     */
+    Log("Step 9: BR 1 (DUT) sends mDNS Response.");
+
+    /**
+     * Step 9b
+     * - Device: Eth 1
+     * - Description (SRP-2.6): Harness instructs the device to send mDNS query QTYPE=SRV
+     *   QNAME-service-test-1._thread-test._udp.local
+     * - Pass Criteria:
+     *   - The DUT MUST send a valid mDNS Response with RCODE=0, containing in the Answers section record:
+     *     service-test-1. thread-test. udp.local ( SRV 55555 host-test-1.local)
+     */
+    Log("Step 9b: Eth 1 sends mDNS query QTYPE=SRV.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+
+        // Clear cache to force a new query
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 10
+     * - Device: N/A
+     * - Description (SRP-2.6): Repeat Steps 4 through 9b, where in step 4 name compression is enabled for all
+     *   repeated names in the SRP Update.
+     * - Pass Criteria:
+     *   - Repeat Steps 4 through 9b.
+     */
+    Log("Step 10: Repeat Steps 4 through 9b with name compression.");
+
+    // Repeat Step 4 (with compression)
+    Log("Step 4 (Repeat): ED 1 sends SRP Update with name compression.");
+    {
+        SuccessOrQuit(ed1.SetDnsNameCompressionEnabled(true));
+        ed1.Get<Srp::Client>().ClearHostAndServices();
+
+        ed1.Get<Srp::Client>().SetLeaseInterval(kLease);
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kKeyLease);
+
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    // Step 5 (Repeat)
+    Log("Step 5 (Repeat): BR 1 (DUT) sends SRP Update Response.");
+
+    // Step 6 (Repeat)
+    Log("Step 6 (Repeat): ED 1 sends unicast DNS QType PTR query.");
+    {
+        SuccessOrQuit(ed1.Get<Dns::Client>().Browse(kSrpFullServiceType, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    // Step 7 (Repeat)
+    Log("Step 7 (Repeat): BR 1 (DUT) sends DNS response.");
+
+    // Step 8 (Repeat)
+    Log("Step 8 (Repeat): Eth 1 sends mDNS query QType=PTR.");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    // Step 9 (Repeat)
+    Log("Step 9 (Repeat): BR 1 (DUT) sends mDNS Response.");
+
+    // Step 9b (Repeat)
+    Log("Step 9b (Repeat): Eth 1 sends mDNS query QTYPE=SRV.");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+
+        // Clear cache to force a new query
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    nexus.AddTestVar("ED_1_MLEID_ADDR", ed1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    {
+        Ip6::Prefix omrPrefix;
+        SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetOmrPrefix(omrPrefix));
+        nexus.AddTestVar("ED_1_OMR_ADDR",
+                         ed1.FindMatchingAddress(omrPrefix.ToString().AsCString()).ToString().AsCString());
+    }
+    nexus.AddTestVar("BR_1_MLEID_ADDR", br1.Get<Mle::Mle>().GetMeshLocalEid().ToString().AsCString());
+    nexus.AddTestVar("BR_1_SRP_PORT", br1.Get<Srp::Server>().GetPort());
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_6((argc > 2) ? argv[2] : "test_1_3_SRP_TC_6.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_3_SRP_TC_8.cpp b/tests/nexus/test_1_3_SRP_TC_8.cpp
new file mode 100644
index 0000000..3110abc
--- /dev/null
+++ b/tests/nexus/test_1_3_SRP_TC_8.cpp
@@ -0,0 +1,489 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]     = "_thread-test._udp";
+static const char         kSrpInstanceName1[]   = "service-test-1";
+static const char         kSrpInstanceName2[]   = "service-test-2";
+static const char         kSrpHostName[]        = "host-test-1";
+static const char         kSrpFullServiceType[] = "_thread-test._udp.default.service.arpa.";
+static constexpr uint16_t kSrpServicePort1      = 55555;
+static constexpr uint16_t kSrpServicePort2      = 55556;
+static constexpr uint16_t kSrpServicePriority2  = 8;
+static constexpr uint32_t kSrpLease             = 10 * 60; // 10 minutes
+static constexpr uint32_t kSrpKeyLease          = 20 * 60; // 20 minutes
+static constexpr uint32_t kSrpKeyLeaseShorter   = 10 * 60; // 10 minutes
+
+void Test_1_3_SRP_TC_8(const char *aJsonFileName)
+{
+    Srp::Client::Service service1;
+    Srp::Client::Service service2;
+
+    /**
+     * 2.8. [1.3] [CERT] Removing Some Published Services
+     *
+     * 2.8.1. Purpose
+     * To test the following:
+     * - 1. Remove only selected service instances, not all.
+     *
+     * 2.8.2. Topology
+     * - BR 1 (DUT) - Thread Border Router and the Leader
+     * - ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+     * - Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * SRP Server       | N/A          | 1.3
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("Eth_1");
+
+    br1.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+
+    /**
+     * Step 1
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.8): Enable
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: BR 1 (DUT) Enable");
+    SuccessOrQuit(br1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    /**
+     * Step 2
+     *   Device: Eth 1, ED 1
+     *   Description (SRP-2.8): Enable
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth 1, ED 1 Enable");
+    ed1.Join(br1, Node::kAsFed);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    /**
+     * Step 3
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.8): Automatically adds SRP Server information in the Thread Network Data. Automatically adds
+     *     OMR prefix in Network Data.
+     *   Pass Criteria:
+     *   - [T1-T6] The DUT's SRP Server information MUST appear in the Thread Network Data.
+     */
+    Log("Step 3: BR 1 (DUT) SRP Server info in Network Data");
+    nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+    /**
+     * Step 4
+     *   Device: ED 1
+     *   Description (SRP-2.8): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+     *     service-test-1._thread-test._udp ( SRV 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1> with the
+     *     following options: Update Lease Option, Lease: 10 minutes, Key Lease: 20 minutes
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: ED 1 sends SRP Update for service 1");
+    {
+        ed1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        SuccessOrQuit(ed1.Get<Srp::Client>().SetHostName(kSrpHostName));
+        SuccessOrQuit(ed1.Get<Srp::Client>().EnableAutoHostAddress());
+
+        ed1.Get<Srp::Client>().SetLeaseInterval(kSrpLease);
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLease);
+
+        ClearAllBytes(service1);
+        service1.mName         = kSrpServiceType;
+        service1.mInstanceName = kSrpInstanceName1;
+        service1.mPort         = kSrpServicePort1;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service1));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 5
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.8): Automatically sends SRP Update Response.
+     *   Pass Criteria:
+     *   - [T7] The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 5: BR 1 (DUT) sends SRP Update Response");
+
+    /**
+     * Step 6
+     *   Device: ED 1
+     *   Description (SRP-2.8): Harness instructs the device to unicast DNS query QType SRV for
+     *     "service-test-1._thread-test. udp.default.service.arpa." to the DUT.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED 1 unicast DNS query SRV for service 1");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstanceName1, kSrpFullServiceType, nullptr, nullptr));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 7
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.8): Automatically sends DNS response.
+     *   Pass Criteria:
+     *   - [T8] The DUT MUST send a valid DNS Response, with RCODE=0 (NoError) and
+     *   - [T9] contains answer record: $ORIGIN default.service.arpa. service-test-1. thread-test._udp ( SRV 55555
+     *     host-test-1 )
+     *   - [T10] The DNS Response MAY contain in Additional records section: SORIGIN default.service.arpa. host-test-1
+     *     AAAA <OMR address of ED 1>
+     */
+    Log("Step 7: BR 1 (DUT) sends DNS response");
+
+    /**
+     * Step 8
+     *   Device: Eth_1
+     *   Description (SRP-2.8): Harness instructs the device to send mDNS query QType SRV for "service-test-1.
+     *     thread-test. udp.local.".
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 8: Eth 1 sends mDNS query SRV for service 1");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName1;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 9
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.8): Automatically sends mDNS Response.
+     *   Pass Criteria:
+     *   - [T11] The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError) and
+     *   - [T12] contains answer record: SORIGIN local. service-test-1. thread-test._udp ( SRV 55555 host-test-1 )
+     *   - [T13] DNS Response MAY contain in Additional records section: SORIGIN local. Host-test-1 AAAA <OMR address of
+     *     ED 1>
+     */
+    Log("Step 9: BR 1 (DUT) sends mDNS Response");
+
+    /**
+     * Step 10
+     *   Device: ED_1
+     *   Description (SRP-2.8): Harness instructs the device to send SRP Update, adding one more service: $ORIGIN
+     *     default.service.arpa. service-test-2._thread-test._udp ( SRV 8 55556 host-test-1 ) host-test-1 AAAA <OMR
+     *     address of ED_1> with the following options: Update Lease Option, Lease: 10 minutes, Key Lease: 20 minutes
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: ED 1 sends SRP Update adding service 2");
+    {
+        ClearAllBytes(service2);
+        service2.mName         = kSrpServiceType;
+        service2.mInstanceName = kSrpInstanceName2;
+        service2.mPort         = kSrpServicePort2;
+        service2.mPriority     = kSrpServicePriority2;
+        SuccessOrQuit(ed1.Get<Srp::Client>().AddService(service2));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 11
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.8): Automatically sends SRP Update Response.
+     *   Pass Criteria:
+     *   - [T14] The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 11: BR 1 (DUT) sends SRP Update Response");
+
+    /**
+     * Step 12
+     *   Device: ED_1
+     *   Description (SRP-2.8): Harness instructs the device to unicast DNS query QType=SRV for service-test-2 to the
+     *     DUT
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 12: ED 1 unicast DNS query SRV for service 2");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstanceName2, kSrpFullServiceType, nullptr, nullptr));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 13
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.8): Automatically sends DNS response.
+     *   Pass Criteria:
+     *   - [T15] The DUT MUST send a valid DNS Response.
+     *   - [T16] containing service-test-2 answer record: $ORIGIN default.service.arpa. service-test-2._thread-test._udp
+     *     ( SRV 55556 host-test-1 )
+     *   - [T17] DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+     *     <OMR address of ED_1>
+     */
+    Log("Step 13: BR 1 (DUT) sends DNS response");
+
+    /**
+     * Step 14
+     *   Device: Eth_1
+     *   Description (SRP-2.8): Repeat Step 8. Note: this is repeated to verify that the first service is still there.
+     *   Pass Criteria:
+     *   - Repeat Step 8
+     */
+    Log("Step 14: Eth 1 sends mDNS query SRV for service 1 (repeat)");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName1;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 15
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.8): Repeat Step 9
+     *   Pass Criteria:
+     *   - [T18-T20] Repeat Step 9
+     */
+    Log("Step 15: BR 1 (DUT) sends mDNS Response (repeat)");
+
+    /**
+     * Step 16
+     *   Device: ED 1
+     *   Description (SRP-2.8): Harness instructs device to send SRP Update, removing the first service only: 1. Service
+     *     Discovery Instruction Delete an RR from an RRset semantics using data: $ORIGIN default.service.arpa. PTR
+     *     service-test-1. thread-test._udp 2. Service Description Instruction Delete All RRsets From a Name semantics:
+     *     $ORIGIN default.service.arpa. service-test-1._thread-test._udp ANY <tt1=0> with the following options: Update
+     *     Lease Option, Lease: 10 minutes (unchanged), Key Lease: 10 minutes (shorter). Note: this method to remove a
+     *     service follows 3.2.5.5.2 of [draft-srp-23]. Note: for OT reference device, the command 'srp client
+     *     keyleaseinterval 600' is used to shorten the key lease interval as indicated.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 16: ED 1 sends SRP Update removing service 1");
+    {
+        ed1.Get<Srp::Client>().SetKeyLeaseInterval(kSrpKeyLeaseShorter);
+        SuccessOrQuit(ed1.Get<Srp::Client>().RemoveService(service1));
+    }
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    /**
+     * Step 17
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.8): Automatically sends SRP Update Response.
+     *   Pass Criteria:
+     *   - [T21] The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+     */
+    Log("Step 17: BR 1 (DUT) sends SRP Update Response");
+
+    /**
+     * Step 18
+     *   Device: ED 1
+     *   Description (SRP-2.8): Harness instructs the device to unicast DNS query QType SRV for "service-test-1.
+     *     thread-test. udp.default.service.arpa." to the DUT
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 18: ED 1 unicast DNS query SRV for service 1");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstanceName1, kSrpFullServiceType, nullptr, nullptr));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 19
+     *   Device: BR 1 (DUT)
+     *   Description (SRP-2.8): Automatically sends DNS response. Note: the service instance name with all associated
+     *     records has been deleted in step 16.
+     *   Pass Criteria:
+     *   - [T22] The DUT MUST send a valid DNS Response, with RCODE=0 (NoError); and
+     *   - [T23] MUST contain 0 answer records.
+     */
+    Log("Step 19: BR 1 (DUT) sends DNS response (0 answers)");
+
+    /**
+     * Step 20
+     *   Device: ED 1
+     *   Description (SRP-2.8): Harness instructs the device to unicast DNS query QType SRV for "service-test-2.
+     *     thread-test. udp.default.service.arpa." to the DUT.
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 20: ED 1 unicast DNS query SRV for service 2");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveService(kSrpInstanceName2, kSrpFullServiceType, nullptr, nullptr));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    /**
+     * Step 21
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.8): Automatically sends DNS response.
+     *   Pass Criteria:
+     *   - [T24] The DUT MUST send a valid DNS Response, with RCODE=0 (NoError) and
+     *   - [T25] contains answer record: $ORIGIN default.service.arpa. service-test-2. thread-test._udp ( SRV 55556
+     *     host-test-1 )
+     *   - [T26] DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+     *     <OMR address of ED 1>
+     */
+    Log("Step 21: BR 1 (DUT) sends DNS response (service 2)");
+
+    /**
+     * Step 22
+     *   Device: Eth_1
+     *   Description (SRP-2.8): Harness instructs the device to send mDNS query QType SRV for "service-test-1.
+     *     thread-test. udp.local".
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 22: Eth 1 sends mDNS query SRV for service 1");
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = kSrpInstanceName1;
+        resolver.mServiceType     = kSrpServiceType;
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    /**
+     * Step 23
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.8): Does not send a mDNS response.
+     *   Pass Criteria:
+     *   - [T27] The DUT MUST NOT send an mDNS response.
+     */
+    Log("Step 23: BR 1 (DUT) does NOT send mDNS response");
+
+    /**
+     * Step 24
+     *   Device: Eth 1
+     *   Description (SRP-2.8): Harness instructs the device to send mDNS query QType=PTR any services of type
+     *     "_thread-test._udp.local".
+     *   Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 24: Eth 1 sends mDNS query PTR for services");
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = [](otInstance *, const otPlatDnssdBrowseResult *) {};
+        browser.mServiceType  = kSrpServiceType;
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    /**
+     * Step 25
+     *   Device: BR_1 (DUT)
+     *   Description (SRP-2.8): Automatically sends mDNS response with service-test-2.
+     *   Pass Criteria:
+     *   - [T28] The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError) and
+     *   - [T29] contains service-test-2 answer record: $ORIGIN local. _thread-test._udp ( PTR
+     *     service-test-2._thread-test._udp )
+     *   - [T30] Response MUST NOT contain any further records in the Answers section.
+     *   - [T31-32] DNS Response MAY contain in Additional records section: SORIGIN local.
+     *     Service-test-2._thread-test._udp ( SRV 8 0 55556 host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+     */
+    Log("Step 25: BR 1 (DUT) sends mDNS response with service 2 only");
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_3_SRP_TC_8((argc > 2) ? argv[2] : "test_1_3_SRP_TC_8.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_CS_TC_3.cpp b/tests/nexus/test_1_4_CS_TC_3.cpp
new file mode 100644
index 0000000..76f31cb
--- /dev/null
+++ b/tests/nexus/test_1_4_CS_TC_3.cpp
@@ -0,0 +1,921 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/uri_paths.hpp"
+#include "utils/verhoeff_checksum.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 50 * Time::kOneSecondInMsec;
+
+/**
+ * Time to advance for the DUT to connect to the network, in milliseconds.
+ */
+static constexpr uint32_t kAttachTime = 10 * Time::kOneSecondInMsec;
+
+/**
+ * Time to advance for mDNS discovery, in milliseconds.
+ */
+static constexpr uint32_t kMdnsDiscoveryTime = 5 * Time::kOneSecondInMsec;
+
+/**
+ * Time to advance for DTLS connection, in milliseconds.
+ */
+static constexpr uint32_t kConnectTimeout = 3 * Time::kOneSecondInMsec;
+
+/**
+ * Time to advance for TMF message exchange, in milliseconds.
+ */
+static constexpr uint32_t kTmfExchangeTime = 1 * Time::kOneSecondInMsec;
+
+/**
+ * Max ePSKc connection attempts.
+ */
+static constexpr uint8_t kMaxEpskcConnAttempts = 10;
+
+/**
+ * The filename for the DTLS key log.
+ */
+static const char kKeyLogFilename[] = "/tmp/test_1_4_CS_TC_3.keys";
+
+/**
+ * The master secret length in bytes.
+ */
+static constexpr uint8_t kMasterSecretLength = 48;
+
+/**
+ * The random buffer length in bytes.
+ */
+static constexpr uint8_t kRandomBufferLength = MeshCoP::SecureTransport::kSecureTransportRandomBufferSize;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * The UDP port for ePSKc DTLS session.
+ */
+static constexpr uint16_t kEpskcPort = 1234;
+
+/**
+ * Time to wait for DTLS retransmissions to finish (in milliseconds).
+ */
+static constexpr uint32_t kDtlsRetransmissionWaitTime = 60 * Time::kOneSecondInMsec;
+
+/**
+ * Time to wait for DUT state to be cleaned up after DTLS session (in milliseconds).
+ */
+static constexpr uint32_t kDutStateCleanTime = 100 * Time::kOneSecondInMsec;
+
+using EphemeralKeyManager = MeshCoP::BorderAgent::EphemeralKeyManager;
+using Manager             = MeshCoP::BorderAgent::Manager;
+
+static void HandleMbedtlsExportKeys(void                       *aContext,
+                                    mbedtls_ssl_key_export_type aType,
+                                    const unsigned char        *aMasterSecret,
+                                    size_t                      aMasterSecretLen,
+                                    const unsigned char         aClientRandom[kRandomBufferLength],
+                                    const unsigned char         aServerRandom[kRandomBufferLength],
+                                    mbedtls_tls_prf_types       aTlsPrfType)
+{
+    FILE *file = fopen(kKeyLogFilename, "a");
+
+    OT_UNUSED_VARIABLE(aContext);
+    OT_UNUSED_VARIABLE(aMasterSecretLen);
+    OT_UNUSED_VARIABLE(aServerRandom);
+    OT_UNUSED_VARIABLE(aTlsPrfType);
+
+    if (file != nullptr)
+    {
+        if (aType == MBEDTLS_SSL_KEY_EXPORT_TLS12_MASTER_SECRET)
+        {
+            fprintf(file, "CLIENT_RANDOM ");
+            for (int i = 0; i < kRandomBufferLength; i++)
+            {
+                fprintf(file, "%02x", aClientRandom[i]);
+            }
+            fprintf(file, " ");
+            for (int i = 0; i < kMasterSecretLength; i++)
+            {
+                fprintf(file, "%02x", aMasterSecret[i]);
+            }
+            fprintf(file, "\n");
+        }
+        fclose(file);
+    }
+}
+
+typedef Dns::Name::Buffer DnsName;
+
+struct BrowseOutcome
+{
+    DnsName      mServiceInstance;
+    DnsName      mHostName;
+    uint16_t     mPort;
+    Ip6::Address mAddress;
+};
+
+static Array<BrowseOutcome, 5> sBrowseOutcomes;
+
+static void HandleBrowseCallback(otInstance *aInstance, const Dns::Multicast::Core::BrowseResult *aResult)
+{
+    BrowseOutcome *outcome;
+
+    OT_UNUSED_VARIABLE(aInstance);
+
+    outcome = sBrowseOutcomes.PushBack();
+    VerifyOrQuit(outcome != nullptr);
+
+    SuccessOrQuit(StringCopy(outcome->mServiceInstance, aResult->mServiceInstance));
+}
+
+static void HandleSrvCallback(otInstance *aInstance, const Dns::Multicast::Core::SrvResult *aResult)
+{
+    OT_UNUSED_VARIABLE(aInstance);
+
+    for (BrowseOutcome &outcome : sBrowseOutcomes)
+    {
+        if (StringMatch(outcome.mServiceInstance, aResult->mServiceInstance))
+        {
+            outcome.mPort = aResult->mPort;
+            SuccessOrQuit(StringCopy(outcome.mHostName, aResult->mHostName));
+            break;
+        }
+    }
+}
+
+static void HandleAddressCallback(otInstance *aInstance, const Dns::Multicast::Core::AddressResult *aResult)
+{
+    OT_UNUSED_VARIABLE(aInstance);
+
+    for (BrowseOutcome &outcome : sBrowseOutcomes)
+    {
+        if (StringMatch(outcome.mHostName, aResult->mHostName))
+        {
+            if (aResult->mAddressesLength > 0)
+            {
+                outcome.mAddress = AsCoreType(&aResult->mAddresses[0].mAddress);
+            }
+            break;
+        }
+    }
+}
+
+static void DiscoverMeshcopEService(Node &aCommNode, Core &aNexus, Ip6::Address &aAddress, uint16_t &aPort)
+{
+    sBrowseOutcomes.Clear();
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mServiceType  = "_meshcop-e._udp";
+        browser.mInfraIfIndex = kInfraIfIndex;
+        browser.mCallback     = HandleBrowseCallback;
+        SuccessOrQuit(aCommNode.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        aNexus.AdvanceTime(kMdnsDiscoveryTime);
+        SuccessOrQuit(aCommNode.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    VerifyOrQuit(sBrowseOutcomes.GetLength() == 1);
+
+    {
+        Dns::Multicast::Core::SrvResolver srvResolver;
+        ClearAllBytes(srvResolver);
+        srvResolver.mServiceInstance = sBrowseOutcomes[0].mServiceInstance;
+        srvResolver.mServiceType     = "_meshcop-e._udp";
+        srvResolver.mInfraIfIndex    = kInfraIfIndex;
+        srvResolver.mCallback        = HandleSrvCallback;
+        SuccessOrQuit(aCommNode.Get<Dns::Multicast::Core>().StartSrvResolver(srvResolver));
+        aNexus.AdvanceTime(kMdnsDiscoveryTime);
+        SuccessOrQuit(aCommNode.Get<Dns::Multicast::Core>().StopSrvResolver(srvResolver));
+    }
+
+    {
+        Dns::Multicast::Core::AddressResolver addrResolver;
+        ClearAllBytes(addrResolver);
+        addrResolver.mHostName     = sBrowseOutcomes[0].mHostName;
+        addrResolver.mInfraIfIndex = kInfraIfIndex;
+        addrResolver.mCallback     = HandleAddressCallback;
+        SuccessOrQuit(aCommNode.Get<Dns::Multicast::Core>().StartIp6AddressResolver(addrResolver));
+        aNexus.AdvanceTime(kMdnsDiscoveryTime);
+        SuccessOrQuit(aCommNode.Get<Dns::Multicast::Core>().StopIp6AddressResolver(addrResolver));
+    }
+
+    aPort    = sBrowseOutcomes[0].mPort;
+    aAddress = sBrowseOutcomes[0].mAddress;
+}
+
+void Test1_4_CS_TC_3(void)
+{
+    /**
+     * 12.3. [1.4] [CERT] [COMPONENT] BR component creates numeric passcode and Candidate connects
+     *
+     * 12.3.1. Purpose
+     * This test is for automatically testing BR component DUTs. Instead of a test operator typing the passcode, the
+     *   DUT generates a passcode and shares it via the Thread Harness API to the Harness so that it can execute the
+     *   automated test. No UI, GUI or display is needed on the BR DUT in this case.
+     * In detail, the purpose is to verify that the Border Router DUT component:
+     * - correctly implements the Thread Credential Sharing feature by supporting the Thread Administration Sharing
+     *   procedure
+     * - is able to generate a numeric Thread Administration One-Time Passcode consisting of 9 digits
+     * - allows an external Candidate to connect using the ePSKc that is based on the passcode
+     * - is able to facilitate the Candidate’s operations
+     * - enables access to a Candidate using the ePSKc, only once.
+     *
+     * 12.3.2. Topology
+     * - BR_1 – Border Router Thread Component DUT
+     * - Router_1 – Thread Router reference device and Leader
+     * - Comm_1 – IPv6 host reference device on the AIL that implements a Commissioner Candidate role.
+     *
+     * Spec Reference            | V1.4 Section
+     * --------------------------|-----------------
+     * Thread Credential Sharing | 8.4.1.2.3, 8.4.9
+     */
+
+    Core nexus;
+
+    Node &router1 = nexus.CreateNode();
+    Node &br1     = nexus.CreateNode();
+    Node &comm1   = nexus.CreateNode();
+
+    router1.SetName("Router_1");
+    br1.SetName("BR_1");
+    comm1.SetName("Comm_1");
+
+    router1.Get<Manager>().SetEnabled(false);
+    comm1.Get<Manager>().SetEnabled(false);
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    // Remove any existing key log file.
+    remove(kKeyLogFilename);
+
+#if OPENTHREAD_CONFIG_MBEDTLS_PROVIDES_SSL_KEY_EXPORT
+    static_cast<MeshCoP::SecureTransport &>(comm1.Get<Tmf::SecureAgent>())
+        .SetKeylogCallback(HandleMbedtlsExportKeys, &comm1.Get<Tmf::SecureAgent>());
+#endif
+
+    SuccessOrQuit(br1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    SuccessOrQuit(comm1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    comm1.Get<ThreadNetif>().Up();
+
+    // Wait for the interfaces to be ready and addresses to be configured.
+    nexus.AdvanceTime(Time::kOneSecondInMsec);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Enable and form network.");
+
+    /**
+     * Step 1
+     * - Device: Router_1, BR_1 (DUT)
+     * - Description (CS-12.3): Enable and form network. The DUT is configured with the Network Key of the test
+     *   Thread Network, so that it can connect to it.
+     * - Pass Criteria: The DUT MUST connect to Router_1’s network.
+     */
+
+    router1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    br1.Join(router1);
+    nexus.AdvanceTime(kAttachTime);
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsAttached());
+
+    SuccessOrQuit(comm1.Get<Mac::Mac>().SetPanChannel(router1.Get<Mac::Mac>().GetPanChannel()));
+    comm1.Get<Mac::Mac>().SetPanId(router1.Get<Mac::Mac>().GetPanId());
+    comm1.Get<ThreadNetif>().Up();
+
+    br1.Get<Manager>().SetEnabled(true);
+    nexus.AdvanceTime(Time::kOneSecondInMsec);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Comm_1 discovers BR DUT service.");
+
+    /**
+     * Step 2
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to do mDNS Qtype=PTR discovery query for DNS-SD service type
+     *   “_meshcop._udp.local”, to find the BR DUT service. ... Harness then verifies data in the SRV record and TXT
+     *   record as detailed to the right.
+     * - Pass Criteria:
+     *   - The DUT MUST respond with an mDNS message that contains one PTR record of type “_meshcop._udp.local” in the
+     *     Answer section.
+     *   - Comm_1 MUST be able to obtain the TXT record as detailed on the left.
+     *   - In the TXT record:
+     *     - ePSKc Support flag (Bit 11) in State Bitmap (sb) MUST be ‘1’ (ePSKc supported)
+     *     - ‘vn’ field MUST be present and contain >= 1 characters.
+     *     - ‘mn’ field MUST be present and contain >= 1 characters.
+     *   - Comm_1 MUST be able to obtain the SRV record as detailed on the left. Harness logs and stores the port
+     *     number in the SRV record for later use.
+     */
+
+    // Border Agent starts automatically when attached.
+    VerifyOrQuit(br1.Get<Manager>().IsEnabled());
+
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mServiceType  = "_meshcop._udp";
+        browser.mInfraIfIndex = kInfraIfIndex;
+        browser.mCallback     = HandleBrowseCallback;
+        SuccessOrQuit(comm1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kMdnsDiscoveryTime);
+        SuccessOrQuit(comm1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    VerifyOrQuit(sBrowseOutcomes.GetLength() == 1);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: BR_1 (DUT) produces passcode.");
+
+    /**
+     * Step 3
+     * - Device: BR_1 (DUT)
+     * - Description (CS-12.3): Harness instructs the DUT, via Test Harness API, to produce the Thread Administration
+     *   One-Time Passcode.
+     * - Pass Criteria: Passcode is successfully retrieved.
+     */
+
+    EphemeralKeyManager::Tap tap1;
+    SuccessOrQuit(tap1.GenerateRandom());
+    Log("Passcode 1: %s", tap1.mTap);
+    nexus.AddTestVar("Passcode1", tap1.mTap);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Harness validates the passcode.");
+
+    /**
+     * Step 4
+     * - Device: Harness
+     * - Description (CS-12.3): Harness validates the passcode. Harness calculates the ePSKc as follows: each
+     *   character from the passcode string is taken as an ePSKc octet.
+     * - Pass Criteria:
+     *   - Harness validates the following, using its passcode validation function:
+     *   - The passcode MUST contain only digits 0-9.
+     *   - The passcode MUST be 9 digits long.
+     *   - The last digit of the passcode MUST be equal to the check digit calculated by the Verhoeff algorithm on
+     *     the first 8 digits.
+     */
+
+    SuccessOrQuit(tap1.Validate());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Comm_1 discovers meshcop-e service.");
+
+    /**
+     * Step 5
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to discover a DNS-SD service type via mDNS, Qtype=PTR, name
+     *   “_meshcop-e._udp.local”. ... Harness stores the discovered IPv6 address and port for later use; and logs it.
+     * - Pass Criteria:
+     *   - The DUT MUST respond (mDNS) with a DNS-SD message that contains one PTR record of type
+     *     “_meshcop-e._udp.local” in the Answer section.
+     *   - Comm_1 MUST be able to obtain an IPv6 address (from AAAA record) and port (from SRV record) of the
+     *     “meshcop-e” service as detailed on the left.
+     */
+
+    SuccessOrQuit(br1.Get<EphemeralKeyManager>().Start(tap1.mTap, 0, kEpskcPort));
+
+    uint16_t     epskcPort;
+    Ip6::Address br1Addr;
+    DiscoverMeshcopEService(comm1, nexus, br1Addr, epskcPort);
+    Log("Discovered ePSKc address: %s, port: %u", br1Addr.ToString().AsCString(), epskcPort);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Comm_1 connects with incorrect ePSKc.");
+
+    /**
+     * Step 6
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to connect to the DUT on the address and port found in step 5.
+     *   For the DTLS PSK, it uses as ePSKc an incorrect ePSKc which is calculated as follows: 1. Seven octets
+     *   (b[0]…b[6]) copied from the correct ePSKc of step 4. 2. One octet (b[7]) as a random digit that differs from
+     *   the correct ePSKc of step 4. 3. One octet (b[8]) calculated as the correct Verhoeff check digit over the
+     *   previous 8 numeric digits. ... Harness then waits for 3 seconds.
+     * - Pass Criteria:
+     *   - The DTLS handshake/connection to the DUT MUST NOT succeed: the DUT MUST reject attempt to establish a DTLS
+     *     session.
+     */
+
+    {
+        char    incorrectTap[EphemeralKeyManager::Tap::kLength + 1];
+        char    checksum;
+        uint8_t i = 7;
+
+        memcpy(incorrectTap, tap1.mTap, EphemeralKeyManager::Tap::kLength + 1);
+        incorrectTap[i]++;
+        if (incorrectTap[i] > '9')
+        {
+            incorrectTap[i] = '0';
+        }
+        incorrectTap[EphemeralKeyManager::Tap::kLength - 1] = '\0';
+        SuccessOrQuit(Utils::VerhoeffChecksum::Calculate(incorrectTap, checksum));
+        incorrectTap[EphemeralKeyManager::Tap::kLength - 1] = checksum;
+
+        Ip6::SockAddr br1SockAddr;
+        br1SockAddr.SetAddress(br1Addr);
+        br1SockAddr.SetPort(epskcPort);
+
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(incorrectTap),
+                                                           EphemeralKeyManager::Tap::kLength));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Open(Ip6::kNetifBackbone));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Connect(br1SockAddr));
+
+        nexus.AdvanceTime(kConnectTimeout);
+        VerifyOrQuit(!comm1.Get<Tmf::SecureAgent>().IsConnected());
+        comm1.Get<Tmf::SecureAgent>().Close();
+
+        // Wait longer to ensure all retransmissions are finished.
+        nexus.AdvanceTime(kDtlsRetransmissionWaitTime);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Comm_1 connects with correct ePSKc.");
+
+    /**
+     * Step 7
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to connect again to the DUT on the address and port found in
+     *   step 5. For the DTLS PSK, it now uses the correct ePSKc as calculated in step 4.
+     * - Pass Criteria: The DTLS handshake/connection to the DUT MUST succeed.
+     */
+
+    {
+        Ip6::SockAddr br1SockAddr;
+        br1SockAddr.SetAddress(br1Addr);
+        br1SockAddr.SetPort(epskcPort);
+
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(tap1.mTap),
+                                                           EphemeralKeyManager::Tap::kLength));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Open(Ip6::kNetifBackbone));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Connect(br1SockAddr));
+
+        nexus.AdvanceTime(kConnectTimeout);
+        VerifyOrQuit(comm1.Get<Tmf::SecureAgent>().IsConnected());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Comm_1 sends MGMT_ACTIVE_GET.req.");
+
+    /**
+     * Step 8
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to send TMF message MGMT_ACTIVE_GET.req to the DUT over the
+     *   secure DTLS session. The Get TLV is not included in the request.
+     * - Pass Criteria:
+     *   - Comm_1 MUST receive the response TMF message MGMT_ACTIVE_GET.rsp over the secure DTLS session.
+     *   - Response payload MUST contain Network Key TLV, ...
+     */
+
+    {
+        Coap::Message *message;
+        message = comm1.Get<Tmf::SecureAgent>().AllocateAndInitPriorityConfirmablePostMessage(kUriActiveGet);
+        VerifyOrQuit(message != nullptr);
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().SendMessage(*message));
+    }
+    nexus.AdvanceTime(kTmfExchangeTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Comm_1 sends MGMT_PENDING_GET.req.");
+
+    /**
+     * Step 9
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to send MGMT_PENDING_GET.req to the DUT over the secure DTLS
+     *   session. The Get TLV is not included in the request.
+     * - Pass Criteria:
+     *   - Comm_1 MUST receive the TMF response message MGMT_PENDING_GET.rsp.
+     *   - Response CoAP payload MUST be empty.
+     */
+
+    {
+        Coap::Message *message;
+        message = comm1.Get<Tmf::SecureAgent>().AllocateAndInitPriorityConfirmablePostMessage(kUriPendingGet);
+        VerifyOrQuit(message != nullptr);
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().SendMessage(*message));
+    }
+    nexus.AdvanceTime(kTmfExchangeTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 10: Comm_1 closes the DTLS connection.");
+
+    /**
+     * Step 10
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to close the Commissioner session, i.e. close the DTLS
+     *   connection. Then, wait for 3 seconds.
+     * - Pass Criteria: N/A
+     */
+
+    comm1.Get<Tmf::SecureAgent>().Close();
+    nexus.AdvanceTime(3 * Time::kOneSecondInMsec);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 11: Comm_1 connects again (should fail).");
+
+    /**
+     * Step 11
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to connect again to the DUT on the address and port found in
+     *   step 5. For the DTLS PSK, it uses the correct ePSKc as determined in step 4. ...
+     * - Pass Criteria:
+     *   - The DTLS handshake/connection to the DUT MUST NOT succeed: the DUT MUST reject the DTLS connection attempt.
+     */
+
+    {
+        Ip6::SockAddr br1SockAddr;
+        br1SockAddr.SetAddress(br1Addr);
+        br1SockAddr.SetPort(epskcPort);
+
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Open(Ip6::kNetifBackbone));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Connect(br1SockAddr));
+
+        nexus.AdvanceTime(kConnectTimeout);
+        VerifyOrQuit(!comm1.Get<Tmf::SecureAgent>().IsConnected());
+        comm1.Get<Tmf::SecureAgent>().Close();
+
+        // Wait much longer to ensure all retransmissions are finished and the DUT state is clean.
+        // MbedTLS retransmissions can last for a while.
+        nexus.AdvanceTime(kDutStateCleanTime);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 12: Wait 3s and clear mDNS cache.");
+
+    /**
+     * Step 12
+     * - Device: Harness
+     * - Description (CS-12.3): Harness waits for 3 seconds. Harness clears its mDNS discovery cache.
+     * - Pass Criteria: N/A
+     */
+
+    nexus.AdvanceTime(3 * Time::kOneSecondInMsec);
+    // Nexus mDNS cache is handled by AdvanceTime usually or explicitly.
+    // In this test, we just clear our browse outcomes.
+    sBrowseOutcomes.Clear();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 13: Comm_1 discovers meshcop-e service (should fail).");
+
+    /**
+     * Step 13
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to discover a service type via mDNS, Qtype=PTR, name
+     *   “_meshcop-e._udp.local”.
+     * - Pass Criteria: The DUT MUST NOT respond with an mDNS message that contains a PTR record of type
+     *   “_meshcop-e._udp.local” in the Answer section.
+     */
+
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mServiceType  = "_meshcop-e._udp";
+        browser.mInfraIfIndex = kInfraIfIndex;
+        browser.mCallback     = HandleBrowseCallback;
+        SuccessOrQuit(comm1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kMdnsDiscoveryTime);
+        SuccessOrQuit(comm1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+    VerifyOrQuit(sBrowseOutcomes.GetLength() == 0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 14: BR_1 (DUT) produces new passcode.");
+
+    /**
+     * Step 14
+     * - Device: BR_1 (DUT)
+     * - Description (CS-12.3): Harness instructs the DUT, via Test Harness API, to produce the Thread Administration
+     *   One-Time Passcode.
+     * - Pass Criteria: Passcode is successfully retrieved.
+     */
+
+    EphemeralKeyManager::Tap tap2;
+    SuccessOrQuit(tap2.GenerateRandom());
+    Log("Passcode 2: %s", tap2.mTap);
+    nexus.AddTestVar("Passcode2", tap2.mTap);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 15: Harness validates the new passcode.");
+
+    /**
+     * Step 15
+     * - Device: Harness
+     * - Description (CS-12.3): Harness validates the passcode. From the passcode, the Harness calculates the ePSKc
+     *   using the procedure of step 4.
+     * - Pass Criteria:
+     *   - Harness validates the following, using its passcode validation function:
+     *   - The passcode MUST differ from the passcode previously obtained in step 4
+     *   - The passcode MUST contain only digits 0-9
+     *   - The passcode MUST be 9 digits long
+     *   - The last digit of the passcode MUST be equal to the check digit calculated by the Verhoeff algorithm on
+     *     the first 8 digits.
+     */
+
+    SuccessOrQuit(tap2.Validate());
+    VerifyOrQuit(!StringMatch(tap2.mTap, tap1.mTap));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 16: Comm_1 discovers meshcop-e service.");
+
+    /**
+     * Step 16
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to discover a service type via mDNS, Qtype=PTR, name
+     *   “_meshcop-e._udp.local”. ... Harness stores the discovered IPv6 address and port for later use; and logs these.
+     * - Pass Criteria:
+     *   - The DUT MUST respond with an mDNS message that contains one PTR record of type “_meshcop-e._udp.local” in
+     *     the Answer section.
+     *   - Comm_1 MUST be able to obtain an IPv6 address (from AAAA record) and port (from SRV record) of the
+     *     “meshcop-e” service as detailed on the left.
+     */
+
+    SuccessOrQuit(br1.Get<EphemeralKeyManager>().Start(tap2.mTap, 0, kEpskcPort + 1));
+
+    DiscoverMeshcopEService(comm1, nexus, br1Addr, epskcPort);
+    Log("Discovered ePSKc address: %s, port: %u", br1Addr.ToString().AsCString(), epskcPort);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 17: Comm_1 connects with correct ePSKc.");
+
+    /**
+     * Step 17
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to connect to the DUT on the address and port found in step
+     *   16. For the DTLS PSK, it uses the correct ePSKc as calculated in step 15.
+     * - Pass Criteria: The DTLS handshake/connection to the DUT MUST succeed.
+     */
+
+    {
+        Ip6::SockAddr br1SockAddr;
+        br1SockAddr.SetAddress(br1Addr);
+        br1SockAddr.SetPort(epskcPort);
+
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(tap2.mTap),
+                                                           EphemeralKeyManager::Tap::kLength));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Open(Ip6::kNetifBackbone));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Connect(br1SockAddr));
+
+        nexus.AdvanceTime(kConnectTimeout);
+        VerifyOrQuit(comm1.Get<Tmf::SecureAgent>().IsConnected());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 18: Comm_1 sends MGMT_ACTIVE_GET.req.");
+
+    /**
+     * Step 18
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs the device to send TMF message MGMT_ACTIVE_GET.req to the DUT over
+     *   the secure DTLS session. The Get TLV is not included in the request.
+     * - Pass Criteria:
+     *   - Comm_1 MUST receive the response TMF message MGMT_ACTIVE_GET.rsp over the secure DTLS session.
+     *   - Response payload MUST contain Network Key TLV, ...
+     */
+
+    {
+        Coap::Message *message;
+        message = comm1.Get<Tmf::SecureAgent>().AllocateAndInitPriorityConfirmablePostMessage(kUriActiveGet);
+        VerifyOrQuit(message != nullptr);
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().SendMessage(*message));
+    }
+    nexus.AdvanceTime(kTmfExchangeTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 19: Comm_1 closes the DTLS connection.");
+
+    /**
+     * Step 19
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to close the DTLS connection.
+     * - Pass Criteria: N/A
+     */
+
+    comm1.Get<Tmf::SecureAgent>().Close();
+    nexus.AdvanceTime(3 * Time::kOneSecondInMsec);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 20: BR_1 (DUT) produces new passcode.");
+
+    /**
+     * Step 20
+     * - Device: BR_1 (DUT)
+     * - Description (CS-12.3): Harness instructs the DUT, via Test Harness API, to produce the Thread Administration
+     *   One-Time Passcode.
+     * - Pass Criteria: Passcode is successfully retrieved.
+     */
+
+    EphemeralKeyManager::Tap tap3;
+    SuccessOrQuit(tap3.GenerateRandom());
+    Log("Passcode 3: %s", tap3.mTap);
+    nexus.AddTestVar("Passcode3", tap3.mTap);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 21: Harness validates the new passcode.");
+
+    /**
+     * Step 21
+     * - Device: Harness
+     * - Description (CS-12.3): Harness validates the passcode. From the passcode, the Harness calculates the ePSKc
+     *   again using the procedure of step 4.
+     * - Pass Criteria:
+     *   - Harness validates the following, equal to step 4, using its passcode validation function:
+     *   - The passcode MUST contain only digits 0-9.
+     *   - The passcode MUST be 9 digits long.
+     *   - The last digit of the passcode MUST be equal to the check digit calculated by the Verhoeff algorithm on
+     *     the first 8 digits.
+     *   - Harness also validates that the passcode MUST be different from the passcodes obtained before in steps 4
+     *     and 15.
+     */
+
+    SuccessOrQuit(tap3.Validate());
+    VerifyOrQuit(!StringMatch(tap3.mTap, tap1.mTap));
+    VerifyOrQuit(!StringMatch(tap3.mTap, tap2.mTap));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 22: Comm_1 discovers meshcop-e service.");
+
+    /**
+     * Step 22
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to discover a service type via mDNS, Qtype=PTR, name
+     *   “_meshcop-e._udp.local”. ... Harness stores the discovered IPv6 address and port for later use; and logs these.
+     * - Pass Criteria:
+     *   - DUT MUST respond with an mDNS message that contains one PTR record of type “_meshcop-e._udp.local” in the
+     *     Answer section.
+     *   - Comm_1 MUST be able to obtain an IPv6 address (from AAAA record) and port (from SRV record) of the
+     *     “meshcop-e” service as detailed on the left.
+     */
+
+    SuccessOrQuit(br1.Get<EphemeralKeyManager>().Start(tap3.mTap, 0, kEpskcPort + 2));
+
+    DiscoverMeshcopEService(comm1, nexus, br1Addr, epskcPort);
+    Log("Discovered ePSKc address: %s, port: %u", br1Addr.ToString().AsCString(), epskcPort);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 23: Comm_1 connects with incorrect ePSKc 10 times.");
+
+    /**
+     * Step 23
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to connect to the DUT on the address and port found in step
+     *   22. For the DTLS PSK, it uses an incorrect ePSKc as calculated using the procedure of step 6 applied to the
+     *   passcode found in step 21. This connection attempt is done in total MAX_EPSKC_CONN_ATTEMPTS (10) times. Each
+     *   time, the pass criteria are checked. Between attempts, the Harness pauses for 2 seconds.
+     * - Pass Criteria:
+     *   - The DTLS handshake/connection to DUT MUST NOT succeed.
+     *   - In all cases, the DUT MUST NOT send ICMPv6 Port Unreachable error - this way of failing is not allowed here.
+     *     The DUT MUST respond with DTLS messages.
+     */
+
+    {
+        char    incorrectTap[EphemeralKeyManager::Tap::kLength + 1];
+        char    checksum;
+        uint8_t i = 7;
+
+        memcpy(incorrectTap, tap3.mTap, EphemeralKeyManager::Tap::kLength + 1);
+        incorrectTap[i]++;
+        if (incorrectTap[i] > '9')
+        {
+            incorrectTap[i] = '0';
+        }
+        incorrectTap[EphemeralKeyManager::Tap::kLength - 1] = '\0';
+        SuccessOrQuit(Utils::VerhoeffChecksum::Calculate(incorrectTap, checksum));
+        incorrectTap[EphemeralKeyManager::Tap::kLength - 1] = checksum;
+
+        Ip6::SockAddr br1SockAddr;
+        br1SockAddr.SetAddress(br1Addr);
+        br1SockAddr.SetPort(epskcPort);
+
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(incorrectTap),
+                                                           EphemeralKeyManager::Tap::kLength));
+
+        for (uint8_t attempt = 1; attempt <= kMaxEpskcConnAttempts; attempt++)
+        {
+            Log("Attempt %u with incorrect ePSKc.", attempt);
+            SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Open(Ip6::kNetifBackbone));
+            SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Connect(br1SockAddr));
+            nexus.AdvanceTime(kConnectTimeout);
+            VerifyOrQuit(!comm1.Get<Tmf::SecureAgent>().IsConnected());
+            comm1.Get<Tmf::SecureAgent>().Close();
+            nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
+        }
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 24: Comm_1 connects with correct ePSKc (should fail).");
+
+    /**
+     * Step 24
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to connect again to the DUT on the address and port found in
+     *   step 22. For the DTLS PSK, it uses the correct ePSKc as calculated in step 21. But the connection still fails
+     *   due to the maximum number of attempts passed.
+     * - Pass Criteria:
+     *   - The DTLS handshake/connection to DUT MUST NOT succeed.
+     *   - Note: the reason that the correct ePSKc does not succeed here is that the maximum number of failed
+     *     connection attempts has been reached. This is being tested here.
+     */
+
+    {
+        Ip6::SockAddr br1SockAddr;
+        br1SockAddr.SetAddress(br1Addr);
+        br1SockAddr.SetPort(epskcPort);
+
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(tap3.mTap),
+                                                           EphemeralKeyManager::Tap::kLength));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Open(Ip6::kNetifBackbone));
+        SuccessOrQuit(comm1.Get<Tmf::SecureAgent>().Connect(br1SockAddr));
+
+        nexus.AdvanceTime(kConnectTimeout);
+        VerifyOrQuit(!comm1.Get<Tmf::SecureAgent>().IsConnected());
+        comm1.Get<Tmf::SecureAgent>().Close();
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 25: Comm_1 clears its mDNS discovery cache.");
+
+    /**
+     * Step 25
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs device to clear its mDNS discovery cache.
+     * - Pass Criteria: N/A
+     */
+
+    sBrowseOutcomes.Clear();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 26: Comm_1 discovers meshcop-e service (should fail).");
+
+    /**
+     * Step 26
+     * - Device: Comm_1
+     * - Description (CS-12.3): Harness instructs the device to discover a service type via mDNS, Qtype=PTR, name
+     *   “_meshcop-e._udp.local”.
+     * - Pass Criteria: The DUT MUST NOT respond with an mDNS message that contains a PTR record of type
+     *   “_meshcop-e._udp.local” in the Answer section.
+     */
+
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mServiceType  = "_meshcop-e._udp";
+        browser.mInfraIfIndex = kInfraIfIndex;
+        browser.mCallback     = HandleBrowseCallback;
+        SuccessOrQuit(comm1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kMdnsDiscoveryTime);
+        SuccessOrQuit(comm1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+    VerifyOrQuit(sBrowseOutcomes.GetLength() == 0);
+
+    nexus.SaveTestInfo("test_1_4_CS_TC_3.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test1_4_CS_TC_3();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_DNS_TC_1.cpp b/tests/nexus/test_1_4_DNS_TC_1.cpp
new file mode 100644
index 0000000..d888840
--- /dev/null
+++ b/tests/nexus/test_1_4_DNS_TC_1.cpp
@@ -0,0 +1,360 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * 11.1. [1.3] [CERT] Handling of multi-question DNS queries
+ *
+ * 11.1.1. Purpose
+ *   - To verify that the Thread Border Router DUT can successfully handle DNS queries with
+ *     multiple questions (QDCOUNT > 1) by either:
+ *   - Responding with an error status (signaling to the querier that it needs to retry with
+ *     single-question queries)
+ *   - Providing the answers to the multiple questions (as defined by Thread) if the questions
+ *     are for the same QNAME but different record types.
+ *
+ * 11.1.2. Topology
+ *   - Eth_1 host registering a service
+ *   - BR_1 Border Router DUT
+ *   - Thread Router_1 registering service(s) (Connected to BR DUT)
+ *   - Thread ED_1 sending QDCOUNT > 1 queries (Child of Router_1)
+ */
+
+namespace {
+
+static constexpr uint32_t kFormNetworkTime   = 13 * 1000;
+static constexpr uint32_t kJoinNetworkTime   = 20 * 1000;
+static constexpr uint32_t kStabilizationTime = 10 * 1000;
+static constexpr uint16_t kServicePort       = 55556;
+
+void SendMultiQuestionQuery(Node &aNode, const Ip6::Address &aDest, const char *aName, uint16_t aType1, uint16_t aType2)
+{
+    Ip6::Udp::Socket socket(aNode, nullptr, nullptr);
+    SuccessOrQuit(socket.Open(Ip6::kNetifThreadInternal));
+
+    Message *message = socket.NewMessage();
+    VerifyOrQuit(message != nullptr);
+
+    Dns::Header header;
+    header.SetType(Dns::Header::kTypeQuery);
+    header.SetQuestionCount(2);
+    SuccessOrQuit(message->Append(header));
+
+    SuccessOrQuit(Dns::Name::AppendName(aName, *message));
+    Dns::Question question1(aType1);
+    SuccessOrQuit(message->Append(question1));
+
+    SuccessOrQuit(Dns::Name::AppendName(aName, *message));
+    Dns::Question question2(aType2);
+    SuccessOrQuit(message->Append(question2));
+
+    Ip6::MessageInfo messageInfo;
+    messageInfo.SetPeerAddr(aDest);
+    messageInfo.SetPeerPort(53);
+
+    SuccessOrQuit(socket.SendTo(*message, messageInfo));
+    SuccessOrQuit(socket.Close());
+}
+
+void SendSingleQuestionQuery(Node &aNode, const Ip6::Address &aDest, const char *aName, uint16_t aType)
+{
+    Ip6::Udp::Socket socket(aNode, nullptr, nullptr);
+    SuccessOrQuit(socket.Open(Ip6::kNetifThreadInternal));
+
+    Message *message = socket.NewMessage();
+    VerifyOrQuit(message != nullptr);
+
+    Dns::Header header;
+    header.SetType(Dns::Header::kTypeQuery);
+    header.SetQuestionCount(1);
+    SuccessOrQuit(message->Append(header));
+
+    SuccessOrQuit(Dns::Name::AppendName(aName, *message));
+    Dns::Question question(aType);
+    SuccessOrQuit(message->Append(question));
+
+    Ip6::MessageInfo messageInfo;
+    messageInfo.SetPeerAddr(aDest);
+    messageInfo.SetPeerPort(53);
+
+    SuccessOrQuit(socket.SendTo(*message, messageInfo));
+    SuccessOrQuit(socket.Close());
+}
+
+} // namespace
+
+void Test_1_4_DNS_TC_1(const char *aJsonFileName)
+{
+    Core nexus;
+
+    Node &eth1 = nexus.CreateNode();
+    Node &br1  = nexus.CreateNode();
+    Node &r1   = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+
+    Dns::Multicast::Core::Host    host;
+    Ip6::Address                  eth1Addr;
+    Dns::Multicast::Core::Service mdnsService;
+    Srp::Client::Service          srpService;
+    static const Dns::TxtEntry    kTxtEntries[] = {{"key1", reinterpret_cast<const uint8_t *>("value1"), 6}};
+
+    eth1.SetName("Eth", 1);
+    br1.SetName("BR", 1);
+    r1.SetName("Router", 1);
+    ed1.SetName("ED", 1);
+
+    IgnoreError(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    /**
+     * Step 1
+     *   Device: All
+     *   Description (DNS-11.1): Start topology
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 1: Topology: Start Eth_1, BR_1, Router_1, and ED_1.");
+
+    // BR_1 and Router_1
+    AllowLinkBetween(br1, r1);
+
+    // Router_1 and ED_1
+    AllowLinkBetween(r1, ed1);
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    r1.Join(br1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    ed1.Join(r1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    // Setup BR_1 as Border Router
+    br1.Get<BorderRouter::InfraIf>().Init(Mdns::kInfraIfIndex, true);
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    br1.Get<Srp::Server>().SetEnabled(true);
+    SuccessOrQuit(br1.Get<Dns::ServiceDiscovery::Server>().Start());
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 2
+     *   Device: Eth_1
+     *   Description (DNS-11.1): Harness instructs device to advertise a test service using
+     *     mDNS: $ORIGIN local. Service-test-2._thread-test._udp ( SRV 0 0 55556 host-eth-1
+     *     TXT key2=value2 ) host-eth-1 AAAA <ULA address of Eth_1>
+     *   Pass Criteria:
+     *     - mDNS service MUST be registered without name conflict or service name change.
+     */
+    Log("Step 2: Eth_1 host registers a service using mDNS.");
+
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, Mdns::kInfraIfIndex));
+    {
+        SuccessOrQuit(eth1Addr.FromString("fd00:1234:5678:abcd::1"));
+        eth1.mInfraIf.AddAddress(eth1Addr);
+
+        ClearAllBytes(host);
+        host.mHostName        = "host-eth-1";
+        host.mAddresses       = &eth1Addr;
+        host.mAddressesLength = 1;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().RegisterHost(host, 0, nullptr));
+
+        ClearAllBytes(mdnsService);
+        mdnsService.mServiceInstance = "service-test-2";
+        mdnsService.mServiceType     = "_thread-test._udp";
+        mdnsService.mHostName        = "host-eth-1";
+        mdnsService.mTxtData         = reinterpret_cast<const uint8_t *>("\x0bkey2=value2");
+        mdnsService.mTxtDataLength   = 12;
+        mdnsService.mPort            = kServicePort;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().RegisterService(mdnsService, 1, nullptr));
+    }
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Ip6::Address br1Addr = br1.Get<Mle::Mle>().GetMeshLocalEid();
+
+    /**
+     * Step 3
+     *   Device: Router_1
+     *   Description (DNS-11.1): Harness instructs device to register a test service using
+     *     SRP: $ORIGIN default.service.arpa. service-test-1._thread-test._udp ( SRV 0 0
+     *     55556 host-router-1 TXT key1=value1 ) host-router-1 AAAA <OMR address of
+     *     Router_1>
+     *   Pass Criteria:
+     *     - The DUT MUST respond with success status (RCODE=0) to the SRP registration.
+     */
+    Log("Step 3: Router_1 host registers a service using SRP.");
+
+    {
+        Ip6::SockAddr serverSockAddr;
+        serverSockAddr.SetAddress(br1Addr);
+        serverSockAddr.SetPort(br1.Get<Srp::Server>().GetPort());
+        SuccessOrQuit(r1.Get<Srp::Client>().Start(serverSockAddr));
+    }
+
+    SuccessOrQuit(r1.Get<Srp::Client>().SetHostName("host-router-1"));
+    SuccessOrQuit(r1.Get<Srp::Client>().EnableAutoHostAddress());
+
+    {
+        ClearAllBytes(srpService);
+        srpService.mName          = "_thread-test._udp";
+        srpService.mInstanceName  = "service-test-1";
+        srpService.mPort          = kServicePort;
+        srpService.mTxtEntries    = kTxtEntries;
+        srpService.mNumTxtEntries = 1;
+        SuccessOrQuit(srpService.Init());
+        SuccessOrQuit(r1.Get<Srp::Client>().AddService(srpService));
+    }
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 4
+     *   Device: ED_1
+     *   Description (DNS-11.1): Harness instructs device to perform DNS query with QDCOUNT=2:
+     *     First question QNAME=service-test-1._thread-test._udp. Default.service.arpa QTYPE
+     *     =SRV Second question QNAME=service-test-1._thread-test._udp. Default.service.arpa
+     *     QTYPE =TXT
+     *   Pass Criteria:
+     *     - The DUT MUST either: 3. Respond with error RCODE=1 (FormErr) and 0 answers; or 3.
+     *       Respond with success RCODE=0 (NoError) and two answer records in the Answers
+     *       section as follows: $ORIGIN default.service.arpa.
+     *       service-test-1._thread-test._udp ( SRV 0 0 55556 host-router-1 TXT key1=value1 )
+     *     - Furthermore the Additional records section MAY contain : host-router-1 AAAA <OMR
+     *       address of Router_1>
+     *     - If this record is present, address MUST be same as in step 9.
+     */
+    Log("Step 4: ED_1 performs a DNS query with two questions (QDCOUNT=2).");
+    SendMultiQuestionQuery(ed1, br1Addr, "service-test-1._thread-test._udp.default.service.arpa",
+                           Dns::ResourceRecord::kTypeSrv, Dns::ResourceRecord::kTypeTxt);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 5
+     *   Device: ED_1
+     *   Description (DNS-11.1): Harness instructs devie to perform DNS query with QDCOUNT=2:
+     *     First question QNAME=service-test-2._thread-test._udp. Default.service.arpa QTYPE
+     *     =SRV Second question QNAME=service-test-2._thread-test._udp. Default.service.arpa
+     *     QTYPE =TXT
+     *   Pass Criteria:
+     *     - The DUT MUST either: 3. 3. Respond with error RCODE=1 (FormErr) and 0 answers;
+     *       or Respond with success RCODE=0 (NoError) and two answer records in the
+     *       Answers section as follows: $ORIGIN default.service.arpa.
+     *       service-test-2._thread-test._udp ( SRV 0 0 55556 host-eth-1 TXT key2=value2 )
+     *     - Furthermore the Additional records section MAY contain : host-eth-1 AAAA <ULA
+     *       address of Eth_1>
+     *     - If this record is present, address MUST be same as in step 8.
+     */
+    Log("Step 5: ED_1 performs a DNS query with two questions (QDCOUNT=2).");
+    SendMultiQuestionQuery(ed1, br1Addr, "service-test-2._thread-test._udp.default.service.arpa",
+                           Dns::ResourceRecord::kTypeSrv, Dns::ResourceRecord::kTypeTxt);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 6
+     *   Device: ED_1
+     *   Description (DNS-11.1): Harness instructs device to perform DNS query with
+     *     QDCOUNT=1: First question QNAME=service-test-1_thread-test.
+     *     _udp.default.service.arpa QTYPE=SRV
+     *   Pass Criteria:
+     *     - The DUT MUST respond with success RCODE=0 (NoError) and one answer record in
+     *       the Answers section as follows: $ORIGIN default.service.arpa.
+     *       service-test-1._thread-test._udp ( SRV 0 0 55556 host-router-1 )
+     *     - Furthermore the Additional records section MAY contain : host-router-1 AAAA
+     *       <OMR address of Router_1>
+     */
+    Log("Step 6: ED_1 performs a DNS query for service-test-1 (SRV).");
+    SendSingleQuestionQuery(ed1, br1Addr, "service-test-1._thread-test._udp.default.service.arpa",
+                            Dns::ResourceRecord::kTypeSrv);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 7
+     *   Device: ED_1
+     *   Description (DNS-11.1): Harness instructs device to perform DNS query with
+     *     QDCOUNT=1: First question QNAME=service-test-2_thread-test.
+     *     _udp.default.service.arpa QTYPE=TXT
+     *   Pass Criteria:
+     *     - The DUT MUST respond with success RCODE=0 (NoError) and one answer record in
+     *       the Answers section as follows: $ORIGIN default.service.arpa.
+     *       service-test-2._thread-test._udp ( TXT key2=value2 )
+     */
+    Log("Step 7: ED_1 performs a DNS query for service-test-2 (TXT).");
+    SendSingleQuestionQuery(ed1, br1Addr, "service-test-2._thread-test._udp.default.service.arpa",
+                            Dns::ResourceRecord::kTypeTxt);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 8
+     *   Device: ED_1
+     *   Description (DNS-11.1): Harness instructs device to perform DNS query with
+     *     QDCOUNT=1: First question QNAME=host-eth-1.default.service.arpa QTYPE=AAAA
+     *   Pass Criteria:
+     *     - The DUT MUST respond with success RCODE=0 (NoError) and one answer record in
+     *       the Answers section as follows: $ORIGIN default.service.arpa. host-eth-1 AAAA
+     *       <ULA address of Eth_1>
+     *     - The DUT MUST NOT include a link-local address in an AAAA record in the
+     *       answer(s).
+     */
+    Log("Step 8: ED_1 performs a DNS query for host-eth-1 (AAAA).");
+    SendSingleQuestionQuery(ed1, br1Addr, "host-eth-1.default.service.arpa", Dns::ResourceRecord::kTypeAaaa);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 9
+     *   Device: ED_1
+     *   Description (DNS-11.1): Harness instructs device to perform DNS query with
+     *     QDCOUNT=1: First question QNAME=host-router-1.default.service.arpa QTYPE=AAAA
+     *   Pass Criteria:
+     *     - The DUT MUST respond with success RCODE=0 (NoError) and one answer record in
+     *       the Answers section as follows: $ORIGIN default.service.arpa. host-router-1
+     *       AAAA <OMR address of Router_1>
+     *     - The DUT MUST NOT include a link-local address in an AAAA record in the
+     *       answer(s).
+     */
+    Log("Step 9: ED_1 performs a DNS query for host-router-1 (AAAA).");
+    SendSingleQuestionQuery(ed1, br1Addr, "host-router-1.default.service.arpa", Dns::ResourceRecord::kTypeAaaa);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_4_DNS_TC_1((argc > 2) ? argv[2] : "test_1_4_dns_tc_1.json");
+    ot::Nexus::Log("All tests passed");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_DNS_TC_3.cpp b/tests/nexus/test_1_4_DNS_TC_3.cpp
new file mode 100644
index 0000000..2d58d31
--- /dev/null
+++ b/tests/nexus/test_1_4_DNS_TC_3.cpp
@@ -0,0 +1,473 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "nexus_core.hpp"
+#include "nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+namespace {
+
+static constexpr char kGua1Prefix[] = "2005:1234:abcd:100::/64";
+
+static constexpr char kEth1Addr[] = "2005:1234:abcd:100::E1";
+static constexpr char kEth2Addr[] = "2005:1234:abcd:100::E2";
+
+static constexpr char kThreadGroup1[] = "threadgroup1.org";
+static constexpr char kThreadGroup2[] = "threadgroup2.org";
+static constexpr char kThreadGroup3[] = "threadgroup3.org";
+
+static constexpr char kEth1Answer1[] = "2002:1234::E1:1";
+static constexpr char kEth1Answer2[] = "2002:1234::E1:2";
+static constexpr char kEth1Answer3[] = "2002:1234::E1:3";
+
+static constexpr char kEth2Answer1[] = "2002:1234::E2:1";
+static constexpr char kEth2Answer2[] = "2002:1234::E2:2";
+static constexpr char kEth2Answer3[] = "2002:1234::E2:3";
+
+struct Record
+{
+    const char *mName;
+    const char *mAddress;
+};
+
+const Record kEth1Records[] = {
+    {kThreadGroup1, kEth1Answer1},
+    {kThreadGroup2, kEth1Answer2},
+    {kThreadGroup3, kEth1Answer3},
+};
+
+const Record kEth2Records[] = {
+    {kThreadGroup1, kEth2Answer1},
+    {kThreadGroup2, kEth2Answer2},
+    {kThreadGroup3, kEth2Answer3},
+};
+
+static Node *sEth1Node = nullptr;
+static Node *sEth2Node = nullptr;
+
+bool HandleUdpHook(Instance &aInstance, Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
+{
+    Node         &node    = AsNode(&aInstance);
+    const Record *records = nullptr;
+    uint16_t      count   = 0;
+    Dns::Header   dnsHeader;
+    Dns::Question question;
+    char          name[Dns::Name::kMaxNameSize];
+    uint16_t      readPos;
+    Message      *response;
+    bool          handled = false;
+
+    if (&node == sEth1Node)
+    {
+        records = kEth1Records;
+        count   = GetArrayLength(kEth1Records);
+    }
+    else if (&node == sEth2Node)
+    {
+        records = kEth2Records;
+        count   = GetArrayLength(kEth2Records);
+    }
+
+    VerifyOrExit(records != nullptr);
+    VerifyOrExit(aMessageInfo.GetSockPort() == UpstreamDns::kDnsPort);
+
+    SuccessOrExit(aMessage.Read(aMessage.GetOffset(), dnsHeader));
+    VerifyOrExit(dnsHeader.GetType() == Dns::Header::kTypeQuery);
+    VerifyOrExit(dnsHeader.GetQuestionCount() == 1);
+
+    readPos = aMessage.GetOffset() + sizeof(dnsHeader);
+    SuccessOrExit(Dns::Name::ReadName(aMessage, readPos, name));
+    SuccessOrExit(aMessage.Read(readPos, question));
+
+    Log("Node %u (%s) received DNS query for %s", node.GetInstance().GetId(), node.GetName(), name);
+
+    response = node.Get<MessagePool>().Allocate(Message::kTypeIp6);
+    VerifyOrExit(response != nullptr);
+
+    dnsHeader.SetType(Dns::Header::kTypeResponse);
+    dnsHeader.SetAnswerCount(0);
+    dnsHeader.SetRecursionDesiredFlag();
+    dnsHeader.SetRecursionAvailableFlag();
+
+    for (uint16_t i = 0; i < count; i++)
+    {
+        if (StringMatch(name, records[i].mName, kStringCaseInsensitiveMatch))
+        {
+            Ip6::Address        address;
+            Dns::ResourceRecord rr;
+
+            SuccessOrQuit(address.FromString(records[i].mAddress));
+
+            dnsHeader.SetAnswerCount(1);
+            SuccessOrQuit(response->Append(dnsHeader));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            SuccessOrQuit(response->Append(question));
+
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            rr.Init(Dns::ResourceRecord::kTypeAaaa);
+            rr.SetTtl(1800);
+            rr.SetLength(sizeof(Ip6::Address));
+            SuccessOrQuit(response->Append(rr));
+            SuccessOrQuit(response->Append(address));
+
+            Log("Node %u (%s) sending DNS response for %s with address %s", node.GetInstance().GetId(), node.GetName(),
+                name, records[i].mAddress);
+            break;
+        }
+    }
+
+    if (dnsHeader.GetAnswerCount() == 0)
+    {
+        dnsHeader.SetResponseCode(Dns::Header::kResponseNameError);
+        SuccessOrQuit(response->Append(dnsHeader));
+        SuccessOrQuit(Dns::Name::AppendName(name, *response));
+        SuccessOrQuit(response->Append(question));
+        Log("Node %u (%s) sending DNS NXDOMAIN for %s", node.GetInstance().GetId(), node.GetName(), name);
+    }
+
+    node.mInfraIf.SendUdp(aMessageInfo.GetSockAddr(), aMessageInfo.GetPeerAddr(), UpstreamDns::kDnsPort,
+                          aMessageInfo.GetPeerPort(), *response);
+    handled = true;
+
+exit:
+    return handled;
+}
+
+void HandleDnsResponse(otError aError, const otDnsAddressResponse *aResponse, void *aContext)
+{
+    OT_UNUSED_VARIABLE(aError);
+    OT_UNUSED_VARIABLE(aResponse);
+    OT_UNUSED_VARIABLE(aContext);
+}
+
+static void SendRa(Node &aNode, const Ip6::Prefix &aPrefix, const Ip6::Address &aDnsAddr)
+{
+    Ip6::Nd::RouterAdvert::TxMessage ra;
+    Ip6::Nd::RouterAdvert::Header    header;
+    Ip6::Nd::Icmp6Packet             packet;
+
+    header.SetToDefault();
+    header.SetRouterLifetime(1800);
+    SuccessOrQuit(ra.Append(header));
+    SuccessOrQuit(ra.AppendPrefixInfoOption(
+        aPrefix, 1800, 1800, Ip6::Nd::PrefixInfoOption::kOnLinkFlag | Ip6::Nd::PrefixInfoOption::kAutoConfigFlag));
+    SuccessOrQuit(ra.AppendRecursiveDnsServerOption(&aDnsAddr, 1, 1800));
+    ra.GetAsPacket(packet);
+    aNode.mInfraIf.SendIcmp6Nd(Ip6::Address::GetLinkLocalAllNodesMulticast(), packet.GetBytes(), packet.GetLength());
+}
+
+/**
+ * 11.3. [1.4] [CERT] Selection of upstream DNS resolver
+ *
+ * 11.3.1. Purpose
+ * To verify that the BR DUT:
+ * - Selects the upstream DNS resolver based on ND RA RDNSS Option, sent by the infrastructure network, pointing to the
+ *   default DNS resolver.
+ * - Reacts to change of the upstream DNS resolver, e.g. due to a configuration change, new ISP, or other reasons.
+ * - Can handle link-local and global IPv6 address pointing to upstream DNS resolver.
+ *
+ * 11.3.2. Topology
+ * - BR_1 (DUT) - Border Router
+ * - Router_1 - Thread Router Reference Device, attached to BR_1
+ * - ED_1 - Thread Reference Device, End Device (e.g. FED/REED) role, attached to Router_1
+ * - Eth_1 - Adjacent Infrastructure Link Linux Reference Device with DNS server
+ * - Eth_2 - Adjacent Infrastructure Link Linux Reference Device with DNS server and RA daemon
+ */
+void Test_1_4_DNS_TC_3(const char *aJsonFileName)
+{
+    Core         nexus;
+    Node        &br1     = nexus.CreateNode();
+    Node        &router1 = nexus.CreateNode();
+    Node        &ed1     = nexus.CreateNode();
+    Node        &eth1    = nexus.CreateNode();
+    Node        &eth2    = nexus.CreateNode();
+    Ip6::Prefix  gua1Prefix;
+    Ip6::Address eth1Addr;
+    Ip6::Address eth2Addr;
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    br1.SetName("BR", 1);
+    router1.SetName("Router", 1);
+    ed1.SetName("ED", 1);
+    eth1.SetName("Eth", 1);
+    eth2.SetName("Eth", 2);
+
+    sEth1Node = &eth1;
+    sEth2Node = &eth2;
+
+    eth1.mInfraIf.SetUdpHook(HandleUdpHook);
+    eth2.mInfraIf.SetUdpHook(HandleUdpHook);
+
+    AllowLinkBetween(br1, router1);
+    AllowLinkBetween(router1, ed1);
+
+    SuccessOrQuit(gua1Prefix.FromString(kGua1Prefix));
+    SuccessOrQuit(eth1Addr.FromString(kEth1Addr));
+    SuccessOrQuit(eth2Addr.FromString(kEth2Addr));
+
+    /**
+     * Step 1
+     * - Device: Eth_1, Eth_2
+     * - Description (DNS-11.3): Enable
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: Enable Eth_1, Eth_2");
+    eth1.Get<ThreadNetif>().Up();
+    eth2.Get<ThreadNetif>().Up();
+    eth1.mInfraIf.AddAddress(eth1Addr);
+    eth2.mInfraIf.AddAddress(eth2Addr);
+
+    /**
+     * Step 2
+     * - Device: Eth_2
+     * - Description (DNS-11.3): Harness instructs device to: Configure router
+     *   advertisement daemon (radvd) to multicast ND RA with: Prefix Information
+     *   Option (PIO) with a global on-link prefix GUA_1 (SLAAC is enabled (A=1),
+     *   on-link (L=1)), Recursive DNS Server Option (25), with Addresses of IPv6
+     *   Recursive DNS Servers field contains a single global IPv6 address of Eth_1.
+     *   Configure DNS server with records: threadgroup1.org AAAA 2002:1234::E2:1,
+     *   threadgroup2.org AAAA 2002:1234::E2:2, threadgroup3.org AAAA 2002:1234::E2:3.
+     *   Note: prefix GUA_1 may be 2005:1234:abcd:100::/64.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth_2 sends RA with global IPv6 address of Eth_1");
+    SendRa(eth2, gua1Prefix, eth1Addr);
+
+    /**
+     * Step 3
+     * - Device: Eth_1
+     * - Description (DNS-11.3): Harness instructs device to: Configure DNS server
+     *   with records: threadgroup1.org AAAA 2002:1234::E1:1, threadgroup2.org AAAA
+     *   2002:1234::E1:2, threadgroup3.org AAAA 2002:1234::E1:3
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 3: Configure DNS records on Eth_1");
+    // Handled by HandleUdpHook using kEth1Records.
+
+    /**
+     * Step 4
+     * - Device: BR_1 (DUT), Router_1, ED_1
+     * - Description (DNS-11.3): Enable
+     * - Pass Criteria:
+     *   - Single Thread Network forms.
+     */
+    Log("Step 4: Enable BR_1, Router_1, ED_1");
+    br1.Form();
+    nexus.AdvanceTime(10000);
+
+    router1.Join(br1, Node::kAsFtd);
+    ed1.Join(br1, Node::kAsFed);
+
+    nexus.AdvanceTime(20000);
+
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsAttached());
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(ed1.Get<Mle::Mle>().IsChild());
+
+    /**
+     * Step 5
+     * - Device: BR_1 (DUT)
+     * - Description (DNS-11.3): Automatically obtains / configures an OMR prefix for
+     *   the Thread Network, and assigns an address for its AIL interface using SLAAC.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 5: BR_1 configures OMR prefix and AIL address");
+    br1.Get<BorderRouter::InfraIf>().Init(1, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    SuccessOrQuit(br1.Get<Dns::ServiceDiscovery::Server>().Start());
+    br1.Get<Dns::ServiceDiscovery::Server>().SetUpstreamQueryEnabled(true);
+
+    {
+        Dns::Client::QueryConfig dnsConfig;
+        dnsConfig.Clear();
+        AsCoreType(&dnsConfig.mServerSockAddr.mAddress) = br1.Get<Mle::Mle>().GetMeshLocalEid();
+        dnsConfig.mServerSockAddr.mPort                 = UpstreamDns::kDnsPort;
+        ed1.Get<Dns::Client>().SetDefaultConfig(dnsConfig);
+    }
+
+    // Resend RA to ensure BR_1 (which was just initialized) receives it.
+    SendRa(eth2, gua1Prefix, eth1Addr);
+    nexus.AdvanceTime(5000);
+
+    /**
+     * Step 6
+     * - Device: ED_1
+     * - Description (DNS-11.3): Harness instructs device to perform DNS query
+     *   Qtype=AAAA, name threadgroup1.org. Automatically, the DNS query gets
+     *   routed to the DUT
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 6: ED_1 performs DNS query for threadgroup1.org");
+    ed1.Get<Dns::Client>().Stop();
+    SuccessOrQuit(ed1.Get<Dns::Client>().Start());
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveAddress(kThreadGroup1, HandleDnsResponse, nullptr));
+
+    /**
+     * Step 7
+     * - Device: BR_1 (DUT)
+     * - Description (DNS-11.3): Automatically processes the DNS query by requesting
+     *   upstream to the Eth_1 DNS server. Then, it responds back with the answer
+     *   to ED_1.
+     * - Pass Criteria:
+     *   - N/A
+     *
+     * Step 8
+     * - Device: ED_1
+     * - Description (DNS-11.3): Successfully receives DNS query result:
+     *   threadgroup1.org AAAA 2002:1234::E1:1
+     * - Pass Criteria:
+     *   - ED_1 MUST receive correct DNS query answer from the DUT.
+     */
+    Log("Step 7 & 8: Verify BR_1 requests upstream to Eth_1 and ED_1 receives correct answer");
+    nexus.AdvanceTime(5000);
+
+    /**
+     * Step 9
+     * - Device: Eth_2
+     * - Description (DNS-11.3): Harness instructs device to configure router
+     *   advertisement daemon (radvd) to multicast ND RA with updated information,
+     *   pointing to another DNS server: Prefix Information Option (PIO) - as
+     *   before, Recursive DNS Server Option (25), with Addresses of IPv6
+     *   Recursive DNS Servers field contains a single global IPv6 address of
+     *   Eth_2. Harness waits for a time of at least RA_PERIOD + 1 seconds, where
+     *   RA_PERIOD is the max period between multicast ND RA transmissions by
+     *   Eth_2, to ensure that the new ND RA is received by DUT.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 9: Eth_2 sends RA with global IPv6 address of Eth_2");
+    SendRa(eth2, gua1Prefix, eth2Addr);
+    nexus.AdvanceTime(10000); // Wait for RA_PERIOD + 1 seconds
+
+    /**
+     * Step 10
+     * - Device: ED_1
+     * - Description (DNS-11.3): Harness instructs device to perform DNS query
+     *   Qtype=AAAA, name threadgroup2.org. Automatically, the DNS query gets
+     *   routed to the DUT.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 10: ED_1 performs DNS query for threadgroup2.org");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveAddress(kThreadGroup2, HandleDnsResponse, nullptr));
+
+    /**
+     * Step 11
+     * - Device: BR_1 (DUT)
+     * - Description (DNS-11.3): Automatically processes the DNS query by requesting
+     *   upstream to the Eth_2 DNS server. Then, it responds back with the answer
+     *   to ED_1.
+     * - Pass Criteria:
+     *   - N/A
+     *
+     * Step 12
+     * - Device: ED_1
+     * - Description (DNS-11.3): Successfully receives DNS query result:
+     *   threadgroup2.org AAAA 2002:1234::E2:2
+     * - Pass Criteria:
+     *   - ED_1 MUST receive correct DNS query answer from the DUT.
+     */
+    Log("Step 11 & 12: Verify BR_1 requests upstream to Eth_2 and ED_1 receives correct answer");
+    nexus.AdvanceTime(5000);
+
+    /**
+     * Step 13
+     * - Device: Eth_2
+     * - Description (DNS-11.3): Harness instructs device to configure router
+     *   advertisement daemon (radvd) to multicast ND RA with updated information,
+     *   pointing to the first DNS server using link-local address: Prefix
+     *   Information Option (PIO) - as before, Recursive DNS Server Option (25),
+     *   with Addresses of IPv6 Recursive DNS Servers field contains a single
+     *   link-local IPv6 address of Eth_1. Harness waits for a time of at least
+     *   RA_PERIOD + 1 seconds, where RA_PERIOD is the max period between
+     *   multicast ND RA transmissions by Eth_2, to ensure that the new RA is
+     *   received by DUT.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 13: Eth_2 sends RA with link-local IPv6 address of Eth_1");
+    {
+        const Ip6::Address &eth1Lla = eth1.mInfraIf.GetLinkLocalAddress();
+        SendRa(eth2, gua1Prefix, eth1Lla);
+    }
+    nexus.AdvanceTime(10000); // Wait for RA_PERIOD + 1 seconds
+
+    /**
+     * Step 14
+     * - Device: ED_1
+     * - Description (DNS-11.3): Harness instructs device to perform DNS query
+     *   Qtype=AAAA, name threadgroup3.org. Automatically, the DNS query gets
+     *   routed to the DUT
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 14: ED_1 performs DNS query for threadgroup3.org");
+    SuccessOrQuit(ed1.Get<Dns::Client>().ResolveAddress(kThreadGroup3, HandleDnsResponse, nullptr));
+
+    /**
+     * Step 15
+     * - Device: BR_1 (DUT)
+     * - Description (DNS-11.3): Automatically processes the DNS query by requesting
+     *   upstream to the Eth_1 DNS server. Then, it responds back with the answer
+     *   to ED_1.
+     * - Pass Criteria:
+     *   - N/A
+     *
+     * Step 16
+     * - Device: ED_1
+     * - Description (DNS-11.3): Successfully receives DNS query result:
+     *   threadgroup3.org AAAA 2002:1234::E1:3
+     * - Pass Criteria:
+     *   - ED_1 MUST receive correct DNS query answer from the DUT.
+     */
+    Log("Step 15 & 16: Verify BR_1 requests upstream to Eth_1 and ED_1 receives correct answer");
+    nexus.AdvanceTime(5000);
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_4_DNS_TC_3((argc > 2) ? argv[2] : "test_1_4_dns_tc_3.json");
+    ot::Nexus::Log("All tests passed");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_DNS_TC_5.cpp b/tests/nexus/test_1_4_DNS_TC_5.cpp
new file mode 100644
index 0000000..23becac
--- /dev/null
+++ b/tests/nexus/test_1_4_DNS_TC_5.cpp
@@ -0,0 +1,524 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "nexus_core.hpp"
+#include "nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+namespace {
+
+static constexpr uint32_t kFormNetworkTime   = 10 * 1000;
+static constexpr uint32_t kJoinNetworkTime   = 20 * 1000;
+static constexpr uint32_t kStabilizationTime = 10 * 1000;
+
+static constexpr char kGuaPrefix[]  = "2005:1234:abcd:100::/64";
+static constexpr char kEth1Addr[]   = "2005:1234:abcd:100::E1";
+static constexpr char kBr1AilAddr[] = "2005:1234:abcd:100::B1";
+
+static constexpr char kThreadGroup1[] = "threadgroup1.org";
+static constexpr char kThreadGroup2[] = "threadgroup2.org";
+
+static constexpr char kThreadGroup1Aaaa[] = "2002:1234::E1:1";
+static constexpr char kThreadGroup1A[]    = "192.168.217.1";
+static constexpr char kThreadGroup2A[]    = "192.168.217.2";
+
+static constexpr uint16_t kTypePrivateUse = 65285; // 0xFF05
+static constexpr uint32_t kPrivateData    = 0x12345678;
+
+static Node *sEth1Node = nullptr;
+
+bool HandleUdpHook(Instance &aInstance, Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
+{
+    Node       &node = AsNode(&aInstance);
+    Dns::Header dnsHeader;
+    char        name[Dns::Name::kMaxNameSize];
+    uint16_t    readPos;
+    uint16_t    qType;
+    uint16_t    qClass;
+    Message    *response;
+    bool        handled = false;
+
+    VerifyOrExit(&node == sEth1Node);
+    VerifyOrExit(aMessageInfo.GetSockPort() == UpstreamDns::kDnsPort);
+
+    SuccessOrExit(aMessage.Read(aMessage.GetOffset(), dnsHeader));
+    VerifyOrExit(dnsHeader.GetType() == Dns::Header::kTypeQuery);
+    VerifyOrExit(dnsHeader.GetQuestionCount() == 1);
+
+    readPos = aMessage.GetOffset() + sizeof(dnsHeader);
+    SuccessOrExit(Dns::Name::ReadName(aMessage, readPos, name));
+    SuccessOrExit(aMessage.Read(readPos, qType));
+    qType = BigEndian::HostSwap16(qType);
+    readPos += sizeof(uint16_t);
+    SuccessOrExit(aMessage.Read(readPos, qClass));
+    qClass = BigEndian::HostSwap16(qClass);
+    readPos += sizeof(uint16_t);
+
+    Log("Eth_1 received DNS query for '%s' (type %u)", name, qType);
+
+    response = node.Get<MessagePool>().Allocate(Message::kTypeIp6);
+    VerifyOrExit(response != nullptr);
+
+    dnsHeader.SetType(Dns::Header::kTypeResponse);
+    dnsHeader.SetAnswerCount(0);
+    dnsHeader.SetRecursionDesiredFlag();
+    dnsHeader.SetRecursionAvailableFlag();
+
+    if (Dns::Name::IsSameDomain(name, kThreadGroup1))
+    {
+        if (qType == Dns::ResourceRecord::kTypeAaaa)
+        {
+            Ip6::Address        address;
+            Dns::ResourceRecord rr;
+
+            SuccessOrQuit(address.FromString(kThreadGroup1Aaaa));
+            dnsHeader.SetAnswerCount(1);
+            SuccessOrQuit(response->Append(dnsHeader));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            SuccessOrQuit(response->Append(BigEndian::HostSwap16(qType)));
+            SuccessOrQuit(response->Append(BigEndian::HostSwap16(qClass)));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            rr.Init(Dns::ResourceRecord::kTypeAaaa);
+            rr.SetTtl(1800);
+            rr.SetLength(sizeof(Ip6::Address));
+            SuccessOrQuit(response->Append(rr));
+            SuccessOrQuit(response->Append(address));
+            Log("Eth_1 sending AAAA response for %s: %s", name, kThreadGroup1Aaaa);
+        }
+        else if (qType == Dns::ResourceRecord::kTypeA)
+        {
+            Ip4::Address        address;
+            Dns::ResourceRecord rr;
+
+            SuccessOrQuit(address.FromString(kThreadGroup1A));
+            dnsHeader.SetAnswerCount(1);
+            SuccessOrQuit(response->Append(dnsHeader));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            SuccessOrQuit(response->Append(BigEndian::HostSwap16(qType)));
+            SuccessOrQuit(response->Append(BigEndian::HostSwap16(qClass)));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            rr.Init(Dns::ResourceRecord::kTypeA);
+            rr.SetTtl(1800);
+            rr.SetLength(sizeof(Ip4::Address));
+            SuccessOrQuit(response->Append(rr));
+            SuccessOrQuit(response->Append(address));
+            Log("Eth_1 sending A response for %s: %s", name, kThreadGroup1A);
+        }
+    }
+    else if (Dns::Name::IsSameDomain(name, kThreadGroup2))
+    {
+        if (qType == Dns::ResourceRecord::kTypeA)
+        {
+            Ip4::Address        address;
+            Dns::ResourceRecord rr;
+
+            SuccessOrQuit(address.FromString(kThreadGroup2A));
+            dnsHeader.SetAnswerCount(1);
+            SuccessOrQuit(response->Append(dnsHeader));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            SuccessOrQuit(response->Append(BigEndian::HostSwap16(qType)));
+            SuccessOrQuit(response->Append(BigEndian::HostSwap16(qClass)));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            rr.Init(Dns::ResourceRecord::kTypeA);
+            rr.SetTtl(1800);
+            rr.SetLength(sizeof(Ip4::Address));
+            SuccessOrQuit(response->Append(rr));
+            SuccessOrQuit(response->Append(address));
+            Log("Eth_1 sending A response for %s: %s", name, kThreadGroup2A);
+        }
+        else if (qType == kTypePrivateUse)
+        {
+            Dns::ResourceRecord rr;
+            uint32_t            data = BigEndian::HostSwap32(kPrivateData);
+
+            dnsHeader.SetAnswerCount(1);
+            SuccessOrQuit(response->Append(dnsHeader));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            SuccessOrQuit(response->Append(BigEndian::HostSwap16(qType)));
+            SuccessOrQuit(response->Append(BigEndian::HostSwap16(qClass)));
+            SuccessOrQuit(Dns::Name::AppendName(name, *response));
+            rr.Init(kTypePrivateUse);
+            rr.SetTtl(1800);
+            rr.SetLength(sizeof(uint32_t));
+            SuccessOrQuit(response->Append(rr));
+            SuccessOrQuit(response->Append(data));
+            Log("Eth_1 sending PrivateUse response for %s", name);
+        }
+    }
+
+    if (dnsHeader.GetAnswerCount() == 0)
+    {
+        Log("Eth_1 sending empty response for %s", name);
+        SuccessOrQuit(response->Append(dnsHeader));
+        SuccessOrQuit(Dns::Name::AppendName(name, *response));
+        SuccessOrQuit(response->Append(BigEndian::HostSwap16(qType)));
+        SuccessOrQuit(response->Append(BigEndian::HostSwap16(qClass)));
+    }
+
+    node.mInfraIf.SendUdp(aMessageInfo.GetSockAddr(), aMessageInfo.GetPeerAddr(), UpstreamDns::kDnsPort,
+                          aMessageInfo.GetPeerPort(), *response);
+    handled = true;
+
+exit:
+    return handled;
+}
+
+void HandleDnsResponse(otError aError, const otDnsAddressResponse *aResponse, void *aContext)
+{
+    OT_UNUSED_VARIABLE(aError);
+    OT_UNUSED_VARIABLE(aResponse);
+    OT_UNUSED_VARIABLE(aContext);
+}
+
+void HandleDnsRecordResponse(otError aError, const otDnsRecordResponse *aResponse, void *aContext)
+{
+    OT_UNUSED_VARIABLE(aError);
+    OT_UNUSED_VARIABLE(aResponse);
+    OT_UNUSED_VARIABLE(aContext);
+}
+
+void SendQuery(Node &aNode, const char *aName, uint16_t aType)
+{
+    if (aType == Dns::ResourceRecord::kTypeAaaa)
+    {
+        SuccessOrQuit(aNode.Get<Dns::Client>().ResolveAddress(aName, HandleDnsResponse, nullptr));
+    }
+    else
+    {
+        SuccessOrQuit(aNode.Get<Dns::Client>().QueryRecord(aType, nullptr, aName, HandleDnsRecordResponse, nullptr));
+    }
+}
+
+void SendRa(Node &aNode, const Ip6::Address &aDnsAddr, const Ip6::Prefix &aPrefix)
+{
+    Ip6::Nd::RouterAdvert::TxMessage ra;
+    Ip6::Nd::RouterAdvert::Header    header;
+    Ip6::Nd::Icmp6Packet             packet;
+
+    header.SetToDefault();
+    header.SetRouterLifetime(1800);
+    SuccessOrQuit(ra.Append(header));
+    SuccessOrQuit(ra.AppendPrefixInfoOption(
+        aPrefix, 1800, 1800, Ip6::Nd::PrefixInfoOption::kOnLinkFlag | Ip6::Nd::PrefixInfoOption::kAutoConfigFlag));
+    SuccessOrQuit(ra.AppendRecursiveDnsServerOption(&aDnsAddr, 1, 1800));
+    ra.GetAsPacket(packet);
+    aNode.mInfraIf.SendIcmp6Nd(Ip6::Address::GetLinkLocalAllNodesMulticast(), packet.GetBytes(), packet.GetLength());
+}
+
+} // namespace
+
+/**
+ * 11.5. [1.4] [CERT] DNS record types and special cases
+ *
+ * 11.5.1. Purpose
+ * To verify that the BR DUT:
+ * - Can resolve A records
+ * - Can resolve A records without doing IPv6 AAAA synthesis
+ * - Can resolve AAAA records
+ * - Can resolve records contributed by different sources (on-mesh, on-AIL, and upstream DNS server)
+ * - Supports non-typical record types (RRTypes) for queries
+ * - Supports queries using RR type 0xFF00-0xFFFE (65280-65534) “Private Use”
+ * - Blocks the “ipv4only.arpa” query
+ */
+void Test_1_4_DNS_TC_5(const char *aJsonFileName)
+{
+    Core         nexus;
+    Node        &br1  = nexus.CreateNode();
+    Node        &ed1  = nexus.CreateNode();
+    Node        &eth1 = nexus.CreateNode();
+    Ip6::Address eth1Addr;
+    Ip6::Address br1AilAddr;
+    Ip6::Prefix  guaPrefix;
+
+    br1.SetName("BR", 1);
+    ed1.SetName("ED", 1);
+    eth1.SetName("Eth", 1);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    sEth1Node = &eth1;
+    eth1.mInfraIf.SetUdpHook(HandleUdpHook);
+
+    AllowLinkBetween(br1, ed1);
+
+    SuccessOrQuit(eth1Addr.FromString(kEth1Addr));
+    SuccessOrQuit(br1AilAddr.FromString(kBr1AilAddr));
+    SuccessOrQuit(guaPrefix.FromString(kGuaPrefix));
+
+    /**
+     * Step 1
+     *   Device: Eth_1, BR_1, ED_1
+     *   Description (DNS-11.5): Form topology. Thread network is formed.
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 1: Form topology. Thread network is formed.");
+
+    eth1.Get<ThreadNetif>().Up();
+    eth1.mInfraIf.AddAddress(eth1Addr);
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    ed1.Join(br1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    br1.Get<BorderRouter::InfraIf>().Init(1, true);
+    br1.mInfraIf.AddAddress(br1AilAddr);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    SuccessOrQuit(br1.Get<Dns::ServiceDiscovery::Server>().Start());
+    br1.Get<Dns::ServiceDiscovery::Server>().SetUpstreamQueryEnabled(true);
+
+    {
+        Dns::Client::QueryConfig dnsConfig;
+        dnsConfig.Clear();
+        AsCoreType(&dnsConfig.mServerSockAddr.mAddress) = br1.Get<Mle::Mle>().GetMeshLocalEid();
+        dnsConfig.mServerSockAddr.mPort                 = 53;
+        ed1.Get<Dns::Client>().SetDefaultConfig(dnsConfig);
+    }
+
+    // Send RA for BR to adopt the DNS server address and OMR prefix
+    SendRa(eth1, eth1Addr, guaPrefix);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 2
+     *   Device: Eth_1
+     *   Description (DNS-11.5): Harness instructs device to configure DNS server with
+     *     records: threadgroup1.org AAAA 2002:1234::E1:1, threadgroup1.org A 192.168.217.1,
+     *     threadgroup2.org A 192.168.217.2, threadgroup2.org TYPE65285 \# 4 12345678.
+     *     Note: the record type 0xFF05 is a private range record type, hence it has the
+     *     symbolic name TYPE65285 associated.
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 2: Eth_1 configures DNS server with records.");
+
+    /**
+     * Step 3
+     *   Device: Eth_1
+     *   Description (DNS-11.5): Harness instructs device to start advertising mDNS
+     *     services on the AIL: Note: the PTR records for services are not shown below;
+     *     however these are present as usual. $ORIGIN local. Service-test-1.
+     *     _infra-test._udp ( SRV 0 0 55551 host-test-eth TXT dummy=abcd;thread-test=a49 )
+     *     service-test-2._infra-test._udp ( SRV 0 0 55552 host-test-eth TXT a=2;thread-test
+     *     =b50_test ) ) host-test-eth A <IPv4 address of Eth_1>.
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 3: Eth_1 starts advertising mDNS services on the AIL.");
+    {
+        Dns::Multicast::Core::Service mdnsService;
+        Ip6::Address                  eth1Ip4Addr;
+        Ip4::Address                  ip4Addr;
+
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetLocalHostName("host-test-eth"));
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, 1));
+
+        SuccessOrQuit(ip4Addr.FromString("192.168.217.100"));
+        eth1Ip4Addr.InitAsIp4Mapped(ip4Addr);
+        eth1.mInfraIf.AddAddress(eth1Ip4Addr);
+        eth1.mMdns.SignalIfAddresses(eth1.GetInstance());
+
+        ClearAllBytes(mdnsService);
+        mdnsService.mServiceInstance     = "service-test-1";
+        mdnsService.mServiceType         = "_infra-test._udp";
+        mdnsService.mHostName            = "host-test-eth";
+        static const uint8_t kTxtData1[] = "\x0adummy=abcd\x0fthread-test=a49";
+        mdnsService.mTxtData             = kTxtData1;
+        mdnsService.mTxtDataLength       = sizeof(kTxtData1) - 1;
+        mdnsService.mPort                = 55551;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().RegisterService(mdnsService, 1, nullptr));
+
+        mdnsService.mServiceInstance     = "service-test-2";
+        mdnsService.mPort                = 55552;
+        static const uint8_t kTxtData2[] = "\x03a=2\x14thread-test=b50_test";
+        mdnsService.mTxtData             = kTxtData2;
+        mdnsService.mTxtDataLength       = sizeof(kTxtData2) - 1;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().RegisterService(mdnsService, 2, nullptr));
+    }
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 4
+     *   Device: ED_1
+     *   Description (DNS-11.5): Harness instructs device to send a DNS query to the DUT
+     *     with Qtype=AAAA for name “threadgroup1.org”.
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 4: ED_1 sends a DNS query to the DUT with Qtype=AAAA for name threadgroup1.org.");
+    SendRa(eth1, eth1Addr, guaPrefix);
+    SendQuery(ed1, kThreadGroup1, Dns::ResourceRecord::kTypeAaaa);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 5
+     *   Device: BR_1 (DUT)
+     *   Description (DNS-11.5): Automatically performs the DNS query via the Eth_1 DNS
+     *     server, and provides the answer back to ED_1.
+     *   Pass Criteria:
+     *     - The DUT MUST respond Rcode=0 (NoError) with answer record: threadgroup1.org
+     *       AAAA 2002:1234::E1:1
+     */
+    Log("Step 5: BR_1 performs the DNS query via Eth_1 and provides the answer to ED_1.");
+
+    /**
+     * Step 6
+     *   Device: ED_1
+     *   Description (DNS-11.5): Harness instructs device to send a DNS query to the DUT,
+     *     with Qtype=A for name “threadgroup1.org”.
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 6: ED_1 sends a DNS query to the DUT with Qtype=A for name threadgroup1.org.");
+    SendRa(eth1, eth1Addr, guaPrefix);
+    SendQuery(ed1, kThreadGroup1, Dns::ResourceRecord::kTypeA);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 7
+     *   Device: BR_1 (DUT)
+     *   Description (DNS-11.5): Automatically performs the DNS query via the Eth_1 DNS
+     *     server, and provides the answer back to ED_1.
+     *   Pass Criteria:
+     *     - The DUT MUST respond Rcode=0 (NoError) with answer record: threadgroup1.org A
+     *       192.168.217.1
+     */
+    Log("Step 7: BR_1 performs the DNS query via Eth_1 and provides the answer to ED_1.");
+
+    /**
+     * Step 8
+     *   Device: ED_1
+     *   Description (DNS-11.5): Harness instructs device to send a DNS query to the DUT,
+     *     with Qtype=AAAA for name “threadgroup2.org”.
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 8: ED_1 sends a DNS query to the DUT with Qtype=AAAA for name threadgroup2.org.");
+    SendRa(eth1, eth1Addr, guaPrefix);
+    SendQuery(ed1, kThreadGroup2, Dns::ResourceRecord::kTypeAaaa);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 9
+     *   Device: BR_1 (DUT)
+     *   Description (DNS-11.5): Automatically performs the DNS query via the Eth_1 DNS
+     *     server, and provides the error answer back to ED_1. Note: this checks that the
+     *     DUT does not perform AAAA synthesis from the A record.
+     *   Pass Criteria:
+     *     - The DUT MUST respond Rcode=0 (NoError) with zero answer records (ANCOUNT=0).
+     */
+    Log("Step 9: BR_1 performs the DNS query via Eth_1 and provides the error answer to ED_1.");
+
+    /**
+     * Step 10
+     *   Device: ED_1
+     *   Description (DNS-11.5): Harness instructs device to send a DNS query, with
+     *     Qtype=A for name “host-test-eth.default.service.arpa”.
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 10: ED_1 sends a DNS query with Qtype=A for host-test-eth.default.service.arpa.");
+    SendQuery(ed1, "host-test-eth.default.service.arpa", Dns::ResourceRecord::kTypeA);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 11
+     *   Device: BR_1 (DUT)
+     *   Description (DNS-11.5): Automatically performs the mDNS query on AIL
+     *     (optionally, if still needed), and provides the answer back to ED_1.
+     *   Pass Criteria:
+     *     - The DUT MUST respond Rcode=0 (NoError) with answer record: host-test-eth.
+     *       default.service.arpa A <IPv4 address of Eth_1>
+     */
+    Log("Step 11: BR_1 performs mDNS query on AIL and provides the answer to ED_1.");
+
+    /**
+     * Step 12
+     *   Device: ED_1
+     *   Description (DNS-11.5): Harness instructs device to send DNS query Qtype=A for
+     *     name “ipv4only.arpa”
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 12: ED_1 sends DNS query Qtype=A for name ipv4only.arpa.");
+    SendQuery(ed1, "ipv4only.arpa", Dns::ResourceRecord::kTypeA);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 13
+     *   Device: BR_1 (DUT)
+     *   Description (DNS-11.5): Automatically blocks the ipv4only.arpa query and
+     *     responds NXDomain error.
+     *   Pass Criteria:
+     *     - The DUT MUST respond Rcode=3 (NXDomain) with zero answer records (ANCOUNT=0).
+     *     - The DUT MUST NOT send a DNS query to Eth_1.
+     */
+    Log("Step 13: BR_1 blocks the ipv4only.arpa query and responds NXDomain error.");
+
+    /**
+     * Step 14
+     *   Device: ED_1
+     *   Description (DNS-11.5): Harness instructs device to send DNS query with RR type
+     *     in the private use range Qtype=0xFF05 for name “threadgroup2.org”. To do this
+     *     with OT CLI, use the following: udp send <BR_1 ML-EID address> 53 -x
+     *     e13c010000010000000000000c74687265616467726f757032036f726700ff050001 “”
+     *   Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 14: ED_1 sends DNS query with RR type in private use range Qtype=0xFF05.");
+    SendRa(eth1, eth1Addr, guaPrefix);
+    SendQuery(ed1, kThreadGroup2, kTypePrivateUse);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    /**
+     * Step 15
+     *   Device: BR_1 (DUT)
+     *   Description (DNS-11.5): Automatically responds with the requested record.
+     *   Pass Criteria:
+     *     - The DUT MUST respond Rcode=0 (NoError) with answer record with binary data
+     *       (4 bytes): threadgroup2.org 0xFF05 0x12345678
+     */
+    Log("Step 15: BR_1 responds with the requested record.");
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::Test_1_4_DNS_TC_5((argc > 2) ? argv[2] : "test_1_4_dns_tc_5.json");
+    ot::Nexus::Log("All tests passed");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_PIC_TC_1.cpp b/tests/nexus/test_1_4_PIC_TC_1.cpp
new file mode 100644
index 0000000..c570dd4
--- /dev/null
+++ b/tests/nexus/test_1_4_PIC_TC_1.cpp
@@ -0,0 +1,580 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "common/offset_range.hpp"
+#include "net/dhcp6_types.hpp"
+#include "net/dns_types.hpp"
+#include "net/tcp6.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (DHCPv6-PD, RA), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 30 * 1000;
+
+/**
+ * Time to advance for the DNS query and response, in milliseconds.
+ */
+static constexpr uint32_t kDnsTime = 5 * 1000;
+
+/**
+ * Time to advance for the ping response, in milliseconds.
+ */
+static constexpr uint32_t kPingTime = 2 * 1000;
+
+/**
+ * Time to advance for the TCP connection and data transfer, in milliseconds.
+ */
+static constexpr uint32_t kTcpTime = 10 * 1000;
+
+/**
+ * DHCPv6-PD Server Port.
+ */
+static constexpr uint16_t kDhcp6ServerPort = 547;
+
+/**
+ * DHCPv6-PD Client Port.
+ */
+static constexpr uint16_t kDhcp6ClientPort = 546;
+
+/**
+ * DNS Server Port.
+ */
+static constexpr uint16_t kDnsPort = 53;
+
+/**
+ * HTTP Server Port.
+ */
+static constexpr uint16_t kHttpPort = 80;
+
+/**
+ * Echo Request identifier.
+ */
+static constexpr uint16_t kEchoIdentifier = 0x1234;
+
+/**
+ * Echo Request payload size.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+/**
+ * Internet Server Address.
+ */
+static const char kInternetServerAddr[] = "2002:1234::1234";
+
+/**
+ * Delegated Prefix.
+ */
+static const char kDelegatedPrefix[] = "2005:1234:abcd:1::/64";
+
+/**
+ * Eth_1 GUA address prefix.
+ */
+static const char kEth1Prefix[] = "2001:db8:1::/64";
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * DHCPv6 Server Hook to simulate DHCPv6-PD server and DNS server.
+ */
+bool HandleEth2Udp(Instance &aInstance, Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
+{
+    Node &eth2    = Node::From(&aInstance);
+    bool  handled = false;
+
+    if (aMessageInfo.GetSockPort() == kDhcp6ServerPort)
+    {
+        Dhcp6::Header header;
+        SuccessOrQuit(aMessage.Read(aMessage.GetOffset(), header));
+
+        if (header.GetMsgType() == Dhcp6::kMsgTypeSolicit || header.GetMsgType() == Dhcp6::kMsgTypeRequest)
+        {
+            Message *reply = eth2.Get<MessagePool>().Allocate(Message::kTypeIp6);
+            VerifyOrQuit(reply != nullptr);
+
+            Dhcp6::Header replyHeader;
+            replyHeader.Clear();
+            replyHeader.SetMsgType((header.GetMsgType() == Dhcp6::kMsgTypeSolicit) ? Dhcp6::kMsgTypeAdvertise
+                                                                                   : Dhcp6::kMsgTypeReply);
+            replyHeader.SetTransactionId(header.GetTransactionId());
+            SuccessOrQuit(reply->Append(replyHeader));
+
+            OffsetRange   offsetRange;
+            Dhcp6::Option option;
+            uint32_t      iaid = 0;
+
+            offsetRange.InitFromRange(aMessage.GetOffset() + sizeof(Dhcp6::Header), aMessage.GetLength());
+
+            while (offsetRange.Contains(sizeof(Dhcp6::Option)))
+            {
+                SuccessOrQuit(aMessage.Read(offsetRange.GetOffset(), option));
+                VerifyOrQuit(offsetRange.Contains(option.GetSize()));
+
+                if (option.GetCode() == Dhcp6::Option::kClientId)
+                {
+                    SuccessOrQuit(reply->AppendBytesFromMessage(aMessage, offsetRange.GetOffset(), option.GetSize()));
+                }
+                else if (option.GetCode() == Dhcp6::Option::kIaPd)
+                {
+                    Dhcp6::IaPdOption iaPdSolicit;
+
+                    VerifyOrQuit(option.GetSize() >= sizeof(iaPdSolicit));
+                    SuccessOrQuit(aMessage.Read(offsetRange.GetOffset(), iaPdSolicit));
+                    iaid = iaPdSolicit.GetIaid();
+                }
+                offsetRange.AdvanceOffset(option.GetSize());
+            }
+
+            // Append Server ID (ETH_2 address)
+            SuccessOrQuit(Dhcp6::ServerIdOption::AppendWithEui64Duid(*reply, eth2.Get<Mac::Mac>().GetExtAddress()));
+
+            if (header.GetMsgType() == Dhcp6::kMsgTypeSolicit)
+            {
+                // Append Preference
+                Dhcp6::PreferenceOption preference;
+                preference.Init();
+                preference.SetPreference(255);
+                SuccessOrQuit(reply->Append(preference));
+            }
+
+            Dhcp6::IaPdOption iaPd;
+            iaPd.Init();
+            iaPd.SetIaid(iaid);
+            iaPd.SetT1(1000);
+            iaPd.SetT2(2000);
+            SuccessOrQuit(reply->Append(iaPd));
+
+            Dhcp6::IaPrefixOption prefixOption;
+            prefixOption.Init();
+            Ip6::Prefix prefix;
+            SuccessOrQuit(prefix.FromString(kDelegatedPrefix));
+            prefixOption.SetPrefix(prefix);
+            prefixOption.SetPreferredLifetime(3600);
+            prefixOption.SetValidLifetime(7200);
+            SuccessOrQuit(reply->Append(prefixOption));
+
+            Dhcp6::Option::UpdateOptionLengthInMessage(*reply,
+                                                       reply->GetLength() - sizeof(iaPd) - sizeof(prefixOption));
+
+            eth2.mInfraIf.SendUdp(eth2.mInfraIf.GetLinkLocalAddress(), aMessageInfo.GetPeerAddr(), kDhcp6ServerPort,
+                                  kDhcp6ClientPort, *reply);
+            handled = true;
+        }
+    }
+    else if (aMessageInfo.GetSockPort() == kDnsPort)
+    {
+        Dns::Header header;
+        SuccessOrQuit(aMessage.Read(aMessage.GetOffset(), header));
+
+        if (header.GetType() == Dns::Header::kTypeQuery)
+        {
+            Message *reply = eth2.Get<MessagePool>().Allocate(Message::kTypeIp6);
+            VerifyOrQuit(reply != nullptr);
+
+            Dns::Header replyHeader;
+            replyHeader.SetMessageId(header.GetMessageId());
+            replyHeader.SetType(Dns::Header::kTypeResponse);
+            replyHeader.SetQueryType(Dns::Header::kQueryTypeStandard);
+            replyHeader.SetRecursionDesiredFlag();
+            replyHeader.SetRecursionAvailableFlag();
+            replyHeader.SetQuestionCount(1);
+            replyHeader.SetAnswerCount(1);
+            SuccessOrQuit(reply->Append(replyHeader));
+
+            uint16_t offset      = aMessage.GetOffset() + sizeof(Dns::Header);
+            uint16_t questionLen = aMessage.GetLength() - offset;
+            SuccessOrQuit(reply->AppendBytesFromMessage(aMessage, offset, questionLen));
+
+            SuccessOrQuit(Dns::Name::AppendName("threadgroup.org", *reply));
+            Dns::ResourceRecord rr;
+            rr.SetType(Dns::ResourceRecord::kTypeAaaa);
+            rr.SetClass(Dns::ResourceRecord::kClassInternet);
+            rr.SetTtl(3600);
+            rr.SetLength(sizeof(Ip6::Address));
+            SuccessOrQuit(reply->Append(rr));
+            Ip6::Address addr;
+            SuccessOrQuit(addr.FromString(kInternetServerAddr));
+            SuccessOrQuit(reply->Append(addr));
+
+            eth2.mInfraIf.SendUdp(eth2.mInfraIf.GetLinkLocalAddress(), aMessageInfo.GetPeerAddr(), kDnsPort,
+                                  aMessageInfo.GetPeerPort(), *reply);
+            handled = true;
+        }
+    }
+
+    return handled;
+}
+
+void Test_1_4_PIC_TC_1(void)
+{
+    /**
+     * 10.1. IPv6 Connectivity using DHCPv6-PD delegated OMR prefix
+     *
+     * 10.1.1. Purpose
+     *   - To verify that BR DUT:
+     *     - uses DHCPv6-PD client functionality to obtain an OMR prefix for its Thread Network from the upstream CPE
+     *       router
+     *     - Offers IPv6 public internet connectivity to/from Thread Devices that configured an OMR address
+     *     - Offers IPv6 local network connectivity to/from Thread Devices that configured an OMR address
+     *     - Operates DNS recursive resolver to look up IPv6 server addresses on public internet
+     *
+     * 10.1.2. Topology
+     *   - BR_1 (DUT) - Border Router
+     *   - Router_1 - Thread Router Reference Device, attached to BR_1
+     *   - ED_1 - Thread Reference Device, End Device (e.g. FED/REED) role, attached to Router_1
+     *   - Eth_1 - Adjacent Infrastructure Link Linux Reference Device
+     *     - Has HTTP web server with resource ‘/test2.html’.
+     *   - Eth_2 - Adjacent Infrastructure Link SPIFF Reference Device
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &r1   = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+    Node &eth2 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    r1.SetName("ROUTER_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("ETH_1");
+    eth2.SetName("ETH_2");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("Step 1: Enable Eth_1 and Eth_2");
+    /**
+     * Step 1
+     *   - Device: Eth_1, Eth_2
+     *   - Description (PIC-10.1): Enable
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    eth2.mInfraIf.SetIsDnsServer(true);
+
+    {
+        Ip6::Address internetServer;
+        SuccessOrQuit(internetServer.FromString(kInternetServerAddr));
+        eth2.mInfraIf.AddAddress(internetServer);
+    }
+
+    Ip6::Prefix eth1Prefix;
+    SuccessOrQuit(eth1Prefix.FromString(kEth1Prefix));
+    eth2.mInfraIf.StartRouterAdvertisement(eth1Prefix);
+    eth2.mInfraIf.SetUdpHook(HandleEth2Udp);
+
+    Log("Step 2: Eth_2 default configuration");
+    /**
+     * Step 2
+     *   - Device: Eth_2 (SPIFF)
+     *   - Description (PIC-10.1): Harness does not configure the device, other than the default configuration. Note:
+     *     in previous versions of the test plan, explicit configuration was provided in this step. Note: Eth_1 will
+     *     automatically configure a GUA using SLAAC and the RA-advertised prefix.
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 3: Enable BR_1, Router_1, ED_1");
+    /**
+     * Step 3
+     *   - Device: BR_1 (DUT), Router_1, ED_1
+     *   - Description (PIC-10.1): Enable
+     *   - Pass Criteria:
+     *     - N/A
+     */
+
+    AllowLinkBetween(br1, r1);
+    AllowLinkBetween(r1, ed1);
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    br1.Get<BorderRouter::RoutingManager>().SetDhcp6PdEnabled(true);
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    SuccessOrQuit(br1.Get<Dns::ServiceDiscovery::Server>().Start());
+    br1.Get<Dns::ServiceDiscovery::Server>().SetUpstreamQueryEnabled(true);
+
+    r1.Join(br1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    ed1.Join(r1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    Log("Step 4: BR_1 obtains OMR prefix via DHCPv6-PD");
+    /**
+     * Step 4
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.1): Automatically uses DHCPv6-PD client function to obtain a delegated prefix from the
+     *     DHCPv6 server. It configures this prefix as the OMR prefix.
+     *   - Pass Criteria:
+     *     - An OMR prefix OMR_1 MUST appear in Thread Network Data in a Prefix TLV:
+     *     - It MUST be subprefix of 2005:1234:abcd:0::/56.
+     *     - It MUST be a /64 prefix
+     *     - Border Router sub-TLV:
+     *       - Prf bits = P_preference = '00' (medium)
+     *       - R = P_default = '1'
+     */
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("Step 4b: BR_1 advertises route to OMR prefix on AIL");
+    /**
+     * Step 4b
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.1): Automatically advertises the route to its OMR prefix on the AIL, as a stub router
+     *     (aka IETF SNAC router). Note: see 9.6.2 for Stub Router / SNAC Router flag detail.The flag is bit 6 of RA
+     *     flags as specified by the TEMPORARY allocation made by IANA (link).
+     *   - Pass Criteria:
+     *     - The DUT MUST advertise the route in multicast ND RA messages:
+     *     - IPv6 destination MUST be ff02::1
+     *     - SNAC Router flag set to '1'
+     *     - Route Information Option (RIO)
+     *       - Prefix: OMR_1
+     *       - Prf bits: '00' or '11'
+     *       - Route Lifetime > 0
+     */
+
+    Log("Step 5: ED_1 performs DNS query for threadgroup.org");
+    /**
+     * Step 5
+     *   - Device: ED_1
+     *   - Description (PIC-10.1): Harness instructs device to perform DNS query QType=AAAA, name “threadgroup.org”.
+     *     Automatically, the DNS query gets routed to BR_1.
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    Ip6::Address dnsServerAddr;
+    dnsServerAddr = br1.Get<Mle::Mle>().GetMeshLocalEid();
+
+    Ip6::Udp::Socket dnsSocket(ed1, nullptr, nullptr);
+    SuccessOrQuit(dnsSocket.Open(Ip6::kNetifThreadInternal));
+
+    Message *dnsQuery = dnsSocket.NewMessage();
+    VerifyOrQuit(dnsQuery != nullptr);
+
+    Dns::Header dnsHeader;
+    dnsHeader.SetType(Dns::Header::kTypeQuery);
+    dnsHeader.SetQuestionCount(1);
+    dnsHeader.SetRecursionDesiredFlag();
+    SuccessOrQuit(dnsQuery->Append(dnsHeader));
+    SuccessOrQuit(Dns::Name::AppendName("threadgroup.org", *dnsQuery));
+    Dns::Question dnsQuestion(Dns::ResourceRecord::kTypeAaaa);
+    SuccessOrQuit(dnsQuery->Append(dnsQuestion));
+
+    Ip6::MessageInfo dnsMessageInfo;
+    dnsMessageInfo.SetPeerAddr(dnsServerAddr);
+    dnsMessageInfo.SetPeerPort(kDnsPort);
+    SuccessOrQuit(dnsSocket.SendTo(*dnsQuery, dnsMessageInfo));
+    SuccessOrQuit(dnsSocket.Close());
+
+    Log("Step 6: BR_1 processes DNS query upstream");
+    /**
+     * Step 6
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.1): Automatically processes the DNS query by requesting upstream to the Eth_2 DNS
+     *     server. Then, it responds back with the answer to ED_1.
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    nexus.AdvanceTime(kDnsTime);
+
+    Log("Step 7: ED_1 receives DNS result");
+    /**
+     * Step 7
+     *   - Device: ED_1
+     *   - Description (PIC-10.1): Successfully receives DNS query result: threadgroup.org AAAA 2002:1234::1234
+     *   - Pass Criteria:
+     *     - ED_1 MUST receive DNS query answer from DUT.
+     */
+
+    Log("Step 8: ED_1 pings internet server");
+    /**
+     * Step 8
+     *   - Device: ED_1
+     *   - Description (PIC-10.1): Harness instructs device to send ICMpv6 ping request to internet server, using
+     *     resolved address above. It is routed via the DUT to the Eth_2 simulated network.
+     *   - Pass Criteria:
+     *     - ED_1 MUST receive ICMPv6 ping response from simulated server.
+     */
+    Ip6::Address internetServer;
+    SuccessOrQuit(internetServer.FromString(kInternetServerAddr));
+    ed1.SendEchoRequest(internetServer, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 8a: ED_1 sends UDP ping to internet server");
+    /**
+     * Step 8a
+     *   - Device: ED_1
+     *   - Description (PIC-10.1): Repeats same using a UDP ping.
+     *   - Pass Criteria:
+     *     - ED_1 MUST receive UDP ping response from simulated server.
+     */
+    {
+        Ip6::Udp::Socket udpSocket(ed1, nullptr, nullptr);
+        SuccessOrQuit(udpSocket.Open(Ip6::kNetifThreadInternal));
+        Message *udpMsg = udpSocket.NewMessage();
+        VerifyOrQuit(udpMsg != nullptr);
+        SuccessOrQuit(udpMsg->SetLength(kEchoPayloadSize));
+        Ip6::MessageInfo udpInfo;
+        udpInfo.SetPeerAddr(internetServer);
+        udpInfo.SetPeerPort(12345);
+        SuccessOrQuit(udpSocket.SendTo(*udpMsg, udpInfo));
+        SuccessOrQuit(udpSocket.Close());
+    }
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 9: ED_1 sends HTTP GET to internet server");
+    /**
+     * Step 9
+     *   - Device: ED_1
+     *   - Description (PIC-10.1): Harness instructs device to sends HTTP GET request to server. If ED_1 is a Linux
+     *     device, it may use 'curl' to save the file: curl -o test.html http://threadgroup.org/test.html. In case ED_1
+     *     is a non-Linux Thread CLI device, it can use the OT CLI commands to send the HTTP GET request: tcp init, tcp
+     *     connect 2002:1234::1234 80, tcp send -x
+     *     474554202F746573742E68746D6C20485454502F312E310D0A486F73743A2074687265616467726F75702E6F72670D0A0D0A, tcp
+     *     sendend, tcp deinit
+     *   - Pass Criteria:
+     *     - In case Linux 'curl' was used: File test.html MUST equal the test.html file as stored on the simulated
+     *       server on Eth_2.
+     *     - In case OT CLI was used: Output on the CLI MUST be the HTML file test.html as stored on the simulator
+     *       server on Eth_2.
+     */
+    {
+        Ip6::Tcp::Endpoint          tcpEndpoint;
+        otTcpEndpointInitializeArgs args;
+        ClearAllBytes(args);
+        SuccessOrQuit(tcpEndpoint.Initialize(ed1, args));
+        Ip6::SockAddr internetSockAddr;
+        internetSockAddr.SetAddress(internetServer);
+        internetSockAddr.SetPort(kHttpPort);
+        SuccessOrQuit(tcpEndpoint.Connect(internetSockAddr, 0));
+        nexus.AdvanceTime(kTcpTime);
+
+        const char     httpGet[] = "GET /test.html HTTP/1.1\r\nHost: threadgroup.org\r\n\r\n";
+        otLinkedBuffer linkedBuffer;
+        ClearAllBytes(linkedBuffer);
+        linkedBuffer.mData   = reinterpret_cast<const uint8_t *>(httpGet);
+        linkedBuffer.mLength = sizeof(httpGet) - 1;
+        SuccessOrQuit(tcpEndpoint.SendByReference(linkedBuffer, 0));
+        nexus.AdvanceTime(kTcpTime);
+        SuccessOrQuit(tcpEndpoint.Deinitialize());
+    }
+
+    Log("Step 10: ED_1 pings local server Eth_1");
+    /**
+     * Step 10
+     *   - Device: ED_1
+     *   - Description (PIC-10.1): Harness instructs device to send ping request to local server Eth_1. Automatically,
+     *     it is routed via the DUT.
+     *   - Pass Criteria:
+     *     - ED_1 MUST receive ping response from Eth_1.
+     */
+    Ip6::Address eth1Addr = eth1.mInfraIf.FindMatchingAddress(kEth1Prefix);
+    ed1.SendEchoRequest(eth1Addr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 11: ED_1 sends HTTP GET to local server Eth_1");
+    /**
+     * Step 11
+     *   - Device: ED_1
+     *   - Description (PIC-10.1): Harness instructs device to send HTTP GET request to local server Eth_1. If ED_1 is a
+     *     Linux device, it may use 'curl' to save the file: curl -o test2.html http:///test2.html. In case ED_1 is a
+     *     non-Linux Thread CLI device, it can use the OT CLI commands to send the HTTP GET request: tcp init, tcp
+     *     connect <Eth_1_GUA_IPv6_addr> 80, tcp send -x
+     *     474554202F74657374322E68746D6C20485454502F312E310D0A486F73743A206C6F63616C746573742E6F72670D0A0D0A, tcp
+     *     sendend, tcp deinit
+     *   - Pass Criteria:
+     *     - In case Linux 'curl' was used: File test2.html MUST equal the test2.html file as stored on the Eth_1
+     *       server.
+     *     - In case OT CLI was used: Output on the CLI MUST be the HTML file test.html as stored on the simulator
+     *       server on Eth_2.
+     */
+    {
+        Ip6::Tcp::Endpoint          tcpEndpoint;
+        otTcpEndpointInitializeArgs args;
+        ClearAllBytes(args);
+        SuccessOrQuit(tcpEndpoint.Initialize(ed1, args));
+        Ip6::SockAddr eth1SockAddr;
+        eth1SockAddr.SetAddress(eth1Addr);
+        eth1SockAddr.SetPort(kHttpPort);
+        SuccessOrQuit(tcpEndpoint.Connect(eth1SockAddr, 0));
+        nexus.AdvanceTime(kTcpTime);
+
+        const char     httpGet[] = "GET /test2.html HTTP/1.1\r\nHost: localtest.org\r\n\r\n";
+        otLinkedBuffer linkedBuffer;
+        ClearAllBytes(linkedBuffer);
+        linkedBuffer.mData   = reinterpret_cast<const uint8_t *>(httpGet);
+        linkedBuffer.mLength = sizeof(httpGet) - 1;
+        SuccessOrQuit(tcpEndpoint.SendByReference(linkedBuffer, 0));
+        nexus.AdvanceTime(kTcpTime);
+        SuccessOrQuit(tcpEndpoint.Deinitialize());
+    }
+
+    nexus.AddTestVar("ETH_1_ADDR", eth1Addr.ToString().AsCString());
+    nexus.AddTestVar("ETH_2_ADDR", eth2.mInfraIf.GetLinkLocalAddress().ToString().AsCString());
+    nexus.AddTestVar("INTERNET_SERVER_ADDR", kInternetServerAddr);
+    nexus.AddTestVar("DELEGATED_PREFIX", kDelegatedPrefix);
+
+    nexus.SaveTestInfo("test_1_4_PIC_TC_1.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_4_PIC_TC_1();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_PIC_TC_3.cpp b/tests/nexus/test_1_4_PIC_TC_3.cpp
new file mode 100644
index 0000000..f362017
--- /dev/null
+++ b/tests/nexus/test_1_4_PIC_TC_3.cpp
@@ -0,0 +1,487 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "net/dhcp6_types.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+
+/**
+ * Time to advance for the BR to perform automatic actions (DHCPv6-PD, RA), in milliseconds.
+ */
+static constexpr uint32_t kBrActionTime = 30 * 1000;
+
+/**
+ * Time to advance for the ping response, in milliseconds.
+ */
+static constexpr uint32_t kPingTime = 2 * 1000;
+
+/**
+ * DHCPv6-PD Server Port.
+ */
+static constexpr uint16_t kDhcp6ServerPort = 547;
+
+/**
+ * DHCPv6-PD Client Port.
+ */
+static constexpr uint16_t kDhcp6ClientPort = 546;
+
+/**
+ * Echo Request identifier.
+ */
+static constexpr uint16_t kEchoIdentifier = 0x1234;
+
+/**
+ * Echo Request payload size.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+/**
+ * Internet Server Address.
+ */
+static const char kInternetServerAddr[] = "2002:1234::1234";
+
+/**
+ * Delegated Prefix.
+ */
+static const char kDelegatedPrefix[] = "2005:1234:abcd:0::/64";
+
+/**
+ * Eth_1 GUA address prefix.
+ */
+static const char kEth1Prefix[] = "2001:db8:1::/64";
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * DHCPv6 Server Hook to simulate DHCPv6-PD server.
+ */
+bool HandleEth2Udp(Instance &aInstance, Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
+{
+    Node &eth2    = Node::From(&aInstance);
+    bool  handled = false;
+
+    if (aMessageInfo.GetSockPort() == kDhcp6ServerPort)
+    {
+        Dhcp6::Header header;
+        SuccessOrQuit(aMessage.Read(aMessage.GetOffset(), header));
+
+        if (header.GetMsgType() == Dhcp6::kMsgTypeSolicit || header.GetMsgType() == Dhcp6::kMsgTypeRequest)
+        {
+            Message *reply = eth2.Get<MessagePool>().Allocate(Message::kTypeIp6);
+            VerifyOrQuit(reply != nullptr);
+
+            Dhcp6::Header replyHeader;
+            replyHeader.Clear();
+            replyHeader.SetMsgType((header.GetMsgType() == Dhcp6::kMsgTypeSolicit) ? Dhcp6::kMsgTypeAdvertise
+                                                                                   : Dhcp6::kMsgTypeReply);
+            replyHeader.SetTransactionId(header.GetTransactionId());
+            SuccessOrQuit(reply->Append(replyHeader));
+
+            uint16_t      offset = aMessage.GetOffset() + sizeof(Dhcp6::Header);
+            uint16_t      length = aMessage.GetLength() - offset;
+            Dhcp6::Option option;
+            uint32_t      iaid = 0;
+
+            while (length >= sizeof(Dhcp6::Option))
+            {
+                SuccessOrQuit(aMessage.Read(offset, option));
+                VerifyOrQuit(length >= option.GetSize());
+                if (option.GetCode() == Dhcp6::Option::kClientId)
+                {
+                    SuccessOrQuit(reply->AppendBytesFromMessage(aMessage, offset, option.GetSize()));
+                }
+                else if (option.GetCode() == Dhcp6::Option::kIaPd)
+                {
+                    Dhcp6::IaPdOption iaPdSolicit;
+
+                    VerifyOrQuit(option.GetSize() >= sizeof(iaPdSolicit));
+                    SuccessOrQuit(aMessage.Read(offset, iaPdSolicit));
+                    iaid = iaPdSolicit.GetIaid();
+                }
+                offset += option.GetSize();
+                length -= option.GetSize();
+            }
+
+            // Append Server ID (ETH_2 address)
+            SuccessOrQuit(Dhcp6::ServerIdOption::AppendWithEui64Duid(*reply, eth2.Get<Mac::Mac>().GetExtAddress()));
+
+            if (header.GetMsgType() == Dhcp6::kMsgTypeSolicit)
+            {
+                // Append Preference
+                Dhcp6::PreferenceOption preference;
+                preference.Init();
+                preference.SetPreference(255);
+                SuccessOrQuit(reply->Append(preference));
+            }
+
+            Dhcp6::IaPdOption iaPd;
+            iaPd.Init();
+            iaPd.SetIaid(iaid);
+            iaPd.SetT1(1000);
+            iaPd.SetT2(2000);
+            SuccessOrQuit(reply->Append(iaPd));
+
+            Dhcp6::IaPrefixOption prefixOption;
+            prefixOption.Init();
+            Ip6::Prefix prefix;
+            SuccessOrQuit(prefix.FromString(kDelegatedPrefix));
+            prefixOption.SetPrefix(prefix);
+            prefixOption.SetPreferredLifetime(3600);
+            prefixOption.SetValidLifetime(7200);
+            SuccessOrQuit(reply->Append(prefixOption));
+
+            Dhcp6::Option::UpdateOptionLengthInMessage(*reply,
+                                                       reply->GetLength() - sizeof(iaPd) - sizeof(prefixOption));
+
+            eth2.mInfraIf.SendUdp(eth2.mInfraIf.GetLinkLocalAddress(), aMessageInfo.GetPeerAddr(), kDhcp6ServerPort,
+                                  kDhcp6ClientPort, *reply);
+            handled = true;
+        }
+    }
+
+    return handled;
+}
+
+static bool sDropInternetPings = false;
+
+void Br1ReceiveCallback(otMessage *aMessage, void *aContext)
+{
+    if (sDropInternetPings)
+    {
+        Ip6::Header  header;
+        Ip6::Address internetServer;
+
+        SuccessOrQuit(AsCoreType(aMessage).Read(0, header));
+        SuccessOrQuit(internetServer.FromString(kInternetServerAddr));
+
+        if (header.GetDestination() == internetServer)
+        {
+            // Drop it to simulate "no route to host"
+            return;
+        }
+    }
+
+    Node::HandleIp6Receive(aMessage, aContext);
+}
+
+void Test_1_4_PIC_TC_3(void)
+{
+    /**
+     * 10.3. IPv6 default route advertisement
+     *
+     * 10.3.1. Purpose
+     *   - To verify that the BR DUT:
+     *   - Correctly advertises a default route on the Thread Network, using the zero-length route ::/0 in a Prefix TLV.
+     *   - Does not withdraws the advertised default route when the infrastructure network disables the default route,
+     *     as long as there's still a non-ULA prefix active on the AIL.
+     *   - Still advertises the default route, when the infrastructure network re-enables the default route.
+     *   - TBD: a future test update needs to validate that when only a ULA prefix is set at the AIL, the default route
+     *     gets withdrawn and replaced by fc00::/7 route.
+     *
+     * 10.3.2. Topology
+     *   - BR_1 (DUT) - Border Router
+     *   - Router_1 - Thread Router Reference Device, attached to BR_1
+     *   - ED_1 - Thread Reference Device, End Device (e.g. FED/REED) role, attached to Router_1
+     *   - Eth_1 - Adjacent Infrastructure Link Linux Reference Device
+     *   - Eth_2 - Adjacent Infrastructure Link SPIFF Reference Device
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &r1   = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+    Node &eth2 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    r1.SetName("ROUTER_1");
+    ed1.SetName("ED_1");
+    eth1.SetName("ETH_1");
+    eth2.SetName("ETH_2");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("Step 1: Enable Eth_1 and Eth_2");
+    /**
+     * Step 1
+     *   - Device: Eth_1, Eth_2
+     *   - Description (PIC-10.3): Enable
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    eth1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    eth2.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    eth1.Get<BorderRouter::RoutingManager>().Init();
+    eth2.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(eth1.Get<BorderRouter::RoutingManager>().SetEnabled(false));
+    SuccessOrQuit(eth2.Get<BorderRouter::RoutingManager>().SetEnabled(false));
+
+    {
+        Ip6::Address internetServer;
+        SuccessOrQuit(internetServer.FromString(kInternetServerAddr));
+        eth2.mInfraIf.AddAddress(internetServer);
+    }
+
+    Ip6::Prefix pioPrefix;
+    SuccessOrQuit(pioPrefix.FromString(kEth1Prefix));
+    eth2.mInfraIf.StartRouterAdvertisement(pioPrefix);
+    eth2.mInfraIf.SetUdpHook(HandleEth2Udp);
+
+    Log("Step 2: Eth_2 default configuration");
+    /**
+     * Step 2
+     *   - Device: Eth_2
+     *   - Description (PIC-10.3): Harness instructs device to: (only default configuration is applied)
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 3: Enable BR_1, Router_1, ED_1");
+    /**
+     * Step 3
+     *   - Device: BR_1 (DUT), Router_1, ED_1
+     *   - Description (PIC-10.3): Enable
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    AllowLinkBetween(br1, r1);
+    AllowLinkBetween(r1, ed1);
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    br1.Get<BorderRouter::RoutingManager>().SetDhcp6PdEnabled(true);
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    br1.Get<Ip6::Ip6>().SetReceiveCallback(Br1ReceiveCallback, &br1);
+
+    r1.Join(br1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    ed1.Join(r1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    Log("Step 4: BR_1 obtains OMR prefix via DHCPv6-PD");
+    /**
+     * Step 4
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.3): Automatically uses DHCPv6-PD client function to obtain a delegated prefix from the
+     *     DHCPv6 server. It configures this prefix as the OMR prefix.
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("Step 5: BR_1 automatically advertises a default route in its Thread Network Data");
+    /**
+     * Step 5
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.3): Automatically advertises a default route in its Thread Network Data.
+     *   - Pass Criteria:
+     *     - Thread Network Data MUST contain: Prefix TLV with an OMR prefix OMR_1 that starts with
+     *       2005:1234:abcd:0::/56.
+     *     - Border Router sub-TLV MUST indicate the RLOC16 of BR_1, in a P_border_router_16 field.
+     *     - Flag R (P_default) MUST be ‘1’
+     *     - Flag O (P_on_mesh) MUST be ‘1’
+     *     - Thread Network Data MUST contain: Prefix TLV with the zero-length prefix ::/0
+     *     - Has Route sub-TLV Flag NP MUST be ‘0’
+     */
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("Step 6: ED_1 pings internet address");
+    /**
+     * Step 6
+     *   - Device: ED_1
+     *   - Description (PIC-10.3): Harness instructs device to send ping to destination address 2002:1234::1234 with
+     *     its (default) OMR source address, to validate that default route works.
+     *   - Pass Criteria:
+     *     - Ping response received by ED_1.
+     */
+    Ip6::Address internetServer;
+    SuccessOrQuit(internetServer.FromString(kInternetServerAddr));
+    ed1.SendEchoRequest(internetServer, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 7: Eth_2 stops advertising a default route");
+    /**
+     * Step 7
+     *   - Device: Eth_2
+     *   - Description (PIC-10.3): Harnes instructs device to reconfigure ND RA daemon to stop advertising a default
+     *     route: “Router Lifetime” field set to 0 sec (indicating it is not a default router for IPv6).
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    eth2.mInfraIf.StopRouterAdvertisement();
+    nexus.AdvanceTime(kPingTime);
+
+    for (int i = 0; i < 3; i++)
+    {
+        Ip6::Nd::RouterAdvert::TxMessage ra;
+        Ip6::Nd::RouterAdvert::Header    header;
+        Ip6::Nd::Icmp6Packet             packet;
+
+        header.SetToDefault();
+        header.SetRouterLifetime(0);
+        SuccessOrQuit(ra.Append(header));
+        SuccessOrQuit(ra.AppendPrefixInfoOption(pioPrefix, 1800, 1800,
+                                                Ip6::Nd::PrefixInfoOption::kOnLinkFlag |
+                                                    Ip6::Nd::PrefixInfoOption::kAutoConfigFlag));
+        ra.GetAsPacket(packet);
+        eth2.mInfraIf.SendIcmp6Nd(Ip6::Address::GetLinkLocalAllNodesMulticast(), packet.GetBytes(), packet.GetLength());
+        nexus.AdvanceTime(1000);
+    }
+    nexus.AdvanceTime(200 * 1000);
+
+    Log("Step 8: BR_1 does not stop advertising a default route");
+    /**
+     * Step 8
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.3): Does not stop advertising a default route in its Thread Network Data.
+     *   - Pass Criteria:
+     *     - Thread Network Data MUST contain: Prefix TLV with same OMR_1.
+     *     - Border Router sub-TLV MUST indicate the RLOC16 of BR_1, in a P_border_router_16 field.
+     *     - Flag R (P_default) MUST be ‘1’
+     *     - Flag O (P_on_mesh) MUST be ‘1’
+     *     - Thread Network Data MUST contain: Prefix TLV with the zero-length prefix ::/0
+     *     - Has Route sub-TLV Flag NP MUST be ‘0’
+     */
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("Step 9: ED_1 pings Eth_1");
+    /**
+     * Step 9
+     *   - Device: ED_1
+     *   - Description (PIC-10.3): Harness instructs device to send ping to destination Eth_1 with its (default) OMR
+     *     source address, to validate that route to AIL still works.
+     *   - Pass Criteria:
+     *     - Ping response received by ED_1.
+     */
+    Ip6::Address eth1Addr = eth1.mInfraIf.FindMatchingAddress(kEth1Prefix);
+    ed1.SendEchoRequest(eth1Addr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 10: ED_1 pings internet address (should fail)");
+    /**
+     * Step 10
+     *   - Device: ED_1
+     *   - Description (PIC-10.3): Harness instructs device to send ping to destination address 2002:1234::1234 with
+     *     its (default) OMR source address, to validate that default route does not work at the moment.
+     *   - Pass Criteria:
+     *     - Ping request MUST NOT be sent by DUT onto the AIL.
+     *     - Ping response MUST NOT be received by ED_1.
+     */
+    sDropInternetPings = true;
+    ed1.SendEchoRequest(internetServer, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(10 * 1000);
+    sDropInternetPings = false;
+
+    Log("Step 11: Eth_2 starts advertising a default route again");
+    /**
+     * Step 11
+     *   - Device: Eth_2
+     *   - Description (PIC-10.3): Harness instructs device to reconfigure ND RA daemon to start advertising a default
+     *     route again: “Router Lifetime” field set to 875 sec (indicating it is a default router for IPv6).
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    eth2.mInfraIf.StartRouterAdvertisement(pioPrefix);
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("Step 12: BR_1 automatically advertises a default route (same as step 5)");
+    /**
+     * Step 12
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.3): (As in step 5)
+     *   - Pass Criteria:
+     *     - (As in step 5)
+     */
+    nexus.AdvanceTime(kBrActionTime);
+
+    Log("Step 13: ED_1 pings internet address (same as step 6)");
+    /**
+     * Step 13
+     *   - Device: ED_1
+     *   - Description (PIC-10.3): (As in step 6)
+     *   - Pass Criteria:
+     *     - (As in step 6)
+     */
+    ed1.SendEchoRequest(internetServer, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 14: ED_1 pings Eth_1 (same as step 9)");
+    /**
+     * Step 14
+     *   - Device: ED_1
+     *   - Description (PIC-10.3): (As in step 9)
+     *   - Pass Criteria:
+     *     - (As in step 9)
+     */
+    ed1.SendEchoRequest(eth1Addr, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingTime);
+
+    nexus.AddTestVar("ETH_1_ADDR", eth1Addr.ToString().AsCString());
+    nexus.AddTestVar("ETH_2_ADDR", eth2.mInfraIf.GetLinkLocalAddress().ToString().AsCString());
+    nexus.AddTestVar("INTERNET_SERVER_ADDR", kInternetServerAddr);
+
+    nexus.SaveTestInfo("test_1_4_PIC_TC_3.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_4_PIC_TC_3();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_PIC_TC_4.cpp b/tests/nexus/test_1_4_PIC_TC_4.cpp
new file mode 100644
index 0000000..6d8aa40
--- /dev/null
+++ b/tests/nexus/test_1_4_PIC_TC_4.cpp
@@ -0,0 +1,483 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "core/net/ip4_types.hpp"
+#include "core/net/nat64_translator.hpp"
+#include "net/checksum.hpp"
+#include "net/dhcp6_types.hpp"
+#include "net/dns_types.hpp"
+#include "net/tcp6.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+static constexpr uint32_t kJoinNetworkTime = 200 * 1000;
+static constexpr uint32_t kBrActionTime    = 30 * 1000;
+static constexpr uint32_t kDnsTime         = 5 * 1000;
+static constexpr uint32_t kPingTime        = 2 * 1000;
+static constexpr uint32_t kTcpTime         = 10 * 1000;
+static constexpr uint16_t kDnsPort         = 53;
+static constexpr uint16_t kHttpPort        = 80;
+static constexpr uint16_t kEchoIdentifier  = 0x1234;
+static constexpr uint16_t kEchoPayloadSize = 10;
+static constexpr uint32_t kInfraIfIndex    = 1;
+
+static const char kInternetServerIp4Addr[] = "38.106.217.165";
+static const char kLocalServerIp4Addr[]    = "192.168.1.4";
+static const char kEth2Prefix[]            = "2001:db8:1::/64";
+static const char kNat64Cidr[]             = "192.168.100.0/24";
+
+static bool HandleEth2Udp(Instance &aInstance, Message &aMessage, const Ip6::MessageInfo &aMessageInfo)
+{
+    Node &eth2    = Node::From(&aInstance);
+    bool  handled = false;
+
+    if (aMessageInfo.GetSockPort() == kDnsPort)
+    {
+        Dns::Header header;
+        SuccessOrQuit(aMessage.Read(aMessage.GetOffset(), header));
+
+        if (header.GetType() == Dns::Header::kTypeQuery)
+        {
+            Message *reply = eth2.Get<MessagePool>().Allocate(Message::kTypeIp6);
+            VerifyOrQuit(reply != nullptr);
+
+            Dns::Header replyHeader;
+            replyHeader.SetMessageId(header.GetMessageId());
+            replyHeader.SetType(Dns::Header::kTypeResponse);
+            replyHeader.SetQueryType(Dns::Header::kQueryTypeStandard);
+            replyHeader.SetRecursionDesiredFlag();
+            replyHeader.SetRecursionAvailableFlag();
+            replyHeader.SetQuestionCount(1);
+            replyHeader.SetAnswerCount(1);
+            SuccessOrQuit(reply->Append(replyHeader));
+
+            uint16_t offset      = aMessage.GetOffset() + sizeof(Dns::Header);
+            uint16_t questionLen = aMessage.GetLength() - offset;
+            SuccessOrQuit(reply->AppendBytesFromMessage(aMessage, offset, questionLen));
+
+            SuccessOrQuit(Dns::Name::AppendName("threadv4.org", *reply));
+
+            Dns::ResourceRecord rr;
+            rr.SetType(Dns::ResourceRecord::kTypeA);
+            rr.SetClass(Dns::ResourceRecord::kClassInternet);
+            rr.SetTtl(3600);
+            rr.SetLength(sizeof(Ip4::Address));
+            SuccessOrQuit(reply->Append(rr));
+
+            Ip4::Address addr;
+            SuccessOrQuit(addr.FromString(kInternetServerIp4Addr));
+            SuccessOrQuit(reply->Append(addr));
+
+            eth2.mInfraIf.SendUdp(eth2.mInfraIf.GetLinkLocalAddress(), aMessageInfo.GetPeerAddr(), kDnsPort,
+                                  aMessageInfo.GetPeerPort(), *reply);
+            handled = true;
+        }
+    }
+
+    return handled;
+}
+
+static void HandleIp4Receive(otMessage *aMessage, void *aContext)
+{
+    Node        *br1     = static_cast<Node *>(aContext);
+    Message     *message = static_cast<Message *>(aMessage);
+    Ip4::Headers ip4Headers;
+
+    VerifyOrQuit(ip4Headers.ParseFrom(*message) == kErrorNone);
+
+    if (ip4Headers.IsIcmp4())
+    {
+        Log("Received ICMPv4 packet");
+        Ip4::Icmp4Header icmpHeader;
+        SuccessOrQuit(message->Read(sizeof(Ip4::Header), icmpHeader));
+
+        if (icmpHeader.GetType() == Ip4::Icmp4Header::kTypeEchoRequest)
+        {
+            Log("Received ICMPv4 Echo Request, sending reply");
+            uint16_t payloadLen = message->GetLength() - sizeof(Ip4::Header) - sizeof(Ip4::Icmp4Header);
+
+            OwnedPtr<Message> replyMessage;
+            replyMessage.Reset(br1->Get<MessagePool>().Allocate(Message::kTypeIp4));
+            VerifyOrQuit(replyMessage != nullptr);
+
+            Ip4::Header ip4Header;
+            ip4Header.Clear();
+            ip4Header.SetVersionIhl(0x45);
+            ip4Header.SetTotalLength(sizeof(Ip4::Header) + sizeof(Ip4::Icmp4Header) + payloadLen);
+            ip4Header.SetProtocol(Ip4::kProtoIcmp);
+            ip4Header.SetTtl(64);
+            ip4Header.SetSource(ip4Headers.GetDestinationAddress());
+            ip4Header.SetDestination(ip4Headers.GetSourceAddress());
+            SuccessOrQuit(replyMessage->Append(ip4Header));
+
+            Ip4::Icmp4Header replyIcmpHeader;
+            replyIcmpHeader.Clear();
+            replyIcmpHeader.SetType(Ip4::Icmp4Header::kTypeEchoReply);
+            replyIcmpHeader.SetRestOfHeader(icmpHeader.GetRestOfHeader());
+            SuccessOrQuit(replyMessage->Append(replyIcmpHeader));
+
+            // Append the rest of the ICMP request (Id, Sequence, and Payload)
+            SuccessOrQuit(replyMessage->AppendBytesFromMessage(*message, sizeof(Ip4::Header) + sizeof(Ip4::Icmp4Header),
+                                                               payloadLen));
+
+            replyMessage->SetOffset(sizeof(Ip4::Header));
+            Checksum::UpdateMessageChecksum(*replyMessage, ip4Header.GetSource(), ip4Header.GetDestination(),
+                                            ip4Header.GetProtocol());
+            replyMessage->SetOffset(0);
+
+            Checksum::UpdateIp4HeaderChecksum(ip4Header);
+            replyMessage->Write(0, ip4Header);
+
+            SuccessOrQuit(br1->Get<Nat64::Translator>().SendMessage(replyMessage.PassOwnership()));
+        }
+    }
+}
+
+void Test_1_4_PIC_TC_4(void)
+{
+    /**
+     * 10.4 IPv4 Connectivity using BR built-in NAT64
+     * 10.4.1. Purpose
+     *   - To verify that BR DUT:
+     *     - Offers IPv4 internet connectivity to/from Thread Devices using its built-in NAT64 translator, when the
+     *       infrastructure network does not provide a NAT64 translator.
+     *     - Offers IPv4 local network connectivity to/from Thread Devices using its built-in NAT64 translator
+     *     - Operates DNS recursive resolver to look up IPv4 server addresses
+     *     - Withdraws its own NAT64 prefix when another Border Router advertises a preferred NAT64 prefix. TBD - to
+     *       be added.
+     *   - Note: the present test requires a /96 NAT64 prefix, which is stricter than what the specification requires.
+     *     RFC 6052 (Section 2.2) allows other lengths as well; however the present test case is not yet written to
+     *     support all these different options.
+     * 10.4.2. Topology
+     *   - BR_1 (DUT) - Border Router
+     *   - Router_1 - Thread Router Reference Device
+     *   - ED_1 - Thread Reference Device, End Device (e.g. FED/REED) role
+     *   - Eth_1 - Adjacent Infrastructure Link Linux Reference Device on AIL
+     *     - Has HTTP web server with resource ‘/test2.html’.
+     *   - Eth_2 - Adjacent Infrastructure Link SPIFF Reference Device on AIL
+     *     - Has HTTP web server on simulated-internet with resource ‘/testv4.html’
+     */
+
+    Core nexus;
+
+    Node &br1  = nexus.CreateNode();
+    Node &r1   = nexus.CreateNode();
+    Node &ed1  = nexus.CreateNode();
+    Node &eth2 = nexus.CreateNode();
+
+    br1.SetName("BR_1");
+    r1.SetName("ROUTER_1");
+    ed1.SetName("ED_1");
+    eth2.SetName("ETH_2");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("Step 1: Enable Eth_1 and Eth_2");
+    /**
+     * Step 1
+     *   - Device: Eth_1, Eth_2
+     *   - Description (PIC-10.4): Enable Harness configures Eth_1 with IPv4 address 192.168.1.4.
+     *   - Pass Criteria: N/A
+     */
+    eth2.mInfraIf.SetIsDnsServer(true);
+    eth2.mInfraIf.SetUdpHook(HandleEth2Udp);
+
+    Ip6::Prefix eth2Prefix;
+    SuccessOrQuit(eth2Prefix.FromString(kEth2Prefix));
+    eth2.mInfraIf.StartRouterAdvertisement(eth2Prefix);
+
+    Log("Step 2: Eth_2 default configuration");
+    /**
+     * Step 2
+     *   - Device: Eth_2
+     *   - Description (PIC-10.4): Harness instructs device to: Enable DHCP IPv4 server.
+     *   - Pass Criteria: N/A
+     */
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 3: Enable BR_1, Router_1, ED_1");
+    /**
+     * Step 3
+     *   - Device: BR_1 (DUT), Router_1, ED_1
+     *   - Description (PIC-10.4): Enable
+     *   - Pass Criteria: N/A
+     */
+
+    AllowLinkBetween(br1, r1);
+    AllowLinkBetween(r1, ed1);
+
+    br1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br1.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+
+    SuccessOrQuit(br1.Get<Dns::ServiceDiscovery::Server>().Start());
+    br1.Get<Dns::ServiceDiscovery::Server>().SetUpstreamQueryEnabled(true);
+
+    r1.Join(br1);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    ed1.Join(r1, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    Log("Step 4: BR_1 configures IPv4 address and NAT64 prefix");
+    /**
+     * Step 4
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.4): Automatically configures an IPv4 address on the AIL; and identifies the IPv4 gateway
+     *     on Eth_2. Automatically configures a NAT64 prefix in the Thread Network Data. Note: the check for length 96
+     *     is not due to it being required by, but because the current test script is only designed to handle this
+     *     case. Other cases are TBD. See note in Section 10.4.1 above.
+     *   - Pass Criteria:
+     *     - The DUT MUST add one NAT64 prefix in the Thread Network Data, which is a route entry published as follows:
+     *       - Prefix TLV
+     *         - Prefix Length field MUST be 96.
+     */
+    Ip4::Cidr nat64Cidr;
+    SuccessOrQuit(nat64Cidr.FromString(kNat64Cidr));
+
+    br1.Get<Ip6::Ip6>().SetNat64ReceiveIp4Callback(HandleIp4Receive, &br1);
+    SuccessOrQuit(br1.Get<Nat64::Translator>().SetIp4Cidr(nat64Cidr));
+    br1.Get<BorderRouter::RoutingManager>().SetNat64PrefixManagerEnabled(true);
+
+    nexus.AdvanceTime(kBrActionTime);
+
+    Ip6::Prefix nat64Prefix;
+    SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().GetNat64Prefix(nat64Prefix));
+
+    br1.Get<Nat64::Translator>().SetNat64Prefix(nat64Prefix);
+    br1.Get<Nat64::Translator>().SetEnabled(true);
+
+    Log("Step 5: ED_1 performs DNS query for threadv4.org");
+    /**
+     * Step 5
+     *   - Device: ED_1
+     *   - Description (PIC-10.4): Harness instructs device to perform DNS query QType=ANY, name “threadv4.org”.
+     *     Automatically, the DNS query gets routed to the DUT.
+     *   - Pass Criteria: N/A
+     */
+    Ip6::Address dnsServerAddr;
+    dnsServerAddr = br1.Get<Mle::Mle>().GetMeshLocalEid();
+
+    Ip6::Udp::Socket dnsSocket(ed1, nullptr, nullptr);
+    SuccessOrQuit(dnsSocket.Open(Ip6::kNetifThreadInternal));
+
+    Message *dnsQuery = dnsSocket.NewMessage();
+    VerifyOrQuit(dnsQuery != nullptr);
+
+    Dns::Header dnsHeader;
+    dnsHeader.SetType(Dns::Header::kTypeQuery);
+    dnsHeader.SetQuestionCount(1);
+    dnsHeader.SetRecursionDesiredFlag();
+    SuccessOrQuit(dnsQuery->Append(dnsHeader));
+    SuccessOrQuit(Dns::Name::AppendName("threadv4.org", *dnsQuery));
+    Dns::Question dnsQuestion(Dns::ResourceRecord::kTypeAny);
+    SuccessOrQuit(dnsQuery->Append(dnsQuestion));
+
+    Ip6::MessageInfo dnsMessageInfo;
+    dnsMessageInfo.SetPeerAddr(dnsServerAddr);
+    dnsMessageInfo.SetPeerPort(kDnsPort);
+    SuccessOrQuit(dnsSocket.SendTo(*dnsQuery, dnsMessageInfo));
+    SuccessOrQuit(dnsSocket.Close());
+
+    Log("Step 6: BR_1 processes DNS query upstream");
+    /**
+     * Step 6
+     *   - Device: BR_1 (DUT)
+     *   - Description (PIC-10.4): Automatically processes the DNS query by requesting upstream to the Eth_2 DNS server.
+     *     Then, it responds back with the answer to ED_1.
+     *   - Pass Criteria: N/A
+     */
+    nexus.AdvanceTime(kDnsTime);
+
+    Log("Step 7: ED_1 receives DNS result");
+    /**
+     * Step 7
+     *   - Device: ED_1
+     *   - Description (PIC-10.4): Successfully receives DNS query result.
+     *   - Pass Criteria:
+     *     - ED_1 MUST receive DNS query answer from DUT:
+     *       - threadv4.org A 38.106.217.165
+     */
+
+    Log("Step 8: ED_1 pings internet server");
+    /**
+     * Step 8
+     *   - Device: ED_1
+     *   - Description (PIC-10.4): Harness instructs device to send ICMPv6 ping request to internet server, using
+     *     synthesized IPv6 address: <NAT64-prefix>:266a:d9a5 Request is routed via the DUT to the Eth_2 simulated
+     *     network. The DUT translates the above IPv6 address into the IPv4 destination address 38.106.217.165.
+     *   - Pass Criteria:
+     *     - ED_1 MUST receive ICMPv6 ping response from simulated server.
+     */
+    Ip4::Address internetServerIp4;
+    SuccessOrQuit(internetServerIp4.FromString(kInternetServerIp4Addr));
+    Ip6::Address internetServerIp6;
+    internetServerIp6.SynthesizeFromIp4Address(nat64Prefix, internetServerIp4);
+
+    ed1.SendEchoRequest(internetServerIp6, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 8a: ED_1 sends UDP ping to internet server");
+    /**
+     * Step 8a
+     *   - Device: ED_1
+     *   - Description (PIC-10.4): Repeats same using a UDP ping.
+     *   - Pass Criteria:
+     *     - ED_1 MUST receive UDP ping response from simulated server.
+     */
+    {
+        Ip6::Udp::Socket udpSocket(ed1, nullptr, nullptr);
+        SuccessOrQuit(udpSocket.Open(Ip6::kNetifThreadInternal));
+        Message *udpMsg = udpSocket.NewMessage();
+        VerifyOrQuit(udpMsg != nullptr);
+        SuccessOrQuit(udpMsg->SetLength(kEchoPayloadSize));
+        Ip6::MessageInfo udpInfo;
+        udpInfo.SetPeerAddr(internetServerIp6);
+        udpInfo.SetPeerPort(12345);
+        SuccessOrQuit(udpSocket.SendTo(*udpMsg, udpInfo));
+        SuccessOrQuit(udpSocket.Close());
+    }
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 9: ED_1 sends HTTP GET to internet server");
+    /**
+     * Step 9
+     *   - Device: ED_1
+     *   - Description (PIC-10.4): Harness instructs device to send HTTP GET request to server. If ED_1 is a Linux
+     *     device, it may use 'curl' to save the file: curl -o testv4.html http://threadv4.org/testv4.html In case ED_1
+     *     is a non-Linux Thread CLI device, it can use the OT CLI commands to send the HTTP GET request: tcp init
+     *     tcp connect <NAT64-prefix>:266a:d9a5 80 tcp send -x 474554202F74... tcp sendend tcp deinit
+     *   - Pass Criteria:
+     *     - In case Linux 'curl' was used: File testv4.html MUST equal the testv4.html file as stored on the simulated
+     *       server on Eth_2.
+     *     - In case OT CLI was used: Output on the CLI MUST be the HTML file testv4.html as stored on the simulator
+     *       server on Eth_2.
+     */
+    {
+        Ip6::Tcp::Endpoint          tcpEndpoint;
+        otTcpEndpointInitializeArgs args;
+        ClearAllBytes(args);
+        SuccessOrQuit(tcpEndpoint.Initialize(ed1, args));
+        Ip6::SockAddr internetSockAddr;
+        internetSockAddr.SetAddress(internetServerIp6);
+        internetSockAddr.SetPort(kHttpPort);
+        SuccessOrQuit(tcpEndpoint.Connect(internetSockAddr, 0));
+        nexus.AdvanceTime(kTcpTime);
+
+        const char     httpGet[] = "GET /testv4.html HTTP/1.1\r\nHost: threadv4.org\r\n\r\n";
+        otLinkedBuffer linkedBuffer;
+        ClearAllBytes(linkedBuffer);
+        linkedBuffer.mData   = reinterpret_cast<const uint8_t *>(httpGet);
+        linkedBuffer.mLength = sizeof(httpGet) - 1;
+        SuccessOrQuit(tcpEndpoint.SendByReference(linkedBuffer, 0));
+        nexus.AdvanceTime(kTcpTime);
+        SuccessOrQuit(tcpEndpoint.Deinitialize());
+    }
+
+    Log("Step 10: ED_1 pings local server Eth_1");
+    /**
+     * Step 10
+     *   - Device: ED_1
+     *   - Description (PIC-10.4): Harness instructs device to send ping request to local IPv4 server Eth_1 which has
+     *     IPv4 destination address 192.168.1.4; using synthesized IPv6 address: <NAT64-prefix>:c0a8:0104 Automatically,
+     *     it is routed via BR_1 DUT to Eth_1.
+     *   - Pass Criteria:
+     *     - ED_1 MUST receive ping response from Eth_1.
+     */
+    Ip4::Address localServerIp4;
+    SuccessOrQuit(localServerIp4.FromString(kLocalServerIp4Addr));
+    Ip6::Address localServerIp6;
+    localServerIp6.SynthesizeFromIp4Address(nat64Prefix, localServerIp4);
+
+    ed1.SendEchoRequest(localServerIp6, kEchoIdentifier, kEchoPayloadSize);
+    nexus.AdvanceTime(kPingTime);
+
+    Log("Step 11: ED_1 sends HTTP GET to local server Eth_1");
+    /**
+     * Step 11
+     *   - Device: ED_1
+     *   - Description (PIC-10.4): Harness instructs device to send HTTP GET request to local server Eth_1. In case ED_1
+     *     is a Linux device, it can use 'curl' to save the file locally: curl -o test2.html http:///test2.html In case
+     *     ED_1 is a non-Linux Thread CLI device, it can use the OT CLI commands to send the HTTP GET request: tcp init
+     *     tcp connect <NAT64-prefix>:c0a8:0104 80 tcp send -x 474554202F... tcp sendend tcp deinit
+     *   - Pass Criteria:
+     *     - In case Linux 'curl' was used: File test2.html MUST equal the test2.html file as stored on the Eth_1
+     *       server.
+     *     - In case OT CLI was used: Output on the CLI MUST be the HTML file test2.html as stored on the server on
+     *       Eth_1.
+     */
+    {
+        Ip6::Tcp::Endpoint          tcpEndpoint;
+        otTcpEndpointInitializeArgs args;
+        ClearAllBytes(args);
+        SuccessOrQuit(tcpEndpoint.Initialize(ed1, args));
+        Ip6::SockAddr localSockAddr;
+        localSockAddr.SetAddress(localServerIp6);
+        localSockAddr.SetPort(kHttpPort);
+        SuccessOrQuit(tcpEndpoint.Connect(localSockAddr, 0));
+        nexus.AdvanceTime(kTcpTime);
+
+        const char     httpGet[] = "GET /test2.html HTTP/1.1\r\nHost: localv4.org\r\n\r\n";
+        otLinkedBuffer linkedBuffer;
+        ClearAllBytes(linkedBuffer);
+        linkedBuffer.mData   = reinterpret_cast<const uint8_t *>(httpGet);
+        linkedBuffer.mLength = sizeof(httpGet) - 1;
+        SuccessOrQuit(tcpEndpoint.SendByReference(linkedBuffer, 0));
+        nexus.AdvanceTime(kTcpTime);
+        SuccessOrQuit(tcpEndpoint.Deinitialize());
+    }
+
+    nexus.AddTestVar("ETH_2_ADDR", eth2.mInfraIf.GetLinkLocalAddress().ToString().AsCString());
+    nexus.AddTestVar("INTERNET_SERVER_ADDR", internetServerIp6.ToString().AsCString());
+    nexus.AddTestVar("LOCAL_SERVER_ADDR", localServerIp6.ToString().AsCString());
+    nexus.AddTestVar("NAT64_PREFIX", nat64Prefix.ToString().AsCString());
+
+    nexus.SaveTestInfo("test_1_4_PIC_TC_4.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_4_PIC_TC_4();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_TREL_TC_1.cpp b/tests/nexus/test_1_4_TREL_TC_1.cpp
new file mode 100644
index 0000000..52a291e
--- /dev/null
+++ b/tests/nexus/test_1_4_TREL_TC_1.cpp
@@ -0,0 +1,212 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * 8.1. [1.4] [CERT] Attach and connectivity (unicast/broadcast) between multi-radio and single-radio devices
+ *
+ * 8.1.1. Purpose
+ * To test the following:
+ *   1. Attaching Thread Devices with different radio links.
+ *   2. Verifies the detection of radio link types supported by neighbors/children.
+ *   3. Verifies connectivity between nodes (unicast and broadcast frame exchange with multi-radio support).
+ *
+ * 8.1.2. Topology
+ * - BR Leader (DUT) - Support multi-radio (15.4 and TREL).
+ * - Router_1 - Reference device that supports multi-radio (15.4 and TREL).
+ * - Router_2 - Reference device that supports 15.4 radio only.
+ *
+ * Spec Reference           | V1.1 Section | V1.3.0 Section
+ * -------------------------|--------------|---------------
+ * TREL / Multi-radio Links | N/A          | 8.1
+ */
+
+#if OPENTHREAD_CONFIG_MULTI_RADIO
+
+static constexpr uint32_t kFormNetworkTime     = 13 * 1000;
+static constexpr uint32_t kAttachToRouterTime  = 200 * 1000;
+static constexpr uint32_t kStabilizationTime   = 30 * 1000;
+static constexpr uint32_t kInfraIfIndex        = 1;
+static constexpr uint16_t kPingPayloadSize     = 500;
+static constexpr uint32_t kPingResponseTimeout = 10000;
+
+void Test1_4_Trel_Tc_1(void)
+{
+    Core  nexus;
+    Node &br      = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &router2 = nexus.CreateNode();
+
+    br.SetName("BR_DUT");
+    router1.SetName("ROUTER_1");
+    router2.SetName("ROUTER_2");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    /**
+     * Step 1
+     * - Device: BR (DUT), Router_1, Router_2
+     * - Description (TREL-8.1): Form the topology. Wait for Router_1 and Router_2 to become routers. Note: Router_1
+     *   and Router_2 MUST NOT be able to communicate directly and not be in each other’s neighbor table (e.g. this
+     *   may be realized by using the allow/deny list mechanism).
+     * - Pass Criteria:
+     *   - Verify that topology is formed.
+     *   - The DUT MUST be the network Leader.
+     *   - Router_1 MUST only see Leader as its immediate neighbor (Router_2 MUST not be present in Router_1’s
+     *     neighbor table).
+     *   - Router_2 MUST only see Leader as its immediate neighbor and not Router_1.
+     */
+    Log("Step 1: Form the topology");
+
+    SuccessOrQuit(br.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    SuccessOrQuit(router1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    AllowLinkBetween(br, router1);
+    AllowLinkBetween(br, router2);
+
+    br.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(br.Get<Mle::Mle>().IsLeader());
+
+    router1.Join(br);
+    router2.Join(br);
+    nexus.AdvanceTime(kAttachToRouterTime);
+
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router2.Get<Mle::Mle>().IsRouter());
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    // Verify neighbor tables
+    VerifyOrQuit(router1.Get<NeighborTable>().FindNeighbor(br.Get<Mac::Mac>().GetExtAddress()) != nullptr);
+    VerifyOrQuit(router1.Get<NeighborTable>().FindNeighbor(router2.Get<Mac::Mac>().GetExtAddress()) == nullptr);
+    VerifyOrQuit(router2.Get<NeighborTable>().FindNeighbor(br.Get<Mac::Mac>().GetExtAddress()) != nullptr);
+    VerifyOrQuit(router2.Get<NeighborTable>().FindNeighbor(router1.Get<Mac::Mac>().GetExtAddress()) == nullptr);
+
+    /**
+     * Step 2
+     * - Device: Router_1
+     * - Description (TREL-8.1): Harness instructs device to report its neighbor multi-radio info (which indicates
+     *   which radio links are detected per neighbor/parent)
+     * - Pass Criteria:
+     *   - Router_1 MUST have correctly detected that the DUT supports both TREL and 15.4 radios (multi-radio neighbor
+     *     info).
+     *   - The DUT MUST send TREL frames using the correct format
+     */
+    Log("Step 2: Router_1 reports its neighbor multi-radio info");
+
+    {
+        const Neighbor *neighbor = router1.Get<NeighborTable>().FindNeighbor(br.Get<Mac::Mac>().GetExtAddress());
+
+        VerifyOrQuit(neighbor != nullptr);
+        VerifyOrQuit(neighbor->GetSupportedRadioTypes().Contains(Mac::kRadioTypeIeee802154));
+        VerifyOrQuit(neighbor->GetSupportedRadioTypes().Contains(Mac::kRadioTypeTrel));
+    }
+
+    /**
+     * Step 3
+     * - Device: Router_1
+     * - Description (TREL-8.1): Harness instructs device to send a ping (of size 500 bytes or more) to the DUT using
+     *   the link-local IPv6 address as the destination. Note: The link-local IPv6 address may be derived from its
+     *   extended address by retrieving neighbor info on Router_1 (at step 2). A larger ping size is used to verify
+     *   behavior with a larger MTU payload on TREL.
+     * - Pass Criteria:
+     *   - The DUT MUST send a ping response that is successfully received by Router_1.
+     *   - The ping response packet MUST NOT be present in the 802.15.4 capture. (I.e. travels over TREL link.)
+     *   - The TREL frames sent by DUT MUST use the correct TREL format.
+     */
+    Log("Step 3: Router_1 pings the DUT using link-local address");
+    nexus.SendAndVerifyEchoRequest(router1, br.Get<Mle::Mle>().GetLinkLocalAddress(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    /**
+     * Step 4
+     * - Device: Router_2
+     * - Description (TREL-8.1): Harness instructs device to send a ping (of size 500 bytes or more) to the DUT using
+     *   the link-local IPv6 address as the destination.
+     * - Pass Criteria:
+     *   - The DUT MUST send a ping response that is successfully received by Router_2.
+     */
+    Log("Step 4: Router_2 pings the DUT using link-local address");
+    nexus.SendAndVerifyEchoRequest(router2, br.Get<Mle::Mle>().GetLinkLocalAddress(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    /**
+     * Step 5
+     * - Device: Router_1
+     * - Description (TREL-8.1): Harness instructs device to send a ping (of size 500 bytes or more) to Router_2 using
+     *   mesh-local EID of Router_2 as the destination. Note: this step indirectly verifies the broadcast
+     *   communication by performing address query/resolution.
+     * - Pass Criteria:
+     *   - The DUT MUST forward a ping response that is successfully received by Router_1
+     *   - The TREL frames sent by DUT MUST use the correct TREL frame format.
+     */
+    Log("Step 5: Router_1 pings Router_2 using mesh-local EID");
+    nexus.SendAndVerifyEchoRequest(router1, router2.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    /**
+     * Step 6
+     * - Device: Router_2
+     * - Description (TREL-8.1): Harness instructs device to send a ping (of size 500 bytes or more) to Router_1 using
+     *   mesh-local EID of Router_1 as destination.
+     * - Pass Criteria:
+     *   - The DUT MUST forward a ping response that is successfully received by Router_2.
+     *   - TREL frames sent by DUT MUST use the correct TREL format.
+     */
+    Log("Step 6: Router_2 pings Router_1 using mesh-local EID");
+    nexus.SendAndVerifyEchoRequest(router2, router1.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    nexus.SaveTestInfo("test_1_4_TREL_TC_1.json");
+}
+
+#endif // OPENTHREAD_CONFIG_MULTI_RADIO
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+#if OPENTHREAD_CONFIG_MULTI_RADIO
+    ot::Nexus::Test1_4_Trel_Tc_1();
+    printf("All tests passed\n");
+#else
+    printf("Multi-radio is not enabled - test skipped\n");
+#endif
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_TREL_TC_2.cpp b/tests/nexus/test_1_4_TREL_TC_2.cpp
new file mode 100644
index 0000000..c23b465
--- /dev/null
+++ b/tests/nexus/test_1_4_TREL_TC_2.cpp
@@ -0,0 +1,275 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+#if OPENTHREAD_CONFIG_MULTI_RADIO
+
+static constexpr uint32_t kFormNetworkTime     = 13 * 1000;
+static constexpr uint32_t kAttachToRouterTime  = 150 * 1000;
+static constexpr uint32_t kStabilizationTime   = 60 * 1000;
+static constexpr uint32_t kInfraIfIndex        = 1;
+static constexpr uint16_t kPingPayloadSize     = 500;
+static constexpr uint32_t kPingResponseTimeout = 10000;
+
+void Test_1_4_TREL_TC_2(void)
+{
+    /**
+     * 8.2. [1.4] [CERT] Multi-hop routing with device (in the path) with different radio types and MTUs
+     *
+     * 8.2.1. Purpose
+     * This test covers the use of 6LoWPAN “Mesh Header” messages (messages sent over multi-hop) when
+     * underlying routes (in the path) can support different radio link types with different frame MTU sizes.
+     * Different types of devices (15.4, TREL, Router, End Device) are connected to the DUT.
+     *
+     * 8.2.2. Topology
+     * - BR Router_1 (DUT) - Border Router DUT that supports multi-radio (TREL and 15.4)
+     * - Leader - Reference device that supports multi-radio (TREL and 15.4).
+     * - Router_2 - Reference device that supports 15.4 only
+     * - ED_1 - Reference end device that supports 15.4 only
+     * - Router_3 - Reference device that supports 15.4 only
+     * - Router_4 - Reference device that supports multi-radio (TREL and 15.4).
+     *
+     * Spec Reference           | V1.1 Section | V1.3.0 Section
+     * -------------------------|--------------|---------------
+     * TREL / Multi-radio Links | N/A          | 8.2
+     */
+
+    Core  nexus;
+    Node &router1 = nexus.CreateNode(); // DUT
+    Node &leader  = nexus.CreateNode();
+    Node &router2 = nexus.CreateNode();
+    Node &ed1     = nexus.CreateNode();
+    Node &router3 = nexus.CreateNode();
+    Node &router4 = nexus.CreateNode();
+
+    router1.SetName("ROUTER_1");
+    leader.SetName("LEADER");
+    router2.SetName("ROUTER_2");
+    ed1.SetName("ED_1");
+    router3.SetName("ROUTER_3");
+    router4.SetName("ROUTER_4");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    /**
+     * Step 1
+     * - Device: Leader, Router_1 (DUT), Router_2, ED_1, Router_3, Router_4
+     * - Description (TREL-8.2): Form the topology. Wait for Router_1, Router_2, Router_3 and Router_4 to become
+     *   routers. Note: any devices not connected by line in the topology figure MUST NOT be able to communicate
+     *   directly via a Thread Link or TREL link and not be in each other’s neighbor table (e.g. this may be realized
+     *   by using the allow/deny list mechanism). ED_1 MUST be configured as a non-sleepy MTD child (mode `rn`). Note:
+     *   ED_1 MUST attach to the Router_1 (DUT) as its parent and not the other routers (e.g., can be realized by using
+     *   denylist on ED_1). Note: The requirement for ED_1 to attach as a non-sleepy MTD child to Router_1 (DUT) is
+     *   important to ensure messages from ED_1 are sent to its parent as is without mesh header encapsulation, to
+     *   then be forwarded within the mesh and that the fragmentation and adding of 6LoWPAN “mesh header” is performed
+     *   by Router_1 (DUT) - which is being verified in the next step.
+     * - Pass Criteria:
+     *   - Verify that topology is formed.
+     *   - Leader MUST see both the DUT and Router_2 as its immediate neighbors.
+     *   - Router_2 MUST only see Leader as its immediate neighbor and not the DUT.
+     *   - ED_1 MUST see the DUT as its parent.
+     */
+    Log("Step 1: Form the topology");
+
+    // Leader <-> Router_1 (DUT)
+    AllowLinkBetween(leader, router1);
+
+    // Leader <-> Router_2
+    AllowLinkBetween(leader, router2);
+
+    // Router_1 (DUT) <-> Router_3
+    AllowLinkBetween(router1, router3);
+
+    // Router_1 (DUT) <-> Router_4
+    AllowLinkBetween(router1, router4);
+
+    // Router_1 (DUT) <-> ED_1
+    AllowLinkBetween(router1, ed1);
+
+    // Enable TREL (mDNS) on all TREL nodes.
+    SuccessOrQuit(router1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    SuccessOrQuit(leader.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    SuccessOrQuit(router4.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router1.Join(leader);
+    router2.Join(leader);
+    router3.Join(leader);
+    router4.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+
+    ed1.Join(router1, Node::kAsFed);
+    nexus.AdvanceTime(kAttachToRouterTime);
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router2.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router3.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router4.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(ed1.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(ed1.Get<Mle::Mle>().GetParent().GetExtAddress() == router1.Get<Mac::Mac>().GetExtAddress());
+
+    // Verify neighbors
+    {
+        const Neighbor *neighbor = leader.Get<NeighborTable>().FindNeighbor(router1.Get<Mac::Mac>().GetExtAddress());
+        VerifyOrQuit(neighbor != nullptr);
+        VerifyOrQuit(neighbor->GetSupportedRadioTypes().Contains(Mac::kRadioTypeTrel));
+    }
+    {
+        const Neighbor *neighbor = router1.Get<NeighborTable>().FindNeighbor(router4.Get<Mac::Mac>().GetExtAddress());
+        VerifyOrQuit(neighbor != nullptr);
+        VerifyOrQuit(neighbor->GetSupportedRadioTypes().Contains(Mac::kRadioTypeTrel));
+    }
+    VerifyOrQuit(leader.Get<NeighborTable>().FindNeighbor(router2.Get<Mac::Mac>().GetExtAddress()) != nullptr);
+    VerifyOrQuit(router2.Get<NeighborTable>().FindNeighbor(leader.Get<Mac::Mac>().GetExtAddress()) != nullptr);
+    VerifyOrQuit(router2.Get<NeighborTable>().FindNeighbor(router1.Get<Mac::Mac>().GetExtAddress()) == nullptr);
+
+    /**
+     * Step 2
+     * - Device: ED_1
+     * - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more to
+     *   destination Router_2 using the ML-EID address of Router_2 as the destination address. Note: this verifies the
+     *   DUT behavior of forwarding 6LoWPAN fragmented frames from a Child (incoming over 15.4) over multiple hops.
+     * - Pass Criteria:
+     *   - ED_1 MUST successfully receive a ping reply from Router_2, routed via DUT.
+     *   - TREL UDP frames MUST be used for ping/ping-reply transport between DUT and Leader, and MUST be correct TREL
+     *     frames.
+     */
+    Log("Step 2: ED_1 pings Router_2 ML-EID");
+    nexus.SendAndVerifyEchoRequest(ed1, router2.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    /**
+     * Step 3
+     * - Device: Router_2
+     * - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+     *   Router_2 to ED_1 using the ML-EID address of ED_1 as the destination address.
+     * - Pass Criteria:
+     *   - Router_2 MUST successfully receive a ping reply from ED_1, routed via the DUT.
+     *   - TREL UDP frames MUST be used for ping/ping-reply transport between DUT and Leader, and MUST be correct TREL
+     *     frames.
+     */
+    Log("Step 3: Router_2 pings ED_1 ML-EID");
+    nexus.SendAndVerifyEchoRequest(router2, ed1.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    /**
+     * Step 4
+     * - Device: Router_4
+     * - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+     *   Router_4 to destination Router_2 using the ML-EID address of Router_2 as the destination address. Warning: see
+     *   TESTPLAN-626 for an open issue (TBD) in this step. Note: this intends to verify the DUT behavior of
+     *   forwarding 6LoWPAN “Mesh Header” messages, incoming over TREL, over multi-hop routes.
+     * - Pass Criteria:
+     *   - Router_4 MUST successfully receive a ping reply from Router_2, routed via the DUT.
+     *   - TREL UDP frames MUST be used for ping/ping-reply transport between DUT and Leader, and MUST be correct TREL
+     *     frames.
+     *   - TREL UDP frames MUST be used for ping/ping-reply transport between the DUT and Router_4, and MUST be correct
+     *     TREL frames.
+     */
+    Log("Step 4: Router_4 pings Router_2 ML-EID");
+    nexus.SendAndVerifyEchoRequest(router4, router2.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    /**
+     * Step 5
+     * - Device: Router_2
+     * - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+     *   Router_2 to Router_4 using the ML-EID address of Router_4 as the destination address.
+     * - Pass Criteria:
+     *   - Router_2 MUST successfully receive a ping reply from Router_4, routed via the DUT.
+     *   - TREL UDP frames MUST be used for ping/ping-reply transport between DUT and Leader, and MUST be correct TREL
+     *     frames.
+     *   - TREL UDP frames MUST be used for ping/ping-reply transport between the DUT and Router_4, and MUST be correct
+     *     TREL frames.
+     */
+    Log("Step 5: Router_2 pings Router_4 ML-EID");
+    nexus.SendAndVerifyEchoRequest(router2, router4.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    /**
+     * Step 6
+     * - Device: Router_3
+     * - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+     *   Router_3 to destination Router_2 using the ML-EID address of Router_2 as the destination address. Note: this
+     *   verifies the DUT behavior of forwarding 6LoWPAN “Mesh Header” messages, incoming over 15.4, over multi-hop
+     *   routes.
+     * - Pass Criteria:
+     *   - Router_3 MUST successfully receive a ping reply from Router_2, routed via the DUT.
+     *   - TREL UDP frames MUST be used for ping/ping-reply transport between the DUT and Leader, and MUST be correct
+     *     TREL frames.
+     */
+    Log("Step 6: Router_3 pings Router_2 ML-EID");
+    nexus.SendAndVerifyEchoRequest(router3, router2.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    /**
+     * Step 7
+     * - Device: Router_2
+     * - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+     *   Router_2 to Router_3 using the ML-EID address of Router_3 as the destination address.
+     * - Pass Criteria:
+     *   - Router_2 MUST successfully receive a ping reply from Router_3, routed via the DUT.
+     *   - TREL UDP frames MUST be used for ping/ping-reply transport between the DUT and Leader, and MUST be correct
+     *     TREL frames.
+     */
+    Log("Step 7: Router_2 pings Router_3 ML-EID");
+    nexus.SendAndVerifyEchoRequest(router2, router3.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                   Ip6::kDefaultHopLimit, kPingResponseTimeout);
+
+    nexus.SaveTestInfo("test_1_4_TREL_TC_2.json");
+}
+
+#endif // OPENTHREAD_CONFIG_MULTI_RADIO
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+#if OPENTHREAD_CONFIG_MULTI_RADIO
+    ot::Nexus::Test_1_4_TREL_TC_2();
+    printf("All tests passed\n");
+#else
+    printf("Multi-radio is not enabled - test skipped\n");
+#endif
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_TREL_TC_3.cpp b/tests/nexus/test_1_4_TREL_TC_3.cpp
new file mode 100644
index 0000000..45ef4a3
--- /dev/null
+++ b/tests/nexus/test_1_4_TREL_TC_3.cpp
@@ -0,0 +1,290 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+#if OPENTHREAD_CONFIG_MULTI_RADIO
+
+static constexpr uint32_t kFormNetworkTime      = 13 * 1000;
+static constexpr uint32_t kAttachToRouterTime   = 150 * 1000;
+static constexpr uint32_t kStabilizationTime    = 60 * 1000;
+static constexpr uint32_t kInfraIfIndex         = 1;
+static constexpr uint16_t kPingPayloadSize      = 8;
+static constexpr uint32_t kPingResponseTimeout  = 5000;
+static constexpr uint16_t kStep2PingCount       = 10;
+static constexpr uint16_t kStep4PingCount       = 10;
+static constexpr uint16_t kStep6PingCount       = 5;
+static constexpr uint16_t kStep8UdpCount        = 300;
+static constexpr uint32_t kUdpInterval          = 100;
+static constexpr uint32_t kTrelReenableWaitTime = 5000;
+
+/**
+ * 8.3. [1.4] [CERT] Radio Link (Re)discovery using Probe Mechanism
+ *
+ * 8.3.1. Purpose
+ * This test covers the behavior of the device after TREL connection is temporarily disabled and rediscovery of TREL
+ *   radio using the multi-radio Probe mechanism.
+ *
+ * 8.3.2. Topology
+ * - BR Leader (DUT) - Supports multi-radio (TREL and 15.4)
+ * - Router - Reference device that supports multi-radio (TREL and 15.4).
+ * - ED - Reference End Device that supports 15.4 radio only.
+ *
+ * Spec Reference   | V1.1 Section | V1.3.0 Section
+ * -----------------|--------------|---------------
+ * TREL Radio Links | N/A          | 8.3
+ */
+
+void Test_1_4_TREL_TC_3(void)
+{
+    Core  nexus;
+    Node &br     = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &ed     = nexus.CreateNode();
+
+    br.SetName("BR_DUT");
+    router.SetName("ROUTER");
+    ed.SetName("ED");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    /**
+     * - Step 1
+     *   - Device: BR (DUT), Router
+     *   - Description (TREL-8.3): Form the topology. Wait for Router to become Thread router. ED MUST attach to the
+     *     DUT as its parent (e.g. can be realized using allow/deny list).
+     *   - Pass Criteria:
+     *     - Verify that topology is formed.
+     *     - ED MUST attach to the DUT as its parent.
+     */
+    Log("Step 1: Form the topology. Wait for Router to become Thread router. ED MUST attach to the DUT as its parent.");
+
+    AllowLinkBetween(br, router);
+    AllowLinkBetween(br, ed);
+
+    SuccessOrQuit(br.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    SuccessOrQuit(router.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    br.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(br.Get<Mle::Mle>().IsLeader());
+
+    router.Join(br);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    ed.Join(br, Node::kAsFed);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(ed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(ed.Get<Mle::Mle>().GetParent().GetExtAddress() == br.Get<Mac::Mac>().GetExtAddress());
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    // Ensure TREL peers are discovered before proceeding to Step 2
+    VerifyOrQuit(br.Get<ot::Trel::PeerTable>().GetNumberOfPeers() >= 1);
+    VerifyOrQuit(router.Get<ot::Trel::PeerTable>().GetNumberOfPeers() >= 1);
+
+    /**
+     * - Step 2
+     *   - Device: ED
+     *   - Description (TREL-8.3): Harness instructs device to send 10 pings to Router using its mesh-local IPv6
+     *     address as the destination. Note: this verifies the increase of TREL radio link preference due to its
+     *     successful use to exchange the ping messages.
+     *   - Pass Criteria:
+     *     - The ED MUST receive ping responses.
+     *     - The Router MUST have correctly detected that the DUT supports both TREL and 15.4 radios (from the
+     *       neighbor table entry info).
+     *     - Also TREL radio link MUST be preferred; that is, the preference value associated with TREL MUST be higher
+     *       than or equal to 15.4 radio for the BR (DUT) entry in the Router’s neighbor table multi-radio info.
+     *     - TREL frames sent by the DUT MUST use a correct TREL frame format.
+     */
+    Log("Step 2: Harness instructs device to send 10 pings to Router using its mesh-local IPv6 address.");
+    for (uint16_t i = 0; i < kStep2PingCount; i++)
+    {
+        nexus.SendAndVerifyEchoRequest(ed, router.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                       Ip6::kDefaultHopLimit, kPingResponseTimeout);
+    }
+
+    {
+        Neighbor *neighbor = router.Get<NeighborTable>().FindNeighbor(br.Get<Mac::Mac>().GetExtAddress());
+        VerifyOrQuit(neighbor != nullptr);
+
+        Neighbor::MultiRadioInfo info;
+        neighbor->PopulateMultiRadioInfo(info);
+        VerifyOrQuit(info.mSupportsIeee802154);
+        VerifyOrQuit(info.mSupportsTrelUdp6);
+        VerifyOrQuit(info.mTrelUdp6Info.mPreference >= info.mIeee802154Info.mPreference);
+    }
+
+    /**
+     * - Step 3
+     *   - Device: Router
+     *   - Description (TREL-8.3): Harness disables the TREL connectivity on the Router. Note: This may be realized by
+     *     causing a disconnect on the infrastructure link (e.g. if the infra link is Wi-Fi, the Router device can be
+     *     disconnected from Wi-Fi AP). Alternatively this can be realized by specific APIs added on Router device
+     *     for the purpose of testing. Note: this may also be realized using the "trel filter" CLI command. See link.
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 3: Harness disables the TREL connectivity on the Router.");
+    router.Get<ot::Trel::Interface>().SetFilterEnabled(true);
+    br.Get<ot::Trel::Interface>().SetFilterEnabled(true);
+
+    /**
+     * - Step 4
+     *   - Device: ED
+     *   - Description (TREL-8.3): Harness instructs device to send 10 pings to Router using its mesh-local IPv6
+     *     address as the destination. Note: some of the pings may fail, which is expected behavior. This step tests
+     *     the detection of a disconnect in a TREL link by the DUT.
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 4: Harness instructs device to send 10 pings to Router using its mesh-local IPv6 address.");
+    for (uint16_t i = 0; i < kStep4PingCount; i++)
+    {
+        ed.SendEchoRequest(router.Get<Mle::Mle>().GetMeshLocalEid(), i, kPingPayloadSize);
+        nexus.AdvanceTime(2000);
+    }
+
+    /**
+     * - Step 5
+     *   - Device: Router
+     *   - Description (TREL-8.3): Harness instructs device to report its radio link preference from its neighbor
+     *     table.
+     *   - Pass Criteria:
+     *   - The Router MUST report that the DUT supports both TREL and 15.4 radios
+     *   - The TREL radio link preference MUST be strictly less than the 15.4 radio link preference. (Note: this is a
+     *     deviation from the test specification which requires it to be zero).
+     */
+    Log("Step 5: Harness instructs device to report its radio link preference from its neighbor table.");
+    {
+        Neighbor *neighbor = router.Get<NeighborTable>().FindNeighbor(br.Get<Mac::Mac>().GetExtAddress());
+        VerifyOrQuit(neighbor != nullptr);
+
+        Neighbor::MultiRadioInfo info;
+        neighbor->PopulateMultiRadioInfo(info);
+        VerifyOrQuit(info.mSupportsIeee802154);
+        VerifyOrQuit(info.mSupportsTrelUdp6);
+
+        // Deviation from test spec: relax to strictly less than 15.4 preference
+        VerifyOrQuit(info.mTrelUdp6Info.mPreference < info.mIeee802154Info.mPreference);
+    }
+
+    /**
+     * - Step 6
+     *   - Device: ED
+     *   - Description (TREL-8.3): Harness instructs device to send 5 pings to Router using its mesh-local IPv6
+     *     address as the destination. Note: this step verifies that the DUT correctly falls back to using 15.4
+     *     radio on detection of TREL disconnect.
+     *   - Pass Criteria:
+     *     - The ED MUST receive all Ping responses from the Router successfully.
+     */
+    Log("Step 6: Harness instructs device to send 5 pings to Router using its mesh-local IPv6 address.");
+    for (uint16_t i = 0; i < kStep6PingCount; i++)
+    {
+        nexus.SendAndVerifyEchoRequest(ed, router.Get<Mle::Mle>().GetMeshLocalEid(), kPingPayloadSize,
+                                       Ip6::kDefaultHopLimit, kPingResponseTimeout);
+    }
+
+    /**
+     * - Step 7
+     *   - Device: Router
+     *   - Description (TREL-8.3): Harness re-enables TREL connectivity on the Router.
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 7: Harness re-enables TREL connectivity on the Router.");
+    router.Get<ot::Trel::Interface>().SetFilterEnabled(false);
+    br.Get<ot::Trel::Interface>().SetFilterEnabled(false);
+    nexus.AdvanceTime(kTrelReenableWaitTime);
+
+    /**
+     * - Step 8
+     *   - Device: ED
+     *   - Description (TREL-8.3): Harness instructs device to send 300 UDP messages (small payload) to Router using
+     *     mesh-local IPv6 addresses as source and destination. Note: this step is intended to trigger and verify
+     *     the probe mechanism on the DUT.
+     *   - Pass Criteria:
+     *     - N/A
+     */
+    Log("Step 8: Harness instructs device to send 300 UDP messages (small payload) to Router.");
+    for (uint16_t i = 0; i < kStep8UdpCount; i++)
+    {
+        ed.SendEchoRequest(router.Get<Mle::Mle>().GetMeshLocalEid(), i, kPingPayloadSize);
+        nexus.AdvanceTime(kUdpInterval);
+    }
+
+    /**
+     * - Step 9
+     *   - Device: Router
+     *   - Description (TREL-8.3): Harness instructs device to report its radio link preference from its neighbor
+     *     table.
+     *   - Pass Criteria:
+     *     - The Router MUST report that the DUT supports both TREL and 15.4 radios.
+     *     - The reported TREL radio link preference MUST be greater than zero (again reachable on the TREL radio
+     *       link), and higher or equal to the 15.4 radio link preference.
+     *     - TREL frames sent by the DUT MUST use a correct TREL frame format.
+     */
+    Log("Step 9: Harness instructs device to report its radio link preference from its neighbor table.");
+    {
+        Neighbor *neighbor = router.Get<NeighborTable>().FindNeighbor(br.Get<Mac::Mac>().GetExtAddress());
+        VerifyOrQuit(neighbor != nullptr);
+
+        Neighbor::MultiRadioInfo info;
+        neighbor->PopulateMultiRadioInfo(info);
+        VerifyOrQuit(info.mSupportsIeee802154);
+        VerifyOrQuit(info.mSupportsTrelUdp6);
+        VerifyOrQuit(info.mTrelUdp6Info.mPreference >= info.mIeee802154Info.mPreference);
+    }
+
+    nexus.SaveTestInfo("test_1_4_TREL_TC_3.json");
+}
+
+#endif // OPENTHREAD_CONFIG_MULTI_RADIO
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+#if OPENTHREAD_CONFIG_MULTI_RADIO
+    ot::Nexus::Test_1_4_TREL_TC_3();
+    printf("All tests passed\n");
+#else
+    printf("Multi-radio is not enabled - test skipped\n");
+#endif
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_TREL_TC_4.cpp b/tests/nexus/test_1_4_TREL_TC_4.cpp
new file mode 100644
index 0000000..c015908
--- /dev/null
+++ b/tests/nexus/test_1_4_TREL_TC_4.cpp
@@ -0,0 +1,378 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Infra interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the network to reach steady state.
+ */
+static constexpr uint32_t kSteadyStateTime = 10 * 1000;
+
+/**
+ * Time to advance for a ping to be sent and received.
+ */
+static constexpr uint32_t kPingTime = 1 * 1000;
+
+/**
+ * Number of pings to send for radio link preference detection.
+ */
+static constexpr uint8_t kNumPingsPref = 10;
+
+/**
+ * Number of pings to send for reachability check.
+ */
+static constexpr uint8_t kNumPingsReach = 5;
+
+/**
+ * Number of UDP messages to send to trigger probe.
+ */
+static constexpr uint16_t kNumUdpMessages = 300;
+
+/**
+ * Initial preference value for radio links.
+ */
+static constexpr uint8_t kInitPreference = 200;
+
+void Test_1_4_TREL_TC_4(void)
+{
+    /**
+     * 8.4. [1.4] [CERT] Radio Link (Re)discovery through Receive
+     *
+     * 8.4.1. Purpose
+     * This test covers:
+     * - behavior of BR DUT after TREL connection is temporarily disabled
+     * - rediscovery of TREL radio by receiving messages over the TREL radio link from the neighbor
+     * - using TREL link when the 802.15.4 link becomes unavailable
+     *
+     * 8.4.2. Topology
+     * - BR Leader (DUT) - Support multi-radio (TREL and 15.4)
+     * - Router - Reference device that supports multi-radio (TREL and 15.4).
+     * - ED - Reference device that supports 15.4 radio only.
+     *
+     * Spec Reference                 | V1.1 Section | V1.4 Section
+     * -------------------------------|--------------|-------------
+     * Radio Link (Re)discovery (1.4) | N/A          | 8.4
+     */
+
+    Core nexus;
+
+    Node &br     = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &ed     = nexus.CreateNode();
+
+    br.SetName("BR");
+    router.SetName("ROUTER");
+    ed.SetName("ED");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Form the topology.");
+
+    /**
+     * Step 1
+     * - Device: BR (DUT), Router, ED
+     * - Description (TREL-8.4): Form the topology. Wait for Router and BR (DUT) to become routers.
+     *   ED MUST attach to BR (DUT) as its parent (can be realized using allow/deny list).
+     * - Pass Criteria:
+     *   - Verify that topology is formed.
+     *   - ED MUST attach to the DUT as its parent.
+     */
+
+    AllowLinkBetween(br, router);
+    AllowLinkBetween(br, ed);
+
+    SuccessOrQuit(br.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    SuccessOrQuit(router.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    br.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(br.Get<Mle::Mle>().IsLeader());
+
+    router.Join(br);
+    for (uint32_t i = 0; i < kAttachToRouterTime / 1000; i++)
+    {
+        nexus.AdvanceTime(1000);
+        if (router.Get<Mle::Mle>().IsRouter())
+        {
+            break;
+        }
+    }
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    ed.Join(br, Node::kAsFed);
+    for (uint32_t i = 0; i < kAttachToRouterTime / 1000; i++)
+    {
+        nexus.AdvanceTime(1000);
+        if (ed.Get<Mle::Mle>().IsChild())
+        {
+            break;
+        }
+    }
+    VerifyOrQuit(ed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(ed.Get<Mle::Mle>().GetParent().GetExtAddress() == br.Get<Mac::Mac>().GetExtAddress());
+
+    // Send pings immediately to increase TREL preference before 15.4 reaches max.
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Send 10 pings from Router to ED.");
+
+    /**
+     * Step 2
+     * - Device: Router
+     * - Description (TREL-8.4): Harness instructs device to send 10 pings from Router to ED using its
+     *   mesh-local IPv6 address as the destination. Note: this verifies the increase of TREL radio
+     *   link preference due to its successful use to exchange messages.
+     * - Pass Criteria:
+     *   - The Router MUST receive ping responses from ED.
+     *   - The Router MUST have correctly detected that the DUT supports both TREL and 15.4 radios
+     *     (from the neighbor table entry info).
+     *   - The TREL radio link MUST be preferred, i.e. the preference value associated with TREL
+     *     higher than 15.4 for DUR entry in the Router’s neighbor table.
+     */
+
+    for (uint8_t i = 0; i < kNumPingsPref; i++)
+    {
+        nexus.SendAndVerifyEchoRequest(router, ed.Get<Mle::Mle>().GetMeshLocalEid(), 0, 64, kPingTime);
+    }
+
+    {
+        Neighbor::MultiRadioInfo info;
+        Neighbor                *neighbor;
+
+        neighbor = router.Get<NeighborTable>().FindNeighbor(br.Get<Mac::Mac>().GetExtAddress(),
+                                                            Neighbor::kInStateAnyExceptInvalid);
+        VerifyOrQuit(neighbor != nullptr);
+        neighbor->PopulateMultiRadioInfo(info);
+
+        Log("Preferences for BR: 15.4=%d, TREL=%d", info.mIeee802154Info.mPreference, info.mTrelUdp6Info.mPreference);
+        VerifyOrQuit(info.mSupportsIeee802154);
+        VerifyOrQuit(info.mSupportsTrelUdp6);
+        /**
+         * The TREL radio link MUST be preferred. In simulation, both preferences reach the maximum
+         * value (255) quickly, so we verify that TREL preference is at least as high as 15.4.
+         * When preferences are equal, TREL is preferred by the selection order.
+         */
+        VerifyOrQuit(info.mTrelUdp6Info.mPreference >= info.mIeee802154Info.mPreference);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Disable TREL connectivity on the Router.");
+
+    /**
+     * Step 3
+     * - Device: Router
+     * - Description (TREL-8.4): Harness disables the TREL connectivity on the Router. Note: This may be
+     *   realized by causing a disconnect on the infrastructure link (e.g. if the infra link is Wi-Fi,
+     *   the Router device can be disconnected from Wi-Fi AP). Alternatively this can be realized by
+     *   specific APIs added on device for the purpose of testing.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    router.mTrel.Disable();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Send 10 pings from Router to ED.");
+
+    /**
+     * Step 4
+     * - Device: Router
+     * - Description (TREL-8.4): Harness instructs device to send 10 pings from Router to ED using its
+     *   mesh-local IPv6 address as the destination. Note: this step verifies the detection of a
+     *   disconnect in a TREL radio link.
+     * - Pass Criteria:
+     *   - After the ping transmissions are done, the Router MUST still be aware that the DUT supports
+     *     both TREL and 15.4 radios (from the neighbor table entry info).
+     *   - The TREL radio link preference MUST however be set to zero i.e., the Router MUST detect that
+     *     the Leader (DUT) is no longer reachable on the TREL radio link.
+     */
+
+    for (uint8_t i = 0; i < kNumPingsPref; i++)
+    {
+        router.SendEchoRequest(ed.Get<Mle::Mle>().GetMeshLocalEid());
+        nexus.AdvanceTime(kPingTime);
+    }
+
+    {
+        Neighbor::MultiRadioInfo info;
+        Neighbor                *neighbor;
+
+        neighbor = router.Get<NeighborTable>().FindNeighbor(br.Get<Mac::Mac>().GetExtAddress(),
+                                                            Neighbor::kInStateAnyExceptInvalid);
+        VerifyOrQuit(neighbor != nullptr);
+        neighbor->PopulateMultiRadioInfo(info);
+
+        VerifyOrQuit(info.mSupportsIeee802154);
+        VerifyOrQuit(info.mSupportsTrelUdp6);
+
+        /**
+         * The test specification requires that the TREL radio link preference is set to zero. However,
+         * we relax this criterion here and only verify that it is strictly less than the 15.4 radio
+         * link preference.
+         */
+        VerifyOrQuit(info.mTrelUdp6Info.mPreference < info.mIeee802154Info.mPreference);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Send 5 pings from Router to ED.");
+
+    /**
+     * Step 5
+     * - Device: Router
+     * - Description (TREL-8.4): Harness instructs device to send 5 pings from Router to ED using its
+     *   mesh-local IPv6 address as the destination.
+     * - Pass Criteria:
+     *   - Ping responses from the ED MUST be received successfully by the Router.
+     */
+
+    for (uint8_t i = 0; i < kNumPingsReach; i++)
+    {
+        nexus.SendAndVerifyEchoRequest(router, ed.Get<Mle::Mle>().GetMeshLocalEid(), 0, 64, kPingTime);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Re-enable TREL connectivity on the Router.");
+
+    /**
+     * Step 6
+     * - Device: Router
+     * - Description (TREL-8.4): Harness re-enables TREL connectivity on the Router.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    router.mTrel.mEnabled = true;
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Send 300 UDP messages from Router to ED.");
+
+    /**
+     * Step 7
+     * - Device: Router
+     * - Description (TREL-8.4): Harness instructs device to send 300 UDP messages (few bytes of UDP
+     *   payload) from Router to ED using mesh-local IPv6 addresses as source and destination. Note:
+     *   this step is intended to trigger and verify the probe mechanism on Router. The DUT then
+     *   detects that Router is sending to it over the TREL radio link and increments its preference
+     *   for TREL radio link with every Rx.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    for (uint16_t i = 0; i < kNumUdpMessages; i++)
+    {
+        router.SendEchoRequest(ed.Get<Mle::Mle>().GetMeshLocalEid());
+        nexus.AdvanceTime(10);
+    }
+
+    Log("Wait for TREL preference to recover on DUT");
+    for (int i = 0; i < 30; i++)
+    {
+        Neighbor::MultiRadioInfo info;
+        Neighbor                *neighbor;
+
+        neighbor = br.Get<NeighborTable>().FindNeighbor(router.Get<Mac::Mac>().GetExtAddress(),
+                                                        Neighbor::kInStateAnyExceptInvalid);
+        if (neighbor != nullptr)
+        {
+            neighbor->PopulateMultiRadioInfo(info);
+            if (info.mTrelUdp6Info.mPreference >= kInitPreference)
+            {
+                break;
+            }
+        }
+        nexus.AdvanceTime(1000);
+    }
+
+    nexus.AdvanceTime(kSteadyStateTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 8: Disable 15.4 radio link on Router.");
+
+    /**
+     * Step 8
+     * - Device: Router
+     * - Description (TREL-8.4): Harness disables 15.4 radio link on Router. Note: This may be realized
+     *   by using specific APIs on the Router reference device.
+     * - Pass Criteria:
+     *   - N/A
+     */
+
+    router.Get<Mac::Mac>().SetRadioFilterEnabled(true);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 9: Send 10 pings from ED to Router.");
+
+    /**
+     * Step 9
+     * - Device: ED
+     * - Description (TREL-8.4): Harness instructs device to send 10 pings from ED to Router using
+     *   mesh-local address as destination.
+     * - Pass Criteria:
+     *   - The ED MUST receive ping responses from Router.
+     */
+
+    for (uint8_t i = 0; i < kNumPingsPref; i++)
+    {
+        nexus.SendAndVerifyEchoRequest(ed, router.Get<Mle::Mle>().GetMeshLocalEid(), 0, 64, kPingTime);
+    }
+
+    nexus.SaveTestInfo("test_1_4_TREL_TC_4.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_4_TREL_TC_4();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_TREL_TC_5.cpp b/tests/nexus/test_1_4_TREL_TC_5.cpp
new file mode 100644
index 0000000..52495dd
--- /dev/null
+++ b/tests/nexus/test_1_4_TREL_TC_5.cpp
@@ -0,0 +1,227 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+#if OPENTHREAD_CONFIG_MULTI_RADIO
+
+static constexpr uint32_t kFormNetworkTime     = 35 * 1000;
+static constexpr uint32_t kScanTime            = 10 * 1000;
+static constexpr uint32_t kInfraIfIndex        = 1;
+static constexpr uint16_t kDiscoveryMaxResults = 16;
+static constexpr uint16_t kPanIdAny            = 0xffff;
+
+struct DiscoverContext
+{
+    void Clear(void)
+    {
+        mDiscoverDone = false;
+        mScanResults.Clear();
+    }
+
+    bool                                    mDiscoverDone;
+    Array<ScanResult, kDiscoveryMaxResults> mScanResults;
+};
+
+void HandleDiscoverResult(otActiveScanResult *aResult, void *aContext)
+{
+    DiscoverContext *context = static_cast<DiscoverContext *>(aContext);
+    ScanResult      *result  = AsCoreTypePtr(aResult);
+
+    VerifyOrQuit(aContext != nullptr);
+
+    if (result == nullptr)
+    {
+        context->mDiscoverDone = true;
+    }
+    else
+    {
+        VerifyOrQuit(!context->mDiscoverDone);
+        SuccessOrQuit(context->mScanResults.PushBack(*result));
+    }
+}
+
+void Test1_4_Trel_Tc_5(void)
+{
+    /**
+     * 8.5. [1.4] [CERT] Discover Scan over multi-radio
+     *
+     * 8.5.1. Purpose
+     * This test covers MLE discovery scan when nodes support different radio links.
+     *
+     * 8.5.2. Topology
+     * - 1. Node_1 BR (DUT) - Support multi-radio (15.4 and TREL).
+     * - 2. Node_2 - Reference device that supports multi-radio (15.4 and TREL).
+     * - 3. Node_3 - Reference device that supports 15.4 radio only.
+     * Devices using TREL MUST be connected to the same infrastructure link.
+     * Note: the infrastructure link is not shown in the figure below.
+     *
+     * Spec Reference   | V1.1 Section | V1.3.0 Section
+     * -----------------|--------------|---------------
+     * Discovery Scan   | 4.7.2.1      | 4.5.2.1
+     */
+
+    Core  nexus;
+    Node &node1 = nexus.CreateNode();
+    Node &node2 = nexus.CreateNode();
+    Node &node3 = nexus.CreateNode();
+
+    DiscoverContext resultContext;
+
+    node1.SetName("NODE_1_DUT");
+    node2.SetName("NODE_2");
+    node3.SetName("NODE_3");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    /**
+     * Step 1
+     * - Device: Node_1 (DUT), Node_2, Node_3
+     * - Description (TREL-8.5): Form a network on Node_1. Form a different network on Node_2 and different one on
+     *   Node_3 (each node has its own network).
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: Form separate networks on Node_1, Node_2, and Node_3");
+
+    SuccessOrQuit(node1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    SuccessOrQuit(node2.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    /** Node_3 supports 15.4 radio only. Disable TREL interface on Node_3. */
+    node3.Get<ot::Trel::Interface>().SetEnabled(false, ot::Trel::Interface::kRequesterUser);
+
+    node1.Form();
+    node2.Form();
+    node3.Form();
+
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    VerifyOrQuit(node1.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(node2.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(node3.Get<Mle::Mle>().IsLeader());
+
+    /**
+     * Step 2
+     * - Device: Node_2
+     * - Description (TREL-8.5): Harness instructs device to perform MLE Discovery Scan.
+     * - Pass Criteria:
+     *   - Node_2 MUST see both the DUT and Node_3 in its scan result.
+     */
+    Log("Step 2: Node_2 performs MLE Discovery Scan");
+
+    resultContext.Clear();
+    SuccessOrQuit(node2.Get<Mle::DiscoverScanner>().Discover(Mac::ChannelMask(), kPanIdAny, /* aJoiner */ false,
+                                                             /* aFilter */ false, /* aFilterIndexes */ nullptr,
+                                                             HandleDiscoverResult, &resultContext));
+
+    nexus.AdvanceTime(kScanTime);
+
+    VerifyOrQuit(resultContext.mDiscoverDone);
+    {
+        bool foundNode1 = false;
+        bool foundNode3 = false;
+
+        for (const ScanResult &result : resultContext.mScanResults)
+        {
+            if (result.GetExtAddress() == node1.Get<Mac::Mac>().GetExtAddress())
+            {
+                foundNode1 = true;
+            }
+            if (result.GetExtAddress() == node3.Get<Mac::Mac>().GetExtAddress())
+            {
+                foundNode3 = true;
+            }
+        }
+
+        VerifyOrQuit(foundNode1, "Node_2 did not see Node_1 (DUT) in scan result");
+        VerifyOrQuit(foundNode3, "Node_2 did not see Node_3 in scan result");
+    }
+
+    /**
+     * Step 3
+     * - Device: Node_3
+     * - Description (TREL-8.5): Harness instructs device to perform MLE Discovery Scan.
+     * - Pass Criteria:
+     *   - Node_3 MUST see both the DUT and Node_2 in its scan result.
+     */
+    Log("Step 3: Node_3 performs MLE Discovery Scan");
+
+    resultContext.Clear();
+    SuccessOrQuit(node3.Get<Mle::DiscoverScanner>().Discover(Mac::ChannelMask(), kPanIdAny, /* aJoiner */ false,
+                                                             /* aFilter */ false, /* aFilterIndexes */ nullptr,
+                                                             HandleDiscoverResult, &resultContext));
+
+    nexus.AdvanceTime(kScanTime);
+
+    VerifyOrQuit(resultContext.mDiscoverDone);
+    {
+        bool foundNode1 = false;
+        bool foundNode2 = false;
+
+        for (const ScanResult &result : resultContext.mScanResults)
+        {
+            if (result.GetExtAddress() == node1.Get<Mac::Mac>().GetExtAddress())
+            {
+                foundNode1 = true;
+            }
+            if (result.GetExtAddress() == node2.Get<Mac::Mac>().GetExtAddress())
+            {
+                foundNode2 = true;
+            }
+        }
+
+        VerifyOrQuit(foundNode1, "Node_3 did not see Node_1 (DUT) in scan result");
+        VerifyOrQuit(foundNode2, "Node_3 did not see Node_2 in scan result");
+    }
+
+    nexus.SaveTestInfo("test_1_4_TREL_TC_5.json");
+}
+
+#endif // OPENTHREAD_CONFIG_MULTI_RADIO
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+#if OPENTHREAD_CONFIG_MULTI_RADIO
+    ot::Nexus::Test1_4_Trel_Tc_5();
+    printf("All tests passed\n");
+#else
+    printf("Multi-radio is not enabled - test skipped\n");
+#endif
+    return 0;
+}
diff --git a/tests/nexus/test_1_4_TREL_TC_6.cpp b/tests/nexus/test_1_4_TREL_TC_6.cpp
new file mode 100644
index 0000000..f6e9add
--- /dev/null
+++ b/tests/nexus/test_1_4_TREL_TC_6.cpp
@@ -0,0 +1,254 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 20 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * Infrastructure interface index.
+ */
+static constexpr uint32_t kInfraIfIndex = 1;
+
+/**
+ * Global prefix string.
+ */
+static constexpr char kGuaPrefixStr[] = "910b:1234::/64";
+
+/**
+ * TREL service instance name captured from mDNS browsing.
+ */
+static char sTrelInstanceName[Dns::Name::kMaxNameSize];
+
+/**
+ * Callback for mDNS browsing to capture TREL service instance name.
+ */
+static void HandleBrowseResult(otInstance *aInstance, const otPlatDnssdBrowseResult *aResult)
+{
+    if (aResult->mTtl > 0)
+    {
+        // Save the instance name if it is not from Eth_1 itself.
+        if (!StringEndsWith(aResult->mServiceInstance,
+                            Node::From(aInstance).Get<Mac::Mac>().GetExtAddress().ToString().AsCString()))
+        {
+            IgnoreError(StringCopy(sTrelInstanceName, aResult->mServiceInstance));
+        }
+    }
+}
+
+void Test_1_4_TREL_TC_6(void)
+{
+    /**
+     * 8.6. [1.4] [CERT] mDNS Discovery of TREL Service
+     *
+     * 8.6.1. Purpose
+     * This test covers mDNS discovery of TREL Service on the BR DUT, and validation of the DNS-SD parameters and fields
+     *   advertised.
+     *
+     * 8.6.2. Topology
+     * - 1. BR Leader (DUT) - Support multi-radio (TREL and 15.4)
+     * - 2. Eth_1 - Reference device on the Adjacent Infrastructure Link performing mDNS discovery
+     *
+     * Spec Reference   | Section
+     * -----------------|----------
+     * TREL Discovery   | 15.6.3.1
+     */
+
+    Core nexus;
+
+    Node &br   = nexus.CreateNode();
+    Node &eth1 = nexus.CreateNode();
+
+    br.SetName("BR_DUT");
+    eth1.SetName("Eth_1");
+
+    eth1.mTrel.Disable();
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 1
+     * - Device: Eth_1, BR (DUT)
+     * - Description (TREL-8.6): Form the topology. Eth_1 advertises an on-link prefix using multicast ND RA PIO, such
+     *   as 910b:1234::/64. Wait for the DUT to become Leader. The DUT automatically configures an IPv6 address on the
+     *   AIL based on the advertised prefix.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 1: Form the topology");
+
+    Ip6::Prefix guaPrefix;
+    SuccessOrQuit(guaPrefix.FromString(kGuaPrefixStr));
+    eth1.mInfraIf.StartRouterAdvertisement(guaPrefix);
+
+    br.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    br.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    br.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(br.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    SuccessOrQuit(br.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    nexus.AdvanceTime(0);
+    VerifyOrQuit(br.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 2
+     * - Device: Eth_1
+     * - Description (TREL-8.6): Harness instructs device to send multicast mDNS query QType=PTR for name
+     *   “_trel._udp.local”. Note: for test reliability purposes, it's allowed to retransmit this query one additional
+     *   time.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 2: Eth_1 sends mDNS query PTR for _trel._udp.local");
+
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    {
+        Dns::Multicast::Core::Browser browser;
+
+        ClearAllBytes(browser);
+        browser.mCallback     = HandleBrowseResult;
+        browser.mServiceType  = "_trel._udp";
+        browser.mInfraIfIndex = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 3
+     * - Device: BR (DUT)
+     * - Description (TREL-8.6): Automatically responds with mDNS response. Note: the response can be unicast or
+     *   multicast.
+     * - Pass Criteria:
+     *   - The DUT MUST send an mDNS response (unicast and/or multicast).
+     *   - Eth_1 MUST receive the mDNS response containing the TREL service, as follows, in the Answer Record section:
+     *     $ORIGIN local. _trel._udp PTR <name>._trel._udp
+     *   - The response MAY contain the following in the Additional Record section or in the Answer Record section:
+     *     $ORIGIN local. <name>._trel._udp ( SRV <val1> <val2> <port> <hostname> TXT xa=<extaddr>;xp=<xpanid> )
+     *     <hostname> AAAA <link-local address> AAAA <global address>
+     *   - Where: <name> is a service instance name selected by BR DUT.
+     *   - Where: <val1> is a value >= 0.
+     *   - Where: <val2> is a value >= 0.
+     *   - Where: <port> is a port number selected by BR DUT.
+     *   - Where: <hostname> is a hostname selected by BR DUT.
+     *   - Where: <global address> is an address starting with prefix 910b:1234::/64.
+     *   - Where: <link-local address> is an IPv6 address starting with fe80::/10.
+     *   - Where: <extaddr> is the extended address of BR DUT, binary encoded. It MUST NOT be ASCII hexadecimal encoded.
+     *   - Where: <xpanid> is the extended PAN ID of the Thread Network, binary encoded. It MUST NOT be ASCII
+     *     hexadecimal encoded.
+     *   - Verify that <xpanid> in the TXT record equals the configured Extended PAN ID (XPANID) of the Thread Network.
+     */
+    Log("Step 3: BR (DUT) responds with mDNS response");
+
+    VerifyOrQuit(sTrelInstanceName[0] != '\0');
+    nexus.AddTestVar("TREL_INSTANCE_NAME", sTrelInstanceName);
+    nexus.AddTestVar("BR_EXT_ADDR", br.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
+    nexus.AddTestVar("XPANID", br.Get<MeshCoP::NetworkIdentity>().GetExtPanId().ToString().AsCString());
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 4
+     * - Device: Eth_1
+     * - Description (TREL-8.6): Harness instructs device to send multicast mDNS query QType=SRV for name
+     *   “<name>._trel._udp.local”, the service found in the previous step. Note: <name> is taken from the previous
+     *   step response for the PTR record. Note: for test reliability purposes, it's allowed to retransmit this query
+     *   one additional time.
+     * - Pass Criteria:
+     *   - N/A
+     */
+    Log("Step 4: Eth_1 sends mDNS query SRV for service instance");
+
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
+    nexus.AdvanceTime(1000);
+    SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+
+    {
+        Dns::Multicast::Core::SrvResolver resolver;
+        ClearAllBytes(resolver);
+        resolver.mCallback        = [](otInstance *, const otPlatDnssdSrvResult *) {};
+        resolver.mServiceInstance = sTrelInstanceName;
+        resolver.mServiceType     = "_trel._udp";
+        resolver.mInfraIfIndex    = kInfraIfIndex;
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartSrvResolver(resolver));
+        nexus.AdvanceTime(kDnsQueryTime);
+        SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopSrvResolver(resolver));
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    /**
+     * Step 5
+     * - Device: BR (DUT)
+     * - Description (TREL-8.6): Automatically responds with mDNS response. Note: the response can be unicast or
+     *   multicast.
+     * - Pass Criteria:
+     *   - The DUT MUST send mDNS response (unicast and/or multicast)
+     *   - Eth_1 MUST receive the mDNS response containing the TREL service, as follows, in the Answer Record section:
+     *     $ORIGIN local. <name>._trel._udp ( SRV <val1> <val2> <port> <hostname> )
+     *   - It MAY contain in the Additional Record section or in the Answer Record section the following: <hostname>
+     *     AAAA <link-local address> AAAA <global address>
+     *   - Also it MAY contain in the Additional Record section or the Answer Record section the following:
+     *     <name>._trel._udp TXT xa=<extaddr>;xp=<xpanid>
+     *   - Where: all the names in pointy brackets are as specified in step 3. For all items present, the same
+     *     validation as in step 3 MUST be performed.
+     */
+    Log("Step 5: BR (DUT) responds with mDNS response");
+
+    nexus.SaveTestInfo("test_1_4_TREL_TC_6.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_1_4_TREL_TC_6();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_announce_no_flap_on_unmergeable_partitions.cpp b/tests/nexus/test_announce_no_flap_on_unmergeable_partitions.cpp
new file mode 100644
index 0000000..1f2fb34
--- /dev/null
+++ b/tests/nexus/test_announce_no_flap_on_unmergeable_partitions.cpp
@@ -0,0 +1,204 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+// Regression test for the endless MLE role flap in
+// `Mle::AnnounceHandler::HandleAnnounce`.
+//
+// Two FTDs share the same channel, PAN ID, and network credentials but
+// hold different Active Dataset Timestamps, and the RF link between them
+// is too weak to merge their partitions (Advertisements from a different
+// partition are rejected with `kErrorLinkMarginLow` at `mle_ftd.cpp`,
+// since their link margin is below `kPartitionMergeMinMargin`). MLE
+// Announce frames are not link-margin gated, so they still reach the
+// lower-timestamp peer.
+//
+// Without the fix, the lower-timestamp node loops
+// Leader -> Detached -> Disabled -> Detached -> Leader: it runs the
+// `kAnnounceAttachAfterDelay` action and pointlessly Stop()/Start()s on
+// every Announce, even though the announced channel/PAN ID already match
+// its current MAC parameters (so there is nothing to migrate to). Each
+// re-attach allocates a fresh partition ID. With the fix, both nodes stay
+// stable Leaders of their separate partitions and keep their partition
+// IDs.
+
+// RSS that yields a link margin below `kPartitionMergeMinMargin` (= 5 in
+// the Nexus config) but still above the radio receive sensitivity
+// (-100 dBm). With noise floor = sensitivity = -100 dBm, margin =
+// rss + 100 = 3 dB: Advertisements are rejected for partition merge,
+// while frames (including Announces) are still delivered and processed.
+static constexpr int8_t kWeakLinkRss = -97;
+
+// Active Timestamps (seconds). Identical dataset content otherwise; the
+// higher timestamp drives the Announces that, without the fix, restart
+// the lower-timestamp peer.
+static constexpr uint64_t kTimestampOld = 1;
+static constexpr uint64_t kTimestampNew = 1000;
+
+static constexpr uint32_t kFormNetworkTime = 13 * 1000; // Time to form/attach as leader, in ms.
+static constexpr uint32_t kSettleTime      = 10 * 1000; // Time for the network to settle, in ms.
+
+// Per-round wait after each Announce. Far larger than the buggy
+// `kAnnounceProcessTimeout` (250 ms) plus the subsequent re-attach, so a
+// flap would be observed within a single round if present.
+static constexpr uint32_t kAnnounceSettleTime = 5 * 1000;
+
+// Number of Announce rounds to drive. Covers the original ~20-minute
+// Python test window in (instant) simulated time.
+static constexpr uint32_t kAnnounceRounds = 12;
+
+void Test(void)
+{
+    Core nexus;
+
+    Node &leaderOld = nexus.CreateNode();
+    Node &leaderNew = nexus.CreateNode();
+
+    leaderOld.SetName("LEADER_OLD");
+    leaderNew.SetName("LEADER_NEW");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: LEADER_OLD forms its own partition with the older Active Timestamp");
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        MeshCoP::Timestamp     timestamp;
+
+        // Build one shared dataset (random credentials/channel/PAN ID),
+        // then give each node the same content but a different Active
+        // Timestamp.
+        SuccessOrQuit(datasetInfo.GenerateRandom(leaderOld.GetInstance()));
+
+        timestamp.Clear();
+        timestamp.SetSeconds(kTimestampOld);
+        datasetInfo.Set<MeshCoP::Dataset::kActiveTimestamp>(timestamp);
+
+        leaderOld.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
+        leaderOld.Get<ThreadNetif>().Up();
+        SuccessOrQuit(leaderOld.Get<Mle::Mle>().Start());
+        nexus.AdvanceTime(kFormNetworkTime);
+        VerifyOrQuit(leaderOld.Get<Mle::Mle>().IsLeader());
+
+        Log("---------------------------------------------------------------------------------------");
+        Log("Step 2: LEADER_NEW forms its own partition with the newer Active Timestamp (isolated)");
+
+        // Isolate both nodes so they cannot hear each other. This prevents
+        // LEADER_OLD from attempting to attach to LEADER_NEW, and forces
+        // LEADER_NEW to form its own partition.
+        leaderNew.Get<Mac::Filter>().SetMode(Mac::Filter::kModeAllowlist);
+        leaderOld.Get<Mac::Filter>().SetMode(Mac::Filter::kModeAllowlist);
+
+        timestamp.SetSeconds(kTimestampNew);
+        datasetInfo.Set<MeshCoP::Dataset::kActiveTimestamp>(timestamp);
+
+        leaderNew.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
+        leaderNew.Get<ThreadNetif>().Up();
+        SuccessOrQuit(leaderNew.Get<Mle::Mle>().Start());
+        nexus.AdvanceTime(kFormNetworkTime);
+        VerifyOrQuit(leaderNew.Get<Mle::Mle>().IsLeader());
+    }
+
+    uint32_t partitionOld = leaderOld.Get<Mle::Mle>().GetLeaderData().GetPartitionId();
+    uint32_t partitionNew = leaderNew.Get<Mle::Mle>().GetLeaderData().GetPartitionId();
+
+    VerifyOrQuit(partitionOld != partitionNew);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Wire a weak link - Advertisements rejected (no merge), Announces still delivered");
+
+    {
+        const Mac::ExtAddress &extOld = leaderOld.Get<Mac::Mac>().GetExtAddress();
+        const Mac::ExtAddress &extNew = leaderNew.Get<Mac::Mac>().GetExtAddress();
+
+        leaderOld.AllowList(leaderNew);
+        SuccessOrQuit(leaderOld.Get<Mac::Filter>().AddRssIn(extNew, kWeakLinkRss));
+
+        leaderNew.AllowList(leaderOld);
+        SuccessOrQuit(leaderNew.Get<Mac::Filter>().AddRssIn(extOld, kWeakLinkRss));
+    }
+
+    nexus.AdvanceTime(kSettleTime);
+
+    // The link must be genuinely unmergeable: both nodes remain Leaders of
+    // their own partitions.
+    VerifyOrQuit(leaderOld.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(leaderNew.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(leaderOld.Get<Mle::Mle>().GetLeaderData().GetPartitionId() == partitionOld);
+    VerifyOrQuit(leaderNew.Get<Mle::Mle>().GetLeaderData().GetPartitionId() == partitionNew);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Drive Announces from the newer peer; the older node must not flap");
+
+    {
+        uint8_t channel = leaderOld.Get<Mac::Mac>().GetPanChannel();
+
+        for (uint32_t round = 0; round < kAnnounceRounds; round++)
+        {
+            // LEADER_NEW announces on the shared channel. LEADER_OLD sees a
+            // newer Active Timestamp with a matching channel/PAN ID -- the
+            // `kAnnounceAttachAfterDelay` path that used to Stop()/Start()
+            // the attached node.
+            leaderNew.Get<Mle::Mle>().SendAnnounce(channel);
+            nexus.AdvanceTime(kAnnounceSettleTime);
+
+            // A Stop()/Start() flap would drop the Leader role and
+            // re-create the partition with a fresh ID on re-attach.
+            VerifyOrQuit(leaderOld.Get<Mle::Mle>().IsLeader());
+            VerifyOrQuit(leaderOld.Get<Mle::Mle>().GetLeaderData().GetPartitionId() == partitionOld);
+
+            // The mirrored `kSendAnnouceBack` guard: LEADER_OLD's own
+            // Announces reach LEADER_NEW (older timestamp) and must not
+            // disturb it either.
+            VerifyOrQuit(leaderNew.Get<Mle::Mle>().IsLeader());
+            VerifyOrQuit(leaderNew.Get<Mle::Mle>().GetLeaderData().GetPartitionId() == partitionNew);
+        }
+    }
+
+    nexus.SaveTestInfo("test_announce_no_flap_on_unmergeable_partitions.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_anycast.cpp b/tests/nexus/test_anycast.cpp
new file mode 100644
index 0000000..3d82342
--- /dev/null
+++ b/tests/nexus/test_anycast.cpp
@@ -0,0 +1,251 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/network_data_local.hpp"
+#include "thread/network_data_notifier.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the network to stabilize after nodes have attached.
+ */
+static constexpr uint32_t kStabilizationTime = 60 * 1000;
+
+static void AddPrefix(Node &aNode, const char *aPrefixString, const char *aFlags)
+{
+    NetworkData::OnMeshPrefixConfig config;
+
+    config.Clear();
+    SuccessOrQuit(config.GetPrefix().FromString(aPrefixString));
+    config.mOnMesh = true;
+
+    for (const char *f = aFlags; *f != '\0'; f++)
+    {
+        switch (*f)
+        {
+        case 'p':
+            config.mPreferred = true;
+            break;
+        case 'a':
+            config.mSlaac = true;
+            break;
+        case 'd':
+            config.mDhcp = true;
+            break;
+        case 'c':
+            config.mConfigure = true;
+            break;
+        case 'n':
+            config.mNdDns = true;
+            break;
+        case 'r':
+            config.mDefaultRoute = true;
+            break;
+        case 'o':
+            config.mOnMesh = true;
+            break;
+        case 's':
+            config.mStable = true;
+            break;
+        }
+    }
+
+    SuccessOrQuit(aNode.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    aNode.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+}
+
+static void RemovePrefix(Node &aNode, const char *aPrefixString)
+{
+    Ip6::Prefix prefix;
+
+    SuccessOrQuit(prefix.FromString(aPrefixString));
+    SuccessOrQuit(aNode.Get<NetworkData::Local>().RemoveOnMeshPrefix(prefix));
+    aNode.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+}
+
+static Ip6::Address GetAloc(Node &aNode)
+{
+    Ip6::Address aloc;
+
+    aloc.Clear();
+
+    for (const Ip6::Netif::UnicastAddress &addr : aNode.Get<Ip6::Netif>().GetUnicastAddresses())
+    {
+        if (addr.GetAddress().GetIid().IsAnycastLocator())
+        {
+            // We expect the prefix-based ALOC (DHCPv6 or ND Agent).
+            // Leader ALOC is 0xfc00.
+            if (addr.GetAddress().GetIid().GetLocator() != 0xfc00)
+            {
+                aloc = addr.GetAddress();
+                break;
+            }
+        }
+    }
+
+    return aloc;
+}
+
+void TestAnycast(void)
+{
+    /**
+     * Test Anycast Address Routing
+     *
+     * Topology:
+     * R1 (Leader) -- R2 -- R3 -- R4 -- R5
+     */
+
+    Core nexus;
+
+    Node &r1 = nexus.CreateNode();
+    Node &r2 = nexus.CreateNode();
+    Node &r3 = nexus.CreateNode();
+    Node &r4 = nexus.CreateNode();
+    Node &r5 = nexus.CreateNode();
+
+    r1.SetName("R1");
+    r2.SetName("R2");
+    r3.SetName("R3");
+    r4.SetName("R4");
+    r5.SetName("R5");
+
+    AllowLinkBetween(r1, r2);
+    AllowLinkBetween(r2, r3);
+    AllowLinkBetween(r3, r4);
+    AllowLinkBetween(r4, r5);
+
+    nexus.AdvanceTime(0);
+
+    Log("Step 0: Form network");
+    r1.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(r1.Get<Mle::Mle>().IsLeader());
+
+    r2.Join(r1);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(r2.Get<Mle::Mle>().IsRouter());
+
+    r3.Join(r2);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(r3.Get<Mle::Mle>().IsRouter());
+
+    r4.Join(r3);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(r4.Get<Mle::Mle>().IsRouter());
+
+    r5.Join(r4);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(r5.Get<Mle::Mle>().IsRouter());
+
+    const char *kPrefix      = "2001:dead:beef:cafe::/64";
+    const char *kTestFlags[] = {"ds", "cs"};
+
+    for (const char *flags : kTestFlags)
+    {
+        Log("---------------------------------------------------------------------------------------");
+        Log("Testing anycast with flags: %s", flags);
+
+        // 1. Add prefix on R2
+        Log("Step 1: Add prefix on R2");
+        AddPrefix(r2, kPrefix, flags);
+        nexus.AdvanceTime(kStabilizationTime);
+
+        Ip6::Address aloc2 = GetAloc(r2);
+        VerifyOrQuit(!aloc2.IsUnspecified(), "R2 should have an ALOC");
+        Log("R2 ALOC: %s", aloc2.ToString().AsCString());
+
+        // 2. Ping ALOC from R3 -> should go to R2
+        Log("Step 2: Ping ALOC from R3 (expected R2)");
+        nexus.SendAndVerifyEchoRequest(r3, aloc2);
+
+        // 3. Add same prefix on R5
+        Log("Step 3: Add prefix on R5");
+        AddPrefix(r5, kPrefix, flags);
+        nexus.AdvanceTime(kStabilizationTime);
+
+        Ip6::Address aloc5 = GetAloc(r5);
+        VerifyOrQuit(!aloc5.IsUnspecified(), "R5 should have an ALOC");
+        VerifyOrQuit(aloc5 == aloc2, "R5 should have the same ALOC as R2");
+        Log("R5 ALOC: %s", aloc5.ToString().AsCString());
+
+        // 4. Ping ALOC from R3 -> should go to R2 (closest)
+        Log("Step 4: Ping ALOC from R3 (expected R2 - closest)");
+        nexus.SendAndVerifyEchoRequest(r3, aloc2);
+
+        // 5. Ping ALOC from R4 -> should go to R5 (closest)
+        Log("Step 5: Ping ALOC from R4 (expected R5 - closest)");
+        nexus.SendAndVerifyEchoRequest(r4, aloc2);
+
+        // 6. Remove prefix on R2
+        Log("Step 6: Remove prefix on R2");
+        RemovePrefix(r2, kPrefix);
+        nexus.AdvanceTime(kStabilizationTime);
+        VerifyOrQuit(GetAloc(r2).IsUnspecified(), "R2 should no longer have the ALOC");
+
+        // 7. Ping ALOC from R3 -> should go to R5
+        Log("Step 7: Ping ALOC from R3 (expected R5)");
+        nexus.SendAndVerifyEchoRequest(r3, aloc2);
+
+        // 8. Ping ALOC from R4 -> should go to R5
+        Log("Step 8: Ping ALOC from R4 (expected R5)");
+        nexus.SendAndVerifyEchoRequest(r4, aloc2);
+
+        // 9. Remove prefix on R5
+        Log("Step 9: Remove prefix on R5");
+        RemovePrefix(r5, kPrefix);
+        nexus.AdvanceTime(kStabilizationTime);
+        VerifyOrQuit(GetAloc(r5).IsUnspecified(), "R5 should no longer have the ALOC");
+    }
+
+    nexus.SaveTestInfo("test_anycast.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestAnycast();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_anycast_locator.cpp b/tests/nexus/test_anycast_locator.cpp
new file mode 100644
index 0000000..3c7e08c
--- /dev/null
+++ b/tests/nexus/test_anycast_locator.cpp
@@ -0,0 +1,238 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <initializer_list>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+struct LocateResult
+{
+    Error        mError;
+    Ip6::Address mMeshLocalAddress;
+    uint16_t     mRloc16;
+    bool         mCallbackInvoked;
+
+    void Clear(void)
+    {
+        mError = kErrorNone;
+        mMeshLocalAddress.Clear();
+        mRloc16          = 0;
+        mCallbackInvoked = false;
+    }
+};
+
+static void HandleLocateResult(void *aContext, otError aError, const otIp6Address *aAddress, uint16_t aRloc16)
+{
+    LocateResult *result = static_cast<LocateResult *>(aContext);
+
+    result->mError           = aError;
+    result->mCallbackInvoked = true;
+    result->mRloc16          = aRloc16;
+
+    if (aAddress != nullptr)
+    {
+        result->mMeshLocalAddress = AsCoreType(aAddress);
+    }
+}
+
+static void Locate(Core &aNexus, Node &aNode, const Ip6::Address &aAnycastAddress, LocateResult &aResult)
+{
+    aResult.Clear();
+    SuccessOrQuit(aNode.Get<AnycastLocator>().Locate(aAnycastAddress, HandleLocateResult, &aResult));
+
+    for (uint32_t i = 0; i < 10000; i++)
+    {
+        aNexus.AdvanceTime(10);
+        if (aResult.mCallbackInvoked)
+        {
+            break;
+        }
+    }
+
+    VerifyOrQuit(aResult.mCallbackInvoked);
+    SuccessOrQuit(aResult.mError);
+}
+
+void TestAnycastLocator(void)
+{
+    // Topology:
+    //   LEADER -- ROUTER1 -- ROUTER2 -- ROUTER3 -- ROUTER4
+
+    Core nexus;
+
+    Node &leader  = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &router2 = nexus.CreateNode();
+    Node &router3 = nexus.CreateNode();
+    Node &router4 = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router1.SetName("ROUTER1");
+    router2.SetName("ROUTER2");
+    router3.SetName("ROUTER3");
+    router4.SetName("ROUTER4");
+
+    Node *nodes[] = {&leader, &router1, &router2, &router3, &router4};
+
+    nexus.AdvanceTime(0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form network with line topology");
+
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, router2);
+    AllowLinkBetween(router2, router3);
+    AllowLinkBetween(router3, router4);
+
+    leader.Form();
+    nexus.AdvanceTime(20 * 1000);
+
+    for (uint8_t i = 1; i < GetArrayLength(nodes); i++)
+    {
+        nodes[i]->Join(*nodes[i - 1]);
+        nexus.AdvanceTime(20 * 1000);
+    }
+
+    // Wait for all to become routers and network to stabilize
+    nexus.AdvanceTime(300 * 1000);
+
+    for (Node *node : nodes)
+    {
+        VerifyOrQuit(node->Get<Mle::Mle>().IsRouter() || node->Get<Mle::Mle>().IsLeader());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("1. Locate leader ALOC from all nodes");
+
+    Ip6::Address leaderAloc;
+    leader.Get<Mle::Mle>().ComposeLeaderAloc(leaderAloc);
+
+    LocateResult result;
+
+    for (Node *node : nodes)
+    {
+        Log("Node %s locating leader ALOC", node->GetName());
+        Locate(nexus, *node, leaderAloc, result);
+        VerifyOrQuit(result.mMeshLocalAddress == leader.Get<Mle::Mle>().GetMeshLocalEid());
+        VerifyOrQuit(result.mRloc16 == leader.Get<Mle::Mle>().GetRloc16());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("2. Add a service on router4 and locate its ALOC");
+
+    uint32_t                 enterpriseNumber = 44970;
+    NetworkData::ServiceData serviceData;
+    NetworkData::ServerData  serverData;
+    uint8_t                  sData[] = {0x57, 0x12, 0x34};
+    uint8_t                  rData[] = {0x00};
+
+    serviceData.Init(sData, sizeof(sData));
+    serverData.Init(rData, sizeof(rData));
+
+    SuccessOrQuit(router4.Get<NetworkData::Local>().AddService(enterpriseNumber, serviceData, true, serverData));
+    router4.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    nexus.AdvanceTime(20 * 1000);
+
+    // Get the service ALOC from router4
+    Ip6::Address serviceAloc;
+    bool         found = false;
+
+    for (const Ip6::Netif::UnicastAddress &addr : router4.Get<ThreadNetif>().GetUnicastAddresses())
+    {
+        if (router4.Get<Mle::Mle>().IsAnycastLocator(addr.GetAddress()) &&
+            addr.GetAddress().GetIid().IsAnycastLocator())
+        {
+            // The leader ALOC is also an ALOC, so we need to make sure it's not the leader ALOC.
+            if (addr.GetAddress() != leaderAloc)
+            {
+                serviceAloc = addr.GetAddress();
+                found       = true;
+                break;
+            }
+        }
+    }
+    VerifyOrQuit(found);
+
+    for (Node *node : nodes)
+    {
+        Log("Node %s locating service ALOC", node->GetName());
+        Locate(nexus, *node, serviceAloc, result);
+        VerifyOrQuit(result.mMeshLocalAddress == router4.Get<Mle::Mle>().GetMeshLocalEid());
+        VerifyOrQuit(result.mRloc16 == router4.Get<Mle::Mle>().GetRloc16());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("3. Add same service on leader and ensure we locate the closest ALOC destination");
+
+    SuccessOrQuit(leader.Get<NetworkData::Local>().AddService(enterpriseNumber, serviceData, true, serverData));
+    leader.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    nexus.AdvanceTime(20 * 1000);
+
+    // leader and router1 should locate leader as closest service ALOC
+    for (Node *node : {&leader, &router1})
+    {
+        Log("Node %s locating service ALOC (expected leader)", node->GetName());
+        Locate(nexus, *node, serviceAloc, result);
+        VerifyOrQuit(result.mMeshLocalAddress == leader.Get<Mle::Mle>().GetMeshLocalEid());
+        VerifyOrQuit(result.mRloc16 == leader.Get<Mle::Mle>().GetRloc16());
+    }
+
+    // router3 and router4 should locate router4
+    for (Node *node : {&router3, &router4})
+    {
+        Log("Node %s locating service ALOC (expected router4)", node->GetName());
+        Locate(nexus, *node, serviceAloc, result);
+        VerifyOrQuit(result.mMeshLocalAddress == router4.Get<Mle::Mle>().GetMeshLocalEid());
+        VerifyOrQuit(result.mRloc16 == router4.Get<Mle::Mle>().GetRloc16());
+    }
+
+    // router2 is in middle and can locate either one
+    Log("Node %s locating service ALOC (expected either leader or router4)", router2.GetName());
+    Locate(nexus, router2, serviceAloc, result);
+    VerifyOrQuit((result.mMeshLocalAddress == leader.Get<Mle::Mle>().GetMeshLocalEid() &&
+                  result.mRloc16 == leader.Get<Mle::Mle>().GetRloc16()) ||
+                 (result.mMeshLocalAddress == router4.Get<Mle::Mle>().GetMeshLocalEid() &&
+                  result.mRloc16 == router4.Get<Mle::Mle>().GetRloc16()));
+
+    Log("All tests passed");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestAnycastLocator();
+    return 0;
+}
diff --git a/tests/nexus/test_border_admitter.cpp b/tests/nexus/test_border_admitter.cpp
index 41fcae2..213fd15 100644
--- a/tests/nexus/test_border_admitter.cpp
+++ b/tests/nexus/test_border_admitter.cpp
@@ -60,7 +60,7 @@
 
     nexus.AdvanceTime(0);
 
-    leader.GetInstance().SetLogLevel(kLogLevel);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevel));
 
     // Form the topology.
 
@@ -449,7 +449,7 @@
 
     enroller.Get<Tmf::SecureAgent>().RegisterResourceHandler(HandleResource, &recvContext);
 
-    SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(Time::kOneSecondInMsec);
@@ -475,7 +475,7 @@
 
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerId));
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, mode));
-    SuccessOrQuit(Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+    SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
     responseContext.Clear();
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContext));
@@ -652,7 +652,7 @@
     SuccessOrQuit(steeringData.UpdateBloomFilter(admitter.Get<Mac::Mac>().GetExtAddress()));
 
     SuccessOrQuit(Tlv::Append<MeshCoP::StateTlv>(*message, MeshCoP::StateTlv::kAccept));
-    SuccessOrQuit(Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+    SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
     responseContext.Clear();
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContext));
@@ -742,7 +742,7 @@
 
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerId));
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, mode));
-    SuccessOrQuit(Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+    SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
     responseContext.Clear();
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContext));
@@ -816,7 +816,7 @@
 
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerId));
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, mode));
-    SuccessOrQuit(Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+    SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
     responseContext.Clear();
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContext));
@@ -851,7 +851,7 @@
 
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerIdAlt));
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, mode));
-    SuccessOrQuit(Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+    SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
     responseContext.Clear();
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContext));
@@ -964,8 +964,7 @@
 
         if (testIter != 2)
         {
-            SuccessOrQuit(
-                Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+            SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
         }
 
         responseContext.Clear();
@@ -1005,8 +1004,7 @@
 
         SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerId));
         SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, mode));
-        SuccessOrQuit(
-            Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+        SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
         responseContext.Clear();
         SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContext));
@@ -1031,7 +1029,7 @@
 
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerId));
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, mode));
-    SuccessOrQuit(Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+    SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
     responseContext.Clear();
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContext));
@@ -1135,7 +1133,7 @@
 
     enroller.Get<Tmf::SecureAgent>().RegisterResourceHandler(HandleResource, &recvContext);
 
-    SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(Time::kOneSecondInMsec);
@@ -1154,7 +1152,7 @@
 
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerId));
     SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, mode));
-    SuccessOrQuit(Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+    SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
     SuccessOrQuit(enroller.Get<Tmf::SecureAgent>().SendMessage(*message));
 
@@ -1326,8 +1324,10 @@
 
 void LogEnroller(const Admitter::EnrollerInfo &aInfo)
 {
-    Log("   Enroller - id:%s steeringData:%s mode:0x%02x", aInfo.mId,
-        AsCoreType(&aInfo.mSteeringData).ToString().AsCString(), aInfo.mMode);
+    Log("   Enroller - id:%s steeringData:%s mode:0x%02x[%c%c]", aInfo.mId,
+        AsCoreType(&aInfo.mSteeringData).ToString().AsCString(), aInfo.mMode,
+        aInfo.mMode & MeshCoP::EnrollerModeTlv::kForwardJoinerRelayRx ? 'J' : '-',
+        aInfo.mMode & MeshCoP::EnrollerModeTlv::kForwardUdpProxyRx ? 'U' : '-');
 }
 
 void LogJoiner(const Admitter::JoinerInfo &aInfo)
@@ -1421,7 +1421,7 @@
         recvContext[i].Clear();
         enroller->Get<Tmf::SecureAgent>().RegisterResourceHandler(HandleResource, &recvContext[i]);
 
-        SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Open());
+        SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Open(0));
         SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Connect(sockAddr));
 
         nexus.AdvanceTime(Time::kOneSecondInMsec);
@@ -1463,8 +1463,7 @@
 
         SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerIds[i]));
         SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, mode));
-        SuccessOrQuit(
-            Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData[i].GetData(), steeringData[i].GetLength()));
+        SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData[i]));
 
         responseContexts[i].Clear();
         SuccessOrQuit(
@@ -1759,7 +1758,7 @@
         recvContext[i].Clear();
         enroller->Get<Tmf::SecureAgent>().RegisterResourceHandler(HandleResource, &recvContext[i]);
 
-        SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Open());
+        SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Open(0));
         SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Connect(sockAddr));
 
         nexus.AdvanceTime(Time::kOneSecondInMsec);
@@ -1788,8 +1787,7 @@
 
         SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerIds[i]));
         SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, modes[i]));
-        SuccessOrQuit(
-            Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+        SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
         responseContexts[i].Clear();
         SuccessOrQuit(
@@ -1861,7 +1859,7 @@
                                                   /* aCallback */ nullptr,
                                                   /* aContext */ nullptr));
 
-    joinerIids[0].SetFromExtAddress(joiners[0]->Get<Joiner>().GetId());
+    joinerIids[0].InitFromExtAddress(joiners[0]->Get<Joiner>().GetId());
 
     nexus.AdvanceTime(8 * Time::kOneSecondInMsec);
 
@@ -1918,7 +1916,7 @@
 
     VerifyOrQuit(responseContexts[0].mReceived);
     VerifyOrQuit(responseContexts[0].mResponseState == MeshCoP::StateTlv::kAccept);
-    VerifyOrQuit(!responseContexts[0].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[0].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Validate that the accepted `joiners[0]` is tracked by `enrollers[0]` entry on `admitter`");
@@ -2010,7 +2008,7 @@
                                                   /* aCallback */ nullptr,
                                                   /* aContext */ nullptr));
 
-    joinerIids[1].SetFromExtAddress(joiners[1]->Get<Joiner>().GetId());
+    joinerIids[1].InitFromExtAddress(joiners[1]->Get<Joiner>().GetId());
 
     nexus.AdvanceTime(8 * Time::kOneSecondInMsec);
 
@@ -2041,6 +2039,219 @@
     joiners[1]->Get<Joiner>().Stop();
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Send `EnrollerKeepAlive` message from `enrollers[0]` change its mode to disallow `kForwardJoinerRelayRx`");
+
+    modes[0] = MeshCoP::EnrollerModeTlv::kForwardUdpProxyRx;
+
+    message =
+        enrollers[0]->Get<Tmf::SecureAgent>().AllocateAndInitPriorityConfirmablePostMessage(kUriEnrollerKeepAlive);
+    VerifyOrQuit(message != nullptr);
+
+    SuccessOrQuit(Tlv::Append<MeshCoP::StateTlv>(*message, MeshCoP::StateTlv::kAccept));
+    SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, modes[0]));
+
+    responseContexts[0].Clear();
+    SuccessOrQuit(enrollers[0]->Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContexts[0]));
+
+    nexus.AdvanceTime(Time::kOneSecondInMsec);
+
+    VerifyOrQuit(responseContexts[0].mReceived);
+    VerifyOrQuit(responseContexts[0].mResponseState == MeshCoP::StateTlv::kAccept);
+    VerifyOrQuit(responseContexts[0].mHasAdmitterState);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Validate that the `enrollers[0]` mode got changed on `admitter`");
+
+    foundEnrollers.Clear();
+    iter.Init(admitter.GetInstance());
+
+    while (iter.GetNextEnrollerInfo(enrollerInfo) == kErrorNone)
+    {
+        uint8_t matchedIndex;
+
+        LogEnroller(enrollerInfo);
+
+        matchedIndex = FindMatchingEnroller<kNumEnrollers>(enrollerInfo, kEnrollerIds, foundEnrollers);
+
+        VerifyOrQuit(AsCoreType(&enrollerInfo.mSteeringData) == steeringData);
+        VerifyOrQuit(enrollerInfo.mMode == modes[matchedIndex]);
+
+        if (matchedIndex == 0)
+        {
+            SuccessOrQuit(iter.GetNextJoinerInfo(joinerInfo));
+            VerifyOrQuit(AsCoreType(&joinerInfo.mIid) == joinerIids[0]);
+            LogJoiner(joinerInfo);
+        }
+
+        VerifyOrQuit(iter.GetNextJoinerInfo(joinerInfo) == kErrorNotFound);
+    }
+
+    VerifyOrQuit(DidFindAllEnrollers<kNumEnrollers>(foundEnrollers));
+
+    nexus.AdvanceTime(Time::kOneSecondInMsec);
+
+    for (uint8_t i = 0; i < kNumEnrollers; i++)
+    {
+        recvContext[i].Clear();
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Start `joiners[0]` again and validate that its `RelayRx` is still only forwarded to `enrollers[0]`");
+
+    joiners[0]->Get<ThreadNetif>().Up();
+    SuccessOrQuit(joiners[0]->Get<Joiner>().Start(kPskd,
+                                                  /* aProvisioningUrl */ nullptr,
+                                                  /* aVendorName */ nullptr,
+                                                  /* aVendorModel */ nullptr,
+                                                  /* aVendorSwVersion */ nullptr,
+                                                  /* aVendorData */ nullptr,
+                                                  /* aCallback */ nullptr,
+                                                  /* aContext */ nullptr));
+
+    nexus.AdvanceTime(8 * Time::kOneSecondInMsec);
+
+    for (uint8_t i = 0; i < kNumEnrollers; i++)
+    {
+        Ip6::InterfaceIdentifier readIid;
+        uint16_t                 joinerRouterRloc;
+
+        message = AsCoapMessagePtr(recvContext[i].mRelayRxMsgs.GetHead());
+
+        if (i != 0)
+        {
+            VerifyOrQuit(message == nullptr);
+            continue;
+        }
+
+        VerifyOrQuit(message != nullptr);
+
+        VerifyOrQuit(message->ReadType() == Coap::kTypeNonConfirmable);
+        VerifyOrQuit(message->ReadCode() == Coap::kCodePost);
+        SuccessOrQuit(Tlv::Find<MeshCoP::JoinerIidTlv>(*message, readIid));
+        SuccessOrQuit(Tlv::Find<MeshCoP::JoinerRouterLocatorTlv>(*message, joinerRouterRloc));
+
+        VerifyOrQuit(readIid == joinerIids[0]);
+        VerifyOrQuit(joinerRouterRloc == admitter.Get<Mle::Mle>().GetRloc16());
+    }
+
+    joiners[0]->Get<Joiner>().Stop();
+
+    for (uint8_t i = 0; i < kNumEnrollers; i++)
+    {
+        recvContext[i].Clear();
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Start `joiners[1]` and validate that its `RelayRx` is not longer forwarded to `enrollers[0]`");
+
+    joiners[1]->Get<ThreadNetif>().Up();
+    SuccessOrQuit(joiners[1]->Get<Joiner>().Start(kPskd,
+                                                  /* aProvisioningUrl */ nullptr,
+                                                  /* aVendorName */ nullptr,
+                                                  /* aVendorModel */ nullptr,
+                                                  /* aVendorSwVersion */ nullptr,
+                                                  /* aVendorData */ nullptr,
+                                                  /* aCallback */ nullptr,
+                                                  /* aContext */ nullptr));
+
+    joinerIids[1].InitFromExtAddress(joiners[1]->Get<Joiner>().GetId());
+
+    nexus.AdvanceTime(8 * Time::kOneSecondInMsec);
+
+    for (uint8_t i = 0; i < kNumEnrollers; i++)
+    {
+        Ip6::InterfaceIdentifier readIid;
+        uint16_t                 joinerRouterRloc;
+
+        message = AsCoapMessagePtr(recvContext[i].mRelayRxMsgs.GetHead());
+
+        if ((modes[i] & MeshCoP::EnrollerModeTlv::kForwardJoinerRelayRx) == 0)
+        {
+            VerifyOrQuit(message == nullptr);
+            continue;
+        }
+
+        VerifyOrQuit(message != nullptr);
+
+        VerifyOrQuit(message->ReadType() == Coap::kTypeNonConfirmable);
+        VerifyOrQuit(message->ReadCode() == Coap::kCodePost);
+        SuccessOrQuit(Tlv::Find<MeshCoP::JoinerIidTlv>(*message, readIid));
+        SuccessOrQuit(Tlv::Find<MeshCoP::JoinerRouterLocatorTlv>(*message, joinerRouterRloc));
+
+        VerifyOrQuit(readIid == joinerIids[1]);
+        VerifyOrQuit(joinerRouterRloc == admitter.Get<Mle::Mle>().GetRloc16());
+    }
+
+    joiners[1]->Get<Joiner>().Stop();
+
+    for (uint8_t i = 0; i < kNumEnrollers; i++)
+    {
+        recvContext[i].Clear();
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Send `EnrollerKeepAlive` message from `enrollers[0]` to revert its mode back to allowing both Joiner and UDP");
+
+    modes[0] = MeshCoP::EnrollerModeTlv::kForwardJoinerRelayRx | MeshCoP::EnrollerModeTlv::kForwardUdpProxyRx;
+
+    message =
+        enrollers[0]->Get<Tmf::SecureAgent>().AllocateAndInitPriorityConfirmablePostMessage(kUriEnrollerKeepAlive);
+    VerifyOrQuit(message != nullptr);
+
+    SuccessOrQuit(Tlv::Append<MeshCoP::StateTlv>(*message, MeshCoP::StateTlv::kAccept));
+    SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, modes[0]));
+
+    responseContexts[0].Clear();
+    SuccessOrQuit(enrollers[0]->Get<Tmf::SecureAgent>().SendMessage(*message, HandleResponse, &responseContexts[0]));
+
+    nexus.AdvanceTime(Time::kOneSecondInMsec);
+
+    VerifyOrQuit(responseContexts[0].mReceived);
+    VerifyOrQuit(responseContexts[0].mResponseState == MeshCoP::StateTlv::kAccept);
+    VerifyOrQuit(responseContexts[0].mHasAdmitterState);
+
+    for (uint8_t i = 0; i < kNumEnrollers; i++)
+    {
+        recvContext[i].Clear();
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Validate that the `enrollers[0]` mode got changed on `admitter`");
+
+    foundEnrollers.Clear();
+    iter.Init(admitter.GetInstance());
+
+    while (iter.GetNextEnrollerInfo(enrollerInfo) == kErrorNone)
+    {
+        uint8_t matchedIndex;
+
+        LogEnroller(enrollerInfo);
+
+        matchedIndex = FindMatchingEnroller<kNumEnrollers>(enrollerInfo, kEnrollerIds, foundEnrollers);
+
+        VerifyOrQuit(AsCoreType(&enrollerInfo.mSteeringData) == steeringData);
+        VerifyOrQuit(enrollerInfo.mMode == modes[matchedIndex]);
+
+        if (matchedIndex == 0)
+        {
+            SuccessOrQuit(iter.GetNextJoinerInfo(joinerInfo));
+            VerifyOrQuit(AsCoreType(&joinerInfo.mIid) == joinerIids[0]);
+            LogJoiner(joinerInfo);
+        }
+
+        VerifyOrQuit(iter.GetNextJoinerInfo(joinerInfo) == kErrorNotFound);
+    }
+
+    VerifyOrQuit(DidFindAllEnrollers<kNumEnrollers>(foundEnrollers));
+
+    nexus.AdvanceTime(Time::kOneSecondInMsec);
+
+    for (uint8_t i = 0; i < kNumEnrollers; i++)
+    {
+        recvContext[i].Clear();
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Send `EnrollerKeepAlive` message from all `enrollers` to maintain the connection");
 
     for (uint8_t i = 0; i < kNumEnrollers; i++)
@@ -2081,7 +2292,7 @@
 
     VerifyOrQuit(responseContexts[0].mReceived);
     VerifyOrQuit(responseContexts[0].mResponseState == MeshCoP::StateTlv::kAccept);
-    VerifyOrQuit(!responseContexts[0].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[0].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Validate that both accepted `joiners` are tracked by `enrollers[0]` on `admitter`");
@@ -2196,7 +2407,7 @@
 
     VerifyOrQuit(responseContexts[1].mReceived);
     VerifyOrQuit(responseContexts[1].mResponseState == MeshCoP::StateTlv::kReject);
-    VerifyOrQuit(!responseContexts[1].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[1].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Validate `joiners[1]` is still accepted by `enrollers[0]`");
@@ -2309,7 +2520,7 @@
 
     VerifyOrQuit(responseContexts[0].mReceived);
     VerifyOrQuit(responseContexts[0].mResponseState == MeshCoP::StateTlv::kAccept);
-    VerifyOrQuit(!responseContexts[0].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[0].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Validate that there is no change in the `enrollers` list and the tracked `joiners` on `admitter`");
@@ -2376,7 +2587,7 @@
 
     VerifyOrQuit(responseContexts[0].mReceived);
     VerifyOrQuit(responseContexts[0].mResponseState == MeshCoP::StateTlv::kAccept);
-    VerifyOrQuit(!responseContexts[0].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[0].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Validate that the released `joiners[0]` is removed on `admitter`");
@@ -2427,7 +2638,7 @@
 
     VerifyOrQuit(responseContexts[0].mReceived);
     VerifyOrQuit(responseContexts[0].mResponseState == MeshCoP::StateTlv::kAccept);
-    VerifyOrQuit(!responseContexts[0].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[0].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Send an `EnrollerJoinerRelease` message releasing `joiners[1]` from `enrollers[0]`");
@@ -2445,7 +2656,7 @@
 
     VerifyOrQuit(responseContexts[0].mReceived);
     VerifyOrQuit(responseContexts[0].mResponseState == MeshCoP::StateTlv::kAccept);
-    VerifyOrQuit(!responseContexts[0].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[0].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Send two `EnrollerJoinerAccept` messages from `enrollers[2]` accepting both `joiners`");
@@ -2466,7 +2677,7 @@
 
         VerifyOrQuit(responseContexts[2].mReceived);
         VerifyOrQuit(responseContexts[2].mResponseState == MeshCoP::StateTlv::kAccept);
-        VerifyOrQuit(!responseContexts[2].mHasAdmitterState);
+        VerifyOrQuit(responseContexts[2].mHasAdmitterState);
     }
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -2535,7 +2746,7 @@
 
     VerifyOrQuit(responseContexts[2].mReceived);
     VerifyOrQuit(responseContexts[2].mResponseState == MeshCoP::StateTlv::kAccept);
-    VerifyOrQuit(!responseContexts[2].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[2].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Validate that all previously accepted joiners by `enrollers[2]` on `admitter` are now removed");
@@ -2576,7 +2787,7 @@
 
     VerifyOrQuit(responseContexts[2].mReceived);
     VerifyOrQuit(responseContexts[2].mResponseState == MeshCoP::StateTlv::kReject);
-    VerifyOrQuit(!responseContexts[2].mHasAdmitterState);
+    VerifyOrQuit(responseContexts[2].mHasAdmitterState);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Send two `EnrollerJoinerAccept` messages from `enrollers[2]` accepting both `joiners`");
@@ -2597,7 +2808,7 @@
 
         VerifyOrQuit(responseContexts[2].mReceived);
         VerifyOrQuit(responseContexts[2].mResponseState == MeshCoP::StateTlv::kAccept);
-        VerifyOrQuit(!responseContexts[2].mHasAdmitterState);
+        VerifyOrQuit(responseContexts[2].mHasAdmitterState);
     }
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -2964,7 +3175,7 @@
 
     static const char *kEnrollerIds[kNumEnrollers] = {"1", "2", "3", "4"};
 
-    static const uint8_t kDiagTlvs[] = {NetworkDiagnostic::Tlv::kExtMacAddress, NetworkDiagnostic::Tlv::kVersion};
+    static const uint8_t kDiagTlvs[] = {NetDiag::Tlv::kExtMacAddress, NetDiag::Tlv::kVersion};
 
     Core                               nexus;
     Node                              &admitter = nexus.CreateNode();
@@ -2985,7 +3196,6 @@
     uint16_t                           sessionId;
     uint16_t                           rloc16;
     MeshCoP::UdpEncapsulationTlvHeader udpEncapHeader;
-    ExtendedTlv                        extTlv;
 
     Log("------------------------------------------------------------------------------------------------------");
     Log("TestBorderAdmitterForwardingUdpProxy");
@@ -3044,7 +3254,7 @@
         recvContext[i].Clear();
         enroller->Get<Tmf::SecureAgent>().RegisterResourceHandler(HandleResource, &recvContext[i]);
 
-        SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Open());
+        SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Open(0));
         SuccessOrQuit(enroller->Get<Tmf::SecureAgent>().Connect(sockAddr));
 
         nexus.AdvanceTime(Time::kOneSecondInMsec);
@@ -3073,8 +3283,7 @@
 
         SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerIdTlv>(*message, kEnrollerIds[i]));
         SuccessOrQuit(Tlv::Append<MeshCoP::EnrollerModeTlv>(*message, modes[i]));
-        SuccessOrQuit(
-            Tlv::Append<MeshCoP::SteeringDataTlv>(*message, steeringData.GetData(), steeringData.GetLength()));
+        SuccessOrQuit(MeshCoP::SteeringDataTlv::AppendTo(*message, steeringData));
 
         responseContexts[i].Clear();
         SuccessOrQuit(
@@ -3138,7 +3347,7 @@
 
     diagMessage = enrollers[0]->Get<Tmf::Agent>().AllocateAndInitNonConfirmablePostMessage(kUriDiagnosticGetQuery);
     VerifyOrQuit(diagMessage != nullptr);
-    SuccessOrQuit(Tlv::Append<NetworkDiagnostic::TypeListTlv>(*diagMessage, kDiagTlvs, sizeof(kDiagTlvs)));
+    SuccessOrQuit(Tlv::Append<NetDiag::TypeListTlv>(*diagMessage, kDiagTlvs, sizeof(kDiagTlvs)));
     diagMessage->WriteMessageId(0);
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -3150,10 +3359,8 @@
     udpEncapHeader.SetSourcePort(Tmf::kUdpPort);
     udpEncapHeader.SetDestinationPort(Tmf::kUdpPort);
 
-    extTlv.SetType(MeshCoP::Tlv::kUdpEncapsulation);
-    extTlv.SetLength(sizeof(udpEncapHeader) + diagMessage->GetLength());
-
-    SuccessOrQuit(message->Append(extTlv));
+    SuccessOrQuit(Tlv::AppendTlvHeader(*message, MeshCoP::Tlv::kUdpEncapsulation,
+                                       sizeof(udpEncapHeader) + diagMessage->GetLength()));
     SuccessOrQuit(message->Append(udpEncapHeader));
     SuccessOrQuit(message->AppendBytesFromMessage(*diagMessage, 0, diagMessage->GetLength()));
     diagMessage->Free();
@@ -3207,43 +3414,50 @@
     Dns::Multicast::Core::Iterator  *iterator;
     Dns::Multicast::Core::Service    service;
     Dns::Multicast::Core::EntryState entryState;
+    bool                             found = false;
 
     iterator = aNode.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(aNode.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-
-    for (uint16_t i = 0; i < service.mSubTypeLabelsLength; i++)
+    while (aNode.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
     {
-        Log("  SubType: %s", service.mSubTypeLabels[i]);
+        Log("- - - - - - - - - - - - - - - - -");
+        Log("  HostName: %s", service.mHostName);
+        Log("  ServiceInstance: %s", service.mServiceInstance);
+        Log("  ServiceType: %s", service.mServiceType);
+
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            for (uint16_t i = 0; i < service.mSubTypeLabelsLength; i++)
+            {
+                Log("  SubType: %s", service.mSubTypeLabels[i]);
+            }
+
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
+
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mPort == aNode.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == 1);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+
+            if (aNode.Get<Admitter>().IsPrimeAdmitter())
+            {
+                VerifyOrQuit(service.mSubTypeLabelsLength == 1);
+                VerifyOrQuit(StringMatch(service.mSubTypeLabels[0], "_admitter"));
+            }
+            else
+            {
+                VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            }
+
+            found = true;
+        }
     }
 
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
-
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mPort == aNode.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == 1);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-
-    if (aNode.Get<Admitter>().IsPrimeAdmitter())
-    {
-        VerifyOrQuit(service.mSubTypeLabelsLength == 1);
-        VerifyOrQuit(StringMatch(service.mSubTypeLabels[0], "_admitter"));
-    }
-    else
-    {
-        VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    }
-
-    VerifyOrQuit(aNode.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+    VerifyOrQuit(found);
 
     aNode.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 }
diff --git a/tests/nexus/test_border_agent.cpp b/tests/nexus/test_border_agent.cpp
index 02fbd97..3d70d20 100644
--- a/tests/nexus/test_border_agent.cpp
+++ b/tests/nexus/test_border_agent.cpp
@@ -113,7 +113,7 @@
     node0.Get<KeyManager>().GetPskc(pskc);
     SuccessOrQuit(node1.Get<Tmf::SecureAgent>().SetPsk(pskc.m8, Pskc::kSize));
 
-    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -145,7 +145,7 @@
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Establish a secure connection again");
 
-    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -245,11 +245,11 @@
     Log("Establish two more secure sessions while the first session is still active");
 
     SuccessOrQuit(node2.Get<Tmf::SecureAgent>().SetPsk(pskc.m8, Pskc::kSize));
-    SuccessOrQuit(node2.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node2.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node2.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     SuccessOrQuit(node3.Get<Tmf::SecureAgent>().SetPsk(pskc.m8, Pskc::kSize));
-    SuccessOrQuit(node3.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node3.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node3.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -313,7 +313,7 @@
 
     nexus.AdvanceTime(25 * Time::kOneSecondInMsec);
 
-    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -481,7 +481,7 @@
     SuccessOrQuit(
         node1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(kEphemeralKey), kEphemeralKeySize));
 
-    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -521,7 +521,7 @@
     VerifyOrQuit(node0.Get<EphemeralKeyManager>().GetState() == EphemeralKeyManager::kStateStarted);
     VerifyOrQuit(node0.Get<EphemeralKeyManager>().GetUdpPort() == kUdpPort);
 
-    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
@@ -619,7 +619,7 @@
     SuccessOrQuit(
         node1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(kEphemeralKey), kEphemeralKeySize));
 
-    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -670,7 +670,7 @@
 
     node0.Get<KeyManager>().GetPskc(pskc);
     SuccessOrQuit(node2.Get<Tmf::SecureAgent>().SetPsk(pskc.m8, Pskc::kSize));
-    SuccessOrQuit(node2.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node2.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node2.Get<Tmf::SecureAgent>().Connect(baSockAddr));
 
     nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -690,7 +690,7 @@
     SuccessOrQuit(
         node1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(kEphemeralKey), kEphemeralKeySize));
 
-    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Connect(sockAddr));
 
     nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -902,7 +902,7 @@
 
     nexus.AdvanceTime(0);
 
-    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open());
+    SuccessOrQuit(node1.Get<Tmf::SecureAgent>().Open(0));
     SuccessOrQuit(
         node1.Get<Tmf::SecureAgent>().SetPsk(reinterpret_cast<const uint8_t *>(kEphemeralKey), kEphemeralKeySize - 2));
 
@@ -1311,6 +1311,10 @@
     static constexpr uint32_t kThreadRoleLeader             = 3 << 9;
     static constexpr uint32_t kFlagEpskcSupported           = 1 << 11;
     static constexpr uint32_t kFlagAdmitterSupported        = 1 << 14;
+#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
+    static constexpr uint32_t kFlagBbrIsActive  = 1 << 7;
+    static constexpr uint32_t kFlagBbrIsPrimary = 1 << 8;
+#endif
 
     MeshCoP::BorderAgent::Id id;
     BaTxtData::Info          info;
@@ -1449,6 +1453,35 @@
         VerifyOrQuit(!info.mStateBitmap.mAdmitterSupported);
     }
 
+#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
+    {
+        bool bbrEnabled = aNode.Get<Mle::Mle>().IsAttached() && aNode.Get<BackboneRouter::Local>().IsEnabled();
+        bool bbrPrimary = aNode.Get<Mle::Mle>().IsAttached() && aNode.Get<BackboneRouter::Local>().IsPrimary();
+
+        if (bbrEnabled)
+        {
+            VerifyOrQuit(stateBitmap & kFlagBbrIsActive);
+            VerifyOrQuit(info.mStateBitmap.mBbrIsActive);
+        }
+        else
+        {
+            VerifyOrQuit(!(stateBitmap & kFlagBbrIsActive));
+            VerifyOrQuit(!info.mStateBitmap.mBbrIsActive);
+        }
+
+        if (bbrPrimary)
+        {
+            VerifyOrQuit(stateBitmap & kFlagBbrIsPrimary);
+            VerifyOrQuit(info.mStateBitmap.mBbrIsPrimary);
+        }
+        else
+        {
+            VerifyOrQuit(!(stateBitmap & kFlagBbrIsPrimary));
+            VerifyOrQuit(!info.mStateBitmap.mBbrIsPrimary);
+        }
+    }
+#endif
+
     if (aExpectVendorInfo)
     {
         const char *expectedVendorName = (aVendorName != nullptr) ? aVendorName : aNode.Get<VendorInfo>().GetName();
@@ -1461,6 +1494,13 @@
         aTxtData.ValidateKey("mn", expectedModelName);
         VerifyOrQuit(info.mHasModelName);
         VerifyOrQuit(StringMatch(info.mModelName, expectedModelName));
+
+        if (aNode.Get<VendorInfo>().GetOui().IsValid())
+        {
+            const VendorInfo::Oui &oui = aNode.Get<VendorInfo>().GetOui();
+
+            aTxtData.ValidateKey("vo", oui.GetBytes(), oui.GetSize());
+        }
     }
     else
     {
@@ -1673,6 +1713,8 @@
     Dns::Multicast::Core::Iterator   *iterator;
     Dns::Multicast::Core::Service     service;
     Dns::Multicast::Core::EntryState  entryState;
+    bool                              foundService;
+    bool                              foundEpskService;
     Dns::Multicast::Core::Browser     browser;
     Dns::Multicast::Core::SrvResolver srvResolver;
     Dns::Multicast::Core::TxtResolver txtResolver;
@@ -1712,26 +1754,33 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
+    foundService = false;
 
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        Log("- - - - - - - - - - - - - - - - -");
+        Log("  HostName: %s", service.mHostName);
+        Log("  ServiceInstance: %s", service.mServiceInstance);
+        Log("  ServiceType: %s", service.mServiceType);
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+            foundService = true;
+        }
+    }
+
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -1800,43 +1849,58 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    for (uint8_t num = 2; num > 0; num--)
+    foundService     = false;
+    foundEpskService = false;
+
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
     {
-        SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-        Log("- - - - - - - - - - - - - - - - -");
-        Log("  HostName: %s", service.mHostName);
-        Log("  ServiceInstance: %s", service.mServiceInstance);
-        Log("  ServiceType: %s", service.mServiceType);
-        Log("  Port: %u", service.mPort);
-        Log("  TTL: %lu", ToUlong(service.mTtl));
-
-        VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
-        VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-        VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-        VerifyOrQuit(service.mTtl > 0);
-        VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-        VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-
         if (StringMatch(service.mServiceType, "_meshcop._udp"))
         {
+            VerifyOrQuit(!foundService);
+            foundService = true;
+
+            Log("- - - - - - - - - - - - - - - - -");
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
+
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
             VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
             ValidateRegisteredServiceData(service, node0);
         }
         else if (StringMatch(service.mServiceType, "_meshcop-e._udp"))
         {
+            VerifyOrQuit(!foundEpskService);
+            foundEpskService = true;
+
+            Log("- - - - - - - - - - - - - - - - -");
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
+
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
             VerifyOrQuit(service.mPort == kUdpPort);
             VerifyOrQuit(service.mTxtDataLength == 1);
             VerifyOrQuit(service.mTxtData[0] == 0);
         }
-        else
-        {
-            // Unexpected service type
-            VerifyOrQuit(false);
-        }
     }
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+    VerifyOrQuit(foundService);
+    VerifyOrQuit(foundEpskService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -1851,25 +1915,36 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, kDefaultServiceBaseName));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
+
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -1883,25 +1958,36 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -1940,28 +2026,36 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
-    VerifyOrQuit(iterator != nullptr);
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
-
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -1981,28 +2075,37 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
-    VerifyOrQuit(iterator != nullptr);
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
+            VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -2017,7 +2120,11 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop._udp"));
+        VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+    }
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -2039,7 +2146,11 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop._udp"));
+        VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+    }
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -2058,26 +2169,38 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
-    txtDataLengthWithNoVendorData = service.mTxtDataLength;
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+            txtDataLengthWithNoVendorData = service.mTxtDataLength;
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
+
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -2096,31 +2219,43 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0, /* aExpectedVendorName */ "nexus");
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    // Check that vendor TXT data is included at the end of
-    // the registered service TXT data.
-    VerifyOrQuit(service.mTxtDataLength > sizeof(kVendorTxtData));
-    VerifyOrQuit(!memcmp(&service.mTxtData[service.mTxtDataLength - sizeof(kVendorTxtData)], kVendorTxtData,
-                         sizeof(kVendorTxtData)));
+            VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0, /* aExpectedVendorName */ "nexus");
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            // Check that vendor TXT data is included at the end of
+            // the registered service TXT data.
+            VerifyOrQuit(service.mTxtDataLength > sizeof(kVendorTxtData));
+            VerifyOrQuit(!memcmp(&service.mTxtData[service.mTxtDataLength - sizeof(kVendorTxtData)], kVendorTxtData,
+                                 sizeof(kVendorTxtData)));
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
+
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -2133,26 +2268,38 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
-    VerifyOrQuit(service.mTxtDataLength == txtDataLengthWithNoVendorData);
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+            VerifyOrQuit(service.mTxtDataLength == txtDataLengthWithNoVendorData);
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
+
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -2165,25 +2312,37 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
+
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
 
@@ -2196,27 +2355,435 @@
     iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
     VerifyOrQuit(iterator != nullptr);
 
-    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState));
-    Log("  HostName: %s", service.mHostName);
-    Log("  ServiceInstance: %s", service.mServiceInstance);
-    Log("  ServiceType: %s", service.mServiceType);
-    Log("  Port: %u", service.mPort);
-    Log("  TTL: %lu", ToUlong(service.mTtl));
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
 
-    VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
-    VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
-    VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
-    VerifyOrQuit(service.mSubTypeLabelsLength == 0);
-    VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
-    VerifyOrQuit(service.mTtl > 0);
-    VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
-    VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
-    ValidateRegisteredServiceData(service, node0);
+            Log("  HostName: %s", service.mHostName);
+            Log("  ServiceInstance: %s", service.mServiceInstance);
+            Log("  ServiceType: %s", service.mServiceType);
+            Log("  Port: %u", service.mPort);
+            Log("  TTL: %lu", ToUlong(service.mTtl));
 
-    // Check that there is no more registered mDNS service
-    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNotFound);
+            VerifyOrQuit(StringMatch(service.mServiceType, "_meshcop._udp"));
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, "OpenThreadAgent"));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+        }
+        else
+        {
+            VerifyOrQuit(!StringMatch(service.mServiceType, "_meshcop-e._udp"));
+        }
+    }
+
+    VerifyOrQuit(foundService);
 
     node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Enable Backbone Router on node0 and validate state bitmap updates");
+
+    node0.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
+    node0.Get<BorderRouter::RoutingManager>().Init();
+    SuccessOrQuit(node0.Get<BorderRouter::RoutingManager>().SetEnabled(true));
+    node0.Get<BackboneRouter::Local>().SetEnabled(true);
+
+    nexus.AdvanceTime(10 * Time::kOneSecondInMsec);
+
+    iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
+    VerifyOrQuit(iterator != nullptr);
+
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
+            ValidateRegisteredServiceData(service, node0);
+        }
+    }
+    VerifyOrQuit(foundService);
+    node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Disable Backbone Router on node0 and validate state bitmap updates");
+
+    node0.Get<BackboneRouter::Local>().SetEnabled(false);
+
+    nexus.AdvanceTime(10 * Time::kOneSecondInMsec);
+
+    iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
+    VerifyOrQuit(iterator != nullptr);
+
+    foundService = false;
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(!foundService);
+            foundService = true;
+            ValidateRegisteredServiceData(service, node0);
+        }
+    }
+    VerifyOrQuit(foundService);
+    node0.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
+}
+
+void TestBorderAgentServiceRegistrationRename(void)
+{
+    static const char kServiceBaseName[] = "VeryLongServiceBaseNameForTestingPurposeOfLimitsMax";
+    static const char kEphemeralKey[]    = "nexus1234";
+
+    static constexpr uint32_t kUdpPort = 49155;
+
+    Core                             nexus;
+    Node                            &node0 = nexus.CreateNode();
+    Node                            &node1 = nexus.CreateNode();
+    Mac::ExtAddress                  extAddress;
+    Dns::Multicast::Core::Iterator  *iterator;
+    Dns::Multicast::Core::Service    service;
+    Dns::Multicast::Core::EntryState entryState;
+    String<Dns::Name::kMaxLabelSize> expectedName;
+    bool                             foundService;
+    bool                             foundEpskService;
+
+    Log("------------------------------------------------------------------------------------------------------");
+    Log("TestBorderAgentServiceRegistrationRename");
+
+    nexus.AdvanceTime(0);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Set the same Ext address on both node0 and node1
+
+    extAddress.GenerateRandom();
+    extAddress.m8[6] = 0xbe;
+    extAddress.m8[7] = 0xef;
+
+    node0.Get<Mac::Mac>().SetExtAddress(extAddress);
+    node1.Get<Mac::Mac>().SetExtAddress(extAddress);
+    VerifyOrQuit(node1.Get<Mac::Mac>().GetExtAddress() == node0.Get<Mac::Mac>().GetExtAddress());
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Set the Service Base Name on both node0 and node1
+    SuccessOrQuit(node0.Get<MeshCoP::BorderAgent::Manager>().SetServiceBaseName(kServiceBaseName));
+    SuccessOrQuit(node1.Get<MeshCoP::BorderAgent::Manager>().SetServiceBaseName(kServiceBaseName));
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Disable Border Agent function on node1
+    node1.Get<MeshCoP::BorderAgent::Manager>().SetEnabled(false);
+
+    node0.Form();
+    node1.Form();
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Construct the expected service instance name label.
+
+    expectedName.Append("%s #%02X%02X", kServiceBaseName, extAddress.m8[6], extAddress.m8[7]);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Enable mDNS
+    SuccessOrQuit(node0.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    VerifyOrQuit(node0.Get<Dns::Multicast::Core>().IsEnabled());
+    SuccessOrQuit(node1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
+    VerifyOrQuit(node1.Get<Dns::Multicast::Core>().IsEnabled());
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+    nexus.AdvanceTime(50 * Time::kOneSecondInMsec);
+    VerifyOrQuit(node0.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(node1.Get<Mle::Mle>().IsLeader());
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+    VerifyOrQuit(node0.Get<MeshCoP::BorderAgent::Manager>().IsEnabled());
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Validate the registered mDNS MeshCop service by Border Agent");
+
+    iterator = node0.Get<Dns::Multicast::Core>().AllocateIterator();
+    VerifyOrQuit(iterator != nullptr);
+
+    foundService = false;
+
+    while (node0.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        Log("- - - - - - - - - - - - - - - - -");
+        Log("  HostName: %s", service.mHostName);
+        Log("  ServiceInstance: %s", service.mServiceInstance);
+        Log("  ServiceType: %s", service.mServiceType);
+        Log("  Port: %u", service.mPort);
+        Log("  TTL: %lu", ToUlong(service.mTtl));
+
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(StringMatch(service.mServiceInstance, expectedName.AsCString()));
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mPort == node0.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node0);
+            foundService = true;
+        }
+    }
+
+    VerifyOrQuit(foundService);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Enable BorderAgent on node1");
+
+    node1.Get<MeshCoP::BorderAgent::Manager>().SetEnabled(true);
+
+    nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Validate the registered mDNS MeshCop service by Border Agent on node1");
+
+    iterator = node1.Get<Dns::Multicast::Core>().AllocateIterator();
+    VerifyOrQuit(iterator != nullptr);
+
+    foundService = false;
+
+    while (node1.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        Log("- - - - - - - - - - - - - - - - -");
+        Log("  HostName: %s", service.mHostName);
+        Log("  ServiceInstance: %s", service.mServiceInstance);
+        Log("  ServiceType: %s", service.mServiceType);
+        Log("  Port: %u", service.mPort);
+        Log("  TTL: %lu", ToUlong(service.mTtl));
+
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            Log("Validate that node1 properly renamed the conflicted service name");
+
+            VerifyOrQuit(StringStartsWith(service.mServiceInstance, expectedName.AsCString()));
+            expectedName.Append(" (1)");
+            VerifyOrQuit(StringMatch(service.mServiceInstance, expectedName.AsCString()));
+
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            ValidateRegisteredServiceData(service, node1);
+            foundService = true;
+        }
+    }
+
+    VerifyOrQuit(foundService);
+
+    node1.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Enable and start ephemeral key on node 1");
+
+    node1.Get<EphemeralKeyManager>().SetEnabled(true);
+    VerifyOrQuit(node1.Get<EphemeralKeyManager>().GetState() == EphemeralKeyManager::kStateStopped);
+    node1.Get<EphemeralKeyManager>().SetCallback(HandleEphemeralKeyChange, &node1);
+
+    SuccessOrQuit(node1.Get<EphemeralKeyManager>().Start(kEphemeralKey, /* aTimeout */ 0, kUdpPort));
+
+    nexus.AdvanceTime(10 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(node1.Get<EphemeralKeyManager>().GetState() == EphemeralKeyManager::kStateStarted);
+    VerifyOrQuit(node1.Get<EphemeralKeyManager>().GetUdpPort() == kUdpPort);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check the registered services - validate the same service name is used for `_meshcop-e` service");
+
+    iterator = node1.Get<Dns::Multicast::Core>().AllocateIterator();
+    VerifyOrQuit(iterator != nullptr);
+
+    foundService     = false;
+    foundEpskService = false;
+
+    while (node1.Get<Dns::Multicast::Core>().GetNextService(*iterator, service, entryState) == kErrorNone)
+    {
+        Log("- - - - - - - - - - - - - - - - -");
+        Log("  HostName: %s", service.mHostName);
+        Log("  ServiceInstance: %s", service.mServiceInstance);
+        Log("  ServiceType: %s", service.mServiceType);
+        Log("  Port: %u", service.mPort);
+        Log("  TTL: %lu", ToUlong(service.mTtl));
+
+        if (StringMatch(service.mServiceType, "_meshcop._udp"))
+        {
+            VerifyOrQuit(StringMatch(service.mServiceInstance, expectedName.AsCString()));
+
+            VerifyOrQuit(service.mPort == node1.Get<MeshCoP::BorderAgent::Manager>().GetUdpPort());
+            ValidateRegisteredServiceData(service, node1);
+
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            foundService = true;
+        }
+        else if (StringMatch(service.mServiceType, "_meshcop-e._udp"))
+        {
+            VerifyOrQuit(StringMatch(service.mServiceInstance, expectedName.AsCString()));
+
+            VerifyOrQuit(service.mPort == kUdpPort);
+            VerifyOrQuit(service.mTxtDataLength == 1);
+            VerifyOrQuit(service.mTxtData[0] == 0);
+
+            VerifyOrQuit(StringStartsWith(service.mHostName, "ot"));
+            VerifyOrQuit(service.mSubTypeLabelsLength == 0);
+            VerifyOrQuit(service.mTtl > 0);
+            VerifyOrQuit(service.mInfraIfIndex == kInfraIfIndex);
+            VerifyOrQuit(entryState == OT_MDNS_ENTRY_STATE_REGISTERED);
+            foundEpskService = true;
+        }
+    }
+
+    VerifyOrQuit(foundService);
+    VerifyOrQuit(foundEpskService);
+
+    node1.Get<Dns::Multicast::Core>().FreeIterator(*iterator);
+}
+
+void TestBorderAgentSessionsLimit(void)
+{
+    Core                     nexus;
+    Node                    &node0 = nexus.CreateNode();
+    Node                    *nodes[20];
+    Ip6::SockAddr            sockAddr;
+    Pskc                     pskc;
+    Manager::SessionIterator iter;
+    Manager::SessionInfo     sessionInfo;
+    uint8_t                  sessionCount;
+
+    Log("------------------------------------------------------------------------------------------------------");
+    Log("TestBorderAgentSessionsLimit");
+
+    nexus.AdvanceTime(0);
+    SuccessOrQuit(node0.SetLogLevel(kLogLevelInfo));
+
+    node0.Form();
+
+    nexus.AdvanceTime(50 * Time::kOneSecondInMsec);
+    VerifyOrQuit(node0.Get<Mle::Mle>().IsLeader());
+
+    for (uint8_t i = 0; i < 20; i++)
+    {
+        nodes[i] = &nexus.CreateNode();
+        SuccessOrQuit(nodes[i]->Get<Mac::Mac>().SetPanChannel(node0.Get<Mac::Mac>().GetPanChannel()));
+        nodes[i]->Get<Mac::Mac>().SetPanId(node0.Get<Mac::Mac>().GetPanId());
+        nodes[i]->Get<ThreadNetif>().Up();
+    }
+
+    VerifyOrQuit(node0.Get<Manager>().IsEnabled());
+    VerifyOrQuit(node0.Get<Manager>().IsRunning());
+
+    SuccessOrQuit(node0.Get<Ip6::Filter>().AddUnsecurePort(node0.Get<Manager>().GetUdpPort()));
+
+    sockAddr.SetAddress(node0.Get<Mle::Mle>().GetLinkLocalAddress());
+    sockAddr.SetPort(node0.Get<Manager>().GetUdpPort());
+    node0.Get<KeyManager>().GetPskc(pskc);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Establish 16 concurrent secure sessions (the limit)");
+
+    for (uint8_t i = 0; i < 16; i++)
+    {
+        SuccessOrQuit(nodes[i]->Get<Tmf::SecureAgent>().SetPsk(pskc.m8, Pskc::kSize));
+        SuccessOrQuit(nodes[i]->Get<Tmf::SecureAgent>().Open(0));
+        SuccessOrQuit(nodes[i]->Get<Tmf::SecureAgent>().Connect(sockAddr));
+    }
+
+    nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
+
+    for (uint8_t i = 0; i < 16; i++)
+    {
+        VerifyOrQuit(nodes[i]->Get<Tmf::SecureAgent>().IsConnected());
+    }
+
+    VerifyOrQuit(node0.Get<Manager>().GetCounters().mPskcSecureSessionSuccesses == 16);
+
+    // Verify exactly 16 sessions are connected
+    sessionCount = 0;
+    iter.Init(node0.GetInstance());
+    while (iter.GetNextSessionInfo(sessionInfo) == kErrorNone)
+    {
+        VerifyOrQuit(sessionInfo.mIsConnected);
+        sessionCount++;
+    }
+    VerifyOrQuit(sessionCount == 16);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Try to establish a 17th secure session, should be rejected");
+
+    SuccessOrQuit(nodes[16]->Get<Tmf::SecureAgent>().SetPsk(pskc.m8, Pskc::kSize));
+    SuccessOrQuit(nodes[16]->Get<Tmf::SecureAgent>().Open(0));
+    SuccessOrQuit(nodes[16]->Get<Tmf::SecureAgent>().Connect(sockAddr));
+
+    nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(!nodes[16]->Get<Tmf::SecureAgent>().IsConnected());
+    VerifyOrQuit(node0.Get<Manager>().GetCounters().mPskcSecureSessionSuccesses == 16);
+
+    // Verify count remains 16
+    sessionCount = 0;
+    iter.Init(node0.GetInstance());
+    while (iter.GetNextSessionInfo(sessionInfo) == kErrorNone)
+    {
+        sessionCount++;
+    }
+    VerifyOrQuit(sessionCount == 16);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Close all sessions to clean up");
+
+    for (uint8_t i = 0; i < 17; i++)
+    {
+        nodes[i]->Get<Tmf::SecureAgent>().Close();
+    }
+
+    nexus.AdvanceTime(5 * Time::kOneSecondInMsec);
+
+    iter.Init(node0.GetInstance());
+    VerifyOrQuit(iter.GetNextSessionInfo(sessionInfo) == kErrorNotFound);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Verify Handshake Timeout: start a session but block the client right after first packet");
+
+    SuccessOrQuit(nodes[0]->Get<Tmf::SecureAgent>().SetPsk(pskc.m8, Pskc::kSize));
+    SuccessOrQuit(nodes[0]->Get<Tmf::SecureAgent>().Open(0));
+    SuccessOrQuit(nodes[0]->Get<Tmf::SecureAgent>().Connect(sockAddr));
+
+    // Drop client's interface immediately so it never receives HelloVerifyRequest
+    nodes[0]->Get<ThreadNetif>().Down();
+
+    // Wait for 14 seconds (handshake timeout is 15 seconds)
+    nexus.AdvanceTime(14 * Time::kOneSecondInMsec);
+
+    // The session should still be in connecting state on node0
+    iter.Init(node0.GetInstance());
+    SuccessOrQuit(iter.GetNextSessionInfo(sessionInfo));
+    VerifyOrQuit(!sessionInfo.mIsConnected);
+    VerifyOrQuit(iter.GetNextSessionInfo(sessionInfo) == kErrorNotFound);
+
+    // Wait for additional 4 seconds (total 18 seconds, past the 15-second timeout + 2-second guard time)
+    nexus.AdvanceTime(4 * Time::kOneSecondInMsec);
+
+    // The session should have timed out and been removed
+    iter.Init(node0.GetInstance());
+    VerifyOrQuit(iter.GetNextSessionInfo(sessionInfo) == kErrorNotFound);
+
+    Log("TestBorderAgentSessionsLimit passed successfully!");
 }
 
 } // namespace Nexus
@@ -2230,6 +2797,8 @@
     ot::Nexus::TestHistoryTrackerBorderAgentEpskcEvent();
     ot::Nexus::TestBorderAgentTxtDataCallback();
     ot::Nexus::TestBorderAgentServiceRegistration();
+    ot::Nexus::TestBorderAgentServiceRegistrationRename();
+    ot::Nexus::TestBorderAgentSessionsLimit();
     printf("All tests passed\n");
     return 0;
 }
diff --git a/tests/nexus/test_br_upgrade_router_role.cpp b/tests/nexus/test_br_upgrade_router_role.cpp
new file mode 100644
index 0000000..8a4f362
--- /dev/null
+++ b/tests/nexus/test_br_upgrade_router_role.cpp
@@ -0,0 +1,187 @@
+/*
+ *  Copyright (c) 2024, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router.
+ */
+static constexpr uint32_t kAttachToRouterTime = 125 * 1000;
+
+/**
+ * Time to wait for BR to upgrade to router.
+ */
+static constexpr uint32_t kBrUpgradeTime = 15 * 1000;
+
+void TestBrUpgradeRouterRole(void)
+{
+    // This test verifies behavior of Border Routers (which provide IP connectivity) requesting router role within
+    // Thread mesh.
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &br1    = nexus.CreateNode();
+    Node &br2    = nexus.CreateNode();
+    Node &br3    = nexus.CreateNode();
+
+    leader.SetName("Leader");
+    router.SetName("Router");
+    br1.SetName("BR_1");
+    br2.SetName("BR_2");
+    br3.SetName("BR_3");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Set the router upgrade threshold to 2 on all nodes.");
+
+    leader.Get<Mle::Mle>().SetRouterUpgradeThreshold(2);
+    leader.Get<Mle::Mle>().SetLeaderUpgradeThreshold(2);
+
+    router.Get<Mle::Mle>().SetRouterUpgradeThreshold(2);
+    br1.Get<Mle::Mle>().SetRouterUpgradeThreshold(2);
+    br2.Get<Mle::Mle>().SetRouterUpgradeThreshold(2);
+    br3.Get<Mle::Mle>().SetRouterUpgradeThreshold(2);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Start the leader and router.");
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Start all three BRs, we expect all to stay as child since there are already two");
+    Log("routers in the Thread mesh.");
+
+    br1.Join(leader);
+    br2.Join(leader);
+    br3.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(br2.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(br3.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Add an external route on `br1`, it should now try to become a router requesting");
+    Log("with status BorderRouterRequest. Verify that leader allows it to become router.");
+
+    {
+        NetworkData::ExternalRouteConfig routeConfig;
+        routeConfig.Clear();
+        SuccessOrQuit(routeConfig.GetPrefix().FromString("2001:dead:beef:cafe::/64"));
+        routeConfig.mStable = true;
+        SuccessOrQuit(br1.Get<NetworkData::Local>().AddHasRoutePrefix(routeConfig));
+        br1.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+
+    nexus.AdvanceTime(kBrUpgradeTime);
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Add a prefix with default route on `br2`, it should also become a router.");
+
+    {
+        NetworkData::OnMeshPrefixConfig prefixConfig;
+        prefixConfig.Clear();
+        SuccessOrQuit(prefixConfig.GetPrefix().FromString("2001:dead:beef:2222::/64"));
+        prefixConfig.mSlaac        = true;
+        prefixConfig.mPreferred    = true;
+        prefixConfig.mOnMesh       = true;
+        prefixConfig.mDefaultRoute = true;
+        prefixConfig.mStable       = true;
+        SuccessOrQuit(br2.Get<NetworkData::Local>().AddOnMeshPrefix(prefixConfig));
+        br2.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+
+    nexus.AdvanceTime(kBrUpgradeTime);
+    VerifyOrQuit(br2.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Add an external route on `br3`, it should not become a router since we already have");
+    Log("two that requested router role upgrade as border router reason.");
+
+    {
+        NetworkData::ExternalRouteConfig routeConfig;
+        routeConfig.Clear();
+        SuccessOrQuit(routeConfig.GetPrefix().FromString("2001:dead:beef:cafe::/64"));
+        routeConfig.mStable = true;
+        SuccessOrQuit(br3.Get<NetworkData::Local>().AddHasRoutePrefix(routeConfig));
+        br3.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(br3.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Remove the external route on `br1`. This should now trigger `br3` to request a");
+    Log("router role upgrade since number of BRs acting as router in network data is now");
+    Log("below the threshold of two.");
+
+    {
+        Ip6::Prefix prefix;
+        SuccessOrQuit(prefix.FromString("2001:dead:beef:cafe::/64"));
+        SuccessOrQuit(br1.Get<NetworkData::Local>().RemoveHasRoutePrefix(prefix));
+        br1.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+
+    nexus.AdvanceTime(kBrUpgradeTime);
+    VerifyOrQuit(br1.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(br3.Get<Mle::Mle>().IsRouter());
+
+    nexus.SaveTestInfo("test_br_upgrade_router_role.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestBrUpgradeRouterRole();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_child_supervision.cpp b/tests/nexus/test_child_supervision.cpp
new file mode 100644
index 0000000..6f9c2d0
--- /dev/null
+++ b/tests/nexus/test_child_supervision.cpp
@@ -0,0 +1,169 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/child_supervision.hpp"
+#include "thread/child_table.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestChildSupervision(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &sed    = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    sed.SetName("SED");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form network");
+
+    leader.Form();
+    nexus.AdvanceTime(15 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    sed.Join(leader, Node::kAsSed);
+    nexus.AdvanceTime(5 * 1000);
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+
+    SuccessOrQuit(sed.Get<DataPollSender>().SetExternalPollPeriod(500));
+
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetCounter() == 0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Check initial supervision interval");
+
+    {
+        Child *child = leader.Get<ChildTable>().GetChildAtIndex(0);
+        VerifyOrQuit(child != nullptr);
+        VerifyOrQuit(child->GetSupervisionInterval() == sed.Get<SupervisionListener>().GetInterval());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Change supervision interval on child");
+
+    sed.Get<SupervisionListener>().SetInterval(20);
+    nexus.AdvanceTime(2 * 1000);
+
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetInterval() == 20);
+    {
+        Child *child = leader.Get<ChildTable>().GetChildAtIndex(0);
+        VerifyOrQuit(child != nullptr);
+        VerifyOrQuit(child->GetSupervisionInterval() == 20);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Change supervision check timeout on the child");
+
+    sed.Get<SupervisionListener>().SetTimeout(25);
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetTimeout() == 25);
+
+    // Wait for multiple supervision intervals and ensure that child stays attached
+    nexus.AdvanceTime(110 * 1000);
+
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetCounter() == 0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Disable supervision check on child and block traffic from child on parent");
+
+    sed.Get<SupervisionListener>().SetTimeout(0);
+
+    leader.Get<Mac::Filter>().SetMode(Mac::Filter::kModeAllowlist);
+    // sed is not in allowlist, so it will be blocked.
+
+    uint32_t childTimeout = leader.Get<ChildTable>().GetChildAtIndex(0)->GetTimeout();
+
+    nexus.AdvanceTime((childTimeout + 1) * 1000);
+    VerifyOrQuit(leader.Get<ChildTable>().GetNumChildren(Child::kInStateValid) == 0);
+
+    // Since supervision check is disabled on the child, it should continue to stay attached (since data polls are acked
+    // by radio driver).
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetCounter() == 0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Re-enable supervision check on child");
+
+    sed.Get<SupervisionListener>().SetTimeout(25);
+
+    nexus.AdvanceTime(35 * 1000);
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsDetached());
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetCounter() > 0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Disable allowlist on parent and re-attach");
+
+    leader.Get<Mac::Filter>().SetMode(Mac::Filter::kModeRssInOnly);
+    nexus.AdvanceTime(30 * 1000);
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+    sed.Get<SupervisionListener>().ResetCounter();
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetCounter() == 0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Set the supervision interval to zero on child");
+
+    sed.Get<SupervisionListener>().SetInterval(0);
+    sed.Get<SupervisionListener>().SetTimeout(25);
+    nexus.AdvanceTime(2 * 1000);
+
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetInterval() == 0);
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetTimeout() == 25);
+
+    {
+        Child *child = leader.Get<ChildTable>().GetChildAtIndex(0);
+        VerifyOrQuit(child != nullptr);
+        VerifyOrQuit(child->GetSupervisionInterval() == 0);
+    }
+
+    // Wait for multiple check timeouts. The child should still stay attached.
+    nexus.AdvanceTime(100 * 1000);
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(leader.Get<ChildTable>().GetNumChildren(Child::kInStateValid) == 1);
+    VerifyOrQuit(sed.Get<SupervisionListener>().GetCounter() > 0);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestChildSupervision();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_cli_basic.cpp b/tests/nexus/test_cli_basic.cpp
new file mode 100644
index 0000000..0fce88c
--- /dev/null
+++ b/tests/nexus/test_cli_basic.cpp
@@ -0,0 +1,184 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestCliBasic(void)
+{
+    // Validate basic CLI commands.
+
+    static constexpr uint16_t kNumRouters = 8;
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node *routers[kNumRouters];
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNone));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Initial state");
+
+    VerifyOrQuit(leader.GetCliOutputLines().GetLength() == 0);
+
+    leader.InputCli("state");
+    VerifyOrQuit(leader.IsCliOutputSuccess());
+    VerifyOrQuit(leader.GetCliOutputLines()[0].Matches("disabled"));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form network on `leader`");
+
+    leader.InputCli("dataset init new");
+    VerifyOrQuit(leader.IsCliOutputSuccess());
+
+    leader.InputCli("dataset");
+
+    Log("`dataset` command output on `leader`");
+
+    for (const Node::CliOutputLine &line : leader.GetCliOutputLines())
+    {
+        Log("- %s", line.GetLine());
+    }
+
+    leader.InputCli("dataset commit active");
+    VerifyOrQuit(leader.IsCliOutputSuccess());
+
+    leader.InputCli("ifconfig up");
+    VerifyOrQuit(leader.IsCliOutputSuccess());
+
+    leader.InputCli("thread start");
+    VerifyOrQuit(leader.IsCliOutputSuccess());
+
+    nexus.AdvanceTime(2 * Time::kOneMinuteInMsec);
+
+    leader.InputCli("state");
+    VerifyOrQuit(leader.IsCliOutputSuccess());
+    VerifyOrQuit(leader.GetCliOutputLines()[0].Matches("leader"));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Join %u routers to same network", kNumRouters);
+
+    for (Node *&router : routers)
+    {
+        router = &nexus.CreateNode();
+
+        router->InputCli("state");
+        VerifyOrQuit(router->IsCliOutputSuccess());
+        VerifyOrQuit(router->GetCliOutputLines()[0].Matches("disabled"));
+
+        router->InputCli("dataset clear");
+
+        // Set network key and channel
+
+        leader.InputCli("networkkey");
+        VerifyOrQuit(leader.IsCliOutputSuccess());
+        router->InputCli("dataset networkkey %s", leader.GetCliOutputLines()[0].GetLine());
+        VerifyOrQuit(router->IsCliOutputSuccess());
+
+        leader.InputCli("channel");
+        VerifyOrQuit(leader.IsCliOutputSuccess());
+        router->InputCli("dataset channel %s", leader.GetCliOutputLines()[0].GetLine());
+        VerifyOrQuit(router->IsCliOutputSuccess());
+
+        router->InputCli("dataset commit active");
+        VerifyOrQuit(router->IsCliOutputSuccess());
+
+        router->InputCli("ifconfig up");
+        VerifyOrQuit(router->IsCliOutputSuccess());
+
+        router->InputCli("thread start");
+        VerifyOrQuit(router->IsCliOutputSuccess());
+
+        nexus.AdvanceTime(100 * Time::kOneSecondInMsec);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Make sure all routers are attached");
+
+    nexus.AdvanceTime(7 * Time::kOneMinuteInMsec);
+
+    for (Node *router : routers)
+    {
+        router->InputCli("state");
+        VerifyOrQuit(router->IsCliOutputSuccess());
+        VerifyOrQuit(router->GetCliOutputLines()[0].Matches("router"));
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("The neighbor table");
+
+    leader.InputCli("neighbor table");
+    VerifyOrQuit(leader.IsCliOutputSuccess());
+
+    for (const Node::CliOutputLine &line : leader.GetCliOutputLines())
+    {
+        Log("- %s", line.GetLine());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("The router table");
+
+    leader.InputCli("router table");
+    VerifyOrQuit(leader.IsCliOutputSuccess());
+
+    for (const Node::CliOutputLine &line : leader.GetCliOutputLines())
+    {
+        Log("- %s", line.GetLine());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Check behavior with an invalid CLI command");
+
+    leader.InputCli("invalidcommand");
+    VerifyOrQuit(!leader.IsCliOutputSuccess());
+    VerifyOrQuit(leader.GetCliOutputLines().GetLength() == 1);
+    VerifyOrQuit(leader.GetCliOutputLines()[0].StartsWith("Error "));
+
+    for (const Node::CliOutputLine &line : leader.GetCliOutputLines())
+    {
+        Log("- %s", line.GetLine());
+    }
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestCliBasic();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_coap_block.cpp b/tests/nexus/test_coap_block.cpp
new file mode 100644
index 0000000..e5eb2c4
--- /dev/null
+++ b/tests/nexus/test_coap_block.cpp
@@ -0,0 +1,269 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static otError TransmitHook(void *aContext, uint8_t *aBlock, uint32_t aPosition, uint16_t *aBlockLength, bool *aMore);
+
+static bool       sRequestHandlerCalled         = false;
+static bool       sReceiveHookCalled            = false;
+static bool       sTransmitHookCalled           = false;
+static bool       sReproductionResponseReceived = false;
+static otCoapCode sReproductionResponseCode     = OT_COAP_CODE_EMPTY;
+
+static void HandleReproductionResponse(void                *aContext,
+                                       otMessage           *aMessage,
+                                       const otMessageInfo *aMessageInfo,
+                                       otError              aResult)
+{
+    OT_UNUSED_VARIABLE(aContext);
+    OT_UNUSED_VARIABLE(aMessageInfo);
+    OT_UNUSED_VARIABLE(aResult);
+
+    sReproductionResponseReceived = true;
+    if (aMessage != nullptr)
+    {
+        sReproductionResponseCode = otCoapMessageGetCode(aMessage);
+    }
+}
+
+static void HandleRequest(void *aContext, otMessage *aMessage, const otMessageInfo *aMessageInfo)
+{
+    Instance      &instance = *static_cast<Instance *>(aContext);
+    Coap::Message &message  = AsCoapMessage(aMessage);
+    Coap::Code     code     = static_cast<Coap::Code>(message.ReadCode());
+    Coap::Message *response = nullptr;
+
+    sRequestHandlerCalled = true;
+
+    if (code == Coap::kCodeGet)
+    {
+        response = instance.Get<Coap::ApplicationCoap>().NewMessage();
+        VerifyOrQuit(response != nullptr);
+        SuccessOrQuit(response->InitAsResponse(Coap::kTypeAck, Coap::kCodeContent, message));
+
+        Coap::Option::Iterator iterator;
+        SuccessOrQuit(iterator.Init(message, Coap::kOptionBlock2));
+        if (iterator.GetOption() != nullptr)
+        {
+            uint64_t blockValue = 0;
+            SuccessOrQuit(iterator.ReadOptionValue(blockValue));
+            uint32_t blockNum = static_cast<uint32_t>(blockValue >> 4);
+            bool     more     = (blockNum < 2); // We send 3 blocks in total (0, 1, 2)
+
+            Coap::BlockInfo blockInfo;
+            blockInfo.mBlockNumber = blockNum;
+            blockInfo.mBlockSzx    = static_cast<Coap::BlockSzx>(blockValue & 0x7);
+            blockInfo.mMoreBlocks  = more;
+
+            SuccessOrQuit(response->AppendBlockOption(Coap::kOptionBlock2, blockInfo));
+        }
+
+        SuccessOrQuit(instance.Get<Coap::ApplicationCoap>().SendMessageWithResponseHandlerSeparateParams(
+            *response, AsCoreType(aMessageInfo), nullptr, nullptr, &TransmitHook, nullptr, nullptr));
+    }
+    else
+    {
+        response = instance.Get<Coap::ApplicationCoap>().NewMessage();
+        VerifyOrQuit(response != nullptr);
+        SuccessOrQuit(response->InitAsResponse(Coap::kTypeAck, Coap::kCodeChanged, message));
+        SuccessOrQuit(instance.Get<Coap::ApplicationCoap>().SendMessage(*response, AsCoreType(aMessageInfo)));
+    }
+}
+
+static otError ReceiveHook(void          *aContext,
+                           const uint8_t *aBlock,
+                           uint32_t       aPosition,
+                           uint16_t       aBlockLength,
+                           bool           aMore,
+                           uint32_t       aTotalLength)
+{
+    OT_UNUSED_VARIABLE(aContext);
+    OT_UNUSED_VARIABLE(aBlock);
+    OT_UNUSED_VARIABLE(aPosition);
+    OT_UNUSED_VARIABLE(aBlockLength);
+    OT_UNUSED_VARIABLE(aMore);
+    OT_UNUSED_VARIABLE(aTotalLength);
+    sReceiveHookCalled = true;
+    return OT_ERROR_NONE;
+}
+
+static otError TransmitHook(void *aContext, uint8_t *aBlock, uint32_t aPosition, uint16_t *aBlockLength, bool *aMore)
+{
+    OT_UNUSED_VARIABLE(aContext);
+    OT_UNUSED_VARIABLE(aBlock);
+    OT_UNUSED_VARIABLE(aPosition);
+
+    // Provide some data
+    memset(aBlock, 0xaa, *aBlockLength);
+
+    const uint32_t kTotalLength = 48; // 3 blocks of 16 bytes
+
+    if (aPosition + *aBlockLength >= kTotalLength)
+    {
+        *aMore        = false;
+        *aBlockLength = kTotalLength - aPosition;
+    }
+    else
+    {
+        *aMore = true;
+    }
+
+    sTransmitHookCalled = true;
+    return OT_ERROR_NONE;
+}
+
+void TestCoapBlock(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("Form network");
+    leader.Form();
+    nexus.AdvanceTime(13 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(10 * 1000);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsChild() || router.Get<Mle::Mle>().IsRouter());
+
+    // Start CoAP on Leader
+    SuccessOrQuit(leader.Get<Coap::ApplicationCoap>().Start(OT_DEFAULT_COAP_PORT));
+
+    Coap::ResourceBlockWise resource("test", &HandleRequest, &leader.GetInstance(), &ReceiveHook, &TransmitHook);
+
+    leader.Get<Coap::ApplicationCoap>().AddBlockWiseResource(resource);
+
+    // Start CoAP on Router
+    SuccessOrQuit(router.Get<Coap::ApplicationCoap>().Start(OT_DEFAULT_COAP_PORT));
+
+    // Router sends GET request
+    Coap::Message *message = router.Get<Coap::ApplicationCoap>().NewMessage();
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->Init(Coap::kTypeConfirmable, Coap::kCodeGet));
+    SuccessOrQuit(message->AppendUriPathOptions("test"));
+
+    Coap::BlockInfo blockInfo;
+    blockInfo.mBlockNumber = 0;
+    blockInfo.mBlockSzx    = Coap::kBlockSzx16;
+    blockInfo.mMoreBlocks  = false;
+    SuccessOrQuit(message->AppendBlockOption(Coap::kOptionBlock2, blockInfo));
+
+    Ip6::MessageInfo messageInfo;
+    messageInfo.SetPeerAddr(leader.Get<Mle::Mle>().GetMeshLocalEid());
+    messageInfo.SetPeerPort(OT_DEFAULT_COAP_PORT);
+
+    sRequestHandlerCalled = false;
+    sTransmitHookCalled   = false;
+    sReceiveHookCalled    = false;
+
+    SuccessOrQuit(router.Get<Coap::ApplicationCoap>().SendMessageWithResponseHandlerSeparateParams(
+        *message, messageInfo, nullptr, nullptr, &TransmitHook, &ReceiveHook, nullptr));
+
+    nexus.AdvanceTime(5 * 1000);
+
+    // Verify hooks were called
+    VerifyOrQuit(sRequestHandlerCalled);
+    VerifyOrQuit(sTransmitHookCalled); // Leader should transmit blocks
+    VerifyOrQuit(sReceiveHookCalled);  // Router should receive blocks
+
+    // Router sends PUT request
+    message = router.Get<Coap::ApplicationCoap>().NewMessage();
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->Init(Coap::kTypeConfirmable, Coap::kCodePut));
+    SuccessOrQuit(message->AppendUriPathOptions("test"));
+
+    blockInfo.mBlockNumber = 0;
+    blockInfo.mBlockSzx    = Coap::kBlockSzx16;
+    blockInfo.mMoreBlocks  = true;
+    SuccessOrQuit(message->AppendBlockOption(Coap::kOptionBlock1, blockInfo));
+
+    sRequestHandlerCalled = false;
+    sTransmitHookCalled   = false;
+    sReceiveHookCalled    = false;
+
+    SuccessOrQuit(router.Get<Coap::ApplicationCoap>().SendMessageWithResponseHandlerSeparateParams(
+        *message, messageInfo, nullptr, nullptr, &TransmitHook, &ReceiveHook, nullptr));
+
+    nexus.AdvanceTime(5 * 1000);
+
+    VerifyOrQuit(sRequestHandlerCalled);
+    VerifyOrQuit(sTransmitHookCalled); // Router should transmit blocks
+    VerifyOrQuit(sReceiveHookCalled);  // Leader should receive blocks
+
+    // Reproduction test case:
+    // Router sends GET request with Block2 number 1 (NUM > 0) but without active transfer
+    message = router.Get<Coap::ApplicationCoap>().NewMessage();
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->Init(Coap::kTypeConfirmable, Coap::kCodeGet));
+    SuccessOrQuit(message->AppendUriPathOptions("test"));
+
+    blockInfo.mBlockNumber = 1;
+    blockInfo.mBlockSzx    = Coap::kBlockSzx16;
+    blockInfo.mMoreBlocks  = false;
+    SuccessOrQuit(message->AppendBlockOption(Coap::kOptionBlock2, blockInfo));
+
+    sReproductionResponseReceived = false;
+    sReproductionResponseCode     = OT_COAP_CODE_EMPTY;
+
+    SuccessOrQuit(router.Get<Coap::ApplicationCoap>().SendMessageWithResponseHandlerSeparateParams(
+        *message, messageInfo, nullptr, &HandleReproductionResponse, nullptr, nullptr, nullptr));
+
+    nexus.AdvanceTime(5 * 1000);
+
+    VerifyOrQuit(sReproductionResponseReceived);
+    VerifyOrQuit(sReproductionResponseCode == OT_COAP_CODE_REQUEST_INCOMPLETE);
+
+    leader.Get<Coap::ApplicationCoap>().RemoveBlockWiseResource(resource);
+    IgnoreError(leader.Get<Coap::ApplicationCoap>().Stop());
+    IgnoreError(router.Get<Coap::ApplicationCoap>().Stop());
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestCoapBlock();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_coap_observe.cpp b/tests/nexus/test_coap_observe.cpp
new file mode 100644
index 0000000..a9bf39b
--- /dev/null
+++ b/tests/nexus/test_coap_observe.cpp
@@ -0,0 +1,260 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static bool     sRequestHandlerCalled = false;
+static bool     sNotificationReceived = false;
+static uint32_t sObserveValue         = 0;
+static char     sReceivedPayload[32];
+static bool     sSubscriptionActive = false;
+
+static Coap::Token  sSubscriberToken;
+static otIp6Address sSubscriberAddr;
+static uint16_t     sSubscriberPort;
+static bool         sSubscriberPresent = false;
+
+static void HandleRequest(void *aContext, otMessage *aMessage, const otMessageInfo *aMessageInfo)
+{
+    Instance      &instance = *static_cast<Instance *>(aContext);
+    Coap::Message &message  = AsCoapMessage(aMessage);
+    Coap::Code     code     = static_cast<Coap::Code>(message.ReadCode());
+    Coap::Message *response = nullptr;
+
+    Log("HandleRequest called");
+
+    sRequestHandlerCalled = true;
+
+    if (code == Coap::kCodeGet)
+    {
+        response = instance.Get<Coap::ApplicationCoap>().NewMessage();
+        VerifyOrQuit(response != nullptr);
+
+        SuccessOrQuit(response->InitAsResponse(Coap::kTypeAck, Coap::kCodeContent, message));
+
+        Coap::Option::Iterator iterator;
+        SuccessOrQuit(iterator.Init(message, Coap::kOptionObserve));
+        if (iterator.GetOption() != nullptr)
+        {
+            uint64_t observe = 0;
+            SuccessOrQuit(iterator.ReadOptionValue(observe));
+
+            if (observe == 0)
+            {
+                // New subscriber
+                sSubscriberAddr = aMessageInfo->mPeerAddr;
+                sSubscriberPort = aMessageInfo->mPeerPort;
+                SuccessOrQuit(message.ReadToken(sSubscriberToken));
+                sSubscriberPresent = true;
+
+                // Append Observe option to response
+                SuccessOrQuit(response->AppendObserveOption(0));
+            }
+            else if (observe == 1)
+            {
+                // Cancel subscription
+                sSubscriberPresent = false;
+            }
+        }
+
+        SuccessOrQuit(response->AppendPayloadMarker());
+        SuccessOrQuit(response->AppendBytes("Test123", 7));
+
+        SuccessOrQuit(instance.Get<Coap::ApplicationCoap>().SendMessage(*response, AsCoreType(aMessageInfo)));
+    }
+}
+
+static void HandleNotification(void *aContext, otMessage *aMessage, const otMessageInfo *aMessageInfo, otError aResult)
+{
+    OT_UNUSED_VARIABLE(aContext);
+    OT_UNUSED_VARIABLE(aMessageInfo);
+
+    Log("HandleNotification called with result %d", aResult);
+
+    if (!sSubscriptionActive)
+    {
+        Log("Subscription inactive, ignoring message");
+        return;
+    }
+
+    if (aResult != OT_ERROR_NONE)
+    {
+        return;
+    }
+
+    sNotificationReceived = true;
+
+    uint16_t length = AsCoapMessage(aMessage).GetLength() - AsCoapMessage(aMessage).GetOffset();
+    Log("Message length: %u, offset: %u", AsCoapMessage(aMessage).GetLength(), AsCoapMessage(aMessage).GetOffset());
+
+    if (length > 0)
+    {
+        length = Min(length, static_cast<uint16_t>(sizeof(sReceivedPayload) - 1));
+        SuccessOrQuit(AsCoapMessage(aMessage).Read(AsCoapMessage(aMessage).GetOffset(), sReceivedPayload, length));
+        sReceivedPayload[length] = '\0';
+    }
+
+    // Only check options if it's a notification (payload starts with "msg")
+    if (strncmp(sReceivedPayload, "msg", 3) == 0)
+    {
+        Coap::Option::Iterator iterator;
+        SuccessOrQuit(iterator.Init(AsCoapMessage(aMessage), Coap::kOptionObserve));
+        if (iterator.GetOption() != nullptr)
+        {
+            uint64_t observe = 0;
+            SuccessOrQuit(iterator.ReadOptionValue(observe));
+            sObserveValue = static_cast<uint32_t>(observe);
+        }
+    }
+}
+
+void TestCoapObserve(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("Form network");
+    leader.Form();
+    nexus.AdvanceTime(13 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(10 * 1000);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsChild() || router.Get<Mle::Mle>().IsRouter());
+
+    // Start CoAP on Leader
+    SuccessOrQuit(leader.Get<Coap::ApplicationCoap>().Start(OT_DEFAULT_COAP_PORT));
+
+    Coap::Resource resource("test", &HandleRequest, &leader.GetInstance());
+    leader.Get<Coap::ApplicationCoap>().AddResource(resource);
+
+    // Start CoAP on Router
+    SuccessOrQuit(router.Get<Coap::ApplicationCoap>().Start(OT_DEFAULT_COAP_PORT));
+
+    // Router sends Observe request
+    Coap::Message *message = router.Get<Coap::ApplicationCoap>().NewMessage();
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->Init(Coap::kTypeConfirmable, Coap::kCodeGet));
+    SuccessOrQuit(message->AppendObserveOption(0));
+    SuccessOrQuit(message->AppendUriPathOptions("test"));
+
+    Coap::Token token;
+    SuccessOrQuit(token.SetToken(reinterpret_cast<const uint8_t *>("12345678"), 8));
+    IgnoreError(message->WriteToken(token));
+
+    Ip6::MessageInfo messageInfo;
+    messageInfo.SetPeerAddr(leader.Get<Mle::Mle>().GetMeshLocalEid());
+    messageInfo.SetPeerPort(OT_DEFAULT_COAP_PORT);
+
+    sRequestHandlerCalled = false;
+    sSubscriberPresent    = false;
+    sSubscriptionActive   = true;
+
+    SuccessOrQuit(router.Get<Coap::ApplicationCoap>().SendMessageWithResponseHandlerSeparateParams(
+        *message, messageInfo, nullptr, &HandleNotification, nullptr, nullptr, nullptr));
+
+    nexus.AdvanceTime(5 * 1000);
+
+    VerifyOrQuit(sRequestHandlerCalled);
+    VerifyOrQuit(sSubscriberPresent);
+    VerifyOrQuit(sNotificationReceived); // Initial response acts as first notification
+    VerifyOrQuit(strcmp(sReceivedPayload, "Test123") == 0);
+
+    // Now Leader sends a notification
+    sNotificationReceived = false;
+
+    Coap::Message *notification = leader.Get<Coap::ApplicationCoap>().NewMessage();
+    VerifyOrQuit(notification != nullptr);
+    SuccessOrQuit(notification->Init(Coap::kTypeNonConfirmable, Coap::kCodeContent));
+    SuccessOrQuit(notification->WriteToken(sSubscriberToken));
+    SuccessOrQuit(notification->AppendObserveOption(1));
+    SuccessOrQuit(notification->AppendPayloadMarker());
+    SuccessOrQuit(notification->AppendBytes("msg0", 4));
+
+    messageInfo.SetPeerAddr(AsCoreType(&sSubscriberAddr));
+    messageInfo.SetPeerPort(sSubscriberPort);
+
+    SuccessOrQuit(leader.Get<Coap::ApplicationCoap>().SendMessageWithResponseHandlerSeparateParams(
+        *notification, messageInfo, nullptr, nullptr, nullptr, nullptr, nullptr));
+
+    nexus.AdvanceTime(5 * 1000);
+
+    VerifyOrQuit(sNotificationReceived);
+    VerifyOrQuit(sObserveValue == 1);
+    VerifyOrQuit(strcmp(sReceivedPayload, "msg0") == 0);
+
+    // Router cancels subscription
+    message = router.Get<Coap::ApplicationCoap>().NewMessage();
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->Init(Coap::kTypeConfirmable, Coap::kCodeGet));
+    SuccessOrQuit(message->WriteToken(sSubscriberToken)); // Use same token
+    SuccessOrQuit(message->AppendObserveOption(1));       // Observe=1 means cancel
+    SuccessOrQuit(message->AppendUriPathOptions("test"));
+
+    sRequestHandlerCalled = false;
+    sSubscriptionActive   = false;
+
+    messageInfo.SetPeerAddr(leader.Get<Mle::Mle>().GetMeshLocalEid());
+    messageInfo.SetPeerPort(OT_DEFAULT_COAP_PORT);
+
+    SuccessOrQuit(router.Get<Coap::ApplicationCoap>().SendMessageWithResponseHandlerSeparateParams(
+        *message, messageInfo, nullptr, &HandleNotification, nullptr, nullptr, nullptr));
+
+    nexus.AdvanceTime(5 * 1000);
+
+    VerifyOrQuit(sRequestHandlerCalled);
+    VerifyOrQuit(!sSubscriberPresent);
+
+    leader.Get<Coap::ApplicationCoap>().RemoveResource(resource);
+    IgnoreError(leader.Get<Coap::ApplicationCoap>().Stop());
+    IgnoreError(router.Get<Coap::ApplicationCoap>().Stop());
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestCoapObserve();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_coaps.cpp b/tests/nexus/test_coaps.cpp
new file mode 100644
index 0000000..cd7c84c
--- /dev/null
+++ b/tests/nexus/test_coaps.cpp
@@ -0,0 +1,244 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+#include "cli/x509_cert_key.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static bool sRequestHandlerCalled  = false;
+static bool sResponseHandlerCalled = false;
+
+static void HandleRequest(void *aContext, otMessage *aMessage, const otMessageInfo *aMessageInfo)
+{
+    OT_UNUSED_VARIABLE(aMessageInfo);
+    Instance      &instance = *static_cast<Instance *>(aContext);
+    Coap::Message &message  = AsCoapMessage(aMessage);
+    Coap::Message *response = nullptr;
+
+    sRequestHandlerCalled = true;
+
+    Log("Received CoAP request");
+
+    response = instance.Get<Coap::ApplicationCoapSecure>().NewMessage();
+    VerifyOrQuit(response != nullptr);
+    SuccessOrQuit(response->InitAsResponse(Coap::kTypeAck, Coap::kCodeContent, message));
+    SuccessOrQuit(response->AppendPayloadMarker());
+    SuccessOrQuit(response->AppendBytes("hello", 5));
+
+    SuccessOrQuit(instance.Get<Coap::ApplicationCoapSecure>().SendMessage(*response));
+}
+
+static void HandleResponse(void *aContext, Coap::Msg *aMsg, otError aError)
+{
+    OT_UNUSED_VARIABLE(aContext);
+
+    sResponseHandlerCalled = true;
+
+    Log("Received CoAP response, error: %d", aError);
+    VerifyOrQuit(aError == OT_ERROR_NONE);
+    VerifyOrQuit(aMsg != nullptr);
+
+    Coap::Message &message = static_cast<Coap::Message &>(aMsg->mMessage);
+    VerifyOrQuit(message.ReadCode() == Coap::kCodeContent);
+
+    uint16_t length = message.GetLength() - message.GetOffset();
+    char     payload[10];
+    VerifyOrQuit(length < sizeof(payload));
+    message.ReadBytes(message.GetOffset(), payload, length);
+    payload[length] = '\0';
+    Log("Payload: %s", payload);
+    VerifyOrQuit(strcmp(payload, "hello") == 0);
+}
+
+void TestCoapsPsk(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("Form network");
+    leader.Form();
+    nexus.AdvanceTime(13 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(10 * 1000);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsChild() || router.Get<Mle::Mle>().IsRouter());
+
+    const uint8_t kPsk[]         = {'p', 's', 'k'};
+    const char    kPskIdentity[] = "pskIdentity";
+
+    Log("Start CoAPS on Leader (PSK)");
+    leader.Get<Coap::ApplicationCoapSecure>().SetPreSharedKey(
+        kPsk, sizeof(kPsk), reinterpret_cast<const uint8_t *>(kPskIdentity), strlen(kPskIdentity));
+    SuccessOrQuit(leader.Get<Coap::ApplicationCoapSecure>().Open(OT_DEFAULT_COAP_SECURE_PORT));
+
+    Coap::Resource resource("test", &HandleRequest, &leader.GetInstance());
+    leader.Get<Coap::ApplicationCoapSecure>().AddResource(resource);
+
+    Log("Start CoAPS on Router (PSK)");
+    router.Get<Coap::ApplicationCoapSecure>().SetPreSharedKey(
+        kPsk, sizeof(kPsk), reinterpret_cast<const uint8_t *>(kPskIdentity), strlen(kPskIdentity));
+    SuccessOrQuit(router.Get<Coap::ApplicationCoapSecure>().Open(0));
+
+    Ip6::SockAddr sockaddr;
+    sockaddr.SetAddress(leader.Get<Mle::Mle>().GetMeshLocalEid());
+    sockaddr.SetPort(OT_DEFAULT_COAP_SECURE_PORT);
+
+    Log("Connect Router to Leader");
+    SuccessOrQuit(router.Get<Coap::ApplicationCoapSecure>().Connect(sockaddr));
+
+    nexus.AdvanceTime(5 * 1000);
+    VerifyOrQuit(router.Get<Coap::ApplicationCoapSecure>().IsConnected());
+
+    Log("Send GET request");
+    Coap::Message *message = router.Get<Coap::ApplicationCoapSecure>().NewMessage();
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->Init(Coap::kTypeConfirmable, Coap::kCodeGet));
+    SuccessOrQuit(message->AppendUriPathOptions("test"));
+
+    sRequestHandlerCalled  = false;
+    sResponseHandlerCalled = false;
+
+    SuccessOrQuit(router.Get<Coap::ApplicationCoapSecure>().SendMessage(*message, &HandleResponse, nullptr));
+
+    nexus.AdvanceTime(5 * 1000);
+
+    VerifyOrQuit(sRequestHandlerCalled);
+    VerifyOrQuit(sResponseHandlerCalled);
+
+    Log("Disconnect");
+    router.Get<Coap::ApplicationCoapSecure>().Disconnect();
+    nexus.AdvanceTime(1 * 1000);
+    VerifyOrQuit(!router.Get<Coap::ApplicationCoapSecure>().IsConnected());
+
+    leader.Get<Coap::ApplicationCoapSecure>().RemoveResource(resource);
+    leader.Get<Coap::ApplicationCoapSecure>().Close();
+    router.Get<Coap::ApplicationCoapSecure>().Close();
+}
+
+void TestCoapsX509(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("Form network");
+    leader.Form();
+    nexus.AdvanceTime(13 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(10 * 1000);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsChild() || router.Get<Mle::Mle>().IsRouter());
+
+    Log("Start CoAPS on Leader (X509)");
+    leader.Get<Coap::ApplicationCoapSecure>().SetCertificate(
+        reinterpret_cast<const uint8_t *>(OT_CLI_COAPS_X509_CERT), sizeof(OT_CLI_COAPS_X509_CERT),
+        reinterpret_cast<const uint8_t *>(OT_CLI_COAPS_PRIV_KEY), sizeof(OT_CLI_COAPS_PRIV_KEY));
+    leader.Get<Coap::ApplicationCoapSecure>().SetCaCertificateChain(
+        reinterpret_cast<const uint8_t *>(OT_CLI_COAPS_TRUSTED_ROOT_CERTIFICATE),
+        sizeof(OT_CLI_COAPS_TRUSTED_ROOT_CERTIFICATE));
+    SuccessOrQuit(leader.Get<Coap::ApplicationCoapSecure>().Open(OT_DEFAULT_COAP_SECURE_PORT));
+
+    Coap::Resource resource("test", &HandleRequest, &leader.GetInstance());
+    leader.Get<Coap::ApplicationCoapSecure>().AddResource(resource);
+
+    Log("Start CoAPS on Router (X509)");
+    router.Get<Coap::ApplicationCoapSecure>().SetCertificate(
+        reinterpret_cast<const uint8_t *>(OT_CLI_COAPS_X509_CERT), sizeof(OT_CLI_COAPS_X509_CERT),
+        reinterpret_cast<const uint8_t *>(OT_CLI_COAPS_PRIV_KEY), sizeof(OT_CLI_COAPS_PRIV_KEY));
+    router.Get<Coap::ApplicationCoapSecure>().SetCaCertificateChain(
+        reinterpret_cast<const uint8_t *>(OT_CLI_COAPS_TRUSTED_ROOT_CERTIFICATE),
+        sizeof(OT_CLI_COAPS_TRUSTED_ROOT_CERTIFICATE));
+    SuccessOrQuit(router.Get<Coap::ApplicationCoapSecure>().Open(0));
+
+    Ip6::SockAddr sockaddr;
+    sockaddr.SetAddress(leader.Get<Mle::Mle>().GetMeshLocalEid());
+    sockaddr.SetPort(OT_DEFAULT_COAP_SECURE_PORT);
+
+    Log("Connect Router to Leader");
+    SuccessOrQuit(router.Get<Coap::ApplicationCoapSecure>().Connect(sockaddr));
+
+    nexus.AdvanceTime(5 * 1000);
+    VerifyOrQuit(router.Get<Coap::ApplicationCoapSecure>().IsConnected());
+
+    Log("Send GET request");
+    Coap::Message *message = router.Get<Coap::ApplicationCoapSecure>().NewMessage();
+    VerifyOrQuit(message != nullptr);
+    SuccessOrQuit(message->Init(Coap::kTypeConfirmable, Coap::kCodeGet));
+    SuccessOrQuit(message->AppendUriPathOptions("test"));
+
+    sRequestHandlerCalled  = false;
+    sResponseHandlerCalled = false;
+
+    SuccessOrQuit(router.Get<Coap::ApplicationCoapSecure>().SendMessage(*message, &HandleResponse, nullptr));
+
+    nexus.AdvanceTime(5 * 1000);
+
+    VerifyOrQuit(sRequestHandlerCalled);
+    VerifyOrQuit(sResponseHandlerCalled);
+
+    Log("Disconnect");
+    router.Get<Coap::ApplicationCoapSecure>().Disconnect();
+    nexus.AdvanceTime(1 * 1000);
+    VerifyOrQuit(!router.Get<Coap::ApplicationCoapSecure>().IsConnected());
+
+    leader.Get<Coap::ApplicationCoapSecure>().RemoveResource(resource);
+    leader.Get<Coap::ApplicationCoapSecure>().Close();
+    router.Get<Coap::ApplicationCoapSecure>().Close();
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestCoapsPsk();
+    ot::Nexus::TestCoapsX509();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_compact_route_tlv.cpp b/tests/nexus/test_compact_route_tlv.cpp
new file mode 100644
index 0000000..2acbaa3
--- /dev/null
+++ b/tests/nexus/test_compact_route_tlv.cpp
@@ -0,0 +1,137 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestCompactRouteTlv(void)
+{
+    static constexpr uint16_t kNumRouters = 31;
+
+    Core  nexus;
+    Node &leader = nexus.CreateNode();
+    Node *routers[kNumRouters];
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("TestCompactRouteTlv");
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Form topology - leader + %u routers", kNumRouters);
+
+    leader.Get<Mle::Mle>().SetRouterUpgradeThreshold(32);
+    leader.Get<Mle::Mle>().SetLeaderUpgradeThreshold(32);
+    leader.Get<Mle::Mle>().SetRouterDowngradeThreshold(33);
+
+    leader.Form();
+    nexus.AdvanceTime(100 * Time::kOneSecondInMsec);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    for (uint16_t i = 0; i < kNumRouters; i++)
+    {
+        Node &router = nexus.CreateNode();
+
+        routers[i] = &router;
+
+        router.Get<Mle::Mle>().SetRouterUpgradeThreshold(32);
+        router.Get<Mle::Mle>().SetRouterDowngradeThreshold(33);
+        router.Get<Mle::Mle>().SetRouterSelectionJitter(20);
+
+        router.Join(leader, Node::kAsFtd);
+        nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+
+        VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+        Log("Router %-2u -> rloc16: 0x%04x", i, router.Get<Mle::Mle>().GetRloc16());
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check Active Router Count on all routers");
+
+    nexus.AdvanceTime(300 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(leader.Get<RouterTable>().GetActiveRouterCount() == kNumRouters + 1);
+
+    for (const Node *router : routers)
+    {
+        VerifyOrQuit(router->Get<RouterTable>().GetActiveRouterCount() == kNumRouters + 1);
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Reset `routers[0]` and validate that it is restored quickly");
+
+    routers[0]->Reset();
+
+    routers[0]->Get<Mle::Mle>().SetRouterUpgradeThreshold(32);
+    routers[0]->Get<Mle::Mle>().SetRouterDowngradeThreshold(33);
+    routers[0]->Get<Mle::Mle>().SetRouterSelectionJitter(20);
+
+    routers[0]->Get<ThreadNetif>().Up();
+    SuccessOrQuit(routers[0]->Get<Mle::Mle>().Start());
+
+    for (uint16_t i = 0; i < 5000; i++)
+    {
+        nexus.AdvanceTime(1);
+
+        if (routers[0]->Get<Mle::Mle>().IsRouter())
+        {
+            Log("Node `routers[0]` restored it role after reset");
+            Log("Validate that it got a compact Route TLV and therefore does not yet see all routers");
+            VerifyOrQuit(routers[0]->Get<RouterTable>().GetActiveRouterCount() < kNumRouters + 1);
+            break;
+        }
+
+        VerifyOrQuit(routers[0]->Get<Mle::Mle>().IsDetached());
+    }
+
+    VerifyOrQuit(routers[0]->Get<Mle::Mle>().IsRouter());
+
+    Log("Validate that `routes[0]` discovered all routers");
+
+    nexus.AdvanceTime(100 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(routers[0]->Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(routers[0]->Get<RouterTable>().GetActiveRouterCount() == kNumRouters + 1);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestCompactRouteTlv();
+    printf("All tests passed\n");
+
+    return 0;
+}
diff --git a/tests/nexus/test_dataset_updater.cpp b/tests/nexus/test_dataset_updater.cpp
new file mode 100644
index 0000000..77ce215
--- /dev/null
+++ b/tests/nexus/test_dataset_updater.cpp
@@ -0,0 +1,175 @@
+/*
+ *  Copyright (c) 2024, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void VerifyChannel(Core &aNexus, uint16_t aChannel)
+{
+    for (Node &node : aNexus.GetNodes())
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        SuccessOrQuit(node.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
+
+        VerifyOrQuit(datasetInfo.IsPresent<MeshCoP::Dataset::kChannel>());
+        VerifyOrQuit(datasetInfo.Get<MeshCoP::Dataset::kChannel>() == aChannel);
+        VerifyOrQuit(node.Get<Mac::Mac>().GetPanChannel() == aChannel);
+    }
+}
+
+void TestDatasetUpdater(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &med    = nexus.CreateNode();
+    Node &sed    = nexus.CreateNode();
+
+    leader.SetName("leader");
+    router.SetName("router");
+    med.SetName("med");
+    sed.SetName("sed");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form network on channel 11");
+
+    leader.Form();
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        SuccessOrQuit(leader.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
+        datasetInfo.Set<MeshCoP::Dataset::kChannel>(11);
+        leader.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
+    }
+
+    nexus.AdvanceTime(13 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    med.Join(router, Node::kAsMed);
+    sed.Join(router, Node::kAsSed);
+
+    // Set a faster poll period for SED to ensure it receives Dataset updates promptly
+    SuccessOrQuit(sed.Get<DataPollSender>().SetExternalPollPeriod(100));
+
+    nexus.AdvanceTime(300 * 1000);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(med.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+
+    VerifyChannel(nexus, 11);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Update initiated by LEADER to channel 12");
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        datasetInfo.Clear();
+        datasetInfo.Set<MeshCoP::Dataset::kChannel>(12);
+        SuccessOrQuit(leader.Get<MeshCoP::DatasetUpdater>().RequestUpdate(datasetInfo, nullptr, nullptr));
+    }
+
+    nexus.AdvanceTime(200 * 1000);
+    VerifyChannel(nexus, 12);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Update initiated by ROUTER to channel 13");
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        datasetInfo.Clear();
+        datasetInfo.Set<MeshCoP::Dataset::kChannel>(13);
+        SuccessOrQuit(router.Get<MeshCoP::DatasetUpdater>().RequestUpdate(datasetInfo, nullptr, nullptr));
+    }
+
+    nexus.AdvanceTime(200 * 1000);
+    VerifyChannel(nexus, 13);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Update initiated by LEADER overridden by ROUTER (14 -> 15)");
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        datasetInfo.Clear();
+        datasetInfo.Set<MeshCoP::Dataset::kChannel>(14);
+        SuccessOrQuit(leader.Get<MeshCoP::DatasetUpdater>().RequestUpdate(datasetInfo, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(20 * 1000);
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        datasetInfo.Clear();
+        datasetInfo.Set<MeshCoP::Dataset::kChannel>(15);
+        SuccessOrQuit(router.Get<MeshCoP::DatasetUpdater>().RequestUpdate(datasetInfo, nullptr, nullptr));
+    }
+
+    nexus.AdvanceTime(200 * 1000);
+    VerifyChannel(nexus, 15);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Update initiated by ROUTER overridden by LEADER (16 -> 17)");
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        datasetInfo.Clear();
+        datasetInfo.Set<MeshCoP::Dataset::kChannel>(16);
+        SuccessOrQuit(router.Get<MeshCoP::DatasetUpdater>().RequestUpdate(datasetInfo, nullptr, nullptr));
+    }
+    nexus.AdvanceTime(10 * 1000);
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        datasetInfo.Clear();
+        datasetInfo.Set<MeshCoP::Dataset::kChannel>(17);
+        SuccessOrQuit(leader.Get<MeshCoP::DatasetUpdater>().RequestUpdate(datasetInfo, nullptr, nullptr));
+    }
+
+    nexus.AdvanceTime(200 * 1000);
+    VerifyChannel(nexus, 17);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestDatasetUpdater();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_dns_client_config_auto_start.cpp b/tests/nexus/test_dns_client_config_auto_start.cpp
new file mode 100644
index 0000000..6ddaddf
--- /dev/null
+++ b/tests/nexus/test_dns_client_config_auto_start.cpp
@@ -0,0 +1,195 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 200 * 1000;
+
+/**
+ * Default DNS server address for testing.
+ */
+static const char kDefaultAddress[] = "fd00:1:2:3:4:5:6:7";
+
+void TestDnsClientConfigAutoStart(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form network");
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(kJoinNetworkTime + kStabilizationTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("On leader set DNS config explicitly");
+
+    {
+        Dns::Client::QueryConfig dnsConfig = leader.Get<Dns::Client>().GetDefaultConfig();
+        SuccessOrQuit(AsCoreType(&dnsConfig.mServerSockAddr.mAddress).FromString(kDefaultAddress));
+        dnsConfig.mServerSockAddr.mPort = 53;
+        leader.Get<Dns::Client>().SetDefaultConfig(dnsConfig);
+
+        const Dns::Client::QueryConfig &currentConfig = leader.Get<Dns::Client>().GetDefaultConfig();
+        VerifyOrQuit(currentConfig.GetServerSockAddr().GetAddress() == AsCoreType(&dnsConfig.mServerSockAddr.mAddress));
+        VerifyOrQuit(currentConfig.GetServerSockAddr().GetPort() == 53);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Start leader to act as SRP server");
+
+    leader.Get<Srp::Server>().SetEnabled(true);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Enable SRP client auto start on both nodes");
+
+    leader.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    router.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify that on router the default DNS config is changed to the SRP server address");
+
+    Ip6::Address srpServerAddress;
+
+    srpServerAddress = router.Get<Srp::Client>().GetServerAddress().GetAddress();
+    VerifyOrQuit(!srpServerAddress.IsUnspecified());
+    VerifyOrQuit(srpServerAddress == leader.Get<Srp::Client>().GetServerAddress().GetAddress());
+
+    {
+        const Dns::Client::QueryConfig &dnsConfig = router.Get<Dns::Client>().GetDefaultConfig();
+        VerifyOrQuit(dnsConfig.GetServerSockAddr().GetAddress() == srpServerAddress);
+        VerifyOrQuit(dnsConfig.GetServerSockAddr().GetPort() == 53);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify that on leader the default DNS config remains as before");
+
+    {
+        const Dns::Client::QueryConfig &dnsConfig = leader.Get<Dns::Client>().GetDefaultConfig();
+        Ip6::Address                    expectedAddress;
+        SuccessOrQuit(expectedAddress.FromString(kDefaultAddress));
+        VerifyOrQuit(dnsConfig.GetServerSockAddr().GetAddress() == expectedAddress);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("On leader clear DNS config and verify it adopts the SRP server address");
+
+    {
+        Dns::Client::QueryConfig dnsConfig = leader.Get<Dns::Client>().GetDefaultConfig();
+        AsCoreType(&dnsConfig.mServerSockAddr.mAddress).Clear();
+        leader.Get<Dns::Client>().SetDefaultConfig(dnsConfig);
+
+        const Dns::Client::QueryConfig &currentConfig = leader.Get<Dns::Client>().GetDefaultConfig();
+        VerifyOrQuit(currentConfig.GetServerSockAddr().GetAddress() == srpServerAddress);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("On leader set DNS config explicitly again");
+
+    {
+        Dns::Client::QueryConfig dnsConfig = leader.Get<Dns::Client>().GetDefaultConfig();
+        SuccessOrQuit(AsCoreType(&dnsConfig.mServerSockAddr.mAddress).FromString(kDefaultAddress));
+        leader.Get<Dns::Client>().SetDefaultConfig(dnsConfig);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Stop SRP server on leader and start it on router");
+
+    leader.Get<Srp::Server>().SetEnabled(false);
+    router.Get<Srp::Server>().SetEnabled(true);
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify that SRP client on router switched to new SRP server");
+
+    Ip6::Address newSrpServerAddress;
+    newSrpServerAddress = router.Get<Srp::Client>().GetServerAddress().GetAddress();
+    VerifyOrQuit(!newSrpServerAddress.IsUnspecified());
+    VerifyOrQuit(newSrpServerAddress != srpServerAddress);
+    VerifyOrQuit(newSrpServerAddress == leader.Get<Srp::Client>().GetServerAddress().GetAddress());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify that config on router gets changed while on leader it remains unchanged");
+
+    {
+        const Dns::Client::QueryConfig &dnsConfig = router.Get<Dns::Client>().GetDefaultConfig();
+        VerifyOrQuit(dnsConfig.GetServerSockAddr().GetAddress() == newSrpServerAddress);
+    }
+
+    {
+        const Dns::Client::QueryConfig &dnsConfig = leader.Get<Dns::Client>().GetDefaultConfig();
+        Ip6::Address                    expectedAddress;
+        SuccessOrQuit(expectedAddress.FromString(kDefaultAddress));
+        VerifyOrQuit(dnsConfig.GetServerSockAddr().GetAddress() == expectedAddress);
+    }
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestDnsClientConfigAutoStart();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_dnssd.cpp b/tests/nexus/test_dnssd.cpp
new file mode 100644
index 0000000..98ebbbb
--- /dev/null
+++ b/tests/nexus/test_dnssd.cpp
@@ -0,0 +1,524 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "common/as_core_type.hpp"
+#include "common/string.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+namespace {
+
+static constexpr uint32_t kFormNetworkTime   = 13 * 1000;
+static constexpr uint32_t kJoinNetworkTime   = 10 * 1000;
+static constexpr uint32_t kStabilizationTime = 200 * 1000;
+static constexpr uint32_t kDnsQueryTime      = 5 * 1000;
+
+static const char kService[]             = "_ipps._tcp";
+static const char kFullServiceType[]     = "_ipps._tcp.default.service.arpa.";
+static const char kSubtype1ServiceType[] = "_s1._sub._ipps._tcp.default.service.arpa.";
+static const char kSubtype2ServiceType[] = "_s2._sub._ipps._tcp.default.service.arpa.";
+
+struct BrowseContext
+{
+    Error                  mError;
+    uint8_t                mCount;
+    Dns::Name::LabelBuffer mLabels[10];
+
+    void Reset(void)
+    {
+        mError = kErrorNone;
+        mCount = 0;
+    }
+
+    bool ContainsInstance(const char *aLabel) const
+    {
+        bool found = false;
+
+        for (uint8_t i = 0; i < mCount; i++)
+        {
+            if (StringMatch(mLabels[i], aLabel, kStringCaseInsensitiveMatch))
+            {
+                found = true;
+                break;
+            }
+        }
+
+        return found;
+    }
+};
+
+struct ResolveContext
+{
+    Error                    mError;
+    Dns::Name::LabelBuffer   mInstanceLabel;
+    Dns::Name::Buffer        mFullServiceName;
+    Dns::Name::Buffer        mHostName;
+    uint8_t                  mTxtData[128];
+    Dns::Client::ServiceInfo mServiceInfo;
+
+    void Reset(void)
+    {
+        mError              = kErrorNone;
+        mInstanceLabel[0]   = '\0';
+        mFullServiceName[0] = '\0';
+        mHostName[0]        = '\0';
+        ClearAllBytes(mServiceInfo);
+        mServiceInfo.mHostNameBuffer     = mHostName;
+        mServiceInfo.mHostNameBufferSize = sizeof(mHostName);
+        mServiceInfo.mTxtData            = mTxtData;
+        mServiceInfo.mTxtDataSize        = sizeof(mTxtData);
+    }
+};
+
+struct AddressContext
+{
+    Error        mError;
+    Ip6::Address mAddresses[10];
+    uint8_t      mCount;
+
+    void Reset(void)
+    {
+        mError = kErrorNone;
+        mCount = 0;
+    }
+};
+
+struct RecordContext
+{
+    Error             mError;
+    uint8_t           mCount;
+    uint16_t          mType;
+    uint16_t          mLength;
+    uint32_t          mTtl;
+    Dns::Name::Buffer mName;
+
+    void Reset(void)
+    {
+        mError   = kErrorNone;
+        mCount   = 0;
+        mType    = 0;
+        mLength  = 0;
+        mTtl     = 0;
+        mName[0] = '\0';
+    }
+};
+
+void HandleBrowseResponse(otError aError, const otDnsBrowseResponse *aResponse, void *aContext)
+{
+    BrowseContext                     *context  = static_cast<BrowseContext *>(aContext);
+    const Dns::Client::BrowseResponse &response = AsCoreType(aResponse);
+
+    context->mError = static_cast<Error>(aError);
+    SuccessOrExit(context->mError);
+
+    while (response.GetServiceInstance(context->mCount, context->mLabels[context->mCount],
+                                       sizeof(context->mLabels[context->mCount])) == kErrorNone)
+    {
+        context->mCount++;
+    }
+
+exit:
+    return;
+}
+
+void HandleResolveResponse(otError aError, const otDnsServiceResponse *aResponse, void *aContext)
+{
+    ResolveContext                     *context  = static_cast<ResolveContext *>(aContext);
+    const Dns::Client::ServiceResponse &response = AsCoreType(aResponse);
+
+    context->mError = static_cast<Error>(aError);
+    SuccessOrExit(context->mError);
+
+    SuccessOrExit(response.GetServiceName(context->mInstanceLabel, sizeof(context->mInstanceLabel),
+                                          context->mFullServiceName, sizeof(context->mFullServiceName)));
+    SuccessOrExit(response.GetServiceInfo(context->mServiceInfo));
+
+exit:
+    return;
+}
+
+void HandleAddressResolveResponse(otError aError, const otDnsAddressResponse *aResponse, void *aContext)
+{
+    AddressContext                     *context  = static_cast<AddressContext *>(aContext);
+    const Dns::Client::AddressResponse &response = AsCoreType(aResponse);
+    uint32_t                            ttl;
+
+    context->mError = static_cast<Error>(aError);
+    SuccessOrExit(context->mError);
+
+    while (response.GetAddress(context->mCount, context->mAddresses[context->mCount], ttl) == kErrorNone)
+    {
+        context->mCount++;
+    }
+
+exit:
+    return;
+}
+
+void HandleRecordQueryResponse(otError aError, const otDnsRecordResponse *aResponse, void *aContext)
+{
+    RecordContext                     *context  = static_cast<RecordContext *>(aContext);
+    const Dns::Client::RecordResponse &response = AsCoreType(aResponse);
+    Dns::Client::RecordInfo            recordInfo;
+
+    context->mError = static_cast<Error>(aError);
+    SuccessOrExit(context->mError);
+
+    recordInfo.mNameBuffer     = context->mName;
+    recordInfo.mNameBufferSize = sizeof(context->mName);
+
+    for (uint16_t index = 0; response.GetRecordInfo(index, recordInfo) == kErrorNone; index++)
+    {
+        if (recordInfo.mRecordType == context->mType)
+        {
+            context->mCount++;
+            context->mLength = recordInfo.mRecordLength;
+            context->mTtl    = recordInfo.mTtl;
+            break;
+        }
+    }
+
+exit:
+    return;
+}
+
+} // namespace
+
+void TestDnssd(const char *aJsonFileName)
+{
+    Core  nexus;
+    Node &server  = nexus.CreateNode();
+    Node &client1 = nexus.CreateNode();
+    Node &client2 = nexus.CreateNode();
+    Node &client3 = nexus.CreateNode();
+
+    static Srp::Client::Service srv1, srv2, srv3;
+    static const char *const    kSubtypes1[] = {"_s1", "_s2", nullptr};
+    static const char *const    kSubtypes3[] = {"_s1", nullptr};
+
+    static Ip6::Address addrs1[2], addrs2[2], addrs3[2];
+
+    server.SetName("Server");
+    client1.SetName("Client1");
+    client2.SetName("Client2");
+    client3.SetName("Client3");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    //-----------------------------------------------------------------------------------------
+    // Step 1: Start topology
+    Log("Step 1: Start topology");
+
+    server.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    client1.Join(server);
+    client2.Join(server);
+    client3.Join(server);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    server.Get<Srp::Server>().SetEnabled(true);
+    SuccessOrQuit(server.Get<Dns::ServiceDiscovery::Server>().Start());
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    //-----------------------------------------------------------------------------------------
+    // Step 2: Register services on clients
+    Log("Step 2: Register services on clients");
+
+    client1.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    client2.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    client3.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+    addrs1[0] = client1.Get<Mle::Mle>().GetMeshLocalEid();
+    addrs1[1] = client1.Get<Mle::Mle>().GetMeshLocalRloc();
+    SuccessOrQuit(client1.Get<Srp::Client>().SetHostName("host1"));
+    SuccessOrQuit(client1.Get<Srp::Client>().SetHostAddresses(addrs1, 2));
+    ClearAllBytes(srv1);
+    srv1.mName          = kService;
+    srv1.mInstanceName  = "ins1";
+    srv1.mPort          = 11111;
+    srv1.mPriority      = 1;
+    srv1.mWeight        = 1;
+    srv1.mSubTypeLabels = kSubtypes1;
+    SuccessOrQuit(srv1.Init());
+    SuccessOrQuit(client1.Get<Srp::Client>().AddService(srv1));
+
+    addrs2[0] = client2.Get<Mle::Mle>().GetMeshLocalEid();
+    addrs2[1] = client2.Get<Mle::Mle>().GetMeshLocalRloc();
+    SuccessOrQuit(client2.Get<Srp::Client>().SetHostName("host2"));
+    SuccessOrQuit(client2.Get<Srp::Client>().SetHostAddresses(addrs2, 2));
+    ClearAllBytes(srv2);
+    srv2.mName         = kService;
+    srv2.mInstanceName = "ins2";
+    srv2.mPort         = 22222;
+    srv2.mPriority     = 2;
+    srv2.mWeight       = 2;
+    SuccessOrQuit(srv2.Init());
+    SuccessOrQuit(client2.Get<Srp::Client>().AddService(srv2));
+
+    addrs3[0] = client3.Get<Mle::Mle>().GetMeshLocalEid();
+    addrs3[1] = client3.Get<Mle::Mle>().GetMeshLocalRloc();
+    SuccessOrQuit(client3.Get<Srp::Client>().SetHostName("host3"));
+    SuccessOrQuit(client3.Get<Srp::Client>().SetHostAddresses(addrs3, 2));
+    ClearAllBytes(srv3);
+    srv3.mName          = kService;
+    srv3.mInstanceName  = "ins3";
+    srv3.mPort          = 33333;
+    srv3.mPriority      = 3;
+    srv3.mWeight        = 3;
+    srv3.mSubTypeLabels = kSubtypes3;
+    SuccessOrQuit(srv3.Init());
+    SuccessOrQuit(client3.Get<Srp::Client>().AddService(srv3));
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    //-----------------------------------------------------------------------------------------
+    // Step 3: Resolve address (AAAA records)
+    Log("Step 3: Resolve address (AAAA records)");
+
+    AddressContext addressContext;
+    addressContext.Reset();
+
+    Dns::Client::QueryConfig queryConfig;
+    queryConfig.Clear();
+    AsCoreType(&queryConfig.mServerSockAddr).SetAddress(server.Get<Mle::Mle>().GetMeshLocalEid());
+    queryConfig.mServerSockAddr.mPort = 53;
+    client1.Get<Dns::Client>().SetDefaultConfig(queryConfig);
+
+    SuccessOrQuit(client1.Get<Dns::Client>().ResolveAddress("host1.default.service.arpa.", HandleAddressResolveResponse,
+                                                            &addressContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(addressContext.mError);
+    VerifyOrQuit(addressContext.mCount == 2);
+    VerifyOrQuit(addressContext.mAddresses[0] == client1.Get<Mle::Mle>().GetMeshLocalEid() ||
+                 addressContext.mAddresses[0] == client1.Get<Mle::Mle>().GetMeshLocalRloc());
+    VerifyOrQuit(addressContext.mAddresses[1] == client1.Get<Mle::Mle>().GetMeshLocalEid() ||
+                 addressContext.mAddresses[1] == client1.Get<Mle::Mle>().GetMeshLocalRloc());
+
+    addressContext.Reset();
+    SuccessOrQuit(client1.Get<Dns::Client>().ResolveAddress("host2.default.service.arpa.", HandleAddressResolveResponse,
+                                                            &addressContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(addressContext.mError);
+    VerifyOrQuit(addressContext.mCount == 2);
+    VerifyOrQuit(addressContext.mAddresses[0] == client2.Get<Mle::Mle>().GetMeshLocalEid() ||
+                 addressContext.mAddresses[0] == client2.Get<Mle::Mle>().GetMeshLocalRloc());
+    VerifyOrQuit(addressContext.mAddresses[1] == client2.Get<Mle::Mle>().GetMeshLocalEid() ||
+                 addressContext.mAddresses[1] == client2.Get<Mle::Mle>().GetMeshLocalRloc());
+
+    //-----------------------------------------------------------------------------------------
+    // Step 4: Browsing for services
+    Log("Step 4: Browsing for services");
+
+    BrowseContext browseContext;
+    browseContext.Reset();
+    SuccessOrQuit(client1.Get<Dns::Client>().Browse(kFullServiceType, HandleBrowseResponse, &browseContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(browseContext.mError);
+    VerifyOrQuit(browseContext.mCount == 3);
+    VerifyOrQuit(browseContext.ContainsInstance("ins1"));
+    VerifyOrQuit(browseContext.ContainsInstance("ins2"));
+    VerifyOrQuit(browseContext.ContainsInstance("ins3"));
+
+    // Browse for subtype _s1
+    browseContext.Reset();
+    SuccessOrQuit(client1.Get<Dns::Client>().Browse(kSubtype1ServiceType, HandleBrowseResponse, &browseContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(browseContext.mError);
+    VerifyOrQuit(browseContext.mCount == 2);
+    VerifyOrQuit(browseContext.ContainsInstance("ins1"));
+    VerifyOrQuit(browseContext.ContainsInstance("ins3"));
+
+    // Browse for subtype _s2
+    browseContext.Reset();
+    SuccessOrQuit(client1.Get<Dns::Client>().Browse(kSubtype2ServiceType, HandleBrowseResponse, &browseContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(browseContext.mError);
+    VerifyOrQuit(browseContext.mCount == 1);
+    VerifyOrQuit(browseContext.ContainsInstance("ins1"));
+
+    //-----------------------------------------------------------------------------------------
+    // Step 5: Resolve service
+    Log("Step 5: Resolve service");
+
+    ResolveContext resolveContext;
+
+    resolveContext.Reset();
+    SuccessOrQuit(
+        client1.Get<Dns::Client>().ResolveService("ins1", kFullServiceType, HandleResolveResponse, &resolveContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(resolveContext.mError);
+    VerifyOrQuit(StringMatch(resolveContext.mInstanceLabel, "ins1", kStringCaseInsensitiveMatch));
+    VerifyOrQuit(StringMatch(resolveContext.mFullServiceName, kFullServiceType, kStringCaseInsensitiveMatch));
+    VerifyOrQuit(resolveContext.mServiceInfo.mPort == 11111);
+    VerifyOrQuit(resolveContext.mServiceInfo.mPriority == 1);
+    VerifyOrQuit(resolveContext.mServiceInfo.mWeight == 1);
+    VerifyOrQuit(StringMatch(resolveContext.mHostName, "host1.default.service.arpa.", kStringCaseInsensitiveMatch));
+    VerifyOrQuit(AsCoreType(&resolveContext.mServiceInfo.mHostAddress) == client1.Get<Mle::Mle>().GetMeshLocalEid() ||
+                 AsCoreType(&resolveContext.mServiceInfo.mHostAddress) == client1.Get<Mle::Mle>().GetMeshLocalRloc());
+    VerifyOrQuit(resolveContext.mServiceInfo.mTtl > 0);
+    VerifyOrQuit(resolveContext.mServiceInfo.mHostAddressTtl > 0);
+
+    resolveContext.Reset();
+    SuccessOrQuit(
+        client1.Get<Dns::Client>().ResolveService("ins2", kFullServiceType, HandleResolveResponse, &resolveContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(resolveContext.mError);
+    VerifyOrQuit(StringMatch(resolveContext.mInstanceLabel, "ins2", kStringCaseInsensitiveMatch));
+    VerifyOrQuit(StringMatch(resolveContext.mFullServiceName, kFullServiceType, kStringCaseInsensitiveMatch));
+    VerifyOrQuit(resolveContext.mServiceInfo.mPort == 22222);
+    VerifyOrQuit(resolveContext.mServiceInfo.mPriority == 2);
+    VerifyOrQuit(resolveContext.mServiceInfo.mWeight == 2);
+    VerifyOrQuit(StringMatch(resolveContext.mHostName, "host2.default.service.arpa.", kStringCaseInsensitiveMatch));
+    VerifyOrQuit(AsCoreType(&resolveContext.mServiceInfo.mHostAddress) == client2.Get<Mle::Mle>().GetMeshLocalEid() ||
+                 AsCoreType(&resolveContext.mServiceInfo.mHostAddress) == client2.Get<Mle::Mle>().GetMeshLocalRloc());
+
+    resolveContext.Reset();
+    SuccessOrQuit(
+        client1.Get<Dns::Client>().ResolveService("ins3", kFullServiceType, HandleResolveResponse, &resolveContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(resolveContext.mError);
+    VerifyOrQuit(StringMatch(resolveContext.mInstanceLabel, "ins3", kStringCaseInsensitiveMatch));
+    VerifyOrQuit(StringMatch(resolveContext.mFullServiceName, kFullServiceType, kStringCaseInsensitiveMatch));
+    VerifyOrQuit(resolveContext.mServiceInfo.mPort == 33333);
+    VerifyOrQuit(resolveContext.mServiceInfo.mPriority == 3);
+    VerifyOrQuit(resolveContext.mServiceInfo.mWeight == 3);
+    VerifyOrQuit(StringMatch(resolveContext.mHostName, "host3.default.service.arpa.", kStringCaseInsensitiveMatch));
+    VerifyOrQuit(AsCoreType(&resolveContext.mServiceInfo.mHostAddress) == client3.Get<Mle::Mle>().GetMeshLocalEid() ||
+                 AsCoreType(&resolveContext.mServiceInfo.mHostAddress) == client3.Get<Mle::Mle>().GetMeshLocalRloc());
+
+    //-----------------------------------------------------------------------------------------
+    // Step 6: Add another service with TXT entries
+    Log("Step 6: Add another service with TXT entries");
+
+    Srp::Client::Service srv4;
+    {
+        static const Dns::TxtEntry kTxtEntries[] = {{"KEY", reinterpret_cast<const uint8_t *>("ABC"), 3}};
+
+        ClearAllBytes(srv4);
+        srv4.mName          = kService;
+        srv4.mInstanceName  = "ins4";
+        srv4.mPort          = 44444;
+        srv4.mPriority      = 4;
+        srv4.mWeight        = 4;
+        srv4.mTxtEntries    = kTxtEntries;
+        srv4.mNumTxtEntries = 1;
+        srv4.mSubTypeLabels = kSubtypes3;
+
+        SuccessOrQuit(srv4.Init());
+        SuccessOrQuit(client3.Get<Srp::Client>().AddService(srv4));
+    }
+    nexus.AdvanceTime(kStabilizationTime);
+
+    browseContext.Reset();
+    SuccessOrQuit(client1.Get<Dns::Client>().Browse(kFullServiceType, HandleBrowseResponse, &browseContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(browseContext.mError);
+    VerifyOrQuit(browseContext.mCount == 4);
+    VerifyOrQuit(browseContext.ContainsInstance("ins1"));
+    VerifyOrQuit(browseContext.ContainsInstance("ins2"));
+    VerifyOrQuit(browseContext.ContainsInstance("ins3"));
+    VerifyOrQuit(browseContext.ContainsInstance("ins4"));
+
+    resolveContext.Reset();
+    SuccessOrQuit(
+        client1.Get<Dns::Client>().ResolveService("ins4", kFullServiceType, HandleResolveResponse, &resolveContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(resolveContext.mError);
+    VerifyOrQuit(StringMatch(resolveContext.mInstanceLabel, "ins4", kStringCaseInsensitiveMatch));
+    VerifyOrQuit(StringMatch(resolveContext.mFullServiceName, kFullServiceType, kStringCaseInsensitiveMatch));
+    VerifyOrQuit(resolveContext.mServiceInfo.mPort == 44444);
+    VerifyOrQuit(resolveContext.mServiceInfo.mPriority == 4);
+    VerifyOrQuit(resolveContext.mServiceInfo.mWeight == 4);
+    VerifyOrQuit(resolveContext.mServiceInfo.mTxtDataSize == 8);
+    VerifyOrQuit(memcmp(resolveContext.mServiceInfo.mTxtData, "\x07KEY=ABC", 8) == 0);
+
+    //-----------------------------------------------------------------------------------------
+    // Step 7: Query for KEY record
+    Log("Step 7: Query for KEY record");
+
+    RecordContext recordContext;
+    recordContext.Reset();
+    recordContext.mType = 25; // KEY
+    SuccessOrQuit(client1.Get<Dns::Client>().QueryRecord(25, nullptr, "ins1._ipps._tcp.default.service.arpa.",
+                                                         HandleRecordQueryResponse, &recordContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(recordContext.mError);
+    VerifyOrQuit(recordContext.mCount == 1);
+    VerifyOrQuit(recordContext.mLength == 78);
+    VerifyOrQuit(recordContext.mTtl > 0);
+    VerifyOrQuit(
+        StringMatch(recordContext.mName, "ins1._ipps._tcp.default.service.arpa.", kStringCaseInsensitiveMatch));
+
+    //-----------------------------------------------------------------------------------------
+    // Step 8: Query for SRV record
+    Log("Step 8: Query for SRV record");
+
+    recordContext.Reset();
+    recordContext.mType = 33; // SRV
+    SuccessOrQuit(client1.Get<Dns::Client>().QueryRecord(33, nullptr, "ins1._ipps._tcp.default.service.arpa.",
+                                                         HandleRecordQueryResponse, &recordContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(recordContext.mError);
+    VerifyOrQuit(recordContext.mCount == 1);
+    VerifyOrQuit(recordContext.mLength > 0);
+    VerifyOrQuit(recordContext.mTtl > 0);
+    VerifyOrQuit(
+        StringMatch(recordContext.mName, "ins1._ipps._tcp.default.service.arpa.", kStringCaseInsensitiveMatch));
+
+    //-----------------------------------------------------------------------------------------
+    // Step 9: Query for non-existing A record
+    Log("Step 9: Query for non-existing A record");
+
+    recordContext.Reset();
+    recordContext.mType = 1; // A
+    SuccessOrQuit(client1.Get<Dns::Client>().QueryRecord(1, nullptr, "ins1._ipps._tcp.default.service.arpa.",
+                                                         HandleRecordQueryResponse, &recordContext));
+    nexus.AdvanceTime(kDnsQueryTime);
+    SuccessOrQuit(recordContext.mError);
+    VerifyOrQuit(recordContext.mCount == 0);
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::TestDnssd((argc > 2) ? argv[2] : "test_dnssd.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_dnssd_name_with_special_chars.cpp b/tests/nexus/test_dnssd_name_with_special_chars.cpp
new file mode 100644
index 0000000..467597a
--- /dev/null
+++ b/tests/nexus/test_dnssd_name_with_special_chars.cpp
@@ -0,0 +1,229 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "net/dnssd_server.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 15 * 1000;
+
+/**
+ * Time to wait for SRP registration to complete.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 10 * 1000;
+
+/**
+ * Time to advance for DNS query processing.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * SRP service and host names.
+ */
+static const char         kSrpServiceType[]      = "_srv._udp";
+static const char         kSrpFullServiceType[]  = "_srv._udp.default.service.arpa.";
+static const char         kSrpHostName[]         = "host1";
+static const char         kSpecialInstanceName[] = "O\\T 网关";
+static constexpr uint16_t kSrpServicePort        = 1977;
+
+/**
+ * Test context to track progress.
+ */
+struct TestContext
+{
+    uint8_t mBrowseCount;
+    uint8_t mResolveCount;
+};
+
+void HandleResolveResponse(otError aError, const otDnsServiceResponse *aResponse, void *aContext)
+{
+    TestContext             *context = static_cast<TestContext *>(aContext);
+    Dns::Client::ServiceInfo serviceInfo;
+    Dns::Name::Buffer        hostName;
+
+    if (aError != kErrorNone)
+    {
+        Log("HandleResolveResponse error: %s", ErrorToString(aError));
+    }
+    SuccessOrQuit(aError);
+
+    {
+        const Dns::Client::ServiceResponse &response = AsCoreType(aResponse);
+
+        ClearAllBytes(serviceInfo);
+        serviceInfo.mHostNameBuffer     = hostName;
+        serviceInfo.mHostNameBufferSize = sizeof(hostName);
+
+        SuccessOrQuit(response.GetServiceInfo(serviceInfo));
+
+        VerifyOrQuit(serviceInfo.mPort == kSrpServicePort);
+        VerifyOrQuit(StringMatch(hostName, "host1.default.service.arpa."));
+    }
+
+    context->mResolveCount++;
+}
+
+void HandleBrowseResponse(otError aError, const otDnsBrowseResponse *aResponse, void *aContext)
+{
+    TestContext           *context = static_cast<TestContext *>(aContext);
+    Dns::Name::LabelBuffer label;
+
+    if (aError != kErrorNone)
+    {
+        Log("HandleBrowseResponse error: %s", ErrorToString(aError));
+    }
+    SuccessOrQuit(aError);
+
+    {
+        const Dns::Client::BrowseResponse &response = AsCoreType(aResponse);
+
+        for (uint16_t index = 0; response.GetServiceInstance(index, label, sizeof(label)) == kErrorNone; index++)
+        {
+            Log("Found service instance %s", label);
+            if (StringMatch(label, kSpecialInstanceName))
+            {
+                context->mBrowseCount++;
+            }
+        }
+    }
+}
+
+/**
+ * Verifies SRP/DNS client/server support for Service Instance names with special and unicode characters.
+ *
+ * Topology:
+ *   SERVER (SRP server)
+ *     |
+ *   CLIENT (SRP client)
+ *
+ */
+void TestDnssdNameWithSpecialChars(void)
+{
+    Core  nexus;
+    Node &server = nexus.CreateNode();
+    Node &client = nexus.CreateNode();
+
+    server.SetName("SERVER");
+    client.SetName("CLIENT");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("Forming network");
+    server.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(server.Get<Mle::Mle>().IsLeader());
+
+    client.Join(server);
+    nexus.AdvanceTime(kJoinNetworkTime);
+    VerifyOrQuit(client.Get<Mle::Mle>().IsFullThreadDevice());
+
+    SuccessOrQuit(server.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    server.Get<Srp::Server>().SetEnabled(true);
+    SuccessOrQuit(server.Get<Dns::ServiceDiscovery::Server>().Start());
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("Registering service with special characters: %s", kSpecialInstanceName);
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName(kSrpHostName));
+    SuccessOrQuit(client.Get<Srp::Client>().EnableAutoHostAddress());
+
+    Srp::Client::Service service;
+    ClearAllBytes(service);
+    service.mName         = kSrpServiceType;
+    service.mInstanceName = kSpecialInstanceName;
+    service.mPort         = kSrpServicePort;
+    SuccessOrQuit(service.Init());
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(service));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    {
+        Srp::Client::ItemState state = client.Get<Srp::Client>().GetHostInfo().GetState();
+        Log("SRP Host State: %d", state);
+        VerifyOrQuit(state == Srp::Client::kRegistered);
+    }
+
+    Log("Checking service discovery via DNS browse");
+    TestContext context;
+    context.mBrowseCount  = 0;
+    context.mResolveCount = 0;
+
+    Dns::Client::QueryConfig queryConfig = client.Get<Dns::Client>().GetDefaultConfig();
+    AsCoreType(&queryConfig.mServerSockAddr).SetAddress(server.Get<Mle::Mle>().GetMeshLocalEid());
+    queryConfig.mServerSockAddr.mPort = 53;
+
+    SuccessOrQuit(client.Get<Dns::Client>().Browse(kSrpFullServiceType, HandleBrowseResponse, &context, &queryConfig));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    VerifyOrQuit(context.mBrowseCount == 1);
+
+    Log("Checking service discovery via DNS resolve");
+    SuccessOrQuit(client.Get<Dns::Client>().ResolveService(kSpecialInstanceName, kSrpFullServiceType,
+                                                           HandleResolveResponse, &context, &queryConfig));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    VerifyOrQuit(context.mResolveCount == 1);
+
+    Log("Checking service discovery via DNS resolve (case-insensitive)");
+    // Note: DNS is generally case-insensitive for ASCII.
+    // Unicode case-insensitivity might be more complex, but let's see if it works.
+    SuccessOrQuit(client.Get<Dns::Client>().ResolveService("o\\t 网关", kSrpFullServiceType, HandleResolveResponse,
+                                                           &context, &queryConfig));
+    nexus.AdvanceTime(kDnsQueryTime);
+
+    VerifyOrQuit(context.mResolveCount == 2);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestDnssdNameWithSpecialChars();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_dtls.cpp b/tests/nexus/test_dtls.cpp
index 2660d2b..d889917 100644
--- a/tests/nexus/test_dtls.cpp
+++ b/tests/nexus/test_dtls.cpp
@@ -125,11 +125,11 @@
 
     VerifyOrQuit(message != nullptr);
 
-    length = Random::NonCrypto::GetUint16InRange(1, kMessageSize);
+    length = Random::NonCrypto::GenerateFromMinUpToExcluding<uint16_t>(1, kMessageSize);
 
     for (uint16_t i = 0; i < length; i++)
     {
-        SuccessOrQuit(message->Append(Random::NonCrypto::GetUint8()));
+        SuccessOrQuit(message->Append(Random::NonCrypto::Generate<uint8_t>()));
     }
 
     return OwnedPtr<Message>(message);
@@ -274,8 +274,7 @@
         SuccessOrQuit(dtls0.SetPsk(kPsk, sizeof(kPsk)));
         dtls0.SetReceiveCallback(HandleReceive, &node0);
         dtls0.SetConnectCallback(HandleConnectEvent, &node0);
-        SuccessOrQuit(dtls0.Open());
-        SuccessOrQuit(dtls0.Bind(kUdpPort));
+        SuccessOrQuit(dtls0.Open(kUdpPort));
 
         nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
 
@@ -291,7 +290,7 @@
         SuccessOrQuit(dtls1.SetPsk(kPsk, sizeof(kPsk) - 1));
         dtls1.SetReceiveCallback(HandleReceive, &node1);
         dtls1.SetConnectCallback(HandleConnectEvent, &node1);
-        SuccessOrQuit(dtls1.Open());
+        SuccessOrQuit(dtls1.Open(0));
 
         for (uint16_t iter = 0; iter <= kMaxAttempts + 1; iter++)
         {
@@ -315,7 +314,7 @@
         SuccessOrQuit(dtls1.SetPsk(kPsk, sizeof(kPsk)));
         dtls1.SetReceiveCallback(HandleReceive, &node1);
         dtls1.SetConnectCallback(HandleConnectEvent, &node1);
-        SuccessOrQuit(dtls1.Open());
+        SuccessOrQuit(dtls1.Open(0));
         SuccessOrQuit(dtls1.Connect(sockAddr));
 
         nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -386,7 +385,7 @@
         SuccessOrQuit(dtls2.SetPsk(kPsk, sizeof(kPsk)));
         dtls2.SetReceiveCallback(HandleReceive, &node2);
         dtls2.SetReceiveCallback(HandleReceive, &node2);
-        SuccessOrQuit(dtls2.Open());
+        SuccessOrQuit(dtls2.Open(0));
         SuccessOrQuit(dtls2.Connect(sockAddr));
 
         nexus.AdvanceTime(20 * Time::kOneSecondInMsec);
@@ -425,8 +424,7 @@
         SuccessOrQuit(dtls0.SetPsk(kPsk, sizeof(kPsk)));
         dtls0.SetReceiveCallback(HandleReceive, &node0);
         dtls0.SetConnectCallback(HandleConnectEvent, &node0);
-        SuccessOrQuit(dtls0.Open());
-        SuccessOrQuit(dtls0.Bind(kUdpPort));
+        SuccessOrQuit(dtls0.Open(kUdpPort));
 
         nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
 
@@ -439,7 +437,7 @@
         SuccessOrQuit(dtls1.SetPsk(kPsk, sizeof(kPsk) - 1));
         dtls1.SetReceiveCallback(HandleReceive, &node1);
         dtls1.SetConnectCallback(HandleConnectEvent, &node1);
-        SuccessOrQuit(dtls1.Open());
+        SuccessOrQuit(dtls1.Open(0));
 
         for (uint16_t iter = 0; iter < kMaxAttempts - 1; iter++)
         {
@@ -513,8 +511,7 @@
         Log("Start DTLS (server) on node0 bound to port %u", kUdpPort);
 
         SuccessOrQuit(dtls0.SetPsk(kPsk, sizeof(kPsk)));
-        SuccessOrQuit(dtls0.Open());
-        SuccessOrQuit(dtls0.Bind(kUdpPort));
+        SuccessOrQuit(dtls0.Open(kUdpPort));
 
         nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
 
@@ -531,7 +528,7 @@
         SuccessOrQuit(dtls1.SetPsk(kPsk, sizeof(kPsk)));
         dtls1.SetReceiveCallback(HandleReceive, &node1);
         dtls1.SetConnectCallback(HandleConnectEvent, &node1);
-        SuccessOrQuit(dtls1.Open());
+        SuccessOrQuit(dtls1.Open(0));
         SuccessOrQuit(dtls1.Connect(sockAddr));
 
         nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
@@ -560,7 +557,7 @@
         SuccessOrQuit(dtls2.SetPsk(kPsk, sizeof(kPsk)));
         dtls2.SetReceiveCallback(HandleReceive, &node2);
         dtls2.SetConnectCallback(HandleConnectEvent, &node2);
-        SuccessOrQuit(dtls2.Open());
+        SuccessOrQuit(dtls2.Open(0));
         SuccessOrQuit(dtls2.Connect(sockAddr));
 
         nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
diff --git a/tests/nexus/test_fed_rx_only_link_establishment.cpp b/tests/nexus/test_fed_rx_only_link_establishment.cpp
new file mode 100644
index 0000000..e715684
--- /dev/null
+++ b/tests/nexus/test_fed_rx_only_link_establishment.cpp
@@ -0,0 +1,130 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static uint8_t sRxOnlyNeighborCount = 0;
+
+static void HandleNeighborTableChanged(otNeighborTableEvent aEvent, const otNeighborTableEntryInfo *aEntryInfo)
+{
+    VerifyOrQuit(static_cast<NeighborTable::Event>(aEvent) == NeighborTable::kRouterAdded);
+    VerifyOrQuit(aEntryInfo != nullptr);
+
+    Log("   Neighbor Table Event ROUTER_ADDED -> rloc16: 0x%04x", aEntryInfo->mInfo.mRouter.mRloc16);
+    sRxOnlyNeighborCount++;
+}
+
+void TestFedRxOnlyLinkEstablishment(void)
+{
+    static constexpr uint16_t kNumRouters = 15;
+
+    Core  nexus;
+    Node &leader = nexus.CreateNode();
+    Node *routers[kNumRouters];
+    Node *fed;
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("TestFedRxOnlyLinkEstablishment");
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Form topology - leader + %u routers", kNumRouters);
+
+    leader.Form();
+    nexus.AdvanceTime(100 * Time::kOneSecondInMsec);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    for (uint16_t i = 0; i < kNumRouters; i++)
+    {
+        Node &router = nexus.CreateNode();
+
+        router.Join(leader, Node::kAsFtd);
+        nexus.AdvanceTime(300 * Time::kOneSecondInMsec);
+        VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+        routers[i] = &router;
+
+        Log("Router %-2u -> rloc16: 0x%04x", i, router.Get<Mle::Mle>().GetRloc16());
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check Active Router Count on all routers");
+
+    nexus.AdvanceTime(300 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(leader.Get<RouterTable>().GetActiveRouterCount() == kNumRouters + 1);
+
+    for (const Node *router : routers)
+    {
+        VerifyOrQuit(router->Get<RouterTable>().GetActiveRouterCount() == kNumRouters + 1);
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Add an FED child to the network");
+
+    fed = &nexus.CreateNode();
+    fed->Get<NeighborTable>().RegisterCallback(HandleNeighborTableChanged);
+
+    fed->Join(leader, Node::kAsFed);
+    nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
+    VerifyOrQuit(fed->Get<Mle::Mle>().IsChild());
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check that the FED successfully establishes link (rx-only) with all neighboring routers");
+
+    VerifyOrQuit(fed->Get<RouterTable>().GetActiveRouterCount() == kNumRouters + 1);
+
+    for (uint16_t i = 0; i < 45; i++)
+    {
+        nexus.AdvanceTime(2 * Time::kOneMinuteInMsec);
+
+        if (sRxOnlyNeighborCount == kNumRouters)
+        {
+            break;
+        }
+    }
+
+    VerifyOrQuit(sRxOnlyNeighborCount == kNumRouters);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestFedRxOnlyLinkEstablishment();
+    printf("All tests passed\n");
+
+    return 0;
+}
diff --git a/tests/nexus/test_form_join.cpp b/tests/nexus/test_form_join.cpp
index 6973afd..f310bd9 100644
--- a/tests/nexus/test_form_join.cpp
+++ b/tests/nexus/test_form_join.cpp
@@ -52,10 +52,7 @@
 
     nexus.AdvanceTime(0);
 
-    for (Node &node : nexus.GetNodes())
-    {
-        node.GetInstance().SetLogLevel(kLogLevelInfo);
-    }
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     Log("---------------------------------------------------------------------------------------");
     Log("Form network");
diff --git a/tests/nexus/test_grpc.cpp b/tests/nexus/test_grpc.cpp
new file mode 100644
index 0000000..0c47854
--- /dev/null
+++ b/tests/nexus/test_grpc.cpp
@@ -0,0 +1,254 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "openthread-core-config.h"
+
+#include <grpcpp/grpcpp.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_grpc.hpp"
+#include "platform/nexus_node.hpp"
+#include "platform/nexus_sim.hpp"
+
+#include "simulation.grpc.pb.h"
+
+namespace ot {
+namespace Nexus {
+
+void TestGrpc(void)
+{
+    Core       nexus;
+    Simulation sim;
+    GrpcServer server;
+
+    auto channel = grpc::CreateChannel(kDefaultGrpcServerAddress, grpc::InsecureChannelCredentials());
+
+    // Wait for the channel to be ready with a timeout
+    VerifyOrQuit(channel->WaitForConnected(std::chrono::system_clock::now() + std::chrono::seconds(5)),
+                 "gRPC channel failed to connect within timeout");
+
+    auto stub = nexus::NexusService::NewStub(channel);
+
+    uint32_t leaderId;
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test CreateNode RPC");
+    {
+        nexus::CreateNodeRequest request;
+        request.set_x(10.0);
+        request.set_y(20.0);
+        nexus::CreateNodeResponse response;
+        grpc::ClientContext       context;
+        grpc::Status              status = stub->CreateNode(&context, request, &response);
+
+        VerifyOrQuit(status.ok(), "CreateNode RPC failed");
+        leaderId = response.node_id();
+        VerifyOrQuit(leaderId == 0, "Invalid node ID returned");
+
+        std::lock_guard<Simulation> lock(sim);
+        Node                       *node = nexus.FindNodeById(leaderId);
+        VerifyOrQuit(node != nullptr, "Node not found in Core");
+        VerifyOrQuit(node->GetPositionX() == 10.0, "Wrong X position");
+        VerifyOrQuit(node->GetPositionY() == 20.0, "Wrong Y position");
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test SetSpeed RPC");
+    {
+        nexus::SetSpeedRequest request;
+        request.set_speed_factor(2.5);
+        nexus::SetSpeedResponse response;
+        grpc::ClientContext     context;
+        grpc::Status            status = stub->SetSpeed(&context, request, &response);
+
+        VerifyOrQuit(status.ok(), "SetSpeed RPC failed");
+
+        {
+            std::lock_guard<Simulation> lock(sim);
+            VerifyOrQuit(sim.GetSpeedFactor() == 2.5, "Speed factor not set correctly");
+        }
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test StreamEvents");
+    {
+        grpc::ClientContext  context;
+        nexus::StreamRequest request;
+        auto                 reader = stub->StreamEvents(&context, request);
+
+        // The StreamEvents call should trigger DumpState
+        nexus::SimulationEvent event;
+        VerifyOrQuit(reader->Read(&event), "Failed to read event from stream");
+
+        // Push an event manually to verify it comes through
+        nexus::SimulationEvent manualEvent;
+        manualEvent.set_timestamp_us(987654);
+        server.PushEvent(manualEvent);
+
+        bool foundManualEvent = false;
+        // We might get multiple events (DumpState pushes many)
+        for (int i = 0; i < 100; i++)
+        {
+            if (!reader->Read(&event))
+                break;
+            if (event.timestamp_us() == 987654)
+            {
+                foundManualEvent = true;
+                break;
+            }
+        }
+        VerifyOrQuit(foundManualEvent, "Failed to receive manual event via stream");
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test SetNodePosition RPC");
+    {
+        nexus::SetNodePositionRequest request;
+        request.set_node_id(leaderId);
+        request.set_x(30.0);
+        request.set_y(40.0);
+        nexus::SetNodePositionResponse response;
+        grpc::ClientContext            context;
+        grpc::Status                   status = stub->SetNodePosition(&context, request, &response);
+
+        VerifyOrQuit(status.ok(), "SetNodePosition RPC failed");
+
+        std::lock_guard<Simulation> lock(sim);
+        Node                       *node = nexus.FindNodeById(leaderId);
+        VerifyOrQuit(node != nullptr, "Leader node not found");
+        VerifyOrQuit(node->GetPositionX() == 30.0, "Wrong X position after SetNodePosition");
+        VerifyOrQuit(node->GetPositionY() == 40.0, "Wrong Y position after SetNodePosition");
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test FormNetwork RPC");
+    {
+        nexus::FormNetworkRequest request;
+        request.set_node_id(leaderId);
+        nexus::FormNetworkResponse response;
+        grpc::ClientContext        context;
+        grpc::Status               status = stub->FormNetwork(&context, request, &response);
+
+        VerifyOrQuit(status.ok(), "FormNetwork RPC failed");
+
+        // Advancing time to allow forming
+        {
+            std::lock_guard<Simulation> lock(sim);
+            nexus.AdvanceTime(13000);
+        }
+
+        std::lock_guard<Simulation> lock(sim);
+        Node                       *node = nexus.FindNodeById(leaderId);
+        VerifyOrQuit(node != nullptr, "Leader node not found");
+        VerifyOrQuit(node->Get<Mle::Mle>().IsLeader(), "Node failed to become leader");
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test JoinNetwork RPC");
+    {
+        nexus::CreateNodeRequest createRequest;
+        createRequest.set_x(50.0);
+        createRequest.set_y(50.0);
+        nexus::CreateNodeResponse createResponse;
+        grpc::ClientContext       createCtx;
+        stub->CreateNode(&createCtx, createRequest, &createResponse);
+        uint32_t joinerId = createResponse.node_id();
+
+        nexus::JoinNetworkRequest joinRequest;
+        joinRequest.set_node_id(joinerId);
+        joinRequest.set_target_node_id(leaderId);
+        joinRequest.set_mode(nexus::JOIN_MODE_FTD);
+        nexus::JoinNetworkResponse joinResponse;
+        grpc::ClientContext        joinCtx;
+        grpc::Status               status = stub->JoinNetwork(&joinCtx, joinRequest, &joinResponse);
+
+        VerifyOrQuit(status.ok(), "JoinNetwork RPC failed");
+
+        {
+            std::lock_guard<Simulation> lock(sim);
+            nexus.AdvanceTime(10000);
+        }
+
+        std::lock_guard<Simulation> lock(sim);
+        Node                       *joiner = nexus.FindNodeById(joinerId);
+        VerifyOrQuit(joiner != nullptr, "Joiner node not found");
+        VerifyOrQuit(joiner->Get<Mle::Mle>().IsChild() || joiner->Get<Mle::Mle>().IsRouter(),
+                     "Node failed to join network");
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test SetNodeState RPC (Disable)");
+    {
+        nexus::SetNodeStateRequest request;
+        request.set_node_id(leaderId);
+        request.set_enabled(false);
+        nexus::SetNodeStateResponse response;
+        grpc::ClientContext         context;
+        grpc::Status                status = stub->SetNodeState(&context, request, &response);
+
+        VerifyOrQuit(status.ok(), "SetNodeState (Disable) failed");
+
+        std::lock_guard<Simulation> lock(sim);
+        Node                       *node = nexus.FindNodeById(leaderId);
+        VerifyOrQuit(node != nullptr, "Leader node not found");
+        VerifyOrQuit(node->Get<Mle::Mle>().GetRole() == ot::Mle::kRoleDisabled, "Node role not disabled");
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test SetNodeState RPC (Enable)");
+    {
+        nexus::SetNodeStateRequest request;
+        request.set_node_id(leaderId);
+        request.set_enabled(true);
+        nexus::SetNodeStateResponse response;
+        grpc::ClientContext         context;
+        grpc::Status                status = stub->SetNodeState(&context, request, &response);
+
+        VerifyOrQuit(status.ok(), "SetNodeState (Enable) failed");
+
+        {
+            std::lock_guard<Simulation> lock(sim);
+            nexus.AdvanceTime(1000);
+        }
+
+        std::lock_guard<Simulation> lock(sim);
+        Node                       *node = nexus.FindNodeById(leaderId);
+        VerifyOrQuit(node != nullptr, "Leader node not found");
+        VerifyOrQuit(node->Get<Mle::Mle>().GetRole() != ot::Mle::kRoleDisabled, "Node role still disabled");
+    }
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestGrpc();
+    ot::Nexus::Log("All tests passed");
+    return 0;
+}
diff --git a/tests/nexus/test_history_tracker.cpp b/tests/nexus/test_history_tracker.cpp
new file mode 100644
index 0000000..dfebe30
--- /dev/null
+++ b/tests/nexus/test_history_tracker.cpp
@@ -0,0 +1,244 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestHistoryTracker(void)
+{
+    static constexpr uint32_t kFormLeaderTimeMsec        = 13 * 1000;
+    static constexpr uint32_t kStopLeaderTimeMsec        = 5 * 1000;
+    static constexpr uint32_t kRestartLeaderTimeMsec     = 25 * 1000;
+    static constexpr uint32_t kJoinChildTimeMsec         = 10 * 1000;
+    static constexpr uint32_t kAgeVerificationWindowMsec = 10000u;
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &child  = nexus.CreateNode();
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Start leader and verify its netinfo history");
+
+    leader.Form();
+    nexus.AdvanceTime(kFormLeaderTimeMsec);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    HistoryTracker::Iterator           iter;
+    uint32_t                           age;
+    const HistoryTracker::NetworkInfo *netInfo;
+
+    iter.Init();
+    netInfo = leader.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(MapEnum(netInfo->mRole) == Mle::kRoleLeader);
+    VerifyOrQuit(netInfo->mMode.mRxOnWhenIdle);
+    VerifyOrQuit(netInfo->mMode.mDeviceType);
+    VerifyOrQuit(netInfo->mRloc16 == leader.Get<Mle::Mle>().GetRloc16());
+    VerifyOrQuit(netInfo->mPartitionId == leader.Get<Mle::Mle>().GetLeaderData().GetPartitionId());
+
+    netInfo = leader.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(MapEnum(netInfo->mRole) == Mle::kRoleDetached);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Stop leader and verify its netinfo history contains 'disabled'");
+
+    leader.Get<ThreadNetif>().Down();
+    leader.Get<Mle::Mle>().Stop();
+
+    nexus.AdvanceTime(kStopLeaderTimeMsec);
+
+    iter.Init();
+    netInfo = leader.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(MapEnum(netInfo->mRole) == Mle::kRoleDisabled);
+
+    netInfo = leader.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(MapEnum(netInfo->mRole) == Mle::kRoleLeader);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Wait for 49 days and verify age calculations");
+
+    nexus.AdvanceTime(Time::kOneDayInMsec);
+
+    iter.Init();
+    netInfo = leader.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(age >= Time::kOneDayInMsec && age < Time::kOneDayInMsec + kAgeVerificationWindowMsec);
+
+    nexus.AdvanceTime(Time::kOneDayInMsec);
+    iter.Init();
+    netInfo = leader.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(age >= 2 * Time::kOneDayInMsec && age < 2 * Time::kOneDayInMsec + kAgeVerificationWindowMsec);
+
+    nexus.AdvanceTime(47 * Time::kOneDayInMsec);
+    iter.Init();
+    netInfo = leader.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(age == HistoryTracker::kMaxAge);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Restart Leader, join Child");
+
+    leader.Form();
+    nexus.AdvanceTime(kRestartLeaderTimeMsec);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    child.Join(leader, Node::kAsMed);
+    nexus.AdvanceTime(kJoinChildTimeMsec);
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify child netinfo");
+
+    iter.Init();
+    netInfo = child.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(MapEnum(netInfo->mRole) == Mle::kRoleChild);
+    VerifyOrQuit(netInfo->mMode.mRxOnWhenIdle);
+    VerifyOrQuit(!netInfo->mMode.mDeviceType);
+    VerifyOrQuit(netInfo->mRloc16 == child.Get<Mle::Mle>().GetRloc16());
+    VerifyOrQuit(netInfo->mPartitionId == leader.Get<Mle::Mle>().GetLeaderData().GetPartitionId());
+
+    netInfo = child.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(MapEnum(netInfo->mRole) == Mle::kRoleDetached);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Change child mode and verify netinfo");
+
+    Mle::DeviceMode mode(Mle::DeviceMode::kModeRxOnWhenIdle | Mle::DeviceMode::kModeFullThreadDevice |
+                         Mle::DeviceMode::kModeFullNetworkData);
+
+    SuccessOrQuit(child.Get<Mle::Mle>().SetDeviceMode(mode));
+    SuccessOrQuit(child.Get<Mle::Mle>().SetRouterEligible(false));
+    nexus.AdvanceTime(kJoinChildTimeMsec);
+
+    iter.Init();
+    netInfo = child.Get<HistoryTracker::Local>().IterateNetInfoHistory(iter, age);
+    VerifyOrQuit(netInfo != nullptr);
+    VerifyOrQuit(netInfo->mMode.mDeviceType);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Ping between leader and child and verify TX and RX message histories");
+
+    static constexpr uint16_t kPingSizes[] = {10, 100, 1000};
+    for (uint16_t size : kPingSizes)
+    {
+        nexus.SendAndVerifyEchoRequest(leader, child.Get<Mle::Mle>().GetMeshLocalEid(), size);
+    }
+
+    // Check the TX history of the leader for the 3 Echo Requests
+    const HistoryTracker::MessageInfo *msgInfo;
+
+    iter.Init();
+    uint8_t txRequestsFound = 0;
+    while ((msgInfo = leader.Get<HistoryTracker::Local>().IterateTxHistory(iter, age)) != nullptr)
+    {
+        if (msgInfo->mIcmp6Type == OT_ICMP6_TYPE_ECHO_REQUEST)
+        {
+            VerifyOrQuit(txRequestsFound < 3);
+            uint16_t expectedSize = kPingSizes[2 - txRequestsFound];
+            VerifyOrQuit(msgInfo->mIpProto == OT_IP6_PROTO_ICMP6);
+            VerifyOrQuit(msgInfo->mPayloadLength == expectedSize + sizeof(Ip6::Icmp6Header));
+            VerifyOrQuit(AsCoreType(&msgInfo->mSource.mAddress) == leader.Get<Mle::Mle>().GetMeshLocalEid());
+            VerifyOrQuit(AsCoreType(&msgInfo->mDestination.mAddress) == child.Get<Mle::Mle>().GetMeshLocalEid());
+            VerifyOrQuit(msgInfo->mLinkSecurity);
+            VerifyOrQuit(msgInfo->mRadioIeee802154);
+            VerifyOrQuit(msgInfo->mPriority == OT_HISTORY_TRACKER_MSG_PRIORITY_NORMAL);
+            VerifyOrQuit(msgInfo->mNeighborRloc16 == child.Get<Mle::Mle>().GetRloc16());
+            VerifyOrQuit(msgInfo->mChecksum != 0);
+            VerifyOrQuit(msgInfo->mTxSuccess);
+            txRequestsFound++;
+        }
+    }
+    VerifyOrQuit(txRequestsFound == 3);
+
+    // Now check the RX history of the child (should have received the 3 Echo Requests)
+    iter.Init();
+    uint8_t rxRequestsFound = 0;
+    while ((msgInfo = child.Get<HistoryTracker::Local>().IterateRxHistory(iter, age)) != nullptr)
+    {
+        if (msgInfo->mIcmp6Type == OT_ICMP6_TYPE_ECHO_REQUEST)
+        {
+            VerifyOrQuit(rxRequestsFound < 3);
+            uint16_t expectedSize = kPingSizes[2 - rxRequestsFound];
+            VerifyOrQuit(msgInfo->mIpProto == OT_IP6_PROTO_ICMP6);
+            VerifyOrQuit(msgInfo->mPayloadLength == expectedSize + sizeof(Ip6::Icmp6Header));
+            VerifyOrQuit(AsCoreType(&msgInfo->mSource.mAddress) == leader.Get<Mle::Mle>().GetMeshLocalEid());
+            VerifyOrQuit(AsCoreType(&msgInfo->mDestination.mAddress) == child.Get<Mle::Mle>().GetMeshLocalEid());
+            VerifyOrQuit(msgInfo->mLinkSecurity);
+            VerifyOrQuit(msgInfo->mRadioIeee802154);
+            VerifyOrQuit(msgInfo->mPriority == OT_HISTORY_TRACKER_MSG_PRIORITY_NORMAL);
+            VerifyOrQuit(msgInfo->mNeighborRloc16 == leader.Get<Mle::Mle>().GetRloc16());
+            VerifyOrQuit(msgInfo->mChecksum != 0);
+            rxRequestsFound++;
+        }
+    }
+    VerifyOrQuit(rxRequestsFound == 3);
+
+    // The child then replied, so let's check the RX history of the leader for the 3 Echo Replies
+    iter.Init();
+    uint8_t repliesFound = 0;
+    while ((msgInfo = leader.Get<HistoryTracker::Local>().IterateRxHistory(iter, age)) != nullptr)
+    {
+        if (msgInfo->mIcmp6Type == OT_ICMP6_TYPE_ECHO_REPLY)
+        {
+            VerifyOrQuit(msgInfo->mIpProto == OT_IP6_PROTO_ICMP6);
+            VerifyOrQuit(AsCoreType(&msgInfo->mSource.mAddress) == child.Get<Mle::Mle>().GetMeshLocalEid());
+            VerifyOrQuit(AsCoreType(&msgInfo->mDestination.mAddress) == leader.Get<Mle::Mle>().GetMeshLocalEid());
+            VerifyOrQuit(msgInfo->mLinkSecurity);
+            VerifyOrQuit(msgInfo->mRadioIeee802154);
+            VerifyOrQuit(msgInfo->mPriority == OT_HISTORY_TRACKER_MSG_PRIORITY_NORMAL);
+            VerifyOrQuit(msgInfo->mNeighborRloc16 == child.Get<Mle::Mle>().GetRloc16());
+            VerifyOrQuit(msgInfo->mChecksum != 0);
+            repliesFound++;
+        }
+    }
+    VerifyOrQuit(repliesFound == 3);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestHistoryTracker();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_inform_previous_parent_on_reattach.cpp b/tests/nexus/test_inform_previous_parent_on_reattach.cpp
new file mode 100644
index 0000000..30f034a
--- /dev/null
+++ b/tests/nexus/test_inform_previous_parent_on_reattach.cpp
@@ -0,0 +1,122 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * The purpose of this test is to verify a SED will inform its previous parent when re-attaches to another parent.
+ *
+ * Initial Topology:
+ *
+ *  LEADER ----- ROUTER
+ *    |
+ *   SED
+ *
+ */
+
+void RunTest(const char *aJsonFileName)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &sed    = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+    sed.SetName("SED");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Form network with LEADER and ROUTER");
+
+    AllowLinkBetween(leader, router);
+    AllowLinkBetween(leader, sed);
+
+    leader.Form();
+    nexus.AdvanceTime(13000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(200000);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: SED joins LEADER");
+
+    sed.Get<Mle::Mle>().SetTimeout(60); // 60 seconds
+    sed.Join(leader, Node::kAsSed);
+    nexus.AdvanceTime(20000);
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(sed.Get<Mle::Mle>().GetParent().GetExtAddress() == leader.Get<Mac::Mac>().GetExtAddress());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Switch links (Block LEADER <-> SED, Allow ROUTER <-> SED)");
+
+    UnallowLinkBetween(leader, sed);
+    AllowLinkBetween(router, sed);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: SED re-attaches to ROUTER");
+
+    // We wait for SED to realize LEADER is gone and re-attach to ROUTER.
+    // Child timeout is 60s. We wait 100s.
+    nexus.AdvanceTime(100000);
+
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(sed.Get<Mle::Mle>().GetParent().GetExtAddress() == router.Get<Mac::Mac>().GetExtAddress());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Verify LEADER's child table");
+
+    // SED will re-attach to ROUTER, which will inform LEADER to remove the child.
+    VerifyOrQuit(leader.Get<ChildTable>().FindChild(sed.Get<Mac::Mac>().GetExtAddress(), Child::kInStateValid) ==
+                 nullptr);
+
+    nexus.SaveTestInfo(aJsonFileName, &leader);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::RunTest(argc > 2 ? argv[2] : "test_inform_previous_parent_on_reattach.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_ipv6_fragmentation.cpp b/tests/nexus/test_ipv6_fragmentation.cpp
new file mode 100644
index 0000000..382fb07
--- /dev/null
+++ b/tests/nexus/test_ipv6_fragmentation.cpp
@@ -0,0 +1,219 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/** Time to advance for a node to form a network and become leader, in milliseconds. */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/** Time to advance for a node to join as a child and upgrade to a router, in milliseconds. */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/** Time to wait for ICMPv6 Echo response, in milliseconds. */
+static constexpr uint32_t kEchoTimeout = 10 * 1000;
+
+static bool     gForwardedPacketReceived = false;
+static uint16_t gForwardedPacketLen      = 0;
+
+static void MyCustomIp6ReceiveCallback(otMessage *aMessage, void *aContext)
+{
+    OT_UNUSED_VARIABLE(aContext);
+    Message    &message = *AsCoreTypePtr(aMessage);
+    Ip6::Header header;
+    if (header.ParseFrom(message) == kErrorNone)
+    {
+        if (header.GetNextHeader() == Ip6::kProtoUdp && message.GetLength() == 640)
+        {
+            gForwardedPacketReceived = true;
+            gForwardedPacketLen      = message.GetLength();
+            Log("MyCustomIp6ReceiveCallback: Received forwarded packet of length %u", gForwardedPacketLen);
+        }
+    }
+}
+
+static void SendGapFragment1(Node &aRouter, const Ip6::Address &aLeaderEid, uint32_t aIdentification)
+{
+    Message *message = aRouter.Get<MessagePool>().Allocate(Message::kTypeIp6);
+    VerifyOrQuit(message != nullptr);
+
+    // 1. Outer IPv6 Header
+    Ip6::Header outerHeader;
+    outerHeader.InitVersionTrafficClassFlow();
+    outerHeader.SetPayloadLength(48); // 8 bytes (Fragment Header) + 40 bytes (Inner IPv6 Header)
+    outerHeader.SetNextHeader(Ip6::kProtoFragment);
+    outerHeader.SetHopLimit(64);
+    outerHeader.SetSource(aRouter.Get<Mle::Mle>().GetMeshLocalEid());
+    outerHeader.SetDestination(aLeaderEid);
+    SuccessOrQuit(message->Append(outerHeader));
+
+    // 2. Fragment Header
+    Ip6::FragmentHeader fragmentHeader;
+    fragmentHeader.Init();
+    fragmentHeader.SetNextHeader(Ip6::kProtoIp6);
+    fragmentHeader.SetOffset(0);
+    fragmentHeader.SetMoreFlag();
+    fragmentHeader.SetIdentification(aIdentification);
+    SuccessOrQuit(message->Append(fragmentHeader));
+
+    // 3. Inner IPv6 Header (Payload of Fragment 1)
+    Ip6::Header innerHeader;
+    innerHeader.InitVersionTrafficClassFlow();
+    innerHeader.SetPayloadLength(600); // 600 bytes inner payload to stay under MTU limit
+    innerHeader.SetNextHeader(Ip6::kProtoUdp);
+    innerHeader.SetHopLimit(64);
+    innerHeader.SetSource(aLeaderEid);
+    innerHeader.SetDestination(aRouter.Get<Mle::Mle>().GetMeshLocalEid());
+    SuccessOrQuit(message->Append(innerHeader));
+
+    // Set message priority/etc
+    SuccessOrQuit(message->SetPriority(Message::kPriorityNormal));
+
+    // Send it
+    SuccessOrQuit(aRouter.Get<Ip6::Ip6>().SendRaw(OwnedPtr<Message>(message)));
+}
+
+static void SendGapFragment2(Node &aRouter, const Ip6::Address &aLeaderEid, uint32_t aIdentification)
+{
+    Message *message = aRouter.Get<MessagePool>().Allocate(Message::kTypeIp6);
+    VerifyOrQuit(message != nullptr);
+
+    // 1. Outer IPv6 Header
+    Ip6::Header outerHeader;
+    outerHeader.InitVersionTrafficClassFlow();
+    outerHeader.SetPayloadLength(8); // 8 bytes (Fragment Header)
+    outerHeader.SetNextHeader(Ip6::kProtoFragment);
+    outerHeader.SetHopLimit(64);
+    outerHeader.SetSource(aRouter.Get<Mle::Mle>().GetMeshLocalEid());
+    outerHeader.SetDestination(aLeaderEid);
+    SuccessOrQuit(message->Append(outerHeader));
+
+    // 2. Fragment Header
+    Ip6::FragmentHeader fragmentHeader;
+    fragmentHeader.Init();
+    fragmentHeader.SetNextHeader(Ip6::kProtoIp6);
+    fragmentHeader.SetOffset(80);   // 80 * 8 = 640 bytes (under MTU limit)
+    fragmentHeader.ClearMoreFlag(); // M = 0
+    fragmentHeader.SetIdentification(aIdentification);
+    SuccessOrQuit(message->Append(fragmentHeader));
+
+    // Set message priority/etc
+    SuccessOrQuit(message->SetPriority(Message::kPriorityNormal));
+
+    // Send it
+    SuccessOrQuit(aRouter.Get<Ip6::Ip6>().SendRaw(OwnedPtr<Message>(message)));
+}
+
+void TestIPv6Fragmentation(void)
+{
+    /**
+     * Test IPv6 Fragmentation
+     *
+     * Topology:
+     * - Leader
+     * - Router
+     *
+     * Description:
+     * The purpose of this test case is to validate IPv6 fragmentation and reassembly.
+     * Large ICMPv6 Echo Requests (exceeding 1280 bytes MTU) are sent between nodes.
+     */
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("Step 1: Form network");
+
+    AllowLinkBetween(leader, router);
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    Log("Step 2: Large Echo Request from Leader to Router (1952 bytes payload)");
+    // 1952 bytes payload + 8 bytes ICMP header + 40 bytes IPv6 header = 2000 bytes.
+    // This exceeds the 1280 bytes MTU and triggers IPv6 fragmentation.
+    nexus.SendAndVerifyEchoRequest(leader, router.Get<Mle::Mle>().GetMeshLocalEid(), 1952, 64, kEchoTimeout);
+
+    Log("Step 3: Large Echo Request from Router to Leader (1831 bytes payload)");
+    // 1831 bytes payload + 8 bytes ICMP header + 40 bytes IPv6 header = 1879 bytes.
+    // This exceeds the 1280 bytes MTU and triggers IPv6 fragmentation.
+    nexus.SendAndVerifyEchoRequest(router, leader.Get<Mle::Mle>().GetMeshLocalEid(), 1831, 64, kEchoTimeout);
+
+    Log("Step 4: Fragment Reassembly Gap Check Test");
+    gForwardedPacketReceived = false;
+    gForwardedPacketLen      = 0;
+
+    // Register the custom receive callback on Router to intercept the forwarded packet.
+    router.Get<Ip6::Ip6>().SetReceiveCallback(MyCustomIp6ReceiveCallback, nullptr);
+
+    {
+        uint32_t identification = 0x12345678;
+        Log("Sending Fragment 1 (offset=0, M=1)");
+        SendGapFragment1(router, leader.Get<Mle::Mle>().GetMeshLocalEid(), identification);
+        nexus.AdvanceTime(100); // Short wait
+
+        Log("Sending Fragment 2 (offset=640, M=0)");
+        SendGapFragment2(router, leader.Get<Mle::Mle>().GetMeshLocalEid(), identification);
+        nexus.AdvanceTime(1000); // Wait for reassembly and forwarding
+    }
+
+    // Check if the packet was received (Expect no packet received in patched version!)
+    VerifyOrQuit(!gForwardedPacketReceived, "Reassembly gap check failed: forwarded packet was received!");
+    Log("Success: No forwarded packet was received. Reassembly gap is properly handled!");
+
+    // Restore default receive callback on Router
+    router.Get<Ip6::Ip6>().SetReceiveCallback(Node::HandleIp6Receive, &router);
+
+    nexus.SaveTestInfo("test_ipv6_fragmentation.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestIPv6Fragmentation();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_ipv6_recursion.cpp b/tests/nexus/test_ipv6_recursion.cpp
new file mode 100644
index 0000000..d24447b
--- /dev/null
+++ b/tests/nexus/test_ipv6_recursion.cpp
@@ -0,0 +1,184 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestIPv6Recursion(void)
+{
+    /**
+     * Test IPv6 Recursion
+     *
+     * Topology:
+     * - Leader
+     *
+     * Description:
+     * The purpose of this test case is to validate that both Ip6::HandleDatagram
+     * and lowpan compression enforce their respective kMaxRecursionDepth limit
+     * to prevent unbounded stack recursion/excessive recursive stack usage.
+     */
+
+    Core  nexus;
+    Node &leader = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    leader.Form();
+    nexus.AdvanceTime(10 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    Ip6::Address selfAddress = leader.Get<Mle::Mle>().GetMeshLocalEid();
+
+    // Test Case 1: Recursion depth 3 (acceptable limit <= 4)
+    {
+        Log("Test Case 1: Send nested IPv6 packet with depth 3 (within limit)");
+        Message *message = leader.Get<Ip6::Ip6>().NewMessage();
+        VerifyOrQuit(message != nullptr);
+
+        // Construct 3 nested headers:
+        // - i = 2 (innermost): NH = kProtoNone, PayloadLen = 0
+        // - i = 1: NH = kProtoIp6, PayloadLen = 40
+        // - i = 0 (outermost): NH = kProtoIp6, PayloadLen = 80
+        for (int i = 0; i < 3; i++)
+        {
+            Ip6::Header header;
+            header.InitVersionTrafficClassFlow();
+            header.SetSource(selfAddress);
+            header.SetDestination(selfAddress);
+            if (i == 2)
+            {
+                header.SetNextHeader(Ip6::kProtoNone);
+                header.SetPayloadLength(0);
+            }
+            else
+            {
+                header.SetNextHeader(Ip6::kProtoIp6);
+                header.SetPayloadLength((2 - i) * sizeof(Ip6::Header));
+            }
+            SuccessOrQuit(message->Append(header));
+        }
+
+        Error error = leader.Get<Ip6::Ip6>().HandleDatagram(OwnedPtr<Message>(message));
+        VerifyOrQuit(error != kErrorDrop);
+        Log("Test Case 1: Passed successfully!");
+    }
+
+    // Test Case 2: Recursion depth 6 (exceeds limit of 4 in Ip6::HandleDatagram)
+    {
+        Log("Test Case 2: Send nested IPv6 packet with depth 6 (exceeds limit)");
+        Message *message = leader.Get<Ip6::Ip6>().NewMessage();
+        VerifyOrQuit(message != nullptr);
+
+        // Construct 6 nested headers:
+        // - i = 5 (innermost): NH = kProtoNone, PayloadLen = 0
+        // - i = 0 to 4: NH = kProtoIp6, PayloadLen = (5 - i) * 40
+        for (int i = 0; i < 6; i++)
+        {
+            Ip6::Header header;
+            header.InitVersionTrafficClassFlow();
+            header.SetSource(selfAddress);
+            header.SetDestination(selfAddress);
+            if (i == 5)
+            {
+                header.SetNextHeader(Ip6::kProtoNone);
+                header.SetPayloadLength(0);
+            }
+            else
+            {
+                header.SetNextHeader(Ip6::kProtoIp6);
+                header.SetPayloadLength((5 - i) * sizeof(Ip6::Header));
+            }
+            SuccessOrQuit(message->Append(header));
+        }
+
+        Error error = leader.Get<Ip6::Ip6>().HandleDatagram(OwnedPtr<Message>(message));
+        VerifyOrQuit(error == kErrorDrop);
+        Log("Test Case 2: Passed successfully (packet with depth 6 was dropped with kErrorDrop)!");
+    }
+
+    // Test Case 3: Direct call to Lowpan::Compress with depth 6 nested packet
+    {
+        Log("Test Case 3: Call Lowpan::Compress with depth 6 nested packet (should cap and fall back gracefully)");
+        Message *message = leader.Get<Ip6::Ip6>().NewMessage();
+        VerifyOrQuit(message != nullptr);
+
+        // Construct 6 nested headers (similar to Test Case 1)
+        for (int i = 0; i < 6; i++)
+        {
+            Ip6::Header header;
+            header.InitVersionTrafficClassFlow();
+            header.SetSource(selfAddress);
+            header.SetDestination(selfAddress);
+            if (i == 5)
+            {
+                header.SetNextHeader(Ip6::kProtoNone);
+                header.SetPayloadLength(0);
+            }
+            else
+            {
+                header.SetNextHeader(Ip6::kProtoIp6);
+                header.SetPayloadLength((5 - i) * sizeof(Ip6::Header));
+            }
+            SuccessOrQuit(message->Append(header));
+        }
+
+        Mac::Addresses macAddrs;
+        macAddrs.mSource.SetShort(leader.Get<Mac::Mac>().GetShortAddress());
+        macAddrs.mDestination.SetShort(leader.Get<Mac::Mac>().GetShortAddress());
+
+        uint8_t      frameBuffer[512];
+        FrameBuilder frameBuilder;
+        frameBuilder.Init(frameBuffer, sizeof(frameBuffer));
+
+        // This should succeed (return kErrorNone) because it will cap recursion at depth 4
+        // and emit the remaining nested headers inline as opaque payload.
+        Error error = leader.Get<Lowpan::Lowpan>().Compress(*message, macAddrs, frameBuilder);
+        VerifyOrQuit(error == kErrorNone);
+        Log("Test Case 3: Passed successfully (returned kErrorNone without excessive stack usage)!");
+        message->Free();
+    }
+
+    nexus.SaveTestInfo("test_ipv6_recursion.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestIPv6Recursion();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_ipv6_source_selection.cpp b/tests/nexus/test_ipv6_source_selection.cpp
new file mode 100644
index 0000000..c6e38bf
--- /dev/null
+++ b/tests/nexus/test_ipv6_source_selection.cpp
@@ -0,0 +1,153 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestIPv6SourceSelection(void)
+{
+    /**
+     * Test IPv6 Source Address Selection
+     *
+     * Purpose & Description:
+     * This test verifies that the correct source IPv6 address is selected based on the destination address.
+     */
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+
+    AllowLinkBetween(leader, router);
+
+    nexus.AdvanceTime(0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 0: Form network");
+
+    leader.Form();
+    nexus.AdvanceTime(13 * 1000); // kFormNetworkTime
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(200 * 1000); // kAttachToRouterTime
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    Ip6::Address leaderRloc      = leader.Get<Mle::Mle>().GetMeshLocalRloc();
+    Ip6::Address leaderMleid     = leader.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address leaderLinkLocal = leader.Get<Mle::Mle>().GetLinkLocalAddress();
+    Ip6::Address leaderAloc;
+    leader.Get<Mle::Mle>().ComposeLeaderAloc(leaderAloc);
+
+    Ip6::Address routerRloc      = router.Get<Mle::Mle>().GetMeshLocalRloc();
+    Ip6::Address routerMleid     = router.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address routerLinkLocal = router.Get<Mle::Mle>().GetLinkLocalAddress();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: RLOC source for RLOC destination");
+    nexus.SendAndVerifyEchoRequest(router, routerRloc, leaderRloc, 0, Ip6::kDefaultHopLimit, 1000, false);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: ML-EID source for ALOC destination");
+    nexus.SendAndVerifyEchoRequest(router, routerMleid, leaderAloc, 0, Ip6::kDefaultHopLimit, 1000, false);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: ML-EID source for ML-EID destination");
+    nexus.SendAndVerifyEchoRequest(router, routerMleid, leaderMleid, 0, Ip6::kDefaultHopLimit, 1000, false);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Link-local source for Link-local destination");
+    nexus.SendAndVerifyEchoRequest(router, routerLinkLocal, leaderLinkLocal, 0, Ip6::kDefaultHopLimit, 1000, false);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: ML-EID source for Realm-local multicast destination");
+    Ip6::Address multicastAddr;
+    SuccessOrQuit(multicastAddr.FromString("ff03::1"));
+    nexus.SendAndVerifyEchoRequest(router, routerMleid, multicastAddr, 0, Ip6::kDefaultHopLimit, 1000, false);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: GUA source for GUA destination");
+
+    NetworkData::OnMeshPrefixConfig config;
+    config.Clear();
+    SuccessOrQuit(config.GetPrefix().FromString("2001::/64"));
+    config.mPreferred    = true;
+    config.mSlaac        = true;
+    config.mOnMesh       = true;
+    config.mDefaultRoute = true;
+    config.mStable       = true;
+
+    SuccessOrQuit(leader.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    leader.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    nexus.AdvanceTime(10 * 1000);
+
+    const Ip6::Address &leaderGua = leader.FindMatchingAddress("2001::/64");
+    const Ip6::Address &routerGua = router.FindMatchingAddress("2001::/64");
+
+    VerifyOrQuit(!leaderGua.IsUnspecified());
+    VerifyOrQuit(!routerGua.IsUnspecified());
+
+    nexus.SendAndVerifyEchoRequest(router, routerGua, leaderGua, 0, Ip6::kDefaultHopLimit, 1000, false);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: GUA source for external address (default route)");
+
+    Ip6::Address externalAddr;
+    SuccessOrQuit(externalAddr.FromString("2007::1"));
+
+    // Add externalAddr to leader so it responds to pings
+    otNetifAddress extNetifAddr;
+    memset(&extNetifAddr, 0, sizeof(extNetifAddr));
+    AsCoreType(&extNetifAddr.mAddress) = externalAddr;
+    extNetifAddr.mPrefixLength         = 128;
+    extNetifAddr.mPreferred            = true;
+    extNetifAddr.mValid                = true;
+    SuccessOrQuit(otIp6AddUnicastAddress(&leader.GetInstance(), &extNetifAddr));
+
+    nexus.SendAndVerifyEchoRequest(router, routerGua, externalAddr, 0, Ip6::kDefaultHopLimit, 1000, false);
+
+    nexus.SaveTestInfo("test_ipv6_source_selection.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestIPv6SourceSelection();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_key_rotation_guard_time.cpp b/tests/nexus/test_key_rotation_guard_time.cpp
new file mode 100644
index 0000000..e1dfd0f
--- /dev/null
+++ b/tests/nexus/test_key_rotation_guard_time.cpp
@@ -0,0 +1,192 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "meshcop/dataset_manager.hpp"
+#include "meshcop/dataset_updater.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/key_manager.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachTime = 200 * 1000;
+
+/**
+ * Time to wait for ICMPv6 Echo response, in milliseconds.
+ */
+static constexpr uint32_t kEchoTimeout = 5000;
+
+void TestKeyRotationGuardTime(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &child  = nexus.CreateNode();
+    Node &reed   = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    child.SetName("CHILD");
+    reed.SetName("REED");
+    router.SetName("ROUTER");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form the network");
+
+    AllowLinkBetween(leader, child);
+    AllowLinkBetween(leader, reed);
+    AllowLinkBetween(leader, router);
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    child.Join(leader, Node::kAsMed);
+    reed.Join(leader, Node::kAsFed);
+    router.Join(leader, Node::kAsFtd);
+
+    nexus.AdvanceTime(kAttachTime);
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Validate initial key sequence counter and key switch guard time");
+
+    Node *nodes[] = {&leader, &child, &reed, &router};
+
+    for (Node *node : nodes)
+    {
+        VerifyOrQuit(node->Get<KeyManager>().GetCurrentKeySequence() == 0);
+        VerifyOrQuit(node->Get<KeyManager>().GetKeySwitchGuardTime() == 624);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Change the key rotation time and verify key switch guard time");
+
+    uint16_t rotationTimes[] = {100, 1, 10, 888, 2};
+
+    for (uint16_t rotationTime : rotationTimes)
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+
+        datasetInfo.Clear();
+        datasetInfo.Update<MeshCoP::Dataset::kSecurityPolicy>().SetToDefault();
+        datasetInfo.Update<MeshCoP::Dataset::kSecurityPolicy>().mRotationTime = rotationTime;
+
+        SuccessOrQuit(leader.Get<MeshCoP::DatasetUpdater>().RequestUpdate(datasetInfo, nullptr, nullptr));
+
+        nexus.AdvanceTime(301 * 1000); // Wait for propagation (default delay is 300s)
+
+        uint16_t effectiveRotationTime =
+            (rotationTime < SecurityPolicy::kMinKeyRotationTime) ? SecurityPolicy::kMinKeyRotationTime : rotationTime;
+        static constexpr uint32_t kKeySwitchGuardTimePercentage = 93;
+        uint16_t                  expectedGuardTime =
+            static_cast<uint16_t>(static_cast<uint32_t>(effectiveRotationTime) * kKeySwitchGuardTimePercentage / 100);
+
+        for (Node *node : nodes)
+        {
+            VerifyOrQuit(node->Get<KeyManager>().GetKeySwitchGuardTime() == expectedGuardTime);
+        }
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Wait for automatic rotation to 1");
+
+    // All nodes should already have rotationTime = 2 and guardTime = 1 hour from the end of the previous loop.
+    // Trigger rotation by waiting 2 hours.
+    nexus.AdvanceTime(2 * 3600 * 1000);
+
+    for (Node *node : nodes)
+    {
+        VerifyOrQuit(node->Get<KeyManager>().GetCurrentKeySequence() == 1);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Manually increment key sequence counter on `router` and verify guard time prevents update on others");
+
+    router.Get<KeyManager>().SetCurrentKeySequence(2, KeyManager::kForceUpdate | KeyManager::kResetGuardTimer);
+
+    // Advance 20 minutes. Guard timer is still active on others.
+    nexus.AdvanceTime(20 * 60 * 1000);
+
+    VerifyOrQuit(router.Get<KeyManager>().GetCurrentKeySequence() == 2);
+    VerifyOrQuit(leader.Get<KeyManager>().GetCurrentKeySequence() == 1);
+    VerifyOrQuit(child.Get<KeyManager>().GetCurrentKeySequence() == 1);
+    VerifyOrQuit(reed.Get<KeyManager>().GetCurrentKeySequence() == 1);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify nodes can still communicate");
+
+    nexus.SendAndVerifyEchoRequest(leader, router.Get<Mle::Mle>().GetMeshLocalEid(), 0, 64, kEchoTimeout);
+    nexus.SendAndVerifyEchoRequest(router, child.Get<Mle::Mle>().GetMeshLocalEid(), 0, 64, kEchoTimeout);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Wait for guard time to expire and verify all nodes update");
+
+    // Total wait of 4 hours since manual update of router.
+    // T+1h: others' guard timer expires, they update to 2.
+    // T+2h: router rotates to 3.
+    // T+3h: others' guard timer expires, they update to 3.
+    nexus.AdvanceTime(4 * 3600 * 1000);
+
+    for (Node *node : nodes)
+    {
+        VerifyOrQuit(node->Get<KeyManager>().GetCurrentKeySequence() >= 3);
+    }
+
+    nexus.SendAndVerifyEchoRequest(leader, router.Get<Mle::Mle>().GetMeshLocalEid(), 0, 64, kEchoTimeout);
+    nexus.SendAndVerifyEchoRequest(router, child.Get<Mle::Mle>().GetMeshLocalEid(), 0, 64, kEchoTimeout);
+
+    nexus.SaveTestInfo("test_key_rotation_guard_time.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestKeyRotationGuardTime();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_leader_reboot_multiple_link_request.cpp b/tests/nexus/test_leader_reboot_multiple_link_request.cpp
new file mode 100644
index 0000000..b0b4dec
--- /dev/null
+++ b/tests/nexus/test_leader_reboot_multiple_link_request.cpp
@@ -0,0 +1,120 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 10 * 1000;
+
+/**
+ * Time to advance after Leader reset to allow it to send all link requests.
+ */
+static constexpr uint32_t kResetRestoringTime = 10 * 1000;
+
+void TestLeaderRebootMultipleLinkRequest(void)
+{
+    /**
+     * Test Leader Reboot Multiple Link Request
+     *
+     * Topology
+     * - Leader
+     * - Router
+     *
+     * Purpose & Description
+     * The purpose of this test case is to show that when the Leader is rebooted, it sends
+     * MLE_MAX_RESTORING_TRANSMISSION_COUNT MLE link request packets if no response is received.
+     */
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("Step 1: Form topology");
+
+    AllowLinkBetween(leader, router);
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("Step 2: Reset Leader and isolate it from Router");
+
+    leader.Reset();
+
+    // Isolate Leader from Router so it doesn't receive Link Accept
+    UnallowLinkBetween(leader, router);
+
+    Log("Step 3: Start Leader and wait for it to send multiple Link Requests");
+
+    leader.Get<ThreadNetif>().Up();
+    SuccessOrQuit(leader.Get<Mle::Mle>().Start());
+
+    nexus.AdvanceTime(kResetRestoringTime);
+
+    nexus.SaveTestInfo("test_leader_reboot_multiple_link_request.json", &leader);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestLeaderRebootMultipleLinkRequest();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_log_override.cpp b/tests/nexus/test_log_override.cpp
new file mode 100644
index 0000000..85082c1
--- /dev/null
+++ b/tests/nexus/test_log_override.cpp
@@ -0,0 +1,122 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestLogOverride(void)
+{
+    Core  nexus;
+    Node &node = nexus.CreateNode();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("TestLogOverride");
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check initial log level");
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelCrit);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check OverrideLogLevel to a higher level");
+
+    node.Get<Instance>().OverrideLogLevel(kLogLevelInfo);
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelInfo);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check RestoreLogLevel");
+
+    node.Get<Instance>().RestoreLogLevel();
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelCrit);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check OverrideLogLevel with a level lower than the current level");
+
+    SuccessOrQuit(node.SetLogLevel(kLogLevelWarn));
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelWarn);
+
+    node.Get<Instance>().OverrideLogLevel(kLogLevelCrit);
+
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelWarn);
+
+    node.Get<Instance>().RestoreLogLevel();
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelWarn);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check user SetLogLevel while an override is active");
+
+    node.Get<Instance>().OverrideLogLevel(kLogLevelInfo);
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelInfo);
+
+    SuccessOrQuit(node.SetLogLevel(kLogLevelCrit));
+
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelInfo);
+
+    node.Get<Instance>().RestoreLogLevel();
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelCrit);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check multiple OverrideLogLevel calls");
+
+    node.Get<Instance>().OverrideLogLevel(kLogLevelWarn);
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelWarn);
+
+    node.Get<Instance>().OverrideLogLevel(kLogLevelInfo);
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelInfo);
+
+    node.Get<Instance>().RestoreLogLevel();
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelCrit);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check OverrideLogLevel and then SetLogLevel to a higher level than override");
+
+    node.Get<Instance>().OverrideLogLevel(kLogLevelWarn);
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelWarn);
+
+    SuccessOrQuit(node.SetLogLevel(kLogLevelInfo));
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelInfo);
+
+    node.Get<Instance>().RestoreLogLevel();
+    VerifyOrQuit(node.Get<Instance>().GetLogLevel() == kLogLevelInfo);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestLogOverride();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_long_routes.cpp b/tests/nexus/test_long_routes.cpp
new file mode 100644
index 0000000..a345bd7
--- /dev/null
+++ b/tests/nexus/test_long_routes.cpp
@@ -0,0 +1,95 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+#if OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+
+void TestLongRoutes(void)
+{
+    static constexpr uint16_t kNumRouters = 25;
+
+    Core  nexus;
+    Node &leader = nexus.CreateNode();
+    Node *routers[kNumRouters];
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("TestLongRoutes");
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Form topology - long chain of routers");
+
+    leader.Form();
+    nexus.AdvanceTime(100 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    routers[0] = &nexus.CreateNode();
+    AllowLinkBetween(leader, *routers[0]);
+    routers[0]->Join(leader, Node::kAsFtd);
+
+    for (uint16_t i = 1; i < kNumRouters; i++)
+    {
+        Log("Router %u", i);
+
+        routers[i] = &nexus.CreateNode();
+        AllowLinkBetween(*routers[i], *routers[i - 1]);
+        routers[i]->Join(*routers[i - 1], Node::kAsFtd);
+        nexus.AdvanceTime(20 * 1000);
+
+        VerifyOrQuit(routers[i - 1]->Get<Mle::Mle>().IsRouter());
+    }
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check the path cost from last router to leader");
+
+    VerifyOrQuit(routers[kNumRouters - 1]->Get<RouterTable>().GetPathCostToLeader() == kNumRouters);
+}
+
+#endif // OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+#if OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE
+    ot::Nexus::TestLongRoutes();
+    printf("All tests passed\n");
+#else
+    printf("OPENTHREAD_CONFIG_MLE_LONG_ROUTES_ENABLE is not enabled, test is skipped\n");
+#endif
+    return 0;
+}
diff --git a/tests/nexus/test_mac_scan.cpp b/tests/nexus/test_mac_scan.cpp
new file mode 100644
index 0000000..23fc5d7
--- /dev/null
+++ b/tests/nexus/test_mac_scan.cpp
@@ -0,0 +1,142 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+struct MacScanContext
+{
+    static constexpr uint16_t kMaxResults = 16;
+
+    void Clear(void)
+    {
+        mScanDone = false;
+        mScanResults.Clear();
+    }
+
+    bool                           mScanDone;
+    Array<ScanResult, kMaxResults> mScanResults;
+};
+
+void HandleMacScanResult(otActiveScanResult *aResult, void *aContext)
+{
+    MacScanContext *context = static_cast<MacScanContext *>(aContext);
+    ScanResult     *result  = AsCoreTypePtr(aResult);
+
+    VerifyOrQuit(aContext != nullptr);
+
+    Log("   HandleMacScanResult() called%s", result == nullptr ? " (done)" : "");
+
+    if (result == nullptr)
+    {
+        context->mScanDone = true;
+    }
+    else
+    {
+        VerifyOrQuit(!context->mScanDone);
+        SuccessOrQuit(context->mScanResults.PushBack(*result));
+    }
+}
+
+void TestMacScan(void)
+{
+    Core           nexus;
+    Node          &leader = nexus.CreateNode();
+    Node          &router = nexus.CreateNode();
+    MacScanContext resultContext;
+    ScanResult    *result;
+
+    Log("------------------------------------------------------------------------------------------------------");
+    Log("TestMacScan");
+
+    nexus.AdvanceTime(0);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Form the network");
+
+    leader.Form();
+    nexus.AdvanceTime(10 * Time::kOneSecondInMsec);
+
+    leader.AllowList(router);
+    router.AllowList(leader);
+
+    router.Join(leader);
+    nexus.AdvanceTime(50 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsAttached());
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Perform MAC scan from leader to find router");
+
+    resultContext.Clear();
+
+    SuccessOrQuit(otLinkActiveScan(&leader.GetInstance(), 0, 0, HandleMacScanResult, &resultContext));
+
+    nexus.AdvanceTime(10 * Time::kOneSecondInMsec);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Check the Scan Result");
+
+    VerifyOrQuit(resultContext.mScanDone);
+    VerifyOrQuit(resultContext.mScanResults.GetLength() == 1);
+
+    bool foundRouter = false;
+    for (uint16_t i = 0; i < resultContext.mScanResults.GetLength(); i++)
+    {
+        result = &resultContext.mScanResults[i];
+        Log("   Found result: extaddr:%s channel:%u", result->GetExtAddress().ToString().AsCString(),
+            result->GetChannel());
+
+        if (result->GetExtAddress() == router.Get<Mac::Mac>().GetExtAddress())
+        {
+            foundRouter = true;
+            VerifyOrQuit(result->GetChannel() == leader.Get<Mac::Mac>().GetPanChannel());
+        }
+    }
+
+    VerifyOrQuit(foundRouter, "Leader did not find Router in MAC scan");
+    Log("Leader found Router in MAC scan successfully");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestMacScan();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_mesh_diag.cpp b/tests/nexus/test_mesh_diag.cpp
new file mode 100644
index 0000000..ccb0785
--- /dev/null
+++ b/tests/nexus/test_mesh_diag.cpp
@@ -0,0 +1,323 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+using MeshDiag = ot::Utils::MeshDiag;
+
+static constexpr uint16_t kMaxEntries = 8;
+
+struct DiscoveredRouter
+{
+    bool                                    mValid;
+    uint16_t                                mRloc16;
+    bool                                    mIsThisDevice;
+    bool                                    mIsLeader;
+    uint16_t                                mVersion;
+    Array<Ip6::Address, kMaxEntries>        mIp6Addrs;
+    Array<MeshDiag::ChildInfo, kMaxEntries> mChildren;
+    Array<uint8_t, kMaxEntries>             mExtraTlvTypes;
+};
+
+static Error                                sLastCallbackError;
+static Array<DiscoveredRouter, kMaxEntries> sDiscoveredRouters;
+
+static void ResetTest(void)
+{
+    sLastCallbackError = kErrorNone;
+    sDiscoveredRouters.Clear();
+}
+
+static void HandleDiscoverCallback(otError aError, otMeshDiagRouterInfo *aRouterInfo, void *aContext)
+{
+    DiscoveredRouter *router;
+
+    VerifyOrQuit(aContext == nullptr);
+
+    sLastCallbackError = aError;
+
+    VerifyOrExit(aRouterInfo != nullptr);
+
+    router = sDiscoveredRouters.PushBack();
+
+    VerifyOrQuit(router != nullptr, "sDiscoveredRouters is full");
+
+    router->mValid        = true;
+    router->mRloc16       = aRouterInfo->mRloc16;
+    router->mIsThisDevice = aRouterInfo->mIsThisDevice;
+    router->mIsLeader     = aRouterInfo->mIsLeader;
+    router->mVersion      = aRouterInfo->mVersion;
+    router->mIp6Addrs.Clear();
+    router->mChildren.Clear();
+    router->mExtraTlvTypes.Clear();
+
+    if (aRouterInfo->mIp6AddrIterator != nullptr)
+    {
+        MeshDiag::Ip6AddrIterator &ip6Iterator = AsCoreType(aRouterInfo->mIp6AddrIterator);
+        Ip6::Address               ip6Addr;
+
+        while (ip6Iterator.GetNextAddress(ip6Addr) == kErrorNone)
+        {
+            SuccessOrQuit(router->mIp6Addrs.PushBack(ip6Addr));
+        }
+    }
+
+    if (aRouterInfo->mChildIterator != nullptr)
+    {
+        MeshDiag::ChildIterator &childIterator = AsCoreType(aRouterInfo->mChildIterator);
+        MeshDiag::ChildInfo      childInfo;
+
+        while (childIterator.GetNextChildInfo(childInfo) == kErrorNone)
+        {
+            SuccessOrQuit(router->mChildren.PushBack(childInfo));
+        }
+    }
+
+    if (aRouterInfo->mTlvIterator != nullptr)
+    {
+        MeshDiag::TlvIterator &tlvIterator = AsCoreType(aRouterInfo->mTlvIterator);
+        MeshDiag::DiagTlvInfo  tlvInfo;
+
+        while (tlvIterator.GetNextTlvInfo(tlvInfo) == kErrorNone)
+        {
+            SuccessOrQuit(router->mExtraTlvTypes.PushBack(tlvInfo.mType));
+        }
+    }
+
+exit:
+    return;
+}
+
+void TestMeshDiag(void)
+{
+    Core nexus;
+
+    Node &leader  = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &child1  = nexus.CreateNode();
+    Node &child2  = nexus.CreateNode();
+
+    MeshDiag::DiscoverConfig config;
+    Array<uint8_t, 3>        extraTlvTypes;
+    Array<uint8_t, 1>        forbiddenTlvTypes;
+    bool                     foundLeader;
+    bool                     foundRouter1;
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(router1, child1);
+    AllowLinkBetween(router1, child2);
+
+    leader.Form();
+    nexus.AdvanceTime(15 * Time::kOneSecondInMsec);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router1.Join(leader);
+    nexus.AdvanceTime(5 * Time::kOneMinuteInMsec);
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+
+    child1.Join(router1, Node::kAsFed);
+    nexus.AdvanceTime(5 * Time::kOneSecondInMsec);
+    child2.Join(router1, Node::kAsFed);
+    nexus.AdvanceTime(5 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(child1.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(child2.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test Scenario 1: Base Discovery (both config flags false, no extra TLVs)");
+
+    ResetTest();
+
+    ClearAllBytes(config);
+
+    SuccessOrQuit(leader.Get<Utils::MeshDiag>().DiscoverTopology(config, HandleDiscoverCallback, nullptr));
+
+    nexus.AdvanceTime(3 * Time::kOneSecondInMsec);
+
+    SuccessOrQuit(sLastCallbackError);
+    VerifyOrQuit(sDiscoveredRouters.GetLength() == 2);
+
+    foundLeader  = false;
+    foundRouter1 = false;
+
+    for (DiscoveredRouter &router : sDiscoveredRouters)
+    {
+        VerifyOrQuit(router.mValid);
+
+        if (router.mRloc16 == leader.Get<Mle::Mle>().GetRloc16())
+        {
+            foundLeader = true;
+            VerifyOrQuit(router.mIsLeader);
+            VerifyOrQuit(router.mIsThisDevice);
+        }
+        else if (router.mRloc16 == router1.Get<Mle::Mle>().GetRloc16())
+        {
+            foundRouter1 = true;
+            VerifyOrQuit(!router.mIsLeader);
+            VerifyOrQuit(!router.mIsThisDevice);
+        }
+        else
+        {
+            VerifyOrQuit(false, "Discovered unexpected router RLOC16");
+        }
+
+        VerifyOrQuit(router.mIp6Addrs.IsEmpty());
+        VerifyOrQuit(router.mChildren.IsEmpty());
+        VerifyOrQuit(router.mExtraTlvTypes.IsEmpty());
+    }
+
+    VerifyOrQuit(foundLeader);
+    VerifyOrQuit(foundRouter1);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test Scenario 2: Discover IPv6 and Child Tables (both config flags true)");
+
+    ResetTest();
+
+    ClearAllBytes(config);
+    config.mDiscoverIp6Addresses = true;
+    config.mDiscoverChildTable   = true;
+
+    SuccessOrQuit(leader.Get<Utils::MeshDiag>().DiscoverTopology(config, HandleDiscoverCallback, nullptr));
+
+    nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
+
+    SuccessOrQuit(sLastCallbackError);
+    VerifyOrQuit(sDiscoveredRouters.GetLength() == 2);
+
+    foundLeader  = false;
+    foundRouter1 = false;
+
+    for (DiscoveredRouter &router : sDiscoveredRouters)
+    {
+        VerifyOrQuit(router.mValid);
+        if (router.mRloc16 == leader.Get<Mle::Mle>().GetRloc16())
+        {
+            foundLeader = true;
+            VerifyOrQuit(router.mIsLeader);
+            VerifyOrQuit(router.mIsThisDevice);
+
+            // Leader has no children in this topology (both children joined Router 1)
+            VerifyOrQuit(router.mChildren.IsEmpty());
+        }
+        else if (router.mRloc16 == router1.Get<Mle::Mle>().GetRloc16())
+        {
+            foundRouter1 = true;
+            VerifyOrQuit(!router.mIsLeader);
+            VerifyOrQuit(!router.mIsThisDevice);
+
+            // Router 1 should have 2 children (Child 1 and Child 2)
+            VerifyOrQuit(router.mChildren.GetLength() == 2);
+            VerifyOrQuit(router.mChildren[0].mRloc16 == child1.Get<Mle::Mle>().GetRloc16() ||
+                         router.mChildren[0].mRloc16 == child2.Get<Mle::Mle>().GetRloc16());
+            VerifyOrQuit(router.mChildren[1].mRloc16 == child1.Get<Mle::Mle>().GetRloc16() ||
+                         router.mChildren[1].mRloc16 == child2.Get<Mle::Mle>().GetRloc16());
+        }
+        else
+        {
+            VerifyOrQuit(false, "Discovered unexpected router RLOC16");
+        }
+
+        // Both routers should have returned IPv6 addresses
+        VerifyOrQuit(!router.mIp6Addrs.IsEmpty());
+    }
+
+    VerifyOrQuit(foundLeader);
+    VerifyOrQuit(foundRouter1);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test Scenario 3: Discover Extra Custom TLVs");
+
+    ResetTest();
+
+    extraTlvTypes.Clear();
+    SuccessOrQuit(extraTlvTypes.PushBack(NetDiag::Tlv::kVendorName));
+    SuccessOrQuit(extraTlvTypes.PushBack(NetDiag::Tlv::kVendorModel));
+    SuccessOrQuit(extraTlvTypes.PushBack(NetDiag::Tlv::kVendorSwVersion));
+
+    ClearAllBytes(config);
+    config.mDiscoverIp6Addresses = false;
+    config.mDiscoverChildTable   = false;
+    config.mExtraTlvTypes        = extraTlvTypes.GetArrayBuffer();
+    config.mExtraTlvTypesLength  = extraTlvTypes.GetLength();
+
+    SuccessOrQuit(leader.Get<Utils::MeshDiag>().DiscoverTopology(config, HandleDiscoverCallback, nullptr));
+
+    nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
+
+    SuccessOrQuit(sLastCallbackError);
+    VerifyOrQuit(sDiscoveredRouters.GetLength() == 2);
+
+    for (DiscoveredRouter &router : sDiscoveredRouters)
+    {
+        VerifyOrQuit(router.mValid);
+        VerifyOrQuit(router.mIp6Addrs.IsEmpty());
+        VerifyOrQuit(router.mChildren.IsEmpty());
+
+        // Expect extra TLVs to match the length of requested extra TLVs
+        VerifyOrQuit(router.mExtraTlvTypes.GetLength() == extraTlvTypes.GetLength());
+
+        for (uint8_t tlvType : extraTlvTypes)
+        {
+            VerifyOrQuit(router.mExtraTlvTypes.Contains(tlvType));
+        }
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test Scenario 4: Error/Validation checks for extra TLV types");
+
+    forbiddenTlvTypes.Clear();
+    SuccessOrQuit(forbiddenTlvTypes.PushBack(NetDiag::Tlv::kAddress16));
+
+    ClearAllBytes(config);
+    config.mDiscoverIp6Addresses = false;
+    config.mDiscoverChildTable   = false;
+    config.mExtraTlvTypes        = forbiddenTlvTypes.GetArrayBuffer();
+    config.mExtraTlvTypesLength  = forbiddenTlvTypes.GetLength();
+
+    VerifyOrQuit(leader.Get<Utils::MeshDiag>().DiscoverTopology(config, HandleDiscoverCallback, nullptr) ==
+                 kErrorInvalidArgs);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestMeshDiag();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_mle_blocking_downgrade.cpp b/tests/nexus/test_mle_blocking_downgrade.cpp
new file mode 100644
index 0000000..455b2ae
--- /dev/null
+++ b/tests/nexus/test_mle_blocking_downgrade.cpp
@@ -0,0 +1,211 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestMleBlockingDowngrade(void)
+{
+    // This test validates the MLE router role downgrade logic when a REED is
+    // promoted to a router due to a Child ID Request. It verifies that the
+    // newly promoted router blocks its own downgrade to ensure the child remains
+    // connected, and that this block is correctly lifted when the child detaches
+    // or when a new router is introduced to the network (providing an alternative
+    // parent for the child).
+
+    static constexpr uint8_t kNumRouters = 10;
+
+    Core  nexus;
+    Node &leader    = nexus.CreateNode();
+    Node &dut       = nexus.CreateNode();
+    Node &child     = nexus.CreateNode();
+    Node &newRouter = nexus.CreateNode();
+    Node *routers[kNumRouters];
+
+    for (Node *&router : routers)
+    {
+        router = &nexus.CreateNode();
+    }
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNone));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form initial topology");
+
+    AllowLinkBetween(leader, dut);
+    AllowLinkBetween(leader, newRouter);
+    AllowLinkBetween(child, dut);
+    AllowLinkBetween(child, newRouter);
+
+    for (Node *router : routers)
+    {
+        AllowLinkBetween(leader, *router);
+        AllowLinkBetween(dut, *router);
+        AllowLinkBetween(newRouter, *router);
+    }
+
+    leader.Form();
+    nexus.AdvanceTime(13 * Time::kOneSecondInMsec);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    for (Node *router : routers)
+    {
+        router->Join(leader);
+    }
+
+    nexus.AdvanceTime(300 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    for (Node *router : routers)
+    {
+        VerifyOrQuit(router->Get<Mle::Mle>().IsRouter());
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Setup DUT - Validate it stays as a REED");
+
+    dut.Get<Mle::Mle>().SetRouterUpgradeThreshold(3);
+    dut.Get<Mle::Mle>().SetRouterDowngradeThreshold(4);
+
+    VerifyOrQuit(!dut.Get<Mle::Mle>().IsDowngradeBlocked());
+
+    dut.Join(leader);
+
+    nexus.AdvanceTime(300 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(!dut.Get<Mle::Mle>().IsDowngradeBlocked());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Attach `child` only connecting through the DUT");
+
+    child.Join(dut, Node::kAsFed);
+
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(child.Get<Mle::Mle>().GetParent().GetExtAddress() == dut.Get<Mac::Mac>().GetExtAddress());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Validate that DUT is promoted to router and blocking downgrade");
+
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsDowngradeBlocked());
+
+    nexus.AdvanceTime(10 * Time::kOneMinuteInMsec);
+
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsDowngradeBlocked());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Disconnect one of the routers - validate that DUT still blocks downgrade");
+
+    routers[0]->Get<Mle::Mle>().Stop();
+    routers[0]->Get<ThreadNetif>().Down();
+
+    nexus.AdvanceTime(10);
+
+    VerifyOrQuit(routers[0]->Get<Mle::Mle>().IsDisabled());
+
+    nexus.AdvanceTime(10 * Time::kOneMinuteInMsec);
+
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsDowngradeBlocked());
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Disconnect the child - validate that DUT can now downgrade");
+
+    child.Get<Mle::Mle>().Stop();
+    child.Get<ThreadNetif>().Down();
+
+    nexus.AdvanceTime(10);
+
+    VerifyOrQuit(child.Get<Mle::Mle>().IsDisabled());
+
+    nexus.AdvanceTime(10 * Time::kOneMinuteInMsec);
+
+    VerifyOrQuit(!dut.Get<Mle::Mle>().IsDowngradeBlocked());
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Reconnect the child - validate that DUT once again disallow downgrade");
+
+    child.Get<ThreadNetif>().Up();
+    SuccessOrQuit(child.Get<Mle::Mle>().Start());
+
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsDowngradeBlocked());
+
+    nexus.AdvanceTime(10 * Time::kOneMinuteInMsec);
+
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsDowngradeBlocked());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Introduce `newRouter` as a new router - validate DUT now allows downgrade");
+
+    newRouter.Join(leader);
+
+    nexus.AdvanceTime(15 * Time::kOneMinuteInMsec);
+
+    VerifyOrQuit(newRouter.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(!dut.Get<Mle::Mle>().IsDowngradeBlocked());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Validate that DUT is downgraded to REED and child connected to the new router");
+
+    VerifyOrQuit(dut.Get<Mle::Mle>().IsChild());
+
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(child.Get<Mle::Mle>().GetParent().GetExtAddress() == newRouter.Get<Mac::Mac>().GetExtAddress());
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestMleBlockingDowngrade();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_mle_msg_key_seq_jump.cpp b/tests/nexus/test_mle_msg_key_seq_jump.cpp
new file mode 100644
index 0000000..aaa12f3
--- /dev/null
+++ b/tests/nexus/test_mle_msg_key_seq_jump.cpp
@@ -0,0 +1,240 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static constexpr uint32_t kMaxAdvertisementInterval = 32 * 1000;
+
+void TestMleMsgKeySeqJump(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &child  = nexus.CreateNode();
+    Node &reed   = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    child.SetName("CHILD");
+    reed.SetName("REED");
+    router.SetName("ROUTER");
+
+    Node *nodes[] = {&leader, &child, &reed, &router};
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    for (Node *node : nodes)
+    {
+        node->Get<KeyManager>().SetCurrentKeySequence(0, KeyManager::kForceUpdate);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form the network");
+
+    leader.Form();
+    nexus.AdvanceTime(15000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Nodes join");
+
+    child.Join(leader, Node::kAsMed);
+    reed.Join(leader, Node::kAsFed);
+    router.Join(leader, Node::kAsFtd);
+
+    nexus.AdvanceTime(200000);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    for (Node *node : nodes)
+    {
+        VerifyOrQuit(node->Get<KeyManager>().GetCurrentKeySequence() == 0);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Manually increase the key seq on child and trigger Child Update Request");
+
+    child.Get<KeyManager>().SetCurrentKeySequence(5, KeyManager::kForceUpdate);
+    VerifyOrQuit(child.Get<KeyManager>().GetCurrentKeySequence() == 5);
+
+    // Trigger Child Update Request by changing mode
+    {
+        Mle::DeviceMode mode = child.Get<Mle::Mle>().GetDeviceMode();
+        mode.Set(mode.Get() ^ Mle::DeviceMode::kModeFullNetworkData);
+        SuccessOrQuit(child.Get<Mle::Mle>().SetDeviceMode(mode));
+    }
+    nexus.AdvanceTime(1000);
+
+    VerifyOrQuit(child.Get<KeyManager>().GetCurrentKeySequence() == 5);
+    VerifyOrQuit(leader.Get<KeyManager>().GetCurrentKeySequence() == 5);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Wait for MLE Advertisements for other nodes to adopt the new key seq");
+
+    nexus.AdvanceTime(2 * kMaxAdvertisementInterval);
+    for (Node *node : nodes)
+    {
+        VerifyOrQuit(node->Get<KeyManager>().GetCurrentKeySequence() == 5);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Manually increase the key seq on leader");
+
+    leader.Get<KeyManager>().SetCurrentKeySequence(10, KeyManager::kForceUpdate);
+    VerifyOrQuit(leader.Get<KeyManager>().GetCurrentKeySequence() == 10);
+
+    // Trigger Child Update Request for child (MED) to adopt the new key sequence.
+    {
+        Mle::DeviceMode mode = child.Get<Mle::Mle>().GetDeviceMode();
+        mode.Set(mode.Get() ^ Mle::DeviceMode::kModeFullNetworkData);
+        SuccessOrQuit(child.Get<Mle::Mle>().SetDeviceMode(mode));
+    }
+
+    // All nodes should adopt the new key sequence from authoritative advertisements or Child Update.
+    // Wait long enough for them to do so.
+    nexus.AdvanceTime(3 * kMaxAdvertisementInterval);
+
+    for (Node *node : nodes)
+    {
+        VerifyOrQuit(node->Get<KeyManager>().GetCurrentKeySequence() == 10);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Stop other nodes, jump leader key seq, and restart them");
+
+    router.Reset();
+    reed.Reset();
+    child.Reset();
+
+    leader.Get<KeyManager>().SetCurrentKeySequence(15, KeyManager::kForceUpdate);
+    VerifyOrQuit(leader.Get<KeyManager>().GetCurrentKeySequence() == 15);
+
+    router.Get<ThreadNetif>().Up();
+    SuccessOrQuit(router.Get<Mle::Mle>().Start());
+    child.Get<ThreadNetif>().Up();
+    SuccessOrQuit(child.Get<Mle::Mle>().Start());
+    reed.Get<ThreadNetif>().Up();
+    SuccessOrQuit(reed.Get<Mle::Mle>().Start());
+    nexus.AdvanceTime(10000);
+
+    for (Node *node : nodes)
+    {
+        VerifyOrQuit(node->Get<Mle::Mle>().IsAttached());
+        VerifyOrQuit(node->Get<KeyManager>().GetCurrentKeySequence() == 15);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Stop nodes other than leader, jump child key seq, and restart child");
+
+    router.Reset();
+    reed.Reset();
+    child.Reset();
+
+    child.Get<KeyManager>().SetCurrentKeySequence(20, KeyManager::kForceUpdate);
+    child.Get<ThreadNetif>().Up();
+    SuccessOrQuit(child.Get<Mle::Mle>().Start());
+    VerifyOrQuit(child.Get<KeyManager>().GetCurrentKeySequence() == 20);
+    nexus.AdvanceTime(5000);
+
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(leader.Get<KeyManager>().GetCurrentKeySequence() == 20);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Restart router and reed");
+
+    router.Get<ThreadNetif>().Up();
+    SuccessOrQuit(router.Get<Mle::Mle>().Start());
+    reed.Get<ThreadNetif>().Up();
+    SuccessOrQuit(reed.Get<Mle::Mle>().Start());
+    nexus.AdvanceTime(10000);
+
+    VerifyOrQuit(router.Get<Mle::Mle>().IsAttached());
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsAttached());
+
+    VerifyOrQuit(router.Get<KeyManager>().GetCurrentKeySequence() == 20);
+    VerifyOrQuit(reed.Get<KeyManager>().GetCurrentKeySequence() == 20);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Jump key seq on router and wait for advertisement");
+
+    router.Get<KeyManager>().SetCurrentKeySequence(22, KeyManager::kForceUpdate);
+    VerifyOrQuit(router.Get<KeyManager>().GetCurrentKeySequence() == 22);
+
+    nexus.AdvanceTime(2 * kMaxAdvertisementInterval);
+    VerifyOrQuit(leader.Get<KeyManager>().GetCurrentKeySequence() == 22);
+    VerifyOrQuit(reed.Get<KeyManager>().GetCurrentKeySequence() == 22);
+
+    {
+        Mle::DeviceMode mode = child.Get<Mle::Mle>().GetDeviceMode();
+        mode.Set(mode.Get() ^ Mle::DeviceMode::kModeFullNetworkData);
+        SuccessOrQuit(child.Get<Mle::Mle>().SetDeviceMode(mode));
+    }
+    nexus.AdvanceTime(10000);
+    VerifyOrQuit(child.Get<KeyManager>().GetCurrentKeySequence() == 22);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Child factory reset and join with higher key seq");
+
+    router.Reset();
+    reed.Reset();
+
+    child.Reset();
+    child.mSettings.Wipe();
+    VerifyOrQuit(child.Get<Mle::Mle>().GetRole() == Mle::kRoleDisabled);
+
+    child.Get<KeyManager>().SetCurrentKeySequence(25, KeyManager::kForceUpdate);
+    child.Join(leader, Node::kAsMed);
+    VerifyOrQuit(child.Get<KeyManager>().GetCurrentKeySequence() == 25);
+    nexus.AdvanceTime(5000);
+
+    VerifyOrQuit(child.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(leader.Get<KeyManager>().GetCurrentKeySequence() == 25);
+
+    nexus.SaveTestInfo("test_mle_msg_key_seq_jump.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestMleMsgKeySeqJump();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_mle_router_role_allowed.cpp b/tests/nexus/test_mle_router_role_allowed.cpp
new file mode 100644
index 0000000..d69ad37
--- /dev/null
+++ b/tests/nexus/test_mle_router_role_allowed.cpp
@@ -0,0 +1,238 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Maximum interval between Router advertisements.
+ */
+static constexpr uint32_t kMaximumRouterAdvertisementInterval = 32 * 1000;
+
+/**
+ * Device mode to configure as a Full Thread Device (FTD).
+ */
+static const Mle::DeviceMode kFtdDeviceMode(Mle::DeviceMode::kModeRxOnWhenIdle |
+                                            Mle::DeviceMode::kModeFullThreadDevice |
+                                            Mle::DeviceMode::kModeFullNetworkData);
+
+/**
+ * Device mode to configure as a Minimal End Device (MED).
+ */
+static const Mle::DeviceMode kMedDeviceMode(Mle::DeviceMode::kModeRxOnWhenIdle | Mle::DeviceMode::kModeFullNetworkData);
+
+void TestMleRouterRoleAllowed(void)
+{
+    // This test validates that correctly-formatted transmissions occur based on whether the Router
+    // role is allowed.  This includes Advertisements (only from REEDs and Active Routers),
+    // which should not occur for devices with Router role disallowed.
+    // This test verifies Router role disallowed on FED and MED devices, with a duplicate
+    // pair used to demonstrate that these transmissions begin in the correct format when
+    // re-configured so that the Router role becomes allowed.
+
+    Core nexus;
+
+    Node &leader       = nexus.CreateNode();
+    Node &router       = nexus.CreateNode();
+    Node &reed         = nexus.CreateNode();
+    Node &fedOnly      = nexus.CreateNode();
+    Node &medOnly      = nexus.CreateNode();
+    Node &fedAndRouter = nexus.CreateNode();
+    Node &medAndRouter = nexus.CreateNode();
+
+    leader.SetName("Leader");
+    router.SetName("Router");
+    reed.SetName("REED");
+    fedOnly.SetName("FED_ONLY");
+    medOnly.SetName("MED_ONLY");
+    fedAndRouter.SetName("FED_Router");
+    medAndRouter.SetName("MED_Router");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Form topology");
+    // Child links are only set up with the leader and router,
+    // so that the REED will not become a router due to a parent request.
+
+    AllowLinkBetween(leader, router);
+    AllowLinkBetween(leader, reed);
+    AllowLinkBetween(leader, fedOnly);
+    AllowLinkBetween(leader, medOnly);
+    AllowLinkBetween(leader, fedAndRouter);
+    AllowLinkBetween(leader, medAndRouter);
+
+    AllowLinkBetween(router, reed);
+    AllowLinkBetween(router, fedOnly);
+    AllowLinkBetween(router, medOnly);
+    AllowLinkBetween(router, fedAndRouter);
+    AllowLinkBetween(router, medAndRouter);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1.1: Leader");
+    // All Leader advertisements are expected to have a router source address
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1.2: Join Router");
+    // All Router advertisements are expected to have a router source address
+
+    router.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1.3: Join end devices (only) - reed, fed, & med");
+
+    // All REED advertisements should have a child source address
+    reed.Get<Mle::Mle>().SetRouterUpgradeThreshold(1);
+    reed.Join(leader);
+    // Router role is allowed for the REED, but should not upgrade due to the updated threshold
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    fedOnly.Join(leader);
+    // Attach as an FTD, but not configured as router-eligible
+    SuccessOrQuit(fedOnly.Get<Mle::Mle>().SetRouterEligible(false));
+    VerifyOrQuit(!fedOnly.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    medOnly.Join(leader, Node::kAsMed);
+    VerifyOrQuit(!medOnly.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(fedOnly.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(medOnly.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1.4: Join end devices that will have router role allowed - fed & med");
+
+    fedAndRouter.Join(leader);
+    // Attach as an FTD, but not configured as router-eligible
+    SuccessOrQuit(fedAndRouter.Get<Mle::Mle>().SetRouterEligible(false));
+    VerifyOrQuit(fedAndRouter.Get<Mle::Mle>().IsFullThreadDevice());
+    VerifyOrQuit(!fedAndRouter.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    medAndRouter.Join(leader, Node::kAsMed);
+    VerifyOrQuit(!medAndRouter.Get<Mle::Mle>().IsFullThreadDevice());
+    VerifyOrQuit(!medAndRouter.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1.5: Child attachment");
+    // FED and MED nodes with router role disallowed should not advertise during this time
+
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(fedOnly.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(medOnly.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(fedAndRouter.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(medAndRouter.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Set FED & MED Router Role allowed");
+
+    // Now router eligible so that the former FED is allowed to take the Router role
+    SuccessOrQuit(fedAndRouter.Get<Mle::Mle>().SetRouterEligible(true));
+    VerifyOrQuit(fedAndRouter.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    // Enabling FTD mode so that the former MED is allowed to take the Router role
+    SuccessOrQuit(medAndRouter.Get<Mle::Mle>().SetDeviceMode(kFtdDeviceMode));
+    VerifyOrQuit(medAndRouter.Get<Mle::Mle>().IsFullThreadDevice());
+    VerifyOrQuit(medAndRouter.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    // FED and MED nodes should advertise as Routers at least once during this time
+    nexus.AdvanceTime(kAttachToRouterTime);
+    nexus.AdvanceTime(kMaximumRouterAdvertisementInterval);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(fedOnly.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(medOnly.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(fedAndRouter.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(medAndRouter.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Set FED & MED back to their original configuration");
+
+    // Setting the router role by configuration is expected to immediately detach
+    SuccessOrQuit(fedAndRouter.Get<Mle::Mle>().SetRouterEligible(false));
+    VerifyOrQuit(!fedAndRouter.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    // Setting the mode by configuration is expected to immediately detach
+    SuccessOrQuit(medAndRouter.Get<Mle::Mle>().SetDeviceMode(kMedDeviceMode));
+    VerifyOrQuit(!medAndRouter.Get<Mle::Mle>().IsFullThreadDevice());
+    VerifyOrQuit(!medAndRouter.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    // FED and MED nodes should attach as children and send no further advertisements
+    nexus.AdvanceTime(kAttachToRouterTime);
+    nexus.AdvanceTime(kMaximumRouterAdvertisementInterval);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(fedOnly.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(medOnly.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(fedAndRouter.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(medAndRouter.Get<Mle::Mle>().IsChild());
+
+    nexus.SaveTestInfo("test_mle_router_role_allowed.json", &leader);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestMleRouterRoleAllowed();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_mlr_manager.cpp b/tests/nexus/test_mlr_manager.cpp
new file mode 100644
index 0000000..a649bb1
--- /dev/null
+++ b/tests/nexus/test_mlr_manager.cpp
@@ -0,0 +1,256 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "coap/coap.hpp"
+#include "coap/coap_message.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static constexpr uint32_t kFormNetworkTime     = 10 * 1000;
+static constexpr uint32_t kAttachAsRouterTime  = 200 * 1000;
+static constexpr uint32_t kAttachAsSedTime     = 10 * 1000;
+static constexpr uint32_t kMlrRegistrationTime = 10 * 1000;
+
+struct Context
+{
+    Context(void) { mMlrReqCount = 0; }
+
+    uint32_t     mMlrReqCount;
+    Ip6::Address mTargetAddress;
+};
+
+static Error BrCoapInterceptor(void *aContext, const Coap::Msg &aMsg)
+{
+    Mlr::AddressArray                  addresses;
+    Context                           *context = static_cast<Context *>(aContext);
+    Coap::Message::UriPathStringBuffer uriPath;
+
+    VerifyOrQuit(context != nullptr);
+
+    VerifyOrExit(aMsg.IsPostRequest());
+
+    SuccessOrExit(aMsg.mMessage.ReadUriPathOptions(uriPath));
+    VerifyOrExit(UriFromPath(uriPath) == kUriMlr);
+
+    SuccessOrExit(Ip6AddressesTlv::FindIn(aMsg.mMessage, addresses));
+
+    Log("   Received MLR.req with %u addresses:", addresses.GetLength());
+
+    for (const Ip6::Address &address : addresses)
+    {
+        Log("   - %s", address.ToString().AsCString());
+    }
+
+    if (addresses.Contains(context->mTargetAddress))
+    {
+        context->mMlrReqCount++;
+    }
+
+exit:
+    return kErrorNone;
+}
+
+void TestMlrRedundant(void)
+{
+    /**
+     * This test verifies that the MLR manager does not send redundant registration
+     * requests when multiple entities (a parent router and its children) subscribe
+     * to the same multicast address. It sets up a scenario where the total number
+     * of subscribed addresses exceeds the capacity of a single MLR.req CoAP message,
+     * ensuring that the state is properly managed and shared addresses are registered
+     * exactly once with the Primary Backbone Router.
+     */
+
+    Core         nexus;
+    Node        &br     = nexus.CreateNode();
+    Node        &router = nexus.CreateNode();
+    Node        &sed1   = nexus.CreateNode();
+    Node        &sed2   = nexus.CreateNode();
+    Node        &sed3   = nexus.CreateNode();
+    Ip6::Address sharedAddress;
+    Context      context;
+
+    nexus.AdvanceTime(0);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Form topology");
+
+    AllowLinkBetween(br, router);
+    AllowLinkBetween(router, sed1);
+    AllowLinkBetween(router, sed2);
+    AllowLinkBetween(router, sed3);
+
+    br.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(br.Get<Mle::Mle>().IsLeader());
+
+    router.Join(br, Node::kAsFtd);
+    nexus.AdvanceTime(kAttachAsRouterTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    sed1.Join(router, Node::kAsSed);
+    sed2.Join(router, Node::kAsSed);
+    sed3.Join(router, Node::kAsSed);
+
+    SuccessOrQuit(sed1.Get<DataPollSender>().SetExternalPollPeriod(5 * Time::kOneSecondInMsec));
+    SuccessOrQuit(sed2.Get<DataPollSender>().SetExternalPollPeriod(5 * Time::kOneSecondInMsec));
+    SuccessOrQuit(sed3.Get<DataPollSender>().SetExternalPollPeriod(5 * Time::kOneSecondInMsec));
+
+    nexus.AdvanceTime(kAttachAsSedTime);
+
+    VerifyOrQuit(sed1.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(sed2.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(sed3.Get<Mle::Mle>().IsChild());
+
+    SuccessOrQuit(sharedAddress.FromString("ff04::1"));
+    context.mTargetAddress = sharedAddress;
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Set an interceptor on leader to count incoming MLR.req messages");
+    br.Get<Tmf::Agent>().SetInterceptor(BrCoapInterceptor, &context);
+
+    Log("Router and SEDs subscribe to the same multicast address");
+    SuccessOrQuit(router.Get<Ip6::Netif>().SubscribeExternalMulticast(sharedAddress));
+    SuccessOrQuit(sed1.Get<Ip6::Netif>().SubscribeExternalMulticast(sharedAddress));
+    SuccessOrQuit(sed2.Get<Ip6::Netif>().SubscribeExternalMulticast(sharedAddress));
+    SuccessOrQuit(sed3.Get<Ip6::Netif>().SubscribeExternalMulticast(sharedAddress));
+
+    Log("Router subscribes to 14 more unique multicast addresses to trigger MLR IP6 Addresses TLV limits");
+
+    for (uint8_t i = 2; i <= 15; i++)
+    {
+        Ip6::Address ma;
+
+        ma                = sharedAddress;
+        ma.mFields.m8[15] = i;
+
+        SuccessOrQuit(router.Get<Ip6::Netif>().SubscribeExternalMulticast(ma));
+    }
+
+    nexus.AdvanceTime(15 * Time::kOneSecondInMsec);
+
+    Log("Enable BBR on wait for MLR registrations");
+    br.Get<BackboneRouter::Local>().SetEnabled(true);
+
+    nexus.AdvanceTime(kMlrRegistrationTime);
+
+    Log("Verify the shared address was registered exactly once (no redundant registrations)");
+
+    VerifyOrQuit(context.mMlrReqCount == 1);
+}
+
+void TestMlrState(void)
+{
+    /**
+     * This test verifies the state machine transitions and timing behavior of the MLR manager.
+     */
+
+    Core                   nexus;
+    Node                  &br     = nexus.CreateNode();
+    Node                  &router = nexus.CreateNode();
+    Ip6::Address           firstAddress;
+    Ip6::Address           newAddress;
+    BackboneRouter::Config config;
+    Context                context;
+
+    nexus.AdvanceTime(0);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Form topology");
+
+    AllowLinkBetween(br, router);
+    br.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    router.Join(br, Node::kAsFtd);
+    nexus.AdvanceTime(kAttachAsRouterTime);
+
+    SuccessOrQuit(firstAddress.FromString("ff04::1"));
+    context.mTargetAddress = firstAddress;
+    br.Get<Tmf::Agent>().SetInterceptor(BrCoapInterceptor, &context);
+
+    Log("Enable BBR and verify initial registration");
+    br.Get<BackboneRouter::Local>().SetEnabled(true);
+    SuccessOrQuit(router.Get<Ip6::Netif>().SubscribeExternalMulticast(firstAddress));
+
+    nexus.AdvanceTime(kMlrRegistrationTime);
+    VerifyOrQuit(context.mMlrReqCount == 1);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Add new address and verify quick registration");
+
+    SuccessOrQuit(newAddress.FromString("ff04::100"));
+    context.mTargetAddress = newAddress;
+    context.mMlrReqCount   = 0;
+
+    SuccessOrQuit(router.Get<Ip6::Netif>().SubscribeExternalMulticast(newAddress));
+
+    // Verify it is registered quickly (within short aggregation window)
+    nexus.AdvanceTime(1000);
+    VerifyOrQuit(context.mMlrReqCount == 1);
+
+    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    Log("Update MLR Timeout and verify renewal time update");
+
+    config = br.Get<BackboneRouter::Leader>().GetConfig();
+
+    Log("Initial MLR timeout: %lu", ToUlong(br.Get<BackboneRouter::Leader>().GetConfig().GetMlrTimeout()));
+
+    // We set a short MLR timeout (300 seconds is the minimum).
+    config.mReregistrationDelay = 10;
+    config.mMlrTimeout          = 300;
+    SuccessOrQuit(br.Get<BackboneRouter::Local>().SetConfig(config));
+
+    context.mTargetAddress = firstAddress;
+    context.mMlrReqCount   = 0;
+
+    Log("Wait for renewal registration");
+
+    // Renewal happens between (0.5 * 300) = 150s and (300 - 9) = 291s.
+    nexus.AdvanceTime(300 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(context.mMlrReqCount >= 1);
+    Log("Renewal registration successful");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestMlrRedundant();
+    ot::Nexus::TestMlrState();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_nat64_translator.cpp b/tests/nexus/test_nat64_translator.cpp
index e1ab842..b0484d9 100644
--- a/tests/nexus/test_nat64_translator.cpp
+++ b/tests/nexus/test_nat64_translator.cpp
@@ -30,6 +30,8 @@
 #include <stdio.h>
 #include <string.h>
 
+#include "core/net/ip4_types.hpp"
+#include "core/net/nat64_translator.hpp"
 #include "platform/nexus_core.hpp"
 #include "platform/nexus_node.hpp"
 
@@ -65,7 +67,7 @@
     nexus.AdvanceTime(50 * Time::kOneSecondInMsec);
     VerifyOrQuit(node.Get<Mle::Mle>().IsLeader());
 
-    node.Get<Instance>().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     SuccessOrQuit(node.Get<Notifier>().RegisterCallback(HandleNotifierEvent, nullptr));
 
@@ -292,10 +294,10 @@
                            uint16_t            aDstPort    = 1235,
                            uint16_t            aPayloadLen = 10)
 {
-    Message         *message = nullptr;
-    Ip6::Prefix      nat64Prefix;
-    Ip6::Header      ip6Header;
-    Ip6::Udp::Header udpHeader;
+    Message       *message = nullptr;
+    Ip6::Prefix    nat64Prefix;
+    Ip6::Header    ip6Header;
+    Ip6::UdpHeader udpHeader;
 
     message = aNode.Get<MessagePool>().Allocate(Message::kTypeIp6);
     VerifyOrQuit(message != nullptr);
@@ -309,7 +311,7 @@
     ip6Header.GetDestination().SynthesizeFromIp4Address(nat64Prefix, aDstIp4Address);
 
     ip6Header.SetNextHeader(Ip6::kProtoUdp);
-    ip6Header.SetPayloadLength(sizeof(Ip6::Udp::Header) + aPayloadLen);
+    ip6Header.SetPayloadLength(sizeof(Ip6::UdpHeader) + aPayloadLen);
 
     SuccessOrQuit(message->Append(ip6Header));
 
@@ -335,10 +337,10 @@
                            uint16_t            aDstPort    = 1235,
                            uint16_t            aPayloadLen = 10)
 {
-    Message         *message = nullptr;
-    Ip6::Prefix      nat64Prefix;
-    Ip6::Header      ip6Header;
-    Ip6::Tcp::Header tcpHeader;
+    Message       *message = nullptr;
+    Ip6::Prefix    nat64Prefix;
+    Ip6::Header    ip6Header;
+    Ip6::TcpHeader tcpHeader;
 
     message = aNode.Get<MessagePool>().Allocate(Message::kTypeIp6);
     VerifyOrQuit(message != nullptr);
@@ -352,7 +354,7 @@
     ip6Header.GetDestination().SynthesizeFromIp4Address(nat64Prefix, aDstIp4Address);
 
     ip6Header.SetNextHeader(Ip6::kProtoTcp);
-    ip6Header.SetPayloadLength(sizeof(Ip6::Tcp::Header) + aPayloadLen);
+    ip6Header.SetPayloadLength(sizeof(Ip6::TcpHeader) + aPayloadLen);
 
     SuccessOrQuit(message->Append(ip6Header));
 
@@ -375,10 +377,10 @@
                              const Ip4::Address &aDstIp4Address,
                              uint16_t            aPayloadLen = 10)
 {
-    Message          *message = nullptr;
-    Ip6::Prefix       nat64Prefix;
-    Ip6::Header       ip6Header;
-    Ip6::Icmp::Header icmpHeader;
+    Message         *message = nullptr;
+    Ip6::Prefix      nat64Prefix;
+    Ip6::Header      ip6Header;
+    Ip6::Icmp6Header icmpHeader;
 
     message = aNode.Get<MessagePool>().Allocate(Message::kTypeIp6);
     VerifyOrQuit(message != nullptr);
@@ -392,12 +394,12 @@
     ip6Header.GetDestination().SynthesizeFromIp4Address(nat64Prefix, aDstIp4Address);
 
     ip6Header.SetNextHeader(Ip6::kProtoIcmp6);
-    ip6Header.SetPayloadLength(sizeof(Ip6::Icmp::Header) + aPayloadLen);
+    ip6Header.SetPayloadLength(sizeof(Ip6::Icmp6Header) + aPayloadLen);
 
     SuccessOrQuit(message->Append(ip6Header));
 
     icmpHeader.Clear();
-    icmpHeader.SetType(Ip6::Icmp::Header::kTypeEchoRequest);
+    icmpHeader.SetType(Ip6::Icmp6Header::kTypeEchoRequest);
 
     SuccessOrQuit(message->Append(icmpHeader));
 
@@ -448,7 +450,7 @@
     nexus.AdvanceTime(50 * Time::kOneSecondInMsec);
     VerifyOrQuit(node.Get<Mle::Mle>().IsLeader());
 
-    node.Get<Instance>().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Enable NAT64 translator");
@@ -705,7 +707,7 @@
     nexus.AdvanceTime(50 * Time::kOneSecondInMsec);
     VerifyOrQuit(node.Get<Mle::Mle>().IsLeader());
 
-    node.Get<Instance>().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Enable NAT64 translator");
@@ -872,7 +874,7 @@
     nexus.AdvanceTime(50 * Time::kOneSecondInMsec);
     VerifyOrQuit(node.Get<Mle::Mle>().IsLeader());
 
-    node.Get<Instance>().SetLogLevel(kLogLevelInfo);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     Log("Enable NAT64 translator");
@@ -1101,6 +1103,215 @@
     Log("End of TestNat64Evict()");
 }
 
+void TestNat64IhlBypass(void)
+{
+    Core         nexus;
+    Node        &node = nexus.CreateNode();
+    Ip6::Prefix  prefix;
+    Ip4::Cidr    cidr;
+    Ip6::Address ip6Addr;
+    Ip4::Address ip4Addr;
+    uint16_t     srcPort    = 5678;
+    uint16_t     dstPort    = 1234;
+    uint16_t     payloadLen = 10;
+
+    Log("------------------------------------------------------------------------------------------------------");
+    Log("TestNat64IhlBypass");
+
+    nexus.AdvanceTime(0);
+    node.Form();
+    nexus.AdvanceTime(50 * Time::kOneSecondInMsec);
+    VerifyOrQuit(node.Get<Mle::Mle>().IsLeader());
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    SuccessOrQuit(prefix.FromString("fd00:7d03:7d03:7d03::/96"));
+    SuccessOrQuit(cidr.FromString("192.168.255.0/24"));
+    node.Get<Nat64::Translator>().SetNat64Prefix(prefix);
+    SuccessOrQuit(node.Get<Nat64::Translator>().SetIp4Cidr(cidr));
+    node.Get<Nat64::Translator>().SetEnabled(true);
+
+    // Create a mapping by sending a packet from IPv6 to IPv4
+    SuccessOrQuit(ip6Addr.FromString("fd00::1"));
+    SuccessOrQuit(ip4Addr.FromString("1.2.3.4"));
+
+    {
+        Log("Translate an IPv6 message to create mapping");
+        OwnedPtr<Message> message;
+        message.Reset(node.Get<MessagePool>().Allocate(Message::kTypeIp6));
+        VerifyOrQuit(message != nullptr);
+
+        Ip6::Header ip6Header;
+        ip6Header.Clear();
+        ip6Header.InitVersionTrafficClassFlow();
+        ip6Header.SetSource(ip6Addr);
+        ip6Header.GetDestination().SynthesizeFromIp4Address(prefix, ip4Addr);
+        ip6Header.SetNextHeader(Ip6::kProtoUdp);
+        ip6Header.SetPayloadLength(sizeof(Ip6::UdpHeader) + payloadLen);
+        SuccessOrQuit(message->Append(ip6Header));
+
+        Ip6::UdpHeader udpHeader;
+        udpHeader.Clear();
+        udpHeader.SetSourcePort(srcPort);
+        udpHeader.SetDestinationPort(dstPort);
+        udpHeader.SetLength(sizeof(Ip6::UdpHeader) + payloadLen);
+        SuccessOrQuit(message->Append(udpHeader));
+
+        for (uint16_t i = 0; i < payloadLen; i++)
+        {
+            SuccessOrQuit(message->Append<uint8_t>(0));
+        }
+
+        SuccessOrQuit(node.Get<Nat64::Translator>().TranslateIp6ToIp4(*message));
+    }
+
+    // Now test IPv4 to IPv6 translation with IHL=6 (options)
+    Log("Test 2 (IHL=6, options)");
+    {
+        OwnedPtr<Message> message;
+        message.Reset(node.Get<MessagePool>().Allocate(Message::kTypeIp4));
+        VerifyOrQuit(message != nullptr);
+
+        Ip4::Header ip4Header;
+        ip4Header.Clear();
+        // IHL=6 means header length is 6*4 = 24 bytes.
+        ip4Header.SetVersionIhl(0x46);
+        ip4Header.SetTotalLength(24 + sizeof(Ip4::UdpHeader) + payloadLen);
+        ip4Header.SetProtocol(Ip4::kProtoUdp);
+        ip4Header.SetTtl(64);
+        ip4Header.SetSource(ip4Addr);
+
+        // Find mapped IPv4 address
+        Nat64::Translator::AddressMappingIterator iterator;
+        Nat64::Translator::AddressMapping         mapping;
+        iterator.Init(node.GetInstance());
+        SuccessOrQuit(iterator.GetNext(mapping));
+        ip4Header.SetDestination(AsCoreType(&mapping.mIp4));
+
+        SuccessOrQuit(message->Append(ip4Header));
+
+        // Append 4 bytes of options (NOPs)
+        uint8_t options[] = {0x01, 0x01, 0x01, 0x00};
+        SuccessOrQuit(message->Append(options));
+
+        Ip4::UdpHeader udpHeader;
+        udpHeader.Clear();
+        udpHeader.SetSourcePort(srcPort);
+        udpHeader.SetDestinationPort(dstPort);
+        udpHeader.SetLength(sizeof(Ip4::UdpHeader) + payloadLen);
+        SuccessOrQuit(message->Append(udpHeader));
+
+        for (uint16_t i = 0; i < payloadLen; i++)
+        {
+            SuccessOrQuit(message->Append<uint8_t>(0));
+        }
+
+        SuccessOrQuit(node.Get<Nat64::Translator>().TranslateIp4ToIp6(*message));
+
+        // Check the translated IPv6 packet
+        Ip6::Headers ip6Headers;
+        SuccessOrQuit(ip6Headers.ParseFrom(*message));
+
+        Log("PayloadLength=%d, SrcPort=%d, DstPort=%d", ip6Headers.GetIp6Header().GetPayloadLength(),
+            ip6Headers.GetSourcePort(), ip6Headers.GetDestinationPort());
+
+        VerifyOrQuit(ip6Headers.GetSourcePort() == srcPort, "Source port corrupted!");
+        VerifyOrQuit(ip6Headers.GetDestinationPort() == dstPort, "Destination port corrupted!");
+    }
+
+    // Test 3: Source route options should be discarded
+    Log("Test 3 (IHL=7, LSRR option)");
+    {
+        OwnedPtr<Message> message;
+        message.Reset(node.Get<MessagePool>().Allocate(Message::kTypeIp4));
+        VerifyOrQuit(message != nullptr);
+
+        Ip4::Header ip4Header;
+        ip4Header.Clear();
+        // IHL=7 means header length is 7*4 = 28 bytes.
+        ip4Header.SetVersionIhl(0x47);
+        ip4Header.SetTotalLength(28 + sizeof(Ip4::UdpHeader) + payloadLen);
+        ip4Header.SetProtocol(Ip4::kProtoUdp);
+        ip4Header.SetTtl(64);
+        ip4Header.SetSource(ip4Addr);
+
+        // Find mapped IPv4 address
+        Nat64::Translator::AddressMappingIterator iterator;
+        Nat64::Translator::AddressMapping         mapping;
+        iterator.Init(node.GetInstance());
+        SuccessOrQuit(iterator.GetNext(mapping));
+        ip4Header.SetDestination(AsCoreType(&mapping.mIp4));
+
+        SuccessOrQuit(message->Append(ip4Header));
+
+        // Append LSRR option (Type 131, Length 8, Pointer 4, Address 1.1.1.1)
+        uint8_t options[] = {0x83, 0x08, 0x04, 0x01, 0x01, 0x01, 0x01, 0x00};
+        SuccessOrQuit(message->Append(options));
+
+        Ip4::UdpHeader udpHeader;
+        udpHeader.Clear();
+        udpHeader.SetSourcePort(srcPort);
+        udpHeader.SetDestinationPort(dstPort);
+        udpHeader.SetLength(sizeof(Ip4::UdpHeader) + payloadLen);
+        SuccessOrQuit(message->Append(udpHeader));
+
+        for (uint16_t i = 0; i < payloadLen; i++)
+        {
+            SuccessOrQuit(message->Append<uint8_t>(0));
+        }
+
+        // This should return kErrorDrop
+        VerifyOrQuit(node.Get<Nat64::Translator>().TranslateIp4ToIp6(*message) == kErrorDrop,
+                     "LSRR option not discarded!");
+    }
+
+    // Test 4: SSRR option should be discarded
+    Log("Test 4 (IHL=7, SSRR option)");
+    {
+        OwnedPtr<Message> message;
+        message.Reset(node.Get<MessagePool>().Allocate(Message::kTypeIp4));
+        VerifyOrQuit(message != nullptr);
+
+        Ip4::Header ip4Header;
+        ip4Header.Clear();
+        // IHL=7 means header length is 7*4 = 28 bytes.
+        ip4Header.SetVersionIhl(0x47);
+        ip4Header.SetTotalLength(28 + sizeof(Ip4::UdpHeader) + payloadLen);
+        ip4Header.SetProtocol(Ip4::kProtoUdp);
+        ip4Header.SetTtl(64);
+        ip4Header.SetSource(ip4Addr);
+
+        // Find mapped IPv4 address
+        Nat64::Translator::AddressMappingIterator iterator;
+        Nat64::Translator::AddressMapping         mapping;
+        iterator.Init(node.GetInstance());
+        SuccessOrQuit(iterator.GetNext(mapping));
+        ip4Header.SetDestination(AsCoreType(&mapping.mIp4));
+
+        SuccessOrQuit(message->Append(ip4Header));
+
+        // Append SSRR option (Type 137, Length 8, Pointer 4, Address 1.1.1.1)
+        uint8_t options[] = {0x89, 0x08, 0x04, 0x01, 0x01, 0x01, 0x01, 0x00};
+        SuccessOrQuit(message->Append(options));
+
+        Ip4::UdpHeader udpHeader;
+        udpHeader.Clear();
+        udpHeader.SetSourcePort(srcPort);
+        udpHeader.SetDestinationPort(dstPort);
+        udpHeader.SetLength(sizeof(Ip4::UdpHeader) + payloadLen);
+        SuccessOrQuit(message->Append(udpHeader));
+
+        for (uint16_t i = 0; i < payloadLen; i++)
+        {
+            SuccessOrQuit(message->Append<uint8_t>(0));
+        }
+
+        // This should return kErrorDrop
+        VerifyOrQuit(node.Get<Nat64::Translator>().TranslateIp4ToIp6(*message) == kErrorDrop,
+                     "SSRR option not discarded!");
+    }
+}
+
 } // namespace Nexus
 } // namespace ot
 
@@ -1113,6 +1324,7 @@
     ot::Nexus::TestNat64CidrAddressReuse("192.168.103.0/30");
     ot::Nexus::TestNat64CidrAddressReuse("192.168.104.0/27");
     ot::Nexus::TestNat64Evict();
+    ot::Nexus::TestNat64IhlBypass();
 
     printf("All tests passed\n");
     return 0;
diff --git a/tests/nexus/test_netdata_publisher.cpp b/tests/nexus/test_netdata_publisher.cpp
new file mode 100644
index 0000000..536dcc2
--- /dev/null
+++ b/tests/nexus/test_netdata_publisher.cpp
@@ -0,0 +1,926 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <initializer_list>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/network_data_leader.hpp"
+#include "thread/network_data_local.hpp"
+#include "thread/network_data_publisher.hpp"
+#include "thread/network_data_service.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static constexpr uint32_t kFormNetworkTime   = 20 * 1000;
+static constexpr uint32_t kJoinNetworkTime   = 10 * 1000;
+static constexpr uint32_t kNetDataUpdateTime = 55 * 1000;
+
+static constexpr char kOnMeshPrefix[]  = "fd00:1234::/64";
+static constexpr char kExternalRoute[] = "fd00:abce::/64";
+
+static constexpr uint8_t kAnycastSeqNum = 4;
+
+static const char *const  kDnsSrpAddrStr = "fd00::cdef";
+static constexpr uint16_t kDnsSrpPort    = 49152;
+
+static constexpr uint8_t kDesiredNumAnycast       = 8;
+static constexpr uint8_t kDesiredNumUnicast       = 2;
+static constexpr uint8_t kDesiredNumOnMeshPrefix  = 3;
+static constexpr uint8_t kDesiredNumExternalRoute = 10;
+
+static constexpr uint32_t kThreadEnterpriseNumber = 44970;
+static constexpr uint8_t  kAnycastServiceNum      = 0x5c;
+static constexpr uint8_t  kUnicastServiceNum      = 0x5d;
+
+static uint8_t GetNumServices(Node &aNode)
+{
+    NetworkData::Iterator      iterator = NetworkData::kIteratorInit;
+    NetworkData::ServiceConfig serviceConfig;
+    uint8_t                    num    = 0;
+    const NetworkData::Leader &leader = aNode.Get<NetworkData::Leader>();
+
+    while (leader.GetNext(iterator, serviceConfig) == kErrorNone)
+    {
+        num++;
+    }
+
+    return num;
+}
+
+static void VerifyAnycastService(const NetworkData::ServiceConfig &aService)
+{
+    VerifyOrQuit(aService.mEnterpriseNumber == kThreadEnterpriseNumber);
+    VerifyOrQuit(aService.mServiceDataLength >= 2);
+    VerifyOrQuit(aService.mServiceData[0] == kAnycastServiceNum);
+    VerifyOrQuit(aService.mServiceData[1] == kAnycastSeqNum);
+    VerifyOrQuit(aService.mServerConfig.mStable);
+}
+
+static void VerifyUnicastService(const NetworkData::ServiceConfig &aService)
+{
+    VerifyOrQuit(aService.mEnterpriseNumber == kThreadEnterpriseNumber);
+    VerifyOrQuit(aService.mServiceDataLength >= 1);
+    VerifyOrQuit(aService.mServiceData[0] == kUnicastServiceNum);
+    VerifyOrQuit(aService.mServerConfig.mStable);
+}
+
+enum ServiceType : uint8_t
+{
+    kAsAnycastService,
+    kAsUnicastService,
+};
+
+static void VerifyServices(Node &aNode, ServiceType aType)
+{
+    NetworkData::Iterator      iterator = NetworkData::kIteratorInit;
+    NetworkData::ServiceConfig serviceConfig;
+    const NetworkData::Leader &leader = aNode.Get<NetworkData::Leader>();
+
+    while (leader.GetNext(iterator, serviceConfig) == kErrorNone)
+    {
+        if (aType == kAsAnycastService)
+        {
+            VerifyAnycastService(serviceConfig);
+        }
+        else
+        {
+            VerifyUnicastService(serviceConfig);
+        }
+    }
+}
+
+static void CheckNumOfPrefixes(Node &aNode, uint8_t aNumLow, uint8_t aNumMed, uint8_t aNumHigh)
+{
+    NetworkData::Iterator           iterator = NetworkData::kIteratorInit;
+    NetworkData::OnMeshPrefixConfig prefixConfig;
+    const NetworkData::Leader      &leader   = aNode.Get<NetworkData::Leader>();
+    uint8_t                         numTotal = 0;
+    uint8_t                         numLow   = 0;
+    uint8_t                         numMed   = 0;
+    uint8_t                         numHigh  = 0;
+
+    while (leader.GetNext(iterator, prefixConfig) == kErrorNone)
+    {
+        numTotal++;
+        if (prefixConfig.mPreference == NetworkData::kRoutePreferenceLow)
+        {
+            numLow++;
+        }
+        else if (prefixConfig.mPreference == NetworkData::kRoutePreferenceMedium)
+        {
+            numMed++;
+        }
+        else if (prefixConfig.mPreference == NetworkData::kRoutePreferenceHigh)
+        {
+            numHigh++;
+        }
+    }
+
+    VerifyOrQuit(numTotal ==
+                 Min<uint16_t>(static_cast<uint16_t>(aNumHigh) + aNumMed + aNumLow, kDesiredNumOnMeshPrefix));
+    VerifyOrQuit(numHigh == Min(aNumHigh, kDesiredNumOnMeshPrefix));
+    VerifyOrQuit(numMed ==
+                 Min(aNumMed, static_cast<uint8_t>(Max<int>(0, (int)kDesiredNumOnMeshPrefix - (int)aNumHigh))));
+    VerifyOrQuit(numLow == Min(aNumLow, static_cast<uint8_t>(
+                                            Max<int>(0, (int)kDesiredNumOnMeshPrefix - (int)aNumHigh - (int)numMed))));
+}
+
+static void CheckNumOfRoutes(Node &aNode, uint8_t aNumLow, uint8_t aNumMed, uint8_t aNumHigh)
+{
+    NetworkData::Iterator            iterator = NetworkData::kIteratorInit;
+    NetworkData::ExternalRouteConfig routeConfig;
+    const NetworkData::Leader       &leader   = aNode.Get<NetworkData::Leader>();
+    uint8_t                          numTotal = 0;
+    uint8_t                          numLow   = 0;
+    uint8_t                          numMed   = 0;
+    uint8_t                          numHigh  = 0;
+
+    while (leader.GetNext(iterator, routeConfig) == kErrorNone)
+    {
+        numTotal++;
+        if (routeConfig.mPreference == NetworkData::kRoutePreferenceLow)
+        {
+            numLow++;
+        }
+        else if (routeConfig.mPreference == NetworkData::kRoutePreferenceMedium)
+        {
+            numMed++;
+        }
+        else if (routeConfig.mPreference == NetworkData::kRoutePreferenceHigh)
+        {
+            numHigh++;
+        }
+    }
+
+    VerifyOrQuit(numTotal ==
+                 Min<uint16_t>(static_cast<uint16_t>(aNumHigh) + aNumMed + aNumLow, kDesiredNumExternalRoute));
+    VerifyOrQuit(numHigh == Min(aNumHigh, kDesiredNumExternalRoute));
+    VerifyOrQuit(numMed ==
+                 Min(aNumMed, static_cast<uint8_t>(Max<int>(0, (int)kDesiredNumExternalRoute - (int)aNumHigh))));
+    VerifyOrQuit(numLow == Min(aNumLow, static_cast<uint8_t>(
+                                            Max<int>(0, (int)kDesiredNumExternalRoute - (int)aNumHigh - (int)numMed))));
+}
+
+void Test_NetDataPublisher(void)
+{
+    Core nexus;
+
+    Node &leader  = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &router2 = nexus.CreateNode();
+    Node &router3 = nexus.CreateNode();
+    Node &router4 = nexus.CreateNode();
+    Node &router5 = nexus.CreateNode();
+    Node &ed1     = nexus.CreateNode();
+    Node &ed2     = nexus.CreateNode();
+    Node &ed3     = nexus.CreateNode();
+    Node &ed4     = nexus.CreateNode();
+    Node &ed5     = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router1.SetName("ROUTER1");
+    router2.SetName("ROUTER2");
+    router3.SetName("ROUTER3");
+    router4.SetName("ROUTER4");
+    router5.SetName("ROUTER5");
+    ed1.SetName("END_DEV1");
+    ed2.SetName("END_DEV2");
+    ed3.SetName("END_DEV3");
+    ed4.SetName("END_DEV4");
+    ed5.SetName("END_DEV5");
+
+    Node *routers[] = {&router1, &router2, &router3, &router4, &router5};
+    Node *eds[]     = {&ed1, &ed2, &ed3, &ed4, &ed5};
+    Node *nodes[]   = {&leader, &router1, &router2, &router3, &router4, &router5, &ed1, &ed2, &ed3, &ed4, &ed5};
+
+    nexus.AdvanceTime(0);
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    for (Node *router : routers)
+    {
+        router->Join(leader);
+        nexus.AdvanceTime(kJoinNetworkTime);
+    }
+
+    for (Node *ed : eds)
+    {
+        ed->Join(leader, Node::kAsFed);
+        nexus.AdvanceTime(kJoinNetworkTime);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP anycast entries - equal version number");
+
+    leader.Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, 0);
+    for (Node *router : routers)
+    {
+        router->Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, 0);
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+
+    VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(1 + GetArrayLength(routers), kDesiredNumAnycast));
+    VerifyServices(leader, kAsAnycastService);
+
+    for (Node *ed : eds)
+    {
+        ed->Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, 0);
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+
+    VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(GetArrayLength(nodes), kDesiredNumAnycast));
+    VerifyServices(leader, kAsAnycastService);
+
+    uint8_t num = GetArrayLength(nodes);
+    for (Node *node : nodes)
+    {
+        node->Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num--;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumAnycast));
+        VerifyServices(leader, kAsAnycastService);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP anycast entries - different version numbers");
+
+    uint8_t version = 0;
+    leader.Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, version);
+    num = 1;
+
+    for (Node *router : routers)
+    {
+        version++;
+        router->Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, version);
+        num++;
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+
+    VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumAnycast));
+    VerifyServices(leader, kAsAnycastService);
+
+    for (Node *ed : eds)
+    {
+        ed->Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, version);
+        num++;
+        nexus.AdvanceTime(kNetDataUpdateTime);
+
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumAnycast));
+        VerifyServices(leader, kAsAnycastService);
+
+        // Check that the entry from this ED is present by checking its RLOC16
+        bool                       found    = false;
+        NetworkData::Iterator      iterator = NetworkData::kIteratorInit;
+        NetworkData::ServiceConfig serviceConfig;
+        while (leader.Get<NetworkData::Leader>().GetNext(iterator, serviceConfig) == kErrorNone)
+        {
+            if (serviceConfig.mServerConfig.mRloc16 == ed->Get<Mle::Mle>().GetRloc16())
+            {
+                found = true;
+                break;
+            }
+        }
+        VerifyOrQuit(found);
+    }
+
+    for (Node *node : nodes)
+    {
+        node->Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num--;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumAnycast));
+        VerifyServices(leader, kAsAnycastService);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP service data unicast entries - equal version number");
+
+    Ip6::Address dnsSrpAddr;
+    SuccessOrQuit(dnsSrpAddr.FromString(kDnsSrpAddrStr));
+
+    num = 0;
+    for (Node *router : routers)
+    {
+        router->Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(dnsSrpAddr, kDnsSrpPort, 0);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+    }
+
+    for (Node *router : routers)
+    {
+        router->Get<Srp::Server>().SetEnabled(true);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+    }
+
+    uint8_t runningCount = 0;
+    uint8_t stoppedCount = 0;
+    for (Node *router : routers)
+    {
+        if (router->Get<Srp::Server>().GetState() == Srp::Server::kStateRunning)
+        {
+            runningCount++;
+        }
+        else if (router->Get<Srp::Server>().GetState() == Srp::Server::kStateStopped)
+        {
+            stoppedCount++;
+        }
+    }
+    VerifyOrQuit(runningCount == Min<uint16_t>(GetArrayLength(routers), kDesiredNumUnicast));
+    VerifyOrQuit(stoppedCount ==
+                 static_cast<uint8_t>(Max<int>(0, (int)GetArrayLength(routers) - (int)kDesiredNumUnicast)));
+
+    for (Node *router : routers)
+    {
+        router->Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num--;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+    }
+
+    for (Node *router : routers)
+    {
+        router->Get<Srp::Server>().SetEnabled(false);
+        VerifyOrQuit(router->Get<Srp::Server>().GetState() == Srp::Server::kStateDisabled);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP service data unicast entries - different version numbers");
+
+    num = 0;
+    for (uint8_t i = 0; i < GetArrayLength(routers); i++)
+    {
+        routers[i]->Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(dnsSrpAddr, kDnsSrpPort, num);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+
+        // The most recent service should win as it uses a higher version number.
+        bool                       found    = false;
+        NetworkData::Iterator      iterator = NetworkData::kIteratorInit;
+        NetworkData::ServiceConfig serviceConfig;
+        while (leader.Get<NetworkData::Leader>().GetNext(iterator, serviceConfig) == kErrorNone)
+        {
+            if (serviceConfig.mServerConfig.mRloc16 == routers[i]->Get<Mle::Mle>().GetRloc16())
+            {
+                found = true;
+                break;
+            }
+        }
+        VerifyOrQuit(found);
+    }
+
+    for (int i = static_cast<int>(GetArrayLength(routers)) - 1; i >= 0; i--)
+    {
+        routers[i]->Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num--;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP server data unicast entries - equal version number");
+
+    num = 0;
+    for (Node *router : routers)
+    {
+        router->Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(kDnsSrpPort, 0);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+    }
+
+    for (Node *router : routers)
+    {
+        router->Get<Srp::Server>().SetEnabled(true);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+    }
+    runningCount = 0;
+    stoppedCount = 0;
+    for (Node *router : routers)
+    {
+        if (router->Get<Srp::Server>().GetState() == Srp::Server::kStateRunning)
+        {
+            runningCount++;
+        }
+        else if (router->Get<Srp::Server>().GetState() == Srp::Server::kStateStopped)
+        {
+            stoppedCount++;
+        }
+    }
+    VerifyOrQuit(runningCount == Min<uint16_t>(GetArrayLength(routers), kDesiredNumUnicast));
+    VerifyOrQuit(stoppedCount ==
+                 static_cast<uint8_t>(Max<int>(0, (int)GetArrayLength(routers) - (int)kDesiredNumUnicast)));
+
+    for (Node *router : routers)
+    {
+        router->Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num--;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+    }
+
+    for (Node *router : routers)
+    {
+        router->Get<Srp::Server>().SetEnabled(false);
+        VerifyOrQuit(router->Get<Srp::Server>().GetState() == Srp::Server::kStateDisabled);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP server data unicast entries - different version numbers");
+
+    num = 0;
+    for (uint8_t i = 0; i < GetArrayLength(routers); i++)
+    {
+        routers[i]->Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(kDnsSrpPort, num);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+
+        bool                       found    = false;
+        NetworkData::Iterator      iterator = NetworkData::kIteratorInit;
+        NetworkData::ServiceConfig serviceConfig;
+        while (leader.Get<NetworkData::Leader>().GetNext(iterator, serviceConfig) == kErrorNone)
+        {
+            if (serviceConfig.mServerConfig.mRloc16 == routers[i]->Get<Mle::Mle>().GetRloc16())
+            {
+                found = true;
+                break;
+            }
+        }
+        VerifyOrQuit(found);
+    }
+
+    for (int i = static_cast<int>(GetArrayLength(routers)) - 1; i >= 0; i--)
+    {
+        routers[i]->Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num--;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+    }
+
+    num = 0;
+    for (uint8_t i = 0; i < GetArrayLength(routers); i++)
+    {
+        routers[i]->Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(kDnsSrpPort, 20 - num);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+
+        // The service from first router should win as it uses the highest version number.
+        bool                       found    = false;
+        NetworkData::Iterator      iterator = NetworkData::kIteratorInit;
+        NetworkData::ServiceConfig serviceConfig;
+        while (leader.Get<NetworkData::Leader>().GetNext(iterator, serviceConfig) == kErrorNone)
+        {
+            if (serviceConfig.mServerConfig.mRloc16 == routers[0]->Get<Mle::Mle>().GetRloc16())
+            {
+                found = true;
+                break;
+            }
+        }
+        VerifyOrQuit(found);
+    }
+
+    for (Node *router : routers)
+    {
+        router->Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num--;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP server data unicast vs anycast");
+
+    num = 0;
+    for (Node *router : routers)
+    {
+        router->Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(kDnsSrpPort, 0);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+        VerifyServices(leader, kAsUnicastService);
+    }
+
+    leader.Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, 0);
+
+    // Verify that publishing an anycast entry will update the
+    // limit for the server data unicast address entry and all are
+    // removed quickly. We validate this by waiting for a short
+    // time (5000 msec) after publishing anycast.
+    nexus.AdvanceTime(5000);
+    VerifyOrQuit(GetNumServices(leader) == 1);
+    VerifyServices(leader, kAsAnycastService);
+
+    // Removing the anycast entry will cause the lower priority
+    // server data unicast entries to be added again.
+    leader.Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+    nexus.AdvanceTime(kNetDataUpdateTime);
+
+    VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+    VerifyServices(leader, kAsUnicastService);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP server data unicast vs service data unicast");
+
+    leader.Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(dnsSrpAddr, kDnsSrpPort, 0);
+    nexus.AdvanceTime(kNetDataUpdateTime);
+    VerifyOrQuit(GetNumServices(leader) == 1);
+    VerifyServices(leader, kAsUnicastService);
+
+    leader.Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+    nexus.AdvanceTime(kNetDataUpdateTime);
+
+    VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumUnicast));
+    VerifyServices(leader, kAsUnicastService);
+
+    for (Node *router : routers)
+    {
+        router->Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("DNS/SRP entries: Verify publisher preference when removing entries");
+
+    num                      = 0;
+    Node *testRoutersNodes[] = {&leader, &router1, &router2};
+    for (Node *node : testRoutersNodes)
+    {
+        node->Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, 0);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        VerifyOrQuit(GetNumServices(leader) == num);
+        VerifyServices(leader, kAsAnycastService);
+    }
+    for (Node *node : eds)
+    {
+        node->Get<NetworkData::Publisher>().PublishDnsSrpServiceAnycast(kAnycastSeqNum, 0);
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        VerifyOrQuit(GetNumServices(leader) == Min<uint16_t>(num, kDesiredNumAnycast));
+        VerifyServices(leader, kAsAnycastService);
+    }
+
+    VerifyOrQuit(GetNumServices(leader) == kDesiredNumAnycast);
+
+    for (Node *node : {&router3, &router4, &router5})
+    {
+        // Manually add service to router
+        NetworkData::ServiceData serviceData;
+        NetworkData::ServerData  serverData;
+        uint8_t                  sData[] = {kAnycastServiceNum, kAnycastSeqNum};
+
+        serviceData.Init(sData, sizeof(sData));
+        serverData.Init(nullptr, 0);
+
+        SuccessOrQuit(
+            node->Get<NetworkData::Local>().AddService(kThreadEnterpriseNumber, serviceData, true, serverData));
+        node->Get<NetworkData::Notifier>().HandleServerDataUpdated();
+        nexus.AdvanceTime(kNetDataUpdateTime);
+
+        VerifyOrQuit(GetNumServices(leader) == kDesiredNumAnycast);
+        VerifyServices(leader, kAsAnycastService);
+    }
+
+    // Final check for all routers being present.
+    {
+        Node *allRouters[] = {&leader, &router1, &router2, &router3, &router4, &router5};
+        for (Node *router : allRouters)
+        {
+            bool                       found    = false;
+            NetworkData::Iterator      iterator = NetworkData::kIteratorInit;
+            NetworkData::ServiceConfig serviceConfig;
+            while (leader.Get<NetworkData::Leader>().GetNext(iterator, serviceConfig) == kErrorNone)
+            {
+                if (serviceConfig.mServerConfig.mRloc16 == router->Get<Mle::Mle>().GetRloc16())
+                {
+                    found = true;
+                    break;
+                }
+            }
+            VerifyOrQuit(found);
+        }
+    }
+
+    nexus.AdvanceTime(kNetDataUpdateTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("On-mesh prefix");
+
+    Ip6::Prefix onMeshPrefix;
+    SuccessOrQuit(onMeshPrefix.FromString(kOnMeshPrefix));
+
+    uint8_t numLow  = 0;
+    uint8_t numMed  = 0;
+    uint8_t numHigh = 0;
+
+    for (Node *ed : eds)
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        config.GetPrefix() = onMeshPrefix;
+        config.mPreference = NetworkData::kRoutePreferenceLow;
+        config.mSlaac      = true;
+        config.mOnMesh     = true;
+        config.mStable     = true;
+        config.mPreferred  = true;
+        SuccessOrQuit(ed->Get<NetworkData::Publisher>().PublishOnMeshPrefix(config, NetworkData::Publisher::kFromUser));
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        numLow++;
+        CheckNumOfPrefixes(leader, numLow, numMed, numHigh);
+    }
+
+    for (Node *router : routers)
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        config.GetPrefix() = onMeshPrefix;
+        config.mPreference = NetworkData::kRoutePreferenceMedium;
+        config.mSlaac      = true;
+        config.mOnMesh     = true;
+        config.mStable     = true;
+        config.mPreferred  = true;
+        SuccessOrQuit(
+            router->Get<NetworkData::Publisher>().PublishOnMeshPrefix(config, NetworkData::Publisher::kFromUser));
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        numMed++;
+        CheckNumOfPrefixes(leader, numLow, numMed, numHigh);
+    }
+
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        config.GetPrefix() = onMeshPrefix;
+        config.mPreference = NetworkData::kRoutePreferenceHigh;
+        config.mSlaac      = true;
+        config.mOnMesh     = true;
+        config.mStable     = true;
+        config.mPreferred  = true;
+        SuccessOrQuit(
+            leader.Get<NetworkData::Publisher>().PublishOnMeshPrefix(config, NetworkData::Publisher::kFromUser));
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+    numHigh++;
+    CheckNumOfPrefixes(leader, numLow, numMed, numHigh);
+
+    for (Node *router : routers)
+    {
+        SuccessOrQuit(router->Get<NetworkData::Publisher>().UnpublishPrefix(onMeshPrefix));
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        numMed--;
+        CheckNumOfPrefixes(leader, numLow, numMed, numHigh);
+    }
+
+    SuccessOrQuit(leader.Get<NetworkData::Publisher>().UnpublishPrefix(onMeshPrefix));
+    nexus.AdvanceTime(kNetDataUpdateTime);
+    numHigh--;
+    CheckNumOfPrefixes(leader, numLow, numMed, numHigh);
+
+    for (Node *ed : eds)
+    {
+        SuccessOrQuit(ed->Get<NetworkData::Publisher>().UnpublishPrefix(onMeshPrefix));
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        numLow--;
+        CheckNumOfPrefixes(leader, numLow, numMed, numHigh);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify prefix removal preference");
+
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        config.GetPrefix() = onMeshPrefix;
+        config.mPreference = NetworkData::kRoutePreferenceMedium;
+        config.mSlaac      = true;
+        config.mOnMesh     = true;
+        config.mStable     = true;
+        config.mPreferred  = true;
+        SuccessOrQuit(ed1.Get<NetworkData::Publisher>().PublishOnMeshPrefix(config, NetworkData::Publisher::kFromUser));
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+    CheckNumOfPrefixes(leader, 0, 1, 0);
+
+    for (Node *router : routers)
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        config.GetPrefix() = onMeshPrefix;
+        config.mPreference = NetworkData::kRoutePreferenceMedium;
+        config.mSlaac      = true;
+        config.mOnMesh     = true;
+        config.mStable     = true;
+        config.mPreferred  = true;
+        SuccessOrQuit(
+            router->Get<NetworkData::Publisher>().PublishOnMeshPrefix(config, NetworkData::Publisher::kFromUser));
+        nexus.AdvanceTime(kNetDataUpdateTime);
+    }
+    CheckNumOfPrefixes(leader, 0, 1 + GetArrayLength(routers), 0);
+
+    // Check ed1 is in the list
+    {
+        bool                            found    = false;
+        NetworkData::Iterator           iterator = NetworkData::kIteratorInit;
+        NetworkData::OnMeshPrefixConfig prefixConfig;
+        while (leader.Get<NetworkData::Leader>().GetNext(iterator, prefixConfig) == kErrorNone)
+        {
+            if (prefixConfig.mRloc16 == ed1.Get<Mle::Mle>().GetRloc16())
+            {
+                found = true;
+                break;
+            }
+        }
+        VerifyOrQuit(found);
+    }
+
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        config.GetPrefix() = onMeshPrefix;
+        config.mPreference = NetworkData::kRoutePreferenceHigh;
+        config.mSlaac      = true;
+        config.mOnMesh     = true;
+        config.mStable     = true;
+        config.mPreferred  = true;
+        SuccessOrQuit(
+            leader.Get<NetworkData::Publisher>().PublishOnMeshPrefix(config, NetworkData::Publisher::kFromUser));
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+    CheckNumOfPrefixes(leader, 0, 1 + GetArrayLength(routers), 1);
+
+    // Publish same prefix now with `high` preference on leader.
+    // Since it is `high` preference, it is added to network data
+    // which leads to total number of entries to go above the desired
+    // number temporarily and trigger other nodes to try to remove
+    // their entry. The entries from routers should be preferred over
+    // the one from `end_dev1` so that is the one we expect to be
+    // removed. We check that this is the case (i.e., the entry from
+    // `end_dev1` is no longer present in network data).
+    //
+    // Check ed1 is NO LONGER in the list
+    {
+        bool                            found    = false;
+        NetworkData::Iterator           iterator = NetworkData::kIteratorInit;
+        NetworkData::OnMeshPrefixConfig prefixConfig;
+        while (leader.Get<NetworkData::Leader>().GetNext(iterator, prefixConfig) == kErrorNone)
+        {
+            if (prefixConfig.mRloc16 == ed1.Get<Mle::Mle>().GetRloc16())
+            {
+                found = true;
+                break;
+            }
+        }
+        VerifyOrQuit(!found);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("External route");
+
+    Ip6::Prefix externalRoute;
+    SuccessOrQuit(externalRoute.FromString(kExternalRoute));
+
+    num = 0;
+    for (Node *node : nodes)
+    {
+        NetworkData::ExternalRouteConfig config;
+        config.Clear();
+        config.GetPrefix() = externalRoute;
+        config.mPreference = NetworkData::kRoutePreferenceLow;
+        config.mStable     = true;
+        SuccessOrQuit(
+            node->Get<NetworkData::Publisher>().PublishExternalRoute(config, NetworkData::Publisher::kFromUser));
+        nexus.AdvanceTime(kNetDataUpdateTime);
+        num++;
+        CheckNumOfRoutes(leader, num, 0, 0);
+    }
+
+    {
+        NetworkData::ExternalRouteConfig config;
+        config.Clear();
+        config.GetPrefix() = externalRoute;
+        config.mPreference = NetworkData::kRoutePreferenceHigh;
+        config.mStable     = true;
+        SuccessOrQuit(
+            leader.Get<NetworkData::Publisher>().PublishExternalRoute(config, NetworkData::Publisher::kFromUser));
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+    CheckNumOfRoutes(leader, num - 1, 0, 1);
+
+    {
+        Ip6::Prefix defaultRoute;
+        SuccessOrQuit(defaultRoute.FromString("::/0"));
+        NetworkData::ExternalRouteConfig config;
+        config.Clear();
+        config.GetPrefix() = defaultRoute;
+        config.mPreference = NetworkData::kRoutePreferenceMedium;
+        config.mStable     = true;
+        SuccessOrQuit(leader.Get<NetworkData::Publisher>().ReplacePublishedExternalRoute(
+            externalRoute, config, NetworkData::Publisher::kFromUser));
+        nexus.AdvanceTime(kNetDataUpdateTime);
+
+        bool                             found    = false;
+        NetworkData::Iterator            iterator = NetworkData::kIteratorInit;
+        NetworkData::ExternalRouteConfig routeConfig;
+        while (leader.Get<NetworkData::Leader>().GetNext(iterator, routeConfig) == kErrorNone)
+        {
+            if (AsCoreType(&routeConfig.mPrefix) == defaultRoute)
+            {
+                found = true;
+                break;
+            }
+        }
+        VerifyOrQuit(found);
+
+        // Replace it back
+        config.GetPrefix() = externalRoute;
+        config.mPreference = NetworkData::kRoutePreferenceHigh;
+        SuccessOrQuit(leader.Get<NetworkData::Publisher>().ReplacePublishedExternalRoute(
+            defaultRoute, config, NetworkData::Publisher::kFromUser));
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+    CheckNumOfRoutes(leader, num - 1, 0, 1);
+
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        config.GetPrefix() = externalRoute;
+        config.mPreference = NetworkData::kRoutePreferenceLow;
+        config.mSlaac      = true;
+        config.mOnMesh     = true;
+        config.mStable     = true;
+        config.mPreferred  = true;
+        SuccessOrQuit(
+            leader.Get<NetworkData::Publisher>().PublishOnMeshPrefix(config, NetworkData::Publisher::kFromUser));
+    }
+    nexus.AdvanceTime(kNetDataUpdateTime);
+    CheckNumOfRoutes(leader, num - 1, 0, 0);
+
+    {
+        bool                            found    = false;
+        NetworkData::Iterator           iterator = NetworkData::kIteratorInit;
+        NetworkData::OnMeshPrefixConfig prefixConfig;
+        while (leader.Get<NetworkData::Leader>().GetNext(iterator, prefixConfig) == kErrorNone)
+        {
+            if (AsCoreType(&prefixConfig.mPrefix) == externalRoute)
+            {
+                found = true;
+                break;
+            }
+        }
+        VerifyOrQuit(found);
+    }
+
+    Log("All steps completed.");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::Test_NetDataPublisher();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_on_mesh_prefix.cpp b/tests/nexus/test_on_mesh_prefix.cpp
new file mode 100644
index 0000000..5d60b81
--- /dev/null
+++ b/tests/nexus/test_on_mesh_prefix.cpp
@@ -0,0 +1,199 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "mac/data_poll_sender.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the network to stabilize after routers have attached.
+ */
+static constexpr uint32_t kStabilizationTime = 130 * 1000;
+
+static bool HasAddressWithPrefix(Node &aNode, const char *aPrefixString)
+{
+    Ip6::Prefix prefix;
+    bool        found = false;
+
+    SuccessOrQuit(prefix.FromString(aPrefixString));
+
+    for (const Ip6::Netif::UnicastAddress &addr : aNode.Get<ThreadNetif>().GetUnicastAddresses())
+    {
+        if (addr.GetAddress().MatchesPrefix(prefix))
+        {
+            found = true;
+            break;
+        }
+    }
+
+    return found;
+}
+
+void TestOnMeshPrefix(void)
+{
+    /**
+     * Test On-Mesh Prefix functionality.
+     *
+     * Topology:
+     *   LEADER <-> ROUTER <-> MED
+     *                  \ <-> SED
+     */
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &med    = nexus.CreateNode();
+    Node &sed    = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+    med.SetName("MED");
+    sed.SetName("SED");
+
+    const char *kPrefix1 = "2001:2:0:1::/64";
+    const char *kPrefix2 = "2001:2:0:2::/64";
+    const char *kPrefix3 = "2002:2:0:3::/64";
+
+    AllowLinkBetween(leader, router);
+    AllowLinkBetween(router, med);
+    AllowLinkBetween(router, sed);
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("Step 1: Form network and attach nodes");
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader, Node::kAsFtd);
+    med.Join(router, Node::kAsMed);
+    sed.Join(router, Node::kAsSed);
+
+    SuccessOrQuit(med.Get<DataPollSender>().SetExternalPollPeriod(1000));
+    SuccessOrQuit(sed.Get<DataPollSender>().SetExternalPollPeriod(1000));
+
+    nexus.AdvanceTime(kAttachToRouterTime);
+
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(med.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+
+    Log("Step 2: Add prefixes on ROUTER");
+    // Prefix 1: 'paros' -> Preferred, SLAAC, Router, On-Mesh, Stable
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        SuccessOrQuit(config.GetPrefix().FromString(kPrefix1));
+        config.mPreferred = true;
+        config.mSlaac     = true;
+        config.mOnMesh    = true;
+        config.mStable    = true;
+        SuccessOrQuit(router.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    }
+
+    // Prefix 2: 'paro' -> Preferred, SLAAC, Router, On-Mesh, NOT Stable
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        SuccessOrQuit(config.GetPrefix().FromString(kPrefix2));
+        config.mPreferred = true;
+        config.mSlaac     = true;
+        config.mOnMesh    = true;
+        config.mStable    = false;
+        SuccessOrQuit(router.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    }
+
+    router.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("Step 3: Verify addresses on MED and SED");
+    // MED should have both
+    VerifyOrQuit(HasAddressWithPrefix(med, kPrefix1));
+    VerifyOrQuit(HasAddressWithPrefix(med, kPrefix2));
+
+    // SED should have only Prefix 1 (stable)
+    VerifyOrQuit(HasAddressWithPrefix(sed, kPrefix1));
+    VerifyOrQuit(!HasAddressWithPrefix(sed, kPrefix2));
+
+    Log("Step 4: Verify pings from LEADER");
+    nexus.SendAndVerifyEchoRequest(leader, med.FindMatchingAddress(kPrefix1));
+    nexus.SendAndVerifyEchoRequest(leader, med.FindMatchingAddress(kPrefix2));
+    nexus.SendAndVerifyEchoRequest(leader, sed.FindMatchingAddress(kPrefix1));
+
+    Log("Step 5: Add prefix 3 (NOT On-Mesh)");
+    // Prefix 3: 'pars' -> Preferred, SLAAC, Router, Stable, NOT On-Mesh
+    {
+        NetworkData::OnMeshPrefixConfig config;
+        config.Clear();
+        SuccessOrQuit(config.GetPrefix().FromString(kPrefix3));
+        config.mPreferred = true;
+        config.mSlaac     = true;
+        config.mOnMesh    = false;
+        config.mStable    = true;
+        SuccessOrQuit(router.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    }
+
+    router.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("Step 6: Verify address with Prefix 3 exists on MED and SED");
+    VerifyOrQuit(HasAddressWithPrefix(med, kPrefix3));
+    VerifyOrQuit(HasAddressWithPrefix(sed, kPrefix3));
+
+    nexus.SaveTestInfo("test_on_mesh_prefix.json");
+
+    Log("Test passed");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestOnMeshPrefix();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_pbbr_aloc.cpp b/tests/nexus/test_pbbr_aloc.cpp
new file mode 100644
index 0000000..0556e94
--- /dev/null
+++ b/tests/nexus/test_pbbr_aloc.cpp
@@ -0,0 +1,110 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/** Delay in milliseconds for node startup/joining */
+static constexpr uint32_t kNodeJoinDelay = 20 * 1000;
+/** Delay in milliseconds for network to stabilize and PBBR to become primary */
+static constexpr uint32_t kStabilizationDelay = 300 * 1000;
+
+void TestPbbrAloc(void)
+{
+    // Topology:
+    //   PBBR -- LEADER -- ROUTER
+
+    Core nexus;
+
+    Node &pbbr   = nexus.CreateNode();
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+
+    pbbr.SetName("PBBR");
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+
+    nexus.AdvanceTime(0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form network and enable PBBR");
+
+    AllowLinkBetween(pbbr, leader);
+    AllowLinkBetween(leader, router);
+
+    leader.Form();
+    nexus.AdvanceTime(kNodeJoinDelay);
+
+    pbbr.Join(leader);
+    pbbr.Get<BackboneRouter::Local>().SetEnabled(true);
+    nexus.AdvanceTime(kNodeJoinDelay);
+
+    router.Join(leader);
+    nexus.AdvanceTime(kNodeJoinDelay);
+
+    // Wait for network to stabilize and PBBR to become primary
+    nexus.AdvanceTime(kStabilizationDelay);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(pbbr.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(pbbr.Get<BackboneRouter::Local>().IsPrimary());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify ALOC connectivity");
+
+    Ip6::Address aloc;
+
+    // 1. Leader ALOC
+    leader.Get<Mle::Mle>().ComposeLeaderAloc(aloc);
+    Log("Pinging Leader ALOC %s from ROUTER", aloc.ToString().AsCString());
+    nexus.SendAndVerifyEchoRequest(router, aloc);
+
+    // 2. PBBR ALOC
+    leader.Get<Mle::Mle>().ComposeAloc(Mle::Aloc16::ForPrimaryBackboneRouter(), aloc);
+    Log("Pinging PBBR ALOC %s from ROUTER", aloc.ToString().AsCString());
+    nexus.SendAndVerifyEchoRequest(router, aloc);
+
+    Log("All tests passed");
+
+    nexus.SaveTestInfo("test_pbbr_aloc.json", &leader);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestPbbrAloc();
+    return 0;
+}
diff --git a/tests/nexus/test_ping_lla_src.cpp b/tests/nexus/test_ping_lla_src.cpp
new file mode 100644
index 0000000..49ca548
--- /dev/null
+++ b/tests/nexus/test_ping_lla_src.cpp
@@ -0,0 +1,123 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestPingLlaSrc(void)
+{
+    /**
+     * Test ping with LLA source address.
+     *
+     * Topology:
+     *   ROUTER_2 ----- ROUTER_1 ---- ROUTER_3
+     *
+     * ROUTER_1 is leader. ROUTER_2 and ROUTER_3 are routers.
+     */
+
+    Core nexus;
+
+    Node &router1 = nexus.CreateNode();
+    Node &router2 = nexus.CreateNode();
+    Node &router3 = nexus.CreateNode();
+
+    router1.SetName("Router_1");
+    router2.SetName("Router_2");
+    router3.SetName("Router_3");
+
+    AllowLinkBetween(router1, router2);
+    AllowLinkBetween(router1, router3);
+
+    nexus.AdvanceTime(0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Form network");
+
+    router1.Form();
+    nexus.AdvanceTime(13 * 1000); // kFormNetworkTime
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsLeader());
+
+    router2.Join(router1);
+    nexus.AdvanceTime(200 * 1000); // kAttachToRouterTime
+    VerifyOrQuit(router2.Get<Mle::Mle>().IsRouter());
+
+    router3.Join(router1);
+    nexus.AdvanceTime(200 * 1000); // kAttachToRouterTime
+    VerifyOrQuit(router3.Get<Mle::Mle>().IsRouter());
+
+    Ip6::Address router1Mleid     = router1.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address router2LinkLocal = router2.Get<Mle::Mle>().GetLinkLocalAddress();
+    Ip6::Address router3Mleid     = router3.Get<Mle::Mle>().GetMeshLocalEid();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Ping a neighbor with LLA src should succeed");
+    // router2 pings router1's MLEID using its own LLA as source.
+    nexus.SendAndVerifyEchoRequest(router2, router2LinkLocal, router1Mleid);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Ping a non-neighbor with LLA src should fail");
+    // router2 pings router3's MLEID using its own LLA as source.
+    // It should fail because LLA is only valid for one-hop.
+    nexus.SendAndVerifyNoEchoResponse(router2, router2LinkLocal, router3Mleid);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Make sure external routes are not used for LLA src");
+
+    NetworkData::ExternalRouteConfig routeConfig;
+    routeConfig.Clear();
+    SuccessOrQuit(routeConfig.GetPrefix().FromString("2001::/64"));
+    routeConfig.mStable = true;
+
+    SuccessOrQuit(router1.Get<NetworkData::Local>().AddHasRoutePrefix(routeConfig));
+    router1.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    nexus.AdvanceTime(10 * 1000);
+
+    Ip6::Address externalAddr;
+    SuccessOrQuit(externalAddr.FromString("2001::1"));
+
+    // router2 pings external address using its own LLA as source.
+    // External routes should not be used for LLA source.
+    nexus.SendAndVerifyNoEchoResponse(router2, router2LinkLocal, externalAddr);
+
+    nexus.SaveTestInfo("test_ping_lla_src.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestPingLlaSrc();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_radio_filter.cpp b/tests/nexus/test_radio_filter.cpp
new file mode 100644
index 0000000..d4c81ba
--- /dev/null
+++ b/tests/nexus/test_radio_filter.cpp
@@ -0,0 +1,148 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestRadioFilter(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &sed    = nexus.CreateNode();
+
+    leader.SetName("Leader");
+    router.SetName("Router");
+    sed.SetName("SED");
+
+    AllowLinkBetween(leader, router);
+    AllowLinkBetween(leader, sed);
+
+    nexus.AdvanceTime(0);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Form network");
+
+    leader.Form();
+    nexus.AdvanceTime(13 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(200 * 1000);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    sed.Join(leader, Node::kAsSed);
+    nexus.AdvanceTime(200 * 1000);
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+
+    // Set poll period for SED to receive replies faster
+    SuccessOrQuit(sed.Get<DataPollSender>().SetExternalPollPeriod(40));
+
+    Ip6::Address leaderMleid = leader.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address routerMleid = router.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address sedMleid    = sed.Get<Mle::Mle>().GetMeshLocalEid();
+
+    // Validate initial state of `radiofilter` upon start
+    VerifyOrQuit(!leader.Get<Mac::Mac>().IsRadioFilterEnabled());
+    VerifyOrQuit(!router.Get<Mac::Mac>().IsRadioFilterEnabled());
+    VerifyOrQuit(!sed.Get<Mac::Mac>().IsRadioFilterEnabled());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Validate connections by pinging");
+    nexus.SendAndVerifyEchoRequest(router, leaderMleid);
+    nexus.SendAndVerifyEchoRequest(sed, leaderMleid);
+    nexus.SendAndVerifyEchoRequest(leader, routerMleid);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Validate behavior when `radiofilter` is enabled on router");
+    router.Get<Mac::Mac>().SetRadioFilterEnabled(true);
+    VerifyOrQuit(router.Get<Mac::Mac>().IsRadioFilterEnabled());
+
+    nexus.SendAndVerifyNoEchoResponse(leader, routerMleid);
+    nexus.SendAndVerifyNoEchoResponse(router, leaderMleid);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Validate behavior when `radiofilter` is disabled on router");
+    router.Get<Mac::Mac>().SetRadioFilterEnabled(false);
+    VerifyOrQuit(!router.Get<Mac::Mac>().IsRadioFilterEnabled());
+
+    nexus.SendAndVerifyEchoRequest(leader, routerMleid);
+    nexus.SendAndVerifyEchoRequest(router, leaderMleid);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Validate `radiofilter` behavior on sed");
+    nexus.SendAndVerifyEchoRequest(sed, leaderMleid);
+
+    sed.Get<Mac::Mac>().SetRadioFilterEnabled(true);
+    VerifyOrQuit(sed.Get<Mac::Mac>().IsRadioFilterEnabled());
+
+    nexus.SendAndVerifyNoEchoResponse(sed, leaderMleid);
+
+    // Advance time to trigger detach
+    nexus.AdvanceTime(300 * 1000);
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsDetached());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Validate behavior when `radiofilter` is disabled on sed");
+    sed.Get<Mac::Mac>().SetRadioFilterEnabled(false);
+    VerifyOrQuit(!sed.Get<Mac::Mac>().IsRadioFilterEnabled());
+
+    sed.Join(leader, Node::kAsSed);
+
+    // Advance time to allow reattach
+    for (int i = 0; i < 200; i++)
+    {
+        nexus.AdvanceTime(1000);
+        if (sed.Get<Mle::Mle>().IsChild())
+        {
+            break;
+        }
+    }
+    VerifyOrQuit(sed.Get<Mle::Mle>().IsChild());
+
+    nexus.SendAndVerifyEchoRequest(leader, sedMleid);
+    nexus.SendAndVerifyEchoRequest(sed, leaderMleid);
+
+    nexus.SaveTestInfo("test_radio_filter.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestRadioFilter();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_reed_address_solicit_rejected.cpp b/tests/nexus/test_reed_address_solicit_rejected.cpp
new file mode 100644
index 0000000..b58f839
--- /dev/null
+++ b/tests/nexus/test_reed_address_solicit_rejected.cpp
@@ -0,0 +1,136 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestReedAddressSolicitRejected(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &reed   = nexus.CreateNode();
+
+    leader.SetName("Leader");
+    reed.SetName("REED");
+
+    nexus.AdvanceTime(0);
+
+    // Leader sets RouterUpgradeThreshold to 1 to reject further routers
+    leader.Get<Mle::Mle>().SetRouterUpgradeThreshold(1);
+    leader.Get<Mle::Mle>().SetLeaderUpgradeThreshold(1);
+
+    Log("Step 1: Form network and join REED");
+    AllowLinkBetween(leader, reed);
+    leader.Form();
+    nexus.AdvanceTime(13 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    reed.Join(leader);
+    nexus.AdvanceTime(10 * 1000);
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsChild());
+
+    Log("Step 2: REED adds a service and registers it");
+    {
+        uint32_t                 enterpriseNumber = 123;
+        NetworkData::ServiceData serviceData;
+        NetworkData::ServerData  serverData;
+        uint8_t                  serviceDataBytes[] = {0x12, 0x34};
+        uint8_t                  serverDataBytes[]  = {0xab, 0xcd};
+
+        serviceData.Init(serviceDataBytes, sizeof(serviceDataBytes));
+        serverData.Init(serverDataBytes, sizeof(serverDataBytes));
+
+        SuccessOrQuit(reed.Get<NetworkData::Local>().AddService(enterpriseNumber, serviceData, true, serverData));
+        reed.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+    }
+
+    // REED sends a Server Data Notification to the leader, the leader
+    // updates its network data and broadcasts the update to the
+    // network. We advance time enough to ensure all these steps are
+    // completed.
+    nexus.AdvanceTime(15 * 1000);
+
+    Log("Step 3: Verify REED has the Service ALOC");
+    {
+        bool hasAloc = false;
+        for (const Ip6::Netif::UnicastAddress &addr : reed.Get<ThreadNetif>().GetUnicastAddresses())
+        {
+            if (addr.GetAddress().GetIid().IsAnycastServiceLocator() &&
+                (addr.GetAddress().GetIid().GetLocator() == 0xfc10))
+            {
+                hasAloc = true;
+                break;
+            }
+        }
+        VerifyOrQuit(hasAloc, "REED should have the Service ALOC");
+    }
+
+    Log("Step 4: Force REED to try to become a router");
+    reed.Get<Mle::Mle>().SetRouterUpgradeThreshold(16);
+    reed.Get<Mle::Mle>().SetRouterSelectionJitter(120);
+
+    // We advance time enough to cover jitter and router upgrade attempt.
+    nexus.AdvanceTime(130 * 1000);
+
+    Log("Step 5: Verify REED remains a child and still has the Service ALOC");
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsChild(), "REED should remain a child");
+
+    {
+        bool hasAloc = false;
+        for (const Ip6::Netif::UnicastAddress &addr : reed.Get<ThreadNetif>().GetUnicastAddresses())
+        {
+            if (addr.GetAddress().GetIid().IsAnycastServiceLocator() &&
+                (addr.GetAddress().GetIid().GetLocator() == 0xfc10))
+            {
+                hasAloc = true;
+                break;
+            }
+        }
+        VerifyOrQuit(hasAloc, "REED should still have the Service ALOC");
+    }
+
+    nexus.SaveTestInfo("test_reed_address_solicit_rejected.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestReedAddressSolicitRejected();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_reset.cpp b/tests/nexus/test_reset.cpp
new file mode 100644
index 0000000..b4ffd7f
--- /dev/null
+++ b/tests/nexus/test_reset.cpp
@@ -0,0 +1,154 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for a node to join as a child.
+ */
+static constexpr uint32_t kAttachAsChildTime = 5 * 1000;
+
+/**
+ * The number of pings to send to advance the frame counter beyond the storage threshold.
+ */
+static constexpr uint16_t kNumPings = 1010;
+
+void TestReset(void)
+{
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &ed     = nexus.CreateNode();
+
+    leader.SetName("Leader");
+    router.SetName("Router");
+    ed.SetName("ED");
+
+    nexus.AdvanceTime(0);
+
+    // Set up allowlists to enforce Leader <-> Router <-> ED topology
+    AllowLinkBetween(leader, router);
+    AllowLinkBetween(router, ed);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Forming network");
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    ed.Join(router, Node::kAsMed);
+    nexus.AdvanceTime(kAttachAsChildTime);
+    VerifyOrQuit(ed.Get<Mle::Mle>().IsChild());
+
+    nexus.AdvanceTime(10 * 1000); // Extra time to stabilize
+
+    const Ip6::Address &leaderRloc = leader.Get<Mle::Mle>().GetMeshLocalRloc();
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Sending %u pings from ED to Leader", kNumPings);
+
+    for (uint16_t i = 0; i < kNumPings; i++)
+    {
+        // Use a 500ms timeout for each ping to ensure it has enough time in a multi-hop setup
+        nexus.SendAndVerifyEchoRequest(ed, leaderRloc, 0, Ip6::kDefaultHopLimit, 500);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Resetting nodes sequentially");
+
+    Log("Resetting Leader");
+    leader.Reset();
+    AllowLinkBetween(leader, router);
+    leader.Get<ThreadNetif>().Up();
+    SuccessOrQuit(leader.Get<Mle::Mle>().Start());
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    Log("Resetting Router");
+    router.Reset();
+    AllowLinkBetween(router, leader);
+    AllowLinkBetween(router, ed);
+    router.Get<ThreadNetif>().Up();
+    SuccessOrQuit(router.Get<Mle::Mle>().Start());
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    Log("Resetting ED");
+    ed.Reset();
+    AllowLinkBetween(ed, router);
+    ed.Get<ThreadNetif>().Up();
+    SuccessOrQuit(ed.Get<Mle::Mle>().Start());
+    nexus.AdvanceTime(kAttachAsChildTime);
+    VerifyOrQuit(ed.Get<Mle::Mle>().IsChild());
+
+    nexus.AdvanceTime(10 * 1000); // Extra time to stabilize
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verifying connectivity after resets");
+
+    // The success of this ping after resets is critical.
+    // Since we sent 1010 pings before resets, the frame counters on all nodes
+    // have advanced significantly. If a node fails to recover its frame counter
+    // from settings after reset, it would start at 0, and its packets would be
+    // rejected by its neighbors.
+    nexus.SendAndVerifyEchoRequest(ed, leaderRloc);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestReset();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_retransmission_security.cpp b/tests/nexus/test_retransmission_security.cpp
new file mode 100644
index 0000000..cca7384
--- /dev/null
+++ b/tests/nexus/test_retransmission_security.cpp
@@ -0,0 +1,135 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a SSED.
+ */
+static constexpr uint32_t kAttachAsSsedTime = 20 * 1000;
+
+/**
+ * CSL Period in milliseconds.
+ */
+static constexpr uint32_t kCslPeriodMs = 100;
+
+/**
+ * CSL Period in units of 10 symbols.
+ */
+static constexpr uint32_t kCslPeriod = kCslPeriodMs * 1000 / OT_US_PER_TEN_SYMBOLS;
+
+/**
+ * Time to advance for CSL synchronization to complete, in milliseconds.
+ */
+static constexpr uint32_t kCslSyncTime = 5 * 1000;
+
+/**
+ * Payload size for a standard ICMPv6 Echo Request.
+ */
+static constexpr uint16_t kEchoPayloadSize = 10;
+
+void TestRetransmissionSecurity(void)
+{
+    /**
+     * Retransmission Security Test
+     *
+     * Topology:
+     * - Leader (DUT)
+     * - SSED_1
+     *
+     * Purpose:
+     * Validate that retransmitted frames with CSL IE use an incremented frame counter
+     * and are correctly encrypted for each attempt.
+     */
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &ssed1  = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    ssed1.SetName("SSED_1");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Network Formation");
+
+    AllowLinkBetween(leader, ssed1);
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    ssed1.Join(leader, Node::kAsSed);
+    nexus.AdvanceTime(kAttachAsSsedTime);
+    VerifyOrQuit(ssed1.Get<Mle::Mle>().IsAttached());
+
+    ssed1.Get<Mac::Mac>().SetCslPeriod(kCslPeriod);
+    nexus.AdvanceTime(kCslSyncTime);
+    VerifyOrQuit(ssed1.Get<Mac::Mac>().IsCslEnabled());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Trigger Retransmissions (SSED to Leader)");
+
+    // Turn off leader radio so it misses SSED frames and SSED retries
+    SuccessOrQuit(otPlatRadioSleep(&leader.GetInstance()));
+
+    ssed1.SendEchoRequest(leader.Get<Mle::Mle>().GetMeshLocalEid(), 0x1234, kEchoPayloadSize);
+
+    // Advance time enough for multiple retries.
+    nexus.AdvanceTime(2000);
+
+    // Turn leader radio back on
+    SuccessOrQuit(otPlatRadioReceive(&leader.GetInstance(), leader.Get<Mac::Mac>().GetPanChannel()));
+
+    nexus.SaveTestInfo("test_retransmission_security.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestRetransmissionSecurity();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_router_downgrade_on_sec_policy_change.cpp b/tests/nexus/test_router_downgrade_on_sec_policy_change.cpp
new file mode 100644
index 0000000..9537a47
--- /dev/null
+++ b/tests/nexus/test_router_downgrade_on_sec_policy_change.cpp
@@ -0,0 +1,329 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/key_manager.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * Time::kOneSecondInMsec;
+
+/**
+ * Time to attach as a child and upgrade to router role (>120s).
+ */
+static constexpr uint32_t kAttachmentTime = 200 * Time::kOneSecondInMsec;
+
+/**
+ * Time testing that the leader maintains its state for at least 10s after a policy change.
+ */
+static constexpr uint32_t kLeaderHoldTime = 9 * Time::kOneSecondInMsec;
+
+/**
+ * The shortest interval that allows processing events.
+ */
+static constexpr uint32_t kShortestTime = 1 * Time::kOneSecondInMsec;
+
+/**
+ * Sufficient time for the security policy to be propagated and a router
+ * downgrade (>120s) or child timeout (>240s) to occur.
+ */
+static constexpr uint32_t kPolicyStabilizationTime = 600 * Time::kOneSecondInMsec;
+
+/**
+ * Device mode to configure as a Full Thread Device (FTD).
+ */
+static const Mle::DeviceMode kFtdDeviceMode(Mle::DeviceMode::kModeRxOnWhenIdle |
+                                            Mle::DeviceMode::kModeFullThreadDevice |
+                                            Mle::DeviceMode::kModeFullNetworkData);
+
+/**
+ * Device mode to configure as a Minimal End Device (MED).
+ */
+static const Mle::DeviceMode kMedDeviceMode(Mle::DeviceMode::kModeRxOnWhenIdle | Mle::DeviceMode::kModeFullNetworkData);
+
+void TestRouterDowngradeOnSecPolicyChange(void)
+{
+    // This test verifies leader and router downgrade delay on security policy version threshold change,
+    // and that the Router role is correctly controlled when reconfiguring a FED or MED to FTD after it has attached.
+    //
+    // Topology:
+    //
+    // FED -- LEADER -- MED
+    //          |
+    //          |
+    //        ROUTER
+    //
+
+    Core  nexus;
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &fed    = nexus.CreateNode();
+    Node &med    = nexus.CreateNode();
+
+    // Default router selection jitter (120s) is used with the timing below
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNone));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Form initial topology");
+
+    AllowLinkBetween(leader, router);
+    AllowLinkBetween(leader, fed);
+    AllowLinkBetween(leader, med);
+
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+    fed.SetName("FED");
+    med.SetName("MED");
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    router.Join(leader);
+    nexus.AdvanceTime(kAttachmentTime);
+
+    fed.Join(leader);
+    SuccessOrQuit(fed.Get<Mle::Mle>().SetRouterEligible(false));
+    nexus.AdvanceTime(kAttachmentTime);
+
+    med.Join(leader, Node::kAsMed);
+    nexus.AdvanceTime(kAttachmentTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(fed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(med.Get<Mle::Mle>().IsChild());
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouterRoleAllowed());
+    // End device nodes initially have their router role disallowed
+    VerifyOrQuit(!fed.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(!med.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("After initial attachment, convert FED to FTD and verify that the router role is now allowed");
+
+    SuccessOrQuit(fed.Get<Mle::Mle>().SetRouterEligible(true));
+    VerifyOrQuit(fed.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    nexus.AdvanceTime(kAttachmentTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(fed.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(med.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("After initial attachment, convert MED to FTD and verify that the router role is now allowed");
+
+    SuccessOrQuit(med.Get<Mle::Mle>().SetDeviceMode(kFtdDeviceMode));
+    VerifyOrQuit(med.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    nexus.AdvanceTime(kAttachmentTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(fed.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(med.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Revert FED and MED to their original configurations, with router role disallowed");
+
+    SuccessOrQuit(fed.Get<Mle::Mle>().SetRouterEligible(false));
+    VerifyOrQuit(!fed.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    SuccessOrQuit(med.Get<Mle::Mle>().SetDeviceMode(kMedDeviceMode));
+    VerifyOrQuit(!med.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    nexus.AdvanceTime(kAttachmentTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(fed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(med.Get<Mle::Mle>().IsChild());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Change security policy, disable `R` bit and set version threshold to max value (7)");
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        SecurityPolicy         policy;
+        MeshCoP::Timestamp     timestamp;
+
+        SuccessOrQuit(leader.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
+
+        policy.SetToDefault();
+        policy.mRoutersEnabled             = false;
+        policy.mVersionThresholdForRouting = 7;
+        datasetInfo.Set<MeshCoP::Dataset::kSecurityPolicy>(policy);
+
+        timestamp.Clear();
+        timestamp.SetSeconds(30);
+        datasetInfo.Set<MeshCoP::Dataset::kActiveTimestamp>(timestamp);
+
+        leader.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Leader should remain in Leader role for at least 10 seconds");
+
+    nexus.AdvanceTime(kLeaderHoldTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    // Don't verify the router's current role, as it may have downgraded already
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify propagated role changes");
+
+    // Active routers now have Router role disallowed
+    VerifyOrQuit(!leader.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(!router.Get<Mle::Mle>().IsRouterRoleAllowed());
+    // End devices continue to have Router role disallowed
+    VerifyOrQuit(!fed.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(!med.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Change back security policy. This should cancel the ongoing downgrade delay");
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        SecurityPolicy         policy;
+        MeshCoP::Timestamp     timestamp;
+
+        SuccessOrQuit(leader.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
+
+        policy.SetToDefault();
+        policy.mRoutersEnabled             = true;
+        policy.mVersionThresholdForRouting = 0;
+        datasetInfo.Set<MeshCoP::Dataset::kSecurityPolicy>(policy);
+
+        timestamp.Clear();
+        timestamp.SetSeconds(60);
+        datasetInfo.Set<MeshCoP::Dataset::kActiveTimestamp>(timestamp);
+
+        leader.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Make sure the Leader device remains in its role");
+
+    nexus.AdvanceTime(kShortestTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Make sure the Router device remains in or restores its role");
+
+    // Advance a sufficiently long time
+    nexus.AdvanceTime(kPolicyStabilizationTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(fed.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(med.Get<Mle::Mle>().IsChild());
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(!fed.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(!med.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Change security policy again, disable `R` bit and set version threshold to max value (7)");
+
+    {
+        MeshCoP::Dataset::Info datasetInfo;
+        SecurityPolicy         policy;
+        MeshCoP::Timestamp     timestamp;
+
+        SuccessOrQuit(leader.Get<MeshCoP::ActiveDatasetManager>().Read(datasetInfo));
+
+        policy.SetToDefault();
+        policy.mRoutersEnabled             = false;
+        policy.mVersionThresholdForRouting = 7;
+        datasetInfo.Set<MeshCoP::Dataset::kSecurityPolicy>(policy);
+
+        timestamp.Clear();
+        timestamp.SetSeconds(90);
+        datasetInfo.Set<MeshCoP::Dataset::kActiveTimestamp>(timestamp);
+
+        leader.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Leader should remain in Leader role for at least 10 seconds");
+
+    nexus.AdvanceTime(kLeaderHoldTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Make sure both leader and router are downgraded and are now `detached`.");
+
+    nexus.AdvanceTime(kPolicyStabilizationTime);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsDetached());
+    VerifyOrQuit(router.Get<Mle::Mle>().IsDetached());
+    VerifyOrQuit(fed.Get<Mle::Mle>().IsDetached());
+    VerifyOrQuit(med.Get<Mle::Mle>().IsDetached());
+
+    VerifyOrQuit(!leader.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(!router.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(!fed.Get<Mle::Mle>().IsRouterRoleAllowed());
+    VerifyOrQuit(!med.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Verify that the Router role remains disallowed when FED and MED nodes are re-configured as FTDs.");
+
+    SuccessOrQuit(fed.Get<Mle::Mle>().SetRouterEligible(true));
+    VerifyOrQuit(!fed.Get<Mle::Mle>().IsRouterRoleAllowed());
+
+    SuccessOrQuit(med.Get<Mle::Mle>().SetDeviceMode(kFtdDeviceMode));
+    VerifyOrQuit(!med.Get<Mle::Mle>().IsRouterRoleAllowed());
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestRouterDowngradeOnSecPolicyChange();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_router_multicast_link_request.cpp b/tests/nexus/test_router_multicast_link_request.cpp
new file mode 100644
index 0000000..0cc2241
--- /dev/null
+++ b/tests/nexus/test_router_multicast_link_request.cpp
@@ -0,0 +1,154 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/mle_types.hpp"
+#include "thread/router_table.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Link establishment delay threshold (in milliseconds).
+ * test_router_multicast_link_request.py uses 3 seconds + 3 seconds buffer.
+ */
+static constexpr uint32_t kLinkEstablishDelay = 6 * 1000;
+
+static void CheckLink(Node &aNodeA, Node &aNodeB)
+{
+    Router::Info info;
+    uint8_t      bRouterId = Mle::RouterIdFromRloc16(aNodeB.Get<Mle::Mle>().GetRloc16());
+    uint8_t      aRouterId = Mle::RouterIdFromRloc16(aNodeA.Get<Mle::Mle>().GetRloc16());
+
+    Log("Checking link between %s (ID %d) and %s (ID %d)", aNodeA.GetName(), aRouterId, aNodeB.GetName(), bRouterId);
+
+    SuccessOrQuit(aNodeA.Get<RouterTable>().GetRouterInfo(bRouterId, info));
+    VerifyOrQuit(info.mLinkEstablished);
+
+    SuccessOrQuit(aNodeB.Get<RouterTable>().GetRouterInfo(aRouterId, info));
+    VerifyOrQuit(info.mLinkEstablished);
+}
+
+void TestRouterMulticastLinkRequest(const char *aJsonFileName)
+{
+    /**
+     * This test verifies if a device can quickly and efficiently establish links with
+     * neighboring routers by sending a multicast Link Request message after becoming
+     * a router.
+     */
+
+    Core nexus;
+
+    Node &leader  = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &router2 = nexus.CreateNode();
+    Node &router3 = nexus.CreateNode();
+    Node &reed    = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router1.SetName("ROUTER_1");
+    router2.SetName("ROUTER_2");
+    router3.SetName("ROUTER_3");
+    reed.SetName("REED");
+
+    // Topology:
+    // Leader is connected to Router1, Router2, Router3.
+    // REED is connected to Router1, Router2, Router3.
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(leader, router3);
+    AllowLinkBetween(router1, reed);
+    AllowLinkBetween(router2, reed);
+    AllowLinkBetween(router3, reed);
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Leader forms network");
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Routers join the leader");
+    router1.Join(leader);
+    router2.Join(leader);
+    router3.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router2.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router3.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Wait for network to stabilize");
+    nexus.AdvanceTime(60 * 1000);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: REED joins and upgrades to router");
+    // REED joins through router1
+    reed.Join(router1);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(reed.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Verify REED establishes links with all neighboring routers");
+    nexus.AdvanceTime(kLinkEstablishDelay);
+
+    CheckLink(reed, router1);
+    CheckLink(reed, router2);
+    CheckLink(reed, router3);
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    const char *jsonFileName = (argc > 2) ? argv[2] : "test_router_multicast_link_request.json";
+
+    ot::Nexus::TestRouterMulticastLinkRequest(jsonFileName);
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_router_reattach.cpp b/tests/nexus/test_router_reattach.cpp
new file mode 100644
index 0000000..07abef2
--- /dev/null
+++ b/tests/nexus/test_router_reattach.cpp
@@ -0,0 +1,118 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 30 * 1000;
+
+void TestRouterReattach(void)
+{
+    Core          nexus;
+    const uint8_t kNumRouters = 32;
+    Node         *nodes[kNumRouters];
+
+    Log("Creating %u nodes", kNumRouters);
+    for (uint8_t i = 0; i < kNumRouters; i++)
+    {
+        nodes[i] = &nexus.CreateNode();
+        nodes[i]->SetName("Node", i + 1);
+    }
+
+    nexus.AdvanceTime(0);
+
+    // Step 1: Start Leader
+    Log("Step 1: Starting Leader");
+    nodes[0]->Get<Mle::Mle>().SetRouterUpgradeThreshold(32);
+    nodes[0]->Get<Mle::Mle>().SetLeaderUpgradeThreshold(32);
+    nodes[0]->Get<Mle::Mle>().SetRouterDowngradeThreshold(32);
+    nodes[0]->Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(nodes[0]->Get<Mle::Mle>().IsLeader());
+
+    // Step 2: Start other 31 routers
+    Log("Step 2: Starting other 31 routers");
+    for (uint8_t i = 1; i < kNumRouters; i++)
+    {
+        nodes[i]->Get<Mle::Mle>().SetRouterUpgradeThreshold(32);
+        nodes[i]->Get<Mle::Mle>().SetRouterDowngradeThreshold(32);
+        nodes[i]->Get<Mle::Mle>().SetRouterSelectionJitter(1);
+        nodes[i]->Join(*nodes[0]);
+        nexus.AdvanceTime(kAttachToRouterTime);
+        Log("Node %u role: %s", i + 1, nodes[i]->GetExtendedRoleString());
+        VerifyOrQuit(nodes[i]->Get<Mle::Mle>().IsRouter());
+    }
+
+    // Step 3: Reset Node 2 (index 1)
+    Log("Step 3: Resetting Node 2");
+    nodes[1]->Reset();
+    nodes[1]->Get<Mle::Mle>().SetRouterUpgradeThreshold(32);
+    nodes[1]->Get<Mle::Mle>().SetRouterDowngradeThreshold(32);
+    nodes[1]->Get<Mle::Mle>().SetRouterSelectionJitter(3);
+
+    // Re-enable and start
+    Log("Step 4: Restarting Node 2");
+    nodes[1]->Get<ThreadNetif>().Up();
+    SuccessOrQuit(nodes[1]->Get<Mle::Mle>().Start());
+    VerifyOrQuit(nodes[1]->Get<Mle::Mle>().GetRouterDowngradeThreshold() == 32);
+
+    // Verify it restores as Router
+    Log("Step 5: Verifying Node 2 restores as Router");
+    nexus.AdvanceTime(1000);
+    VerifyOrQuit(nodes[1]->Get<Mle::Mle>().IsRouter());
+
+    // Verify it doesn't downgrade after Router Selection Jitter
+    Log("Step 6: Verifying Node 2 does not downgrade");
+    nexus.AdvanceTime(5000);
+    VerifyOrQuit(nodes[1]->Get<Mle::Mle>().IsRouter());
+
+    nexus.SaveTestInfo("test_router_reattach.json");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestRouterReattach();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_router_reboot_multiple_link_request.cpp b/tests/nexus/test_router_reboot_multiple_link_request.cpp
new file mode 100644
index 0000000..c8e3223
--- /dev/null
+++ b/tests/nexus/test_router_reboot_multiple_link_request.cpp
@@ -0,0 +1,136 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for a node to join as a child, in milliseconds.
+ */
+static constexpr uint32_t kAttachAsChildTime = 10 * 1000;
+
+/**
+ * Time to advance for the network to stabilize, in milliseconds.
+ */
+static constexpr uint32_t kStabilizationTime = 10 * 1000;
+
+/**
+ * Time to advance after Router reset to allow it to send all link requests.
+ *
+ * Router transitions must account for a potential jitter of up to 2 minutes (120 seconds).
+ */
+static constexpr uint32_t kResetRestoringTime = 120 * 1000;
+
+void TestRouterRebootMultipleLinkRequest(void)
+{
+    /**
+     * Test Router Reboot Multiple Link Request
+     *
+     * Topology
+     * - Leader
+     * - Router
+     * - MED (attached to Router)
+     *
+     * Purpose & Description
+     * The purpose of this test case is to show that when a router is rebooted, it sends
+     * MLE_MAX_RESTORING_TRANSMISSION_COUNT MLE link request packets if no response is received.
+     */
+
+    Core nexus;
+
+    Node &leader = nexus.CreateNode();
+    Node &router = nexus.CreateNode();
+    Node &med    = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router.SetName("ROUTER");
+    med.SetName("MED");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
+    Log("Step 1: Form topology");
+
+    AllowLinkBetween(leader, router);
+    AllowLinkBetween(router, med);
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router.Get<Mle::Mle>().IsRouter());
+
+    med.Join(router, Node::kAsMed);
+    nexus.AdvanceTime(kAttachAsChildTime);
+    VerifyOrQuit(med.Get<Mle::Mle>().IsChild());
+    VerifyOrQuit(med.Get<Mle::Mle>().GetParent().GetExtAddress() == router.Get<Mac::Mac>().GetExtAddress());
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("Step 2: Reset Router and isolate it from Leader");
+
+    router.Reset();
+
+    // Isolate Router from Leader so it doesn't receive Link Accept
+    UnallowLinkBetween(leader, router);
+
+    Log("Step 3: Start Router and wait for it to send multiple Link Requests");
+
+    router.Get<ThreadNetif>().Up();
+    SuccessOrQuit(router.Get<Mle::Mle>().Start());
+
+    nexus.AdvanceTime(kResetRestoringTime);
+
+    nexus.SaveTestInfo("test_router_reboot_multiple_link_request.json", &leader);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestRouterRebootMultipleLinkRequest();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_service.cpp b/tests/nexus/test_service.cpp
new file mode 100644
index 0000000..3b7afb6
--- /dev/null
+++ b/tests/nexus/test_service.cpp
@@ -0,0 +1,272 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+namespace {
+
+struct NoEchoReplyContext
+{
+    bool     mReceived;
+    uint16_t mId;
+};
+
+void HandleIcmpNoEchoReply(void *aContext, otMessage *, const otMessageInfo *, const otIcmp6Header *aIcmpHeader)
+{
+    NoEchoReplyContext     *ctx    = static_cast<NoEchoReplyContext *>(aContext);
+    const Ip6::Icmp6Header *header = AsCoreTypePtr(aIcmpHeader);
+
+    if (header->GetType() == Ip6::Icmp6Header::kTypeEchoReply && header->GetId() == ctx->mId)
+    {
+        ctx->mReceived = true;
+    }
+}
+
+void SendAndVerifyNoEchoReply(Core &aNexus, Node &aSender, const Ip6::Address &aDestination, uint32_t aTimeout = 1000)
+{
+    static constexpr uint16_t kIdentifier = 0xabcd;
+
+    NoEchoReplyContext context;
+    context.mReceived = false;
+    context.mId       = kIdentifier;
+
+    Ip6::Icmp::Handler icmpHandler(HandleIcmpNoEchoReply, &context);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().RegisterHandler(icmpHandler));
+
+    aSender.SendEchoRequest(aDestination, kIdentifier);
+    aNexus.AdvanceTime(aTimeout);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().UnregisterHandler(icmpHandler));
+
+    VerifyOrQuit(!context.mReceived, "Echo reply received when none was expected");
+}
+
+} // namespace
+
+void TestService(void)
+{
+    Core nexus;
+
+    Node &leader  = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &router2 = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router1.SetName("ROUTER1");
+    router2.SetName("ROUTER2");
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Forming network...");
+    AllowLinkBetween(leader, router1);
+    AllowLinkBetween(leader, router2);
+    AllowLinkBetween(router1, router2);
+
+    leader.Form();
+    nexus.AdvanceTime(10000);
+
+    router1.Join(leader);
+    router2.Join(leader);
+
+    // Wait for all to become routers
+    nexus.AdvanceTime(300000);
+
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router2.Get<Mle::Mle>().IsRouter());
+
+    Ip6::Address aloc0;
+    Ip6::Address aloc1;
+
+    leader.Get<Mle::Mle>().ComposeServiceAloc(0, aloc0);
+    leader.Get<Mle::Mle>().ComposeServiceAloc(1, aloc1);
+
+    // Initial check: no ALOCs
+    for (Node &node : nexus.GetNodes())
+    {
+        VerifyOrQuit(!node.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+        VerifyOrQuit(!node.Get<ThreadNetif>().HasUnicastAddress(aloc1));
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Add Service 0 on ROUTER1");
+
+    uint32_t enterpriseNumber0 = 123;
+    uint8_t  serviceData0[]    = {0x31, 0x32, 0x33}; // "123"
+    uint8_t  serverData0[]     = {0x61, 0x62, 0x63}; // "abc"
+
+    NetworkData::ServiceData sData0;
+    NetworkData::ServerData  rData0;
+
+    sData0.Init(serviceData0, sizeof(serviceData0));
+    rData0.Init(serverData0, sizeof(serverData0));
+
+    SuccessOrQuit(router1.Get<NetworkData::Local>().AddService(enterpriseNumber0, sData0, true, rData0));
+    router1.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(5000);
+
+    // Verify Service 0 ALOC on ROUTER1
+    // Note: Leader assigns Service IDs. First service should get ID 0.
+    VerifyOrQuit(!leader.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(router1.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(!router2.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+
+    Log("Pinging Service 0 ALOC %s from all nodes", aloc0.ToString().AsCString());
+    for (Node &node : nexus.GetNodes())
+    {
+        nexus.SendAndVerifyEchoRequest(node, aloc0);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Add same Service 0 on LEADER");
+    SuccessOrQuit(leader.Get<NetworkData::Local>().AddService(enterpriseNumber0, sData0, true, rData0));
+    leader.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(5000);
+
+    VerifyOrQuit(leader.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(router1.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(!router2.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+
+    Log("Pinging Service 0 ALOC from all nodes again");
+    for (Node &node : nexus.GetNodes())
+    {
+        nexus.SendAndVerifyEchoRequest(node, aloc0);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Add Service 1 on ROUTER2");
+
+    uint32_t enterpriseNumber1 = 234;
+    uint8_t  serviceData1[]    = {0x34, 0x35, 0x36}; // "456"
+    uint8_t  serverData1[]     = {0x64, 0x65, 0x66}; // "def"
+
+    NetworkData::ServiceData sData1;
+    NetworkData::ServerData  rData1;
+
+    sData1.Init(serviceData1, sizeof(serviceData1));
+    rData1.Init(serverData1, sizeof(serverData1));
+
+    SuccessOrQuit(router2.Get<NetworkData::Local>().AddService(enterpriseNumber1, sData1, true, rData1));
+    router2.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(5000);
+
+    VerifyOrQuit(leader.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(router1.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(!router2.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(router2.Get<ThreadNetif>().HasUnicastAddress(aloc1));
+    VerifyOrQuit(!leader.Get<ThreadNetif>().HasUnicastAddress(aloc1));
+    VerifyOrQuit(!router1.Get<ThreadNetif>().HasUnicastAddress(aloc1));
+
+    Log("Pinging both ALOCs from all nodes");
+    for (Node &node : nexus.GetNodes())
+    {
+        nexus.SendAndVerifyEchoRequest(node, aloc0);
+        nexus.SendAndVerifyEchoRequest(node, aloc1);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Remove Service 0 from ROUTER1");
+    SuccessOrQuit(router1.Get<NetworkData::Local>().RemoveService(enterpriseNumber0, sData0));
+    router1.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(5000);
+
+    VerifyOrQuit(leader.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(!router1.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(router2.Get<ThreadNetif>().HasUnicastAddress(aloc1));
+
+    Log("Pinging both ALOCs from all nodes again");
+    for (Node &node : nexus.GetNodes())
+    {
+        nexus.SendAndVerifyEchoRequest(node, aloc0);
+        nexus.SendAndVerifyEchoRequest(node, aloc1);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Remove Service 0 from LEADER");
+    SuccessOrQuit(leader.Get<NetworkData::Local>().RemoveService(enterpriseNumber0, sData0));
+    leader.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(5000);
+
+    VerifyOrQuit(!leader.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(!router1.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(!router2.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+    VerifyOrQuit(router2.Get<ThreadNetif>().HasUnicastAddress(aloc1));
+
+    Log("Service 0 ALOC should now be unreachable");
+    for (Node &node : nexus.GetNodes())
+    {
+        SendAndVerifyNoEchoReply(nexus, node, aloc0);
+    }
+    Log("Service 1 ALOC should still be reachable");
+    for (Node &node : nexus.GetNodes())
+    {
+        nexus.SendAndVerifyEchoRequest(node, aloc1);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Remove Service 1 from ROUTER2");
+    SuccessOrQuit(router2.Get<NetworkData::Local>().RemoveService(enterpriseNumber1, sData1));
+    router2.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+
+    nexus.AdvanceTime(5000);
+
+    for (Node &node : nexus.GetNodes())
+    {
+        VerifyOrQuit(!node.Get<ThreadNetif>().HasUnicastAddress(aloc0));
+        VerifyOrQuit(!node.Get<ThreadNetif>().HasUnicastAddress(aloc1));
+    }
+
+    Log("Service 1 ALOC should now be unreachable");
+    for (Node &node : nexus.GetNodes())
+    {
+        SendAndVerifyNoEchoReply(nexus, node, aloc1);
+    }
+
+    Log("All tests passed!");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestService();
+    return 0;
+}
diff --git a/tests/nexus/test_srp_auto_start.cpp b/tests/nexus/test_srp_auto_start.cpp
new file mode 100644
index 0000000..45c6607
--- /dev/null
+++ b/tests/nexus/test_srp_auto_start.cpp
@@ -0,0 +1,246 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Verifies SRP client auto-start functionality.
+ *
+ * This test replicates the logic from `tests/scripts/thread-cert/test_srp_auto_start_mode.py`.
+ *
+ * Topology:
+ *   Four routers, one acting as SRP client, others as SRP server.
+ *
+ */
+void TestSrpAutoStart(void)
+{
+    Core  nexus;
+    Node &client  = nexus.CreateNode();
+    Node &server1 = nexus.CreateNode();
+    Node &server2 = nexus.CreateNode();
+    Node &server3 = nexus.CreateNode();
+
+    client.SetName("client");
+    server1.SetName("server1");
+    server2.SetName("server2");
+    server3.SetName("server3");
+
+    Log("Forming network");
+    client.Get<Srp::Server>().SetEnabled(false);
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    client.Form();
+    nexus.AdvanceTime(10 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Mle::Mle>().IsLeader());
+
+    server1.Join(client);
+    server2.Join(client);
+    server3.Join(client);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(server1.Get<Mle::Mle>().IsFullThreadDevice());
+    VerifyOrQuit(server2.Get<Mle::Mle>().IsFullThreadDevice());
+    VerifyOrQuit(server3.Get<Mle::Mle>().IsFullThreadDevice());
+
+    Ip6::Address server1Mleid = server1.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address server2Mleid = server2.Get<Mle::Mle>().GetMeshLocalEid();
+    uint16_t     anycastPort  = 53;
+
+    // Enable server1 with unicast address mode
+    Log("Enable server1 with unicast address mode");
+    SuccessOrQuit(server1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    server1.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+
+    // Check auto start mode on client and check that server1 is selected
+    Log("Check auto start mode on client and check that server1 is selected");
+    VerifyOrQuit(client.Get<Srp::Client>().IsAutoStartModeEnabled());
+    nexus.AdvanceTime(2 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == server1Mleid);
+
+    // Disable server1 and check client is stopped/disabled.
+    Log("Disable server1 and check client is stopped/disabled.");
+    server1.Get<Srp::Server>().SetEnabled(false);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(!client.Get<Srp::Client>().IsRunning());
+
+    // Enable server2 with unicast address mode and check client starts again.
+    Log("Enable server2 with unicast address mode and check client starts again.");
+    SuccessOrQuit(server2.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    server2.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == server2Mleid);
+
+    // Enable server1 and check that client stays with server2
+    Log("Enable server1 and check that client stays with server2");
+    server1.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == server2Mleid);
+
+    // Disable server2 and check client switches to server1.
+    Log("Disable server2 and check client switches to server1.");
+    server2.Get<Srp::Server>().SetEnabled(false);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == server1Mleid);
+
+    // Enable server2 with anycast mode seq-num 1, and check that client switched to it.
+    Log("Enable server2 with anycast mode seq-num 1, and check that client switched to it.");
+    SuccessOrQuit(server2.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeAnycast));
+    SuccessOrQuit(server2.Get<Srp::Server>().SetAnycastModeSequenceNumber(1));
+    server2.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(server2.Get<Srp::Server>().GetAnycastModeSequenceNumber() == 1);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress().GetIid().IsAnycastServiceLocator());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == anycastPort);
+
+    // Enable server3 with anycast mode seq-num 2, and check that client switched to it since seq number is higher.
+    Log("Enable server3 with anycast mode seq-num 2, and check that client switched to it since seq number is higher.");
+    SuccessOrQuit(server3.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeAnycast));
+    SuccessOrQuit(server3.Get<Srp::Server>().SetAnycastModeSequenceNumber(2));
+    server3.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+
+    VerifyOrQuit(server3.Get<Srp::Server>().GetAnycastModeSequenceNumber() == 2);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress().GetIid().IsAnycastServiceLocator());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == anycastPort);
+
+    // Disable server3 and check that client goes back to server2.
+    Log("Disable server3 and check that client goes back to server2.");
+    server3.Get<Srp::Server>().SetEnabled(false);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress().GetIid().IsAnycastServiceLocator());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == anycastPort);
+
+    // Enable server3 with anycast mode seq-num 0 (which is smaller than server2 seq-num 1) and check that client stays
+    // with server2.
+    Log("Enable server3 with anycast mode seq-num 0 and check that client stays with server2.");
+    SuccessOrQuit(server3.Get<Srp::Server>().SetAnycastModeSequenceNumber(0));
+    server3.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(server3.Get<Srp::Server>().GetAnycastModeSequenceNumber() == 0);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress().GetIid().IsAnycastServiceLocator());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == anycastPort);
+
+    // Disable server2 and check that client goes back to server3.
+    Log("Disable server2 and check that client goes back to server3.");
+    server2.Get<Srp::Server>().SetEnabled(false);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress().GetIid().IsAnycastServiceLocator());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == anycastPort);
+
+    // Disable server3 and check that client goes back to server1 with unicast address.
+    Log("Disable server3 and check that client goes back to server1 with unicast address.");
+    server3.Get<Srp::Server>().SetEnabled(false);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == server1Mleid);
+
+    // Enable server2 with anycast mode seq-num 5, and check that client switched to it.
+    Log("Enable server2 with anycast mode seq-num 5, and check that client switched to it.");
+    SuccessOrQuit(server2.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeAnycast));
+    SuccessOrQuit(server2.Get<Srp::Server>().SetAnycastModeSequenceNumber(5));
+    server2.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(server2.Get<Srp::Server>().GetAnycastModeSequenceNumber() == 5);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress().GetIid().IsAnycastServiceLocator());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == anycastPort);
+
+    // Publish an entry on server3 with specific unicast address
+    // This entry should be now preferred over anycast of server2.
+    Log("Publish an entry on server3 with specific unicast address");
+    Ip6::Address unicastAddr3;
+    uint16_t     unicastPort3 = 1234;
+    SuccessOrQuit(unicastAddr3.FromString("fd00:0:0:0:0:3333:beef:cafe"));
+    server3.Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(unicastAddr3, unicastPort3, 1);
+    nexus.AdvanceTime(65 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == unicastAddr3);
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == unicastPort3);
+
+    // Publish an entry on server1 with specific unicast address
+    // Client should still stay with server3 which it originally selected.
+    Log("Publish an entry on server1 with specific unicast address");
+    Ip6::Address unicastAddr1;
+    uint16_t     unicastPort1 = 10203;
+    SuccessOrQuit(unicastAddr1.FromString("fd00:0:0:0:0:2222:beef:cafe"));
+    server1.Get<Srp::Server>().SetEnabled(false);
+    server1.Get<NetworkData::Publisher>().PublishDnsSrpServiceUnicast(unicastAddr1, unicastPort1, 1);
+    nexus.AdvanceTime(65 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == unicastAddr3);
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == unicastPort3);
+
+    // Unpublish the entry on server3. Now client should switch to entry from server1.
+    Log("Unpublish the entry on server3.");
+    server3.Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+    nexus.AdvanceTime(65 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == unicastAddr1);
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == unicastPort1);
+
+    // Unpublish the entry on server1 and check client goes back to anycast entry from server2.
+    Log("Unpublish the entry on server1.");
+    server1.Get<NetworkData::Publisher>().UnpublishDnsSrpService();
+    nexus.AdvanceTime(65 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress().GetIid().IsAnycastServiceLocator());
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == anycastPort);
+
+    // Finally disable server2, and check that client is disabled.
+    Log("Finally disable server2, and check that client is disabled.");
+    server2.Get<Srp::Server>().SetEnabled(false);
+    nexus.AdvanceTime(30 * Time::kOneSecondInMsec);
+    VerifyOrQuit(!client.Get<Srp::Client>().IsRunning());
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestSrpAutoStart();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_client_change_lease.cpp b/tests/nexus/test_srp_client_change_lease.cpp
new file mode 100644
index 0000000..24c8a68
--- /dev/null
+++ b/tests/nexus/test_srp_client_change_lease.cpp
@@ -0,0 +1,154 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * SRP Lease time in seconds.
+ */
+static constexpr uint32_t kLeaseTime    = 60;
+static constexpr uint32_t kNewLeaseTime = 120;
+static constexpr uint32_t kKeyLeaseTime = 240;
+static constexpr uint32_t kNewTtl       = 10;
+
+/**
+ * SRP service and host names.
+ */
+static const char         kSrpServiceType[]  = "_ipps._tcp";
+static const char         kSrpInstanceName[] = "my-service";
+static const char         kSrpHostName[]     = "my-host";
+static const char         kSrpHostAddress[]  = "2001::1";
+static constexpr uint16_t kSrpServicePort    = 12345;
+
+void TestSrpClientChangeLease(const char *aJsonFileName)
+{
+    Core                 nexus;
+    Ip6::Address         srpHostAddress;
+    Srp::Client::Service srpService;
+
+    Node &server = nexus.CreateNode();
+    Node &client = nexus.CreateNode();
+
+    server.SetName("SRP_SERVER");
+    client.SetName("SRP_CLIENT");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("Step 0: Start the server and client devices.");
+
+    server.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    {
+        Srp::Server::LeaseConfig leaseConfig;
+        leaseConfig.mMinLease    = kLeaseTime;
+        leaseConfig.mMaxLease    = kLeaseTime;
+        leaseConfig.mMinKeyLease = kKeyLeaseTime;
+        leaseConfig.mMaxKeyLease = kKeyLeaseTime;
+        SuccessOrQuit(server.Get<Srp::Server>().SetLeaseConfig(leaseConfig));
+    }
+    server.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(5 * 1000);
+
+    client.Join(server, Node::kAsFed);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    Log("Step 1: Register a single service and verify that it works.");
+
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName(kSrpHostName));
+
+    SuccessOrQuit(srpHostAddress.FromString(kSrpHostAddress));
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostAddresses(&srpHostAddress, 1));
+
+    ClearAllBytes(srpService);
+    srpService.mName         = kSrpServiceType;
+    srpService.mInstanceName = kSrpInstanceName;
+    srpService.mPort         = kSrpServicePort;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(srpService));
+
+    nexus.AdvanceTime(2 * 1000);
+
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("Step 2: Change server lease range and client lease interval.");
+    {
+        Srp::Server::LeaseConfig leaseConfig;
+        leaseConfig.mMinLease    = kNewLeaseTime;
+        leaseConfig.mMaxLease    = kNewLeaseTime;
+        leaseConfig.mMinKeyLease = kKeyLeaseTime;
+        leaseConfig.mMaxKeyLease = kKeyLeaseTime;
+        SuccessOrQuit(server.Get<Srp::Server>().SetLeaseConfig(leaseConfig));
+    }
+    client.Get<Srp::Client>().SetLeaseInterval(kNewLeaseTime);
+    nexus.AdvanceTime(kNewLeaseTime * 1000);
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("Step 3: Wait for KEY_LEASE_TIME * 2.");
+    nexus.AdvanceTime(kKeyLeaseTime * 2 * 1000);
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("Step 4: Change client TTL.");
+    client.Get<Srp::Client>().SetTtl(kNewTtl);
+    nexus.AdvanceTime(kNewLeaseTime * 1000);
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("Step 5: Set TTL to 0.");
+    client.Get<Srp::Client>().SetTtl(0);
+    nexus.AdvanceTime(kNewLeaseTime * 1000);
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::TestSrpClientChangeLease((argc > 2) ? argv[2] : "test_srp_client_change_lease.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_client_remove_host.cpp b/tests/nexus/test_srp_client_remove_host.cpp
new file mode 100644
index 0000000..e4c0f69
--- /dev/null
+++ b/tests/nexus/test_srp_client_remove_host.cpp
@@ -0,0 +1,178 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static const Srp::Server::Host *FindHost(Node &aServer, const char *aName)
+{
+    static constexpr uint16_t kNameStringSize = 400;
+
+    const Srp::Server::Host *host = nullptr;
+    String<kNameStringSize>  fullName;
+
+    fullName.Append("%s.%s", aName, aServer.Get<Srp::Server>().GetDomain());
+
+    while (true)
+    {
+        host = aServer.Get<Srp::Server>().GetNextHost(host);
+
+        if (host == nullptr)
+        {
+            break;
+        }
+
+        if (StringMatch(host->GetFullName(), fullName.AsCString()))
+        {
+            break;
+        }
+    }
+
+    return host;
+}
+
+void TestSrpClientRemoveHost(void)
+{
+    Core nexus;
+
+    Node &server = nexus.CreateNode();
+    Node &client = nexus.CreateNode();
+
+    server.SetName("server");
+    client.SetName("client");
+
+    Log("Step 0: Start the server and client devices.");
+
+    server.Form();
+    nexus.AdvanceTime(13000);
+    VerifyOrQuit(server.Get<Mle::Mle>().IsLeader());
+
+    client.Join(server);
+    nexus.AdvanceTime(200000);
+    VerifyOrQuit(client.Get<Mle::Mle>().IsRouter());
+
+    server.Get<Srp::Server>().SetEnabled(true);
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    nexus.AdvanceTime(15000);
+
+    Log("Step 1: Register a single service and verify that it worked.");
+
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName("host"));
+
+    Ip6::Address         address;
+    Srp::Client::Service service;
+
+    SuccessOrQuit(address.FromString("2001::1"));
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostAddresses(&address, 1));
+
+    service.mName          = "_srv._udp";
+    service.mInstanceName  = "ins";
+    service.mSubTypeLabels = nullptr;
+    service.mTxtEntries    = nullptr;
+    service.mNumTxtEntries = 0;
+    service.mPort          = 1977;
+    service.mPriority      = 0;
+    service.mWeight        = 0;
+    service.mLease         = 0;
+    service.mKeyLease      = 0;
+    SuccessOrQuit(service.Init());
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(service));
+
+    nexus.AdvanceTime(2000);
+
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    const Srp::Server::Host *host = FindHost(server, "host");
+    VerifyOrQuit(host != nullptr);
+    VerifyOrQuit(!host->IsDeleted());
+
+    uint8_t             numAddresses;
+    const Ip6::Address *addresses = host->GetAddresses(numAddresses);
+    VerifyOrQuit(numAddresses == 1);
+    {
+        Ip6::Address expectedAddress;
+        SuccessOrQuit(expectedAddress.FromString("2001::1"));
+        VerifyOrQuit(addresses[0] == expectedAddress);
+    }
+
+    VerifyOrQuit(!host->GetServices().IsEmpty());
+    const Srp::Server::Service *serverService = host->GetServices().GetHead();
+    VerifyOrQuit(!serverService->IsDeleted());
+    VerifyOrQuit(serverService->GetPort() == 1977);
+
+    Log("Step 2: Clear the info on client, and verify that it is still present on server.");
+
+    client.Get<Srp::Client>().ClearHostAndServices();
+    nexus.AdvanceTime(2000);
+
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetName() == nullptr);
+    VerifyOrQuit(FindHost(server, "host") != nullptr);
+
+    Log("Step 3: Set host name and request 'host remove' requiring update to server.");
+
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName("host"));
+    SuccessOrQuit(
+        client.Get<Srp::Client>().RemoveHostAndServices(/* aRemoveKeyLease */ false, /* aSendUnregToServer */ true));
+    nexus.AdvanceTime(2000);
+
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetName() == nullptr);
+
+    host = FindHost(server, "host");
+    VerifyOrQuit(host != nullptr);
+    VerifyOrQuit(host->IsDeleted());
+    VerifyOrQuit(!host->GetServices().IsEmpty());
+    VerifyOrQuit(host->GetServices().GetHead()->IsDeleted());
+
+    Log("Step 4: Request 'host remove' with `remove_key`.");
+
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName("host"));
+    SuccessOrQuit(
+        client.Get<Srp::Client>().RemoveHostAndServices(/* aRemoveKeyLease */ true, /* aSendUnregToServer */ true));
+    nexus.AdvanceTime(2000);
+
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetName() == nullptr);
+    VerifyOrQuit(FindHost(server, "host") == nullptr);
+
+    Log("Test passed successfully");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestSrpClientRemoveHost();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_client_save_server_info.cpp b/tests/nexus/test_srp_client_save_server_info.cpp
new file mode 100644
index 0000000..ff8e404
--- /dev/null
+++ b/tests/nexus/test_srp_client_save_server_info.cpp
@@ -0,0 +1,187 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for the network to stabilize.
+ */
+static constexpr uint32_t kWaitTime = 16 * 1000;
+
+/**
+ * SRP service port.
+ */
+static constexpr uint16_t kSrpServicePort = 12345;
+
+void TestSrpClientSaveServerInfo(const char *aJsonFileName)
+{
+    Core nexus;
+
+    Node &client  = nexus.CreateNode();
+    Node &server1 = nexus.CreateNode();
+    Node &server2 = nexus.CreateNode();
+    Node &server3 = nexus.CreateNode();
+
+    client.SetName("CLIENT");
+    server1.SetName("SERVER1");
+    server2.SetName("SERVER2");
+    server3.SetName("SERVER3");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Start the server & client devices.");
+
+    client.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(client.Get<Mle::Mle>().IsLeader());
+
+    AllowLinkBetween(client, server1);
+    AllowLinkBetween(client, server2);
+    AllowLinkBetween(client, server3);
+
+    server1.Join(client);
+    server2.Join(client);
+    server3.Join(client);
+    nexus.AdvanceTime(Time::SecToMsec(200));
+
+    VerifyOrQuit(server1.Get<Mle::Mle>().IsRouterOrLeader());
+    VerifyOrQuit(server2.Get<Mle::Mle>().IsRouterOrLeader());
+    VerifyOrQuit(server3.Get<Mle::Mle>().IsRouterOrLeader());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Enable auto start mode on client and check that server1 is used.");
+
+    SuccessOrQuit(server1.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    server1.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(kWaitTime);
+
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName("host"));
+    SuccessOrQuit(client.Get<Srp::Client>().EnableAutoHostAddress());
+    {
+        Srp::Client::Service service;
+        ClearAllBytes(service);
+        service.mName         = "_ipps._tcp";
+        service.mInstanceName = "my-service";
+        service.mPort         = kSrpServicePort;
+        SuccessOrQuit(client.Get<Srp::Client>().AddService(service));
+    }
+
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    VerifyOrQuit(client.Get<Srp::Client>().IsAutoStartModeEnabled());
+
+    nexus.AdvanceTime(kWaitTime);
+
+    VerifyOrQuit(client.Get<Srp::Client>().IsRunning());
+    VerifyOrQuit(
+        server1.Get<ThreadNetif>().HasUnicastAddress(client.Get<Srp::Client>().GetServerAddress().GetAddress()));
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Enable server2 and server3 and check that server1 is still used.");
+
+    SuccessOrQuit(server2.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    server2.Get<Srp::Server>().SetEnabled(true);
+    SuccessOrQuit(server3.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    server3.Get<Srp::Server>().SetEnabled(true);
+
+    nexus.AdvanceTime(kWaitTime);
+    VerifyOrQuit(
+        server1.Get<ThreadNetif>().HasUnicastAddress(client.Get<Srp::Client>().GetServerAddress().GetAddress()));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Stop and restart the client (multiple times) and verify that server1 is always picked.");
+
+    for (int iter = 0; iter < 5; iter++)
+    {
+        client.Get<Srp::Client>().Stop();
+        client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        nexus.AdvanceTime(kWaitTime);
+        VerifyOrQuit(
+            server1.Get<ThreadNetif>().HasUnicastAddress(client.Get<Srp::Client>().GetServerAddress().GetAddress()));
+        VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Stop the client, then stop server1 and restart client and verify that server1 is no longer used.");
+
+    client.Get<Srp::Client>().Stop();
+    server1.Get<Srp::Server>().SetEnabled(false);
+    nexus.AdvanceTime(kWaitTime);
+
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    nexus.AdvanceTime(kWaitTime);
+
+    Ip6::Address serverAddress = client.Get<Srp::Client>().GetServerAddress().GetAddress();
+    VerifyOrQuit(!server1.Get<ThreadNetif>().HasUnicastAddress(serverAddress));
+    VerifyOrQuit(server2.Get<ThreadNetif>().HasUnicastAddress(serverAddress) ||
+                 server3.Get<ThreadNetif>().HasUnicastAddress(serverAddress));
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Start back server1, then restart client and verify that now we remain with the new saved server "
+        "info.");
+
+    server1.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(kWaitTime);
+
+    for (int iter = 0; iter < 5; iter++)
+    {
+        client.Get<Srp::Client>().Stop();
+        nexus.AdvanceTime(kWaitTime);
+        client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        nexus.AdvanceTime(kWaitTime);
+        VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == serverAddress);
+        VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+    }
+
+    Log("---------------------------------------------------------------------------------------");
+
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::TestSrpClientSaveServerInfo((argc > 2) ? argv[2] : "test_srp_client_save_server_info.json");
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_lease.cpp b/tests/nexus/test_srp_lease.cpp
index a49c766..81a6adc 100644
--- a/tests/nexus/test_srp_lease.cpp
+++ b/tests/nexus/test_srp_lease.cpp
@@ -144,8 +144,8 @@
     service->mSubTypeLabels = nullptr;
     service->mTxtEntries    = nullptr;
     service->mNumTxtEntries = 0;
-    service->mPort          = Random::NonCrypto::GetUint16InRange(0x100, 0xff00);
-    service->mSubTypeLabels = &info->mSubTypeLabels[Random::NonCrypto::GetUint8InRange(0, 4)];
+    service->mPort          = Random::NonCrypto::GenerateFromMinUpToExcluding<uint16_t>(0x100, 0xff00);
+    service->mSubTypeLabels = &info->mSubTypeLabels[Random::NonCrypto::GenerateUpToExcluding<uint8_t>(4)];
 
     SuccessOrQuit(aNode.Get<Srp::Client>().AddService(*service));
 }
@@ -154,7 +154,7 @@
 {
     // Uses the given probability to randomly decide whether a certain action should be performed.
 
-    return Random::NonCrypto::GetUint16InRange(0, 1000) < aProbability;
+    return Random::NonCrypto::GenerateUpToExcluding<uint16_t>(1000) < aProbability;
 }
 
 static const Srp::Server::Host *FindHost(Node &aServer, const char *aName)
diff --git a/tests/nexus/test_srp_many_services_mtu_check.cpp b/tests/nexus/test_srp_many_services_mtu_check.cpp
new file mode 100644
index 0000000..6f26399
--- /dev/null
+++ b/tests/nexus/test_srp_many_services_mtu_check.cpp
@@ -0,0 +1,189 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Nexus test for SRP many services MTU check.
+ *
+ * Verifies that the SRP client handles cases where many services cause the SRP Update message to exceed the
+ * IPv6 MTU size (1280 bytes). In such cases, the SRP client should register services by splitting them into
+ * multiple update messages.
+ *
+ */
+void TestSrpManyServicesMtuCheck(void)
+{
+    Core nexus;
+
+    Node &server = nexus.CreateNode();
+    Node &client = nexus.CreateNode();
+
+    server.SetName("SRP_SERVER");
+    client.SetName("SRP_CLIENT");
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Start the server & client devices.");
+
+    server.Form();
+    SuccessOrQuit(server.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    server.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(20 * 1000);
+    VerifyOrQuit(server.Get<Mle::Mle>().IsLeader());
+
+    client.Join(server, Node::kAsFed);
+    nexus.AdvanceTime(20 * 1000);
+    VerifyOrQuit(client.Get<Mle::Mle>().IsAttached());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Register 15 services with long names and sub-types to hit MTU and trigger splitting.");
+
+    const uint16_t kNumServices = 15;
+
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(client.Get<Srp::Client>().EnableAutoHostAddress());
+    SuccessOrQuit(
+        client.Get<Srp::Client>().SetHostName("hosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthos"));
+
+    static Srp::Client::Service services[15];
+    static String<64>           serviceNames[15];
+    static const char *const    kSubTypeLabels[] = {"_sub1", nullptr};
+    static const Dns::TxtEntry  kTxtEntries[]    = {
+        Dns::TxtEntry("xyz", reinterpret_cast<const uint8_t *>("987654321"), 9),
+        Dns::TxtEntry("uvw", reinterpret_cast<const uint8_t *>("abcdefghijklmnopqrstu"), 21),
+        Dns::TxtEntry("qwp", reinterpret_cast<const uint8_t *>("564321abcdefghij"), 16),
+    };
+
+    for (uint16_t i = 0; i < kNumServices; i++)
+    {
+        serviceNames[i].Clear().AppendCharMultipleTimes('a' + i, 63);
+
+        ClearAllBytes(services[i]);
+        services[i].mName          = "_longlongsrvname._udp";
+        services[i].mInstanceName  = serviceNames[i].AsCString();
+        services[i].mSubTypeLabels = kSubTypeLabels;
+        services[i].mTxtEntries    = kTxtEntries;
+        services[i].mNumTxtEntries = GetArrayLength(kTxtEntries);
+        services[i].mPort          = 1977 + i;
+
+        SuccessOrQuit(client.Get<Srp::Client>().AddService(services[i]));
+    }
+
+    nexus.AdvanceTime(200 * 1000);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Make sure all services are successfully registered.");
+
+    Log("Client Running: %s", client.Get<Srp::Client>().IsRunning() ? "Yes" : "No");
+    Log("Host state: %s", Srp::Client::ItemStateToString(client.Get<Srp::Client>().GetHostInfo().GetState()));
+
+    VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+
+    for (uint16_t i = 0; i < kNumServices; i++)
+    {
+        // Find the service in client and check state
+        const Srp::Client::Service *service = client.Get<Srp::Client>().GetServices().GetHead();
+        bool                        found   = false;
+        while (service != nullptr)
+        {
+            if (StringMatch(service->mInstanceName, serviceNames[i].AsCString(), kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->GetState() == Srp::Client::kRegistered);
+                found = true;
+                break;
+            }
+            service = service->GetNext();
+        }
+        VerifyOrQuit(found);
+    }
+
+    {
+        const Srp::Server::Host *host = server.Get<Srp::Server>().GetNextHost(nullptr);
+        VerifyOrQuit(host != nullptr);
+
+        uint16_t registeredCount = 0;
+        for (const Srp::Server::Service *service = host->GetNextService(nullptr); service != nullptr;
+             service                             = host->GetNextService(service))
+        {
+            if (!service->IsDeleted())
+            {
+                registeredCount++;
+            }
+        }
+        VerifyOrQuit(registeredCount == kNumServices);
+    }
+
+    Log("Registration worked for 15 long services on both client and server (MTU exceeded).");
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Remove all 15 services.");
+
+    for (uint16_t i = 0; i < kNumServices; i++)
+    {
+        SuccessOrQuit(client.Get<Srp::Client>().RemoveService(services[i]));
+    }
+
+    nexus.AdvanceTime(200 * 1000);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Verify services are removed.");
+
+    VerifyOrQuit(client.Get<Srp::Client>().GetServices().IsEmpty());
+
+    {
+        const Srp::Server::Host *host = server.Get<Srp::Server>().GetNextHost(nullptr);
+        VerifyOrQuit(host != nullptr);
+        const Srp::Server::Service *service      = host->GetNextService(nullptr);
+        uint16_t                    deletedCount = 0;
+        while (service != nullptr)
+        {
+            VerifyOrQuit(service->IsDeleted());
+            deletedCount++;
+            service = host->GetNextService(service);
+        }
+        VerifyOrQuit(deletedCount == kNumServices);
+    }
+
+    Log("All services removed and marked as deleted on server.");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestSrpManyServicesMtuCheck();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_register_services_diff_lease.cpp b/tests/nexus/test_srp_register_services_diff_lease.cpp
new file mode 100644
index 0000000..f2b34b9
--- /dev/null
+++ b/tests/nexus/test_srp_register_services_diff_lease.cpp
@@ -0,0 +1,473 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "common/string.hpp"
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+#include <openthread/srp_server.h>
+
+namespace ot {
+namespace Nexus {
+
+void TestSrpRegisterServicesDiffLease(void)
+{
+    Core  nexus;
+    Node &server = nexus.CreateNode();
+    Node &client = nexus.CreateNode();
+
+    server.SetName("SRP_SERVER");
+    client.SetName("SRP_CLIENT");
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    server.Form();
+    nexus.AdvanceTime(10000);
+    VerifyOrQuit(server.Get<Mle::Mle>().IsLeader());
+
+    client.Join(server, Node::kAsFtd);
+    nexus.AdvanceTime(200000);
+    VerifyOrQuit(client.Get<Mle::Mle>().IsRouter());
+
+    server.Get<Srp::Server>().SetEnabled(true);
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+
+    nexus.AdvanceTime(15000);
+
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName("host"));
+    SuccessOrQuit(client.Get<Srp::Client>().EnableAutoHostAddress());
+
+    // Add a service with specific lease and key lease
+    Srp::Client::Service ins1;
+    ClearAllBytes(ins1);
+    ins1.mName         = "_test._udp";
+    ins1.mInstanceName = "ins1";
+    ins1.mPort         = 1111;
+    ins1.mLease        = 60;
+    ins1.mKeyLease     = 800;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins1));
+
+    nexus.AdvanceTime(5000);
+
+    // Check service on server
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = host->GetNextService(nullptr);
+
+        VerifyOrQuit(StringMatch(service->GetInstanceName(), "ins1._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch));
+        VerifyOrQuit(!service->IsDeleted());
+        VerifyOrQuit(service->GetTtl() == 60);
+
+        Srp::Server::LeaseInfo leaseInfo;
+
+        service->GetLeaseInfo(leaseInfo);
+        VerifyOrQuit(leaseInfo.mLease == 60000);
+        VerifyOrQuit(leaseInfo.mKeyLease == 800000);
+    }
+
+    // Register two more services with different lease intervals.
+    Srp::Client::Service ins2, ins3;
+    ClearAllBytes(ins2);
+    ins2.mName         = "_test._udp";
+    ins2.mInstanceName = "ins2";
+    ins2.mPort         = 2222;
+    ins2.mLease        = 30;
+    ins2.mKeyLease     = 200;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins2));
+
+    ClearAllBytes(ins3);
+    ins3.mName         = "_test._udp";
+    ins3.mInstanceName = "ins3";
+    ins3.mPort         = 3333;
+    ins3.mLease        = 100;
+    ins3.mKeyLease     = 1000;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins3));
+
+    nexus.AdvanceTime(10000);
+
+    // Verify all 3 services on server
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            Srp::Server::LeaseInfo leaseInfo;
+            service->GetLeaseInfo(leaseInfo);
+            if (StringMatch(service->GetInstanceName(), "ins1._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->GetTtl() == 60);
+                VerifyOrQuit(leaseInfo.mLease == 60000);
+                VerifyOrQuit(leaseInfo.mKeyLease == 800000);
+            }
+            else if (StringMatch(service->GetInstanceName(), "ins2._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->GetTtl() == 30);
+                VerifyOrQuit(leaseInfo.mLease == 30000);
+                VerifyOrQuit(leaseInfo.mKeyLease == 200000);
+            }
+            else if (StringMatch(service->GetInstanceName(), "ins3._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->GetTtl() == 100);
+                VerifyOrQuit(leaseInfo.mLease == 100000);
+                VerifyOrQuit(leaseInfo.mKeyLease == 1000000);
+            }
+            else
+            {
+                VerifyOrQuit(false);
+            }
+        }
+        VerifyOrQuit(count == 3);
+    }
+
+    // Wait for longest lease time to validate that all services renew their lease successfully.
+    nexus.AdvanceTime(105000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            VerifyOrQuit(!service->IsDeleted());
+        }
+        VerifyOrQuit(count == 3);
+    }
+
+    // Remove two services
+    SuccessOrQuit(client.Get<Srp::Client>().RemoveService(ins2));
+    SuccessOrQuit(client.Get<Srp::Client>().RemoveService(ins3));
+
+    nexus.AdvanceTime(10000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            if (StringMatch(service->GetInstanceName(), "ins1._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(!service->IsDeleted());
+            }
+            else
+            {
+                VerifyOrQuit(service->IsDeleted());
+            }
+        }
+        VerifyOrQuit(count == 3);
+    }
+
+    // Wait for longer than key-lease of `ins2` service and check that it is removed on server.
+    nexus.AdvanceTime(201000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            if (StringMatch(service->GetInstanceName(), "ins1._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(!service->IsDeleted());
+            }
+            else if (StringMatch(service->GetInstanceName(), "ins3._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->IsDeleted());
+            }
+            else
+            {
+                VerifyOrQuit(false);
+            }
+        }
+        VerifyOrQuit(count == 2);
+    }
+
+    // Add both services again now with same lease intervals.
+    ins2.mLease    = 30;
+    ins2.mKeyLease = 100;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins2));
+    ins3.mLease    = 30;
+    ins3.mKeyLease = 100;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins3));
+
+    nexus.AdvanceTime(10000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            VerifyOrQuit(!service->IsDeleted());
+        }
+        VerifyOrQuit(count == 3);
+    }
+
+    // Remove `ins1` while adding a new service with same key-lease as `ins1` but different lease interval.
+    SuccessOrQuit(client.Get<Srp::Client>().RemoveService(ins1));
+    Srp::Client::Service ins4;
+    ClearAllBytes(ins4);
+    ins4.mName         = "_test._udp";
+    ins4.mInstanceName = "ins4";
+    ins4.mPort         = 4444;
+    ins4.mLease        = 90;
+    ins4.mKeyLease     = 800;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins4));
+
+    nexus.AdvanceTime(5000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            if (StringMatch(service->GetInstanceName(), "ins4._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(!service->IsDeleted());
+                VerifyOrQuit(service->GetTtl() == 90);
+            }
+            else if (StringMatch(service->GetInstanceName(), "ins1._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->IsDeleted());
+            }
+            else
+            {
+                VerifyOrQuit(!service->IsDeleted());
+            }
+        }
+        VerifyOrQuit(count == 4);
+    }
+
+    // Remove `ins2` and `ins3`.
+    SuccessOrQuit(client.Get<Srp::Client>().RemoveService(ins2));
+    SuccessOrQuit(client.Get<Srp::Client>().RemoveService(ins3));
+
+    nexus.AdvanceTime(10000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            if (StringMatch(service->GetInstanceName(), "ins4._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(!service->IsDeleted());
+            }
+            else
+            {
+                VerifyOrQuit(service->IsDeleted());
+            }
+        }
+        VerifyOrQuit(count == 4);
+    }
+
+    // Add `ins1` with key-lease smaller than lease and check that client handles this properly
+    ins1.mLease    = 100;
+    ins1.mKeyLease = 90;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins1));
+
+    nexus.AdvanceTime(10000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            if (StringMatch(service->GetInstanceName(), "ins1._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(!service->IsDeleted());
+                Srp::Server::LeaseInfo leaseInfo;
+                service->GetLeaseInfo(leaseInfo);
+                VerifyOrQuit(leaseInfo.mLease == 100000);
+                VerifyOrQuit(leaseInfo.mKeyLease == 100000);
+            }
+        }
+        VerifyOrQuit(count == 4);
+    }
+
+    // Change default lease and key-lease intervals on client.
+    client.Get<Srp::Client>().SetLeaseInterval(40);
+    client.Get<Srp::Client>().SetKeyLeaseInterval(330);
+
+    // Add `ins2` and `ins3`. `ins2` specifies the key-lease explicitly but leaves lease as default. `ins3` does the
+    // opposite.
+    ClearAllBytes(ins2);
+    ins2.mName         = "_test._udp";
+    ins2.mInstanceName = "ins2";
+    ins2.mPort         = 2222;
+    ins2.mKeyLease     = 330;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins2));
+
+    ClearAllBytes(ins3);
+    ins3.mName         = "_test._udp";
+    ins3.mInstanceName = "ins3";
+    ins3.mPort         = 3333;
+    ins3.mLease        = 40;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(ins3));
+
+    nexus.AdvanceTime(10000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            Srp::Server::LeaseInfo leaseInfo;
+            service->GetLeaseInfo(leaseInfo);
+            if (StringMatch(service->GetInstanceName(), "ins2._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch) ||
+                StringMatch(service->GetInstanceName(), "ins3._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(leaseInfo.mLease == 40000);
+                VerifyOrQuit(leaseInfo.mKeyLease == 330000);
+            }
+        }
+        VerifyOrQuit(count == 4);
+    }
+
+    // Change the default lease to 50 and wait for long enough for `ins2` and `ins3` to do lease refresh. Validate that
+    // `ins2` now requests new default lease of 50 while `ins3` should stay as before.
+    client.Get<Srp::Client>().SetLeaseInterval(50);
+    nexus.AdvanceTime(45000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            Srp::Server::LeaseInfo leaseInfo;
+            service->GetLeaseInfo(leaseInfo);
+            if (StringMatch(service->GetInstanceName(), "ins2._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(leaseInfo.mLease == 50000);
+            }
+            else if (StringMatch(service->GetInstanceName(), "ins3._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(leaseInfo.mLease == 40000);
+            }
+        }
+        VerifyOrQuit(count == 4);
+    }
+
+    // Change the default key lease to 35. `ins3` should adopt this but since it is shorter than its explicitly
+    // specified lease the client should use same value for both lease and key-lease.
+    client.Get<Srp::Client>().SetKeyLeaseInterval(35);
+    nexus.AdvanceTime(45000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            Srp::Server::LeaseInfo leaseInfo;
+            service->GetLeaseInfo(leaseInfo);
+            if (StringMatch(service->GetInstanceName(), "ins3._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(leaseInfo.mLease == 40000);
+                VerifyOrQuit(leaseInfo.mKeyLease == 40000);
+            }
+        }
+        VerifyOrQuit(count == 4);
+    }
+
+    // Change the requested TTL.
+    client.Get<Srp::Client>().SetTtl(65);
+    nexus.AdvanceTime(110000);
+    {
+        const Srp::Server::Host    *host    = server.Get<Srp::Server>().GetNextHost(nullptr);
+        const Srp::Server::Service *service = nullptr;
+        uint16_t                    count   = 0;
+
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            count++;
+            if (StringMatch(service->GetInstanceName(), "ins1._test._udp.default.service.arpa.",
+                            kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->GetTtl() == 65);
+            }
+            else if (StringMatch(service->GetInstanceName(), "ins2._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->GetTtl() == 50);
+            }
+            else if (StringMatch(service->GetInstanceName(), "ins3._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->GetTtl() == 40);
+            }
+            else if (StringMatch(service->GetInstanceName(), "ins4._test._udp.default.service.arpa.",
+                                 kStringCaseInsensitiveMatch))
+            {
+                VerifyOrQuit(service->GetTtl() == 65);
+            }
+        }
+        VerifyOrQuit(count == 4);
+    }
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestSrpRegisterServicesDiffLease();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_scale.cpp b/tests/nexus/test_srp_scale.cpp
new file mode 100644
index 0000000..812fdec
--- /dev/null
+++ b/tests/nexus/test_srp_scale.cpp
@@ -0,0 +1,243 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+namespace {
+
+const uint16_t kNumSrpClients        = 25;
+const uint16_t kInstanceNumPerClient = 20;
+const uint16_t kServicePort          = 12345;
+const char    *kServiceName          = "_srpclient._tcp";
+
+struct ClientInfo
+{
+    Srp::Client::Service mServices[kInstanceNumPerClient];
+    String<32>           mInstanceNames[kInstanceNumPerClient];
+};
+
+static ClientInfo sClientInfo[kNumSrpClients + 1];
+
+/**
+ * This function registers SRP services from a given node.
+ *
+ * @param[in] aNode      The node from which to register services.
+ * @param[in] aClientId  The client ID to use for host and instance names.
+ *
+ */
+void RegisterServices(Node &aNode, uint16_t aClientId)
+{
+    String<32> hostName;
+
+    hostName.Append("client%u", aClientId);
+
+    aNode.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    SuccessOrQuit(aNode.Get<Srp::Client>().SetHostName(hostName.AsCString()));
+    SuccessOrQuit(aNode.Get<Srp::Client>().EnableAutoHostAddress());
+
+    for (uint16_t j = 1; j <= kInstanceNumPerClient; j++)
+    {
+        String<32> &instanceName = sClientInfo[aClientId].mInstanceNames[j - 1];
+
+        instanceName.Clear().Append("client%u_%u", aClientId, j);
+
+        Srp::Client::Service &service = sClientInfo[aClientId].mServices[j - 1];
+
+        // We use `memset` to zero-initialize the service object. This is
+        // safer than `ClearAllBytes` as it correctly handles the
+        // `LinkedListEntry` base class part.
+        memset(&service, 0, sizeof(service));
+
+        service.mName         = kServiceName;
+        service.mInstanceName = instanceName.AsCString();
+        service.mPort         = kServicePort;
+
+        SuccessOrQuit(service.Init());
+
+        Error error = aNode.Get<Srp::Client>().AddService(service);
+        if (error != kErrorNone)
+        {
+            Log("AddService failed with error %s (%d) for client %u, instance %u", ErrorToString(error), error,
+                aClientId, j);
+        }
+        SuccessOrQuit(error);
+
+        if (j % 10 == 0 || j == kInstanceNumPerClient)
+        {
+            Core::Get().AdvanceTime(5 * 1000);
+        }
+    }
+}
+
+/**
+ * This function verifies registered services on a given SRP client node.
+ *
+ * @param[in] aNode  The node to verify.
+ *
+ */
+void VerifyClient(Node &aNode)
+{
+    const Srp::Client::Service *service = aNode.Get<Srp::Client>().GetServices().GetHead();
+    uint16_t                    count   = 0;
+
+    while (service != nullptr)
+    {
+        VerifyOrQuit(service->GetState() == Srp::Client::kRegistered);
+        count++;
+        service = service->GetNext();
+    }
+
+    VerifyOrQuit(count == kInstanceNumPerClient);
+}
+
+} // namespace
+
+/**
+ * This test verifies SRP function that SRP clients can register up to 500 services to one SRP server.
+ *
+ * Topology:
+ *     LEADER (SRP server)
+ *       |
+ *       |
+ *     ROUTER1 ... ROUTER13 (SRP clients)
+ *       |            |
+ *      FED14  ...   FED25  (SRP clients)
+ *
+ */
+void TestSrpScale(void)
+{
+    const uint16_t kNumRouters = (kNumSrpClients / 2) + (kNumSrpClients % 2);
+    const uint16_t kNumFeds    = kNumSrpClients - kNumRouters;
+
+    Core  nexus;
+    Node &leader = nexus.CreateNode();
+    Node *routers[kNumRouters];
+    Node *feds[kNumFeds];
+
+    Log("Test SRP Scale: 500 services");
+
+    leader.Form();
+    nexus.AdvanceTime(15 * 1000);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    leader.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(1000);
+
+    for (uint16_t i = 0; i < kNumRouters; i++)
+    {
+        routers[i] = &nexus.CreateNode();
+        routers[i]->SetName("router", i + 1);
+        routers[i]->Get<Mle::Mle>().SetRouterSelectionJitter(1);
+        routers[i]->Join(leader);
+    }
+
+    // Wait for routers to become routers
+    nexus.AdvanceTime(20 * 1000);
+
+    for (uint16_t i = 0; i < kNumRouters; i++)
+    {
+        VerifyOrQuit(routers[i]->Get<Mle::Mle>().IsRouter());
+    }
+
+    for (uint16_t i = 0; i < kNumFeds; i++)
+    {
+        feds[i] = &nexus.CreateNode();
+        feds[i]->SetName("fed", i + kNumRouters + 1);
+        feds[i]->Join(*routers[i], Node::kAsFed);
+    }
+
+    nexus.AdvanceTime(10 * 1000);
+
+    for (uint16_t i = 0; i < kNumFeds; i++)
+    {
+        VerifyOrQuit(feds[i]->Get<Mle::Mle>().IsChild());
+    }
+
+    Log("Registering services from routers...");
+    for (uint16_t i = 0; i < kNumRouters; i++)
+    {
+        RegisterServices(*routers[i], i + 1);
+    }
+
+    Log("Registering services from FEDs...");
+    for (uint16_t i = 0; i < kNumFeds; i++)
+    {
+        RegisterServices(*feds[i], i + kNumRouters + 1);
+    }
+
+    // Give it some more time to settle
+    nexus.AdvanceTime(20 * 1000);
+
+    Log("Verifying registrations...");
+
+    for (uint16_t i = 0; i < kNumRouters; i++)
+    {
+        VerifyClient(*routers[i]);
+    }
+
+    for (uint16_t i = 0; i < kNumFeds; i++)
+    {
+        VerifyClient(*feds[i]);
+    }
+
+    uint32_t                 totalServices = 0;
+    const Srp::Server::Host *host          = nullptr;
+    while ((host = leader.Get<Srp::Server>().GetNextHost(host)) != nullptr)
+    {
+        const Srp::Server::Service *service = nullptr;
+        while ((service = host->GetNextService(service)) != nullptr)
+        {
+            if (!service->IsDeleted())
+            {
+                totalServices++;
+            }
+        }
+    }
+
+    Log("Total services registered on server: %u", totalServices);
+    VerifyOrQuit(totalServices == kNumSrpClients * kInstanceNumPerClient);
+
+    Log("Test passed!");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestSrpScale();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_server_anycast_mode.cpp b/tests/nexus/test_srp_server_anycast_mode.cpp
new file mode 100644
index 0000000..2ff32e2
--- /dev/null
+++ b/tests/nexus/test_srp_server_anycast_mode.cpp
@@ -0,0 +1,255 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join a network, in milliseconds.
+ */
+static constexpr uint32_t kJoinNetworkTime = 10 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 20 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 15 * 1000;
+
+/**
+ * Time to advance for DNS query processing, in milliseconds.
+ */
+static constexpr uint32_t kDnsQueryTime = 5 * 1000;
+
+/**
+ * SRP service registration parameters.
+ */
+static const char         kSrpServiceType[]     = "_srv._udp";
+static const char         kSrpInstanceName[]    = "ins1";
+static const char         kSrpHostName[]        = "host";
+static const char         kSrpFullServiceType[] = "_srv._udp.default.service.arpa.";
+static constexpr uint16_t kSrpServicePort       = 1313;
+
+static constexpr uint8_t  kSrpServerAnycastSeqNum = 17;
+static constexpr uint16_t kSrpServerAnycastPort   = 53;
+
+/**
+ * Test context for DNS browse resolution.
+ */
+struct BrowseContext
+{
+    Node *mClientNode;
+    bool  mDone;
+};
+
+static void HandleBrowseResponse(otError aError, const otDnsBrowseResponse *aResponse, void *aContext)
+{
+    BrowseContext                     *context  = static_cast<BrowseContext *>(aContext);
+    const Dns::Client::BrowseResponse &response = *static_cast<const Dns::Client::BrowseResponse *>(aResponse);
+    char                               label[Dns::Name::kMaxLabelSize];
+    Dns::Client::ServiceInfo           serviceInfo;
+    char                               hostName[Dns::Name::kMaxNameSize];
+
+    SuccessOrQuit(aError);
+
+    // Since there is only one match the server should include the service info in additional section.
+    SuccessOrQuit(response.GetServiceInstance(0, label, sizeof(label)));
+    VerifyOrQuit(StringMatch(label, kSrpInstanceName, kStringCaseInsensitiveMatch));
+
+    serviceInfo.mHostNameBuffer     = hostName;
+    serviceInfo.mHostNameBufferSize = sizeof(hostName);
+    serviceInfo.mTxtData            = nullptr;
+    serviceInfo.mTxtDataSize        = 0;
+
+    SuccessOrQuit(response.GetServiceInfo(label, serviceInfo));
+
+    VerifyOrQuit(serviceInfo.mPort == kSrpServicePort);
+    VerifyOrQuit(StringStartsWith(serviceInfo.mHostNameBuffer, kSrpHostName, kStringCaseInsensitiveMatch));
+    VerifyOrQuit(AsCoreType(&serviceInfo.mHostAddress) == context->mClientNode->Get<Mle::Mle>().GetMeshLocalEid());
+
+    context->mDone = true;
+}
+
+void TestSrpServerAnycastMode(const char *aJsonFileName)
+{
+    // This test covers the SRP server's behavior using different address
+    // modes (unicast or anycast). The address mode indicates how the SRP
+    // server determines its address and port and how this info is
+    // published in the Thread Network Data. When using anycast address
+    // mode, the SRP server and DNS server/resolver will both listen on port
+    // 53 and they re-use the same socket. This test verifies the behavior of
+    // both modules in such a situation.
+
+    Core nexus;
+
+    Node &server  = nexus.CreateNode();
+    Node &client  = nexus.CreateNode();
+    Node &browser = nexus.CreateNode();
+
+    server.SetName("SERVER");
+    client.SetName("CLIENT");
+    browser.SetName("BROWSER");
+
+    server.Form();
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Test SRP Server Anycast and Unicast modes");
+
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    client.Join(server);
+    browser.Join(server);
+    nexus.AdvanceTime(kJoinNetworkTime);
+
+    const Srp::Server::AddressMode kAddressModes[] = {Srp::Server::kAddressModeAnycast,
+                                                      Srp::Server::kAddressModeUnicast};
+
+    for (Srp::Server::AddressMode addrMode : kAddressModes)
+    {
+        Log("Address Mode: %s", (addrMode == Srp::Server::kAddressModeAnycast) ? "anycast" : "unicast");
+
+        // 1. Set the SRP server address mode and start the SRP server.
+        SuccessOrQuit(server.Get<Srp::Server>().SetAddressMode(addrMode));
+
+        if (addrMode == Srp::Server::kAddressModeAnycast)
+        {
+            SuccessOrQuit(server.Get<Srp::Server>().SetAnycastModeSequenceNumber(kSrpServerAnycastSeqNum));
+            VerifyOrQuit(server.Get<Srp::Server>().GetAnycastModeSequenceNumber() == kSrpServerAnycastSeqNum);
+        }
+
+        server.Get<Srp::Server>().SetEnabled(true);
+        nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+
+        // Verify that the SRP server information is correctly published in Network Data.
+        {
+            NetworkData::Service::Iterator iterator(client.Get<Instance>());
+
+            if (addrMode == Srp::Server::kAddressModeAnycast)
+            {
+                NetworkData::Service::DnsSrpAnycastInfo anycastInfo;
+
+                SuccessOrQuit(iterator.GetNextDnsSrpAnycastInfo(anycastInfo));
+                VerifyOrQuit(anycastInfo.mSequenceNumber == kSrpServerAnycastSeqNum);
+                VerifyOrQuit(iterator.GetNextDnsSrpAnycastInfo(anycastInfo) == kErrorNotFound);
+            }
+            else
+            {
+                NetworkData::Service::DnsSrpUnicastInfo unicastInfo;
+
+                SuccessOrQuit(iterator.GetNextDnsSrpUnicastInfo(NetworkData::Service::kAddrInServerData, unicastInfo));
+                VerifyOrQuit(unicastInfo.mSockAddr.GetAddress() == server.Get<Mle::Mle>().GetMeshLocalEid());
+                VerifyOrQuit(unicastInfo.mSockAddr.GetPort() == server.Get<Srp::Server>().GetPort());
+                VerifyOrQuit(iterator.GetNextDnsSrpUnicastInfo(NetworkData::Service::kAddrInServerData, unicastInfo) ==
+                             kErrorNotFound);
+            }
+        }
+
+        // 2. Enable auto-start on SRP client and browser.
+        client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        browser.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+        nexus.AdvanceTime(kSrpRegistrationTime);
+
+        // 3. Verify client finds the server and uses proper address/port.
+        if (addrMode == Srp::Server::kAddressModeAnycast)
+        {
+            // In anycast mode, the client should find the Anycast ALOC address and port 53.
+            VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == kSrpServerAnycastPort);
+            const Ip6::Address &serverAddr = client.Get<Srp::Client>().GetServerAddress().GetAddress();
+            VerifyOrQuit(serverAddr.GetIid().IsAnycastServiceLocator());
+        }
+        else
+        {
+            // In unicast mode, it uses ML-EID and a dynamic port.
+            VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() ==
+                         server.Get<Mle::Mle>().GetMeshLocalEid());
+            VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetPort() == server.Get<Srp::Server>().GetPort());
+        }
+
+        // 4. Add a service on SRP client and verify registration.
+        SuccessOrQuit(client.Get<Srp::Client>().SetHostName(kSrpHostName));
+        SuccessOrQuit(client.Get<Srp::Client>().EnableAutoHostAddress());
+
+        Srp::Client::Service service;
+        ClearAllBytes(service);
+        service.mName         = kSrpServiceType;
+        service.mInstanceName = kSrpInstanceName;
+        service.mPort         = kSrpServicePort;
+        SuccessOrQuit(client.Get<Srp::Client>().AddService(service));
+
+        nexus.AdvanceTime(kSrpRegistrationTime);
+
+        // Check if service is registered on client side.
+        VerifyOrQuit(service.GetState() == Srp::Client::kRegistered);
+
+        // 5. Browse for the service from the browser node and verify result.
+        BrowseContext context;
+        context.mClientNode = &client;
+        context.mDone       = false;
+
+        SuccessOrQuit(browser.Get<Dns::Client>().Browse(kSrpFullServiceType, HandleBrowseResponse, &context));
+        nexus.AdvanceTime(kDnsQueryTime);
+        VerifyOrQuit(context.mDone);
+
+        // 6. Clear host on client and stop both client and server.
+        client.Get<Srp::Client>().ClearHostAndServices();
+        client.Get<Srp::Client>().Stop();
+        server.Get<Srp::Server>().SetEnabled(false);
+        nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+    }
+
+    Log("All steps completed.");
+    nexus.SaveTestInfo(aJsonFileName);
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(int argc, char *argv[])
+{
+    ot::Nexus::TestSrpServerAnycastMode((argc > 2) ? argv[2] : "test_srp_server_anycast_mode.json");
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_server_reboot_port.cpp b/tests/nexus/test_srp_server_reboot_port.cpp
new file mode 100644
index 0000000..1434d3d
--- /dev/null
+++ b/tests/nexus/test_srp_server_reboot_port.cpp
@@ -0,0 +1,189 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for SRP server information to be updated in Network Data.
+ */
+static constexpr uint32_t kSrpServerInfoUpdateTime = 60 * 1000;
+
+/**
+ * Time to advance for SRP registration, in milliseconds.
+ */
+static constexpr uint32_t kSrpRegistrationTime = 60 * 1000;
+
+/**
+ * Number of reboot times.
+ */
+static constexpr uint16_t kRebootTimes = 25;
+
+void TestSrpServerRebootPort(void)
+{
+    Core nexus;
+
+    Node &client = nexus.CreateNode();
+    Node &server = nexus.CreateNode();
+
+    client.SetName("CLIENT");
+    server.SetName("SERVER");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    //
+    // 0. Start the server & client devices.
+    //
+
+    Log("0. Start the server & client devices.");
+    client.Get<Mle::Mle>().SetRouterUpgradeThreshold(2);
+    client.Get<Mle::Mle>().SetLeaderUpgradeThreshold(2);
+    server.Get<Mle::Mle>().SetRouterUpgradeThreshold(2);
+
+    client.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+
+    server.Join(client);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(server.Get<Mle::Mle>().IsRouter());
+
+    SuccessOrQuit(server.Get<Srp::Server>().SetAddressMode(Srp::Server::kAddressModeUnicast));
+    server.Get<Srp::Server>().SetEnabled(true);
+
+    // Wait for SRP server to be running (published in network data)
+    for (int i = 0; i < 10; i++)
+    {
+        nexus.AdvanceTime(10000);
+        if (server.Get<Srp::Server>().GetState() == Srp::Server::kStateRunning)
+        {
+            break;
+        }
+    }
+    VerifyOrQuit(server.Get<Srp::Server>().GetState() == Srp::Server::kStateRunning);
+
+    //
+    // 1. Check auto start mode on client and check that server is used.
+    //
+
+    Log("1. Check auto start mode on client and check that server is used.");
+    // We set a host name so the client has something to register and thus "runs".
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName("my-host"));
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    VerifyOrQuit(client.Get<Srp::Client>().GetServerAddress().GetAddress() == server.Get<Mle::Mle>().GetMeshLocalEid());
+
+    //
+    // 2. Reboot the server without any service registered. The server should
+    // switch to a new port after re-enabling.
+    //
+
+    Log("2. Reboot the server without any service registered.");
+    {
+        uint16_t oldPort = server.Get<Srp::Server>().GetPort();
+        server.Get<Srp::Server>().SetEnabled(false);
+        nexus.AdvanceTime(5000);
+        server.Get<Srp::Server>().SetEnabled(true);
+        nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+        VerifyOrQuit(server.Get<Srp::Server>().GetState() == Srp::Server::kStateRunning);
+        VerifyOrQuit(server.Get<Srp::Server>().GetPort() != oldPort);
+    }
+
+    //
+    // 3. Register a service
+    //
+
+    Log("3. Register a service");
+    static Srp::Client::Service service;
+    SuccessOrQuit(client.Get<Srp::Client>().EnableAutoHostAddress());
+
+    memset(&service, 0, sizeof(service));
+    service.mName         = "_ipps._tcp";
+    service.mInstanceName = "my-service";
+    service.mPort         = 12345;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(service));
+
+    nexus.AdvanceTime(kSrpRegistrationTime);
+
+    // Reboot the SRP server several times
+    for (uint16_t i = 0; i < kRebootTimes; i++)
+    {
+        Log("Reboot iteration %u", i);
+
+        //
+        // 4. Disable server and check client is stopped/disabled.
+        //
+        uint16_t oldPort = server.Get<Srp::Server>().GetPort();
+        server.Get<Srp::Server>().SetEnabled(false);
+        nexus.AdvanceTime(5000);
+
+        //
+        // 5. Enable server and check client starts again. Verify that the
+        // server is using a different port, and the service have been
+        // re-registered.
+        //
+        server.Get<Srp::Server>().SetEnabled(true);
+        nexus.AdvanceTime(kSrpServerInfoUpdateTime);
+        nexus.AdvanceTime(kSrpRegistrationTime);
+
+        VerifyOrQuit(client.Get<Srp::Client>().GetHostInfo().GetState() == Srp::Client::kRegistered);
+        VerifyOrQuit(server.Get<Srp::Server>().GetNextHost(nullptr)->GetServices().GetHead()->GetPort() == 12345);
+        VerifyOrQuit(server.Get<Srp::Server>().GetPort() != oldPort);
+    }
+
+    Log("All steps completed.");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestSrpServerRebootPort();
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_srp_ttl.cpp b/tests/nexus/test_srp_ttl.cpp
new file mode 100644
index 0000000..a1866c8
--- /dev/null
+++ b/tests/nexus/test_srp_ttl.cpp
@@ -0,0 +1,186 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+#include <string.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+static void CheckTtl(Core &aNexus, Node &aServer, uint32_t aExpectedTtl)
+{
+    const Srp::Server::Host *host = nullptr;
+
+    // We advance time enough to trigger a refresh from the client.
+    // The lease interval is at most 240 seconds in our test cases.
+    aNexus.AdvanceTime(300 * Time::kOneSecondInMsec);
+
+    while ((host = aServer.Get<Srp::Server>().GetNextHost(host)) != nullptr)
+    {
+        if (StringMatch(host->GetFullName(), "my-host.default.service.arpa.", kStringCaseInsensitiveMatch))
+        {
+            break;
+        }
+    }
+
+    VerifyOrQuit(host != nullptr);
+
+    const Srp::Server::Service *serverService = host->GetNextService(nullptr);
+
+    VerifyOrQuit(serverService != nullptr);
+    Log("Expected TTL: %u, Actual TTL: %u", aExpectedTtl, serverService->GetTtl());
+    VerifyOrQuit(serverService->GetTtl() == aExpectedTtl);
+}
+
+void TestSrpTtl(void)
+{
+    Core  nexus;
+    Node &server = nexus.CreateNode();
+    Node &client = nexus.CreateNode();
+
+    Log("----------------------------------------------------------------------------------------------------------");
+    Log("Testing SRP TTL");
+
+    server.Form();
+    nexus.AdvanceTime(10 * Time::kOneSecondInMsec);
+    VerifyOrQuit(server.Get<Mle::Mle>().IsLeader());
+
+    server.Get<Srp::Server>().SetEnabled(true);
+    nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
+
+    client.Join(server, Node::kAsFed);
+    nexus.AdvanceTime(10 * Time::kOneSecondInMsec);
+    VerifyOrQuit(client.Get<Mle::Mle>().IsChild());
+
+    client.Get<Srp::Client>().EnableAutoStartMode(nullptr, nullptr);
+    nexus.AdvanceTime(1 * Time::kOneSecondInMsec);
+
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostName("my-host"));
+
+    Ip6::Address address;
+    SuccessOrQuit(address.FromString("2001::1"));
+    SuccessOrQuit(client.Get<Srp::Client>().SetHostAddresses(&address, 1));
+
+    Srp::Client::Service service;
+    ClearAllBytes(service);
+    service.mName         = "_ipps._tcp";
+    service.mInstanceName = "my-service";
+    service.mPort         = 12345;
+    SuccessOrQuit(client.Get<Srp::Client>().AddService(service));
+
+    // Case 1: CLIENT_TTL < TTL_MIN < LEASE_MAX ==> TTL_MIN
+    Log("Case 1: CLIENT_TTL < TTL_MIN < LEASE_MAX ==> TTL_MIN");
+    {
+        Srp::Server::TtlConfig ttlConfig;
+        ttlConfig.mMinTtl = 120;
+        ttlConfig.mMaxTtl = 240;
+        SuccessOrQuit(server.Get<Srp::Server>().SetTtlConfig(ttlConfig));
+
+        Srp::Server::LeaseConfig leaseConfig;
+        leaseConfig.mMinLease    = 120;
+        leaseConfig.mMaxLease    = 240;
+        leaseConfig.mMinKeyLease = 240;
+        leaseConfig.mMaxKeyLease = 240;
+        SuccessOrQuit(server.Get<Srp::Server>().SetLeaseConfig(leaseConfig));
+
+        client.Get<Srp::Client>().SetTtl(100);
+        CheckTtl(nexus, server, 120);
+    }
+
+    // Case 2: TTL_MIN < CLIENT_TTL < TTL_MAX < LEASE_MAX ==> CLIENT_TTL
+    Log("Case 2: TTL_MIN < CLIENT_TTL < TTL_MAX < LEASE_MAX ==> CLIENT_TTL");
+    {
+        Srp::Server::TtlConfig ttlConfig;
+        ttlConfig.mMinTtl = 60;
+        ttlConfig.mMaxTtl = 120;
+        SuccessOrQuit(server.Get<Srp::Server>().SetTtlConfig(ttlConfig));
+
+        Srp::Server::LeaseConfig leaseConfig;
+        leaseConfig.mMinLease    = 120;
+        leaseConfig.mMaxLease    = 240;
+        leaseConfig.mMinKeyLease = 240;
+        leaseConfig.mMaxKeyLease = 240;
+        SuccessOrQuit(server.Get<Srp::Server>().SetLeaseConfig(leaseConfig));
+
+        client.Get<Srp::Client>().SetTtl(100);
+        CheckTtl(nexus, server, 100);
+    }
+
+    // Case 3: TTL_MAX < LEASE_MAX < CLIENT_TTL ==> TTL_MAX
+    Log("Case 3: TTL_MAX < LEASE_MAX < CLIENT_TTL ==> TTL_MAX");
+    {
+        Srp::Server::TtlConfig ttlConfig;
+        ttlConfig.mMinTtl = 60;
+        ttlConfig.mMaxTtl = 120;
+        SuccessOrQuit(server.Get<Srp::Server>().SetTtlConfig(ttlConfig));
+
+        Srp::Server::LeaseConfig leaseConfig;
+        leaseConfig.mMinLease    = 120;
+        leaseConfig.mMaxLease    = 240;
+        leaseConfig.mMinKeyLease = 240;
+        leaseConfig.mMaxKeyLease = 240;
+        SuccessOrQuit(server.Get<Srp::Server>().SetLeaseConfig(leaseConfig));
+
+        client.Get<Srp::Client>().SetTtl(240);
+        CheckTtl(nexus, server, 120);
+    }
+
+    // Case 4: LEASE_MAX < TTL_MAX < CLIENT_TTL ==> LEASE_MAX
+    Log("Case 4: LEASE_MAX < TTL_MAX < CLIENT_TTL ==> LEASE_MAX");
+    {
+        Srp::Server::TtlConfig ttlConfig;
+        ttlConfig.mMinTtl = 60;
+        ttlConfig.mMaxTtl = 120;
+        SuccessOrQuit(server.Get<Srp::Server>().SetTtlConfig(ttlConfig));
+
+        Srp::Server::LeaseConfig leaseConfig;
+        leaseConfig.mMinLease    = 30;
+        leaseConfig.mMaxLease    = 60;
+        leaseConfig.mMinKeyLease = 240;
+        leaseConfig.mMaxKeyLease = 240;
+        SuccessOrQuit(server.Get<Srp::Server>().SetLeaseConfig(leaseConfig));
+
+        client.Get<Srp::Client>().SetTtl(240);
+        CheckTtl(nexus, server, 60);
+    }
+
+    Log("Test passed successfully");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestSrpTtl();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_tmf_origin.cpp b/tests/nexus/test_tmf_origin.cpp
new file mode 100644
index 0000000..3392eba
--- /dev/null
+++ b/tests/nexus/test_tmf_origin.cpp
@@ -0,0 +1,178 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+
+namespace ot {
+namespace Nexus {
+
+void TestTmfOrigin(void)
+{
+    Core  nexus;
+    Node &node = nexus.CreateNode();
+
+    node.Form();
+    nexus.AdvanceTime(10 * 1000);
+    VerifyOrQuit(node.Get<Mle::Mle>().IsLeader());
+
+    Ip6::Address mlEid     = node.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address linkLocal = node.Get<Mle::Mle>().GetLinkLocalAddress();
+
+    // Allocate and construct the message:
+    // Outer IPv6 Header + Inner IPv6 Header + Inner UDP Header
+    Message *message = node.Get<MessagePool>().Allocate(Message::kTypeIp6);
+    VerifyOrQuit(message != nullptr);
+
+    // 1. Inner UDP Header
+    Ip6::UdpHeader udpHeader;
+    udpHeader.Clear();
+    udpHeader.SetSourcePort(1234);
+    udpHeader.SetDestinationPort(Tmf::kUdpPort);
+    udpHeader.SetLength(sizeof(Ip6::UdpHeader));
+
+    // 2. Inner IPv6 Header
+    Ip6::Header innerHeader;
+    innerHeader.Clear();
+    innerHeader.InitVersionTrafficClassFlow();
+    innerHeader.SetSource(Ip6::Address::GetLinkLocalAllNodesMulticast()); // dummy LL source
+    innerHeader.SetDestination(linkLocal);
+    innerHeader.SetNextHeader(Ip6::kProtoUdp);
+    innerHeader.SetPayloadLength(sizeof(Ip6::UdpHeader));
+
+    // 3. Outer IPv6 Header (IP-in-IP)
+    Ip6::Header outerHeader;
+    outerHeader.Clear();
+    outerHeader.InitVersionTrafficClassFlow();
+    outerHeader.SetSource(linkLocal);
+    outerHeader.SetDestination(mlEid);
+    outerHeader.SetNextHeader(Ip6::kProtoIp6);
+    outerHeader.SetPayloadLength(sizeof(Ip6::Header) + sizeof(Ip6::UdpHeader));
+
+    // Append all headers to the message
+    SuccessOrQuit(message->Append(outerHeader));
+    SuccessOrQuit(message->Append(innerHeader));
+    SuccessOrQuit(message->Append(udpHeader));
+
+    // Mark the message origin as Host Untrusted
+    message->SetOrigin(Message::kOriginHostUntrusted);
+
+    // Call SendRaw, which will trigger HandleDatagram
+    Error error = node.Get<Ip6::Ip6>().SendRaw(OwnedPtr<Message>(message));
+
+    Log("SendRaw returned error: %s", ErrorToString(error));
+
+    // Verify that SendRaw returned kErrorDrop
+    VerifyOrQuit(error == kErrorDrop);
+
+    Log("Verified that untrusted IP-in-IP packet was dropped successfully.");
+}
+
+void TestTmfOriginBypassed(void)
+{
+    Core  nexus;
+    Node &node = nexus.CreateNode();
+
+    node.Form();
+    nexus.AdvanceTime(10 * 1000);
+    VerifyOrQuit(node.Get<Mle::Mle>().IsLeader());
+
+    Ip6::Address mlEid     = node.Get<Mle::Mle>().GetMeshLocalEid();
+    Ip6::Address linkLocal = node.Get<Mle::Mle>().GetLinkLocalAddress();
+
+    // Allocate and construct the message:
+    // Outer IPv6 Header + Destination Options Header + Inner IPv6 Header + Inner UDP Header
+    Message *message = node.Get<MessagePool>().Allocate(Message::kTypeIp6);
+    VerifyOrQuit(message != nullptr);
+
+    // 1. Inner UDP Header
+    Ip6::UdpHeader udpHeader;
+    udpHeader.Clear();
+    udpHeader.SetSourcePort(1234);
+    udpHeader.SetDestinationPort(Tmf::kUdpPort);
+    udpHeader.SetLength(sizeof(Ip6::UdpHeader));
+
+    // 2. Inner IPv6 Header
+    Ip6::Header innerHeader;
+    innerHeader.Clear();
+    innerHeader.InitVersionTrafficClassFlow();
+    innerHeader.SetSource(Ip6::Address::GetLinkLocalAllNodesMulticast()); // dummy LL source
+    innerHeader.SetDestination(linkLocal);
+    innerHeader.SetNextHeader(Ip6::kProtoUdp);
+    innerHeader.SetPayloadLength(sizeof(Ip6::UdpHeader));
+
+    // 3. Destination Options Header (containing 6 bytes of padding to make it 8 bytes total)
+    Ip6::ExtensionHeader dstHeader;
+    dstHeader.SetNextHeader(Ip6::kProtoIp6);
+    dstHeader.SetLength(0);
+
+    Ip6::PadOption padOption;
+    padOption.InitForPadSize(6);
+
+    // 4. Outer IPv6 Header (IP-in-IP)
+    Ip6::Header outerHeader;
+    outerHeader.Clear();
+    outerHeader.InitVersionTrafficClassFlow();
+    outerHeader.SetSource(linkLocal);
+    outerHeader.SetDestination(mlEid);
+    outerHeader.SetNextHeader(Ip6::kProtoDstOpts);
+    outerHeader.SetPayloadLength(dstHeader.GetSize() + sizeof(Ip6::Header) + sizeof(Ip6::UdpHeader));
+
+    // Append all headers to the message
+    SuccessOrQuit(message->Append(outerHeader));
+    SuccessOrQuit(message->Append(dstHeader));
+    SuccessOrQuit(message->AppendBytes(&padOption, padOption.GetSize()));
+    SuccessOrQuit(message->Append(innerHeader));
+    SuccessOrQuit(message->Append(udpHeader));
+
+    // Mark the message origin as Host Untrusted
+    message->SetOrigin(Message::kOriginHostUntrusted);
+
+    // Call SendRaw, which will trigger HandleDatagram
+    Error error = node.Get<Ip6::Ip6>().SendRaw(OwnedPtr<Message>(message));
+
+    Log("SendRaw bypassed returned error: %s", ErrorToString(error));
+
+    // Verify that SendRaw returned kErrorDrop due to the traversal detection
+    VerifyOrQuit(error == kErrorDrop);
+
+    Log("Verified that untrusted bypassed IP-in-IP packet was dropped successfully.");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestTmfOrigin();
+    ot::Nexus::TestTmfOriginBypassed();
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/nexus/test_trel.cpp b/tests/nexus/test_trel.cpp
index 0046d08..8b45539 100644
--- a/tests/nexus/test_trel.cpp
+++ b/tests/nexus/test_trel.cpp
@@ -63,9 +63,10 @@
 
     nexus.AdvanceTime(0);
 
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelWarn));
+
     for (Node &node : nexus.GetNodes())
     {
-        node.GetInstance().SetLogLevel(kLogLevelWarn);
         SuccessOrQuit(node.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
     }
 
@@ -179,7 +180,7 @@
 
     nexus.AdvanceTime(0);
 
-    node.GetInstance().SetLogLevel(kLogLevelWarn);
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelWarn));
     SuccessOrQuit(node.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -243,9 +244,10 @@
 
     nexus.AdvanceTime(0);
 
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelWarn));
+
     for (Node &node : nexus.GetNodes())
     {
-        node.GetInstance().SetLogLevel(kLogLevelWarn);
         VerifyOrQuit(!node.Get<Dns::Multicast::Core>().IsEnabled());
     }
 
@@ -441,9 +443,10 @@
 
     nexus.AdvanceTime(0);
 
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelWarn));
+
     for (Node &node : nexus.GetNodes())
     {
-        node.GetInstance().SetLogLevel(kLogLevelWarn);
         VerifyOrQuit(!node.Get<Dns::Multicast::Core>().IsEnabled());
     }
 
@@ -525,9 +528,10 @@
 
     nexus.AdvanceTime(0);
 
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelWarn));
+
     for (Node &node : nexus.GetNodes())
     {
-        node.GetInstance().SetLogLevel(kLogLevelWarn);
         VerifyOrQuit(!node.Get<Dns::Multicast::Core>().IsEnabled());
     }
 
@@ -659,9 +663,10 @@
 
     nexus.AdvanceTime(0);
 
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelInfo));
+
     for (Node &nodeEntry : nexus.GetNodes())
     {
-        nodeEntry.GetInstance().SetLogLevel(kLogLevelInfo);
         VerifyOrQuit(!nodeEntry.Get<Dns::Multicast::Core>().IsEnabled());
     }
 
diff --git a/tests/nexus/test_wasm_bindings.mjs b/tests/nexus/test_wasm_bindings.mjs
new file mode 100644
index 0000000..916bed2
--- /dev/null
+++ b/tests/nexus/test_wasm_bindings.mjs
@@ -0,0 +1,127 @@
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+async function createTopology(Nexus) {
+    console.log("Creating Topology in JavaScript...");
+
+    const leaderId = Nexus.createNode(350, 100);
+    const fedId = Nexus.createNode(100, 500);
+    const sedId = Nexus.createNode(350, 500);
+    const medId = Nexus.createNode(600, 500);
+    const router1Id = Nexus.createNode(200, 300);
+    const router2Id = Nexus.createNode(500, 300);
+
+    Nexus.formNetwork(leaderId);
+
+    // Use the exported JoinMode enum
+    Nexus.joinNetwork(fedId, leaderId, Nexus.JoinMode.AsFed);
+    Nexus.joinNetwork(sedId, leaderId, Nexus.JoinMode.AsSed);
+    Nexus.joinNetwork(medId, leaderId, Nexus.JoinMode.AsMed);
+    Nexus.joinNetwork(router1Id, leaderId, Nexus.JoinMode.AsFtd);
+    Nexus.joinNetwork(router2Id, leaderId, Nexus.JoinMode.AsFtd);
+
+    return { leaderId, fedId, sedId, medId, router1Id, router2Id };
+}
+
+async function runTest(modulePath) {
+    const absolutePath = path.resolve(modulePath);
+    console.log(`Loading Nexus WASM module from: ${absolutePath}`);
+
+    // Load the Emscripten-generated modularized ES6 module
+    const { default: createNexusModule } = await import('file://' + absolutePath);
+
+    // Initialize the module
+    const Nexus = await createNexusModule();
+
+    await createTopology(Nexus);
+
+    console.log("Initial simulation time:", Nexus.getNow());
+
+    console.log("Stepping Simulation...");
+    let nodeStateChangedCount = 0;
+    let linkUpdateCount = 0;
+    let packetEventCount = 0;
+
+    // Advance simulation and check for events
+    for (let i = 0; i < 50; i++) {
+        Nexus.stepSimulation(100);
+        let event;
+        while ((event = Nexus.pollEvent()) !== null) {
+            const type = event.type;
+            const data = event.data;
+
+            if (type === "node_state_changed") {
+                nodeStateChangedCount++;
+                if (nodeStateChangedCount < 10) {
+                    console.log(`[Event] Node ${data.id} state: ${data.role} at (${data.x}, ${data.y}) RLOC16: ${data.rloc16} ExtAddr: ${data.extAddress}`);
+                }
+                if (!data.extAddress) {
+                    throw new Error(`Node ${data.id} state change event missing extAddress`);
+                }
+            } else if (type === "link_update") {
+                linkUpdateCount++;
+            } else if (type === "packet_event") {
+                packetEventCount++;
+                if (packetEventCount < 5) {
+                    console.log(`[Event] Packet from ${data.srcId} to ${data.dstId} (length: ${data.length}) frame: ${data.frame.length} bytes`);
+                }
+                if (!data.frame || data.frame.length !== data.length) {
+                    throw new Error(`Packet event from ${data.srcId} to ${data.dstId} has invalid frame data`);
+                }
+            }
+        }
+    }
+
+    console.log(`Received ${nodeStateChangedCount} node_state_changed events`);
+    console.log(`Received ${linkUpdateCount} link_update events`);
+    console.log(`Received ${packetEventCount} packet_event events`);
+
+    if (nodeStateChangedCount === 0) {
+        throw new Error("Expected node_state_changed events were not received");
+    }
+
+    // Verify getNodeId
+    let event;
+    // We need to find an event that has extAddress to test getNodeId
+    // Since we've already polled all events, we should have some node state changed events.
+    // Let's assume the first few events we printed have what we need, or just use the last polled data if available.
+    // Actually, let's just create a new node and test it.
+
+    console.log("Creating a manual node...");
+    const nodeId = Nexus.createNode(100, 200);
+    console.log("Created node ID:", nodeId);
+
+    // Step to trigger state change event to get its extAddress
+    Nexus.stepSimulation(1);
+    let foundExtAddr = null;
+    while ((event = Nexus.pollEvent()) !== null) {
+        if (event.type === "node_state_changed" && event.data.id === nodeId) {
+            foundExtAddr = event.data.extAddress;
+        }
+    }
+
+    if (foundExtAddr) {
+        const resolvedId = Nexus.getNodeId(foundExtAddr);
+        console.log(`Resolved ExtAddr ${foundExtAddr} to ID: ${resolvedId}`);
+        if (resolvedId !== nodeId) {
+            throw new Error(`getNodeId failed: expected ${nodeId}, got ${resolvedId}`);
+        }
+    }
+
+    Nexus.setNodePosition(nodeId, 150, 250);
+    Nexus.setNodeEnabled(nodeId, false);
+    Nexus.setNodeEnabled(nodeId, true);
+
+    console.log("Test completed successfully!");
+}
+
+const modulePath = process.argv[2];
+if (!modulePath) {
+    console.error("Usage: node test_wasm_bindings.mjs <path-to-nexus_live_demo.js>");
+    process.exit(1);
+}
+
+runTest(modulePath).catch(err => {
+    console.error("Test failed:", err);
+    process.exit(1);
+});
diff --git a/tests/nexus/test_zero_len_external_route.cpp b/tests/nexus/test_zero_len_external_route.cpp
new file mode 100644
index 0000000..6d10980
--- /dev/null
+++ b/tests/nexus/test_zero_len_external_route.cpp
@@ -0,0 +1,299 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <stdio.h>
+
+#include "platform/nexus_core.hpp"
+#include "platform/nexus_node.hpp"
+#include "thread/network_data_local.hpp"
+#include "thread/network_data_notifier.hpp"
+
+namespace ot {
+namespace Nexus {
+
+/**
+ * Time to advance for a node to form a network and become leader, in milliseconds.
+ */
+static constexpr uint32_t kFormNetworkTime = 13 * 1000;
+
+/**
+ * Time to advance for a node to join as a child and upgrade to a router, in milliseconds.
+ */
+static constexpr uint32_t kAttachToRouterTime = 200 * 1000;
+
+/**
+ * Time to advance for the network to stabilize after nodes have attached.
+ */
+static constexpr uint32_t kStabilizationTime = 10 * 1000;
+
+/**
+ * Time to wait for ICMPv6 Echo response.
+ */
+static constexpr uint32_t kEchoResponseWaitTime = 5 * 1000;
+
+/**
+ * ICMPv6 Echo Request payload size, in bytes.
+ */
+static constexpr uint16_t kEchoPayloadSize = 0;
+
+/**
+ * Add an on-mesh prefix to a node.
+ */
+static void AddPrefix(Node &aNode, const char *aPrefixString, bool aDefaultRoute)
+{
+    NetworkData::OnMeshPrefixConfig config;
+
+    config.Clear();
+    SuccessOrQuit(config.GetPrefix().FromString(aPrefixString));
+    config.mOnMesh       = true;
+    config.mStable       = true;
+    config.mPreferred    = true;
+    config.mSlaac        = true;
+    config.mDefaultRoute = aDefaultRoute;
+
+    SuccessOrQuit(aNode.Get<NetworkData::Local>().AddOnMeshPrefix(config));
+    aNode.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+}
+
+/**
+ * Remove an on-mesh prefix from a node.
+ */
+static void RemovePrefix(Node &aNode, const char *aPrefixString)
+{
+    Ip6::Prefix prefix;
+    SuccessOrQuit(prefix.FromString(aPrefixString));
+    SuccessOrQuit(aNode.Get<NetworkData::Local>().RemoveOnMeshPrefix(prefix));
+    aNode.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+}
+
+/**
+ * Add an external route to a node.
+ */
+static void AddExternalRoute(Node &aNode, const char *aPrefixString)
+{
+    NetworkData::ExternalRouteConfig config;
+
+    config.Clear();
+    SuccessOrQuit(config.GetPrefix().FromString(aPrefixString));
+    config.mStable     = true;
+    config.mPreference = NetworkData::kRoutePreferenceMedium;
+
+    SuccessOrQuit(aNode.Get<NetworkData::Local>().AddHasRoutePrefix(config));
+    aNode.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+}
+
+/**
+ * Remove an external route from a node.
+ */
+static void RemoveExternalRoute(Node &aNode, const char *aPrefixString)
+{
+    Ip6::Prefix prefix;
+    SuccessOrQuit(prefix.FromString(aPrefixString));
+    SuccessOrQuit(aNode.Get<NetworkData::Local>().RemoveHasRoutePrefix(prefix));
+    aNode.Get<NetworkData::Notifier>().HandleServerDataUpdated();
+}
+
+/**
+ * Add a manual IPv6 address to a node.
+ */
+static void AddIpAddress(Node &aNode, otNetifAddress &aAddress, const char *aAddressString)
+{
+    ClearAllBytes(aAddress);
+    SuccessOrQuit(AsCoreType(&aAddress.mAddress).FromString(aAddressString));
+    aAddress.mPrefixLength  = 64;
+    aAddress.mAddressOrigin = OT_ADDRESS_ORIGIN_MANUAL;
+    aAddress.mPreferred     = true;
+    aAddress.mValid         = true;
+
+    SuccessOrQuit(otIp6AddUnicastAddress(&aNode.GetInstance(), &aAddress));
+}
+
+/**
+ * Remove a manual IPv6 address from a node.
+ */
+static void RemoveIpAddress(Node &aNode, const char *aAddressString)
+{
+    Ip6::Address address;
+    SuccessOrQuit(address.FromString(aAddressString));
+    SuccessOrQuit(otIp6RemoveUnicastAddress(&aNode.GetInstance(), &address));
+}
+
+struct IcmpResponseContext
+{
+    IcmpResponseContext(uint16_t aIdentifier)
+        : mIdentifier(aIdentifier)
+        , mResponseReceived(false)
+    {
+    }
+
+    uint16_t mIdentifier;
+    bool     mResponseReceived;
+};
+
+static void HandleIcmpResponse(void *aContext, otMessage *, const otMessageInfo *, const otIcmp6Header *aIcmpHeader)
+{
+    IcmpResponseContext    *context = static_cast<IcmpResponseContext *>(aContext);
+    const Ip6::Icmp6Header *header  = AsCoreTypePtr(aIcmpHeader);
+
+    if ((header->GetType() == Ip6::Icmp6Header::kTypeEchoReply) && (header->GetId() == context->mIdentifier))
+    {
+        context->mResponseReceived = true;
+    }
+}
+
+/**
+ * Send an ICMPv6 Echo Request and verify that NO response is received.
+ */
+static void SendAndVerifyNoEchoResponse(Core               &aNexus,
+                                        Node               &aSender,
+                                        const Ip6::Address &aDestination,
+                                        uint32_t            aWaitTime)
+{
+    static constexpr uint16_t kIdentifier = 0xabcd;
+
+    IcmpResponseContext icmpContext(kIdentifier);
+    Ip6::Icmp::Handler  icmpHandler(HandleIcmpResponse, &icmpContext);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().RegisterHandler(icmpHandler));
+
+    aSender.SendEchoRequest(aDestination, kIdentifier);
+    aNexus.AdvanceTime(aWaitTime);
+
+    SuccessOrQuit(aSender.Get<Ip6::Icmp>().UnregisterHandler(icmpHandler));
+
+    VerifyOrQuit(!icmpContext.mResponseReceived, "Received unexpected Echo Reply");
+}
+
+void TestZeroLenExternalRoute(void)
+{
+    Core           nexus;
+    otNetifAddress router1Addr;
+    otNetifAddress router2Addr;
+
+    Node &leader  = nexus.CreateNode();
+    Node &router1 = nexus.CreateNode();
+    Node &router2 = nexus.CreateNode();
+
+    leader.SetName("LEADER");
+    router1.SetName("ROUTER_1");
+    router2.SetName("ROUTER_2");
+
+    nexus.AdvanceTime(0);
+
+    SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 1: Form network and join routers");
+
+    leader.Form();
+    nexus.AdvanceTime(kFormNetworkTime);
+    VerifyOrQuit(leader.Get<Mle::Mle>().IsLeader());
+
+    router1.Join(leader);
+    router2.Join(leader);
+    nexus.AdvanceTime(kAttachToRouterTime);
+    VerifyOrQuit(router1.Get<Mle::Mle>().IsRouter());
+    VerifyOrQuit(router2.Get<Mle::Mle>().IsRouter());
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 2: Add prefix on ROUTER_2 and external route and address on ROUTER_1");
+
+    // Add an on-mesh prefix with SLAAC on router2 (without default route flag).
+    AddPrefix(router2, "fd00:1234::/64", false);
+
+    // Add a zero length external route on router1. Also add an IPv6
+    // address on router1.
+    AddExternalRoute(router1, "::/0");
+    AddIpAddress(router1, router1Addr, "fd00:abcd::1");
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 3: Ping from LEADER to the address on ROUTER_1");
+
+    // Ping from leader the address added on router1. The zero
+    // length external route should ensure the message is routed
+    // to router1.
+    Ip6::Address destAddress;
+    SuccessOrQuit(destAddress.FromString("fd00:abcd::1"));
+    nexus.SendAndVerifyEchoRequest(leader, destAddress, kEchoPayloadSize, Ip6::kDefaultHopLimit, kEchoResponseWaitTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 4: Change prefix on ROUTER_2 to have default route flag");
+
+    // Change the on-mesh prefix on router2 to now also have
+    // the default route flag.
+    RemovePrefix(router2, "fd00:1234::/64");
+    AddPrefix(router2, "fd00:5678::/64", true);
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 5: Ping again from LEADER. External route ::/0 should still be preferred");
+
+    // Again ping from leader the same address. The explicit
+    // external route (even with zero length) on router1 should
+    // still be preferred over the default route flag of the
+    // on-mesh prefix from router2.
+    nexus.SendAndVerifyEchoRequest(leader, destAddress, kEchoPayloadSize, Ip6::kDefaultHopLimit, kEchoResponseWaitTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 6: Remove external route on ROUTER_1 and verify ping failure");
+
+    // Remove the external route on router1.
+    RemoveExternalRoute(router1, "::/0");
+
+    nexus.AdvanceTime(kStabilizationTime);
+
+    // Now the ping should fail since the message would be routed
+    // to router2 (due to its on-mesh prefix with default route flag).
+    SendAndVerifyNoEchoResponse(nexus, leader, destAddress, kEchoResponseWaitTime);
+
+    Log("---------------------------------------------------------------------------------------");
+    Log("Step 7: Move address to ROUTER_2 and verify ping success");
+
+    // Remove the address from router1 and add it on router2 and
+    // ping it again from leader to verify that the message is
+    // being routed to router2.
+    RemoveIpAddress(router1, "fd00:abcd::1");
+    AddIpAddress(router2, router2Addr, "fd00:abcd::1");
+
+    nexus.AdvanceTime(kStabilizationTime);
+    nexus.SendAndVerifyEchoRequest(leader, destAddress, kEchoPayloadSize, Ip6::kDefaultHopLimit, kEchoResponseWaitTime);
+
+    printf("All tests passed\n");
+}
+
+} // namespace Nexus
+} // namespace ot
+
+int main(void)
+{
+    ot::Nexus::TestZeroLenExternalRoute();
+    return 0;
+}
diff --git a/tests/nexus/verify_1_1_5_3_6.py b/tests/nexus/verify_1_1_5_3_6.py
index ef9d15c..dfb2fc6 100644
--- a/tests/nexus/verify_1_1_5_3_6.py
+++ b/tests/nexus/verify_1_1_5_3_6.py
@@ -66,17 +66,26 @@
     #   - Description: Ensure topology is formed correctly.
     #   - Pass Criteria: N/A
     print("Step 1: All")
-    pkts.filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
+    leader_pkt = pkts.filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
         filter_wpan_src64(LEADER).\
         must_next()
+    leader_child_id = leader_pkt.mle.tlv.source_addr & 0x1FF
+    # The leader should only advertise as a router
+    assert leader_child_id == 0
     router1_pkt = pkts.filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
         filter_wpan_src64(ROUTER_1).\
         must_next()
     router1_id = (router1_pkt.mle.tlv.source_addr >> 10)
+    router1_child_id = router1_pkt.mle.tlv.source_addr & 0x1FF
+    # Router 1 should only advertise as a router
+    assert router1_child_id == 0
     router2_pkt = pkts.filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
         filter_wpan_src64(ROUTER_2).\
         must_next()
     router2_id = (router2_pkt.mle.tlv.source_addr >> 10)
+    router2_child_id = router2_pkt.mle.tlv.source_addr & 0x1FF
+    # Router 2 should only advertise as a router
+    assert router2_child_id == 0
 
     # Step 2: Router_2
     #
@@ -111,13 +120,16 @@
     #   - Description: Harness re-enables the device and waits for it to reattach and upgrade to a
     #     router.
     #   - Pass Criteria:
-    #     - The DUT MUST reset the MLE Advertisement trickle timer and send an Advertisement.
+    #     - The DUT MUST reset the MLE Advertisement trickle timer and send a Router Advertisement.
     print("Step 5: Router_2")
     # Verify Router 2 becomes router again (sends advertisement) and get its new ID
     router2_rejoin_pkt = pkts.filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
         filter_wpan_src64(ROUTER_2).\
         must_next()
     router2_id_reattached = (router2_rejoin_pkt.mle.tlv.source_addr >> 10)
+    router2_child_id_reattached = router2_rejoin_pkt.mle.tlv.source_addr & 0x1FF
+    # Router 2 should only advertise as a router
+    assert router2_child_id_reattached == 0
 
     # Verify Leader sends advertisement after Router 2 re-joins
     pkts.filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
diff --git a/tests/nexus/verify_1_1_5_6_9.py b/tests/nexus/verify_1_1_5_6_9.py
index fe07a90..0d370b2 100644
--- a/tests/nexus/verify_1_1_5_6_9.py
+++ b/tests/nexus/verify_1_1_5_6_9.py
@@ -199,7 +199,7 @@
         must_next()
 
     # Step 10: Router_1 (DUT)
-    # - Description: Depending on the the DUT’s implementation, automatically sends new stable network data to SED_1
+    # - Description: Depending on the DUT’s implementation, automatically sends new stable network data to SED_1
     #   via a unicast MLE Child Update Request or MLE Data Response.
     # - Pass Criteria: The DUT MUST send EITHER a unicast MLE Child Update Request OR a unicast MLE Data Response to
     #   SED_1, which includes the following TLVs:
diff --git a/tests/nexus/verify_1_1_5_8_4.py b/tests/nexus/verify_1_1_5_8_4.py
index 219d294..645eb5f 100644
--- a/tests/nexus/verify_1_1_5_8_4.py
+++ b/tests/nexus/verify_1_1_5_8_4.py
@@ -98,9 +98,6 @@
         must_next()
     pkts.filter_coap_ack(consts.MGMT_ACTIVE_SET_URI).must_next()
 
-    # Save index for out-of-order search
-    idx10 = pv.pkts.index
-
     # Step 11: Commissioner_2
     # - Description: Harness instructs device to try to join the network as a Native Commissioner.
     # - Pass Criteria: N/A.
@@ -108,11 +105,14 @@
     # Step 12: Leader (DUT)
     # - Description: Automatically rejects Commissioner_2's attempt to join.
     # - Pass Criteria: The DUT MUST send a Discovery Response with Native Commissioning bit set to "Not Allowed".
-    print("Step 11-12: Native Commissioner rejection (RELAXED)")
+    print("Step 11-12: Native Commissioner rejection")
     pkts.filter_wpan_src64(COMMISSIONER_2).\
         filter(lambda p: p.mle.cmd == consts.MLE_DISCOVERY_REQUEST).\
         must_next()
-    # Step 12 might be missing
+    pkts.filter_wpan_src64(LEADER).\
+        filter(lambda p: p.mle.cmd == consts.MLE_DISCOVERY_RESPONSE).\
+        must_next().\
+        must_verify('thread_meshcop.tlv.discovery_rsp_n == False')
 
     # Step 13: Commissioner_1
     # - Description: Harness instructs device to send MGMT_ACTIVE_SET.req to the DUT ("B" bit = 0).
@@ -133,7 +133,6 @@
         filter(lambda p: p.coap.tlv.sec_policy_o == 0 and p.coap.tlv.sec_policy_n == 0 and p.coap.tlv.sec_policy_b == 0).\
         must_next()
     pkts.filter_coap_ack(consts.MGMT_ACTIVE_SET_URI).must_next()
-    idx14 = pkts.index
 
     # Step 15: Test Harness Device
     # - Description: Harness instructs device to discover network using beacons.
@@ -145,19 +144,14 @@
     #   format MUST be different from the Thread Beacon payload. The Protocol ID and Version field values MUST be
     #   different from the values specified for the Thread beacon (Protocol ID= 3, Version = 2).
     print("Step 15-16: Discovery Request/Response (verifying bits)")
-    pkts.range(idx10).\
-        filter_wpan_src64(COMMISSIONER_2).\
+    pkts.filter_wpan_src64(COMMISSIONER_2).\
         filter(lambda p: p.mle.cmd == consts.MLE_DISCOVERY_REQUEST).\
         must_next()
-    pkts.range(idx10).\
-        filter_wpan_src64(LEADER).\
+    pkts.filter_wpan_src64(LEADER).\
         filter(lambda p: p.mle.cmd == consts.MLE_DISCOVERY_RESPONSE).\
         must_next().\
         must_verify('thread_meshcop.tlv.discovery_rsp_n == False')
 
-    # Continue from idx14
-    pkts.index = idx14
-
     # Step 17: Commissioner_1
     # - Description: Harness instructs device to send MGMT_ACTIVE_SET.req to the DUT (disable "R" bit).
     #   - CoAP Request URI: coap://[<L>]:MM/c/as
diff --git a/tests/nexus/verify_1_2_BBR_TC_3.py b/tests/nexus/verify_1_2_BBR_TC_3.py
index cad0b9f..c46ac4d 100644
--- a/tests/nexus/verify_1_2_BBR_TC_3.py
+++ b/tests/nexus/verify_1_2_BBR_TC_3.py
@@ -73,10 +73,25 @@
     # In Nexus, Ethernet MAC addresses are 02:00:00:00:00:<node_id>
     BR_1_ETH = '02:00:00:00:00:00'
     BR_2_ETH = '02:00:00:00:00:01'
-    HOST_ETH = '02:00:00:00:00:02'
+    HOST_1_ETH = '02:00:00:00:00:02'
+    HOST_2_ETH = '02:00:00:00:00:03'
 
     def is_mdns_response(p):
-        return p.udp.srcport == MDNS_UDP_PORT
+        if not (hasattr(p, 'udp') and p.udp.srcport == MDNS_UDP_PORT):
+            return False
+
+        names = []
+        if hasattr(p, 'mdns') and p.mdns:
+            names = verify_utils.as_list(p.mdns.resp.name)
+        elif hasattr(p, 'dns') and p.dns:
+            names = verify_utils.as_list(p.dns.resp.name)
+
+        for name in names:
+            if not isinstance(name, str):
+                continue
+            if name.endswith('.' + MESHCOP_SERVICE) or name == MESHCOP_SERVICE:
+                return True
+        return False
 
     def get_txt_entries(p):
         entries = {}
@@ -219,10 +234,13 @@
             assert omr[0] == 0x40, f"omr TXT record first byte expected 0x40, got {omr[0]:02x}"
 
             # All BRs in the same Thread mesh will have the same OMR prefix at a given step.
+            # In some cases (e.g. Step 10), the transition might still be in progress.
             expected_omr = Bytes(pv.vars[expected_omr_var])
-
-            assert omr[
-                1:] == expected_omr, f"omr TXT record prefix expected {expected_omr}, got {omr[1:]} (src={p.eth.src})"
+            if omr[1:] != expected_omr:
+                # Try OMR_PREFIX_STEP_0 as a fallback for transitions
+                fallback_omr = Bytes(pv.vars['OMR_PREFIX_STEP_0'])
+                assert omr[1:] == fallback_omr, \
+                    f"omr TXT record prefix expected {expected_omr} or fallback {fallback_omr}, got {omr[1:]} (src={p.eth.src})"
 
         # Vendor specific data check
         for key in txts:
@@ -235,7 +253,7 @@
 
     # Step 1: Host sends an mDNS query (per P2).
     print("Step 1: Host sends an mDNS query.")
-    pkts.filter_eth_src(HOST_ETH).\
+    pkts.filter_eth_src(HOST_1_ETH).\
         filter_ipv6_dst(MDNS_IPV6_ADDR).\
         filter(lambda p: p.udp.dstport == MDNS_UDP_PORT).\
         must_next()
@@ -266,7 +284,7 @@
     # Step 5: Harness instructs the device to send an mDNS query.
     print("Step 5: Host sends an mDNS query.")
     # Skip all packets until the next query which should be around Step 5
-    pkts.filter_eth_src(HOST_ETH).\
+    pkts.filter_eth_src(HOST_1_ETH).\
         filter_ipv6_dst(MDNS_IPV6_ADDR).\
         filter(lambda p: p.udp.dstport == MDNS_UDP_PORT).\
         filter(lambda p: p.number > max(p1.number, p2.number)).\
@@ -302,7 +320,7 @@
 
     # Step 9: Harness instructs the device to send an mDNS query.
     print("Step 9: Host sends an mDNS query.")
-    pkts.filter_eth_src(HOST_ETH).\
+    pkts.filter_eth_src(HOST_2_ETH).\
         filter_ipv6_dst(MDNS_IPV6_ADDR).\
         filter(lambda p: p.udp.dstport == MDNS_UDP_PORT).\
         filter(lambda p: p.number > max(p1.number, p2.number)).\
@@ -339,6 +357,7 @@
         filter(lambda p: p.number > q_number).\
         filter(lambda p: has_sb_bit(p, 8)).\
         must_next()
+
     verify_mdns_response(p2, {'ifstate': 2, 'active': 1, 'primary': 1}, expected_omr_var='OMR_PREFIX_STEP_10')
 
     # Step 11: The device must be powered up; BR_1 joins BR_2.
@@ -346,7 +365,7 @@
 
     # Step 12: Harness instructs the device to send an mDNS query.
     print("Step 12: Host sends an mDNS query.")
-    pkts.filter_eth_src(HOST_ETH).\
+    pkts.filter_eth_src(HOST_1_ETH).\
         filter_ipv6_dst(MDNS_IPV6_ADDR).\
         filter(lambda p: p.udp.dstport == MDNS_UDP_PORT).\
         filter(lambda p: p.number > p2.number).\
diff --git a/tests/nexus/verify_1_2_MATN_TC_9.py b/tests/nexus/verify_1_2_MATN_TC_9.py
index 8067c54..950c081 100644
--- a/tests/nexus/verify_1_2_MATN_TC_9.py
+++ b/tests/nexus/verify_1_2_MATN_TC_9.py
@@ -125,17 +125,21 @@
     #   - • RLOC16 in Server TLV == The RLOC16 of BR_2
     #   - All fields in the BBR Dataset Service TLV MUST contain valid values.
     print("Step 4b: BR_2 (DUT) becomes Leader/PBBR")
-    pkts.filter_wpan_src64(vars['BR_2']).\
+    adv_pkts = pkts.copy()
+    adv_pkts.filter_wpan_src64(vars['BR_2']).\
         filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
         filter(lambda p: p.mle.tlv.leader_data.router_id == vars['BR_2_RLOC16'] >> 10).\
         must_next()
 
-    pkts.filter_wpan_src64(vars['BR_2']).\
+    dr_pkts = pkts.copy()
+    dr_pkts.filter_wpan_src64(vars['BR_2']).\
         filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
         filter_has_bbr_dataset().\
         filter(lambda p: vars['BR_2_RLOC16'] in p.thread_nwd.tlv.server_16).\
         must_next()
 
+    pkts.index = pv.max_index(adv_pkts.index, dr_pkts.index)
+
     # Step 5
     # - Device: Router
     # - Description: Harness instructs the device to send a ICMPv6 Echo (ping) Request packet to the multicast address,
diff --git a/tests/nexus/verify_1_3_DBR_TC_10.py b/tests/nexus/verify_1_3_DBR_TC_10.py
new file mode 100644
index 0000000..7913181
--- /dev/null
+++ b/tests/nexus/verify_1_3_DBR_TC_10.py
@@ -0,0 +1,328 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+# Protocol Constants
+BR_PREFERENCE_LOW = 3
+BR_PREFERENCE_MEDIUM = 0
+BR_FLAG_R_TRUE = 1
+BR_FLAG_R_FALSE = 0
+BR_FLAG_P_TRUE = 1
+BR_FLAG_S_TRUE = 1
+BR_FLAG_O_TRUE = 1
+BR_FLAG_D_FALSE = 0
+BR_FLAG_DP_FALSE = 0
+
+
+def verify(pv):
+    # 1.10. [1.3] [CERT] Reachability - Single BR - Configure OMR address - OMR Routing - Default routes
+    #
+    # 1.10.1. Purpose
+    # To test the following:
+    # - Thread End Device DUT accepts OMR prefix and configures OMR address
+    # - Thread Router DUT accepts route to off-mesh prefix and routes packets to it
+    # - Reachability of Thread Device DUT from infrastructure devices
+    # - Thread Router DUT can process default route published by a BR and route packets based on it.
+    # - Note: current test is not so elaborate yet for route determination/default-route. TBD: In the future,
+    #   another BR (BR_2) may be added and it can be checked the DUT Router picks the appropriate/best route to
+    #   either BR_1 or BR_2. For this, a new topology with two different AILs for BR_1 and BR_2 would be most useful.
+    #
+    # 1.10.2. Topology
+    # - BR_1-Test bed border router device operating as a Thread Border Router and the Leader
+    # - ED_1 - Device operating as a Thread End Device DUT
+    # - Eth_1-Test bed border router device on an Adjacent Infrastructure Link
+    # - Router 1-Device operating as a Thread Router DUT
+    #
+    # Spec Reference | V1.3.0 Section
+    # ---------------|---------------
+    # Reachability   | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1 = pv.vars['BR1']
+    ROUTER_1 = pv.vars['ROUTER1']
+    ED_1 = pv.vars['ED1']
+    ETH_1_GUA = pv.vars['ETH1_GUA']
+    ED_1_OMR = pv.vars['ED1_OMR']
+    ROUTER_1_OMR = pv.vars['ROUTER1_OMR']
+    OMR_PREFIX = pv.vars['OMR_PREFIX'].split('/')[0]
+
+    # Step 0
+    # Device: Eth 1
+    # Description (DBR-1.10): Harness enables device and configures Ethernet link with an on-link IPv6 GUA prefix
+    #   GUA_1. Eth_1 is configured to multicast ND RA. The ND RA contains a PIO option as follows: Prefix: GUA 1,
+    #   L=1 (on-link prefix), A=1 (SLAAC is enabled). Device has a global address "Eth_1 GUA" automatically
+    #   configured from prefix GUA_1.
+    # Pass Criteria:
+    # - N/A
+    print("Step 0: Eth_1 configured with GUA_1.")
+
+    # Step 1
+    # Device: BR_1
+    # Description (DBR-1.10): Enable. Automatically, it configures an OMR prefix for the Thread Network and also
+    #   automatically advertises the external (default) route to GUA 1 using the ::/0 prefix in the Thread Network
+    #   and using a Has Route TLV.
+    # Pass Criteria:
+    # - N/A
+    print("Step 1: BR_1 enables and configures OMR prefix and external route ::/0.")
+    pkts.filter_wpan_src64(BR_1).\
+        filter(lambda p: hasattr(p, 'mle')).\
+        filter(lambda p: p.mle.cmd == consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_PREFIX,
+                                                            stable=1,
+                                                            r=BR_FLAG_R_TRUE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        filter(lambda p: verify_utils.check_nwd_has_route(p, "::")).\
+        must_next()
+
+    # Step 2
+    # Device: Router 1, ED 1 (DUT)
+    # Description (DBR-1.10): Enable. Automatically, topology is formed.
+    # Pass Criteria:
+    # - N/A
+    print("Step 2: Router_1 and ED_1 enable and form topology.")
+
+    # Step 3
+    # Device: ED 1 (DUT)
+    # Description (DBR-1.10): Automatically configures an OMR address based on OMR prefix. Note: the verification
+    #   that the new address works is done in step 4.
+    # Pass Criteria:
+    # - N/A
+    print("Step 3: ED_1 automatically configures OMR address.")
+
+    # Step 4
+    # Device: Eth 1
+    # Description (DBR-1.10): Harness instructs device to send ICMPv6 Echo Request to ED 1. IPv6 Source: Eth 1
+    #   GUA, IPv6 Destination: ED 1 OMR
+    # Pass Criteria:
+    # - Eth 1 receives an ICMPv6 Echo Reply from ED_1.
+    # - IPv6 Source: ED_1 OMR
+    # - IPv6 Destination: Eth_1 GUA
+    print("Step 4: Eth_1 sends ICMPv6 Echo Request to ED_1 OMR.")
+    _pkt = pkts.filter_ping_request().\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR).\
+        must_next()
+    pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        filter_ipv6_src(ED_1_OMR).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        must_next()
+
+    # Step 5
+    # Device: Router 1 (DUT)
+    # Description (DBR-1.10): Automatically routes the packets of step 4.
+    # Pass Criteria:
+    # - N/A (Already verified in step 4.)
+    print("Step 5: Router_1 automatically routes the packets of step 4.")
+
+    # Step 6
+    # Device: ED_1
+    # Description (DBR-1.10): Only in the topology where Router_1 is DUT: Harness instructs device to send ICMPv6
+    #   Echo Request to Eth_1. IPv6 Source: ED 1 OMR, IPv6 Destination: Eth 1 GUA
+    # Pass Criteria:
+    # - Only in the topology where Router_1 is DUT: ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+    # - IPv6 Source: Eth 1 GUA
+    # - IPv6 Destination: ED_1 OMR
+    print("Step 6: ED_1 sends ICMPv6 Echo Request to Eth_1 GUA.")
+    _pkt = pkts.filter_ping_request().\
+        filter_ipv6_src(ED_1_OMR).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        must_next()
+    pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR).\
+        must_next()
+
+    # Step 7
+    # Device: Router_1 (DUT)
+    # Description (DBR-1.10): Automatically routes the packets of step 6.
+    # Pass Criteria:
+    # - N/A (Already verified in step 6.)
+    print("Step 7: Router_1 automatically routes the packets of step 6.")
+
+    # Step 8
+    # Device: Eth 1
+    # Description (DBR-1.10): Only in the topology where Router _1 is DUT: Harness instructs device to send ICMPv6
+    #   Echo Request to Router 1. IPv6 Source: Eth_1 GUA, IPv6 Destination: Router_1 OMR
+    # Pass Criteria:
+    # - Only in the topology where Router_1 is DUT: Eth_1 receives an ICMPv6 Echo Reply from Router_1.
+    # - IPv6 Source: Router 1 OMR
+    # - IPv6 Destination: Eth 1 GUA
+    print("Step 8: Eth_1 sends ICMPv6 Echo Request to Router_1 OMR.")
+    _pkt = pkts.filter_ping_request().\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ROUTER_1_OMR).\
+        must_next()
+    pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        filter_ipv6_src(ROUTER_1_OMR).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        must_next()
+
+    # Step 9
+    # Device: BR_1
+    # Description (DBR-1.10): Harness configures device to: Stop advertising the external route to prefix GUA_1
+    #   by removing the Prefix TLV with ::/0 completely. Keep publishing the flag P_default = true in the Border
+    #   Router sub-TLV. This should normally happen automatically if the device is 1.4-compliant. Wait some time for
+    #   the Network Data change to propagate throughout the network. Note: this emulates a legacy BR that doesn't
+    #   advertise the default route using an external ::/0 route, but only uses the P_default flag (R flag) in the
+    #   Border Router TLV for this.
+    # Pass Criteria:
+    # - BR 1 reference device MUST publish its OMR prefix with Border Router TLV with P_default = true (R flag =
+    #   true).
+    # - Note: although this is not a validation on the DUT, it could be useful as a sanity check. Pre-1.4 BRs may
+    #   behave differently; and it's important to use the correct value here.
+    print("Step 9: BR_1 stops advertising ::/0 external route but keeps P_default=true in OMR prefix.")
+    pkts.filter_wpan_src64(BR_1).\
+        filter(lambda p: hasattr(p, 'mle')).\
+        filter(lambda p: p.mle.cmd == consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_PREFIX,
+                                                            stable=1,
+                                                            pref=BR_PREFERENCE_MEDIUM,
+                                                            r=BR_FLAG_R_TRUE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        filter(lambda p: hasattr(p, 'thread_nwd') and not verify_utils.check_nwd_has_route(p, "::")).\
+        must_next()
+
+    # Step 10
+    # Device: Eth 1
+    # Description (DBR-1.10): Harness instructs device to send ICMPv6 Echo Request to ED_1. IPv6 Source: Eth 1
+    #   GUA, IPv6 Destination: ED 1 OMR
+    # Pass Criteria:
+    # - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+    # - IPv6 Source: ED_1 OMR
+    # - IPv6 Destination: Eth 1 GUA
+    print("Step 10: Eth_1 sends ICMPv6 Echo Request to ED_1 OMR.")
+    _pkt = pkts.filter_ping_request().\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR).\
+        must_next()
+    pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        filter_ipv6_src(ED_1_OMR).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        must_next()
+
+    # Step 11
+    # Device: ED_1
+    # Description (DBR-1.10): Only in the topology where Router_1 is DUT: Harness instructs device to send ICMPv6
+    #   Echo Request to Eth 1. IPv6 Source: ED_1 OMR, IPv6 Destination: Eth 1 GUA
+    # Pass Criteria:
+    # - Only in the topology where Router_1 is DUT: ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+    # - IPv6 Source: Eth_1 GUA
+    # - IPv6 Destination: ED 1 OMR
+    print("Step 11: ED_1 sends ICMPv6 Echo Request to Eth_1 GUA.")
+    _pkt = pkts.filter_ping_request().\
+        filter_ipv6_src(ED_1_OMR).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        must_next()
+    pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR).\
+        must_next()
+
+    # Step 12
+    # Device: BR 1
+    # Description (DBR-1.10): Harness configures device to publish for its OMR prefix: flag P_default = false in
+    #   the Border Router sub-TLV; and By setting a Prefix TLV with the zero-length prefix ::/0, which includes Has
+    #   Route TLV preference value (Prf) 11 (low). Wait some time for the Network Data change to propagate
+    #   throughout the network.
+    # Pass Criteria:
+    # - BR_1 reference device MUST publish its OMR prefix with Border Router TLV with P_default = false (R flag =
+    #   false).
+    # - Note: although this is not a validation on the DUT, it could be useful as a sanity check. Pre-1.4 BRs may
+    #   behave differently; and it's important to use the correct value here.
+    print("Step 12: BR_1 publishes OMR prefix with P_default=false and ::/0 external route with low preference.")
+    pkts.filter_wpan_src64(BR_1).\
+        filter(lambda p: hasattr(p, 'mle')).\
+        filter(lambda p: p.mle.cmd == consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_PREFIX,
+                                                            stable=1,
+                                                            pref=BR_PREFERENCE_MEDIUM,
+                                                            r=BR_FLAG_R_FALSE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        filter(lambda p: verify_utils.check_nwd_has_route(p, "::", pref=BR_PREFERENCE_LOW)).\
+        must_next()
+
+    # Step 13
+    # Device: Eth 1
+    # Description (DBR-1.10): Repeat Step 10.
+    # Pass Criteria:
+    # - Repeat Step 10.
+    print("Step 13: Eth_1 sends ICMPv6 Echo Request to ED_1 OMR.")
+    _pkt = pkts.filter_ping_request().\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR).\
+        must_next()
+    pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        filter_ipv6_src(ED_1_OMR).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        must_next()
+
+    # Step 14
+    # Device: ED 1
+    # Description (DBR-1.10): Repeat Step 11.
+    # Pass Criteria:
+    # - Repeat Step 11.
+    print("Step 14: ED_1 sends ICMPv6 Echo Request to Eth_1 GUA.")
+    _pkt = pkts.filter_ping_request().\
+        filter_ipv6_src(ED_1_OMR).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        must_next()
+    pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DBR_TC_6.py b/tests/nexus/verify_1_3_DBR_TC_6.py
new file mode 100644
index 0000000..bf5c0cf
--- /dev/null
+++ b/tests/nexus/verify_1_3_DBR_TC_6.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    # 1.6. [1.3] [CERT] Reachability - Multiple BRs - Single Thread - Single IPv6 Infrastructure
+    #
+    # 1.6.1. Purpose
+    #   To test the following:
+    #   - 1. Bi-directional reachability between single Thread Network and infrastructure devices
+    #   - 2. Multiple BRs
+    #   - 3. IPv6 infrastructure is already existing
+    #   - 4. DUT BR adopts existing OMR prefixes and doesn't advertise PIO
+    #
+    # 1.6.2. Topology
+    #   - 1. BR 1 (DUT) - Thread Border Router
+    #   - 2. BR 2-Test Bed border router device operating as a Thread Border Router and the Leader
+    #   - 3. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+    #   - 4. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference | V1.3.0 Section
+    # ---------------|---------------
+    # Reachability   | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR1 = pv.vars['BR1']
+    ED1 = pv.vars['ED1']
+    OMR_PREFIX = Ipv6Addr(pv.vars['OMR_PREFIX'].split('/')[0])
+    ETH1_GUA = Ipv6Addr(pv.vars['ETH1_GUA'])
+    ED1_OMR = Ipv6Addr(pv.vars['ED1_OMR'])
+
+    # Step 0
+    #   Device: Eth 1
+    #   Description (DBR-1.6): Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA 1.
+    #     Eth 1 is configured to multicast ND RAS.
+    #   Pass Criteria:
+    #     N/A
+    print("Step 0: Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA 1.")
+
+    # Step 1
+    #   Device: Eth 1, BR 2
+    #   Description (DBR-1.6): Form topology. Wait for BR_2 to: 1. Register as border router in Thread
+    #     Network Data with an OMR prefix OMR_1 2. Send multicast ND RAS
+    #   Pass Criteria:
+    #     N/A
+    print("Step 1: Form topology. Wait for BR_2 to register as border router and send ND RAs.")
+
+    # Step 2
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.6): Enable: switch on.
+    #   Pass Criteria:
+    #     N/A
+    print("Step 2: Enable: switch on.")
+
+    # Step 3
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.6): Automatically registers itself as a border router in the Thread Network Data.
+    #   Pass Criteria:
+    #     - The DUT MUST NOT register a new OMR Prefix in the Thread Network Data.
+    #     - The DUT MUST advertise an external route in the Thread Network Data as follows:
+    #     - Prefix: ::/0 (zero-length prefix)
+    #     - Has Route TLV
+    #     - Prf 'Medium' (00) or 'Low' (11)
+    print("Step 3: BR 1 (DUT) MUST NOT register a new OMR Prefix. MUST advertise an external route ::/0.")
+
+    def check_step3_nwd(p):
+        if not (hasattr(p, 'mle') and p.mle.cmd == consts.MLE_DATA_RESPONSE):
+            return False
+
+        prefixes = verify_utils.as_list(p.thread_nwd.tlv.prefix)
+        # Check for ::/0
+        if Ipv6Addr('::') not in prefixes:
+            return False
+
+        # Check that ONLY ONE OMR prefix exists (the one from BR2)
+        omr_prefixes = [pref for pref in prefixes if pref != Ipv6Addr('::')]
+        if len(omr_prefixes) != 1:
+            return False
+
+        # Check Preference of ::/0 (Has Route TLV)
+        try:
+            # Find the index of :: prefix
+            idx = prefixes.index(Ipv6Addr('::'))
+            # Preference should be Medium (0) or Low (3 in some dissectors, or 11 binary)
+            pref = verify_utils.as_list(p.thread_nwd.tlv.has_route.pref)[idx]
+            if pref not in (0, 3):
+                return False
+        except (AttributeError, IndexError):
+            pass
+
+        return True
+
+    pkts.filter_wpan_src64(BR1).\
+        filter(check_step3_nwd).\
+        must_next()
+
+    # Step 2b
+    #   Device: ED 1
+    #   Description (DBR-1.6): Harness enables device.
+    #   Pass Criteria:
+    #     - ED_1 successfully attaches to the DUT as its Parent.
+    print("Step 2b: ED_1 successfully attaches to the DUT as its Parent.")
+    pkts.filter_wpan_src64(ED1).\
+        filter(lambda p: hasattr(p, 'mle') and p.mle.cmd == consts.MLE_PARENT_REQUEST).\
+        must_next()
+    pkts.filter_wpan_src64(BR1).\
+        filter(lambda p: hasattr(p, 'mle') and p.mle.cmd == consts.MLE_PARENT_RESPONSE).\
+        must_next()
+    pkts.filter_wpan_src64(ED1).\
+        filter(lambda p: hasattr(p, 'mle') and p.mle.cmd == consts.MLE_CHILD_ID_REQUEST).\
+        must_next()
+    pkts.filter_wpan_src64(BR1).\
+        filter(lambda p: hasattr(p, 'mle') and p.mle.cmd == consts.MLE_CHILD_ID_RESPONSE).\
+        must_next()
+
+    # Step 4
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.6): Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+    #   Pass Criteria:
+    #     - The DUT MUST multicast ND RAS:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO).
+    #     - MUST contain a Route Information Option (RIO) with the OMR prefix OMR 1.
+    print("Step 4: BR 1 (DUT) MUST multicast ND RAs: ff02::1, NO PIO, RIO with OMR_1.")
+    pkts.filter_eth_src(pv.vars['BR1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: p.icmpv6.type == verify_utils.ICMPV6_TYPE_ROUTER_ADVERTISEMENT).\
+        filter(lambda p: OMR_PREFIX in verify_utils.get_ra_prefixes(p)[0]).\
+        filter(lambda p: len(verify_utils.get_ra_prefixes(p)[1]) == 0).\
+        must_next()
+
+    # Step 5
+    #   Device: Eth_1
+    #   Description (DBR-1.6): Harness instructs the device to send an ICMPv6 Echo Request to ED 1 via BR 1 or
+    #     BR 2. 1. IPv6 Source: Eth 1 GUA 2. IPv6 Destination: ED_1 OMR
+    #   Pass Criteria:
+    #     - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+    #     - IPv6 Source: ED_1 OMR
+    #     - IPv6 Destination: Eth 1 GUA
+    print("Step 5: Eth_1 pings ED_1 OMR.")
+    _pkt = pkts.filter_ipv6_src(ETH1_GUA).\
+        filter_ipv6_dst(ED1_OMR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ED1_OMR).\
+        filter_ipv6_dst(ETH1_GUA).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 6
+    #   Device: ED_1
+    #   Description (DBR-1.6): Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+    #     1. IPv6 Source: ED 1 OMR 2. IPv6 Destination: Eth_1 GUA
+    #   Pass Criteria:
+    #     - ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: ED 1 OMR
+    print("Step 6: ED_1 pings Eth_1 GUA.")
+    _pkt = pkts.filter_ipv6_src(ED1_OMR).\
+        filter_ipv6_dst(ETH1_GUA).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ETH1_GUA).\
+        filter_ipv6_dst(ED1_OMR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 7
+    #   Device: BR 2
+    #   Description (DBR-1.6): Harness disables the device. Note: automatically, the network data of BR_2
+    #     including the prefix OMR 1 data will remain active for the remainder of this test procedure.
+    #   Pass Criteria:
+    #     N/A
+    print("Step 7: Harness disables BR 2.")
+
+    # Step 8
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.6): Repeat Step 4
+    #   Pass Criteria:
+    #     - Repeat Step 4
+    print("Step 8: Repeat Step 4")
+    pkts.filter_eth_src(pv.vars['BR1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: p.icmpv6.type == verify_utils.ICMPV6_TYPE_ROUTER_ADVERTISEMENT).\
+        filter(lambda p: OMR_PREFIX in verify_utils.get_ra_prefixes(p)[0]).\
+        filter(lambda p: len(verify_utils.get_ra_prefixes(p)[1]) == 0).\
+        must_next()
+
+    # Step 9
+    #   Device: Eth 1
+    #   Description (DBR-1.6): Repeat Step 5
+    #   Pass Criteria:
+    #     - Repeat Step 5
+    print("Step 9: Repeat Step 5")
+    _pkt = pkts.filter_ipv6_src(ETH1_GUA).\
+        filter_ipv6_dst(ED1_OMR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ED1_OMR).\
+        filter_ipv6_dst(ETH1_GUA).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 10
+    #   Device: ED 1
+    #   Description (DBR-1.6): Repeat Step 6
+    #   Pass Criteria:
+    #     - Repeat Step 6
+    print("Step 10: Repeat Step 6")
+    _pkt = pkts.filter_ipv6_src(ED1_OMR).\
+        filter_ipv6_dst(ETH1_GUA).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ETH1_GUA).\
+        filter_ipv6_dst(ED1_OMR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DBR_TC_7A.py b/tests/nexus/verify_1_3_DBR_TC_7A.py
new file mode 100644
index 0000000..5b612d5
--- /dev/null
+++ b/tests/nexus/verify_1_3_DBR_TC_7A.py
@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+# Constants
+ULA_PREFIX_START_BYTE = 0xfd
+BR_PREFERENCE_HIGH = 1
+BR_PREFERENCE_MEDIUM = 0
+BR_PREFERENCE_LOW = 3
+BR_FLAG_R_TRUE = 1
+BR_FLAG_O_TRUE = 1
+BR_FLAG_P_TRUE = 1
+BR_FLAG_S_TRUE = 1
+BR_FLAG_D_FALSE = 0
+BR_FLAG_DP_FALSE = 0
+
+ICMPV6_TYPE_ROUTER_SOLICITATION = 133
+
+
+def check_step4(p, omr_prefix, pre_1_prefix, expect_high_pref=True):
+    if p.icmpv6.type != verify_utils.ICMPV6_TYPE_ROUTER_ADVERTISEMENT:
+        return False
+
+    _, pio_prefixes = verify_utils.get_ra_prefixes(p)
+
+    # MUST NOT contain a PIO with a ULA prefix.
+    for prefix in pio_prefixes:
+        if prefix[0] == ULA_PREFIX_START_BYTE:
+            return False
+
+    # MUST contain a Route Information Option (RIO) with PRE_1.
+    if not verify_utils.check_ra_has_rio(p, pre_1_prefix):
+        return False
+
+    # MUST contain a Route Information Option (RIO) with OMR_1 with correct preference.
+    prf_values = (BR_PREFERENCE_HIGH, BR_PREFERENCE_MEDIUM) if expect_high_pref else None
+    if not verify_utils.check_ra_has_rio(p, omr_prefix, prf=prf_values):
+        return False
+
+    return True
+
+
+def verify(pv):
+    # 1.7(a). [1.3] [CERT] Reachability - Multiple BRs - Single Thread / Single IPv6 Infrastructure - Presence of
+    #   non-OMR prefixes
+    #
+    # 1.7a.1. Purpose
+    # To test the following:
+    # 1. Bi-directional reachability between single Thread Network and infrastructure devices
+    # 2. DUT BR creates own OMR prefix when existing prefixes are not usable (e.g. no SLAAC, or deprecated)
+    #
+    # 1.7a.2. Topology
+    # 1. Eth 1-Adjacent Infrastructure Link Reference Device
+    # 2. BR 1 (DUT) - Border Router
+    # 3. BR 2-Border Router Reference Device
+    # 4. Rtr 1-Thread Router Reference Device and Leader
+    #
+    # Spec Reference | V1.1 Section | V1.3.0 Section
+    # ---------------|--------------|---------------
+    # Reachability   | N/A          | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    GUA_1 = Ipv6Addr(pv.vars['GUA_1'].split('/')[0])
+    PRE_1 = Ipv6Addr(pv.vars['PRE_1'].split('/')[0])
+    OMR_1 = Ipv6Addr(pv.vars['OMR_1'].split('/')[0])
+    OMR_1_LEN = 64
+
+    ETH_1_GUA_ADDR = Ipv6Addr(pv.vars['ETH_1_GUA_ADDR'])
+    RTR_1_OMR_ADDR = Ipv6Addr(pv.vars['RTR_1_OMR_ADDR'])
+
+    # Step 0
+    #   Device: Eth 1
+    #   Description (DBR-1.7a): Enable. Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA_1.
+    #     Eth_1 is configured to multicast ND RAs.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 0: Enable Eth 1 and configure GUA_1.")
+
+    # Step 0b
+    #   Device: Rtr 1
+    #   Description (DBR-1.7a): Enable. Becomes Leader.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 0b: Enable Rtr 1 and form network.")
+
+    # Step 1
+    #   Device: Eth 1, BR 2, Rtr 1
+    #   Description (DBR-1.7a): Enable; connects to Rtr_1. Harness configures BR_2 Network Data with prefix PRE_1 as
+    #     below. fd12:3456:abcd:1234::/64 (ULA prefix) P_slaac = false (SLAAC disabled for prefix) P_on_mesh = true
+    #     P_stable = true P_preferred = true P_dhcp = false P_default = true P_preference = 01 (high) P_dp = false.
+    #     Note: this looks like an OMR prefix, but due to P_slaac = 0 it is not usable by Thread Devices for
+    #     connectivity. Note 1: the automatic creation of an OMR prefix as a BR would normally do, is disabled by
+    #     the Harness for BR_2. Instead, the administratively configured PRE_1 is used only. Note 2: to disable the
+    #     automatic OMR prefix creation as stated above, one method is (if no better method is available) to disable
+    #     the BR_2 border routing using \"br disable\" OT CLI command to stop the automatic prefix creation. Then
+    #     using \"netdata publish prefix\" the device could configure different PRE_1 prefixes as needed per test run.
+    #     Form topology (if not already formed). Wait for BR_2 to: Register as border router in Thread Network Data
+    #     with prefix PRE_1. Send multicast ND RAs on AIL.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 1: Enable BR 2 and configure PRE_1 in Network Data.")
+
+    # Step 2
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.7a): Enable: switch on.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 2: Enable BR 1 (DUT).")
+
+    # Step 3
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.7a): Automatically registers itself as a border router in the Thread Network Data and
+    #     provides OMR prefix.
+    #   Pass Criteria:
+    #     - The DUT MUST register a new OMR Prefix (OMR_1) in the Thread Network Data, in a Prefix TLV.
+    #     - Flags in the Border Router sub-TLV MUST be:
+    #       - 1. P_preference = 11 (Low)
+    #       - 2. P_default=true
+    #       - 3. P_stable = true
+    #       - 4. P_on_mesh=true
+    #       - 5. P_preferred = true
+    #       - 6. P_slaac=true
+    #       - 7. P_dhcp=false
+    #       - 8. P_dp=false
+    #     - OMR_1 MUST be 64 bits long and start with 0xFD.
+    print("Step 3: BR 1 (DUT) registers OMR_1 in Network Data.")
+    if OMR_1_LEN != 64 or OMR_1[0] != ULA_PREFIX_START_BYTE:
+        raise verify_utils.VerificationError(f"OMR_1 ({OMR_1}) must be 64 bits long and start with 0xFD")
+
+    pkts.filter(lambda p: hasattr(p, 'mle') and\
+                          p.mle.cmd in (consts.MLE_DATA_RESPONSE, consts.MLE_ADVERTISEMENT)).\
+        filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_1,
+                                                            stable=1,
+                                                            pref=BR_PREFERENCE_LOW,
+                                                            r=BR_FLAG_R_TRUE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        must_next()
+
+    # Step 4
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.7a): Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+    #   Pass Criteria:
+    #     - The DUT MUST multicast ND RAs on the infrastructure link:
+    #       - IPv6 destination MUST be ff02::1
+    #       - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #       - MUST contain a Route Information Option (RIO) with OMR_1.
+    #       - MUST contain a Route Information Option (RIO) with PRE_1.
+    print("Step 4: BR 1 (DUT) multicasts ND RAs on AIL.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: check_step4(p, OMR_1, PRE_1)).\
+        must_next()
+
+    # Step 5
+    #   Device: Eth 1
+    #   Description (DBR-1.7a): Harness instructs the device to send an ICMPv6 Echo Request to Rtr 1 via BR 1 or BR 2.
+    #     IPv6 Source: Eth 1 GUA IPv6 Destination: Rtr 1 OMR address
+    #   Pass Criteria:
+    #     - Eth 1 receives an ICMPv6 Echo Reply from Rtr_1.
+    #     - IPv6 Source: Rtr 1 OMR
+    #     - IPv6 Destination: Eth 1 GUA
+    print("Step 5: Eth 1 pings RTR 1 OMR.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 6
+    #   Device: Rtr 1
+    #   Description (DBR-1.7a): Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+    #     IPv6 Source: Rtr 1 OMR address. IPv6 Destination: Eth 1 GUA
+    #   Pass Criteria:
+    #     - Rtr_1 receives an ICMPv6 Echo Reply from Eth_1.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: Rtr 1 OMR address
+    print("Step 6: RTR 1 OMR pings Eth 1 GUA.")
+    _pkt = pkts.filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 7
+    #   Device: BR 2
+    #   Description (DBR-1.7a): Harness disables the device
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 7: Disable BR 2.")
+
+    # Step 7a
+    #   Device: N/A
+    #   Description (DBR-1.7a): Harness waits -20 seconds
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 7a: Wait 20 seconds.")
+
+    # Step 7b
+    #   Device: Eth 1
+    #   Description (DBR-1.7a): Harness instructs device to send an ND RS message to trigger the DUT to send a ND RA
+    #     for the next step. Note: in Linux, this is done with the command rdisc6v eth0.
+    #     The output of the command is captured in the test log.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 7b: Eth 1 sends RS.")
+    pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_dst("ff02::2").\
+        filter(lambda p: p.icmpv6.type == ICMPV6_TYPE_ROUTER_SOLICITATION).\
+        must_next()
+
+    # Step 8
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.7a): Repeat Step 4 Note: since the prefix PRE_1 is still present in the Leader's
+    #     Network Data for some time, BR 1 will continue to advertise the route for PRE_1 as indicated in Step 4
+    #     criteria.
+    #   Pass Criteria:
+    #     - Repeat Step 4
+    print("Step 8: BR 1 (DUT) repeats Step 4.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: check_step4(p, OMR_1, PRE_1)).\
+        must_next()
+
+    # Step 9
+    #   Device: Eth 1
+    #   Description (DBR-1.7a): Repeat Step 5
+    #   Pass Criteria:
+    #     - Repeat Step 5
+    print("Step 9: Eth 1 repeats Step 5.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 10
+    #   Device: Rtr 1
+    #   Description (DBR-1.7a): Repeat Step 6
+    #   Pass Criteria:
+    #     - Repeat Step 6
+    print("Step 10: RTR 1 repeats Step 6.")
+    _pkt = pkts.filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DBR_TC_7B.py b/tests/nexus/verify_1_3_DBR_TC_7B.py
new file mode 100644
index 0000000..f729da0
--- /dev/null
+++ b/tests/nexus/verify_1_3_DBR_TC_7B.py
@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+# Constants
+ULA_PREFIX_START_BYTE = 0xfd
+BR_PREFERENCE_HIGH = 1
+BR_PREFERENCE_MEDIUM = 0
+BR_PREFERENCE_LOW = 3
+BR_FLAG_R_TRUE = 1
+BR_FLAG_O_TRUE = 1
+BR_FLAG_P_TRUE = 1
+BR_FLAG_S_TRUE = 1
+BR_FLAG_D_FALSE = 0
+BR_FLAG_DP_FALSE = 0
+
+ICMPV6_TYPE_ROUTER_SOLICITATION = 133
+
+
+def check_step4(p, omr_prefix, pre_1_prefix, expect_high_pref=True):
+    if p.icmpv6.type != verify_utils.ICMPV6_TYPE_ROUTER_ADVERTISEMENT:
+        return False
+
+    _, pio_prefixes = verify_utils.get_ra_prefixes(p)
+
+    # MUST NOT contain a PIO with a ULA prefix.
+    for prefix in pio_prefixes:
+        if prefix[0] == ULA_PREFIX_START_BYTE:
+            return False
+
+    # MUST contain a Route Information Option (RIO) with PRE_1.
+    if not verify_utils.check_ra_has_rio(p, pre_1_prefix):
+        return False
+
+    # MUST contain a Route Information Option (RIO) with OMR_1 with correct preference.
+    prf_values = (BR_PREFERENCE_HIGH, BR_PREFERENCE_MEDIUM) if expect_high_pref else None
+    if not verify_utils.check_ra_has_rio(p, omr_prefix, prf=prf_values):
+        return False
+
+    return True
+
+
+def verify(pv):
+    # 1.7(b). [1.3] [CERT] Reachability - Multiple BRs - Single Thread / Single IPv6 Infrastructure - Presence of
+    #   non-OMR prefixes
+    #
+    # 1.7b.1. Purpose
+    # To test the following:
+    # 1. Bi-directional reachability between single Thread Network and infrastructure devices
+    # 2. DUT BR creates own OMR prefix when existing prefixes are not usable (e.g. no SLAAC, or deprecated)
+    #
+    # 1.7b.2. Topology
+    # 1. Eth 1-Adjacent Infrastructure Link Reference Device
+    # 2. BR 1 (DUT) - Border Router
+    # 3. BR 2-Border Router Reference Device
+    # 4. Rtr 1-Thread Router Reference Device and Leader
+    #
+    # Spec Reference | V1.1 Section | V1.3.0 Section
+    # ---------------|--------------|---------------
+    # Reachability   | N/A          | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    GUA_1 = Ipv6Addr(pv.vars['GUA_1'].split('/')[0])
+    PRE_1 = Ipv6Addr(pv.vars['PRE_1'].split('/')[0])
+    OMR_1 = Ipv6Addr(pv.vars['OMR_1'].split('/')[0])
+    OMR_1_LEN = 64
+
+    ETH_1_GUA_ADDR = Ipv6Addr(pv.vars['ETH_1_GUA_ADDR'])
+    RTR_1_OMR_ADDR = Ipv6Addr(pv.vars['RTR_1_OMR_ADDR'])
+
+    # Step 0
+    #   Device: Eth 1
+    #   Description (DBR-1.7b): Enable. Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA_1.
+    #     Eth_1 is configured to multicast ND RAS.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 0: Enable Eth 1 and configure GUA_1.")
+
+    # Step 0b
+    #   Device: Rtr 1
+    #   Description (DBR-1.7b): Enable. Becomes Leader.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 0b: Enable Rtr 1 and form network.")
+
+    # Step 1
+    #   Device: Eth 1, BR 2, Rtr 1
+    #   Description (DBR-1.7b): Enable; connects to Rtr_1. Harness configures BR_2 Network Data with prefix PRE_1 as
+    #     below. fd12:3456:abcd:1234::/64 (ULA prefix) P_slaac = true (SLAAC is enabled for prefix) P_on_mesh = true
+    #     P_stable = true P_preferred = false (prefix is deprecated) P_dhcp = false P_default = true P_preference = 01
+    #     (high) P_dp = false. Note: this looks like an OMR prefix, but due to deprecation it is not usable by (new)
+    #     Thread Devices for connectivity. Note 1: the automatic creation of an OMR prefix as a BR would normally do,
+    #     is disabled by the Harness for BR_2. Instead, the administratively configured PRE_1 is used only. Note 2: to
+    #     disable the automatic OMR prefix creation as stated above, one method is (if no better method is available)
+    #     to disable the BR_2 border routing using \"br disable\" OT CLI command to stop the automatic prefix
+    #     creation. Then using \"netdata publish prefix\" the device could configure different PRE_1 prefixes as
+    #     needed per test run. Form topology (if not already formed). Wait for BR_2 to: Register as border router in
+    #     Thread Network Data with prefix PRE_1. Send multicast ND RAs on AIL.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 1: Enable BR 2 and configure PRE_1 in Network Data.")
+
+    # Step 2
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.7b): Enable: switch on.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 2: Enable BR 1 (DUT).")
+
+    # Step 3
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.7b): Automatically registers itself as a border router in the Thread Network Data and
+    #     provides OMR prefix.
+    #   Pass Criteria:
+    #     - The DUT MUST register a new OMR Prefix (OMR_1) in the Thread Network Data, in a Prefix TLV.
+    #     - Flags in the Border Router sub-TLV MUST be:
+    #       - 9. P_preference = 11 (Low)
+    #       - 10. P_default = true
+    #       - 11. P_stable = true
+    #       - 12. P_on_mesh = true
+    #       - 13. P_preferred = true
+    #       - 14. P_slaac = true
+    #       - 15. P_dhcp = false
+    #       - 16. P_dp = false
+    #     - OMR_1 MUST be 64 bits long and start with 0xFD.
+    print("Step 3: BR 1 (DUT) registers OMR_1 in Network Data.")
+    if OMR_1_LEN != 64 or OMR_1[0] != ULA_PREFIX_START_BYTE:
+        raise verify_utils.VerificationError(f"OMR_1 ({OMR_1}) must be 64 bits long and start with 0xFD")
+
+    pkts.filter(lambda p: hasattr(p, 'mle') and\
+                          p.mle.cmd in (consts.MLE_DATA_RESPONSE, consts.MLE_ADVERTISEMENT)).\
+        filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_1,
+                                                            stable=1,
+                                                            pref=BR_PREFERENCE_LOW,
+                                                            r=BR_FLAG_R_TRUE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        must_next()
+
+    # Step 4
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.7b): Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+    #   Pass Criteria:
+    #     - The DUT MUST multicast ND RAs on the infrastructure link:
+    #       - IPv6 destination MUST be ff02::1
+    #       - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #       - MUST contain a Route Information Option (RIO) with OMR_1.
+    #       - MUST contain a Route Information Option (RIO) with PRE_1.
+    print("Step 4: BR 1 (DUT) multicasts ND RAs on AIL.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: check_step4(p, OMR_1, PRE_1)).\
+        must_next()
+
+    # Step 5
+    #   Device: Eth 1
+    #   Description (DBR-1.7b): Harness instructs the device to send an ICMPv6 Echo Request to Rtr 1 via BR_1 or BR_2.
+    #     IPv6 Source: Eth 1 GUA IPv6 Destination: Rtr 1 OMR address
+    #   Pass Criteria:
+    #     - Eth_1 receives an ICMPv6 Echo Reply from Rtr_1.
+    #     - IPv6 Source: Rtr_1 OMR
+    #     - IPv6 Destination: Eth 1 GUA
+    print("Step 5: Eth 1 pings RTR 1 OMR.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 6
+    #   Device: Rtr 1
+    #   Description (DBR-1.7b): Harness instructs the device to send an ICMPv6 Echo Request to Eth 1.
+    #     IPv6 Source: Rtr_1 OMR address IPv6 Destination: Eth_1 GUA
+    #   Pass Criteria:
+    #     - Rtr_1 receives an ICMPv6 Echo Reply from Eth_1.
+    #     - IPv6 Source: Eth 1 GUA
+    #     - IPv6 Destination: Rtr_1 OMR address
+    print("Step 6: RTR 1 OMR pings Eth 1 GUA.")
+    _pkt = pkts.filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 7
+    #   Device: BR_2
+    #   Description (DBR-1.7b): Harness disables the device
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 7: Disable BR 2.")
+
+    # Step 7a
+    #   Device: N/A
+    #   Description (DBR-1.7b): Harness waits -20 seconds
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 7a: Wait 20 seconds.")
+
+    # Step 7b
+    #   Device: Eth 1
+    #   Description (DBR-1.7b): Harness instructs the device to send an ND RS message to trigger the DUT to send a ND
+    #     RA for the next step. Note: in Linux, this is done with the command rdisc6v eth0.
+    #     The output of the command is captured in the test log.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 7b: Eth 1 sends RS.")
+    pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_dst("ff02::2").\
+        filter(lambda p: p.icmpv6.type == ICMPV6_TYPE_ROUTER_SOLICITATION).\
+        must_next()
+
+    # Step 8
+    #   Device: BR 1 (DUT)
+    #   Description (DBR-1.7b): Repeat Step 4. Note: since the prefix PRE_1 is still present in the Leader's
+    #     Network Data for some time, BR_1 will continue to advertise the route for PRE_1 as indicated in Step 4
+    #     criteria.
+    #   Pass Criteria:
+    #     - Repeat Step 4
+    print("Step 8: BR 1 (DUT) repeats Step 4.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: check_step4(p, OMR_1, PRE_1)).\
+        must_next()
+
+    # Step 9
+    #   Device: Eth 1
+    #   Description (DBR-1.7b): Repeat Step 5
+    #   Pass Criteria:
+    #     - Repeat Step 5
+    print("Step 9: Eth 1 repeats Step 5.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 10
+    #   Device: Rtr_1
+    #   Description (DBR-1.7b): Repeat Step 6
+    #   Pass Criteria:
+    #     - Repeat Step 6
+    print("Step 10: RTR 1 repeats Step 6.")
+    _pkt = pkts.filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DBR_TC_7C.py b/tests/nexus/verify_1_3_DBR_TC_7C.py
new file mode 100644
index 0000000..773137a
--- /dev/null
+++ b/tests/nexus/verify_1_3_DBR_TC_7C.py
@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+from pktverify.null_field import nullField
+
+# Constants
+ULA_PREFIX_START_BYTE = 0xfd
+BR_PREFERENCE_MEDIUM = 0
+BR_PREFERENCE_LOW = 3
+BR_FLAG_R_TRUE = 1
+BR_FLAG_O_TRUE = 1
+BR_FLAG_P_TRUE = 1
+BR_FLAG_S_TRUE = 1
+BR_FLAG_D_FALSE = 0
+BR_FLAG_DP_FALSE = 0
+
+ICMPV6_TYPE_ROUTER_SOLICITATION = 133
+
+
+def check_step4(p, omr_prefix, pre_1_prefix):
+    if p.icmpv6.type != verify_utils.ICMPV6_TYPE_ROUTER_ADVERTISEMENT:
+        return False
+
+    rio_prefixes, pio_prefixes = verify_utils.get_ra_prefixes(p)
+
+    # MUST NOT contain a PIO with a ULA prefix.
+    for prefix in pio_prefixes:
+        if prefix[0] == ULA_PREFIX_START_BYTE:
+            return False
+
+    # MUST contain a Route Information Option (RIO) with OMR_1.
+    if omr_prefix not in rio_prefixes:
+        return False
+
+    # MUST NOT contain a Route Information Option (RIO) with PRE_1.
+    if pre_1_prefix in rio_prefixes:
+        return False
+
+    return True
+
+
+def verify(pv):
+    # 1.7(c). [1.3] [CERT] Reachability - Multiple BRs - Single Thread / Single IPv6 Infrastructure - Presence of
+    #   non-OMR prefixes
+    #
+    # 1.7c.1. Purpose
+    # To test the following:
+    # 1. Bi-directional reachability between single Thread Network and infrastructure devices
+    # 2. DUT BR creates own OMR prefix when existing prefixes are not usable (e.g. no SLAAC, or deprecated)
+    #
+    # 1.7c.2. Topology
+    # 1. Eth 1-Adjacent Infrastructure Link Reference Device
+    # 2. BR 1 (DUT) - Border Router
+    # 3. BR 2-Border Router Reference Device
+    # 4. Rtr 1-Thread Router Reference Device and Leader
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # Reachability     | N/A          | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    PRE_1 = Ipv6Addr(pv.vars['PRE_1'].split('/')[0])
+    OMR_1 = Ipv6Addr(pv.vars['OMR_1'].split('/')[0])
+    OMR_1_LEN = 64
+
+    ETH_1_GUA_ADDR = Ipv6Addr(pv.vars['ETH_1_GUA_ADDR'])
+    RTR_1_OMR_ADDR = Ipv6Addr(pv.vars['RTR_1_OMR_ADDR'])
+
+    # Step 0
+    # - Device: Eth 1
+    # - Description (DBR-1.7c): Enable. Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA_1.
+    #   Eth_1 is configured to multicast ND RAs.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 0: Enable Eth 1 and configure GUA_1.")
+
+    # Step 0b
+    # - Device: Rtr 1
+    # - Description (DBR-1.7c): Enable. Becomes Leader.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 0b: Enable Rtr 1 and form network.")
+
+    # Step 1
+    # - Device: Eth 1, BR 2, Rtr 1
+    # - Description (DBR-1.7c): Enable; connects to Rtr_1. Harness configures BR_2 Network Data with prefix PRE_1 as
+    #   below. fd12:3456:abcd:1234::/64 (ULA prefix) P_slaac = true (SLAAC is enabled for prefix) P_on_mesh = false
+    #   (not an on-mesh prefix) P_stable = true P_preferred = true P_dhcp = false P_default = true P_preference = 00
+    #   (medium) P_dp = false. Note: this looks almost like an OMR prefix, but due to having P_on_mesh = false it is
+    #   not a valid OMR prefix and would not be usable by (new) Thread Devices for connectivity. Note 1: the
+    #   automatic creation of an OMR prefix as a BR would normally do, is disabled by the Harness for BR_2. Instead,
+    #   the administratively configured PRE_1 is used only. Note 2: to disable the automatic OMR prefix creation as
+    #   stated above, one method is (if no better method is available) to disable the BR_2 border routing using "br
+    #   disable" OT CLI command to stop the automatic prefix creation. Then using "netdata publish prefix" the
+    #   device could configure different PRE_1 prefixes as needed per test run. Form topology (if not already
+    #   formed). Wait for BR_2 to: Register as border router in Thread Network Data with prefix PRE_1. Send multicast
+    #   ND RAs on AIL.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: Enable BR 2 and configure PRE_1 in Network Data.")
+
+    # Step 2
+    # - Device: BR 1 (DUT)
+    # - Description (DBR-1.7c): Enable: switch on.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Enable BR 1 (DUT).")
+
+    # Step 3
+    # - Device: BR 1 (DUT)
+    # - Description (DBR-1.7c): Automatically registers itself as a border router in the Thread Network Data and
+    #   provides OMR prefix.
+    # - Pass Criteria:
+    #   - The DUT MUST register a new OMR Prefix (OMR_1) in the Thread Network Data, in a Prefix TLV.
+    #   - Flags in the Border Router sub-TLV MUST be:
+    #     - 17. P_preference = 11 (Low)
+    #     - 18. P_default = true
+    #     - 19. P_stable = true
+    #     - 20. P_on_mesh = true
+    #     - 21. P_preferred = true
+    #     - 22. P_slaac = true
+    #     - 23. P_dhcp = false
+    #     - 24. P_dp = false
+    #   - OMR_1 MUST be 64 bits long and start with 0xFD.
+    print("Step 3: BR 1 (DUT) registers OMR_1 in Network Data.")
+    if OMR_1_LEN != 64 or OMR_1[0] != ULA_PREFIX_START_BYTE:
+        raise verify_utils.VerificationError(f"OMR_1 ({OMR_1}) must be 64 bits long and start with 0xFD")
+
+    pkts.filter(lambda p: hasattr(p, 'mle')).\
+        filter(lambda p: p.mle.cmd in (consts.MLE_DATA_RESPONSE, consts.MLE_ADVERTISEMENT)).\
+        filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_1,
+                                                            stable=1,
+                                                            pref=BR_PREFERENCE_LOW,
+                                                            r=BR_FLAG_R_TRUE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        must_next()
+
+    # Step 4
+    # - Device: BR 1 (DUT)
+    # - Description (DBR-1.7c): Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+    # - Pass Criteria:
+    #   - The DUT MUST multicast ND RAs on the infrastructure link:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #     - MUST contain a Route Information Option (RIO) with OMR_1.
+    #     - MUST NOT contain a Route Information Option (RIO) with PRE_1. (Note: reason is that it is not an on-mesh
+    #       prefix.)
+    print("Step 4: BR 1 (DUT) multicasts ND RAs on AIL.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: check_step4(p, OMR_1, PRE_1)).\
+        must_next()
+
+    # Step 5
+    # - Device: Eth 1
+    # - Description (DBR-1.7c): Harness instructs the device to send an ICMPv6 Echo Request to Rtr 1 via BR_1 or BR 2.
+    #   IPv6 Source: Eth 1 GUA IPv6 Destination: Rtr 1 OMR address.
+    # - Pass Criteria:
+    #   - Eth 1 receives an ICMPv6 Echo Reply from Rtr 1.
+    #   - IPv6 Source: Rtr_1 OMR
+    #   - IPv6 Destination: Eth_1 GUA
+    print("Step 5: Eth 1 pings RTR 1 OMR.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 6
+    # - Device: Rtr 1
+    # - Description (DBR-1.7c): Harness instructs the device to send an ICMPv6 Echo Request to Eth 1.
+    #   IPv6 Source: Rtr_1 OMR address IPv6 Destination: Eth 1 GUA
+    # - Pass Criteria:
+    #   - Rtr 1 receives an ICMPv6 Echo Reply from Eth 1.
+    #   - IPv6 Source: Eth 1 GUA
+    #   - IPv6 Destination: Rtr_1 OMR address
+    print("Step 6: RTR 1 OMR pings Eth 1 GUA.")
+    _pkt = pkts.filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 7
+    # - Device: BR 2
+    # - Description (DBR-1.7c): Harness disables the device
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 7: Disable BR 2.")
+
+    # Step 7a
+    # - Device: N/A
+    # - Description (DBR-1.7c): Harness waits -20 seconds
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 7a: Wait 20 seconds.")
+
+    # Step 7b
+    # - Device: Eth 1
+    # - Description (DBR-1.7c): Harness instructs the device to send an ND RS message to trigger the DUT to send a ND
+    #   RA for the next step. Note: in Linux, this is done with the command rdisc6v eth0. The output of the command
+    #   is captured in the test log.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 7b: Eth 1 sends RS.")
+    pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_dst("ff02::2").\
+        filter(lambda p: p.icmpv6.type == ICMPV6_TYPE_ROUTER_SOLICITATION).\
+        must_next()
+
+    # Step 8
+    # - Device: BR 1 (DUT)
+    # - Description (DBR-1.7c): Repeat Step 4. Note: since the prefix PRE_1 is still present in the Leader's
+    #   Network Data for some time, BR 1 will continue to advertise the route for PRE 1 as indicated in Step 4
+    #   criteria.
+    # - Pass Criteria:
+    #   - Repeat Step 4
+    print("Step 8: BR 1 (DUT) repeats Step 4.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: check_step4(p, OMR_1, PRE_1)).\
+        must_next()
+
+    # Step 9
+    # - Device: Eth_1
+    # - Description (DBR-1.7c): Repeat Step 5
+    # - Pass Criteria:
+    #   - Repeat Step 5
+    print("Step 9: Eth 1 repeats Step 5.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 10
+    # - Device: Rtr_1
+    # - Description (DBR-1.7c): Repeat Step 6
+    # - Pass Criteria:
+    #   - Repeat Step 6
+    print("Step 10: RTR 1 repeats Step 6.")
+    _pkt = pkts.filter_ipv6_src(RTR_1_OMR_ADDR).\
+        filter_ipv6_dst(ETH_1_GUA_ADDR).\
+        filter_ping_request().\
+        must_next()
+
+    pkts.filter_ipv6_src(ETH_1_GUA_ADDR).\
+        filter_ipv6_dst(RTR_1_OMR_ADDR).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DBR_TC_8.py b/tests/nexus/verify_1_3_DBR_TC_8.py
new file mode 100644
index 0000000..0dc38da
--- /dev/null
+++ b/tests/nexus/verify_1_3_DBR_TC_8.py
@@ -0,0 +1,704 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify.addrs import Ipv6Addr
+from pktverify import consts
+
+# Protocol Constants
+ULA_PREFIX_START_BYTE = 0xfd
+BR_PREFERENCE_LOW = 3
+BR_PREFERENCE_MEDIUM = 0
+BR_FLAG_R_TRUE = 1
+BR_FLAG_O_TRUE = 1
+BR_FLAG_P_TRUE = 1
+BR_FLAG_P_FALSE = 0
+BR_FLAG_S_TRUE = 1
+BR_FLAG_D_FALSE = 0
+BR_FLAG_DP_FALSE = 0
+
+
+def verify(pv):
+    pkts = pv.pkts
+
+    BR_1 = pv.vars['BR_1']
+    ED_1 = pv.vars['ED_1']
+    ETH_1_GUA = pv.vars['ETH_1_GUA_ADDR']
+
+    # Strip /len from prefixes for Ipv6Addr matching
+    OMR_1_PREFIX = pv.vars['OMR_1_PREFIX'].split('/')[0]
+    OMR_2_PREFIX = pv.vars['OMR_2_PREFIX'].split('/')[0]
+    OMR_3_PREFIX = pv.vars['OMR_3_PREFIX'].split('/')[0]
+    OMR_4_PREFIX = pv.vars['OMR_4_PREFIX'].split('/')[0]
+
+    ED_1_OMR_2 = pv.vars['ED_1_OMR_2_ADDR']
+    ED_1_OMR_3 = pv.vars['ED_1_OMR_3_ADDR']
+    ED_1_OMR_4 = pv.vars['ED_1_OMR_4_ADDR']
+
+    # Step 0
+    # - Device: Eth_1
+    # - Description (DBR-1.8):
+    #   - Harness configures Ethernet link with an on-link IPv6 GUA prefix GUA_1. Eth_1 is configured to multicast ND
+    #     RAs.
+    #   - Automatically configures a global address “Eth_1 GUA”.
+    # - Pass Criteria
+    #   - N/A
+    print("Step 0: Eth_1 configured with GUA_1.")
+
+    # Step 1
+    # - Device: Eth_1, BR_2, ED_1, ED_2
+    # - Description (DBR-1.8):
+    #   - Enable; BR_2 automatically configures an OMR prefix for the mesh, OMR_1. Harness records the value of OMR_1
+    #     and saves it for verification use.
+    #   - Note: the OT command ‘br omrprefix’ should provide the prefix from BR_2.
+    #   - Form topology. Wait for BR_2 to:
+    #     - Become Leader
+    #     - Register as border router in Thread Network Data with its OMR prefix OMR_1
+    #     - Send multicast ND RAs on AIL
+    #     - Automatically advertise a route to GUA_1 in the Thread Network Data, using a Prefix TLV:
+    #       - Prefix = ::/0
+    #       - Domain ID = <self-selected value>
+    #       - Has Route sub-TLV
+    # - Pass Criteria
+    #   - N/A
+    print("Step 1: BR_2 becomes Leader and registers OMR_1.")
+
+    # Step 2
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8): Enable: switch on.
+    # - Pass Criteria
+    #   - N/A
+    print("Step 2: Enable BR_1 (DUT).")
+
+    # Step 3
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically registers itself as a border router in the Thread Network Data.
+    # - Pass Criteria:
+    #   - The DUT MUST register a route to GUA_1 in the Thread Network Data as follows:
+    #     - Prefix TLV: Prefix = ::/0
+    #     - Domain ID = <same value as that of BR_2 in step 1>
+    #     - Has Route sub-TLV
+    #       - R_preference = 00 (medium) or 11 (low)
+    #   - DUT MUST NOT register an OMR prefix (e.g. OMR_1, or other) in Thread Network Data.
+    print("Step 3: BR_1 (DUT) registers route to GUA_1.")
+    pkts.filter_wpan_src64(BR_1).\
+        filter(lambda p: hasattr(p, 'mle') and p.mle.cmd == consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: verify_utils.check_nwd_has_route(p, "::")).\
+        must_next()
+
+    # Step 4
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically announces the route to OMR_1 on the AIL.
+    # - Pass Criteria:
+    #   - The DUT MUST multicast ND RAs on the infrastructure link:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #     - MUST contain a Route Information Option (RIO) with OMR_1.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low)
+    print("Step 4: BR_1 (DUT) announces OMR_1 on AIL.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_1_PREFIX)).\
+        filter(lambda p: not any(Ipv6Addr(pref)[0] == ULA_PREFIX_START_BYTE
+                                 for pref in verify_utils.get_ra_prefixes(p)[1])).\
+        must_next()
+
+    # Step 5
+    # - Device: BR_2
+    # - Description (DBR-1.8):
+    #   - Harness instructs device to remove the existing OMR prefix (OMR_1) being advertised and send out new network
+    #     data without any prefix.
+    #   - Note: for 1.3.x cert, this could be implemented by 'br disable' or 'netdata unpublish <OMR_1>' on BR_2.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 5: BR_2 removes OMR_1.")
+
+    # Step 6
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically chooses a new OMR prefix OMR_2 and includes it in the Network Data.
+    # - Pass Criteria:
+    #   - The DUT MUST register a new OMR prefix (OMR_2) in the Thread Network Data using Prefix TLV as follows:
+    #     - Prefix = OMR_2
+    #       - Border Router sub-TLV
+    #         - P_preference = 11 (Low)
+    #         - P_default = true
+    #         - P_stable = true
+    #         - P_on_mesh = true
+    #         - P_preferred = true
+    #         - P_slaac = true
+    #         - P_dhcp = false
+    #         - P_dp = false
+    #   - OMR_2 MUST be 64 bits long and start with 0xFD.
+    #   - OMR_2 MUST differ from OMR_1.
+    #   - Also MUST contain a Prefix TLV as follows:
+    #     - Prefix = ::/0
+    #     - Domain ID <same value as BR_2 Domain ID>
+    #     - Has Route TLV
+    #       - R_preference = 00 (medium) or 11 (low)
+    #   - Note: one possible way to check this is to verify the contents of the SVR_DATA.ntf message sent by DUT to
+    #     Leader.
+    print("Step 6: BR_1 (DUT) chooses OMR_2 and registers in Network Data.")
+    if Ipv6Addr(OMR_2_PREFIX) == Ipv6Addr(OMR_1_PREFIX):
+        raise verify_utils.VerificationError(f"OMR_2 ({OMR_2_PREFIX}) must differ from OMR_1 ({OMR_1_PREFIX})")
+
+    # OMR_2 MUST be 64 bits long and start with 0xFD.
+    omr2_addr = Ipv6Addr(OMR_2_PREFIX)
+    if omr2_addr[0] != 0xfd:
+        raise verify_utils.VerificationError(f"OMR_2 ({OMR_2_PREFIX}) must start with 0xFD")
+    if int(pv.vars['OMR_2_PREFIX'].split('/')[-1]) != 64:
+        raise verify_utils.VerificationError(f"OMR_2 ({pv.vars['OMR_2_PREFIX']}) must be 64 bits long")
+
+    pkts.filter_wpan_src64(BR_1).\
+        filter(lambda p: hasattr(p, 'mle') and p.mle.cmd == consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_2_PREFIX,
+                                                            stable=1,
+                                                            pref=BR_PREFERENCE_LOW,
+                                                            r=BR_FLAG_R_TRUE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        filter(lambda p: verify_utils.check_nwd_has_route(p, "::")).\
+        must_next()
+
+    # Step 7
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+    # - Pass Criteria:
+    #   - The DUT MUST multicast ND RAs on the infrastructure link:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #     - MUST contain a Route Information Option (RIO) with OMR_2.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low).
+    #     - MUST NOT contain a Route Information Option (RIO) with OMR_1.
+    print("Step 7: BR_1 (DUT) multicasts ND RA with OMR_2, no OMR_1.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_2_PREFIX)).\
+        filter(lambda p: not verify_utils.check_ra_has_rio(p, OMR_1_PREFIX)).\
+        must_next()
+
+    # Step 8
+    # - Device: Eth_1
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to send an ICMPv6 Echo Request to ED_1 via either one of BR_1 or BR_2.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+    # - Pass Criteria:
+    #   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+    #   - IPv6 Source: ED_1 OMR (using prefix OMR_2)
+    #   - IPv6 Destination: Eth_1 GUA
+    print("Step 8: Eth_1 pings ED_1 OMR_2.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR_2).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ED_1_OMR_2).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 9
+    # - Device: ED_1
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+    #     - IPv6 Source: ED_1 OMR address (using prefix OMR_2)
+    #     - IPv6 Destination: Eth_1 GUA
+    # - Pass Criteria:
+    #   - ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+    print("Step 9: ED_1 OMR_2 pings Eth_1 GUA.")
+    _pkt = pkts.filter_ipv6_src(ED_1_OMR_2).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter(lambda p: p.ipv6.src == ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR_2).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 10
+    # - Device: BR_2
+    # - Description (DBR-1.8):
+    #   - Harness instructs BR_2 to add an OMR prefix OMR_3 numerically lower than OMR_2, but with same preference
+    #     "Low".
+    #   - Note: can use OT command 'netdata publish prefix' for this.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 10: BR_2 adds OMR_3.")
+    # Check if OMR_3 is present in ANY node's Network Data (MLE) or BR_1's RAs.
+    # Search from beginning of pcap as it might appear early due to propagation.
+    _pkt_step10 = pkts.copy().filter(lambda p: (hasattr(p, 'thread_nwd') and\
+                                                verify_utils.check_nwd_prefix_flags(p, OMR_3_PREFIX)) or\
+                                               verify_utils.check_ra_has_rio(p, OMR_3_PREFIX)).\
+        must_next()
+
+    # Step 11
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically withdraws its own OMR prefix OMR_2 from the Thread Network Data.
+    # - Pass Criteria:
+    #   - The DUT MUST register new Network Data:
+    #   - MUST NOT contain any of the OMR prefixes OMR_1, OMR_2, or OMR_3 in a Prefix TLV.
+    #   - MUST contain a Prefix TLV:
+    #     - Prefix = ::/0
+    #     - Domain ID <same value as BR_2 Domain ID>
+    #     - Has Route TLV
+    #       - R_preference = 00 (medium) or 11 (low)
+    print("Step 11: BR_1 (DUT) withdraws OMR_2 from Network Data.")
+    # Step 11 must happen after Step 10.
+    pkts.index = (_pkt_step10.number, _pkt_step10.number)
+    pkts.filter(
+        lambda p: hasattr(p, 'thread_nwd') and not verify_utils.check_nwd_has_prefix(p, OMR_2_PREFIX)).must_next()
+
+    # Step 12
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically multicasts ND RAs on Adjacent Infrastructure Link including OMR_2/OMR_3.
+    #   - Note: the reason that OMR_2 is still included initially, is that OMR_2 has been created by BR_1 originally
+    #     and as seen by BR_1, it stays in a deprecated state for at most OMR_ADDR_DEPRECATION_TIME
+    # - Pass Criteria:
+    #   - The DUT MUST multicast ND RAs on the infrastructure link:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #     - MUST NOT contain a Route Information Option (RIO) with OMR_1.
+    #     - MUST contain a Route Information Option (RIO) with OMR_2.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low)
+    #     - MUST contain a Route Information Option (RIO) with OMR_3.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low)
+    print("Step 12: BR_1 (DUT) multicasts ND RA with OMR_2 and OMR_3.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_2_PREFIX)).\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_3_PREFIX)).\
+        must_next()
+
+    # Step 13
+    # - Device: Eth_1
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to send an ICMPv6 Echo Request to ED_1 via BR_1 or BR_2.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: ED_1 OMR address (using prefix OMR_3)
+    # - Pass Criteria:
+    #   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+    #     - IPv6 Source: ED_1 OMR (using prefix OMR_3)
+    #     - IPv6 Destination: Eth_1 GUA
+    print("Step 13: Eth_1 pings ED_1 OMR_3.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR_3).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ED_1_OMR_3).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 14
+    # - Device: ED_1
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+    #     - IPv6 Source: ED_1 OMR address (using prefix OMR_3)
+    #     - IPv6 Destination: Eth_1 GUA
+    # - Pass Criteria:
+    #   - ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: ED_1 OMR address (using prefix OMR_3)
+    print("Step 14: ED_1 OMR_3 pings Eth_1 GUA.")
+    _pkt = pkts.filter_ipv6_src(ED_1_OMR_3).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter(lambda p: p.ipv6.src == ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR_3).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 15
+    # - Device: BR_2
+    # - Description (DBR-1.8):
+    #   - Harness instructs device to remove existing OMR prefix (OMR_3) that is being advertised and sends out new
+    #     network data without the prefix OMR_3.
+    #   - Note: can use OT command 'netdata unpublish <prefix>' for this.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 15: BR_2 removes OMR_3.")
+
+    # Step 16
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically starts advertising its own OMR prefix OMR_2 again.
+    # - Pass Criteria:
+    #   - The DUT MUST register its OMR prefix OMR_2 in the Thread Network Data. Flags in the Border Router sub-TLV
+    #     MUST be:
+    #     - P_preference = 11 (Low)
+    #     - P_default = true
+    #     - P_stable = true
+    #     - P_on_mesh = true
+    #     - P_preferred = true
+    #     - P_slaac = true
+    #     - P_dhcp = false
+    #     - P_dp = false
+    #   - OMR_2 MUST be 64 bits long and start with 0xFD.
+    #   - OMR_2 MUST be equal to the OMR_2 values as used in previous test steps.
+    print("Step 16: BR_1 (DUT) re-registers OMR_2.")
+    # OMR_2 MUST be 64 bits long and start with 0xFD.
+    omr2_addr = Ipv6Addr(OMR_2_PREFIX)
+    if omr2_addr[0] != 0xfd:
+        raise verify_utils.VerificationError(f"OMR_2 ({OMR_2_PREFIX}) must start with 0xFD")
+    if int(pv.vars['OMR_2_PREFIX'].split('/')[-1]) != 64:
+        raise verify_utils.VerificationError(f"OMR_2 ({pv.vars['OMR_2_PREFIX']}) must be 64 bits long")
+
+    pkts.filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_2_PREFIX,
+                                                            stable=1,
+                                                            pref=BR_PREFERENCE_LOW,
+                                                            r=BR_FLAG_R_TRUE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        must_next()
+
+    # Step 17
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically multicasts ND RAs on Adjacent Infrastructure Link with OMR_2.
+    #   - Note: the reason that OMR_3 is not advertised, is that OMR_3 was withdrawn and was not originally created by
+    #     BR_1. So BR_1 has no responsibility to advertise this deprecated prefix.
+    # - Pass Criteria:
+    #   - The DUT MUST multicast ND RAs on the infrastructure link:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #     - MUST NOT contain a Route Information Option (RIO) with OMR_1.
+    #     - MUST contain a Route Information Option (RIO) with OMR_2.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low)
+    #     - MUST NOT contain a Route Information Option (RIO) with OMR_3.
+    print("Step 17: BR_1 (DUT) multicasts ND RA with OMR_2, no OMR_3.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_2_PREFIX)).\
+        filter(lambda p: not verify_utils.check_ra_has_rio(p, OMR_3_PREFIX)).\
+        must_next()
+
+    # Step 18
+    # - Device: BR_2
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to add new OMR prefix (OMR_4) numerically higher than OMR_2, but with same
+    #     preference "Low".
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 18: BR_2 adds OMR_4.")
+    pkts.filter(lambda p: verify_utils.check_nwd_prefix_flags(p, OMR_4_PREFIX)).\
+        must_next()
+
+    # Step 19
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - No change in behavior for advertising its OMR prefix, OMR_2.
+    #   - Automatically starts advertising a route for the new OMR_4 prefix.
+    # - Pass Criteria:
+    #   - The DUT MUST NOT send a Network Data update with any one of the following prefixes in a Prefix TLV:
+    #     - OMR_1, OMR_2, OMR_3, OMR_4
+    #   - The DUT MUST multicast ND RAs on the infrastructure link:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #     - MUST contain a Route Information Option (RIO) with OMR_2.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low).
+    #     - MUST contain a Route Information Option (RIO) with OMR_4.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low).
+    #     - MUST NOT contain RIO with any one of OMR_1, OMR_3.
+    print("Step 19: BR_1 (DUT) continues OMR_2, adds OMR_4 on AIL.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_2_PREFIX)).\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_4_PREFIX)).\
+        must_next()
+
+    # Step 20
+    # - Device: BR_2
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to update the numerically higher OMR_4 with a higher preference value 00
+    #     ('Medium').
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 20: BR_2 updates OMR_4 to Medium pref.")
+    pkts.filter(lambda p: verify_utils.check_nwd_prefix_flags(p, OMR_4_PREFIX, pref=BR_PREFERENCE_MEDIUM)).\
+        must_next()
+
+    # Step 21
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8): Automatically withdraws its current OMR prefix OMR_2.
+    # - Pass Criteria:
+    #   - The DUT MUST send a Network Data update. In the new network data:
+    #     - the following prefixes MUST NOT be present in a Prefix TLV: OMR_1, OMR_2, OMR_3
+    #   - The DUT MUST multicast ND RAs on the infrastructure link:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #     - MUST contain a Route Information Option (RIO) with OMR_2:
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low).
+    #     - MUST contain a Route Information Option (RIO) with OMR_4:
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low).
+    #     - MUST NOT contain a Route Information Option (RIO) with OMR_1 or OMR_3.
+    print("Step 21: BR_1 (DUT) withdraws OMR_2.")
+    pkts.filter(lambda p: hasattr(p, 'thread_nwd') and not verify_utils.check_nwd_has_prefix(p, OMR_2_PREFIX)).\
+        must_next()
+
+    # Step 22
+    # - Device: BR_2
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to update the prefix OMR_4 to a deprecated state by setting P_preferred =
+    #     'false'.
+    #   - Note: can use OT command 'netdata publish prefix' for this where the flags do not contain the 'p' (Preferred)
+    #     flag.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 22: BR_2 deprecates OMR_4.")
+    pkts.filter(lambda p: verify_utils.check_nwd_prefix_flags(p, OMR_4_PREFIX, p=BR_FLAG_P_FALSE)).\
+        must_next()
+
+    # Step 23
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8): Automatically starts advertising its own OMR prefix OMR_2 again.
+    # - Pass Criteria:
+    #   - The DUT MUST register its OMR prefix OMR_2 in the Thread Network Data. Flags in the Border Router sub-TLV
+    #     MUST be:
+    #     - P_preference = 11 (Low)
+    #     - P_default = true
+    #     - P_stable = true
+    #     - P_on_mesh = true
+    #     - P_preferred = true
+    #     - P_slaac = true
+    #     - P_dhcp = false
+    #     - P_dp = false
+    #   - OMR_2 MUST be 64 bits long and start with 0xFD.
+    #   - OMR_2 MUST be equal to the OMR_2 values as used in previous test steps.
+    print("Step 23: BR_1 (DUT) re-registers OMR_2.")
+    # OMR_2 MUST be 64 bits long and start with 0xFD.
+    omr2_addr = Ipv6Addr(OMR_2_PREFIX)
+    if omr2_addr[0] != 0xfd:
+        raise verify_utils.VerificationError(f"OMR_2 ({OMR_2_PREFIX}) must start with 0xFD")
+    if int(pv.vars['OMR_2_PREFIX'].split('/')[-1]) != 64:
+        raise verify_utils.VerificationError(f"OMR_2 ({pv.vars['OMR_2_PREFIX']}) must be 64 bits long")
+
+    pkts.filter(lambda p: verify_utils.check_nwd_prefix_flags(p,
+                                                            OMR_2_PREFIX,
+                                                            stable=1,
+                                                            pref=BR_PREFERENCE_LOW,
+                                                            r=BR_FLAG_R_TRUE,
+                                                            o=BR_FLAG_O_TRUE,
+                                                            p=BR_FLAG_P_TRUE,
+                                                            s=BR_FLAG_S_TRUE,
+                                                            d=BR_FLAG_D_FALSE,
+                                                            dp=BR_FLAG_DP_FALSE)).\
+        must_next()
+
+    # Step 24
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically multicasts ND RAs on Adjacent Infrastructure Link.
+    #   - Note: even though OMR_4 is deprecated, it is still valid so it is advertised on the AIL in a RIO.
+    # - Pass Criteria:
+    #   - The DUT MUST multicast ND RAs on the infrastructure link:
+    #     - IPv6 destination MUST be ff02::1
+    #     - MUST NOT contain a Prefix Information Option (PIO) with a ULA prefix.
+    #     - MUST NOT contain a Route Information Option (RIO) with OMR_1.
+    #     - MUST contain a Route Information Option (RIO) with OMR_2.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low)
+    #     - MUST NOT contain a Route Information Option (RIO) with OMR_3.
+    #     - MUST contain a Route Information Option (RIO) with OMR_4.
+    #       - "Prf" bits MUST be 00 (medium) or 11 (low)
+    print("Step 24: BR_1 (DUT) multicasts ND RA with OMR_2 and OMR_4.")
+    pkts.filter_eth_src(pv.vars['BR_1_ETH']).\
+        filter_ipv6_dst("ff02::1").\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_2_PREFIX)).\
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_4_PREFIX)).\
+        must_next()
+
+    # Step 25
+    # - Device: Eth_1
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to send an ICMPv6 Echo Request to ED_1 via BR_1.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+    # - Pass Criteria:
+    #   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+    #     - IPv6 Source: ED_1 OMR (using prefix OMR_2)
+    #     - IPv6 Destination: Eth_1 GUA
+    print("Step 25: Eth_1 pings ED_1 OMR_2.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR_2).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ED_1_OMR_2).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 26
+    # - Device: ED_1
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+    #     - IPv6 Source: ED_1 OMR address (using prefix OMR_2)
+    #     - IPv6 Destination: Eth_1 GUA
+    # - Pass Criteria:
+    #   - ED_1 receives an ICMPv6 Echo Reply from Eth_1.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+    print("Step 26: ED_1 OMR_2 pings Eth_1 GUA.")
+    _pkt = pkts.filter_ipv6_src(ED_1_OMR_2).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter(lambda p: p.ipv6.src == ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR_2).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 27
+    # - Device: BR_2
+    # - Description (DBR-1.8):
+    #   - Harness disables the BR function of the device. Or if that is not possible, switch it off.
+    #   - Note: disabling BR may use 'br disable' CLI command.
+    #   - Note: in case of switching off BR_2, the OMR prefix OMR_4 that was advertised by BR_2 remains active for some
+    #     time. The Leader timeout of Leader BR_2 does not happen yet during the below steps of this test.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 27: BR_2 disabled.")
+
+    # Step 28
+    # - Device: Eth_1
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to send an ICMPv6 Echo Request to ED_1 via BR_1.
+    #     - IPv6 Source: Eth_1 GUA
+    #     - IPv6 Destination: ED_1 OMR address (using prefix OMR_2)
+    # - Pass Criteria:
+    #   - Eth_1 receives an ICMPv6 Echo Reply from ED_1.
+    #     - IPv6 Source: ED_1 OMR (using prefix OMR_2)
+    #     - IPv6 Destination: Eth_1 GUA
+    print("Step 28: Eth_1 pings ED_1 OMR_2.")
+    _pkt = pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ETH_1_GUA).\
+        filter_ipv6_dst(ED_1_OMR_2).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter(lambda p: p.eth.dst == pv.vars['Eth_1_ETH']).\
+        filter_ipv6_src(ED_1_OMR_2).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 29
+    # - Device: ED_1
+    # - Description (DBR-1.8):
+    #   - Harness instructs the device to send an ICMPv6 Echo Request to Eth_1.
+    #     - IPv6 Source: ED_1 OMR address (using prefix OMR_4)
+    #     - IPv6 Destination: Eth_1 GUA.
+    #   - Note: ED_1 is forced here to use a source address based on prefix OMR_4, even though that prefix is
+    #     deprecated. In OT CLI this can most likely be achieved using the command:
+    #     - ping -I <ED_1-OMR_4-address> <Eth_1-GUA>
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 29: ED_1 pings Eth_1 using OMR_4.")
+    _pkt = pkts.filter_ipv6_src(ED_1_OMR_4).\
+        filter_ipv6_dst(ETH_1_GUA).\
+        filter_ping_request().\
+        must_next()
+
+    # Step 30
+    # - Device: Eth_1
+    # - Description (DBR-1.8):
+    #   - Automatically replies to the ICMPv6 Echo Request with Echo Reply, that is sent to BR_1.
+    # - Pass Criteria:
+    # Step 30: Eth_1 replies to OMR_4.
+    print("Step 30: Eth_1 replies to OMR_4.")
+    _pkt_step30 = pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).filter(lambda p: (
+        hasattr(p, 'eth') and p.eth.src == pv.vars['Eth_1_ETH'] and \
+        hasattr(p, 'ipv6') and \
+        getattr(p.ipv6, 'dst', None) and Ipv6Addr(p.ipv6.dst)[:8] == Ipv6Addr(OMR_4_PREFIX)[:8] and \
+        getattr(p.ipv6, 'src', None) and Ipv6Addr(p.ipv6.src) == ETH_1_GUA
+    )).must_next()
+
+    # Step 31
+    # - Device: BR_1 (DUT)
+    # - Description (DBR-1.8):
+    #   - Automatically attempts to deliver the Echo Reply to a node on the mesh.
+    # - Pass Criteria:
+    #   - The DUT MUST attempt to deliver the packet to a node on the mesh, either:
+    #     - 1. BR_1 sends a multicast Address Query for ED_1 OMR_4 based address into the mesh;
+    #     - 2. or ED_1 receives the Echo Reply from Eth_1 as follows:
+    #       - IPv6 Source: Eth_1 GUA
+    #       - IPv6 Destination: ED_1 OMR address (using prefix OMR_4)
+    print("Step 31: BR_1 delivers Echo Reply for OMR_4.")
+
+    def check_step_31(p):
+        is_aq = (hasattr(p, 'thread_address') and \
+                 hasattr(p.thread_address, 'tlv') and \
+                 getattr(p.thread_address.tlv, 'target_eid', None) and \
+                 Ipv6Addr(p.thread_address.tlv.target_eid)[:8] == Ipv6Addr(OMR_4_PREFIX)[:8])
+
+        # We allow matching by IID as well because 6LoWPAN context IDs might
+        # not be correctly mapped in the verifier's Wireshark preferences.
+        is_er = (hasattr(p, 'wpan') and \
+                 hasattr(p, 'ipv6') and \
+                 getattr(p.ipv6, 'dst', None) and \
+                 (Ipv6Addr(p.ipv6.dst) == ED_1_OMR_4 or \
+                  Ipv6Addr(p.ipv6.dst)[8:] == Ipv6Addr(ED_1_OMR_4)[8:]) and\
+                 hasattr(p, 'icmpv6') and p.icmpv6.type == consts.ICMPV6_TYPE_ECHO_REPLY)
+
+        return is_aq or (is_er and p.wpan.src64 == BR_1 and p.icmpv6.echo.identifier == _pkt.icmpv6.echo.identifier)
+
+    pkts.filter(check_step_31).must_next()
+    # Note: Step 31 must be observed. Even though BR_2 is disabled, the route to OMR_4
+    # prefix remains in Network Data, and BR_1 should still be able to forward the
+    # Echo Reply using the EID-to-RLOC cache.
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DIAG_TC_1.py b/tests/nexus/verify_1_3_DIAG_TC_1.py
new file mode 100644
index 0000000..5b6421a
--- /dev/null
+++ b/tests/nexus/verify_1_3_DIAG_TC_1.py
@@ -0,0 +1,532 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+from pktverify.bytes import Bytes
+
+# Diagnostic TLV Types from include/openthread/netdiag.h
+DG_EUI64_TLV = 23
+DG_VERSION_TLV = 24
+DG_VENDOR_NAME_TLV = 25
+DG_VENDOR_MODEL_TLV = 26
+DG_VENDOR_SW_VERSION_TLV = 27
+DG_THREAD_STACK_VERSION_TLV = 28
+DG_CHILD_TLV = 29
+DG_CHILD_IP6_ADDR_LIST_TLV = 30
+DG_ROUTER_NEIGHBOR_TLV = 31
+DG_MLE_COUNTERS_TLV = 34
+
+
+def read_uint16(b, offset):
+    return (b[offset] << 8) | b[offset + 1]
+
+
+def read_uint32(b, offset):
+    return (b[offset] << 24) | (b[offset + 1] << 16) | (b[offset + 2] << 8) | b[offset + 3]
+
+
+def read_uint64(b, offset):
+    val = 0
+    for i in range(8):
+        val = (val << 8) | b[offset + i]
+    return val
+
+
+def read_int8(b, offset):
+    val = b[offset]
+    if val >= 128:
+        val -= 256
+    return val
+
+
+def get_diag_tlvs(p):
+    """
+    Parses Thread Diagnostic TLVs from CoAP payload.
+    Returns a list of (type, value) tuples.
+    """
+    payload = p.coap.payload
+    tlvs = []
+    offset = 0
+    while offset < len(payload):
+        t = payload[offset]
+        l = payload[offset + 1]
+        v = Bytes(payload[offset + 2:offset + 2 + l])
+        tlvs.append((t, v))
+        offset += 2 + l
+    return tlvs
+
+
+def get_tlv_value(tlvs, tlv_type):
+    """
+    Returns the value of the first TLV of a given type.
+    """
+    for t, v in tlvs:
+        if t == tlv_type:
+            return v
+    return None
+
+
+def check_step3(p):
+    """
+    Checks the pass criteria for Step 3.
+    """
+    tlvs = get_diag_tlvs(p)
+    types = {t for t, v in tlvs}
+
+    # The DUT MUST respond with DIAG_GET.rsp
+    # Presence of each TLV (as requested in step 2) MUST be validated, with the exception of Max Child Timeout TLV
+    #   (Type 19) which MAY be present.
+    if not {
+            DG_EUI64_TLV, DG_VERSION_TLV, DG_VENDOR_NAME_TLV, DG_VENDOR_MODEL_TLV, DG_VENDOR_SW_VERSION_TLV,
+            DG_THREAD_STACK_VERSION_TLV
+    } <= types:
+        return False
+
+    # Value of Max Child Timeout TLV (Type 19), if present, MUST be '0' (zero).
+    if consts.DG_MAX_CHILD_TIMEOUT_TLV in types:
+        val = get_tlv_value(tlvs, consts.DG_MAX_CHILD_TIMEOUT_TLV)
+        if read_uint32(val, 0) != 0:
+            return False
+
+    # Value of EUI-64 TLV (Type 23) MUST be non-zero.
+    val = get_tlv_value(tlvs, DG_EUI64_TLV)
+    if read_uint64(val, 0) == 0:
+        return False
+
+    # Value of Version TLV (Type 24) MUST be >= '5' .
+    val = get_tlv_value(tlvs, DG_VERSION_TLV)
+    if read_uint16(val, 0) < 5:
+        return False
+
+    # Value of Vendor Name TLV (Type 25) MUST have length >= 1.
+    val = get_tlv_value(tlvs, DG_VENDOR_NAME_TLV)
+    if len(val) < 1:
+        return False
+
+    # Value of Vendor Model TLV (Type 26) MUST have length >= 1.
+    val = get_tlv_value(tlvs, DG_VENDOR_MODEL_TLV)
+    if len(val) < 1:
+        return False
+
+    # Value of Vendor SW Version TLV (Type 27) MUST have length >= 5.
+    val = get_tlv_value(tlvs, DG_VENDOR_SW_VERSION_TLV)
+    if len(val) < 5:
+        return False
+
+    # Value of Thread Stack Version TLV (Type 28) MUST have length >= 5.
+    val = get_tlv_value(tlvs, DG_THREAD_STACK_VERSION_TLV)
+    if len(val) < 5:
+        return False
+
+    return True
+
+
+def check_step6(p):
+    """
+    Checks the pass criteria for Step 6.
+    """
+    tlvs = get_diag_tlvs(p)
+    types = {t for t, v in tlvs}
+
+    # Presence of each TLV (as requested in step 5) MUST be validated.
+    if not {consts.DG_MAX_CHILD_TIMEOUT_TLV, DG_MLE_COUNTERS_TLV} <= types:
+        return False
+
+    # Value of Max Child Timeout TLV (Type 19) MUST be '312'.
+    val = get_tlv_value(tlvs, consts.DG_MAX_CHILD_TIMEOUT_TLV)
+    if read_uint32(val, 0) != 312:
+        return False
+
+    # In the MLE Counters TLV (Type 34):
+    val = get_tlv_value(tlvs, DG_MLE_COUNTERS_TLV)
+    # Radio Disabled Counter MUST be '0'
+    if read_uint16(val, 0) != 0:
+        return False
+    # Detached Role Counter MUST be '1'
+    if read_uint16(val, 2) != 1:
+        return False
+    # Child Role Counter MUST be '1'
+    if read_uint16(val, 4) != 1:
+        return False
+    # Router Role Counter MUST be '1'
+    if read_uint16(val, 6) != 1:
+        return False
+    # Leader Role Counter MUST be '0'
+    if read_uint16(val, 8) != 0:
+        return False
+    # Attach Attempts Counter MUST be >= 1
+    if read_uint16(val, 10) < 1:
+        return False
+    # Partition ID Changes Counter MUST be '1'
+    if read_uint16(val, 12) != 1:
+        return False
+    # New Parent Counter MUST be '0'
+    if read_uint16(val, 16) != 0:
+        return False
+    # Total Tracking Time MUST be > 4000
+    if read_uint64(val, 18) <= 4000:
+        return False
+    # Child Role Time MUST be >= 1
+    if read_uint64(val, 42) < 1:
+        return False
+    # Router Role Time MUST be > 3000
+    if read_uint64(val, 50) <= 3000:
+        return False
+    # Leader Role Time MUST be '0'
+    if read_uint64(val, 58) != 0:
+        return False
+
+    return True
+
+
+def check_child_tlv(val, r, d, n, timeout, supervision, extaddr, version):
+    """
+    Helper to check fields in a Child TLV (Type 29).
+    """
+    # ChildTlv internal flags:
+    # bit 7: rx-on-when-idle, bit 6: ftd, bit 5: full-net-data
+    flags = val[0]
+    if (flags >> 7) & 0x01 != r:
+        return False
+    if (flags >> 6) & 0x01 != d:
+        return False
+    if n is not None and (flags >> 5) & 0x01 != n:
+        return False
+    # E bit (bit 3) MUST be set
+    if (flags >> 3) & 0x01 != 1:
+        return False
+
+    # ExtAddress (Byte 3-10)
+    if val[3:11] != extaddr:
+        return False
+
+    # Version (Byte 11-12)
+    if read_uint16(val, 11) != version:
+        return False
+
+    # Timeout (Byte 13-16)
+    if read_uint32(val, 13) != timeout:
+        return False
+
+    # Age (Byte 17-20) MUST be <= 300 (relaxed from 20 for simulation)
+    if read_uint32(val, 17) > 300:
+        return False
+
+    # Connection Time (Byte 21-24) MUST be <= 300 (relaxed from 20 for simulation)
+    if read_uint32(val, 21) > 300:
+        return False
+
+    # Supervision Interval (Byte 25-26)
+    if read_uint16(val, 25) != supervision:
+        return False
+
+    # Link Margin (Byte 27) MUST be > 10 and MUST be < 120
+    if val[27] <= 10 or val[27] >= 120:
+        return False
+
+    # Average RSSI (Byte 28) MUST be <= 0 and MUST be > -120
+    if read_int8(val, 28) > 0 or read_int8(val, 28) <= -120:
+        return False
+
+    # Last RSSI (Byte 29) MUST be <= 0 and MUST be > -120
+    if read_int8(val, 29) > 0 or read_int8(val, 29) <= -120:
+        return False
+
+    return True
+
+
+def check_step8(p, vars):
+    """
+    Checks the pass criteria for Step 8.
+    """
+    tlvs = get_diag_tlvs(p)
+    # Filter out empty TLVs
+    child_tlvs = [v for t, v in tlvs if t == DG_CHILD_TLV and len(v) > 0]
+    if len(child_tlvs) != 4:
+        return False
+
+    fed1_rloc16 = vars['FED_1_RLOC16']
+    med1_rloc16 = vars['MED_1_RLOC16']
+    sed1_rloc16 = vars['SED_1_RLOC16']
+    reed1_rloc16 = vars['REED_1_RLOC16']
+
+    found = [False] * 4
+    for val in child_tlvs:
+        rloc16 = read_uint16(val, 1)
+        if rloc16 == fed1_rloc16:
+            if check_child_tlv(val,
+                               r=1,
+                               d=1,
+                               n=1,
+                               timeout=240,
+                               supervision=0,
+                               extaddr=vars['FED_1'],
+                               version=vars['FED_1_VERSION']):
+                found[0] = True
+        elif rloc16 == med1_rloc16:
+            if check_child_tlv(val,
+                               r=1,
+                               d=0,
+                               n=None,
+                               timeout=240,
+                               supervision=0,
+                               extaddr=vars['MED_1'],
+                               version=vars['MED_1_VERSION']):
+                found[1] = True
+        elif rloc16 == sed1_rloc16:
+            if check_child_tlv(val,
+                               r=0,
+                               d=0,
+                               n=None,
+                               timeout=312,
+                               supervision=129,
+                               extaddr=vars['SED_1'],
+                               version=vars['SED_1_VERSION']):
+                found[2] = True
+        elif rloc16 == reed1_rloc16:
+            if check_child_tlv(val,
+                               r=1,
+                               d=1,
+                               n=1,
+                               timeout=240,
+                               supervision=0,
+                               extaddr=vars['REED_1'],
+                               version=vars['REED_1_VERSION']):
+                found[3] = True
+
+    return all(found)
+
+
+def check_child_ip6_tlv(val, rloc16, mleid, omr):
+    """
+    Helper to check fields in a Child IPv6 Address List TLV (Type 30).
+    """
+    if read_uint16(val, 0) != rloc16:
+        return False
+
+    addrs = []
+    # Starting from offset 2, we have IPv6 addresses (16 bytes each)
+    offset = 2
+    while offset + 16 <= len(val):
+        addrs.append(Ipv6Addr(val[offset:offset + 16]))
+        offset += 16
+
+    if mleid not in addrs:
+        return False
+    if omr and omr not in addrs:
+        return False
+
+    # The IPv6 Address N fields MUST NOT include an RLOC.
+    for addr in addrs:
+        # Check if it's an RLOC (IID starts with 0000:00ff:fe00)
+        if addr[8:14] == b'\x00\x00\x00\xff\xfe\x00':
+            return False
+
+    return True
+
+
+def check_step10(p, med1_rloc16, med1_mleid, med1_omr, sed1_rloc16, sed1_mleid, sed1_omr):
+    """
+    Checks the pass criteria for Step 10.
+    """
+    tlvs = get_diag_tlvs(p)
+    ip6_tlvs = [v for t, v in tlvs if t == DG_CHILD_IP6_ADDR_LIST_TLV and len(v) > 0]
+    if len(ip6_tlvs) != 2:
+        return False
+
+    found = [False] * 2
+    for val in ip6_tlvs:
+        rloc16 = read_uint16(val, 0)
+        if rloc16 == med1_rloc16:
+            if check_child_ip6_tlv(val, med1_rloc16, med1_mleid, med1_omr):
+                found[0] = True
+        elif rloc16 == sed1_rloc16:
+            if check_child_ip6_tlv(val, sed1_rloc16, sed1_mleid, sed1_omr):
+                found[1] = True
+
+    return all(found)
+
+
+def check_step12(p, leader_rloc16, leader_extaddr, leader_version):
+    """
+    Checks the pass criteria for Step 12.
+    """
+    tlvs = get_diag_tlvs(p)
+    val = get_tlv_value(tlvs, DG_ROUTER_NEIGHBOR_TLV)
+    if val is None:
+        return False
+
+    # bit 7: E bit MUST be set
+    if (val[0] >> 7) & 0x01 != 1:
+        return False
+
+    # RLOC16 (Byte 1-2)
+    if read_uint16(val, 1) != leader_rloc16:
+        return False
+
+    # ExtAddress (Byte 3-10)
+    if val[3:11] != leader_extaddr:
+        return False
+
+    # Version (Byte 11-12)
+    if read_uint16(val, 11) != leader_version:
+        return False
+
+    # Connection Time (Byte 13-16) MUST be > 4
+    if read_uint32(val, 13) <= 4:
+        return False
+
+    # Link Margin (Byte 17) MUST be > 10 and MUST be < 120
+    if val[17] <= 10 or val[17] >= 120:
+        return False
+
+    # Average RSSI (Byte 18) MUST be < 0 and MUST be > -120
+    if read_int8(val, 18) >= 0 or read_int8(val, 18) <= -120:
+        return False
+
+    # Last RSSI (Byte 19) MUST be < 0 and MUST be > -120
+    if read_int8(val, 19) >= 0 or read_int8(val, 19) <= -120:
+        return False
+
+    return True
+
+
+def verify(pv):
+    # 4.1. [1.4] [CERT] Get Diagnostics and Child Information - Router
+    #
+    # 4.1.2. Topology
+    # - Router_1 (DUT)
+    # - FED_1
+    # - MED_1
+    # - SED_1
+    # - REED_1
+    # - Leader
+    #
+    # Spec Reference | Section
+    # ---------------|--------
+    # Thread 1.4     | 4.1
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    Leader_RLOC = pv.vars['Leader_RLOC']
+    Leader_RLOC16 = pv.vars['Leader_RLOC16']
+    Leader_EXTADDR = pv.vars['Leader']
+    Leader_VERSION = pv.vars['Leader_VERSION']
+    Router_1_RLOC = pv.vars['Router_1_RLOC']
+
+    MED_1_RLOC16 = pv.vars['MED_1_RLOC16']
+    SED_1_RLOC16 = pv.vars['SED_1_RLOC16']
+
+    MED_1_MLEID = Ipv6Addr(pv.vars['MED_1_MLEID'])
+    MED_1_OMR = Ipv6Addr(pv.vars['MED_1_OMR']) if pv.vars['MED_1_OMR'] else None
+    SED_1_MLEID = Ipv6Addr(pv.vars['SED_1_MLEID'])
+    SED_1_OMR = Ipv6Addr(pv.vars['SED_1_OMR']) if pv.vars['SED_1_OMR'] else None
+
+    # Step 1: All
+    # - Description (DIAG-4.1): Enable the devices in order. The remaining (child) devices are not yet activated.
+    #   Leader configures its Network Data with an OMR prefix.
+    # - Pass Criteria:
+    #   - Single Thread Network is formed with Leader and the DUT
+    print("Step 1: Single Thread Network is formed with Leader and the DUT.")
+
+    # Step 3: Router_1 (DUT)
+    # - Description (DIAG-4.1): Automatically responds with DIAG_GET.rsp containing the requested Diagnostic TLVs.
+    # - Pass Criteria:
+    #   - The DUT MUST respond with DIAG_GET.rsp
+    #   - The presence of each TLV (as requested in step 2) MUST be validated, with the exception of Max Child Timeout TLV
+    #     (Type 19) which MAY be present.
+    print("Step 3: The DUT MUST respond with DIAG_GET.rsp.")
+    pkts.filter_ipv6_dst(Leader_RLOC).\
+        filter_coap_ack(consts.DIAG_GET_URI).\
+        filter(lambda p: check_step3(p)).\
+        must_next()
+
+    # Step 6: Router_1 (DUT)
+    # - Description (DIAG-4.1): Automatically responds with DIAG_GET.rsp containing the requested Diagnostic TLVs.
+    # - Pass Criteria:
+    #   - The DUT MUST respond with DIAG_GET.rsp
+    #   - Presence of each TLV (as requested in step 5) MUST be validated.
+    #   - Value of Max Child Timeout TLV (Type 19) MUST be '312'.
+    #   - In the MLE Counters TLV (Type 34)...
+    print("Step 6: The DUT MUST respond with DIAG_GET.rsp for MLE Counters.")
+    pkts.filter_ipv6_dst(Leader_RLOC).\
+        filter_coap_ack(consts.DIAG_GET_URI).\
+        filter(lambda p: check_step6(p)).\
+        must_next()
+
+    # Step 8: Router_1 (DUT)
+    # - Description (DIAG-4.1): Automatically responds with DIAG_GET.ans unicast with Child information for all its
+    #   children.
+    # - Pass Criteria:
+    #   - The DUT MUST respond with DIAG_GET.ans
+    #   - The response MUST contain four (4) times a Child TLV (type 29)...
+    print("Step 8: The DUT MUST respond with DIAG_GET.ans with Child information.")
+    pkts.filter_ipv6_dst(Leader_RLOC).\
+        filter_coap_request(consts.DIAG_GET_ANS_URI).\
+        filter(lambda p: check_step8(p, pv.vars)).\
+        must_next()
+
+    # Step 10: Router_1 (DUT)
+    # - Description (DIAG-4.1): Automatically responds with DIAG_GET.ans unicast with Child IPv6 address information
+    #   for all its MTD children.
+    # - Pass Criteria:
+    #   - The DUT MUST respond with DIAG_GET.ans
+    #   - The response MUST contain four (2) times a Child IPv6 Address TLV (type 30)...
+    print("Step 10: The DUT MUST respond with DIAG_GET.ans with Child IPv6 address information.")
+    pkts.filter_ipv6_dst(Leader_RLOC).\
+        filter_coap_request(consts.DIAG_GET_ANS_URI).\
+        filter(lambda p: check_step10(p, MED_1_RLOC16, MED_1_MLEID, MED_1_OMR,
+                                      SED_1_RLOC16, SED_1_MLEID, SED_1_OMR)).\
+        must_next()
+
+    # Step 12: Router_1 (DUT)
+    # - Description (DIAG-4.1): Automatically responds with DIAG_GET.ans unicast with Router neighbor information from
+    #   all its neighbor routers.
+    # - Pass Criteria:
+    #   - The DUT MUST respond with DIAG_GET.ans
+    #   - THe Response MUST contain one (1) Router Neighbor TLV...
+    print("Step 12: The DUT MUST respond with DIAG_GET.ans with Router neighbor information.")
+    pkts.filter_ipv6_dst(Leader_RLOC).\
+        filter_coap_request(consts.DIAG_GET_ANS_URI).\
+        filter(lambda p: check_step12(p, Leader_RLOC16, Leader_EXTADDR, Leader_VERSION)).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DIAG_TC_2.py b/tests/nexus/verify_1_3_DIAG_TC_2.py
new file mode 100644
index 0000000..51a318b
--- /dev/null
+++ b/tests/nexus/verify_1_3_DIAG_TC_2.py
@@ -0,0 +1,315 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.bytes import Bytes
+
+# Diagnostic TLV Types
+DG_EUI64_TLV = 23
+DG_VERSION_TLV = 24
+DG_VENDOR_NAME_TLV = 25
+DG_VENDOR_MODEL_TLV = 26
+DG_VENDOR_SW_VERSION_TLV = 27
+DG_THREAD_STACK_VERSION_TLV = 28
+DG_MLE_COUNTERS_TLV = 34
+
+COAP_CODE_NOT_FOUND = 132
+
+
+def read_uint16(b, offset):
+    return (b[offset] << 8) | b[offset + 1]
+
+
+def read_uint32(b, offset):
+    return (b[offset] << 24) | (b[offset + 1] << 16) | (b[offset + 2] << 8) | b[offset + 3]
+
+
+def read_uint64(b, offset):
+    val = 0
+    for i in range(8):
+        val = (val << 8) | b[offset + i]
+    return val
+
+
+def get_diag_tlvs(p):
+    """
+    Parses Thread Diagnostic TLVs from CoAP payload.
+    Returns a list of (type, value) tuples.
+    """
+    payload = p.coap.payload
+    tlvs = []
+    offset = 0
+    while offset < len(payload):
+        t = payload[offset]
+        l = payload[offset + 1]
+        v = Bytes(payload[offset + 2:offset + 2 + l])
+        tlvs.append((t, v))
+        offset += 2 + l
+    return tlvs
+
+
+def get_tlv_value(tlvs, tlv_type):
+    """
+    Returns the value of the first TLV of a given type.
+    """
+    for t, v in tlvs:
+        if t == tlv_type:
+            return v
+    return None
+
+
+def verify(pv):
+    """
+    4.2. [1.4] [CERT] Get Diagnostics – End Device
+
+    4.2.2. Topology
+    - Leader_1
+    - Router_1
+    - TD_1 (DUT)
+
+    Spec Reference | Section
+    ---------------|--------
+    Thread 1.4     | 4.2
+    """
+    pkts = pv.pkts
+    pv.summary.show()
+
+    Leader_1_RLOC = pv.vars['Leader_1_RLOC']
+
+    # Step 1
+    # Device: Leader, Router_1
+    # Description (DIAG-4.2): Enable the devices in order. The DUT is not yet activated. Leader
+    #   configures its Network Data with an OMR prefix. Note: the OMR prefix can be added using
+    #   an OT CLI command such as: "prefix add 2001:dead:beef:cafe::/64 paros med"
+    # Pass Criteria:
+    # - Single Thread Network is formed with Leader and Router_1.
+    print("Step 1: Single Thread Network is formed with Leader and Router_1.")
+
+    # Step 1b
+    # Device: TD_1 (DUT)
+    # Description (DIAG-4.2): Enable.
+    # Pass Criteria:
+    # - N/A
+    print("Step 1b: TD_1 (DUT) Enable.")
+
+    # Step 2
+    # Device: Leader
+    # Description (DIAG-4.2): Harness instructs device to send DIAG_GET.req to DUT's RLOC for the
+    #   following Diagnostic TLV types: Type 19: Max Child Timeout TLV, Type 23: EUI-64 TLV, Type
+    #   24: Version TLV, Type 25: Vendor Name TLV, Type 26: Vendor Model TLV, Type 27: Vendor SW
+    #   Version TLV, Type 28: Thread Stack Version TLV
+    # Pass Criteria:
+    # - N/A
+    print("Step 2: Leader sends DIAG_GET.req to DUT's RLOC.")
+
+    # Step 3
+    # Device: TD_1 (DUT)
+    # Description (DIAG-4.2): Automatically responds either with 1. DIAG_GET.rsp containing the
+    #   requested Diagnostic TLVs, or a particular subset of those TLVs; 2. CoAP response 4.04 Not
+    #   Found in case no diagnostics are supported at all.
+    # Pass Criteria:
+    # - For case 1, the CoAP status code MUST be 2.04 Changed.
+    # - Each TLV (as requested in step 2) that is present in the response MUST be validated. Any
+    #   number of TLVs MAY be present, from 0 to 7, depending on DUT's diagnostics support level.
+    # - The Max Child Timeout TLV (Type 19) is not expected to be present. However, if present it
+    #   MUST be '0' (zero).
+    # - Value of EUI-64 TLV (Type 23) MUST be non-zero.
+    # - Value of Version TLV (Type 24) MUST be >= '5'.
+    # - Value of Vendor Name TLV (Type 25) MUST have length >= 1.
+    # - Value of Vendor Model TLV (Type 26) MUST have length >= 1.
+    # - Value of Vendor SW Version TLV (Type 27) MUST have length >= 5.
+    # - Value of Thread Stack Version TLV (Type 28) MUST have length >= 5.
+    # - For case 2, the CoAP status code is 4.04 and TLVs MUST NOT be present.
+    print("Step 3: TD_1 (DUT) responds either with 1. DIAG_GET.rsp or 2. CoAP response 4.04.")
+
+    def check_step3(p):
+        if p.coap.code == COAP_CODE_NOT_FOUND:
+            return len(p.coap.payload) == 0
+
+        if p.coap.code != consts.COAP_CODE_ACK:
+            return False
+
+        tlvs = get_diag_tlvs(p)
+        types = {t for t, v in tlvs}
+
+        # Case 1 validation
+        # Each TLV that is present in the response MUST be validated.
+        if consts.DG_MAX_CHILD_TIMEOUT_TLV in types:
+            val = get_tlv_value(tlvs, consts.DG_MAX_CHILD_TIMEOUT_TLV)
+            if read_uint32(val, 0) != 0:
+                return False
+
+        if DG_EUI64_TLV in types:
+            val = get_tlv_value(tlvs, DG_EUI64_TLV)
+            if read_uint64(val, 0) == 0:
+                return False
+
+        if DG_VERSION_TLV in types:
+            val = get_tlv_value(tlvs, DG_VERSION_TLV)
+            if read_uint16(val, 0) < 5:
+                return False
+
+        if DG_VENDOR_NAME_TLV in types:
+            val = get_tlv_value(tlvs, DG_VENDOR_NAME_TLV)
+            if len(val) < 1:
+                return False
+
+        if DG_VENDOR_MODEL_TLV in types:
+            val = get_tlv_value(tlvs, DG_VENDOR_MODEL_TLV)
+            if len(val) < 1:
+                return False
+
+        if DG_VENDOR_SW_VERSION_TLV in types:
+            val = get_tlv_value(tlvs, DG_VENDOR_SW_VERSION_TLV)
+            if len(val) < 5:
+                return False
+
+        if DG_THREAD_STACK_VERSION_TLV in types:
+            val = get_tlv_value(tlvs, DG_THREAD_STACK_VERSION_TLV)
+            if len(val) < 5:
+                return False
+
+        return True
+
+    pkts.filter_ipv6_dst(Leader_1_RLOC).\
+        filter(lambda p: (
+            p.coap.code in (consts.COAP_CODE_ACK, COAP_CODE_NOT_FOUND) and
+            p.coap.opt.uri_path_recon == consts.DIAG_GET_URI
+        )).\
+        filter(lambda p: (
+            check_step3(p)
+        )).\
+        must_next()
+
+    # Step 4
+    # Device: N/A
+    # Description (DIAG-4.2): Harness waits for 4 seconds. Note: this is done only to influence
+    #   diagnostic Time fields that are verified in later steps.
+    # Pass Criteria:
+    # - N/A
+    print("Step 4: Harness waits for 4 seconds.")
+
+    # Step 4
+    # Device: Leader
+    # Description (DIAG-4.2): Harness instructs device to send DIAG_GET.req unicast to DUT's RLOC
+    #   for the following Diagnostic TLV types: Type 34: MLE Counters TLV
+    # Pass Criteria:
+    # - N/A
+    print("Step 4: Leader sends DIAG_GET.req unicast to DUT's RLOC for MLE Counters TLV.")
+
+    # Step 5
+    # Device: TD_1 (DUT)
+    # Description (DIAG-4.2): Automatically responds with DIAG_GET.rsp containing the requested
+    #   Diagnostic TLVs.
+    # Pass Criteria:
+    # - The DUT must send DIAG_GET.rsp
+    # - The presence of each TLV (as requested in the previous step) MUST be validated.
+    # - In the MLE Counters TLV (Type 34):
+    #   - Radio Disabled Counter MUST be '0'
+    #   - Detached Role Counter MUST be '1'
+    #   - Child Role Counter MUST be '1'
+    #   - Router Role Counter MUST be '0'
+    #   - Leader Role Counter MUST be '0'
+    #   - Attach Attempts Counter MUST be >= 1
+    #   - Partition ID Changes Counter MUST be '1'
+    #   - New Parent Counter MUST be '0'
+    #   - Total Tracking Time MUST be > 4000
+    #   - Child Role Time MUST be >= 1
+    #   - Router Role Time MUST be '0'
+    #   - Leader Role Time MUST be '0'
+    print("Step 5: TD_1 (DUT) responds with DIAG_GET.rsp.")
+
+    def check_step5(p):
+        tlvs = get_diag_tlvs(p)
+        types = {t for t, v in tlvs}
+
+        if DG_MLE_COUNTERS_TLV not in types:
+            return False
+
+        val = get_tlv_value(tlvs, DG_MLE_COUNTERS_TLV)
+        if len(val) < 66:
+            return False
+
+        # Radio Disabled Counter MUST be '0'
+        if read_uint16(val, 0) != 0:
+            return False
+        # Detached Role Counter MUST be '1'
+        if read_uint16(val, 2) != 1:
+            return False
+        # Child Role Counter MUST be '1'
+        if read_uint16(val, 4) != 1:
+            return False
+        # Router Role Counter MUST be '0'
+        if read_uint16(val, 6) != 0:
+            return False
+        # Leader Role Counter MUST be '0'
+        if read_uint16(val, 8) != 0:
+            return False
+        # Attach Attempts Counter MUST be >= 1
+        if read_uint16(val, 10) < 1:
+            return False
+        # Partition ID Changes Counter MUST be '1'
+        if read_uint16(val, 12) != 1:
+            return False
+        # New Parent Counter MUST be '0'
+        if read_uint16(val, 16) != 0:
+            return False
+        # Total Tracking Time MUST be > 4000
+        if read_uint64(val, 18) <= 4000:
+            return False
+        # Child Role Time MUST be >= 1
+        if read_uint64(val, 42) < 1:
+            return False
+        # Router Role Time MUST be '0'
+        if read_uint64(val, 50) != 0:
+            return False
+        # Leader Role Time MUST be '0'
+        if read_uint64(val, 58) != 0:
+            return False
+
+        return True
+
+    pkts.filter_ipv6_dst(Leader_1_RLOC).\
+        filter_coap_ack(consts.DIAG_GET_URI).\
+        filter(lambda p: (
+            check_step5(p)
+        )).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DPR_TC_1.py b/tests/nexus/verify_1_3_DPR_TC_1.py
new file mode 100644
index 0000000..73a1c92
--- /dev/null
+++ b/tests/nexus/verify_1_3_DPR_TC_1.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 5.1. [1.3] [CERT] Service discovery of services on Thread and Infrastructure - Single BR
+    #
+    # 5.1.1 Purpose
+    # To test the following:
+    # 1. Discovery proxy on the BR to perform mDNS query on the infrastructure link in the case where it doesn’t have
+    #   any answer.
+    # 2. Respond to unicast DNS queries made by a Thread Device on the Thread interface.
+    # 3. Thread Device can discover services advertised by infrastructure device (mDNS) via BR DUT.
+    # 4. Single BR case
+    #
+    # 5.1.2 Topology
+    # - BR_1 (DUT) - Thread Border Router, and the Leader
+    # - ED_1 - Test Bed device operating as a Thread End Device, attached to BR_1
+    # - Eth_1 - Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference      | V1.1 Section | V1.3.0 Section
+    # --------------------|--------------|---------------
+    # Discovery Proxy     | N/A          | 5.1
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1 = pv.vars['BR_1']
+    BR_1_MLEID = pv.vars['BR_1_MLEID']
+    ED_1 = pv.vars['ED_1']
+    ED_1_MLEID = pv.vars['ED_1_MLEID']
+    Eth_1_ETH = pv.vars['Eth_1_ETH']
+
+    # Step 1: Device: BR_1 (DUT) Description (DPR-5.1): Enable
+    # - Pass Criteria: N/A
+    print("Step 1: BR_1 (DUT) enabled and became Leader.")
+
+    # Step 2: Device: Eth_1, ED_1 Description (DPR-5.1): Enable
+    # - Pass Criteria: N/A
+    print("Step 2: Eth_1 and ED_1 enabled and joined the network.")
+
+    # Step 3: Device: BR_1 (DUT) Description (DPR-5.1): Automatically adds its SRP Server information...
+    # - Pass Criteria:
+    #   - The DUT’s SRP Server information MUST appear in the Thread Network Data.
+    #   - It MUST be a DNS/SRP Unicast Dataset, or DNS/SRP Anycast Dataset, or both.
+    print("Step 3: BR_1 (DUT) adds its SRP Server information to the Thread Network Data.")
+    pkts.filter_wpan_src64(BR_1).\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: {
+            consts.NWD_SERVICE_TLV,
+            consts.NWD_SERVER_TLV
+        } <= set(p.thread_nwd.tlv.type)).\
+        must_next()
+
+    # Step 4: Device: Eth_1 Description (DPR-5.1): Harness instructs device to advertise N=5 services...
+    # - Pass Criteria: N/A
+    print("Step 4: Eth_1 advertises 5 services using mDNS.")
+    pkts.filter_eth_src(Eth_1_ETH).\
+        filter(lambda p: p.mdns).\
+        filter(lambda p: p.mdns.count.answers > 0).\
+        must_next()
+
+    # Step 5: Device: ED_1 Description (DPR-5.1): Harness instructs the device to send an SRP Update...
+    # - Pass Criteria: N/A
+    print("Step 5: ED_1 sends an SRP Update to BR_1 (DUT).")
+    pkts.filter_ipv6_src(ED_1_MLEID).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        must_next()
+
+    # Step 6: Device: BR_1 (DUT) Description (DPR-5.1): Automatically sends SRP Update Response with SUCCESS
+    # - Pass Criteria:
+    #   - The DUT MUST send SRP Update Response with status 0 ( “NoError” ).
+    print("Step 6: BR_1 (DUT) sends SRP Update Response with SUCCESS.")
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        must_next()
+
+    # Step 7: Device: ED_1 Description (DPR-5.1): Harness instructs the device to unicast DNS query QType=PTR...
+    # - Pass Criteria: N/A
+    print("Step 7: ED_1 sends unicast DNS query QType=PTR to BR_1 (DUT).")
+    pkts.filter_ipv6_src(ED_1_MLEID).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: "_infra-test._udp.default.service.arpa" in verify_utils.as_list(p.dns.qry.name)).\
+        filter(lambda p: any(t == consts.DNS_TYPE_PTR for t in verify_utils.as_list(p.dns.qry.type))).\
+        must_next()
+
+    # Step 8: Device: BR_1 (DUT) Description (DPR-5.1): Automatically MAY send mDNS query...
+    # - Pass Criteria: N/A
+    print("Step 8: BR_1 (DUT) MAY send mDNS query on the infrastructure link.")
+
+    # Step 9: Device: Eth_1 Description (DPR-5.1): In case DUT queried in step 8, it automatically sends mDNS...
+    # - Pass Criteria: N/A
+    print("Step 9: Eth_1 sends mDNS response with its active services.")
+    pkts.filter_eth_src(Eth_1_ETH).\
+        filter(lambda p: p.mdns).\
+        filter(lambda p: p.mdns.count.answers > 0).\
+        must_next()
+
+    # Step 10: Device: BR_1 (DUT) Description (DPR-5.1): Automatically sends a DNS response to ED_1
+    # - Pass Criteria: N/A
+    print("Step 10: BR_1 (DUT) sends a DNS response to ED_1.")
+
+    # Step 11: Device: ED_1 Description (DPR-5.1): Receives the DNS response with at least one of the N services
+    # - Pass Criteria:
+    #   - The DUT must send a valid DNS Response, containing in the Answer records section at least one of the services
+    #     advertised by Eth_1, specifically the below where N is a number out of 1, 2, 3, 4, 5:
+    #     $ORIGIN default.service.arpa. _infra-test._udp ( PTR service-test-N._infra-test._udp )
+    #   - It MAY contain in the Additional records section one or more of the below services identified by number N
+    #     plus the host (AAAA) record:
+    #     $ORIGIN default.service.arpa. service-test-1._infra-test._udp ( SRV 0 0 55551 host-test-eth TXT
+    #     dummy=abcd;thread-test=a49 ) ... host-test-eth AAAA <ULA address of Eth_1>
+    #   - Response MUST NOT contain any AAAA record with a link-local address.
+    #   - Response MUST NOT contain “service-test-6” records.
+    #   - Response MUST NOT contain any “host-test-1” records.
+    print("Step 11: ED_1 receives the DNS response with at least one of the 5 services.")
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.count.answers > 0).\
+        filter(lambda p: any(name.startswith("service-test-")
+                             for name in verify_utils.as_list(p.dns.ptr.domain_name))).\
+        filter(lambda p: any(name.endswith("._infra-test._udp.default.service.arpa")
+                             for name in verify_utils.as_list(p.dns.ptr.domain_name))).\
+        filter(lambda p: not any(addr.startswith("fe80")
+                                 for addr in verify_utils.as_list(p.dns.aaaa))).\
+        filter(lambda p: all("service-test-6" not in str(name)
+                             for name in verify_utils.as_list(p.dns.ptr.domain_name))).\
+        filter(lambda p: all("host-test-1" not in str(name)
+                             for name in verify_utils.as_list(p.dns.resp.name))).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_DPR_TC_2.py b/tests/nexus/verify_1_3_DPR_TC_2.py
new file mode 100644
index 0000000..bef853a
--- /dev/null
+++ b/tests/nexus/verify_1_3_DPR_TC_2.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 5.2. 1.3 CERT Service discovery of services on Thread and Infrastructure - Multiple BRs - Multiple Thread -
+    #   Single infrastructure
+    #
+    # 5.2.1. Purpose
+    #   To test the following:
+    #   - 1. Discovery of on-mesh services between multiple Thread networks attached via an adjacent
+    #     infrastructure link.
+    #   - 2. BR Discovery Proxy function can discover services advertised by another BR’s Advertising Proxy
+    #     function, and vice versa.
+    #   - 3. Test both PTR and SRV queries from Thread Network, and finding service in other Network.
+    #
+    # 5.2.2. Topology
+    #   - BR_1 (DUT) - Thread Border Router, and Leader
+    #   - BR_2 - Test Bed border router device operating as a Thread Border Router and Leader of an adjacent Thread
+    #     network
+    #   - ED_1 - Test Bed device operating as a Thread End Device, attached to BR_1
+    #   - ED_2 - Test Bed device operating as a Thread End Device, attached to BR_2
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # Discovery Proxy  | N/A          | 5.2
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1 = pv.vars['BR_1']
+    BR_2 = pv.vars['BR_2']
+    BR_1_ETH = pv.vars['BR_1_ETH']
+    BR_2_ETH = pv.vars['BR_2_ETH']
+    ED_1 = pv.vars['ED_1']
+    ED_2 = pv.vars['ED_2']
+
+    # Step 1
+    # - Device: BR_1 (DUT), ED_1
+    # - Description (DPR-5.2): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: Device: BR_1 (DUT), ED_1 Description (DPR-5.2): Enable")
+
+    # Step 2
+    # - Device: BR_1 (DUT)
+    # - Description (DPR-5.2): Automatically adds its SRP Server information in the Thread Network Data of first
+    #     Thread network.
+    # - Pass Criteria:
+    #   - The DUT’s SRP Server information MUST appear in the Thread Network Data of the first Thread network.
+    print("Step 2: Device: BR_1 (DUT) Description (DPR-5.2): Automatically adds its SRP Server information...")
+    pkts.filter_wpan_src64(BR_1). \
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE). \
+      filter(lambda p: consts.NWD_SERVICE_TLV in p.thread_nwd.tlv.type). \
+      filter(lambda p: consts.NWD_SERVER_TLV in p.thread_nwd.tlv.type). \
+      must_next()
+
+    # Step 3
+    # - Device: ED_1
+    # - Description (DPR-5.2): Harness instructs the device to send SRP Update $ORIGIN default.service.arpa.
+    #     service-test-1._thread-type-1._udp ( SRV 0 4 55555 host-test-1 ) host-test-1 AAAA <ML-EID address of ED_1>
+    #     AAAA <OMR address of ED_1> Note: normally an SRP client would only register the OMR address in this case.
+    #     For testing purposes, both OMR/ML-EID are registered which deviates from usual client behavior.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 3: Device: ED_1 Description (DPR-5.2): Harness instructs the device to send SRP Update...")
+    pkts.filter_wpan_src64(ED_1). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.opcode == 5). \
+      must_next()
+
+    # Step 4
+    # - Device: BR_1 (DUT)
+    # - Description (DPR-5.2): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+    print("Step 4: Device: BR_1 (DUT) Description (DPR-5.2): Automatically sends SRP Update Response")
+    pkts.filter_wpan_src64(BR_1). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.opcode == 5). \
+      filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR). \
+      must_next()
+
+    # Step 5
+    # - Device: BR_2, ED_2
+    # - Description (DPR-5.2): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 5: Device: BR_2, ED_2 Description (DPR-5.2): Enable")
+
+    # Step 6
+    # - Device: BR_2
+    # - Description (DPR-5.2): Automatically adds its SRP Server information in the Thread Network Data of the
+    #     second Thread network.
+    # - Pass Criteria:
+    #   - BR_2’s SRP Server information MUST appear in the Thread Network Data of the second Thread network.
+    print("Step 6: Device: BR_2 Description (DPR-5.2): Automatically adds its SRP Server information...")
+    pkts.filter_wpan_src64(BR_2). \
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE). \
+      filter(lambda p: consts.NWD_SERVICE_TLV in p.thread_nwd.tlv.type). \
+      filter(lambda p: consts.NWD_SERVER_TLV in p.thread_nwd.tlv.type). \
+      must_next()
+
+    # Step 7
+    # - Device: ED_2
+    # - Description (DPR-5.2): Harness instructs the device to send SRP Update $ORIGIN default.service.arpa.
+    #     service-test-2._thread-type-2._udp ( SRV 0 0 55555 host-test-2 ) host-test-2 AAAA <OMR address of ED_2>
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 7: Device: ED_2 Description (DPR-5.2): Harness instructs the device to send SRP Update...")
+    pkts.filter_wpan_src64(ED_2). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.opcode == 5). \
+      must_next()
+
+    # Step 8
+    # - Device: BR_2
+    # - Description (DPR-5.2): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - SRP Update Response is valid and reports NoError (RCODE=0).
+    print("Step 8: Device: BR_2 Description (DPR-5.2): Automatically sends SRP Update Response")
+    pkts.filter_wpan_src64(BR_2). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.opcode == 5). \
+      filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR). \
+      must_next()
+
+    # Step 9
+    # - Device: ED_1
+    # - Description (DPR-5.2): Harness instructs the device to unicast DNS query QType=PTR to the DUT for all
+    #     services of type “_thread-type-2._udp.default.service.arpa.”.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 9: Device: ED_1 Description (DPR-5.2): Harness instructs the device to unicast DNS query...")
+    pkts.filter_wpan_src64(ED_1). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: "_thread-type-2._udp.default.service.arpa" in verify_utils.as_list(p.dns.qry.name)). \
+      filter(lambda p: any(t == consts.DNS_TYPE_PTR for t in verify_utils.as_list(p.dns.qry.type))). \
+      must_next()
+
+    # Step 10
+    # - Device: BR_1 (DUT)
+    # - Description (DPR-5.2): Automatically sends DNS Response. Note: during this step, DUT performs mDNS query to
+    #     BR_2 which responds with service-test-2. This isn’t shown in detail.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response, containing service-test-2 as Answer record: $ORIGIN
+    #     default.service.arpa. _thread-type-2._udp ( PTR service-test-2._thread-type-2._udp )
+    #   - It MAY contain in the Additional records section: $ORIGIN default.service.arpa.
+    #     service-test-2._thread-type-2._udp ( SRV 0 0 55555 host-test-2 ) host-test-2 AAAA <OMR address of ED_2>
+    print("Step 10: Device: BR_1 (DUT) Description (DPR-5.2): Automatically sends DNS Response")
+    pkts.filter_wpan_src64(BR_1). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: any("service-test-2" in str(name)
+                           for name in verify_utils.as_list(p.dns.ptr.domain_name))). \
+      must_next()
+
+    # Step 11
+    # - Device: ED_2
+    # - Description (DPR-5.2): Harness instructs the device to unicast DNS query QType=PTR to BR_2 for all services
+    #     of type “_thread-type-1._udp.default.service.arpa.”.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 11: Device: ED_2 Description (DPR-5.2): Harness instructs the device to unicast DNS query...")
+    pkts.filter_wpan_src64(ED_2). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: "_thread-type-1._udp.default.service.arpa" in verify_utils.as_list(p.dns.qry.name)). \
+      filter(lambda p: any(t == consts.DNS_TYPE_PTR for t in verify_utils.as_list(p.dns.qry.type))). \
+      must_next()
+
+    # Step 12
+    # - Device: BR_2
+    # - Description (DPR-5.2): Automatically sends mDNS query.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 12: Device: BR_2 Description (DPR-5.2): Automatically sends mDNS query")
+    pkts.filter_eth_src(BR_2_ETH). \
+      filter(lambda p: p.mdns). \
+      filter(lambda p: "_thread-type-1._udp.local" in verify_utils.as_list(p.mdns.qry.name)). \
+      must_next()
+
+    # Step 13
+    # - Device: BR_1 (DUT)
+    # - Description (DPR-5.2): Automatically responds to mDNS query with “service-test-1”.
+    # - Pass Criteria:
+    #   - N/A (verified in step 14)
+    print("Step 13: Device: BR_1 (DUT) Description (DPR-5.2): Automatically responds to mDNS query...")
+    pkts.filter_eth_src(BR_1_ETH). \
+      filter(lambda p: p.mdns). \
+      filter(lambda p: any("service-test-1" in str(name)
+                           for name in verify_utils.as_list(p.mdns.resp.name))). \
+      must_next()
+
+    # Step 14
+    # - Device: BR_2
+    # - Description (DPR-5.2): Automatically sends a DNS response to ED_2.
+    # - Pass Criteria:
+    #   - The DNS Response is received at ED_2, is valid and contains only service-test-1 in the Answers section:
+    #     $ORIGIN default.service.arpa. _thread-type-1._udp ( PTR service-test-1._thread-type-1._udp )
+    #   - It MAY contain in the Additional records section: $ORIGIN default.service.arpa.
+    #     service-test-1._thread-type-1._udp ( SRV 0 4 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+    #   - Also it MAY contain in the Additional records section, but only if the above Additional records are also
+    #     included: $ORIGIN default.service.arpa. host-test-1 AAAA <ML-EID address of ED_1>
+    print("Step 14: Device: BR_2 Description (DPR-5.2): Automatically sends a DNS response to ED_2")
+    pkts.filter_wpan_src64(BR_2). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: any("service-test-1" in str(name)
+                           for name in verify_utils.as_list(p.dns.ptr.domain_name))). \
+      must_next()
+
+    # Step 15
+    # - Device: ED_1
+    # - Description (DPR-5.2): Harness instructs the device to unicast DNS query QType=SRV to the DUT for service
+    #     “service-test-2._thread-type-2._udp.default.service.arpa.”.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 15: Device: ED_1 Description (DPR-5.2): Harness instructs the device to unicast DNS query...")
+    pkts.filter_wpan_src64(ED_1). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: "service-test-2._thread-type-2._udp.default.service.arpa"
+             in verify_utils.as_list(p.dns.qry.name)). \
+      filter(lambda p: any(t == consts.DNS_TYPE_SRV for t in verify_utils.as_list(p.dns.qry.type))). \
+      must_next()
+
+    # Step 16
+    # - Device: BR_1 (DUT)
+    # - Description (DPR-5.2): Automatically sends DNS Response. Note: during this step, DUT MAY perform mDNS query
+    #     to BR_2 which responds with service-test-2. This isn’t shown in detail.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response is valid, containing service-test-2 answer record: $ORIGIN
+    #     default.service.arpa. service-test-2._thread-type-2._udp ( SRV 0 0 55555 host-test-2 )
+    #   - It MAY contain in the Additional records section: host-test-2 AAAA <OMR address of ED_2>
+    print("Step 16: Device: BR_1 (DUT) Description (DPR-5.2): Automatically sends DNS Response")
+    pkts.filter_wpan_src64(BR_1). \
+      filter(lambda p: p.dns). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: any("service-test-2" in str(name)
+                           for name in verify_utils.as_list(p.dns.resp.name))). \
+      must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_GEN_TC_1.py b/tests/nexus/verify_1_3_GEN_TC_1.py
new file mode 100644
index 0000000..bf20fff
--- /dev/null
+++ b/tests/nexus/verify_1_3_GEN_TC_1.py
@@ -0,0 +1,346 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+import struct
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts, coap
+from pktverify.bytes import Bytes
+
+# Diagnostic TLV Types from include/openthread/netdiag.h
+DG_TYPE_LIST_TLV = 18
+DG_VERSION_TLV = 24
+
+# CoAP Codes
+COAP_CODE_CHANGED = 68
+COAP_CODE_NOT_FOUND = 132
+
+
+def get_diag_tlvs(p):
+    """
+    Parses Thread Diagnostic TLVs from CoAP payload.
+    Returns a list of (type, value) tuples.
+    """
+    try:
+        payload = p.coap.payload
+    except AttributeError:
+        return []
+    tlvs = []
+    offset = 0
+    while offset < len(payload):
+        t = payload[offset]
+        l = payload[offset + 1]
+        v = Bytes(payload[offset + 2:offset + 2 + l])
+        tlvs.append((t, v))
+        offset += 2 + l
+    return tlvs
+
+
+def get_tlv_value(tlvs, tlv_type):
+    """
+    Returns the value of the first TLV of a given type.
+    """
+    for t, v in tlvs:
+        if t == tlv_type:
+            return v
+    return None
+
+
+def get_diag_tlv_type_list(p):
+    """
+    Parses Thread Diagnostic Type List TLV from CoAP payload.
+    Returns a list of TLV types.
+    """
+    try:
+        payload = p.coap.payload
+    except AttributeError:
+        return []
+    offset = 0
+    while offset < len(payload):
+        t = payload[offset]
+        l = payload[offset + 1]
+        if t == DG_TYPE_LIST_TLV:
+            return list(payload[offset + 2:offset + 2 + l])
+        offset += 2 + l
+    return []
+
+
+def verify(pv):
+    # 9.1. [1.3] [CERT] Thread Version is '4' or higher
+    #
+    # 9.1.1. Purpose
+    # Verify that the Thread Version TLV uses the value '4' or higher. '4' is required for Thread 1.3.x devices. '5'
+    #   is required for Thread 1.4.x devices.
+    #
+    # 9.1.2. Topology
+    # - BR_1: Thread Border Router DUT
+    # - Router_1: Thread Router DUT
+    # - ED_1: Thread End Device DUT, including FEDs, MEDs and SEDs
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # Thread Version   | 5.18.2.1     | 5.18.2.1
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1 = pv.vars['BR_1']
+    ROUTER_1 = pv.vars['Router_1']
+    ED_1 = pv.vars['ED_1']
+
+    BR_1_RLOC16 = pv.vars['BR_1_RLOC16']
+    ROUTER_1_RLOC16 = pv.vars['Router_1_RLOC16']
+    ED_1_RLOC16 = pv.vars['ED_1_RLOC16']
+
+    # Step 1: BR_1 Enable
+    # - Description (GEN-9.1): Enable
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = BR_1: The DUT MUST become Leader of the Thread Network.
+    print("Step 1: BR_1 becomes Leader of the Thread Network.")
+    pkts.filter_wpan_src64(BR_1).\
+        filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
+        filter(lambda p: {
+                          consts.LEADER_DATA_TLV,
+                          consts.SOURCE_ADDRESS_TLV
+                          } <= set(p.mle.tlv.type)).\
+        must_next()
+
+    # Step 2: Router_1 Enable
+    # - Description (GEN-9.1): Enable
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - N/A
+    print("Step 2: Router_1 is enabled.")
+
+    # Step 3: Router_1 Automatically sends MLE Parent Request multicast.
+    # - Description (GEN-9.1): Automatically sends MLE Parent Request multicast.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = Router: The DUT MUST sends MLE Parent Request multicast, with MLE Version TLV with value >= 4.
+    print("Step 3: Router_1 sends MLE Parent Request multicast with MLE Version TLV >= 4.")
+    pkts.filter_wpan_src64(ROUTER_1).\
+        filter_LLARMA().\
+        filter_mle_cmd(consts.MLE_PARENT_REQUEST).\
+        filter(lambda p: p.mle.tlv.version >= 4).\
+        must_next()
+
+    # Step 4: BR_1 Automatically sends MLE Parent Response unicast.
+    # - Description (GEN-9.1): Automatically sends MLE Parent Response unicast.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = BR_1: The DUT MUST sends MLE Parent Response unicast, with MLE Version TLV with value >= 4.
+    print("Step 4: BR_1 sends MLE Parent Response unicast with MLE Version TLV >= 4.")
+    pkts.filter_wpan_src64(BR_1).\
+        filter_wpan_dst64(ROUTER_1).\
+        filter_mle_cmd(consts.MLE_PARENT_RESPONSE).\
+        filter(lambda p: p.mle.tlv.version >= 4).\
+        must_next()
+
+    # Step 5: Router_1 Automatically completes attachment procedure and joins the network.
+    # - Description (GEN-9.1): Automatically completes attachment procedure and joins the network.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = Router: The DUT MUST send MLE Child ID Request message with MLE Version TLV with value >= 4.
+    print("Step 5: Router_1 sends MLE Child ID Request with MLE Version TLV >= 4.")
+    pkts.filter_wpan_src64(ROUTER_1).\
+        filter_mle_cmd(consts.MLE_CHILD_ID_REQUEST).\
+        filter(lambda p: p.mle.tlv.version >= 4).\
+        must_next()
+
+    # Step 6: ED_1 Automatically sends MLE Parent Request multicast.
+    # - Description (GEN-9.1): Automatically sends MLE Parent Request multicast.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = ED_1: The DUT MUST sends MLE Parent Request multicast with MLE Version TLV with value >= 4.
+    print("Step 6: ED_1 sends MLE Parent Request multicast with MLE Version TLV >= 4.")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_LLARMA().\
+        filter_mle_cmd(consts.MLE_PARENT_REQUEST).\
+        filter(lambda p: p.mle.tlv.version >= 4).\
+        must_next()
+
+    # Step 7: Router_1 Automatically sends MLE Parent Response unicast.
+    # - Description (GEN-9.1): Automatically sends MLE Parent Response unicast.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = Router_1: The DUT MUST sends MLE Parent Response unicast with MLE Version TLV with value >= 4.
+    print("Step 7: Router_1 sends MLE Parent Response unicast with MLE Version TLV >= 4.")
+    pkts.filter_wpan_src64(ROUTER_1).\
+        filter_wpan_dst64(ED_1).\
+        filter_mle_cmd(consts.MLE_PARENT_RESPONSE).\
+        filter(lambda p: p.mle.tlv.version >= 4).\
+        must_next()
+
+    # Step 8: ED_1 Automatically completes attachment procedure and joins the network.
+    # - Description (GEN-9.1): Automatically completes attachment procedure and joins the network.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = ED_1: The DUT MUST send MLE Child ID Request message with MLE Version TLV with value >= 4.
+    print("Step 8: ED_1 sends MLE Child ID Request with MLE Version TLV >= 4.")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_mle_cmd(consts.MLE_CHILD_ID_REQUEST).\
+        filter(lambda p: p.mle.tlv.version >= 4).\
+        must_next()
+
+    # Step 9: Router_1 Harness instructs device to send MLE Discovery Request multicast.
+    # - Description (GEN-9.1): Note: only performed if BR_1 is DUT. Harness instructs device to send MLE Discovery
+    #   Request multicast.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - N/A
+    print("Step 9: Router_1 sends MLE Discovery Request multicast.")
+    pkts.filter_wpan_src64(ROUTER_1).\
+        filter_LLARMA().\
+        filter_mle_cmd(consts.MLE_DISCOVERY_REQUEST).\
+        must_next()
+
+    # Step 10: BR_1 Automatically responds with MLE Discovery Response unicast.
+    # - Description (GEN-9.1): Note: only performed if BR_1 is DUT. Automatically responds with MLE Discovery
+    #   Response unicast.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = BR_1: The DUT MUST send MLE Discovery Response with MLE Discovery Response TLV with a value >= 4
+    #     of the 'Ver' field bits.
+    print("Step 10: BR_1 sends MLE Discovery Response with Discovery Version >= 4.")
+    pkts.filter_wpan_src64(BR_1).\
+        filter_wpan_dst64(ROUTER_1).\
+        filter_mle_cmd(consts.MLE_DISCOVERY_RESPONSE).\
+        filter(lambda p: p.thread_meshcop.tlv.discovery_rsp_ver >= 4).\
+        must_next()
+
+    # Step 11: ED_1 Harness instructs device to send MLE Discovery Request multicast.
+    # - Description (GEN-9.1): Note: only performed if Router_1 is DUT. Harness instructs device to send MLE Discovery
+    #   Request multicast.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - N/A
+    print("Step 11: ED_1 sends MLE Discovery Request multicast.")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_LLARMA().\
+        filter_mle_cmd(consts.MLE_DISCOVERY_REQUEST).\
+        must_next()
+
+    # Step 12: Router_1 Automatically responds with MLE Discovery Response unicast.
+    # - Description (GEN-9.1): Note: only performed if Router_1 is DUT. Automatically responds with MLE Discovery
+    #   Response unicast.
+    # - Pass Criteria (only applies if Device == DUT):
+    #   - For DUT = Router_1: The DUT MUST send MLE Discovery Response with MLE Discovery Response TLV with a value >= 4
+    #     of the 'Ver' field bits.
+    print("Step 12: Router_1 sends MLE Discovery Response with Discovery Version >= 4.")
+    pkts.filter_wpan_src64(ROUTER_1).\
+        filter_wpan_dst64(ED_1).\
+        filter_mle_cmd(consts.MLE_DISCOVERY_RESPONSE).\
+        filter(lambda p: p.thread_meshcop.tlv.discovery_rsp_ver >= 4).\
+        must_next()
+
+    # Steps 13-18 are for v1.4 devices
+    # Check if we have diagnostic packets to decide if we should verify them
+    if pkts.filter_coap_request('/d/dg'):
+        # Step 13: Router_1 sends TMF Get Diagnostic Request (DIAG_GET.req) unicast to the DUT.
+        # - Description (GEN-9.1): Note: only performed if BR_1 is DUT and >= v1.4 Device. Harness instructs device to
+        #   send TMF Get Diagnostic Request (DIAG_GET.req) unicast to the DUT, requesting Diagnostic Version TLV
+        #   (Type 24)
+        # - Pass Criteria (only applies if Device == DUT):
+        #   - N/A
+        print("Step 13: Router_1 sends DIAG_GET.req to BR_1.")
+        pkts.filter_wpan_src16(ROUTER_1_RLOC16).\
+            filter_wpan_dst16(BR_1_RLOC16).\
+            filter_coap_request('/d/dg').\
+            filter(lambda p: DG_VERSION_TLV in get_diag_tlv_type_list(p)).\
+            must_next()
+
+        # Step 14: BR_1 Automatically responds with Get Diagnostic Response unicast to Router_1.
+        # - Description (GEN-9.1): Note: only performed if BR_1 is DUT and >= v1.4 Device. Automatically responds with
+        #   Get Diagnostic Response (DIAG_GET.rsp) unicast to Router_1.
+        # - Pass Criteria (only applies if Device == DUT):
+        #   - For DUT = BR_1 and >= v1.4 Device: The DUT MUST send DIAG_GET.RSP with Version TLV (Type 24)
+        #     with value >= 5.
+        print("Step 14: BR_1 sends DIAG_GET.rsp with Version TLV >= 5.")
+        pkts.filter_wpan_src16(BR_1_RLOC16).\
+            filter_wpan_dst16(ROUTER_1_RLOC16).\
+            filter_coap_ack('/d/dg').\
+            filter(lambda p: struct.unpack('>H', get_tlv_value(get_diag_tlvs(p), DG_VERSION_TLV))[0] >= 5).\
+            must_next()
+
+        # Step 15: BR_1 Harness instructs device to send TMF Get Diagnostic Request unicast to the DUT.
+        # - Description (GEN-9.1): Note: only performed if Router_1 is DUT and >= v1.4 Device. Harness instructs device
+        #   to send TMF Get Diagnostic Request (DIAG_GET.req) unicast to the DUT, requesting Diagnostic Version TLV
+        #   (Type 24)
+        # - Pass Criteria (only applies if Device == DUT):
+        #   - N/A
+        print("Step 15: BR_1 sends DIAG_GET.req to Router_1.")
+        pkts.filter_wpan_src16(BR_1_RLOC16).\
+            filter_wpan_dst16(ROUTER_1_RLOC16).\
+            filter_coap_request('/d/dg').\
+            filter(lambda p: DG_VERSION_TLV in get_diag_tlv_type_list(p)).\
+            must_next()
+
+        # Step 16: Router_1 Automatically responds with Get Diagnostic Response unicast to the DUT.
+        # - Description (GEN-9.1): Note: only performed if Router_1 is DUT and >= v1.4 Device. Automatically responds
+        #   with Get Diagnostic Response (DIAG_GET.rsp) unicast to the DUT.
+        # - Pass Criteria (only applies if Device == DUT):
+        #   - For DUT = Router_1 and >= v1.4 device: The DUT MUST send DIAG_GET.RSP with Version TLV (Type 24)
+        #     with value >= 5.
+        print("Step 16: Router_1 sends DIAG_GET.rsp with Version TLV >= 5.")
+        pkts.filter_wpan_src16(ROUTER_1_RLOC16).\
+            filter_wpan_dst16(BR_1_RLOC16).\
+            filter_coap_ack('/d/dg').\
+            filter(lambda p: struct.unpack('>H', get_tlv_value(get_diag_tlvs(p), DG_VERSION_TLV))[0] >= 5).\
+            must_next()
+
+        # Step 17: Router_1 Harness instructs device to send TMF Get Diagnostic Request unicast to the DUT.
+        # - Description (GEN-9.1): Note: only performed if ED_1 is DUT and >= v1.4 Device. Harness instructs device to
+        #   send TMF Get Diagnostic Request (DIAG_GET.req) unicast to the DUT, requesting Diagnostic Version TLV
+        #   (Type 24)
+        # - Pass Criteria (only applies if Device == DUT):
+        #   - N/A
+        print("Step 17: Router_1 sends DIAG_GET.req to ED_1.")
+        pkts.filter_wpan_src16(ROUTER_1_RLOC16).\
+            filter_wpan_dst16(ED_1_RLOC16).\
+            filter_coap_request('/d/dg').\
+            filter(lambda p: DG_VERSION_TLV in get_diag_tlv_type_list(p)).\
+            must_next()
+
+        # Step 18: ED_1 Optionally responds with Get Diagnostic Response unicast to Router_1.
+        # - Description (GEN-9.1): Note: only performed if ED_1 is DUT and >= v1.4 Device. Optionally responds with Get
+        #   Diagnostic Response (DIAG_GET.rsp) unicast to Router_1.
+        # - Pass Criteria (only applies if Device == DUT):
+        #   - For DUT = ED_1 and >= v1.4 device: The DUT MUST respond in either of below two ways:
+        #   - 1. DIAG_GET.rsp Response is successfully received, and MUST contain the Version TLV (Type 24)
+        #     with value >= 5.
+        #   - 2. CoAP error response is received with code 4.04 Not Found.
+        #   - Note: in case 2, the DUT does not support the optional diagnostics request.
+        print("Step 18: ED_1 optionally responds with Get Diagnostic Response or 4.04 Not Found.")
+        pkts.filter_wpan_src16(ED_1_RLOC16).\
+            filter_wpan_dst16(ROUTER_1_RLOC16).\
+            filter_coap_ack('/d/dg').\
+            filter(lambda p: (p.coap.code == COAP_CODE_CHANGED and\
+                              struct.unpack('>H', get_tlv_value(get_diag_tlvs(p), DG_VERSION_TLV))[0] >= 5) or\
+                             (p.coap.code == COAP_CODE_NOT_FOUND)).\
+            must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_GEN_TC_2.py b/tests/nexus/verify_1_3_GEN_TC_2.py
new file mode 100644
index 0000000..5804493
--- /dev/null
+++ b/tests/nexus/verify_1_3_GEN_TC_2.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import verify_utils
+import ipaddress
+
+
+def parse_txt(txt_records):
+    txt_dict = {}
+    for entry in txt_records:
+        if not entry:
+            continue
+        try:
+            # entry is expected to be a Bytes object (from pktverify)
+            if b'=' in entry:
+                k, v = entry.split(b'=', 1)
+                txt_dict[k.decode('ascii', errors='ignore')] = v
+            else:
+                txt_dict[entry.decode('ascii', errors='ignore')] = None
+        except Exception:
+            continue
+    return txt_dict
+
+
+def verify(pv):
+    # Spec Reference | V1.3.0 Section
+    # ---------------|---------------
+    # Thread 1.3.0   | 9.2
+
+    agent_ids = []
+
+    last_idx = (0, 0)
+    pkts = pv.pkts
+
+    for i in range(2):
+        print(f"Iteration {i}")
+
+        BR_1_EXT_ADDR = pv.vars[f'BR_1_EXT_ADDR_{i}'].replace(':', '').lower()
+        BR_1_OMR_PREFIX = pv.vars[f'BR_1_OMR_PREFIX_{i}']
+        expected_pfx = ipaddress.IPv6Network(BR_1_OMR_PREFIX)
+        # OMR value is 8 bytes of the network address in hex
+        expected_omr_value = expected_pfx.network_address.packed[:8].hex().lower()
+
+        print(f"Iteration {i} Expected ExtAddr: {BR_1_EXT_ADDR}")
+        print(f"Iteration {i} Expected OMR Value: {expected_omr_value}")
+
+        print("Step 1: Form topology. DUT becomes Leader. Configures OMR prefix.")
+
+        def match_ra(p):
+            if not (hasattr(p, 'icmpv6') and p.icmpv6.type == 134):
+                return False
+            if not hasattr(p.icmpv6, 'opt') or not hasattr(p.icmpv6.opt, 'prefix'):
+                return False
+            for pfx in p.icmpv6.opt.prefix:
+                try:
+                    actual_pfx = ipaddress.IPv6Network(str(pfx) + '/64', strict=False)
+                    if actual_pfx.network_address == expected_pfx.network_address:
+                        return True
+                except ValueError:
+                    continue
+            return False
+
+        ra = pkts.range(last_idx).filter(match_ra).must_next()
+        print(f"Recorded OMR prefix: {BR_1_OMR_PREFIX} (found in packet {ra.number})")
+        last_idx = pkts.index
+
+        print("Step 2: Eth_1 sends mDNS PTR query for _meshcop._udp.local.")
+        ptr_query = pkts.range(last_idx).filter(lambda p: (hasattr(p, 'mdns') and p.mdns.flags.response == 0 and any(
+            '_meshcop._udp.local' in name for name in p.mdns.qry.name))).must_next()
+        last_idx = pkts.index
+
+        print("Step 3: BR_1 (DUT) responds with mDNS response with PTR records.")
+        ptr_resp = pkts.range(last_idx).filter(lambda p: (hasattr(p, 'mdns') and p.mdns.flags.response == 1 and any(
+            '_meshcop._udp.local' in name for name in p.mdns.resp.name))).must_next()
+        last_idx = pkts.index
+
+        print("Step 5: BR_1 (DUT) responds with mDNS response with TXT record.")
+
+        txt_resp = None
+        for pkt in pkts.range(last_idx):
+            if not (hasattr(pkt, 'mdns') and pkt.mdns.flags.response == 1 and hasattr(pkt.mdns, 'txt')):
+                continue
+
+            txt_dict = parse_txt(pkt.mdns.txt)
+            if 'omr' in txt_dict and txt_dict['omr'].hex().endswith(expected_omr_value):
+                txt_resp = pkt
+                break
+
+        assert txt_resp is not None, f"Could not find TXT response for iteration {i} with OMR value {expected_omr_value}"
+        print(f"Found TXT response in packet {txt_resp.number}")
+        last_idx = (txt_resp.number, txt_resp.number)
+
+        print("Step 6: Verify TXT record keys.")
+        txt_dict = parse_txt(txt_resp.mdns.txt)
+
+        # omr: First byte 0x40, the rest is OMR prefix 8 bytes. Total 9 bytes.
+        assert 'omr' in txt_dict, "omr TXT record missing"
+        omr_val = txt_dict['omr']
+        assert omr_val[0] == 0x40, f"omr value first byte should be 0x40, got {omr_val[0]}"
+        assert omr_val[1:].hex(
+        ) == expected_omr_value, f"omr value mismatch, expected {expected_omr_value}, got {omr_val[1:].hex()}"
+        assert len(omr_val) == 9, f"omr value length should be 9 bytes, got {len(omr_val)}"
+
+        # id: Random number (16 bytes)
+        assert 'id' in txt_dict, "id TXT record missing"
+        agent_id = txt_dict['id'].hex()
+        assert len(agent_id) == 32, f"id length should be 16 bytes, got {len(agent_id)//2}"
+        agent_ids.append(agent_id)
+
+        # nn: "Thread Cert 9.2"
+        assert 'nn' in txt_dict, "nn TXT record missing"
+        nn_val = txt_dict['nn'].decode('ascii')
+        assert nn_val == "Thread Cert 9.2", f"nn value mismatch, expected 'Thread Cert 9.2', got '{nn_val}'"
+
+        # tv: "1.3.0" or "1.4.0"
+        assert 'tv' in txt_dict, "tv TXT record missing"
+        tv_val = txt_dict['tv'].decode('ascii')
+        assert tv_val in ["1.3.0", "1.4.0"], f"tv value mismatch, expected '1.3.0' or '1.4.0', got '{tv_val}'"
+
+        # sb: Bit 9 and 10 MUST be '1'.
+        assert 'sb' in txt_dict, "sb TXT record missing"
+        # sb is 4 bytes.
+        sb_val = int.from_bytes(txt_dict['sb'], 'big')
+        assert (sb_val & 0x00000600) == 0x00000600, f"sb bits 9 and 10 must be '1', got {hex(sb_val)}"
+
+        # xa: MAC Extended Address
+        assert 'xa' in txt_dict, "xa TXT record missing"
+        xa_val = txt_dict['xa'].hex()
+        assert xa_val == BR_1_EXT_ADDR, f"xa value mismatch, expected {BR_1_EXT_ADDR}, got {xa_val}"
+
+        # at: Active Timestamp 0x000000666af13100
+        assert 'at' in txt_dict, "at TXT record missing"
+        at_val = txt_dict['at'].hex()
+        assert at_val == '000000666af13100', f"at value mismatch, expected '000000666af13100', got '{at_val}'"
+
+        # vn: "NexusVendor"
+        assert 'vn' in txt_dict, "vn TXT record missing"
+        vn_val = txt_dict['vn'].decode('ascii')
+        assert vn_val == "NexusVendor", f"vn value mismatch, expected 'NexusVendor', got '{vn_val}'"
+
+        # mn: "NexusModel"
+        assert 'mn' in txt_dict, "mn TXT record missing"
+        mn_val = txt_dict['mn'].decode('ascii')
+        assert mn_val == "NexusModel", f"mn value mismatch, expected 'NexusModel', got '{mn_val}'"
+
+        # vo: 0x000001
+        assert 'vo' in txt_dict, "vo TXT record missing"
+        vo_val = txt_dict['vo'].hex()
+        assert vo_val == '000001', f"vo value mismatch, expected '000001', got '{vo_val}'"
+
+        if i == 1:
+            assert agent_ids[0] != agent_ids[1], f"agent id should be different after factory reset: {agent_ids}"
+
+    print("Verification SUCCESS")
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRPC_TC_1.py b/tests/nexus/verify_1_3_SRPC_TC_1.py
new file mode 100644
index 0000000..5a6d055
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRPC_TC_1.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 3.1. [1.3] [CERT] [COMPONENT] Re-register service with active SRP server - Multiple BRs - No AIL
+    #
+    # 3.1.1. Purpose
+    # To test the following:
+    # - DNS/SRP Unicast Dataset(s) offered in the Thread network
+    # - Detects unresponsiveness of the SRP server and sends SRP update to another active SRP server
+    # - Detects a second, numerically lower, SRP server and DUT stays with its current SRP server and does not switch
+    #   to the numerically lowest SRP server
+    # - Registered service is automatically re-registered with active SRP server before timeout
+    # - In the absence of an OMR prefix, mesh-local (ML-EID) address will be used by SRP client.
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Client       | N/A          | 2.3.2
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_MLEID = pv.vars['BR_1_MLEID']
+    BR_2_MLEID = pv.vars['BR_2_MLEID']
+    ED_1 = pv.vars['ED_1']
+    ED_1_MLEID = pv.vars['ED_1_MLEID']
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service-test-1.' + SRP_SERVICE_NAME
+
+    # Step 0
+    # Device: Router 1
+    # Description (SRPC-3.1): Enable. Automatically, becomes Leader.
+    # Pass Criteria:
+    #   N/A
+    print("Step 0: Router 1 becomes Leader.")
+
+    # Step 1
+    # Device: BR_1
+    # Description (SRPC-3.1): Enable: Harness configures the device with a numerically lower ML-EID address than
+    #   BR_2 will have. Note: due to absence of adjacent infrastructure link (AIL), no OMR address is configured by
+    #   the BR.
+    # Pass Criteria:
+    #   N/A
+    print("Step 1: BR_1 joins the network.")
+
+    # Step 2
+    # Device: ED 1 (DUT)
+    # Description (SRPC-3.1): Enable; automatically connects to Router_1.
+    # Pass Criteria:
+    #   N/A
+    print("Step 2: ED_1 joins the network.")
+
+    # Step 3
+    # Device: BR 1
+    # Description (SRPC-3.1): Automatically adds its SRP Server information in the Thread Network Data by using a
+    #   DNS/SRP Unicast Dataset, indicating its ML-EID address.
+    # Pass Criteria:
+    #   N/A
+    print("Step 3: BR_1 adds its SRP Server information.")
+
+    # Step 4
+    # Device: ED 1 (DUT)
+    # Description (SRPC-3.1): Harness instructs DUT to add a service to the device: $ORIGIN default.service.arpa.
+    #   service-test-1._thread-test._udp ( SRV 33333 host-test-1 ) host-test-1 AAAA <ML-EID address of ED_1> with the
+    #   following options: Update Lease Option Lease: 30 seconds Key Lease: 10 minutes
+    # Pass Criteria:
+    #   - The DUT ED_1 MUST send SRP Update to BR_1 containing the service-test-1.
+    #   - The IPv6 address in the AAAA record of the SRP Update MUST be an ML-EID, specifically the ML-EID of the DUT.
+    #   - It MUST NOT contain any other IPv6 addresses in AAAA records.
+    print("Step 4: ED_1 sends SRP Update to BR_1.")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: ED_1_MLEID in verify_utils.as_list(p.dns.aaaa)).\
+        filter(lambda p: len(verify_utils.as_list(p.dns.aaaa)) == 1).\
+        must_next()
+
+    # Step 5
+    # Device: BR_1
+    # Description (SRPC-3.1): Automatically sends DNS Response (Rcode=0, NoError).
+    # Pass Criteria:
+    #   N/A
+    print("Step 5: BR_1 sends DNS Response.")
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        must_next()
+
+    # Step 6
+    # Device: BR 2
+    # Description (SRPC-3.1): Enable. Harness configures device with a numerically higher ML-EID address than BR 1.
+    #   This ensures that the BR 2 SRP service will be less preferred to the DUT. Note: due to absence of adjacent
+    #   infrastructure link (AIL), no OMR address is configured by the BR.
+    # Pass Criteria:
+    #   N/A
+    print("Step 6: BR_2 joins the network.")
+
+    # Step 7
+    # Device: BR 2
+    # Description (SRPC-3.1): Harness instructs the device to add its SRP Server information in the Thread Network
+    #   Data using a DNS/SRP Unicast Dataset. Note: normally BR_2 does not add this information, since an SRP server
+    #   is already advertised on the Thread Network. The Harness overrides this behavior.
+    # Pass Criteria:
+    #   - The DUT MUST NOT send SRP Update to BR_2.
+    print("Step 7: BR_2 adds its SRP Server information. MUST NOT send SRP Update to BR_2.")
+
+    # Step 8
+    # Device: BR 1
+    # Description (SRPC-3.1): Harness deactivates the device.
+    # Pass Criteria:
+    #   N/A
+    print("Step 8: BR_1 is deactivated.")
+
+    # Step 8b
+    # Device: N/A
+    # Description (SRPC-3.1): Harness waits 30 seconds. Within this time, the next step must happen.
+    # Pass Criteria:
+    #   N/A
+    print("Step 8b: Wait 30 seconds.")
+
+    # Step 9
+    # Device: ED 1 (DUT)
+    # Description (SRPC-3.1): Automatically sends SRP Update to BR_1 to renew service.
+    # Pass Criteria:
+    #   - The DUT MUST send SRP Update to BR_1 as in step 4.
+    print("Step 9: ED_1 sends SRP Update to BR_1 to renew service.")
+
+    # To verify Step 7 and Step 9:
+    # We ensure that the NEXT SRP Update from ED_1 is indeed to BR_1, not BR_2.
+    pkts.filter_wpan_src64(ED_1).\
+        filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+        must_next().\
+        must_verify(lambda p: p.ipv6.dst == BR_1_MLEID).\
+        must_verify(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        must_verify(lambda p: ED_1_MLEID in verify_utils.as_list(p.dns.aaaa)).\
+        must_verify(lambda p: len(verify_utils.as_list(p.dns.aaaa)) == 1)
+
+    # Step 10
+    # Device: BR 1
+    # Description (SRPC-3.1): Does not respond.
+    # Pass Criteria:
+    #   N/A
+    print("Step 10: BR_1 does not respond.")
+
+    # Step 11
+    # Device: ED 1 (DUT)
+    # Description (SRPC-3.1): Automatically sends SRP Update to BR_2
+    # Pass Criteria:
+    #   - The DUT MUST send SRP Update like in step 4 but now to BR_2
+    print("Step 11: ED_1 sends SRP Update to BR_2.")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_2_MLEID).\
+        filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: ED_1_MLEID in verify_utils.as_list(p.dns.aaaa)).\
+        filter(lambda p: len(verify_utils.as_list(p.dns.aaaa)) == 1).\
+        must_next()
+
+    # Step 12
+    # Device: BR 2
+    # Description (SRPC-3.1): Automatically sends SRP Update Response (Rcode=0, NoError).
+    # Pass Criteria:
+    #   N/A
+    print("Step 12: BR_2 sends SRP Update Response.")
+    pkts.filter_ipv6_src(BR_2_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        must_next()
+
+    # Step 13
+    # Device: BR_1
+    # Description (SRPC-3.1): Harness re-enables the device.
+    # Pass Criteria:
+    #   - N/A
+    print("Step 13: BR_1 is re-enabled.")
+
+    # Step 14
+    # Device: BR_1
+    # Description (SRPC-3.1): Harness instructs the device to add its SRP Server information in the Thread Network
+    #   Data using a DNS/SRP Unicast Dataset. Note: normally BR_1 does not add this information, since an SRP server
+    #   is already advertised on the Thread Network. The Harness overrides this behavior.
+    # Pass Criteria:
+    #   - N/A
+    print("Step 14: BR_1 adds its SRP Server information.")
+
+    # Step 15
+    # Device: ED 1 (DUT)
+    # Description (SRPC-3.1): Does not send SRP Update to BR_1, but stays with its current SRP server BR 2..
+    # Pass Criteria:
+    #   - The DUT MUST NOT send SRP Update to BR_1.
+    print("Step 15: ED_1 stays with BR_2. MUST NOT send SRP Update to BR_1.")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+        must_not_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRPC_TC_4.py b/tests/nexus/verify_1_3_SRPC_TC_4.py
new file mode 100644
index 0000000..da615c9
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRPC_TC_4.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+import struct
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+from pktverify.bytes import Bytes
+
+
+def verify(pv):
+    # 3.4. [1.3] [CERT] [COMPONENT] Remove one service only by SRP client
+    #
+    # 3.4.1. Purpose
+    # To test the following:
+    # - SRP client needs to remove one service while other service remains registered.
+    # - Correct usage of SRP Update to remove 1 service.
+    # - Note: a Thread Product DUT must skip this test. Thread Product DUTs do not have a test case equivalent to this
+    #   one, since they would then need to be instructed to register 2 services and then remove 1 service again. This
+    #   seems unlikely to be practical.
+    #
+    # 3.4.2. Topology
+    # - BR_1 - Test Bed border router device operating as a Thread Border Router, and the Leader
+    # - Eth_1 - Test Bed border router device on an Adjacent Infrastructure Link
+    # - TD_1 (DUT) - Thread End Device or Thread Router Component DUT
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Client       | N/A          | 2.3.2
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    TD_1_MLEID = Ipv6Addr(pv.vars['TD_1_MLEID_ADDR'])
+    TD_1_OMR_ADDR = Ipv6Addr(pv.vars['TD_1_OMR_ADDR'])
+    BR_1_SRP_PORT = int(pv.vars['BR_1_SRP_PORT'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME1 = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_INSTANCE_NAME2 = 'service-test-2.' + SRP_SERVICE_NAME
+    SRP_HOST_NAME1 = 'host-test-1.default.service.arpa'
+
+    SRP_SERVICE_PORT1 = 55555
+    SRP_SERVICE_PORT2 = 55556
+
+    SRP_LEASE_5M_10M = Bytes(struct.pack('>II', 300, 600).hex())
+    SRP_LEASE_3M_10M = Bytes(struct.pack('>II', 180, 600).hex())
+
+    # Step 1
+    # - Device: BR_1
+    # - Description (SRPC-3.4): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: BR_1 enable.")
+
+    # Step 2
+    # - Device: TD_1 (DUT)
+    # - Description (SRPC-3.4): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: TD_1 enable.")
+
+    # Step 3
+    # - Device: BR 1
+    # - Description (SRPC-3.4): Automatically adds its SRP Server information in the DNSSRP/ Unicast Dataset in the
+    #   Thread Network Data. Automatically configures an OMR prefix in Thread Network Data.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 3: BR_1 adds SRP Server information and OMR prefix.")
+
+    # Step 4
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.4): Harness instructs device to register a new service: $ORIGIN default.service.arpa.
+    #   service-test-1. thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of TD_1> with the
+    #   following options: Update Lease Option Lease: 5 minutes Key Lease: 10 minutes.
+    # - Pass Criteria:
+    #   - N/A (see step 5 instead)
+    print("Step 4: TD_1 registers service 1.")
+    pkts.filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(TD_1_MLEID).\
+        filter(lambda p: p.udp.dstport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME1 in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT1 in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: 1 in verify_utils.as_list(p.dns.srv.priority)).\
+        filter(lambda p: 1 in verify_utils.as_list(p.dns.srv.weight)).\
+        filter(lambda p: TD_1_OMR_ADDR in verify_utils.as_list(p.dns.aaaa)).\
+        filter(lambda p: p.dns.opt.data is not None).\
+        filter(lambda p: any(data.format_compact() == SRP_LEASE_5M_10M.format_compact() for data in p.dns.opt.data)).\
+        must_next()
+
+    # Step 5
+    # - Device: BR 1
+    # - Description (SRPC-3.4): Automatically responds 'SUCCESS'.
+    # - Pass Criteria:
+    #   - MUST respond 'SUCCESS'.
+    #   - Note: this verification step is done on the BR 1 response to ensure that the DUT in step 4 sent a correct SRP
+    #     Update. This is a quick way to verify the Update was correct.
+    print("Step 5: BR_1 responds SUCCESS.")
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(TD_1_MLEID).\
+        filter(lambda p: p.udp.srcport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 6
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.4): Harness instructs device to register a new, second service: $ORIGIN
+    #   default.service.arpa. service-test-2._thread-test._udp ( SRV 1 1 55556 host-test-1 ) host-test-1 AAAA <OMR
+    #   address of TD 1> with the following options: Update Lease Option Lease: 3 minutes Key Lease: 10 minutes
+    # - Pass Criteria:
+    #   - N/A (see step 7 instead)
+    print("Step 6: TD_1 registers service 2.")
+    pkts.filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(TD_1_MLEID).\
+        filter(lambda p: p.udp.dstport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME2 in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT2 in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: 1 in verify_utils.as_list(p.dns.srv.priority)).\
+        filter(lambda p: 1 in verify_utils.as_list(p.dns.srv.weight)).\
+        filter(lambda p: TD_1_OMR_ADDR in verify_utils.as_list(p.dns.aaaa)).\
+        filter(lambda p: p.dns.opt.data is not None).\
+        filter(lambda p: any(data.format_compact() == SRP_LEASE_3M_10M.format_compact() for data in p.dns.opt.data)).\
+        must_next()
+
+    # Step 7
+    # - Device: BR 1
+    # - Description (SRPC-3.4): Automatically responds SUCCESS.
+    # - Pass Criteria:
+    #   - MUST respond 'SUCCESS'.
+    #   - Note: this verification step is done on the BR_1 response to ensure that the DUT in step 6 sent a correct SRP
+    #     Update. This is a quick way to verify the Update was correct.
+    print("Step 7: BR_1 responds SUCCESS.")
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(TD_1_MLEID).\
+        filter(lambda p: p.udp.srcport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 8
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.4): Harness instructs device to remove its first service.
+    # - Pass Criteria:
+    #   - The DUT MUST send an SRP Update message such that the Service Discovery Instruction contains a single Delete
+    #     an RR from an RRset ([RFC 2136], Section 2.5.4) update that deletes named records: $ORIGIN
+    #     default.service.arpa. service-test-1._thread-test. udp ANY <ttl=0>
+    #   - Note: if this is difficult to verify here then instead the SRP server response in step 11 can be used as
+    #     validation that the correct service was removed.
+    print("Step 8: TD_1 removes service 1.")
+    pkts.filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(TD_1_MLEID).\
+        filter(lambda p: p.udp.dstport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME1 in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: any(ttl == 0 for ttl in verify_utils.as_list(p.dns.resp.ttl))).\
+        must_next()
+
+    # Step 9
+    # - Device: BR_1
+    # - Description (SRPC-3.4): Automatically responds 'SUCCESS'.
+    # - Pass Criteria:
+    #   - MUST respond 'SUCCESS'.
+    print("Step 9: BR_1 responds SUCCESS.")
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(TD_1_MLEID).\
+        filter(lambda p: p.udp.srcport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 10
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.4): Harness instructs device via TH API to send unicast DNS query QType PTR to BR_1 for any
+    #   services of type_thread-test._udp.default.service.arpa.". Note: this is done to validate that the SRP server
+    #   removed the first service and kept the second service.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 10: TD_1 sends DNS query PTR.")
+    pkts.filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(TD_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 11
+    # - Device: BR_1
+    # - Description (SRPC-3.4): Responds with the second service only, not the first service.
+    # - Pass Criteria:
+    #   - MUST respond with the following service DNS-SD response to requesting client in the Answer records section:
+    #     $ORIGIN default.service.arpa. _thread-test._udp ( PTR service-test-2._thread-test._udp )
+    #   - DUT MUST NOT respond with further records in the Answer records section.
+    #   - Response MAY contain in the Additional records section: $ORIGIN default.service.arpa.
+    #     service-test-2._thread-test._udp ( SRV 1 1 55556 host-test-1 ) host-test-1 AAAA <OMR address of TD_1>
+    print("Step 11: BR_1 responds with service 2 only.")
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(TD_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_INSTANCE_NAME2 in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        filter(lambda p: int(p.dns.count.answers) == 1).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRPC_TC_5.py b/tests/nexus/verify_1_3_SRPC_TC_5.py
new file mode 100644
index 0000000..58eeb1b
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRPC_TC_5.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    # 3.5. [1.3] [CERT] [COMPONENT] DNS-SD client discovers services
+    #
+    # 3.5.1. Purpose
+    # To test the following:
+    # - DNS-SD client discovers services and sends out correctly formatted DNS query
+    # - Client handling multiple service responses
+    # - Client handling of both mesh-local discovered services and off-mesh services
+    # - Note: Thread Product DUTs must skip this test.
+    #
+    # 3.5.2. Topology
+    # - BR 1-Border Router Reference Device and Leader
+    # - TD_1 (DUT)-Thread End Device or Thread Router DUT (Component DUT)
+    # - ED 2-Thread Reference End Device
+    # - Eth 1-Host Reference Device on AIL
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Client       | N/A          | 2.3.2
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    TD_1_MLEID = Ipv6Addr(pv.vars['TD_1_MLEID_ADDR'])
+    ED_2_MLEID = Ipv6Addr(pv.vars['ED_2_MLEID_ADDR'])
+    ED_2_OMR_ADDR = Ipv6Addr(pv.vars['ED_2_OMR_ADDR'])
+
+    SERVICE_TYPE = '_thread-test._udp.default.service.arpa'
+    DNS_PORT = 53
+
+    SRP_SERVICE_PORT1 = 55551
+    SRP_SERVICE_PORT2 = 55552
+    SRP_SERVICE_PORT3 = 55553
+    SRP_SERVICE_PORT4 = 55554
+    SRP_SERVICE_PORT5 = 55555
+
+    PORTS = [SRP_SERVICE_PORT1, SRP_SERVICE_PORT2, SRP_SERVICE_PORT3, SRP_SERVICE_PORT4, SRP_SERVICE_PORT5]
+
+    # Step 1
+    # - Device: BR 1. ED 2, Eth 1
+    # - Description (SRPC-3.5): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: BR 1, ED 2, Eth 1 enable.")
+
+    # Step 2
+    # - Device: BR 1
+    # - Description (SRPC-3.5): Automatically adds its SRP Server information in the DNS/SRP Unicast Dataset in the
+    #   Thread Network Data. Automatically configures an OMR prefix in Thread Network Data.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: BR 1 adds SRP Server information and OMR prefix.")
+
+    # Step 3
+    # - Device: ED 2
+    # - Description (SRPC-3.5): Harness instructs device to register multiple N=5 services, each with TXT record per
+    #   DNS-SD key/value formatting:
+    #   - service-test-1. thread-test._udp ( SRV 8 8 55551 host-test-2 TXT dummy=abcd; thread-test=a49 )
+    #   - service-test-2._thread-test._udp ( SRV 8 1 55552 host-test-2 TXT a=2; thread-test=b50_test )
+    #   - service-test-3. thread-test._udp ( SRV 1  55553 host-test-2 TXT a_dummy=42; thread-test= )
+    #   - service-test-4. thread-test._udp ( SRV 1 1 55554 host-test-2 TXT thread-test=1;ignorethis; thread-test= )
+    #   - service-test-5. thread-test.udp ( SRV 2 2 55555 host-test-2 TXT a=88;thread- THREAD-TEST=C51; ignorethis;
+    #     thread-test )
+    #   - host-test-2 AAAA <non-routable address 2001::1234>
+    #   - host-test-2 AAAA <OMR address of ED 2>
+    #   - host-test-2 AAAA <ML-EID address of ED_2>
+    #   - with the following options: Update Lease Option Lease: 5 minutes Key Lease: 10 minutes.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 3: ED 2 registers 5 services.")
+
+    # Step 4
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.5): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 4: TD 1 (DUT) enable.")
+
+    # Step 5
+    # - Device: TD_1 (DUT)
+    # - Description (SRPC-3.5): Harness instructs device to query for any services of type
+    #   "_thread-test._udp.default.service.arpa." using unicast DNS query QType=PTR
+    # - Pass Criteria:
+    #   - The DUT MUST send a correctly formatted DNS query.
+    print("Step 5: TD 1 (DUT) sends DNS query PTR.")
+    pkts.filter_ipv6_src(TD_1_MLEID).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.udp.dstport == DNS_PORT).\
+        filter(lambda p: SERVICE_TYPE in verify_utils.as_list(p.dns.qry.name)).\
+        filter(lambda p: consts.DNS_TYPE_PTR in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    # Step 6
+    # - Device: BR 1
+    # - Description (SRPC-3.5): Automatically responds with all N services.
+    # - Pass Criteria:
+    #   - Responds with DNS answer with all N services as indicated in step 3 in the Answer records section.
+    #   - DNS response Rcode MUST be 'NoError' (0).
+    #   - DNS response MUST contain N=5 answer records.
+    print("Step 6: BR 1 responds with all 5 services.")
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(TD_1_MLEID).\
+        filter(lambda p: p.udp.srcport == DNS_PORT).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: len(verify_utils.as_list(p.dns.ptr.domain_name)) == 5).\
+        must_next()
+
+    # Step 7
+    # - Device: TD_1 (DUT)
+    # - Description (SRPC-3.5): Harness receives each of the N service responses via API from the DUT Component.
+    #   For each service, it uses the API to extract the TXT record key "thread-test" and compares against the
+    #   expected value.
+    # - Pass Criteria:
+    #   - The DUT MUST return the correct values for the "thread-test" key as defined below for each of the
+    #     respective N services:
+    #     - 1. a49
+    #     - 2. b50 test
+    #     - 3. (empty string of 0 characters)
+    #     - 4. 1
+    #     - 5. C51
+    print("Step 7: TD 1 (DUT) extracts and compares TXT record values.")
+
+    # Step 8
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.5): Harness uses API to resolve for each of the N services the IPv6 address and port of
+    #   the service. For each returned/resolved service, Harness uses the API to send a UDP packet to the service's
+    #   IPv6 address at the respective returned port number.
+    # - Pass Criteria:
+    #   - The DUT MUST transmit 5 UDP packets to ED_2, to the following port numbers, which may be in any order:
+    #     - 1. 55551
+    #     - 2. 55552
+    #     - 3. 55553
+    #     - 4. 55554
+    #     - 5. 55555
+    #   - The DUT MAY send to either the ML-EID or OMR address of ED 2.
+    print("Step 8: TD 1 (DUT) transmits 5 UDP packets to ED 2.")
+    for port in PORTS:
+        pkts.filter_ipv6_src(TD_1_MLEID).\
+            filter(lambda p: p.ipv6.dst == ED_2_MLEID or p.ipv6.dst == ED_2_OMR_ADDR).\
+            filter(lambda p: p.udp.dstport == port).\
+            must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRPC_TC_7.py b/tests/nexus/verify_1_3_SRPC_TC_7.py
new file mode 100644
index 0000000..c43a699
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRPC_TC_7.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+import struct
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify import layer_fields
+from pktverify.addrs import Ipv6Addr
+from pktverify.bytes import Bytes
+
+# Monkey-patch dns.key.public_key field
+layer_fields._LAYER_FIELDS['dns.key.public_key'] = layer_fields._list(layer_fields._bytes)
+layer_fields._layer_containers.add('dns.key')
+
+
+def verify(pv):
+    # 3.7. [1.3] [CERT] [COMPONENT] Thread Device Reboots - Re-registers service with same KEY
+    #
+    # 3.7.1. Purpose
+    # To verify that the Thread Component DUT:
+    # - Is able to re-register a service again after reboot.
+    # - Uses the same key for signing the SRP Update, as indicated in the KEY record.
+    #
+    # 3.7.2. Topology
+    # - BR 1 Border Router Reference Device and Leader
+    # - Router 1-Thread Router reference device
+    # - TD_1 (DUT)-Any Thread Device (FTD or MTD): Thread Component DUT
+    # - Eth 1-IPv6 host reference device on the AIL
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Client       | N/A          | 2.3.2
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    TD_1_MLEID = Ipv6Addr(pv.vars['TD_1_MLEID_ADDR'])
+    TD_1_OMR_ADDR = Ipv6Addr(pv.vars['TD_1_OMR_ADDR'])
+    BR_1_SRP_PORT = int(pv.vars['BR_1_SRP_PORT'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_SERVICE_PORT = 33333
+
+    SRP_LEASE_1H_1D = Bytes(struct.pack('>II', 3600, 86400).hex())
+
+    # Step 1
+    # - Device: Eth 1, BR 1, Router 1, TD 1
+    # - Description (SRPC-3.7): Enable & form topology
+    # - Pass Criteria:
+    #   - Single Thread Network is formed
+    print("Step 1: Enable & form topology")
+
+    # Step 2
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.7): Harness instructs the DUT to register the service: $ORIGIN default.service.arpa.
+    #   service-test-1._thread-test._udp ( SRV 33333 host-test-1 ) host-test-1 AAAA <OMR address of TD_1> with the
+    #   following options: Update Lease Option Lease: 60 minutes Key Lease: 1 day
+    # - Pass Criteria:
+    #   - The DUT MUST send an SRP Update to BR_1
+    #   - BR_1 MUST respond Rcode=0 (NoError).
+    print("Step 2: Harness instructs the DUT to register the service")
+    update1 = pkts.filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(TD_1_MLEID).\
+        filter(lambda p: p.udp.dstport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: TD_1_OMR_ADDR in verify_utils.as_list(p.dns.aaaa)).\
+        filter(lambda p: p.dns.opt.data is not None).\
+        filter(lambda p: any(data.format_compact() == SRP_LEASE_1H_1D.format_compact() for data in p.dns.opt.data)).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(TD_1_MLEID).\
+        filter(lambda p: p.udp.srcport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        must_next()
+
+    key1 = update1.dns.key.public_key
+
+    # Step 3
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.7): Harness instructs device to reboot.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 3: Harness instructs device to reboot.")
+
+    # Step 4
+    # - Device: TD 1 (DUT)
+    # - Description (SRPC-3.7): (Repeat step 2)
+    # - Pass Criteria:
+    #   - The DUT MUST send an SRP Update to BR_1
+    #   - The KEY record in the SRP Update MUST have an equal value to the KEY record that was sent in step 2
+    #   - BR_1 MUST respond Rcode=0 (NoError).
+    print("Step 4: (Repeat step 2)")
+    update2 = pkts.filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(TD_1_MLEID).\
+        filter(lambda p: p.udp.dstport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: TD_1_OMR_ADDR in verify_utils.as_list(p.dns.aaaa)).\
+        filter(lambda p: p.dns.opt.data is not None).\
+        filter(lambda p: any(data.format_compact() == SRP_LEASE_1H_1D.format_compact() for data in p.dns.opt.data)).\
+        filter(lambda p: p.dns.key.public_key == key1).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(TD_1_MLEID).\
+        filter(lambda p: p.udp.srcport == BR_1_SRP_PORT).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_1.py b/tests/nexus/verify_1_3_SRP_TC_1.py
new file mode 100644
index 0000000..658eedc
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_1.py
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr, EthAddr
+
+
+def verify(pv):
+    #
+    # 5.10.1 SRP_TC_1: SRP Registration and Discovery (Single BR)
+    #
+    # 5.10.1.2 Purpose & Description
+    # The purpose of this test case is to verify the SRP registration and discovery in a single BR topology.
+    #
+    # 5.10.1.3 Topology
+    # BR 1 (DUT)
+    # ED 1
+    # Eth 1
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Server       | N/A          | 1.3
+    pkts = pv.pkts
+    pv.summary.show()
+
+    # Use ML-EIDs for SRP traffic verification
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    ED_1_OMR = Ipv6Addr(pv.vars['ED_1_OMR_ADDR'])
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    br1_srp_port = int(pv.vars['BR_1_SRP_PORT'])
+    BR_1_ETH = EthAddr(pv.vars['BR_1_ETH'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_HOST_NAME = 'host-test-1.default.service.arpa'
+    MDNS_SERVICE_NAME = '_thread-test._udp.local'
+    MDNS_INSTANCE_NAME = 'service-test-1.' + MDNS_SERVICE_NAME
+    MDNS_HOST_NAME = 'host-test-1.local'
+
+    SRP_SERVICE_PORT = 55555
+    SRP_UPDATED_PORT = 55556
+
+    SERVICE_ID_SRP_SERVER = 93
+
+    # Step 1: BR 1 (DUT) power on.
+    print("Step 1: BR 1 (DUT) power on.")
+
+    # Step 2: Eth 1, ED 1 Enable.
+    print("Step 2: Eth 1, ED 1 Enable.")
+
+    # Step 3: BR 1 (DUT) adds its SRP Server information in the Thread Network Data.
+    print("Step 3: BR 1 (DUT) adds its SRP Server information in the Thread Network Data.")
+    pkts.\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: SERVICE_ID_SRP_SERVER in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+        filter(lambda p: any(l in (1, 19) for l in verify_utils.as_list(p.thread_nwd.tlv.service.s_data_len))).\
+        must_next()
+
+    # Step 4: ED 1 sends SRP Update to register a service.
+    print("Step 4: ED 1 sends SRP Update to register a service.")
+    index_era_1 = pkts.index
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+        must_next()
+
+    # Step 5: BR 1 (DUT) sends SRP Update Response.
+    print("Step 5: BR 1 (DUT) sends SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 6: ED 1 sends unicast DNS query QType PTR.
+    print("Step 6: ED 1 sends unicast DNS query QType PTR.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 7: BR 1 (DUT) sends DNS response.
+    print("Step 7: BR 1 (DUT) sends DNS response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        must_next()
+
+    # Step 7b: ED 1 sends unicast DNS query QTYPE SRV.
+    print("Step 7b: ED 1 sends unicast DNS query QTYPE SRV.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        must_next()
+
+    # Step 7c: ED 1 sends unicast DNS query QTYPE=AAAA.
+    print("Step 7c: ED 1 sends unicast DNS query QTYPE=AAAA.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: ED_1_MLEID in verify_utils.as_list(p.dns.aaaa) and ED_1_OMR in verify_utils.as_list(p.dns.aaaa)).\
+        must_next()
+
+    # mDNS Discovery 1 (Step 8/9 era)
+    print("Step 8/9: mDNS discovery on AIL.")
+    with pkts.save_index():
+        pkts.index = index_era_1
+        era_mdns_55555 = pkts.\
+            filter(lambda p: p.eth.src == BR_1_ETH).\
+            filter(lambda p: p.udp.dstport == 5353).\
+            filter(lambda p: p.mdns.flags.response == 1).\
+            filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+            must_next()
+        assert MDNS_INSTANCE_NAME in verify_utils.as_list(era_mdns_55555.mdns.ptr.domain_name)
+        assert ED_1_OMR in verify_utils.as_list(era_mdns_55555.mdns.aaaa)
+
+    # Step 9b: Eth 1 sends mDNS query QTYPE SRV.
+    print("Step 9b: Eth 1 sends mDNS query QTYPE SRV.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    print("Step 9b: BR 1 (DUT) sends mDNS Response.")
+    pkts.\
+        filter(lambda p: p.eth.src == BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.srv.target)).\
+        must_next()
+
+    # Step 9c: Eth 1 sends mDNS query QTYPE AAAA.
+    print("Step 9c: Eth 1 sends mDNS query QTYPE AAAA.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    print("Step 9c: BR 1 (DUT) sends mDNS Response.")
+    pkts.\
+        filter(lambda p: p.eth.src == BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: ED_1_OMR in verify_utils.as_list(p.mdns.aaaa)).\
+        must_next()
+
+    # Step 10: ED 1 sends SRP Update to update the service parameters and address.
+    print("Step 10: ED 1 sends SRP Update to update the service parameters and address.")
+    index_era_2 = pkts.index
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_UPDATED_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        must_next()
+
+    # Step 11: BR 1 (DUT) sends SRP Update Response.
+    print("Step 11: BR 1 (DUT) sends SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 12: ED 1 sends unicast DNS query QType PTR.
+    print("Step 12: ED 1 sends unicast DNS query QType PTR.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 13: BR 1 (DUT) sends DNS response.
+    print("Step 13: BR 1 (DUT) sends DNS response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        must_next()
+
+    # Step 13b: ED 1 sends unicast DNS query QTYPE SRV.
+    print("Step 13b: ED 1 sends unicast DNS query QTYPE SRV.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: SRP_UPDATED_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        must_next()
+
+    # Step 13c: ED 1 sends unicast DNS query QTYPE=AAAA.
+    print("Step 13c: ED 1 sends unicast DNS query QTYPE=AAAA.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: all(addr == ED_1_MLEID for addr in verify_utils.as_list(p.dns.aaaa))).\
+        must_next()
+
+    # mDNS Discovery 2 (Step 14/15 era)
+    print("Step 14/15: updated mDNS discovery on AIL.")
+    with pkts.save_index():
+        pkts.index = index_era_2
+        pkts.\
+            filter(lambda p: p.eth.src == BR_1_ETH).\
+            filter(lambda p: p.udp.dstport == 5353).\
+            filter(lambda p: p.mdns.flags.response == 1).\
+            filter(lambda p: SRP_UPDATED_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+            must_next()
+    with pkts.save_index():
+        pkts.index = index_era_2
+        pkts.\
+            filter(lambda p: p.eth.src == BR_1_ETH).\
+            filter(lambda p: p.udp.dstport == 5353).\
+            filter(lambda p: p.mdns.flags.response == 1).\
+            filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+            must_next()
+
+    # Step 15b: Eth 1 sends mDNS query QTYPE SRV.
+    print("Step 15b: Eth 1 sends mDNS query QTYPE SRV.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    print("Step 15b: BR 1 (DUT) sends mDNS Response.")
+    pkts.\
+        filter(lambda p: p.eth.src == BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_UPDATED_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.srv.target)).\
+        must_next()
+
+    # Step 15c: Eth 1 sends mDNS query QTYPE AAAA.
+    print("Step 15c: Eth 1 sends mDNS query QTYPE AAAA.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    print("Step 15c: BR 1 (DUT) sends mDNS Response.")
+    pkts.\
+        filter(lambda p: p.eth.src == BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: all(addr == ED_1_MLEID for addr in verify_utils.as_list(p.mdns.aaaa) if isinstance(addr, Ipv6Addr) and not addr.is_link_local)).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_11.py b/tests/nexus/verify_1_3_SRP_TC_11.py
new file mode 100644
index 0000000..95e06d5
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_11.py
@@ -0,0 +1,412 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 2.11. [1.3] [CERT] Recovery after reboot
+    #
+    # 2.11.1. Purpose
+    # To test the following:
+    # - 1. Handle SRP update after reboot of Thread device.
+    # - 2. Handle SRP update after reboot of Border Router
+    # - 3. Respond to mDNS queries on infrastructure interface after reboot of infrastructure device.
+    #
+    # 2.11.2. Topology
+    # - 1. BR_1 (DUT) - Thread Border Router, and the Leader
+    # - 2. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - 3. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference | V1.1 Section | V1.3.0 Section
+    # ---------------|--------------|---------------
+    # SRP Server     | N/A          | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_MLEID = pv.vars['BR_1_MLEID']
+    ED_1_MLEID = pv.vars['ED_1_MLEID']
+    BR_1_SRP_PORT_1 = int(pv.vars['BR_1_SRP_PORT_1'])
+    BR_1_SRP_PORT_2 = int(pv.vars['BR_1_SRP_PORT_2'])
+    BR_1_ETH = pv.vars['BR_1_ETH']
+    ETH_1_ETH = pv.vars['Eth_1_ETH']
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_HOST_NAME = 'host-test-1.default.service.arpa'
+    MDNS_SERVICE_NAME = '_thread-test._udp.local'
+    MDNS_INSTANCE_NAME = 'service-test-1.' + MDNS_SERVICE_NAME
+
+    SRP_SERVICE_PORT = 1500
+    SRP_SERVICE_PRIORITY = 8
+    SRP_SERVICE_WEIGHT = 8
+
+    DNS_TYPE_SRV = 33
+    SERVICE_ID_SRP_UNICAST_DNS = 93
+
+    # Step 1
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: BR 1 (DUT) Enable")
+
+    # Step 2
+    # - Device: Eth 1, ED 1
+    # - Description (SRP-2.11): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth 1, ED 1 Enable")
+
+    # Step 3
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically adds SRP Server information in the Thread Network Data.
+    # - Pass Criteria:
+    #   - The DUT's SRP Server information MUST appear in the Thread Network Data. This MUST be a
+    #     DNS/SRP Unicast Dataset, or a DNS/SRP Anycast Dataset, or both.
+    #   - In case of Anycast Dataset: record the SRP-SN value for later use.
+    print("Step 3: BR 1 (DUT) Automatically adds SRP Server information in the Thread Network Data.")
+    pkts.\
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+      filter(lambda p: SERVICE_ID_SRP_UNICAST_DNS in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+      must_next()
+
+    # Step 4
+    # - Device: ED_1
+    # - Description (SRP-2.11): Harness instructs the device to send SRP Update: $ORIGIN
+    #   default.service.arpa. service-test-1._thread-test._udp ( SRV 8 1500 host-test-1 ) host-test-1
+    #   AAAA <OMR address of ED_1> KEY <public key of ED_1> with the following options: Update Lease
+    #   Option, Lease: 5 minutes, Key Lease: 10 minutes
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 4: ED_1 sends SRP Update")
+    pkts.\
+      filter_ipv6_dst(BR_1_MLEID).\
+      filter_ipv6_src(ED_1_MLEID).\
+      filter(lambda p: p.udp.dstport == BR_1_SRP_PORT_1).\
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+      filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+      filter(lambda p: SRP_SERVICE_PRIORITY in verify_utils.as_list(p.dns.srv.priority)).\
+      filter(lambda p: SRP_SERVICE_WEIGHT in verify_utils.as_list(p.dns.srv.weight)).\
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+      must_next()
+
+    # Step 5
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 5: BR 1 (DUT) Automatically sends SRP Update Response.")
+    pkts.\
+      filter_ipv6_dst(ED_1_MLEID).\
+      filter_ipv6_src(BR_1_MLEID).\
+      filter(lambda p: p.udp.srcport == BR_1_SRP_PORT_1).\
+      filter(lambda p: p.dns.flags.response == 1).\
+      filter(lambda p: p.dns.flags.rcode == 0).\
+      must_next()
+
+    # Step 6
+    # - Device: Eth_1
+    # - Description (SRP-2.11): Harness instructs the device to send mDNS query QType SRV for
+    #   "service-test-1. thread-test, udp.local.".
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 6: Eth_1 sends mDNS query QType SRV")
+    pkts.\
+      filter(lambda p: p.eth.src == ETH_1_ETH).\
+      filter(lambda p: p.udp.dstport == 5353).\
+      filter(lambda p: p.mdns.flags.response == 0).\
+      filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+      filter(lambda p: DNS_TYPE_SRV in verify_utils.as_list(p.mdns.qry.type)).\
+      must_next()
+
+    # Step 7
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError); and contains
+    #     service-test-1 answer record: SORIGIN local. service-test-1. thread-test._udp ( SRV 8 8 1500
+    #     host-test-1 )
+    print("Step 7: BR 1 (DUT) Automatically sends mDNS Response.")
+    pkts.\
+      filter(lambda p: p.eth.src == BR_1_ETH).\
+      filter(lambda p: p.udp.dstport == 5353).\
+      filter(lambda p: p.mdns.flags.response == 1).\
+      filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+      filter(lambda p: SRP_SERVICE_PRIORITY in verify_utils.as_list(p.mdns.srv.priority)).\
+      filter(lambda p: SRP_SERVICE_WEIGHT in verify_utils.as_list(p.mdns.srv.weight)).\
+      must_next()
+
+    # Step 8
+    # - Device: ED 1
+    # - Description (SRP-2.11): Harness instructs the device to reboot/reset. Note: ED 1 MUST
+    #   persist its public/private keypair that is used for signing SRP Updates. So it is not a
+    #   factory-reset.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 8: ED 1 reboot/reset")
+
+    # Step 9
+    # - Device: ED 1
+    # - Description (SRP-2.11): Harness instructs the device to send SRP Update, as in step 4.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 9: ED 1 sends SRP Update")
+    pkts.\
+      filter_ipv6_dst(BR_1_MLEID).\
+      filter_ipv6_src(ED_1_MLEID).\
+      filter(lambda p: p.udp.dstport == BR_1_SRP_PORT_1).\
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+      filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+      filter(lambda p: SRP_SERVICE_PRIORITY in verify_utils.as_list(p.dns.srv.priority)).\
+      filter(lambda p: SRP_SERVICE_WEIGHT in verify_utils.as_list(p.dns.srv.weight)).\
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+      must_next()
+
+    # Step 10
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 10: BR 1 (DUT) Automatically sends SRP Update Response.")
+    pkts.\
+      filter_ipv6_dst(ED_1_MLEID).\
+      filter_ipv6_src(BR_1_MLEID).\
+      filter(lambda p: p.udp.srcport == BR_1_SRP_PORT_1).\
+      filter(lambda p: p.dns.flags.response == 1).\
+      filter(lambda p: p.dns.flags.rcode == 0).\
+      must_next()
+
+    # Step 11
+    # - Device: Eth_1
+    # - Description (SRP-2.11): Harness instructs the device to send mDNS query QType=SRV for
+    #   "service-test-1._thread-test_udp.local.".
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 11: Eth_1 sends mDNS query QType=SRV")
+    pkts.\
+      filter(lambda p: p.eth.src == ETH_1_ETH).\
+      filter(lambda p: p.udp.dstport == 5353).\
+      filter(lambda p: p.mdns.flags.response == 0).\
+      filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+      filter(lambda p: DNS_TYPE_SRV in verify_utils.as_list(p.mdns.qry.type)).\
+      must_next()
+
+    # Step 12
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError); and contains
+    #     service-test-1 answer record: SORIGIN local. service-test-1. thread-test._udp ( SRV 1500
+    #     host-test-1 )
+    print("Step 12: BR 1 (DUT) Automatically sends mDNS Response.")
+    pkts.\
+      filter(lambda p: p.eth.src == BR_1_ETH).\
+      filter(lambda p: p.udp.dstport == 5353).\
+      filter(lambda p: p.mdns.flags.response == 1).\
+      filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+      filter(lambda p: SRP_SERVICE_PRIORITY in verify_utils.as_list(p.mdns.srv.priority)).\
+      filter(lambda p: SRP_SERVICE_WEIGHT in verify_utils.as_list(p.mdns.srv.weight)).\
+      must_next()
+
+    # Step 13
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): User is instructed to reboot the border router (DUT). Note: In case
+    #   DUT uses an Anycast Dataset, this will include a different SRP-SN (compared to the previous
+    #   SRP-SN recorded in step 3). This will then trigger re-registration in the next step. In
+    #   case DUT uses a Unicast Dataset, this will include a different port number (compared to the
+    #   previous port value used in step 3). This will then trigger re-registration.
+    # - Pass Criteria:
+    #   - After reboot, the DUT's SRP Server information MUST appear in the Thread Network Data.
+    #     This MUST be a DNS/SRP Unicast Dataset, or DNS/SRP Anycast Dataset, or both.
+    #   - For an Anycast Dataset, the SRP-SN SHOULD be different but MAY be equal (in case DUT
+    #     persistently stores data, only).
+    #   - For a Unicast Dataset, the port number MUST differ.
+    print("Step 13: BR 1 (DUT) reboot")
+    pkts.\
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+      filter(lambda p: SERVICE_ID_SRP_UNICAST_DNS in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+      must_next()
+
+    # Verify that the SRP server port has changed after reboot
+    print(f"Checking SRP port change: {BR_1_SRP_PORT_1} -> {BR_1_SRP_PORT_2}")
+    assert BR_1_SRP_PORT_1 != BR_1_SRP_PORT_2, f"SRP port MUST differ after reboot (was {BR_1_SRP_PORT_1})"
+
+    # Step 14
+    # - Device: ED_1
+    # - Description (SRP-2.11): Automatically re-registers using SRP Update, as in step 4.
+    # - Pass Criteria:
+    #   - MUST automatically re-register with SRP Update as in step 4.
+    print("Step 14: ED_1 Automatically re-registers using SRP Update")
+    pkts.\
+      filter_ipv6_dst(BR_1_MLEID).\
+      filter_ipv6_src(ED_1_MLEID).\
+      filter(lambda p: p.udp.dstport == BR_1_SRP_PORT_2).\
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+      filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+      filter(lambda p: SRP_SERVICE_PRIORITY in verify_utils.as_list(p.dns.srv.priority)).\
+      filter(lambda p: SRP_SERVICE_WEIGHT in verify_utils.as_list(p.dns.srv.weight)).\
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+      must_next()
+
+    # Step 15
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 15: BR 1 (DUT) Automatically sends SRP Update Response.")
+    pkts.\
+      filter_ipv6_dst(ED_1_MLEID).\
+      filter_ipv6_src(BR_1_MLEID).\
+      filter(lambda p: p.udp.srcport == BR_1_SRP_PORT_2).\
+      filter(lambda p: p.dns.flags.response == 1).\
+      filter(lambda p: p.dns.flags.rcode == 0).\
+      must_next()
+
+    # Step 16
+    # - Device: Eth_1
+    # - Description (SRP-2.11): Harness instructs the device to send mDNS query QType SRV for
+    #   "service-test-1. thread-test_udp.local.".
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 16: Eth_1 sends mDNS query QType SRV")
+    pkts.\
+      filter(lambda p: p.eth.src == ETH_1_ETH).\
+      filter(lambda p: p.udp.dstport == 5353).\
+      filter(lambda p: p.mdns.flags.response == 0).\
+      filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+      filter(lambda p: DNS_TYPE_SRV in verify_utils.as_list(p.mdns.qry.type)).\
+      must_next()
+
+    # Step 17
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError); and contains
+    #     service-test-1 answer record: SORIGIN local. service-test-1._thread-test._udp ( SRV 8 1500
+    #     host-test-1 )
+    print("Step 17: BR 1 (DUT) Automatically sends mDNS Response.")
+    pkts.\
+      filter(lambda p: p.eth.src == BR_1_ETH).\
+      filter(lambda p: p.udp.dstport == 5353).\
+      filter(lambda p: p.mdns.flags.response == 1).\
+      filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+      filter(lambda p: SRP_SERVICE_PRIORITY in verify_utils.as_list(p.mdns.srv.priority)).\
+      filter(lambda p: SRP_SERVICE_WEIGHT in verify_utils.as_list(p.mdns.srv.weight)).\
+      must_next()
+
+    # Step 18
+    # - Device: Eth 1
+    # - Description (SRP-2.11): Harness instructs the device to reset
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 18: Eth 1 reset")
+
+    # Step 19
+    # - Device: ED 1
+    # - Description (SRP-2.11): Harness instructs the device to send SRP Update, as in step 4.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 19: ED 1 sends SRP Update")
+    pkts.\
+      filter_ipv6_dst(BR_1_MLEID).\
+      filter_ipv6_src(ED_1_MLEID).\
+      filter(lambda p: p.udp.dstport == BR_1_SRP_PORT_2).\
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE).\
+      filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+      filter(lambda p: SRP_SERVICE_PRIORITY in verify_utils.as_list(p.dns.srv.priority)).\
+      filter(lambda p: SRP_SERVICE_WEIGHT in verify_utils.as_list(p.dns.srv.weight)).\
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+      must_next()
+
+    # Step 20
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 20: BR 1 (DUT) Automatically sends SRP Update Response.")
+    pkts.\
+      filter_ipv6_dst(ED_1_MLEID).\
+      filter_ipv6_src(BR_1_MLEID).\
+      filter(lambda p: p.udp.srcport == BR_1_SRP_PORT_2).\
+      filter(lambda p: p.dns.flags.response == 1).\
+      filter(lambda p: p.dns.flags.rcode == 0).\
+      must_next()
+
+    # Step 21
+    # - Device: Eth 1
+    # - Description (SRP-2.11): Harness instructs the device to send mDNS query QType SRV for
+    #   "service-test-1. thread-test._udp.local.".
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 21: Eth 1 sends mDNS query QType SRV")
+    pkts.\
+      filter(lambda p: p.eth.src == ETH_1_ETH).\
+      filter(lambda p: p.udp.dstport == 5353).\
+      filter(lambda p: p.mdns.flags.response == 0).\
+      filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+      filter(lambda p: DNS_TYPE_SRV in verify_utils.as_list(p.mdns.qry.type)).\
+      must_next()
+
+    # Step 22
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.11): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError); and contains
+    #     service-test-1. SORIGIN local. service-test-1. thread-test._udp ( SRV 8 8 1500 host-test-1 )
+    print("Step 22: BR 1 (DUT) Automatically sends mDNS Response.")
+    pkts.\
+      filter(lambda p: p.eth.src == BR_1_ETH).\
+      filter(lambda p: p.udp.dstport == 5353).\
+      filter(lambda p: p.mdns.flags.response == 1).\
+      filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+      filter(lambda p: SRP_SERVICE_PRIORITY in verify_utils.as_list(p.mdns.srv.priority)).\
+      filter(lambda p: SRP_SERVICE_WEIGHT in verify_utils.as_list(p.mdns.srv.weight)).\
+      must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_12.py b/tests/nexus/verify_1_3_SRP_TC_12.py
new file mode 100644
index 0000000..35642ad
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_12.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_RLOC16 = pv.vars['BR_1_RLOC16']
+    BR_2_RLOC16 = pv.vars['BR_2_RLOC16']
+    BR_3_RLOC16 = pv.vars['BR_3_RLOC16']
+
+    SERVICE_ID_SRP_ANYCAST_DNS = 92
+    SERVICE_ID_SRP_UNICAST_DNS = 93
+
+    # Step 1
+    print("Step 1: BR 1 (DUT) Enable; switch on.")
+
+    # Step 2
+    print("Step 2: BR 1 (DUT) adds SRP Server information in the Thread Network Data.")
+    pkts.\
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+      filter(lambda p: any(id in (SERVICE_ID_SRP_ANYCAST_DNS, SERVICE_ID_SRP_UNICAST_DNS) \
+                           for id in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier))).\
+      must_next()
+
+    # Step 3
+    print("Step 3: BR 2 Enable.")
+
+    # Step 4
+    print("Step 4: BR 2 adds its SRP Server information with a (faked) high numerical address.")
+
+    # Step 5
+    print("Step 5: BR 1 (DUT) integrates BR 2's SRP Server information.")
+    pkts.\
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+      filter(lambda p: SERVICE_ID_SRP_UNICAST_DNS in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+      filter(lambda p: len(verify_utils.as_list(p.thread_nwd.tlv.server_16)) >= 2).\
+      must_next()
+
+    # Step 6
+    print("Step 6: BR 3 Enable.")
+
+    # Step 7
+    print("Step 7: BR 3 adds its SRP Server information with a (faked) low numerical address.")
+
+    # Step 8
+    print("Step 8: BR 1 (DUT) integrates BR 3's SRP Server information and withdraws its own Unicast Dataset.")
+    pkts.\
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+      filter(lambda p: SERVICE_ID_SRP_UNICAST_DNS in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+      filter(lambda p: all(int(rloc) != BR_1_RLOC16 for rloc in verify_utils.as_list(p.thread_nwd.tlv.server_16))).\
+      filter(lambda p: len(verify_utils.as_list(p.thread_nwd.tlv.server_16)) >= 2).\
+      must_next()
+
+    # Step 9
+    print("Step 9: BR 2 adds an additional Anycast Dataset.")
+
+    # Step 10
+    print("Step 10: BR 1 (DUT) integrates BR 2's new Anycast Dataset.")
+    pkts.\
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+      filter(lambda p: SERVICE_ID_SRP_ANYCAST_DNS in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+      filter(lambda p: SERVICE_ID_SRP_UNICAST_DNS in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+      filter(lambda p: all(int(rloc) != BR_1_RLOC16 for rloc in verify_utils.as_list(p.thread_nwd.tlv.server_16))).\
+      must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_13.py b/tests/nexus/verify_1_3_SRP_TC_13.py
new file mode 100644
index 0000000..cca7ceb
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_13.py
@@ -0,0 +1,296 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+import struct
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+from pktverify.bytes import Bytes
+
+
+def verify(pv):
+    # 2.13. [1.3] [CERT] Thread Device address update
+    #
+    # 2.13.1. Purpose
+    # To test the following:
+    # - 1. Handle SRP update with change in IPv6 address(es) of the Thread Device
+    #
+    # 2.13.2. Topology
+    # - 1. BR 1 (DUT) - Thread Border Router, and the Leader
+    # - 2. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - 3. Eth 1-Test bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Server       | N/A          | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    br1_srp_port = int(pv.vars['BR_1_SRP_PORT'])
+    ADDRESS_1 = Ipv6Addr(pv.vars['ADDRESS_1'])
+    ADDRESS_2 = Ipv6Addr(pv.vars['ADDRESS_2'])
+    ADDRESS_3 = Ipv6Addr(pv.vars['ADDRESS_3'])
+
+    SRP_HOST_NAME = 'host-test-1.default.service.arpa'
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_SERVICE_PORT = 55555
+    MDNS_HOST_NAME = 'host-test-1.local'
+
+    SRP_LEASE_5M = Bytes(struct.pack('>I', 300).hex())
+
+    SERVICE_ID_SRP_UNICAST_DNS = 93
+
+    # Step 1
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.13): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: BR 1 (DUT) Enable")
+
+    # Step 2
+    # - Device: Eth 1, ED 1
+    # - Description (SRP-2.13): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth 1, ED 1 Enable")
+
+    # Step 3
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.13): Automatically adds its SRP Server information in the Thread Network Data.
+    #   Automatically adds an OMR Prefix in the Network Data.
+    # - Pass Criteria:
+    #   - The DUT MUST register an OMR Prefix in the Thread Network Data.
+    #   - The DUT's SRP Server information MUST appear in the Thread Network Data.
+    #   - It MUST be a DNS/SRP Unicast Dataset, or DNS/SRP Anycast Dataset, or both.
+    print("Step 3: BR 1 (DUT) SRP Server information and OMR Prefix in Network Data")
+    pkts.filter_mle_cmd(consts.MLE_DATA_RESPONSE). \
+      filter(lambda p: SERVICE_ID_SRP_UNICAST_DNS in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)). \
+      must_next()
+
+    # Step 4
+    # - Device: ED_1
+    # - Description (SRP-2.13): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+    #   _thread-test. udp PTR service-test-1._thread-test._udp service-test-1. thread-test._udp. ( SRV 55555
+    #   host-test-1 ) host-test-1 AAAA address_1 with the following options: Update Lease Option, Lease: 5 minutes,
+    #   Key Lease: 4 weeks and where address_1 is the OMR address of ED_1.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 4: ED_1 send SRP Update with address_1")
+    pkts.filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE). \
+      filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)). \
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)). \
+      filter(lambda p: ADDRESS_1 in verify_utils.as_list(p.dns.aaaa)). \
+      filter(lambda p: p.dns.opt.data is not None and \
+             any(data.format_compact().startswith(SRP_LEASE_5M.format_compact()) for data in p.dns.opt.data)). \
+      must_next()
+
+    # Step 5
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.13): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 5: BR 1 (DUT) Automatically sends SRP Update Response")
+    pkts.filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == 0). \
+      must_next()
+
+    # Step 6
+    # - Device: ED 1
+    # - Description (SRP-2.13): Harness instructs the device to send unicast QType=AAAA record DNS query for
+    #   host-test-1.default.service.arpa.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 6: ED 1 send DNS query for host-test-1")
+    pkts.filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == 53). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.qry.name)). \
+      must_next()
+
+    # Step 7
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.13): Automatically send DNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response containing: $ORIGIN default.service.arpa. host-test-1 AAAA address_1
+    print("Step 7: BR 1 (DUT) Automatically send DNS Response")
+    pkts.filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == 53). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == 0). \
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: ADDRESS_1 in verify_utils.as_list(p.dns.aaaa)). \
+      must_next()
+
+    # Step 8
+    # - Device: ED 1
+    # - Description (SRP-2.13): Harness instructs the device to send SRP update with an address change: $ORIGIN
+    #   default.service.arpa. _thread-test. udp PTR service-test-1._thread-test._udp service-test-1. thread-test._udp (
+    #   SRV 55555 host-test-1 ) host-test-1 AAAA address 2 host-test-1 AAAA address_3 with the following options:
+    #   Update Lease Option, Lease: 5 minutes, Key Lease: 4 weeks where address_2 is the ML-EID address of ED 1. And
+    #   address_3 is a new Harness-configured address that has the OMR prefix. Note: using the OT command srp client
+    #   host address address 2 address 3, these two addresses can be set for the SRP registration. Note: the OMR
+    #   prefix can be obtained automatically by the Harness by querying netdata on ED_1.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 8: ED 1 send SRP update with address_2 and address_3")
+    pkts.filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE). \
+      filter(lambda p: ADDRESS_2 in verify_utils.as_list(p.dns.aaaa)). \
+      filter(lambda p: ADDRESS_3 in verify_utils.as_list(p.dns.aaaa)). \
+      filter(lambda p: p.dns.opt.data is not None and \
+             any(data.format_compact().startswith(SRP_LEASE_5M.format_compact()) for data in p.dns.opt.data)). \
+      must_next()
+
+    # Step 9
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.13): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 9: BR 1 (DUT) Automatically sends SRP Update Response")
+    pkts.filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == 0). \
+      must_next()
+
+    # Step 10
+    # - Device: ED_1
+    # - Description (SRP-2.13): Harness instructs the device to send QType=AAAA record unicast DNS query for
+    #   host-test-1.default.service.arpa.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 10: ED 1 send DNS query for host-test-1")
+    pkts.filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == 53). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.qry.name)). \
+      must_next()
+
+    # Step 11
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.13): Automatically sends DNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response which contains the answer records: $ORIGIN default.service.arpa.
+    #     host-test-1 AAAA address_2 host-test-1 AAAA address_3
+    #   - The DNS Response MUST NOT contain address_1.
+    print("Step 11: BR 1 (DUT) Automatically sends DNS Response")
+    pkts.filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == 53). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == 0). \
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: ADDRESS_2 in verify_utils.as_list(p.dns.aaaa)). \
+      filter(lambda p: ADDRESS_3 in verify_utils.as_list(p.dns.aaaa)). \
+      filter(lambda p: ADDRESS_1 not in verify_utils.as_list(p.dns.aaaa)). \
+      must_next()
+
+    # Step 12
+    # - Device: Eth_1
+    # - Description (SRP-2.13): Harness instructs the device to send QType=AAAA record multicast mDNS query for
+    #   host-test-1.local.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 12: Eth 1 send mDNS query for host-test-1.local")
+    pkts.filter(lambda p: p.udp.dstport == 5353). \
+      filter(lambda p: p.mdns.flags.response == 0). \
+      filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.qry.name)). \
+      filter(lambda p: consts.DNS_TYPE_AAAA in verify_utils.as_list(p.mdns.qry.type)). \
+      must_next()
+
+    # Step 13
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.13): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response which contains at least one address: $ORIGIN local. Host-test-1 AAAA
+    #     address_3
+    #   - The response MAY further include the ML-EID address: $ORIGIN local. Host-test-1 AAAA address_2
+    #   - mDNS Response MUST NOT contain address_1.
+    print("Step 13: BR 1 (DUT) Automatically sends mDNS Response")
+    pkts.filter(lambda p: p.udp.dstport == 5353). \
+      filter(lambda p: p.mdns.flags.response == 1). \
+      filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.resp.name)). \
+      filter(lambda p: ADDRESS_3 in verify_utils.as_list(p.mdns.aaaa)). \
+      filter(lambda p: ADDRESS_1 not in verify_utils.as_list(p.mdns.aaaa)). \
+      must_next()
+
+    # Step 14
+    # - Device: Eth_1
+    # - Description (SRP-2.13): Harness instructs the device to send QType KEY record multicast mDNS query for
+    #   host-test-1.local.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 14: Eth 1 send mDNS query for KEY record")
+    # Note: Using AAAA query trigger from Step 14 in C++ as a proxy for this step.
+    pkts.filter(lambda p: p.udp.dstport == 5353). \
+      filter(lambda p: p.mdns.flags.response == 0). \
+      filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.qry.name)). \
+      must_next()
+
+    # Step 15
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.13): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response, with RCODE=0.
+    #   - It MAY contain either 0 Answer records, or it MAY contain 1 Answer record as follows: $ORIGIN local.
+    #     Host-test-1 KEY some public key
+    #   - The value some public key MAY be a dummy key value, for example zero.
+    print("Step 15: BR 1 (DUT) Automatically sends mDNS Response")
+    pkts.filter(lambda p: p.udp.dstport == 5353). \
+      filter(lambda p: p.mdns.flags.response == 1). \
+      filter(lambda p: p.mdns.flags.rcode == 0). \
+      must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_15.py b/tests/nexus/verify_1_3_SRP_TC_15.py
new file mode 100644
index 0000000..7347325
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_15.py
@@ -0,0 +1,555 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    # 2.15. [1.3] [CERT] Validation of SRP subtypes
+    #
+    # 2.15.1 Purpose
+    # To test the following:
+    # - 1. Handle service that includes additional subtypes of the basic service type.
+    # - 2. Respond to the DNS queries for the basic service type.
+    # - 3. Respond to the DNS queries for the service subtype.
+    # - 4. Handle addition of new subtype to the service.
+    # - 5. Handle removal of a subtype from the service.
+    # - 6. Test service types using 'tcp' in <Service> part of name.
+    #
+    # 2.15.2 Topology
+    # - 3. BR 1 (DUT) - Thread Border Router, and the Leader
+    # - 4. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - 5. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Server       | N/A          | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    br1_srp_port = int(pv.vars['BR_1_SRP_PORT'])
+
+    SRP_SERVICE_NAME = '_test-type._tcp.default.service.arpa'
+    SRP_SUBTYPE1_NAME = '_test-subtype._sub._test-type._tcp.default.service.arpa'
+    SRP_SUBTYPE2_NAME = '_other-subtype._sub._test-type._tcp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service-test-1._test-type._tcp.default.service.arpa'
+    SRP_HOST_NAME = 'host-test-1.default.service.arpa'
+    SRP_SERVICE_PORT = 55555
+
+    MDNS_SERVICE_NAME = '_test-type._tcp.local'
+    MDNS_SUBTYPE1_NAME = '_test-subtype._sub._test-type._tcp.local'
+    MDNS_SUBTYPE2_NAME = '_other-subtype._sub._test-type._tcp.local'
+    MDNS_SUBTYPE_INVALID_NAME = '_test-subtypee._sub._test-type._tcp.local'
+    MDNS_INSTANCE_NAME = 'service-test-1._test-type._tcp.local'
+
+    SERVICE_ID_SRP_SERVER = 93
+    DNS_PORT = 53
+    MDNS_PORT = 5353
+
+    # Step 1
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Enable
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 1: BR 1 (DUT) Enable")
+
+    # Step 2
+    #   Device: Eth 1, ED 1
+    #   Description (SRP-2.15): Enable
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth 1, ED 1 Enable")
+
+    # Step 3
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically adds SRP Server information in the Thread Network Data.
+    #   Pass Criteria:
+    #   - The DUT's SRP Server information MUST appear in the Thread Network Data.
+    print("Step 3: BR 1 (DUT) adds SRP Server information in Network Data")
+    pkts.\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: SERVICE_ID_SRP_SERVER in\
+               verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+        filter(lambda p: any(l in (1, 19) for l in verify_utils.as_list(p.thread_nwd.tlv.service.s_data_len))).\
+        must_next()
+
+    # Step 4
+    #   Device: ED 1
+    #   Description (SRP-2.15): Harness instructs the device to send SRP Update to the DUT that includes a subtype:
+    #     $ORIGIN default.service.arpa. _test-subtype._sub._test-type._tcp ( PTR service-test-1._test-type._tcp)
+    #     _test-type._tcp ( PTR service-test-1._test-type._tcp) service-test-1._test-type._tcp ( SRV 8 55555
+    #     host-test-1) host-test-1 AAAA <OMR address of ED 1>
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 4: ED 1 sends SRP Update with a subtype")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_SUBTYPE1_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        must_next()
+
+    # Step 5
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends SRP Update Response.
+    #   Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+    print("Step 5: BR 1 (DUT) sends SRP Update Response")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 6
+    #   Device: ED 1
+    #   Description (SRP-2.15): Harness instructs the device to unicast DNS query QType = PTR for
+    #     "_test-type._tcp.default.service.arpa" service type to the DUT.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 6: ED 1 sends unicast DNS query QType PTR for service type")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == DNS_PORT).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 7
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.15): Automatically sends DNS response with service-test-1.
+    #   Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response and contain service-test-1 as follows in the Answer section:
+    #     $ORIGIN default.service.arpa. _test-type._tcp ( PTR service-test-1. test-type._tcp)
+    #   - It MAY also contain in the Additional records section: $ORIGIN default.service.arpa.
+    #     service-test-1._test-type._tcp ( SRV 8 8 55555 host-test-1)
+    #   - host-test-1 AAAA <OMR address of ED 1>
+    print("Step 7: BR 1 (DUT) sends DNS response with service-test-1")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == DNS_PORT).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        must_next()
+
+    # Step 8
+    #   Device: Eth 1
+    #   Description (SRP-2.15): Harness instructs the device to send mDNS query QType PTR for _test-type._tcp.local."
+    #     service type.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 8: Eth 1 sends mDNS query QType PTR for service type")
+    pkts.\
+        filter(lambda p: p.udp.dstport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 9
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+    #   Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response with RCODE=0 (NoError) and contain service-test-1 as follows in the
+    #     Answer section: $ORIGIN local. _test-type._tcp ( PTR service-test-1. test-type._tcp)
+    #   - It MAY also contain in the Additional records section: $ORIGIN local. Service-test-1._test-type. tcp ( SRV 8
+    #     55555 host-test-1)
+    #   - host-test-1 AAAA <OMR address of ED_1>
+    print("Step 9: BR 1 (DUT) sends mDNS Response with service-test-1")
+    pkts.\
+        filter(lambda p: p.udp.srcport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        must_next()
+
+    # Step 10
+    #   Device: ED 1
+    #   Description (SRP-2.15): Harness instructs the device to unicast DNS query QType PTR for
+    #     _test-subtype._sub._test-type._tcp.default.service.arpa" service type to the DUT.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 10: ED 1 sends unicast DNS query QType PTR for service subtype")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == DNS_PORT).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_SUBTYPE1_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 11
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.15): Automatically sends DNS response with service-test-1.
+    #   Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response and contain service-test-1 as follows in the Answer records section:
+    #     $ORIGIN default.service.arpa. _test-subtype._sub._test-type._tcp ( PTR service-test-1. test-type._tcp)
+    #   - It MAY also contain in the Additional records section: $ORIGIN default.service.arpa.
+    #     service-test-1._test-type. tcp ( SRV 55555 host-test-1)
+    #   - host-test-1 AAAA <OMR address of ED_1>
+    print("Step 11: BR 1 (DUT) sends DNS response with service-test-1")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == DNS_PORT).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_SUBTYPE1_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        must_next()
+
+    # Step 12
+    #   Device: Eth 1
+    #   Description (SRP-2.15): Harness instructs the device to mDNS query QType PTR for
+    #     "_test-subtype._sub._test-type._tcp.local" service type.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 12: Eth 1 sends mDNS query QType PTR for service subtype")
+    pkts.\
+        filter(lambda p: p.udp.dstport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SUBTYPE1_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 13
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+    #   Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response with RCODE=0 (NoError) and contain service-test-1 as follows in the
+    #     Answer records: $ORIGIN local. _test-subtype._sub._test-type._tcp ( PTR service-test-1. test-type._tcp)
+    #   - It MAY also contain in the Additional records section: $ORIGIN local. Service-test-1. test-type._tcp ( SRV 8
+    #     55555 host-test-1)
+    #   - host-test-1 AAAA OMR address of ED_1>
+    print("Step 13: BR 1 (DUT) sends mDNS Response with service-test-1")
+    pkts.\
+        filter(lambda p: p.udp.srcport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: MDNS_SUBTYPE1_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        must_next()
+
+    # Step 14
+    #   Device: ED 1
+    #   Description (SRP-2.15): Harness instructs the device to send SRP Update to the DUT to add a new subtype to the
+    #     existing service: $ORIGIN default.service.arpa. _other-subtype._sub._test-type._tcp ( PTR service-test-1.
+    #     test-type._tcp) _test-subtype._sub._test-type._tcp ( PTR service-test-1._test-type._tcp) _test-type._tcp ( PTR
+    #     service-test-1. test-type._tcp) service-test-1. test-type._tcp ( SRV 8 8 55555 host-test-1) host-test-1 AAAA
+    #     OMR address of ED_1>
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 14: ED 1 adds a new subtype to the existing service")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_SUBTYPE1_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SUBTYPE2_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        must_next()
+
+    # Step 15
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends SRP Update Response.
+    #   Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+    print("Step 15: BR 1 (DUT) sends SRP Update Response")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 16
+    #   Device: Eth_1
+    #   Description (SRP-2.15): Harness instructs the device to mDNS query QType PTR for
+    #     "_other-subtype._sub._test-type._tcp.local" service type, to check that the new subtype is present.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 16: Eth 1 sends mDNS query QType PTR for the new subtype")
+    pkts.\
+        filter(lambda p: p.udp.dstport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SUBTYPE2_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 17
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+    #   Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response with RCODE=0 (NoError) and contain service-test-1 as follows in the
+    #     Answer records section: $ORIGIN local. _other-subtype._sub._test-type._tcp ( PTR
+    #     service-test-1._test-type._tcp)
+    #   - It MAY also contain in the Additional records section: $ORIGIN local. Service-test-1. test-type._tcp ( SRV
+    #     55555 host-test-1)
+    #   - host-test-1 AAAA OMR address of ED_1>
+    print("Step 17: BR 1 (DUT) sends mDNS Response with service-test-1")
+    pkts.\
+        filter(lambda p: p.udp.srcport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: MDNS_SUBTYPE2_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        must_next()
+
+    # Step 18
+    #   Device: Eth_1
+    #   Description (SRP-2.15): Harness instructs the device to mDNS query QType=PTR for
+    #     "_test-subtype._sub._test-type._tcp.local" service type, to check that the originally registered subtype is
+    #     still present.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 18: Eth 1 sends mDNS query QType PTR for the original subtype")
+    pkts.\
+        filter(lambda p: p.udp.dstport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SUBTYPE1_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 19
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+    #   Pass Criteria:
+    #   - Repeat Step 13
+    print("Step 19: BR 1 (DUT) sends mDNS Response (Repeat Step 13)")
+    pkts.\
+        filter(lambda p: p.udp.srcport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: MDNS_SUBTYPE1_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        must_next()
+
+    # Step 20
+    #   Device: Eth_1
+    #   Description (SRP-2.15): Harness instructs the device to send mDNS query QType PTR for _test-type._tcp.local."
+    #     service type, to check that the query for the parent type also works after new subtype registration.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 20: Eth 1 sends mDNS query QType PTR for the parent service type")
+    pkts.\
+        filter(lambda p: p.udp.dstport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 21
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+    #   Pass Criteria:
+    #   - Repeat Step 9
+    print("Step 21: BR 1 (DUT) sends mDNS Response (Repeat Step 9)")
+    pkts.\
+        filter(lambda p: p.udp.srcport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        must_next()
+
+    # Step 22
+    #   Device: Eth 1
+    #   Description (SRP-2.15): Harness instructs the device to send mDNS query QType PTR for non-existent subtype (name
+    #     with extra character added): "_test-subtypee._sub._test-type._tcp.local." service type, to check that no
+    #     service answer is given by the DUT.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 22: Eth 1 sends mDNS query QType PTR for a non-existent subtype")
+    pkts.\
+        filter(lambda p: p.udp.dstport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SUBTYPE_INVALID_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 23
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.15): Does not send an mDNS Response.
+    #   Pass Criteria:
+    #   - The DUT MUST NOT send an mDNS response.
+    print("Step 23: BR 1 (DUT) does not send an mDNS Response")
+    pkts.\
+        filter(lambda p: p.udp.srcport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_SUBTYPE_INVALID_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        must_not_next()
+
+    # Step 24
+    #   Device: ED 1
+    #   Description (SRP-2.15): Harness instructs the device to send SRP Update to the DUT to re-register (i.e.,
+    #     update), its service with the subtype "_test-subtype" removed from the service: $ORIGIN
+    #     default.service.arpa. _other-subtype._sub._test-type._tcp ( PTR service-test-1._test-type._tcp)
+    #     _test-type._tcp ( PTR service-test-1._test-type._tcp) service-test-1._test-type._tcp ( SRV 55555 host-test-1)
+    #     host-test-1 AAAA OMR address of ED_1> Note: such an update would automatically delete any other,
+    #     pre-existing subtypes on the SRP server for this service.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 24: ED 1 removes a subtype from the service")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_SUBTYPE2_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        must_next()
+
+    # Step 25
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends SRP Update Response.
+    #   Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response with RCODE=0 (NoError).
+    print("Step 25: BR 1 (DUT) sends SRP Update Response")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 26
+    #   Device: Eth 1
+    #   Description (SRP-2.15): Harness instructs the device to send mDNS query QType PTR for
+    #     _other-subtype._sub._test-type._tcp.local" service type, to check that this subtype is still present.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 26: Eth 1 sends mDNS query QType PTR for the remaining subtype")
+    pkts.\
+        filter(lambda p: p.udp.dstport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SUBTYPE2_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 27
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Automatically sends mDNS Response with service-test-1.
+    #   Pass Criteria:
+    #   - Repeat Step 17
+    print("Step 27: BR 1 (DUT) sends mDNS Response (Repeat Step 17)")
+    pkts.\
+        filter(lambda p: p.udp.srcport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: p.mdns.flags.rcode == 0).\
+        filter(lambda p: MDNS_SUBTYPE2_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        must_next()
+
+    # Step 28
+    #   Device: Eth 1
+    #   Description (SRP-2.15): Harness instructs the device to send mDNS QType=PTR query for
+    #     _test-subtype._sub._test-type._tcp.local." service type to check that the removed subtype is no longer present.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 28: Eth 1 sends mDNS query QType PTR for the removed subtype")
+    pkts.\
+        filter(lambda p: p.udp.dstport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SUBTYPE1_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 29
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Does not send an mDNS Response.
+    #   Pass Criteria:
+    #   - The DUT MUST NOT send an mDNS response.
+    print("Step 29: BR 1 (DUT) does not send an mDNS Response")
+    pkts.\
+        filter(lambda p: p.udp.srcport == MDNS_PORT).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_SUBTYPE1_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        must_not_next()
+
+    # Step 30
+    #   Device: ED 1
+    #   Description (SRP-2.15): Harness instructs the device to send SRP Update to the DUT which isn't a valid SRP
+    #     Update. This invalid update attempts to add a subtype to the service but is missing the Service Description
+    #     Instruction (SRV record): $ORIGIN default.service.arpa. _test-subtype-2._sub._test-type._tcp ( PTR
+    #     service-test-1._test-type._tcp) _test-type._tcp ( PTR service-test-1._test-type._tcp) host-test-1 AAAA OMR
+    #     address of ED_1> Note (TBD): sending an invalid SRP Update of this form is not supported by current reference
+    #     devices. As a future extension, the Harness should construct the required DNS Update message and send it
+    #     using the UDP-send API.
+    #   Pass Criteria:
+    #   - N/A
+    print("Step 30: ED 1 sends an invalid SRP Update (skipped)")
+
+    # Step 31
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.15): Rejects the invalid SRP Update.
+    #   Pass Criteria:
+    #   - N/A (skipping for 1.3.0 cert due to API issue - TBD)
+    #   - (Future requirement TBD: MUST send SRP Update Response with RCODE=5, REFUSED.)
+    print("Step 31: BR 1 (DUT) rejects the invalid SRP Update (skipped)")
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_2.py b/tests/nexus/verify_1_3_SRP_TC_2.py
new file mode 100644
index 0000000..8e82d2e
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_2.py
@@ -0,0 +1,454 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    # 2.2. [1.3] [CERT] Name Conflicts - Single Thread Network
+    #
+    # 2.2.1. Purpose
+    # To test the following:
+    # - 1. Handle name conflict in Host Description record.
+    # - 2. Handle name conflict in Service Description record.
+    # - 3. Verify that original service is seen when discovering, and conflicting
+    #   service is not seen on the AIL.
+    #
+    # 2.2.2. Topology
+    # - 1. BR_1 (DUT) - Border Router and the Leader.
+    # - 2. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - 3. ED 2-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - 4. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # 2.2.3. Initial Conditions
+    # - BR_1 (DUT) should automatically start SRP server capabilities at the
+    #   beginning of the test.
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    ED_2_MLEID = Ipv6Addr(pv.vars['ED_2_MLEID_ADDR'])
+    ED_1_OMR = Ipv6Addr(pv.vars['ED_1_OMR_ADDR'])
+    ED_2_OMR = Ipv6Addr(pv.vars['ED_2_OMR_ADDR'])
+    br1_srp_port = int(pv.vars['BR_1_SRP_PORT'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_1 = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_INSTANCE_2 = 'service-test-2.' + SRP_SERVICE_NAME
+    SRP_HOST_1 = 'host-test-1.default.service.arpa'
+    SRP_HOST_2 = 'host-test-2.default.service.arpa'
+    MDNS_SERVICE_NAME = '_thread-test._udp.local'
+    MDNS_INSTANCE_1 = 'service-test-1.' + MDNS_SERVICE_NAME
+    MDNS_INSTANCE_2 = 'service-test-2.' + MDNS_SERVICE_NAME
+    MDNS_HOST_1 = 'host-test-1.local'
+    MDNS_HOST_2 = 'host-test-2.local'
+
+    SRP_PORT_33333 = 33333
+    SRP_PORT_44444 = 44444
+
+    SERVICE_ID_SRP_SERVER = 93
+
+    # Step 1
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.2): Enable: switch on.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: BR 1 (DUT) switch on.")
+
+    # Step 2
+    # - Device: ED 1, ED 2, Eth 1
+    # - Description (SRP-2.2): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: ED 1, ED 2, Eth 1 Enable.")
+
+    # Step 3
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.2): Automatically adds its SRP Server information in the
+    #   Thread Network Data.
+    # - Pass Criteria:
+    #   - The DUT's SRP Server information MUST appear in the Thread Network Data.
+    #     This can be verified as specified in test case 2.1 step 3.
+    #   - Specifically, it MUST include a DNS-SD Unicast Dataset.
+    print("Step 3: BR 1 (DUT) adds its SRP Server information in the Thread Network Data.")
+    pkts. \
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE). \
+      filter(lambda p: SERVICE_ID_SRP_SERVER in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)). \
+      filter(lambda p: any(l in (1, 19) for l in verify_utils.as_list(p.thread_nwd.tlv.service.s_data_len))). \
+      must_next()
+
+    # Step 4
+    # - Device: ED 1
+    # - Description (SRP-2.2): Harness instructs the device to send SRP Update with
+    #   initial service registration: $ORIGIN default.service.arpa.
+    #   service-test-1._thread-test._udp ( SRV 0 0 33333 host-test-1 ) host-test-1
+    #   AAAA <OMR address of ED_1>
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 4: ED 1 sends SRP Update with initial service registration.")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == 5). \
+      filter(lambda p: SRP_INSTANCE_1 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_PORT_33333 in verify_utils.as_list(p.dns.srv.port)). \
+      filter(lambda p: SRP_HOST_1 in verify_utils.as_list(p.dns.srv.target)). \
+      filter(lambda p: ED_1_OMR in verify_utils.as_list(p.dns.aaaa)). \
+      must_next()
+
+    # Step 5
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.2): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 5: BR 1 (DUT) sends SRP Update Response (NoError).")
+    pkts. \
+      filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == 0). \
+      must_next()
+
+    # Step 6
+    # - Device: ED 2
+    # - Description (SRP-2.2): Harness instructs the device to send SRP Update with
+    #   same-name (conflicting) service registration: $ORIGIN
+    #   default.service.arpa. service-test-1._thread-test._udp ( SRV 0 0 33333
+    #   host-test-2 ) host-test-2 AAAA <OMR address of ED_2>
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 6: ED 2 sends SRP Update with same-name (conflicting) service registration.")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_2_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == 5). \
+      filter(lambda p: SRP_INSTANCE_1 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_PORT_33333 in verify_utils.as_list(p.dns.srv.port)). \
+      filter(lambda p: SRP_HOST_2 in verify_utils.as_list(p.dns.srv.target)). \
+      filter(lambda p: ED_2_OMR in verify_utils.as_list(p.dns.aaaa)). \
+      must_next()
+
+    # Step 7
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.2): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=6 (YXDOMAIN).
+    print("Step 7: BR 1 (DUT) sends SRP Update Response (YXDOMAIN).")
+    pkts. \
+      filter_ipv6_dst(ED_2_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == 6). \
+      must_next()
+
+    # mDNS checks helper
+    def verify_no_ed2_info(p):
+        assert ED_2_OMR not in verify_utils.as_list(p.mdns.aaaa), f"Packet {p.index} contains ED_2_OMR"
+        assert MDNS_INSTANCE_2 not in verify_utils.as_list(
+            p.mdns.ptr.domain_name), f"Packet {p.index} contains MDNS_INSTANCE_2 in PTR"
+        assert MDNS_INSTANCE_2 not in verify_utils.as_list(
+            p.mdns.resp.name), f"Packet {p.index} contains MDNS_INSTANCE_2 in resp.name"
+
+    def verify_mdns_ptr_response(pkts, instance_name):
+        return pkts.\
+            filter(lambda p: p.udp.dstport == 5353). \
+            filter(lambda p: p.mdns.flags.response == 1). \
+            filter(lambda p: instance_name in verify_utils.as_list(p.mdns.ptr.domain_name)). \
+            must_next()
+
+    def verify_mdns_srv_response(pkts, instance_name, port):
+        return pkts.\
+            filter(lambda p: p.udp.dstport == 5353). \
+            filter(lambda p: p.mdns.flags.response == 1). \
+            filter(lambda p: instance_name in verify_utils.as_list(p.mdns.resp.name)). \
+            filter(lambda p: port in verify_utils.as_list(p.mdns.srv.port)). \
+            must_next()
+
+    def verify_mdns_aaaa_response(pkts, host_name, omr_addr):
+        return pkts.\
+            filter(lambda p: p.udp.dstport == 5353). \
+            filter(lambda p: p.mdns.flags.response == 1). \
+            filter(lambda p: host_name in verify_utils.as_list(p.mdns.resp.name)). \
+            filter(lambda p: omr_addr in verify_utils.as_list(p.mdns.aaaa)). \
+            must_next()
+
+    # Step 8 era
+    index_step8 = pkts.index
+
+    # Step 8
+    # - Device: Eth 1
+    # - Description (SRP-2.2): Harness instructs device to perform mDNS query QType
+    #   PTR for any services of type _thread-test._udp.local..
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 8: Eth 1 performs mDNS query QType PTR.")
+
+    # Step 9
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.2): Automatically sends mDNS response with initial
+    #   registered service.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response and contain answer record:
+    #     _thread-test._udp.local PTR ( service-test-1._thread-test._udp.local. )
+    #   - It MAY also contain in the Additional records section:
+    #     service-test-1._thread-test._udp.local. ( SRV 0 0 33333 host-test-1.local. )
+    #   - It MAY also contain in the Additional records section: host-test-1.local.
+    #     AAAA <OMR address of ED_1>
+    #   - The mDNS Response MUST NOT contain any answer record or additional
+    #     record like: <any-string> AAAA <OMR address of ED_2>
+    #   - The mDNS Response MUST NOT contain any answer record or additional
+    #     record like: host-test-1.local. AAAA <any non-OMR address>
+    #   - The mDNS Response MUST NOT contain any answer record or additional
+    #     record like: service-test-2._thread-test._udp.local. SRV <any data>
+    #   - The mDNS Response MUST NOT contain any answer record or additional
+    #     record like: <any-name> PTR service-test-2._thread-test_udp.local.
+    print("Step 9: BR 1 (DUT) sends mDNS response with initial registered service.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        res = verify_mdns_ptr_response(pkts, MDNS_INSTANCE_1)
+        verify_no_ed2_info(res)
+
+    # Step 9b
+    # - Device: Eth 1
+    # - Description (SRP-2.2): Harness instructs the device to send mDNS query
+    #   QTYPE SRV QNAME service-test-1._thread-test._udp.local
+    # - Pass Criteria:
+    #   - The DUT MUST respond RCODE=0 with Answer section record:
+    #     service-test-1._thread-test._udp.local ( SRV 0 0 33333 host-test-1.local )
+    print("Step 9b: Eth 1 sends mDNS query QTYPE SRV.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_srv_response(pkts, MDNS_INSTANCE_1, SRP_PORT_33333)
+
+    # Step 9c
+    # - Device: Eth 1
+    # - Description (SRP-2.2): Harness instructs the device to send mDNS query
+    #   QTYPE AAAA QNAME host-test-1.local
+    # - Pass Criteria:
+    #   - The DUT MUST respond RCODE=0 with Answer section record:
+    #     host-test-1.local AAAA <OMR address of ED_1>
+    print("Step 9c: Eth 1 sends mDNS query QTYPE AAAA.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_aaaa_response(pkts, MDNS_HOST_1, ED_1_OMR)
+
+    # Step 10
+    # - Device: ED 2
+    # - Description (SRP-2.2): Harness instructs the device to send SRP Update with
+    #   same-hostname (conflicting) service registration: $ORIGIN
+    #   default.service.arpa. service-test-2._thread-test._udp ( SRV 0 0 33333
+    #   host-test-1 ) host-test-1 AAAA <OMR address of ED_2>
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 10: ED 2 sends SRP Update with same-hostname (conflicting) service registration.")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_2_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == 5). \
+      filter(lambda p: SRP_INSTANCE_2 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_PORT_33333 in verify_utils.as_list(p.dns.srv.port)). \
+      filter(lambda p: SRP_HOST_1 in verify_utils.as_list(p.dns.srv.target)). \
+      filter(lambda p: ED_2_OMR in verify_utils.as_list(p.dns.aaaa)). \
+      must_next()
+
+    # Step 11
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.2): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=6 (YXDOMAIN).
+    print("Step 11: BR 1 (DUT) sends SRP Update Response (YXDOMAIN).")
+    pkts. \
+      filter_ipv6_dst(ED_2_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == 6). \
+      must_next()
+
+    # Step 12
+    # - Device: Eth 1
+    # - Description (SRP-2.2): Repeat Step 8
+    # - Pass Criteria:
+    #   - Repeat Step 8
+    print("Step 12: Eth 1 repeats mDNS query QType PTR.")
+
+    # Step 13
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.2): Repeat Step 9 (including 9b, 9c, etc)
+    # - Pass Criteria:
+    #   - Repeat Step 9 (including 9b, 9c, etc)
+    print("Step 13: BR 1 (DUT) repeats mDNS response verification.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        res = verify_mdns_ptr_response(pkts, MDNS_INSTANCE_1)
+        assert ED_1_OMR in verify_utils.as_list(res.mdns.aaaa)
+        verify_no_ed2_info(res)
+
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_srv_response(pkts, MDNS_INSTANCE_1, SRP_PORT_33333)
+
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_aaaa_response(pkts, MDNS_HOST_1, ED_1_OMR)
+
+    # Step 14
+    # - Device: ED 2
+    # - Description (SRP-2.2): Harness instructs the device to send SRP Update with
+    #   non-conflicting service registration: $ORIGIN default.service.arpa.
+    #   service-test-2._thread-test._udp ( SRV 1 0 44444 host-test-2 ) host-test-2
+    #   AAAA <OMR address of ED_2>
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 14: ED 2 sends SRP Update with non-conflicting service registration.")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_2_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == 5). \
+      filter(lambda p: SRP_INSTANCE_2 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_PORT_44444 in verify_utils.as_list(p.dns.srv.port)). \
+      filter(lambda p: SRP_HOST_2 in verify_utils.as_list(p.dns.srv.target)). \
+      filter(lambda p: ED_2_OMR in verify_utils.as_list(p.dns.aaaa)). \
+      must_next()
+
+    # Step 15
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.2): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 15: BR 1 (DUT) sends SRP Update Response (NoError).")
+    pkts. \
+      filter_ipv6_dst(ED_2_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == 0). \
+      must_next()
+
+    # Step 16
+    # - Device: Eth 1
+    # - Description (SRP-2.2): Harness instructs the device to perform mDNS query
+    #   QType PTR for any services of type _thread-test_udp.local..
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 16: Eth 1 performs mDNS query QType PTR.")
+
+    # Step 17
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.2): Automatically sends mDNS response with both
+    #   registered services.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response and MUST contain 2 answer
+    #     records: _thread-test._udp.local ( PTR service-test-1._thread-test._udp.local )
+    #     AND _thread-test._udp.local ( PTR service-test-2._thread-test._udp.local )
+    #   - It MAY also contain in the Additional records section:
+    #     service-test-1._thread-test._udp.local. ( SRV 0 0 33333 host-test-1.local. )
+    #   - It MAY also contain in the Additional records section:
+    #     service-test-2._thread-test._udp.local. ( SRV 1 0 44444 host-test-2.local. )
+    #   - It MAY also contain in the Additional records section: host-test-1.local.
+    #     AAAA <OMR address of ED_1>
+    #   - It MAY also contain in the Additional records section: host-test-2.local.
+    #     AAAA <OMR address of ED_2>
+    #   - The response MAY be sent as two separate mDNS response messages,
+    #     splitting over the 2 services.
+    print("Step 17: BR 1 (DUT) sends mDNS response with both registered services.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_ptr_response(pkts, MDNS_INSTANCE_1)
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_ptr_response(pkts, MDNS_INSTANCE_2)
+
+    # Step 17b
+    # - Device: Eth 1
+    # - Description (SRP-2.2): Harness instructs the device to send mDNS query
+    #   QTYPE=SRV QNAME service-test-1._thread-test._udp.local
+    # - Pass Criteria:
+    #   - The DUT MUST respond RCODE=0 with Answer section record:
+    #     service-test-1._thread-test._udp.local ( SRV 0 0 33333 host-test-1.local )
+    print("Step 17b: Eth 1 sends mDNS query QTYPE SRV instance 1.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_srv_response(pkts, MDNS_INSTANCE_1, SRP_PORT_33333)
+
+    # Step 17c
+    # - Device: Eth_1
+    # - Description (SRP-2.2): Harness instructs the device to send mDNS query
+    #   QTYPE SRV QNAME service-test-2._thread-test_udp.local
+    # - Pass Criteria:
+    #   - The DUT MUST respond RCODE=0 with Answer section record:
+    #     service-test-2._thread-test._udp.local ( SRV 1 0 44444 host-test-2.local )
+    print("Step 17c: Eth 1 sends mDNS query QTYPE SRV instance 2.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_srv_response(pkts, MDNS_INSTANCE_2, SRP_PORT_44444)
+
+    # Step 17d
+    # - Device: Eth 1
+    # - Description (SRP-2.2): Harness instructs the device to send mDNS query
+    #   QTYPE=AAAA QNAME host-test-1.local
+    # - Pass Criteria:
+    #   - The DUT MUST respond RCODE=0 with Answer section record:
+    #     host-test-1.local AAAA <OMR address of ED_1>
+    print("Step 17d: Eth 1 sends mDNS query QTYPE AAAA host 1.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_aaaa_response(pkts, MDNS_HOST_1, ED_1_OMR)
+
+    # Step 17e
+    # - Device: Eth_1
+    # - Description (SRP-2.2): Harness instructs the device to send mDNS query
+    #   QTYPE AAAA QNAME host-test-2.local
+    # - Pass Criteria:
+    #   - THE DUT MUST respond RCODE=0 with Answer section record:
+    #     host-test-2.local AAAA <OMR address of ED_2>
+    print("Step 17e: Eth 1 sends mDNS query QTYPE AAAA host 2.")
+    with pkts.save_index():
+        pkts.index = index_step8
+        verify_mdns_aaaa_response(pkts, MDNS_HOST_2, ED_2_OMR)
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_3.py b/tests/nexus/verify_1_3_SRP_TC_3.py
new file mode 100644
index 0000000..296d5ad
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_3.py
@@ -0,0 +1,474 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+import struct
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr, EthAddr
+from pktverify.bytes import Bytes
+
+
+def verify(pv):
+    #
+    # 2.3. [1.3] [CERT] Service Instance Lease - Renewal and Automatic Service/Host Removal
+    #
+    # 2.3.1. Purpose
+    # To test the following:
+    # - Handle lease renewal before lease expiration.
+    # - Handle service/host lease expiration and removal of the service/host.
+    #
+    # 2.3.2. Topology
+    # - 1. BR_1 (DUT)-Thread Border Router and the Leader
+    # - 2. ED_1-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - 3. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference           | V1.3.0 Section
+    # -------------------------|---------------
+    # DNS-SD Unicast Dataset   | 14.3.2.2
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    ED_1_OMR = Ipv6Addr(pv.vars['ED_1_OMR_ADDR'])
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    br1_srp_port = int(pv.vars['BR_1_SRP_PORT'])
+    BR_1_ETH = EthAddr(pv.vars['BR_1_ETH'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_HOST_NAME = 'host-test-1.default.service.arpa'
+    MDNS_SERVICE_NAME = '_thread-test._udp.local'
+    MDNS_INSTANCE_NAME = 'service-test-1.' + MDNS_SERVICE_NAME
+    MDNS_HOST_NAME = 'host-test-1.local'
+
+    SRP_SERVICE_PORT = 55555
+
+    SERVICE_ID_SRP_SERVER = 93
+    SRP_LEASE = 30
+    SRP_KEY_LEASE = 600
+    SRP_LEASE_DATA = Bytes(struct.pack('>II', SRP_LEASE, SRP_KEY_LEASE).hex())
+
+    def verify_srp_update(pkts, dst, src, srp_port, instance_name, service_port, host_name, omr_addr, lease_data):
+        return pkts.\
+            filter_ipv6_dst(dst).\
+            filter_ipv6_src(src).\
+            filter(lambda p: p.udp.dstport == srp_port).\
+            filter(lambda p: p.dns.flags.opcode == 5).\
+            filter(lambda p: instance_name in verify_utils.as_list(p.dns.resp.name)).\
+            filter(lambda p: service_port is None or service_port in verify_utils.as_list(p.dns.srv.port)).\
+            filter(lambda p: host_name is None or host_name in verify_utils.as_list(p.dns.srv.target)).\
+            filter(lambda p: omr_addr in verify_utils.as_list(p.dns.aaaa)).\
+            filter(lambda p: lease_data in verify_utils.as_list(p.dns.opt.data)).\
+            must_next()
+
+    # Step 1
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Enable: switch on.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: BR 1 (DUT) switch on.")
+
+    # Step 2
+    # - Device: Eth 1, ED 1
+    # - Description (SRP-2.3): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth 1, ED 1 Enable.")
+
+    # Step 3
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Automatically adds SRP Server information in the Thread Network Data. Automatically
+    #   provides an OMR prefix to the Thread Network.
+    # - Pass Criteria:
+    #   - The DUT's SRP Server information MUST appear in the Thread Network Data. This can be verified as specified
+    #     in test case 2.1 step 3.
+    #   - Specifically, it MUST include a DNS-SD Unicast Dataset as per [ThreadSpec] 14.3.2.2.
+    #   - S_service_data Length MUST be 1 or 19
+    print("Step 3: BR 1 (DUT) adds SRP Server information in the Thread Network Data.")
+    pkts.\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: SERVICE_ID_SRP_SERVER in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+        filter(lambda p: any(l in (1, 19) for l in verify_utils.as_list(p.thread_nwd.tlv.service.s_data_len))).\
+        must_next()
+
+    # Step 4
+    # - Device: ED 1
+    # - Description (SRP-2.3): Harness instructs the device to send SRP Update to DUT: $ORIGIN default.service.arpa.
+    #   service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1> with the
+    #   following options: Update Lease Option, Lease: 30 seconds, Key Lease: 10 minutes. Harness
+    #   instructs/configures device to avoid automatic renewal or resending of this SRP Update.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 4: ED 1 sends SRP Update to DUT.")
+    verify_srp_update(pkts, BR_1_MLEID, ED_1_MLEID, br1_srp_port, SRP_INSTANCE_NAME, SRP_SERVICE_PORT, SRP_HOST_NAME,
+                      ED_1_OMR, SRP_LEASE_DATA)
+
+    # Step 5
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 5: BR 1 (DUT) sends SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 6
+    # - Device: ED 1
+    # - Description (SRP-2.3): Harness instructs the device to unicast DNS query QType SRV to the DUT for service
+    #   "service-test-1._thread-test._udp.default.service.arpa"
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 6: ED 1 sends unicast DNS query QType SRV.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 7
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Automatically sends DNS response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response and contain in Answers section: $ORIGIN default.service.arpa.
+    #     service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 )
+    #   - The DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+    #     <OMR address of ED_1>
+    print("Step 7: BR 1 (DUT) sends DNS response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+        must_next()
+
+    # Step 8
+    # - Device: Eth_1
+    # - Description (SRP-2.3): Harness instructs the device to send mDNS query QType SRV for
+    #   "service-test-1._thread-test._udp.local."
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 8: Eth 1 sends mDNS query QType SRV.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 9
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response and contain in Answers section: $ORIGIN local.
+    #     Service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 )
+    #   - The DNS Response MAY contain in Additional records section: $ORIGIN local. Host-test-1 AAAA <OMR address of
+    #     ED_1>
+    print("Step 9: BR 1 (DUT) sends mDNS Response.")
+    pkts.\
+        filter(lambda p: p.eth.src == BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.srv.target)).\
+        must_next()
+
+    # Step 10
+    # - Device: N/A
+    # - Description (SRP-2.3): Harness waits for 15 seconds.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 10: Harness waits for 15 seconds.")
+
+    # Step 11
+    # - Device: ED 1
+    # - Description (SRP-2.3): Harness instructs the device to send SRP Update to the DUT to renew the service lease:
+    #   $ORIGIN default.service.arpa. service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 ) host-test-1 AAAA
+    #   <OMR address of ED_1> with the following options: Update Lease Option, Lease: 30 seconds, Key Lease: 10
+    #   minutes. Harness instructs/configures the device to avoid automatic renewal or resending of this SRP Update.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 11: ED 1 sends SRP Update to renew the service lease.")
+    verify_srp_update(pkts, BR_1_MLEID, ED_1_MLEID, br1_srp_port, SRP_INSTANCE_NAME, None, None, ED_1_OMR,
+                      SRP_LEASE_DATA)
+
+    # Step 12
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 12: BR 1 (DUT) sends SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 13
+    # - Device: N/A
+    # - Description (SRP-2.3): Harness waits for 16 seconds.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 13: Harness waits for 16 seconds.")
+
+    # Step 14
+    # - Device: ED 1
+    # - Description (SRP-2.3): Harness instructs the device to unicast DNS query QType SRV to the DUT for service
+    #   "service-test-1._thread-test._udp.default.service.arpa"
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 14: ED 1 sends unicast DNS query QType SRV.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 15
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Automatically sends DNS response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response and contain answer record: $ORIGIN default.service.arpa.
+    #     service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-1 )
+    #   - The DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+    #     <OMR address of ED_1>
+    print("Step 15: BR 1 (DUT) sends DNS response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+        must_next()
+
+    # Step 16
+    # - Device: Eth 1
+    # - Description (SRP-2.3): Harness instructs the device to send mDNS query QType PTR for any services of type
+    #   "_thread-test._udp.local."
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 16: Eth 1 sends mDNS query QType PTR.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 17
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response and contain answer record: $ORIGIN local. _thread-test._udp ( PTR
+    #     service-test-1._thread-test._udp )
+    #   - THe DNS Response MAY contain in Additional records section: $ORIGIN local. Service-test-1_thread-test._udp (
+    #     SRV 0 0 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+    print("Step 17: BR 1 (DUT) sends mDNS Response.")
+    pkts.\
+        filter(lambda p: p.eth.src == BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        must_next()
+
+    # Step 18
+    # - Device: N/A
+    # - Description (SRP-2.3): Harness waits 15 seconds for the service lease to expire. Note: Both service and host
+    #   entries should expire now, after >= 31 seconds after last registration update. But the KEY record registration
+    #   remains active for both names.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 18: Harness waits 15 seconds for the service lease to expire.")
+
+    # Step 19
+    # - Device: ED 1
+    # - Description (SRP-2.3): Harness instructs the device to unicast DNS query QType SRV to the DUT for service
+    #   instance name "service-test-1._thread-test._udp.default.service.arpa"
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 19: ED 1 sends unicast DNS query QType SRV.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 20
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Automatically sends DNS response but no matching SRV records. Note: only the key lease
+    #   is still active on the service instance name. The DUT's response may vary as shown on the right, depending on
+    #   implementation.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response and MUST be one of the below:
+    #   - 1. RCODE=0 (NoError) with 0 answer records.
+    #   - 2. RCODE=3 (NxDomain) with 0 answer records.
+    print("Step 20: BR 1 (DUT) sends DNS response (no SRV).")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode in (0, 3)).\
+        filter(lambda p: p.dns.count.answers == 0).\
+        must_next()
+
+    # Step 21
+    # - Device: ED 1
+    # - Description (SRP-2.3): (Reserved for TBD future unicast DNS query QTYPE=ANY to the DUT for records for name
+    #   "host-test-1.default.service.arpa".)
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 21: ED 1 reserved step.")
+
+    # Step 22
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): (Reserved for TBD future: Automatically sends DNS response.) Note: because the key
+    #   lease is still active, the KEY record is optionally returned in response to the "ANY" query.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 22: BR 1 (DUT) reserved step.")
+
+    # Step 23
+    # - Device: Eth 1
+    # - Description (SRP-2.3): Harness instructs device to send mDNS query QType=SRV for
+    #   "service-test-1._thread-test_udp.local."
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 23: Eth 1 sends mDNS query QType SRV.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    def verify_optional_mdns_no_answer_response():
+        with pkts.save_index():
+            responses = pkts.\
+                filter(lambda p: p.eth.src == BR_1_ETH).\
+                filter(lambda p: p.udp.dstport == 5353).\
+                filter(lambda p: p.mdns.flags.response == 1)
+            p = responses.next()
+            if p:
+                p.must_verify(lambda p: p.mdns.flags.rcode == 0 and
+                              (p.mdns.count.answers == 0 or hasattr(p.mdns, 'nsec')))
+
+    # Step 24
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Does not respond to mDNS query, or automatically responds NSEC, or no-error-no-answer.
+    # - Pass Criteria:
+    #   - The DUT MUST perform one of below options:
+    #   - 1. Sends RCODE=0 (NoError) and NSEC Answer record.
+    #   - 2. MUST NOT send any mDNS response.
+    #   - 3. Sends RCODE=0 (NoError) and 0 Answer records.
+    print("Step 24: BR 1 (DUT) mDNS verification for Step 23.")
+    verify_optional_mdns_no_answer_response()
+
+    # Step 25
+    # - Device: Eth_1
+    # - Description (SRP-2.3): Harness instructs the device to send mDNS query QType=PTR for any services of type
+    #   "_thread-test._udp.local."
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 25: Eth 1 sends mDNS query QType PTR.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 26
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Does not respond to mDNS query, or automatically responds NSEC, or no-error-no-answer.
+    # - Pass Criteria:
+    #   - The DUT MUST perform one of below options:
+    #   - 1. Sends RCODE=0 (NoError) and NSEC Answer record.
+    #   - 2. MUST NOT send any mDNS response.
+    #   - 3. Sends RCODE=0 (NoError) and 0 Answer records.
+    print("Step 26: BR 1 (DUT) mDNS verification for Step 25.")
+    verify_optional_mdns_no_answer_response()
+
+    # Step 27
+    # - Device: Eth 1
+    # - Description (SRP-2.3): Harness instructs device to send mDNS query for Qtype AAAA for name "host-test-1.local."
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 27: Eth 1 sends mDNS query for QType AAAA.")
+    pkts.\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 28
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.3): Does not respond to mDNS query, or automatically responds NSEC, or no-error-no-answer.
+    #   Note: this indicates the DUT is authoritative for the host name, but has no AAAA records to answer for the
+    #   name.
+    # - Pass Criteria:
+    #   - The DUT MUST perform one of below options:
+    #   - 1. Sends RCODE=0 (NoError) and NSEC Answer record.
+    #   - 2. MUST NOT send any mDNS response.
+    #   - 3. Sends RCODE=0 (NoError) and 0 Answer records.
+    print("Step 28: BR 1 (DUT) mDNS verification for Step 27.")
+    verify_optional_mdns_no_answer_response()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_4.py b/tests/nexus/verify_1_3_SRP_TC_4.py
new file mode 100644
index 0000000..5fcdd7b
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_4.py
@@ -0,0 +1,396 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+import struct
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr, EthAddr
+from pktverify.bytes import Bytes
+
+
+def verify(pv):
+    # 2.4. [1.3] [CERT] Key Lease
+    #
+    # 2.4.1. Purpose
+    # To test the following:
+    # - 1. Key lease handling on SRP server on DUT.
+    # - 2. Verify that a service instance name cannot be claimed even after the service instance lease expired, if the
+    #   key lease is still active.
+    #
+    # 2.4.2. Topology
+    # - BR 1 (DUT) - Thread Border Router and the Leader
+    # - ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - ED 2-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Server       | N/A          | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    ED_2_MLEID = Ipv6Addr(pv.vars['ED_2_MLEID_ADDR'])
+    BR_1_ETH = EthAddr(pv.vars['BR_1_ETH'])
+    ETH_1_ETH = EthAddr(pv.vars['Eth_1_ETH'])
+    BR_1_SRP_PORT = int(pv.vars['BR_1_SRP_PORT'])
+
+    ED_1_OMR_ADDR = Ipv6Addr(pv.vars['ED_1_OMR_ADDR'])
+    ED_2_OMR_ADDR = Ipv6Addr(pv.vars['ED_2_OMR_ADDR'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME1 = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_INSTANCE_NAME3 = 'service-test-3.' + SRP_SERVICE_NAME
+    SRP_HOST_NAME1 = 'host-test-1.default.service.arpa'
+    SRP_HOST_NAME2 = 'host-test-2.default.service.arpa'
+
+    MDNS_SERVICE_NAME = '_thread-test._udp.local'
+    MDNS_INSTANCE_NAME1 = 'service-test-1.' + MDNS_SERVICE_NAME
+    MDNS_HOST_NAME1 = 'host-test-1.local'
+
+    SRP_SERVICE_PORT = 55555
+
+    SRP_LEASE_30S_60S = Bytes(struct.pack('>II', 30, 60).hex())
+    SRP_LEASE_30S_30S = Bytes(struct.pack('>II', 30, 30).hex())
+    SRP_LEASE_10S_60S = Bytes(struct.pack('>II', 10, 60).hex())
+
+    def verify_srp_update(pkts, dst, src, srp_port, instance_name, host_name, omr_addr, lease_data):
+        return pkts.\
+            filter_ipv6_dst(dst).\
+            filter_ipv6_src(src).\
+            filter(lambda p: p.udp.dstport == srp_port).\
+            filter(lambda p: p.dns.flags.opcode == 5).\
+            filter(lambda p: instance_name in verify_utils.as_list(p.dns.resp.name)).\
+            filter(lambda p: host_name is None or host_name in verify_utils.as_list(p.dns.srv.target)).\
+            filter(lambda p: omr_addr in verify_utils.as_list(p.dns.aaaa)).\
+            filter(lambda p: p.dns.opt.data is not None and
+                   any(data.format_compact() == lease_data.format_compact() for data in p.dns.opt.data)).\
+            must_next()
+
+    # Step 1
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.4): Enable: switch on.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: BR_1 (DUT) enable.")
+
+    # Step 2
+    # - Device: Eth 1, ED 1, ED 2
+    # - Description (SRP-2.4): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth 1, ED 1, ED 2 enable.")
+
+    # Step 3
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.4): Automatically adds its SRP Server information in the Thread Network Data.
+    # - Pass Criteria:
+    #   - The DUT's SRP Server information MUST appear in the Thread Network Data: it MUST contain a DNS/SRP Unicast
+    #     Dataset, or a DNS/SRP Anycast Dataset, or both.
+    print("Step 3: BR_1 (DUT) adds its SRP Server information in the Thread Network Data.")
+    pkts.\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: 93 in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+        filter(lambda p: any(l in (1, 19) for l in verify_utils.as_list(p.thread_nwd.tlv.service.s_data_len))).\
+        must_next()
+
+    # Step 4
+    # - Device: ED 1
+    # - Description (SRP-2.4): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+    #   service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1> with the
+    #   following options: Update Lease Option Lease: 30 seconds Key Lease: 60 seconds
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 4: ED 1 sends SRP Update.")
+    verify_srp_update(pkts, BR_1_MLEID, ED_1_MLEID, BR_1_SRP_PORT, SRP_INSTANCE_NAME1, SRP_HOST_NAME1, ED_1_OMR_ADDR,
+                      SRP_LEASE_30S_60S)
+
+    # Step 5
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.4): Automatically sends SRP Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Response with RCODE=0 (NoError).
+    print("Step 5: BR 1 (DUT) sends SRP Response.")
+    last_response = pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 6
+    # - Device: ED_1
+    # - Description (SRP-2.4): Harness instructs the device to unicast DNS query QType=SRV for
+    #   "service-test-1._thread-test_udp.default.service.arpa".
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 6: ED 1 sends unicast DNS query QType=SRV.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME1 in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 7
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.4): Automatically sends DNS response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response containing answer record: $ORIGIN default.service.arpa.
+    #     service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 )
+    #   - The DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+    #     <OMR address of ED_1>
+    print("Step 7: BR 1 (DUT) sends DNS response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: SRP_HOST_NAME1 in verify_utils.as_list(p.dns.srv.target)).\
+        must_next()
+
+    # Step 8
+    # - Device: ED 1
+    # - Description (SRP-2.4): Harness instructs the device to send unicast DNS query QType=PTR for any services of type
+    #   _thread-test_udp.default.service.arpa.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 8: ED 1 sends unicast DNS query QType=PTR.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 9
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.4): Automatically sends DNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid DNS Response containing answer record: $ORIGIN default.service.arpa.
+    #     _thread-test._udp ( PTR service-test-1._thread-test._udp )
+    #   - The DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa.
+    #     service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+    print("Step 9: BR 1 (DUT) sends DNS Response.")
+    last_response = pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME1 in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        must_next()
+
+    # Step 10
+    # - Device: N/A
+    # - Description (SRP-2.4): Harness waits for 35 seconds. Note: during this time there must be no automatic
+    #   reregistration of the service done by ED 1. This can be implemented on (OpenThread) ED_1 by either disabling
+    #   automatic service reregistration or by internally removing the service without doing the deregistration
+    #   request.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 10: Wait 35 seconds.")
+    pkts.filter(lambda p: p.sniff_timestamp > last_response.sniff_timestamp + 34).next()
+
+    # Step 11
+    # - Device: ED 2
+    # - Description (SRP-2.4): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+    #   service-test-1._thread-test._udp ( SRV 0 0 55555 host-test-2 ) host-test-2 AAAA <OMR address of ED_2> with the
+    #   following options: Update Lease Option Lease: 30 seconds Key Lease: 30 seconds
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 11: ED 2 sends SRP Update.")
+    verify_srp_update(pkts, BR_1_MLEID, ED_2_MLEID, BR_1_SRP_PORT, SRP_INSTANCE_NAME1, SRP_HOST_NAME2, ED_2_OMR_ADDR,
+                      SRP_LEASE_30S_30S)
+
+    # Step 12
+    # - Device: BR 1. (DUT)
+    # - Description (SRP-2.4): Automatically sends SRP Update Response, denying the registration because the key lease
+    #   for the expired service 'service-test-1' is still active. Note: spec for this is 3.2.5.2, 3.3.3, 3.3.5 and 5.1
+    #   of [draft-srp-23].
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response with RCODE=6 (YXDOMAIN).
+    print("Step 12: BR 1 (DUT) sends SRP Update Response (YXDOMAIN).")
+    last_response = pkts.\
+        filter_ipv6_dst(ED_2_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 6).\
+        must_next()
+
+    # Step 13
+    # - Device: N/A
+    # - Description (SRP-2.4): Harness waits for 30 seconds. Note: during this time there must be no automatic
+    #   reregistration of the service done by ED 2. This can be implemented on (Open Thread) ED_2 by either disabling
+    #   automatic service reregistration or by internally removing the service without doing the deregistration
+    #   request.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 13: Wait 30 seconds.")
+    pkts.filter(lambda p: p.sniff_timestamp > last_response.sniff_timestamp + 29).next()
+
+    # Step 14
+    # - Device: ED 2
+    # - Description (SRP-2.4): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+    #   service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_2> Update
+    #   Lease Option Lease: 10 seconds Key Lease: 60 seconds Note: ED 2 here uses the hostname of ED 1 to verify that
+    #   the name host-test-1 has become available again, after key expiry thereaf. Note: the requested Lease time of
+    #   10 seconds will be automatically adjusted by the SRP Server to 30 seconds (the minimum allowed).
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 14: ED 2 sends SRP Update.")
+    verify_srp_update(pkts, BR_1_MLEID, ED_2_MLEID, BR_1_SRP_PORT, SRP_INSTANCE_NAME1, SRP_HOST_NAME1, ED_2_OMR_ADDR,
+                      SRP_LEASE_10S_60S)
+
+    # Step 15
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.4): Automatically sends SRP Update Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Response with RCODE = 0 (NoError).
+    print("Step 15: BR 1 (DUT) sends SRP Response.")
+    pkts.\
+        filter_ipv6_dst(ED_2_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 16
+    # - Device: Eth_1
+    # - Description (SRP-2.4): Harness instructs the device to send mDNS query QType PTR for any services of type
+    #   "_thread-test._udp.local."
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 16: Eth 1 sends mDNS query QType PTR.")
+    pkts.\
+        filter(lambda p: p.eth.src == ETH_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 17
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.4): Automatically sends mDNS Response.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid mDNS Response containing only answer record: $ORIGIN local. _thread-test._udp (
+    #     PTR service-test-1._thread-test._udp )
+    #   - The mDNS Response MAY contain in Additional records section: $ORIGIN local.
+    #     Service-test-1._thread-test._udp ( SRV 1 1 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_2>
+    print("Step 17: BR 1 (DUT) sends mDNS Response.")
+    last_response = pkts.\
+        filter(lambda p: p.eth.src == BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_INSTANCE_NAME1 in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        filter(lambda p: MDNS_HOST_NAME1 in verify_utils.as_list(p.mdns.srv.target)).\
+        filter(lambda p: ED_2_OMR_ADDR in verify_utils.as_list(p.mdns.aaaa)).\
+        must_next()
+
+    # Step 18
+    # - Device: N/A
+    # - Description (SRP-2.4): Harness waits for 35 seconds, for the service lease to expire.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 18: Wait 35 seconds.")
+    pkts.filter(lambda p: p.sniff_timestamp > last_response.sniff_timestamp + 34).next()
+
+    # Step 19
+    # - Device: Eth_1
+    # - Description (SRP-2.4): Harness instructs the device to send mDNS query QType PTR for any services of type
+    #   "_thread-test_udp.local."
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 19: Eth 1 sends mDNS query QType PTR.")
+    pkts.\
+        filter(lambda p: p.eth.src == ETH_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 20
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.4): Does not send mDNS Response, since the service with matching type has expired. Note:
+    #   although the key leases on service-test-1 and host-test-1 are still active now, the generic service pointer is
+    #   expired and therefore no result to the PTR query is given. There is no record with the generic name stored,
+    #   anymore.
+    # - Pass Criteria:
+    #   - The DUT MUST NOT send an mDNS Response.
+    print("Step 20: BR 1 (DUT) should not send mDNS Response.")
+    pkts.\
+        filter(lambda p: p.eth.src == BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_INSTANCE_NAME1 in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        must_not_next()
+
+    # Step 21
+    # - Device: ED 1
+    # - Description (SRP-2.4): Harness instructs the device to send SRP Update with different service name but
+    #   conflicting host name: $ORIGIN default.service.arpa. service-test-3._thread-test._udp ( SRV 0 0 55555
+    #   host-test-1 ) host-test-1 AAAA <OMR address of ED_1> With the following options: Update Lease Option Lease:
+    #   30 seconds Key Lease: 30 seconds
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 21: ED 1 sends SRP Update with conflicting host name.")
+    verify_srp_update(pkts, BR_1_MLEID, ED_1_MLEID, BR_1_SRP_PORT, SRP_INSTANCE_NAME3, SRP_HOST_NAME1, ED_1_OMR_ADDR,
+                      SRP_LEASE_30S_30S)
+
+    # Step 22
+    # - Device: BR 1 (DUT)
+    # - Description (SRP-2.4): Automatically sends SRP Update Response, denying the registration because host
+    #   'host-test-1' is already claimed by ED_2 for its key lease time.
+    # - Pass Criteria:
+    #   - The DUT MUST send a valid SRP Update Response with RCODE = 6 (YXDOMAIN).
+    print("Step 22: BR 1 (DUT) sends SRP Update Response (YXDOMAIN).")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 6).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_5.py b/tests/nexus/verify_1_3_SRP_TC_5.py
new file mode 100644
index 0000000..5885917
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_5.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    # 2.5. [1.3] [CERT] KEY record inclusion/omission
+    #
+    # 2.5.1. Purpose
+    # To test the following:
+    # 1. Handle Service Description Instructions that include the KEY RR.
+    # 2. Handle Service Description Instructions that omit the KEY RR.
+    # 3. Report error on Host Description Instructions that omit the KEY RR.
+    #
+    # 2.5.2. Topology
+    # 1. BR_1 (DUT) - Thread Border Router and the Leader
+    # 2. ED_1 - Test Bed device operating as a Thread End Device, attached to BR_1
+    # 3. Eth_1 - Test Bed border router device on an Adjacent Infrastructure Link
+    pkts = pv.pkts
+    pv.summary.show()
+
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    br1_srp_port = int(pv.vars['BR_1_SRP_PORT'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service test 1.' + SRP_SERVICE_NAME
+    SRP_HOST_NAME = 'host-test-1.default.service.arpa'
+    MDNS_SERVICE_NAME = '_thread-test._udp.local'
+    MDNS_INSTANCE_NAME = 'service test 1.' + MDNS_SERVICE_NAME
+    DNS_QUERY_NAME = '_thread-test._udp.default.service.arpa'
+
+    SRP_SERVICE_PORT = 1500
+    RR_TYPE_KEY = 25
+
+    # Step 1
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.5): Enable: switch on.
+    print("Step 1: BR_1 (DUT) Enable: switch on.")
+
+    # Step 2
+    # - Device: Eth_1, ED_1
+    # - Description (SRP-2.5): Enable
+    print("Step 2: Eth_1, ED_1 Enable.")
+
+    # Step 3
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.5): Automatically adds SRP Server information in the Thread Network Data; and
+    #   configures OMR prefix.
+    print("Step 3: BR_1 (DUT) Automatically adds SRP Server information in the Thread Network Data.")
+    pkts.\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: 93 in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+        filter(lambda p: any(l in (1, 19) for l in verify_utils.as_list(p.thread_nwd.tlv.service.s_data_len))).\
+        must_next()
+
+    # Step 4
+    # - Device: ED_1
+    # - Description (SRP-2.5): Harness instructs the device to send SRP Update to the DUT with service
+    #   KEY records enabled.
+    print("Step 4: ED_1 sends SRP Update to the DUT with service KEY records enabled.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+        filter(lambda p: verify_utils.as_list(p.dns.resp.type).count(RR_TYPE_KEY) >= 2).\
+        must_next()
+
+    # Step 5
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.5): Automatically sends success SRP Update Response.
+    print("Step 5: BR_1 (DUT) Automatically sends success SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 6
+    # - Device: ED_1
+    # - Description (SRP-2.5): Harness instructs the device to unicast DNS QType=PTR query to the DUT for
+    #   all services of type _thread-test._udp.local..
+    print("Step 6: ED_1 unicast DNS QType=PTR query to the DUT.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: DNS_QUERY_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 7
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.5): Automatically sends DNS response.
+    print("Step 7: BR_1 (DUT) Automatically sends DNS response.")
+    # The DNS Response MUST NOT contain any RRType=KEY records.
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        filter(lambda p: RR_TYPE_KEY not in verify_utils.as_list(p.dns.resp.type)).\
+        must_next()
+
+    # Step 8
+    # - Device: Eth_1
+    # - Description (SRP-2.5): Harness instructs the device to send mDNS QType=PTR query for services of
+    #   type _thread-test._udp.local.
+    print("Step 8: Eth_1 send mDNS QType=PTR query.")
+    pkts.\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 9
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.5): Automatically sends mDNS Response.
+    print("Step 9: BR_1 (DUT) Automatically sends mDNS Response.")
+    # The DNS Response MUST NOT contain any RRType=KEY records.
+    pkts.\
+        filter(lambda p: p.udp.srcport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: RR_TYPE_KEY not in verify_utils.as_list(p.mdns.resp.type)).\
+        must_next()
+
+    # Step 10
+    # - Device: N/A
+    # - Description (SRP-2.5): Repeat Steps 4 through 9 with KEY record omitted from Service Description
+    #   Instruction.
+    print("Step 10: Repeat Steps 4 through 9 with KEY record omitted from Service Description Instruction.")
+
+    # Step 10.4
+    print("Step 10.4: ED_1 sends SRP Update with service KEY record disabled.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: verify_utils.as_list(p.dns.resp.type).count(RR_TYPE_KEY) == 1).\
+        must_next()
+
+    # Step 10.5
+    print("Step 10.5: BR_1 (DUT) Automatically sends success SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 10.6
+    print("Step 10.6: ED_1 unicast DNS QType=PTR query.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: DNS_QUERY_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 10.7
+    print("Step 10.7: BR_1 (DUT) Automatically sends DNS response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        filter(lambda p: RR_TYPE_KEY not in verify_utils.as_list(p.dns.resp.type)).\
+        must_next()
+
+    # Step 10.8
+    print("Step 10.8: Eth_1 send mDNS QType=PTR query.")
+    pkts.\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 10.9
+    print("Step 10.9: BR_1 (DUT) Automatically sends mDNS Response.")
+    pkts.\
+        filter(lambda p: p.udp.srcport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: RR_TYPE_KEY not in verify_utils.as_list(p.mdns.resp.type)).\
+        must_next()
+
+    # Step 11
+    # - Device: ED_1
+    # - Description (SRP-2.5): Repeat Step 4 with service KEY records omitted, while keeping host KEY
+    #   records.
+    print("Step 11: Repeat Step 4 with service KEY records omitted.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: verify_utils.as_list(p.dns.resp.type).count(RR_TYPE_KEY) == 1).\
+        must_next()
+
+    # Step 12
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.5): Automatically sends success SRP Update Response.
+    print("Step 12: BR_1 (DUT) Automatically sends success SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 13
+    # - Device: ED_1
+    # - Description (SRP-2.5): Repeat Step 4 with all KEY records omitted.
+    print("Step 13: ED_1 Repeat Step 4 with all KEY records omitted.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: RR_TYPE_KEY not in verify_utils.as_list(p.dns.resp.type)).\
+        must_next()
+
+    # Step 14
+    # - Device: BR_1 (DUT)
+    # - Description (SRP-2.5): Automatically responds with error SRP Update Response.
+    print("Step 14: BR_1 (DUT) Automatically responds with error SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 5).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_6.py b/tests/nexus/verify_1_3_SRP_TC_6.py
new file mode 100644
index 0000000..25883b9
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_6.py
@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr, EthAddr
+
+
+def verify(pv):
+    #
+    # 2.6. [1.3] [CERT] Name Compression
+    #
+    # 2.6.1. Purpose
+    # To test the following:
+    # - 1. Handle RRs without name compression.
+    # - 2. Handle RRs with name compression
+    #
+    # 2.6.2. Topology
+    # - 1. BR 1 (DUT) - Thread Border Router and the Leader
+    # - 2. ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - 3. Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference | V1.1 Section | V1.3.0 Section
+    # ---------------|--------------|---------------
+    # SRP Server     | N/A          | 1.3
+    pkts = pv.pkts
+    pv.summary.show()
+
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    ED_1_OMR = Ipv6Addr(pv.vars['ED_1_OMR_ADDR'])
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    br1_srp_port = int(pv.vars['BR_1_SRP_PORT'])
+    BR_1_ETH = EthAddr(pv.vars['BR_1_ETH'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_HOST_NAME = 'host-test-1.default.service.arpa'
+    MDNS_SERVICE_NAME = '_thread-test._udp.local'
+    MDNS_INSTANCE_NAME = 'service-test-1.' + MDNS_SERVICE_NAME
+    MDNS_HOST_NAME = 'host-test-1.local'
+
+    SRP_SERVICE_PORT = 55555
+
+    SERVICE_ID_SRP_SERVER = 93
+
+    # Step 1: BR 1 (DUT) Enable.
+    print("Step 1: BR 1 (DUT) Enable.")
+
+    # Step 2: Eth 1, ED 1 Enable.
+    print("Step 2: Eth 1, ED 1 Enable.")
+
+    # Step 3: BR 1 (DUT) adds its SRP Server information in the Thread Network Data.
+    print("Step 3: BR 1 (DUT) adds its SRP Server information in the Thread Network Data.")
+    pkts.\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: SERVICE_ID_SRP_SERVER in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)).\
+        filter(lambda p: any(l in (1, 19) for l in verify_utils.as_list(p.thread_nwd.tlv.service.s_data_len))).\
+        must_next()
+
+    # Step 4: ED 1 sends SRP Update without name compression.
+    print("Step 4: ED 1 sends SRP Update without name compression.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: not verify_utils.is_dns_compression_used(p)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+        filter(lambda p: ED_1_OMR in verify_utils.as_list(p.dns.aaaa)).\
+        must_next()
+
+    # Step 5: BR 1 (DUT) sends SRP Update Response.
+    print("Step 5: BR 1 (DUT) sends SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 6: ED 1 sends unicast DNS QType PTR query.
+    print("Step 6: ED 1 sends unicast DNS QType PTR query.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 7: BR 1 (DUT) sends DNS response.
+    print("Step 7: BR 1 (DUT) sends DNS response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        must_next()
+
+    # Step 8: Eth 1 sends mDNS query QType=PTR.
+    print("Step 8: Eth 1 sends mDNS query QType=PTR.")
+    pkts.\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    # Step 9: BR 1 (DUT) sends mDNS Response.
+    print("Step 9: BR 1 (DUT) sends mDNS Response.")
+    pkts.\
+        filter_eth_src(BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        must_next()
+
+    # Step 9b: Eth 1 sends mDNS query QTYPE=SRV.
+    print("Step 9b: Eth 1 sends mDNS query QTYPE=SRV.")
+    pkts.\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    pkts.\
+        filter_eth_src(BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.srv.target)).\
+        must_next()
+
+    # Step 10: Repeat Steps 4 through 9b with name compression.
+    print("Step 10: Repeat Steps 4 through 9b with name compression.")
+
+    # Step 4 (Repeat)
+    print("Step 4 (Repeat): ED 1 sends SRP Update with name compression.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.opcode == 5).\
+        filter(lambda p: verify_utils.is_dns_compression_used(p)).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.dns.srv.port)).\
+        filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)).\
+        filter(lambda p: ED_1_OMR in verify_utils.as_list(p.dns.aaaa)).\
+        must_next()
+
+    # Step 5 (Repeat)
+    print("Step 5 (Repeat): BR 1 (DUT) sends SRP Update Response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == br1_srp_port).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        must_next()
+
+    # Step 6 (Repeat)
+    print("Step 6 (Repeat): ED 1 sends unicast DNS QType PTR query.")
+    pkts.\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter_ipv6_src(ED_1_MLEID).\
+        filter(lambda p: p.udp.dstport == 53).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.qry.name)).\
+        must_next()
+
+    # Step 7 (Repeat)
+    print("Step 7 (Repeat): BR 1 (DUT) sends DNS response.")
+    pkts.\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter_ipv6_src(BR_1_MLEID).\
+        filter(lambda p: p.udp.srcport == 53).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == 0).\
+        filter(lambda p: SRP_INSTANCE_NAME in verify_utils.as_list(p.dns.ptr.domain_name)).\
+        must_next()
+
+    # Step 8 (Repeat)
+    print("Step 8 (Repeat): Eth 1 sends mDNS query QType=PTR.")
+    pkts.\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    pkts.\
+        filter_eth_src(BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        must_next()
+
+    # Step 9 (Repeat)
+    print("Step 9 (Repeat): BR 1 (DUT) sends mDNS Response.")
+
+    # Step 9b (Repeat)
+    print("Step 9b (Repeat): Eth 1 sends mDNS query QTYPE=SRV.")
+    pkts.\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        must_next()
+
+    pkts.\
+        filter_eth_src(BR_1_ETH).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: MDNS_INSTANCE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: SRP_SERVICE_PORT in verify_utils.as_list(p.mdns.srv.port)).\
+        filter(lambda p: MDNS_HOST_NAME in verify_utils.as_list(p.mdns.srv.target)).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_3_SRP_TC_8.py b/tests/nexus/verify_1_3_SRP_TC_8.py
new file mode 100644
index 0000000..b8832de
--- /dev/null
+++ b/tests/nexus/verify_1_3_SRP_TC_8.py
@@ -0,0 +1,454 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+import struct
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+from pktverify.bytes import Bytes
+
+
+def verify(pv):
+    # 2.8. [1.3] [CERT] Removing Some Published Services
+    #
+    # 2.8.1. Purpose
+    # To test the following:
+    # - 1. Remove only selected service instances, not all.
+    #
+    # 2.8.2. Topology
+    # - BR 1 (DUT) - Thread Border Router and the Leader
+    # - ED 1-Test Bed device operating as a Thread End Device, attached to BR_1
+    # - Eth 1-Test Bed border router device on an Adjacent Infrastructure Link
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # SRP Server       | N/A          | 1.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    ED_1_MLEID = Ipv6Addr(pv.vars['ED_1_MLEID_ADDR'])
+    BR_1_MLEID = Ipv6Addr(pv.vars['BR_1_MLEID_ADDR'])
+    br1_srp_port = int(pv.vars['BR_1_SRP_PORT'])
+
+    SRP_SERVICE_NAME = '_thread-test._udp.default.service.arpa'
+    SRP_INSTANCE_NAME_1 = 'service-test-1.' + SRP_SERVICE_NAME
+    SRP_INSTANCE_NAME_2 = 'service-test-2.' + SRP_SERVICE_NAME
+    SRP_HOST_NAME = 'host-test-1.default.service.arpa'
+    MDNS_SERVICE_NAME = '_thread-test._udp.local'
+    MDNS_INSTANCE_NAME_1 = 'service-test-1.' + MDNS_SERVICE_NAME
+    MDNS_INSTANCE_NAME_2 = 'service-test-2.' + MDNS_SERVICE_NAME
+
+    SRP_SERVICE_PORT_1 = 55555
+    SRP_SERVICE_PORT_2 = 55556
+    SRP_SERVICE_PRIORITY_2 = 8
+
+    SRP_LEASE_10M_20M = Bytes(struct.pack('>II', 600, 1200).hex())
+    SRP_LEASE_10M_10M = Bytes(struct.pack('>II', 600, 600).hex())
+
+    SERVICE_ID_SRP_UNICAST_DNS = 93
+
+    # Step 1
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.8): Enable
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 1: BR 1 (DUT) Enable")
+
+    # Step 2
+    #   Device: Eth 1, ED 1
+    #   Description (SRP-2.8): Enable
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 2: Eth 1, ED 1 Enable")
+
+    # Step 3
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.8): Automatically adds SRP Server information in the Thread Network Data. Automatically adds
+    #     OMR prefix in Network Data.
+    #   Pass Criteria:
+    #     - [T1-T6] The DUT's SRP Server information MUST appear in the Thread Network Data.
+    print("Step 3: BR 1 (DUT) SRP Server info in Network Data")
+    pkts. \
+      filter_mle_cmd(consts.MLE_DATA_RESPONSE). \
+      filter(lambda p: SERVICE_ID_SRP_UNICAST_DNS in verify_utils.as_list(p.thread_nwd.tlv.service.srp_dataset_identifier)). \
+      must_next()
+
+    # Step 4
+    #   Device: ED 1
+    #   Description (SRP-2.8): Harness instructs the device to send SRP Update: $ORIGIN default.service.arpa.
+    #     service-test-1._thread-test._udp ( SRV 55555 host-test-1 ) host-test-1 AAAA <OMR address of ED_1> with the
+    #     following options: Update Lease Option, Lease: 10 minutes, Key Lease: 20 minutes
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 4: ED 1 sends SRP Update for service 1")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE). \
+      filter(lambda p: SRP_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_SERVICE_PORT_1 in verify_utils.as_list(p.dns.srv.port)). \
+      filter(lambda p: SRP_HOST_NAME in verify_utils.as_list(p.dns.srv.target)). \
+      filter(lambda p: p.dns.opt.data is not None and
+             any(data.format_compact() == SRP_LEASE_10M_20M.format_compact() for data in p.dns.opt.data)). \
+      must_next()
+
+    # Step 5
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.8): Automatically sends SRP Update Response.
+    #   Pass Criteria:
+    #     - [T7] The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 5: BR 1 (DUT) sends SRP Update Response")
+    pkts. \
+      filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR). \
+      must_next()
+
+    # Step 6
+    #   Device: ED 1
+    #   Description (SRP-2.8): Harness instructs the device to unicast DNS query QType SRV for
+    #     "service-test-1._thread-test. udp.default.service.arpa." to the DUT.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 6: ED 1 unicast DNS query SRV for service 1")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == 53). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: SRP_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.qry.name)). \
+      must_next()
+
+    # Step 7
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.8): Automatically sends DNS response.
+    #   Pass Criteria:
+    #     - [T8] The DUT MUST send a valid DNS Response, with RCODE=0 (NoError) and
+    #     - [T9] contains answer record: $ORIGIN default.service.arpa. service-test-1. thread-test._udp ( SRV 55555
+    #       host-test-1 )
+    #     - [T10] The DNS Response MAY contain in Additional records section: SORIGIN default.service.arpa. host-test-1
+    #       AAAA <OMR address of ED 1>
+    print("Step 7: BR 1 (DUT) sends DNS response")
+    pkts. \
+      filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == 53). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR). \
+      filter(lambda p: SRP_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_SERVICE_PORT_1 in verify_utils.as_list(p.dns.srv.port)). \
+      must_next()
+
+    # Step 8
+    #   Device: Eth_1
+    #   Description (SRP-2.8): Harness instructs the device to send mDNS query QType SRV for "service-test-1.
+    #     thread-test. udp.local.".
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 8: Eth 1 sends mDNS query SRV for service 1")
+    pkts. \
+      filter_mDNS_query(MDNS_INSTANCE_NAME_1). \
+      filter(lambda p: p.dns.qry.type == consts.DNS_TYPE_SRV). \
+      must_next()
+
+    # Step 9
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.8): Automatically sends mDNS Response.
+    #   Pass Criteria:
+    #     - [T11] The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError) and
+    #     - [T12] contains answer record: SORIGIN local. service-test-1. thread-test._udp ( SRV 55555 host-test-1 )
+    #     - [T13] DNS Response MAY contain in Additional records section: SORIGIN local. Host-test-1 AAAA <OMR address of
+    #       ED 1>
+    print("Step 9: BR 1 (DUT) sends mDNS Response")
+    pkts. \
+      filter_mDNS_response(). \
+      filter(lambda p: MDNS_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_SERVICE_PORT_1 in verify_utils.as_list(p.dns.srv.port)). \
+      must_next()
+
+    # Step 10
+    #   Device: ED_1
+    #   Description (SRP-2.8): Harness instructs the device to send SRP Update, adding one more service: $ORIGIN
+    #     default.service.arpa. service-test-2._thread-test._udp ( SRV 8 55556 host-test-1 ) host-test-1 AAAA <OMR address
+    #     of ED_1> with the following options: Update Lease Option, Lease: 10 minutes, Key Lease: 20 minutes
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 10: ED 1 sends SRP Update adding service 2")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE). \
+      filter(lambda p: SRP_INSTANCE_NAME_2 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_SERVICE_PORT_2 in verify_utils.as_list(p.dns.srv.port)). \
+      filter(lambda p: SRP_SERVICE_PRIORITY_2 in verify_utils.as_list(p.dns.srv.priority)). \
+      filter(lambda p: p.dns.opt.data is not None and
+             any(data.format_compact() == SRP_LEASE_10M_20M.format_compact() for data in p.dns.opt.data)). \
+      must_next()
+
+    # Step 11
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.8): Automatically sends SRP Update Response.
+    #   Pass Criteria:
+    #     - [T14] The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 11: BR 1 (DUT) sends SRP Update Response")
+    pkts. \
+      filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR). \
+      must_next()
+
+    # Step 12
+    #   Device: ED_1
+    #   Description (SRP-2.8): Harness instructs the device to unicast DNS query QType=SRV for service-test-2 to the DUT
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 12: ED 1 unicast DNS query SRV for service 2")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == 53). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: SRP_INSTANCE_NAME_2 in verify_utils.as_list(p.dns.qry.name)). \
+      must_next()
+
+    # Step 13
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.8): Automatically sends DNS response.
+    #   Pass Criteria:
+    #     - [T15] The DUT MUST send a valid DNS Response.
+    #     - [T16] containing service-test-2 answer record: $ORIGIN default.service.arpa. service-test-2._thread-test._udp
+    #       ( SRV 55556 host-test-1 )
+    #     - [T17] DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+    #       <OMR address of ED_1>
+    print("Step 13: BR 1 (DUT) sends DNS response")
+    pkts. \
+      filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == 53). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR). \
+      filter(lambda p: SRP_INSTANCE_NAME_2 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_SERVICE_PORT_2 in verify_utils.as_list(p.dns.srv.port)). \
+      must_next()
+
+    # Step 14
+    #   Device: Eth_1
+    #   Description (SRP-2.8): Repeat Step 8. Note: this is repeated to verify that the first service is still there.
+    #   Pass Criteria:
+    #     - Repeat Step 8
+    print("Step 14: Eth 1 sends mDNS query SRV for service 1 (repeat)")
+    pkts. \
+      filter_mDNS_query(MDNS_INSTANCE_NAME_1). \
+      filter(lambda p: p.dns.qry.type == consts.DNS_TYPE_SRV). \
+      must_next()
+
+    # Step 15
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.8): Repeat Step 9
+    #   Pass Criteria:
+    #     - [T18-T20] Repeat Step 9
+    print("Step 15: BR 1 (DUT) sends mDNS Response (repeat)")
+    pkts. \
+      filter_mDNS_response(). \
+      filter(lambda p: MDNS_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_SERVICE_PORT_1 in verify_utils.as_list(p.dns.srv.port)). \
+      must_next()
+
+    # Step 16
+    #   Device: ED 1
+    #   Description (SRP-2.8): Harness instructs device to send SRP Update, removing the first service only: 1. Service
+    #     Discovery Instruction Delete an RR from an RRset semantics using data: $ORIGIN default.service.arpa. PTR
+    #     service-test-1. thread-test._udp 2. Service Description Instruction Delete All RRsets From a Name semantics:
+    #     $ORIGIN default.service.arpa. service-test-1._thread-test._udp ANY <tt1=0> with the following options: Update
+    #     Lease Option, Lease: 10 minutes (unchanged), Key Lease: 10 minutes (shorter). Note: this method to remove a
+    #     service follows 3.2.5.5.2 of [draft-srp-23]. Note: for OT reference device, the command 'srp client
+    #     keyleaseinterval 600' is used to shorten the key lease interval as indicated.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 16: ED 1 sends SRP Update removing service 1")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.opcode == consts.DNS_OPCODE_UPDATE). \
+      filter(lambda p: SRP_SERVICE_NAME in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.ptr.domain_name)). \
+      filter(lambda p: SRP_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: any(ttl == 0 for ttl in verify_utils.as_list(p.dns.resp.ttl))). \
+      filter(lambda p: p.dns.opt.data is not None and
+             any(data.format_compact() == SRP_LEASE_10M_10M.format_compact() for data in p.dns.opt.data)). \
+      must_next()
+
+    # Step 17
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.8): Automatically sends SRP Update Response.
+    #   Pass Criteria:
+    #     - [T21] The DUT MUST send a valid SRP Update Response, with RCODE=0 (NoError).
+    print("Step 17: BR 1 (DUT) sends SRP Update Response")
+    pkts. \
+      filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == br1_srp_port). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR). \
+      must_next()
+
+    # Step 18
+    #   Device: ED 1
+    #   Description (SRP-2.8): Harness instructs the device to unicast DNS query QType SRV for "service-test-1.
+    #     thread-test. udp.default.service.arpa." to the DUT
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 18: ED 1 unicast DNS query SRV for service 1")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == 53). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: SRP_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.qry.name)). \
+      must_next()
+
+    # Step 19
+    #   Device: BR 1 (DUT)
+    #   Description (SRP-2.8): Automatically sends DNS response. Note: the service instance name with all associated
+    #     records has been deleted in step 16.
+    #   Pass Criteria:
+    #     - [T22] The DUT MUST send a valid DNS Response, with RCODE=0 (NoError); and
+    #     - [T23] MUST contain 0 answer records.
+    print("Step 19: BR 1 (DUT) sends DNS response (0 answers)")
+    pkts. \
+      filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == 53). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode in (consts.DNS_RCODE_NOERROR, consts.DNS_RCODE_NXDOMAIN)). \
+      filter(lambda p: p.dns.count.answers == 0). \
+      must_next()
+
+    # Step 20
+    #   Device: ED 1
+    #   Description (SRP-2.8): Harness instructs the device to unicast DNS query QType SRV for "service-test-2.
+    #     thread-test. udp.default.service.arpa." to the DUT.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 20: ED 1 unicast DNS query SRV for service 2")
+    pkts. \
+      filter_ipv6_dst(BR_1_MLEID). \
+      filter_ipv6_src(ED_1_MLEID). \
+      filter(lambda p: p.udp.dstport == 53). \
+      filter(lambda p: p.dns.flags.response == 0). \
+      filter(lambda p: SRP_INSTANCE_NAME_2 in verify_utils.as_list(p.dns.qry.name)). \
+      must_next()
+
+    # Step 21
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.8): Automatically sends DNS response.
+    #   Pass Criteria:
+    #     - [T24] The DUT MUST send a valid DNS Response, with RCODE=0 (NoError) and
+    #     - [T25] contains answer record: $ORIGIN default.service.arpa. service-test-2. thread-test._udp ( SRV 55556
+    #       host-test-1 )
+    #     - [T26] DNS Response MAY contain in Additional records section: $ORIGIN default.service.arpa. host-test-1 AAAA
+    #       <OMR address of ED 1>
+    print("Step 21: BR 1 (DUT) sends DNS response (service 2)")
+    pkts. \
+      filter_ipv6_dst(ED_1_MLEID). \
+      filter_ipv6_src(BR_1_MLEID). \
+      filter(lambda p: p.udp.srcport == 53). \
+      filter(lambda p: p.dns.flags.response == 1). \
+      filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR). \
+      filter(lambda p: SRP_INSTANCE_NAME_2 in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: SRP_SERVICE_PORT_2 in verify_utils.as_list(p.dns.srv.port)). \
+      must_next()
+
+    # Step 22
+    #   Device: Eth_1
+    #   Description (SRP-2.8): Harness instructs the device to send mDNS query QType SRV for "service-test-1.
+    #     thread-test. udp.local".
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 22: Eth 1 sends mDNS query SRV for service 1")
+    pkts. \
+      filter_mDNS_query(MDNS_INSTANCE_NAME_1). \
+      filter(lambda p: p.dns.qry.type == consts.DNS_TYPE_SRV). \
+      must_next()
+
+    # Step 23
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.8): Does not send a mDNS response.
+    #   Pass Criteria:
+    #     - [T27] The DUT MUST NOT send an mDNS response.
+    print("Step 23: BR 1 (DUT) does NOT send mDNS response")
+    pkts. \
+      filter_mDNS_response(). \
+      filter(lambda p: MDNS_INSTANCE_NAME_1 in verify_utils.as_list(p.dns.resp.name)). \
+      must_not_next()
+
+    # Step 24
+    #   Device: Eth 1
+    #   Description (SRP-2.8): Harness instructs the device to send mDNS query QType=PTR any services of type
+    #     "_thread-test._udp.local".
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 24: Eth 1 sends mDNS query PTR for services")
+    pkts. \
+      filter_mDNS_query(MDNS_SERVICE_NAME). \
+      filter(lambda p: p.dns.qry.type == consts.DNS_TYPE_PTR). \
+      must_next()
+
+    # Step 25
+    #   Device: BR_1 (DUT)
+    #   Description (SRP-2.8): Automatically sends mDNS response with service-test-2.
+    #   Pass Criteria:
+    #     - [T28] The DUT MUST send a valid mDNS Response, with RCODE=0 (NoError) and
+    #     - [T29] contains service-test-2 answer record: $ORIGIN local. _thread-test._udp ( PTR
+    #       service-test-2._thread-test._udp )
+    #     - [T30] Response MUST NOT contain any further records in the Answers section.
+    #     - [T31-32] DNS Response MAY contain in Additional records section: SORIGIN local.
+    #       Service-test-2._thread-test._udp ( SRV 8 0 55556 host-test-1 ) host-test-1 AAAA <OMR address of ED_1>
+    print("Step 25: BR 1 (DUT) sends mDNS response with service 2 only")
+    pkts. \
+      filter_mDNS_response(). \
+      filter(lambda p: MDNS_SERVICE_NAME in verify_utils.as_list(p.dns.resp.name)). \
+      filter(lambda p: MDNS_INSTANCE_NAME_2 in verify_utils.as_list(p.dns.ptr.domain_name)). \
+      filter(lambda p: p.dns.count.answers == 1). \
+      must_next()
diff --git a/tests/nexus/verify_1_4_CS_TC_3.py b/tests/nexus/verify_1_4_CS_TC_3.py
new file mode 100644
index 0000000..80264ec
--- /dev/null
+++ b/tests/nexus/verify_1_4_CS_TC_3.py
@@ -0,0 +1,393 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+import struct
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.null_field import nullField
+
+# Constants
+EPSKC_PORT = 1234
+ALERT_DESC_CLOSE_NOTIFY = 0
+ALERT_LEVEL_FATAL = 2
+ALERT_DESC_DECRYPT_ERROR = 51
+
+MESHCOP_SERVICE = '_meshcop._udp.local'
+MESHCOP_E_SERVICE = '_meshcop-e._udp.local'
+
+
+def verify(pv):
+    pkts = pv.pkts
+    pv.summary.show()
+
+    COMM_1_LL = 'fe80::ff:fe00:2'
+    BR_1_LL = 'fe80::ff:fe00:1'
+    BR_1_INFRA = 'fe80::ff:fe00:1'
+
+    def get_txt_entries(p):
+        entries = {}
+        txt_list = []
+
+        # Try to get all values if it's a list
+        layer = None
+        if hasattr(p, 'mdns') and p.mdns:
+            layer = p.mdns
+        elif hasattr(p, 'dns') and p.dns:
+            layer = p.dns
+
+        if layer:
+            txt_list = layer.txt
+            if not isinstance(txt_list, list):
+                txt_list = [txt_list]
+
+        for txt in txt_list:
+            if isinstance(txt, nullField.__class__):
+                continue
+            # txt is now a Bytes object (hex representation)
+            # Find the '=' character (0x3d)
+            try:
+                eq_idx = txt.index(0x3d)
+                key = bytes(txt[:eq_idx]).decode()
+                value = txt[eq_idx + 1:]
+                entries[key] = value
+            except ValueError:
+                entries[bytes(txt).decode()] = None
+
+        return entries
+
+    # Step 1: Enable and form network.
+    print("Step 1: Enable and form network.")
+
+    # Step 2: Comm_1 discovers BR DUT service.
+    print("Step 2: Comm_1 discovers BR DUT service.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_SERVICE in p.mdns.qry.name).\
+        must_next()
+
+    mdns_resp = pkts.filter_ipv6_src(BR_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_SERVICE in p.mdns.resp.name).\
+        must_next()
+
+    # In the TXT record:
+    # - ePSKc Support flag (Bit 11) in State Bitmap (sb) MUST be ‘1’ (ePSKc supported)
+    # - ‘vn’ field MUST be present and contain >= 1 characters.
+    # - ‘mn’ field MUST be present and contain >= 1 characters.
+    txts = get_txt_entries(mdns_resp)
+    assert 'sb' in txts, "sb field missing in TXT record"
+    sb = struct.unpack('>I', txts['sb'])[0]
+    assert (sb >> 11) & 1 == 1, f"ePSKc support flag (bit 11) in sb is not 1 (sb={sb:08x})"
+    assert 'vn' in txts and len(txts['vn']) >= 1, "vn field missing or empty in TXT record"
+    assert 'mn' in txts and len(txts['mn']) >= 1, "mn field missing or empty in TXT record"
+
+    # Step 3 & 4: Passcode generation and validation.
+    print("Step 3 & 4: Passcode generation and validation.")
+    # (Validated by C++ test)
+
+    # Step 5: Comm_1 discovers meshcop-e service.
+    print("Step 5: Comm_1 discovers meshcop-e service.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_E_SERVICE in p.mdns.qry.name).\
+        must_next()
+
+    mdns_resp = pkts.filter_ipv6_src(BR_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_E_SERVICE in p.mdns.resp.name).\
+        must_next()
+
+    port_step5 = mdns_resp.mdns.srv.port[0]
+    print(f"Step 5 Discovered port: {port_step5}")
+
+    # Step 6: Comm_1 connects with incorrect ePSKc.
+    print("Step 6: Comm_1 connects with incorrect ePSKc.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step5).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_CLIENT_HELLO in p.dtls.handshake.type).\
+        filter(lambda p: p.dtls.handshake.cookie is not nullField).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_INFRA).\
+        filter_ipv6_dst(COMM_1_LL).\
+        filter(lambda p: p.udp.srcport == port_step5).\
+        filter(lambda p: consts.CONTENT_ALERT in p.dtls.record.content_type).\
+        must_next()
+
+    # Step 7: Comm_1 connects with correct ePSKc.
+    print("Step 7: Comm_1 connects with correct ePSKc.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step5).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_CLIENT_HELLO in p.dtls.handshake.type).\
+        filter(lambda p: p.dtls.handshake.cookie is not nullField).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_INFRA).\
+        filter_ipv6_dst(COMM_1_LL).\
+        filter(lambda p: p.udp.srcport == port_step5).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_FINISHED in p.dtls.handshake.type).\
+        must_next()
+
+    # Step 8: Comm_1 sends MGMT_ACTIVE_GET.req.
+    print("Step 8: Comm_1 sends MGMT_ACTIVE_GET.req.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step5).\
+        filter(lambda p: p.coap.opt.uri_path == ['c', 'ag']).\
+        must_next()
+
+    active_get_rsp = pkts.filter_ipv6_src(BR_1_INFRA).\
+        filter_ipv6_dst(COMM_1_LL).\
+        filter(lambda p: p.udp.srcport == port_step5).\
+        filter(lambda p: p.coap.type == 2).\
+        must_next()
+
+    # Response payload MUST contain Network Key, Ext PAN ID, Network Name, PAN ID, PSKc, Security Policy,
+    # Active Timestamp, Mesh-Local Prefix, Channel, Channel Mask.
+    # Note: pktverify uses 'master_key' for Network Key TLV.
+    active_get_rsp.must_verify(lambda p: all(
+        p.coap.tlv.has(field) for field in [
+            'network_key',
+            'ext_pan_id',
+            'network_name',
+            'pan_id',
+            'pskc',
+            'security_policy',
+            'active_timestamp',
+            'mesh_local_prefix',
+            'channel',
+            'channel_mask',
+        ]))
+
+    # Step 9: Comm_1 sends MGMT_PENDING_GET.req.
+    print("Step 9: Comm_1 sends MGMT_PENDING_GET.req.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step5).\
+        filter(lambda p: p.coap.opt.uri_path == ['c', 'pg']).\
+        must_next()
+
+    pending_get_rsp = pkts.filter_ipv6_src(BR_1_INFRA).\
+        filter_ipv6_dst(COMM_1_LL).\
+        filter(lambda p: p.udp.srcport == port_step5).\
+        filter(lambda p: p.coap.type == 2).\
+        must_next()
+
+    # Response CoAP payload MUST be empty.
+    assert pending_get_rsp.coap.payload is nullField, "MGMT_PENDING_GET response payload is not empty"
+
+    # Step 10: Comm_1 closes the DTLS connection.
+    print("Step 10: Comm_1 closes the DTLS connection.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step5).\
+        filter(lambda p: consts.CONTENT_ALERT in p.dtls.record.content_type).\
+        must_next()
+
+    # Step 11: Comm_1 connects again (should fail).
+    print("Step 11: Comm_1 connects again (should fail).")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step5).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_CLIENT_HELLO in p.dtls.handshake.type).\
+        must_next()
+
+    # The DUT may either send an ALERT or simply not respond (since ephemeral key is stopped).
+    # We check if there's an ALERT, but don't fail if there isn't one as long as connection doesn't succeed.
+    pkts.copy().filter_ipv6_src(BR_1_INFRA).\
+        filter_ipv6_dst(COMM_1_LL).\
+        filter(lambda p: p.udp.srcport == port_step5).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_FINISHED in p.dtls.handshake.type).\
+        must_not_next()
+
+    # Step 13: Comm_1 discovers meshcop-e service (should fail).
+    print("Step 13: Comm_1 discovers meshcop-e service (should fail).")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_E_SERVICE in p.mdns.qry.name).\
+        must_next()
+
+    # Step 16: Comm_1 discovers meshcop-e service.
+    print("Step 16: Comm_1 discovers meshcop-e service.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_E_SERVICE in p.mdns.qry.name).\
+        must_next()
+
+    mdns_resp = pkts.filter_ipv6_src(BR_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_E_SERVICE in p.mdns.resp.name).\
+        must_next()
+
+    port_step16 = mdns_resp.mdns.srv.port[0]
+    print(f"Step 16 Discovered port: {port_step16}")
+
+    # Step 17: Comm_1 connects with correct ePSKc.
+    print("Step 17: Comm_1 connects with correct ePSKc.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step16).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_CLIENT_HELLO in p.dtls.handshake.type).\
+        filter(lambda p: p.dtls.handshake.cookie is not nullField).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_INFRA).\
+        filter_ipv6_dst(COMM_1_LL).\
+        filter(lambda p: p.udp.srcport == port_step16).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_FINISHED in p.dtls.handshake.type).\
+        must_next()
+
+    # Step 18: Comm_1 sends MGMT_ACTIVE_GET.req.
+    print("Step 18: Comm_1 sends MGMT_ACTIVE_GET.req.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step16).\
+        filter(lambda p: p.coap.opt.uri_path == ['c', 'ag']).\
+        must_next()
+
+    active_get_rsp = pkts.filter_ipv6_src(BR_1_INFRA).\
+        filter_ipv6_dst(COMM_1_LL).\
+        filter(lambda p: p.udp.srcport == port_step16).\
+        filter(lambda p: p.coap.type == 2).\
+        must_next()
+
+    active_get_rsp.must_verify(lambda p: all(
+        p.coap.tlv.has(field) for field in [
+            'network_key',
+            'ext_pan_id',
+            'network_name',
+            'pan_id',
+            'pskc',
+            'security_policy',
+            'active_timestamp',
+            'mesh_local_prefix',
+            'channel',
+            'channel_mask',
+        ]))
+
+    # Step 19: Comm_1 closes the DTLS connection.
+    print("Step 19: Comm_1 closes the DTLS connection.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step16).\
+        filter(lambda p: consts.CONTENT_ALERT in p.dtls.record.content_type).\
+        must_next()
+
+    # Step 22: Comm_1 discovers meshcop-e service.
+    print("Step 22: Comm_1 discovers meshcop-e service.")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_E_SERVICE in p.mdns.qry.name).\
+        must_next()
+
+    mdns_resp = pkts.filter_ipv6_src(BR_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_E_SERVICE in p.mdns.resp.name).\
+        must_next()
+
+    port_step22 = mdns_resp.mdns.srv.port[0]
+    print(f"Step 22 Discovered port: {port_step22}")
+
+    # Step 23: Comm_1 connects with incorrect ePSKc 10 times.
+    print("Step 23: Comm_1 connects with incorrect ePSKc 10 times.")
+    for i in range(10):
+        pkts.filter_ipv6_src(COMM_1_LL).\
+            filter_ipv6_dst(BR_1_INFRA).\
+            filter(lambda p: p.udp.dstport == port_step22).\
+            filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+            filter(lambda p: consts.HANDSHAKE_CLIENT_HELLO in p.dtls.handshake.type).\
+            filter(lambda p: p.dtls.handshake.cookie is not nullField).\
+            must_next()
+
+        pkts.filter_ipv6_src(BR_1_INFRA).\
+            filter_ipv6_dst(COMM_1_LL).\
+            filter(lambda p: p.udp.srcport == port_step22).\
+            filter(lambda p: consts.CONTENT_ALERT in p.dtls.record.content_type).\
+            must_next()
+
+    # Step 24: Comm_1 connects with correct ePSKc (should fail).
+    print("Step 24: Comm_1 connects with correct ePSKc (should fail).")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst(BR_1_INFRA).\
+        filter(lambda p: p.udp.dstport == port_step22).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_CLIENT_HELLO in p.dtls.handshake.type).\
+        must_next()
+
+    # Similar to Step 11, the DUT may either send an ALERT or not respond.
+    pkts.copy().filter_ipv6_src(BR_1_INFRA).\
+        filter_ipv6_dst(COMM_1_LL).\
+        filter(lambda p: p.udp.srcport == port_step22).\
+        filter(lambda p: consts.CONTENT_HANDSHAKE in p.dtls.record.content_type).\
+        filter(lambda p: consts.HANDSHAKE_FINISHED in p.dtls.handshake.type).\
+        must_not_next()
+
+    # Step 26: Comm_1 discovers meshcop-e service (should fail).
+    print("Step 26: Comm_1 discovers meshcop-e service (should fail).")
+    pkts.filter_ipv6_src(COMM_1_LL).\
+        filter_ipv6_dst('ff02::fb').\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: MESHCOP_E_SERVICE in p.mdns.qry.name).\
+        must_next()
+
+
+if __name__ == '__main__':
+    # Add keylog file to Wireshark preferences if it exists.
+    keylog_file = '/tmp/test_1_4_CS_TC_3.keys'
+    if os.path.exists(keylog_file):
+        consts.WIRESHARK_OVERRIDE_PREFS['tls.keylog_file'] = keylog_file
+        for port in [1234, 1235, 1236]:
+            consts.WIRESHARK_DECODE_AS_ENTRIES[f'udp.port=={port}'] = 'dtls'
+            consts.WIRESHARK_DECODE_AS_ENTRIES[f'dtls.port=={port}'] = 'coap'
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_DNS_TC_1.py b/tests/nexus/verify_1_4_DNS_TC_1.py
new file mode 100644
index 0000000..981b800
--- /dev/null
+++ b/tests/nexus/verify_1_4_DNS_TC_1.py
@@ -0,0 +1,267 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 11.1. [1.3] [CERT] Handling of multi-question DNS queries
+    #
+    # 11.1.1. Purpose
+    #   - To verify that the Thread Border Router DUT can successfully handle DNS queries with
+    #     multiple questions (QDCOUNT > 1) by either:
+    #   - Responding with an error status (signaling to the querier that it needs to retry with
+    #     single-question queries)
+    #   - Providing the answers to the multiple questions (as defined by Thread) if the questions
+    #     are for the same QNAME but different record types.
+    #
+    # 11.1.2. Topology
+    #   - Eth_1 host registering a service
+    #   - BR_1 Border Router DUT
+    #   - Thread Router_1 registering service(s) (Connected to BR DUT)
+    #   - Thread ED_1 sending QDCOUNT > 1 queries (Child of Router_1)
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1 = pv.vars['BR_1']
+    ED_1 = pv.vars['ED_1']
+    BR_1_MLEID = pv.vars['BR_1_MLEID']
+    ED_1_MLEID = pv.vars['ED_1_MLEID']
+
+    # Step 1
+    #   Device: All
+    #   Description (DNS-11.1): Start topology
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 1: Topology: Start Eth_1, BR_1, Router_1, and ED_1.")
+
+    # Step 2
+    #   Device: Eth_1
+    #   Description (DNS-11.1): Harness instructs device to advertise a test service using
+    #     mDNS: $ORIGIN local. Service-test-2._thread-test._udp ( SRV 0 0 55556 host-eth-1
+    #     TXT key2=value2 ) host-eth-1 AAAA <ULA address of Eth_1>
+    #   Pass Criteria:
+    #     - mDNS service MUST be registered without name conflict or service name change.
+    print("Step 2: Eth_1 host registers a service using mDNS.")
+
+    # Step 3
+    #   Device: Router_1
+    #   Description (DNS-11.1): Harness instructs device to register a test service using
+    #     SRP: $ORIGIN default.service.arpa. service-test-1._thread-test._udp ( SRV 0 0
+    #     55556 host-router-1 TXT key1=value1 ) host-router-1 AAAA <OMR address of
+    #     Router_1>
+    #   Pass Criteria:
+    #     - The DUT MUST respond with success status (RCODE=0) to the SRP registration.
+    print("Step 3: Router_1 host registers a service using SRP.")
+
+    # Step 4
+    #   Device: ED_1
+    #   Description (DNS-11.1): Harness instructs device to perform DNS query with QDCOUNT=2:
+    #     First question QNAME=service-test-1._thread-test._udp. Default.service.arpa QTYPE
+    #     =SRV Second question QNAME=service-test-1._thread-test._udp. Default.service.arpa
+    #     QTYPE =TXT
+    #   Pass Criteria:
+    #     - The DUT MUST either: 3. Respond with error RCODE=1 (FormErr) and 0 answers; or 3.
+    #       Respond with success RCODE=0 (NoError) and two answer records in the Answers
+    #       section as follows: $ORIGIN default.service.arpa.
+    #       service-test-1._thread-test._udp ( SRV 0 0 55556 host-router-1 TXT key1=value1 )
+    #     - Furthermore the Additional records section MAY contain : host-router-1 AAAA <OMR
+    #       address of Router_1>
+    #     - If this record is present, address MUST be same as in step 9.
+    print("Step 4: ED_1 performs a DNS query with two questions (QDCOUNT=2).")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: int(p.dns.count.queries) == 2).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: (p.dns.flags.rcode == consts.DNS_RCODE_NOERROR and
+                          int(p.dns.count.answers) == 2 and
+                          55556 in verify_utils.as_list(p.dns.srv.port) and
+                          "host-router-1.default.service.arpa" in verify_utils.as_list(p.dns.srv.target) and
+                          any(b"key1=value1" in t for t in verify_utils.as_list(p.dns.txt))) or
+                         (p.dns.flags.rcode == 1)).\
+        must_next()
+
+    # Step 5
+    #   Device: ED_1
+    #   Description (DNS-11.1): Harness instructs devie to perform DNS query with QDCOUNT=2:
+    #     First question QNAME=service-test-2._thread-test._udp. Default.service.arpa QTYPE
+    #     =SRV Second question QNAME=service-test-2._thread-test._udp. Default.service.arpa
+    #     QTYPE =TXT
+    #   Pass Criteria:
+    #     - The DUT MUST either: 3. 3. Respond with error RCODE=1 (FormErr) and 0 answers;
+    #       or Respond with success RCODE=0 (NoError) and two answer records in the
+    #       Answers section as follows: $ORIGIN default.service.arpa.
+    #       service-test-2._thread-test._udp ( SRV 0 0 55556 host-eth-1 TXT key2=value2 )
+    #     - Furthermore the Additional records section MAY contain : host-eth-1 AAAA <ULA
+    #       address of Eth_1>
+    #     - If this record is present, address MUST be same as in step 8.
+    print("Step 5: ED_1 performs a DNS query with two questions (QDCOUNT=2).")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: int(p.dns.count.queries) == 2).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: (p.dns.flags.rcode == consts.DNS_RCODE_NOERROR and
+                          int(p.dns.count.answers) == 2 and
+                          55556 in verify_utils.as_list(p.dns.srv.port) and
+                          "host-eth-1.default.service.arpa" in verify_utils.as_list(p.dns.srv.target) and
+                          any(b"key2=value2" in t for t in verify_utils.as_list(p.dns.txt))) or
+                         (p.dns.flags.rcode == 1)).\
+        must_next()
+
+    # Step 6
+    #   Device: ED_1
+    #   Description (DNS-11.1): Harness instructs device to perform DNS query with
+    #     QDCOUNT=1: First question QNAME=service-test-1_thread-test.
+    #     _udp.default.service.arpa QTYPE=SRV
+    #   Pass Criteria:
+    #     - The DUT MUST respond with success RCODE=0 (NoError) and one answer record in
+    #       the Answers section as follows: $ORIGIN default.service.arpa.
+    #       service-test-1._thread-test._udp ( SRV 0 0 55556 host-router-1 )
+    #     - Furthermore the Additional records section MAY contain : host-router-1 AAAA
+    #       <OMR address of Router_1>
+    print("Step 6: ED_1 performs a DNS query for service-test-1 (SRV).")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: "service-test-1._thread-test._udp.default.service.arpa" in
+                         verify_utils.as_list(p.dns.qry.name) and
+                         consts.DNS_TYPE_SRV in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR and
+                         int(p.dns.count.answers) >= 1 and
+                         55556 in verify_utils.as_list(p.dns.srv.port) and
+                         "host-router-1.default.service.arpa" in verify_utils.as_list(p.dns.srv.target)).\
+        must_next()
+
+    # Step 7
+    #   Device: ED_1
+    #   Description (DNS-11.1): Harness instructs device to perform DNS query with
+    #     QDCOUNT=1: First question QNAME=service-test-2_thread-test.
+    #     _udp.default.service.arpa QTYPE=TXT
+    #   Pass Criteria:
+    #     - The DUT MUST respond with success RCODE=0 (NoError) and one answer record in
+    #       the Answers section as follows: $ORIGIN default.service.arpa.
+    #       service-test-2._thread-test._udp ( TXT key2=value2 )
+    print("Step 7: ED_1 performs a DNS query for service-test-2 (TXT).")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: "service-test-2._thread-test._udp.default.service.arpa" in
+                         verify_utils.as_list(p.dns.qry.name) and
+                         consts.DNS_TYPE_TXT in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR and
+                         int(p.dns.count.answers) >= 1 and
+                         any(b"key2=value2" in t for t in verify_utils.as_list(p.dns.txt))).\
+        must_next()
+
+    # Step 8
+    #   Device: ED_1
+    #   Description (DNS-11.1): Harness instructs device to perform DNS query with
+    #     QDCOUNT=1: First question QNAME=host-eth-1.default.service.arpa QTYPE=AAAA
+    #   Pass Criteria:
+    #     - The DUT MUST respond with success RCODE=0 (NoError) and one answer record in
+    #       the Answers section as follows: $ORIGIN default.service.arpa. host-eth-1 AAAA
+    #       <ULA address of Eth_1>
+    #     - The DUT MUST NOT include a link-local address in an AAAA record in the
+    #       answer(s).
+    print("Step 8: ED_1 performs a DNS query for host-eth-1 (AAAA).")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: "host-eth-1.default.service.arpa" in
+                         verify_utils.as_list(p.dns.qry.name) and
+                         consts.DNS_TYPE_AAAA in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        filter(lambda p: all(not verify_utils.Ipv6Addr(addr).is_link_local
+                             for addr in verify_utils.as_list(p.dns.aaaa))).\
+        must_next()
+
+    # Step 9
+    #   Device: ED_1
+    #   Description (DNS-11.1): Harness instructs device to perform DNS query with
+    #     QDCOUNT=1: First question QNAME=host-router-1.default.service.arpa QTYPE=AAAA
+    #   Pass Criteria:
+    #     - The DUT MUST respond with success RCODE=0 (NoError) and one answer record in
+    #       the Answers section as follows: $ORIGIN default.service.arpa. host-router-1
+    #       AAAA <OMR address of Router_1>
+    #     - The DUT MUST NOT include a link-local address in an AAAA record in the
+    #       answer(s).
+    print("Step 9: ED_1 performs a DNS query for host-router-1 (AAAA).")
+    pkts.filter_wpan_src64(ED_1).\
+        filter_ipv6_dst(BR_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 0).\
+        filter(lambda p: "host-router-1.default.service.arpa" in
+                         verify_utils.as_list(p.dns.qry.name) and
+                         consts.DNS_TYPE_AAAA in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    pkts.filter_ipv6_src(BR_1_MLEID).\
+        filter_ipv6_dst(ED_1_MLEID).\
+        filter(lambda p: p.dns.flags.response == 1).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        filter(lambda p: all(not verify_utils.Ipv6Addr(addr).is_link_local
+                             for addr in verify_utils.as_list(p.dns.aaaa))).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_DNS_TC_3.py b/tests/nexus/verify_1_4_DNS_TC_3.py
new file mode 100644
index 0000000..04456f0
--- /dev/null
+++ b/tests/nexus/verify_1_4_DNS_TC_3.py
@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+GUA1_PREFIX = "2005:1234:abcd:100::/64"
+ETH1_ADDR = "2005:1234:abcd:100::e1"
+ETH2_ADDR = "2005:1234:abcd:100::e2"
+
+THREADGROUP1 = "threadgroup1.org"
+THREADGROUP2 = "threadgroup2.org"
+THREADGROUP3 = "threadgroup3.org"
+
+ANSWER1_ETH1 = "2002:1234::e1:1"
+ANSWER2_ETH2 = "2002:1234::e2:2"
+ANSWER3_ETH1 = "2002:1234::e1:3"
+
+# ICMPv6 RA Option Types
+ND_OPTION_PIO = 3
+ND_OPTION_RDNSS = 25
+
+
+def verify(pv):
+    pkts = pv.pkts
+
+    BR_1 = pv.vars['BR_1']
+    Router_1 = pv.vars['Router_1']
+    ED_1 = pv.vars['ED_1']
+    Eth_1 = pv.vars['Eth_1']
+    Eth_2 = pv.vars['Eth_2']
+
+    BR_1_ETH_ADDR = pv.vars['BR_1_ETH']
+    Eth_1_ETH_ADDR = pv.vars['Eth_1_ETH']
+    Eth_2_ETH_ADDR = pv.vars['Eth_2_ETH']
+
+    # Step 1
+    # - Device: Eth_1, Eth_2
+    # - Description (DNS-11.3): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: Enable Eth_1, Eth_2")
+
+    # Step 2
+    # - Device: Eth_2
+    # - Description (DNS-11.3): Harness instructs device to: Configure router
+    #   advertisement daemon (radvd) to multicast ND RA with: Prefix Information
+    #   Option (PIO) with a global on-link prefix GUA_1 (SLAAC is enabled (A=1),
+    #   on-link (L=1)), Recursive DNS Server Option (25), with Addresses of IPv6
+    #   Recursive DNS Servers field contains a single global IPv6 address of Eth_1.
+    #   Configure DNS server with records: threadgroup1.org AAAA 2002:1234::E2:1,
+    #   threadgroup2.org AAAA 2002:1234::E2:2, threadgroup3.org AAAA 2002:1234::E2:3.
+    #   Note: prefix GUA_1 may be 2005:1234:abcd:100::/64.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth_2 sends RA with global IPv6 address of Eth_1")
+    pkts.filter_eth_src(Eth_2_ETH_ADDR).\
+        filter_icmpv6_nd_ra().\
+        filter(lambda p: ND_OPTION_PIO in p.icmpv6.opt.type).\
+        filter(lambda p: ND_OPTION_RDNSS in p.icmpv6.opt.type).\
+        must_next()
+
+    # Step 3
+    # - Device: Eth_1
+    # - Description (DNS-11.3): Harness instructs device to: Configure DNS server
+    #   with records: threadgroup1.org AAAA 2002:1234::E1:1, threadgroup2.org AAAA
+    #   2002:1234::E1:2, threadgroup3.org AAAA 2002:1234::E1:3
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 3: Configure DNS records on Eth_1")
+
+    # Step 4
+    # - Device: BR_1 (DUT), Router_1, ED_1
+    # - Description (DNS-11.3): Enable
+    # - Pass Criteria:
+    #   - Single Thread Network forms.
+    print("Step 4: Enable BR_1, Router_1, ED_1")
+    pkts.filter_wpan_src64(BR_1).\
+        filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
+        must_next()
+
+    pkts.filter_wpan_src64(Router_1).\
+        filter_mle_cmd(consts.MLE_CHILD_ID_REQUEST).\
+        must_next()
+
+    pkts.filter_wpan_src64(ED_1).\
+        filter_mle_cmd(consts.MLE_CHILD_ID_REQUEST).\
+        must_next()
+
+    # Step 5
+    # - Device: BR_1 (DUT)
+    # - Description (DNS-11.3): Automatically obtains / configures an OMR prefix for
+    #   the Thread Network, and assigns an address for its AIL interface using SLAAC.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 5: BR_1 configures OMR prefix and AIL address")
+
+    # Step 6
+    # - Device: ED_1
+    # - Description (DNS-11.3): Harness instructs device to perform DNS query
+    #   Qtype=AAAA, name threadgroup1.org. Automatically, the DNS query gets
+    #   routed to the DUT
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 6: ED_1 performs DNS query for threadgroup1.org")
+    pkts.filter(lambda p: 'udp' in p.layer_names and p.udp.dstport == 53).\
+        must_next()
+
+    # Step 7
+    # - Device: BR_1 (DUT)
+    # - Description (DNS-11.3): Automatically processes the DNS query by requesting
+    #   upstream to the Eth_1 DNS server. Then, it responds back with the answer
+    #   to ED_1.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 7: BR_1 requests upstream to Eth_1")
+    pkts.filter_eth_src(BR_1_ETH_ADDR).\
+        filter(lambda p: p.eth.dst == Eth_1_ETH_ADDR).\
+        filter_ipv6_dst(ETH1_ADDR).\
+        filter(lambda p: 'udp' in p.layer_names and p.udp.dstport == 53).\
+        must_next()
+
+    # Step 8
+    # - Device: ED_1
+    # - Description (DNS-11.3): Successfully receives DNS query result:
+    #   threadgroup1.org AAAA 2002:1234::E1:1
+    # - Pass Criteria:
+    #   - ED_1 MUST receive correct DNS query answer from the DUT.
+    print("Step 8: ED_1 receives correct answer")
+    pkts.filter_eth_src(Eth_1_ETH_ADDR).\
+        filter(lambda p: p.eth.dst == BR_1_ETH_ADDR).\
+        must_next()
+
+    # Step 9
+    # - Device: Eth_2
+    # - Description (DNS-11.3): Harness instructs device to configure router
+    #   advertisement daemon (radvd) to multicast ND RA with updated information,
+    #   pointing to another DNS server: Prefix Information Option (PIO) - as
+    #   before, Recursive DNS Server Option (25), with Addresses of IPv6
+    #   Recursive DNS Servers field contains a single global IPv6 address of
+    #   Eth_2. Harness waits for a time of at least RA_PERIOD + 1 seconds, where
+    #   RA_PERIOD is the max period between multicast ND RA transmissions by
+    #   Eth_2, to ensure that the new ND RA is received by DUT.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 9: Eth_2 sends RA with global IPv6 address of Eth_2")
+    pkts.filter_eth_src(Eth_2_ETH_ADDR).\
+        filter_icmpv6_nd_ra().\
+        filter(lambda p: ND_OPTION_PIO in p.icmpv6.opt.type).\
+        filter(lambda p: ND_OPTION_RDNSS in p.icmpv6.opt.type).\
+        must_next()
+
+    # Step 10
+    # - Device: ED_1
+    # - Description (DNS-11.3): Harness instructs device to perform DNS query
+    #   Qtype=AAAA, name threadgroup2.org. Automatically, the DNS query gets
+    #   routed to the DUT.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 10: ED_1 performs DNS query for threadgroup2.org")
+    pkts.filter(lambda p: 'udp' in p.layer_names and p.udp.dstport == 53).\
+        must_next()
+
+    # Step 11
+    # - Device: BR_1 (DUT)
+    # - Description (DNS-11.3): Automatically processes the DNS query by requesting
+    #   upstream to the Eth_2 DNS server. Then, it responds back with the answer
+    #   to ED_1.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 11: BR_1 requests upstream to Eth_2")
+    pkts.filter_eth_src(BR_1_ETH_ADDR).\
+        filter(lambda p: p.eth.dst == Eth_2_ETH_ADDR).\
+        filter_ipv6_dst(ETH2_ADDR).\
+        filter(lambda p: 'udp' in p.layer_names and p.udp.dstport == 53).\
+        must_next()
+
+    # Step 12
+    # - Device: ED_1
+    # - Description (DNS-11.3): Successfully receives DNS query result:
+    #   threadgroup2.org AAAA 2002:1234::E2:2
+    # - Pass Criteria:
+    #   - ED_1 MUST receive correct DNS query answer from the DUT.
+    print("Step 12: ED_1 receives correct answer")
+    pkts.filter_eth_src(Eth_2_ETH_ADDR).\
+        filter(lambda p: p.eth.dst == BR_1_ETH_ADDR).\
+        must_next()
+
+    # Step 13
+    # - Device: Eth_2
+    # - Description (DNS-11.3): Harness instructs device to configure router
+    #   advertisement daemon (radvd) to multicast ND RA with updated information,
+    #   pointing to the first DNS server using link-local address: Prefix
+    #   Information Option (PIO) - as before, Recursive DNS Server Option (25),
+    #   with Addresses of IPv6 Recursive DNS Servers field contains a single
+    #   link-local IPv6 address of Eth_1. Harness waits for a time of at least
+    #   RA_PERIOD + 1 seconds, where RA_PERIOD is the max period between
+    #   multicast ND RA transmissions by Eth_2, to ensure that the new RA is
+    #   received by DUT.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 13: Eth_2 sends RA with link-local IPv6 address of Eth_1")
+    pkts.filter_eth_src(Eth_2_ETH_ADDR).\
+        filter_icmpv6_nd_ra().\
+        filter(lambda p: ND_OPTION_PIO in p.icmpv6.opt.type).\
+        filter(lambda p: ND_OPTION_RDNSS in p.icmpv6.opt.type).\
+        must_next()
+
+    # Step 14
+    # - Device: ED_1
+    # - Description (DNS-11.3): Harness instructs device to perform DNS query
+    #   Qtype=AAAA, name threadgroup3.org. Automatically, the DNS query gets
+    #   routed to the DUT
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 14: ED_1 performs DNS query for threadgroup3.org")
+    pkts.filter(lambda p: 'udp' in p.layer_names and p.udp.dstport == 53).\
+        must_next()
+
+    # Step 15
+    # - Device: BR_1 (DUT)
+    # - Description (DNS-11.3): Automatically processes the DNS query by requesting
+    #   upstream to the Eth_1 DNS server. Then, it responds back with the answer
+    #   to ED_1.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 15: BR_1 requests upstream to Eth_1")
+    pkts.filter_eth_src(BR_1_ETH_ADDR).\
+        filter(lambda p: p.eth.dst == Eth_1_ETH_ADDR).\
+        filter(lambda p: 'udp' in p.layer_names and p.udp.dstport == 53).\
+        must_next()
+
+    # Step 16
+    # - Device: ED_1
+    # - Description (DNS-11.3): Successfully receives DNS query result:
+    #   threadgroup3.org AAAA 2002:1234::E1:3
+    # - Pass Criteria:
+    #   - ED_1 MUST receive correct DNS query answer from the DUT.
+    print("Step 16: ED_1 receives correct answer")
+    pkts.filter_eth_src(Eth_1_ETH_ADDR).\
+        filter(lambda p: p.eth.dst == BR_1_ETH_ADDR).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_DNS_TC_5.py b/tests/nexus/verify_1_4_DNS_TC_5.py
new file mode 100644
index 0000000..29273d7
--- /dev/null
+++ b/tests/nexus/verify_1_4_DNS_TC_5.py
@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 11.5. [1.4] [CERT] DNS record types and special cases
+    #
+    # 11.5.1. Purpose
+    #   To verify that the BR DUT:
+    #   - Can resolve A records
+    #   - Can resolve A records without doing IPv6 AAAA synthesis
+    #   - Can resolve AAAA records
+    #   - Can resolve records contributed by different sources (on-mesh, on-AIL, and upstream DNS server)
+    #   - Supports non-typical record types (RRTypes) for queries
+    #   - Supports queries using RR type 0xFF00-0xFFFE (65280-65534) “Private Use”
+    #   - Blocks the “ipv4only.arpa” query
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    # Step 1
+    #   Device: Eth_1, BR_1, ED_1
+    #   Description (DNS-11.5): Form topology. Thread network is formed.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 1: Form topology. Thread network is formed.")
+
+    # Step 2
+    #   Device: Eth_1
+    #   Description (DNS-11.5): Harness instructs device to configure DNS server with records:
+    #     threadgroup1.org AAAA 2002:1234::E1:1, threadgroup1.org A 192.168.217.1,
+    #     threadgroup2.org A 192.168.217.2, threadgroup2.org TYPE65285 \# 4 12345678.
+    #     Note: the record type 0xFF05 is a private range record type, hence it has the
+    #     symbolic name TYPE65285 associated.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 2: Eth_1 configures DNS server with records.")
+
+    # Step 3
+    #   Device: Eth_1
+    #   Description (DNS-11.5): Harness instructs device to start advertising mDNS services on the AIL:
+    #     Note: the PTR records for services are not shown below; however these are present as usual.
+    #     $ORIGIN local. Service-test-1._infra-test._udp ( SRV 0 0 55551 host-test-eth TXT
+    #     dummy=abcd;thread-test=a49 ) service-test-2._infra-test._udp ( SRV 0 0 55552
+    #     host-test-eth TXT a=2;thread-test=b50_test ) ) host-test-eth A <IPv4 address of Eth_1>.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 3: Eth_1 starts advertising mDNS services on the AIL.")
+
+    # Step 4
+    #   Device: ED_1
+    #   Description (DNS-11.5): Harness instructs device to send a DNS query to the DUT with
+    #     Qtype=AAAA for name “threadgroup1.org”.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 4: ED_1 sends a DNS query to the DUT with Qtype=AAAA for name threadgroup1.org.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 0).\
+        filter(lambda p: any(name in ("threadgroup1.org", "threadgroup1.org.") for name in verify_utils.as_list(p.dns.qry.name))).\
+        filter(lambda p: consts.DNS_TYPE_AAAA in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    # Step 5
+    #   Device: BR_1 (DUT)
+    #   Description (DNS-11.5): Automatically performs the DNS query via the Eth_1 DNS server,
+    #     and provides the answer back to ED_1.
+    #   Pass Criteria:
+    #     - The DUT MUST respond Rcode=0 (NoError) with answer record: threadgroup1.org
+    #       AAAA 2002:1234::E1:1
+    print("Step 5: BR_1 performs the DNS query via Eth_1 and provides the answer to ED_1.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 1).\
+        filter(lambda p: consts.DNS_TYPE_AAAA in verify_utils.as_list(p.dns.qry.type)).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        filter(lambda p: int(p.dns.count.answers) > 0).\
+        filter(lambda p: any(verify_utils.Ipv6Addr("2002:1234::e1:1") == addr
+                             for addr in verify_utils.as_list(p.dns.aaaa))).\
+        must_next()
+
+    # Step 6
+    #   Device: ED_1
+    #   Description (DNS-11.5): Harness instructs device to send a DNS query to the DUT, with
+    #     Qtype=A for name “threadgroup1.org”.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 6: ED_1 sends a DNS query to the DUT with Qtype=A for name threadgroup1.org.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 0).\
+        filter(lambda p: any(name in ("threadgroup1.org", "threadgroup1.org.") for name in verify_utils.as_list(p.dns.qry.name))).\
+        filter(lambda p: consts.DNS_TYPE_A in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    # Step 7
+    #   Device: BR_1 (DUT)
+    #   Description (DNS-11.5): Automatically performs the DNS query via the Eth_1 DNS server,
+    #     and provides the answer back to ED_1.
+    #   Pass Criteria:
+    #     - The DUT MUST respond Rcode=0 (NoError) with answer record: threadgroup1.org A 192.168.217.1
+    print("Step 7: BR_1 performs the DNS query via Eth_1 and provides the answer to ED_1.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 1).\
+        filter(lambda p: consts.DNS_TYPE_A in verify_utils.as_list(p.dns.qry.type)).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        filter(lambda p: int(p.dns.count.answers) > 0).\
+        filter(lambda p: any("192.168.217.1" == str(a) for a in verify_utils.as_list(p.dns.a))).\
+        must_next()
+
+    # Step 8
+    #   Device: ED_1
+    #   Description (DNS-11.5): Harness instructs device to send a DNS query to the DUT, with
+    #     Qtype=AAAA for name “threadgroup2.org”.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 8: ED_1 sends a DNS query to the DUT with Qtype=AAAA for name threadgroup2.org.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 0).\
+        filter(lambda p: any(name in ("threadgroup2.org", "threadgroup2.org.") for name in verify_utils.as_list(p.dns.qry.name))).\
+        filter(lambda p: consts.DNS_TYPE_AAAA in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    # Step 9
+    #   Device: BR_1 (DUT)
+    #   Description (DNS-11.5): Automatically performs the DNS query via the Eth_1 DNS server,
+    #     and provides the error answer back to ED_1. Note: this checks that the DUT does not
+    #     perform AAAA synthesis from the A record.
+    #   Pass Criteria:
+    #     - The DUT MUST respond Rcode=0 (NoError) with zero answer records (ANCOUNT=0).
+    print("Step 9: BR_1 performs the DNS query via Eth_1 and provides the error answer to ED_1.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 1).\
+        filter(lambda p: consts.DNS_TYPE_AAAA in verify_utils.as_list(p.dns.qry.type)).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        filter(lambda p: int(p.dns.count.answers) == 0).\
+        must_next()
+
+    # Step 10
+    #   Device: ED_1
+    #   Description (DNS-11.5): Harness instructs device to send a DNS query, with Qtype=A for
+    #     name “host-test-eth.default.service.arpa”.
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 10: ED_1 sends a DNS query with Qtype=A for host-test-eth.default.service.arpa.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 0).\
+        filter(lambda p: any(name in ("host-test-eth.default.service.arpa", "host-test-eth.default.service.arpa.") for name in verify_utils.as_list(p.dns.qry.name))).\
+        filter(lambda p: consts.DNS_TYPE_A in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    # Step 11
+    #   Device: BR_1 (DUT)
+    #   Description (DNS-11.5): Automatically performs the mDNS query on AIL (optionally, if
+    #     still needed), and provides the answer back to ED_1.
+    #   Pass Criteria:
+    #     - The DUT MUST respond Rcode=0 (NoError) with answer record:
+    #       host-test-eth.default.service.arpa A <IPv4 address of Eth_1>
+    print("Step 11: BR_1 performs mDNS query on AIL and provides the answer to ED_1.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 1).\
+        filter(lambda p: consts.DNS_TYPE_A in verify_utils.as_list(p.dns.qry.type)).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        filter(lambda p: int(p.dns.count.answers) > 0).\
+        filter(lambda p: any("192.168.217.100" == str(a) for a in verify_utils.as_list(p.dns.a))).\
+        must_next()
+
+    # Step 12
+    #   Device: ED_1
+    #   Description (DNS-11.5): Harness instructs device to send DNS query Qtype=A for name “ipv4only.arpa”
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 12: ED_1 sends DNS query Qtype=A for name ipv4only.arpa.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 0).\
+        filter(lambda p: any(name in ("ipv4only.arpa", "ipv4only.arpa.") for name in verify_utils.as_list(p.dns.qry.name))).\
+        filter(lambda p: consts.DNS_TYPE_A in verify_utils.as_list(p.dns.qry.type)).\
+        must_next()
+
+    # Step 13
+    #   Device: BR_1 (DUT)
+    #   Description (DNS-11.5): Automatically blocks the ipv4only.arpa query and responds NXDomain error.
+    #   Pass Criteria:
+    #     - The DUT MUST respond Rcode=3 (NXDomain) with zero answer records (ANCOUNT=0).
+    #     - The DUT MUST NOT send a DNS query to Eth_1.
+    print("Step 13: BR_1 blocks the ipv4only.arpa query and responds NXDomain error.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 1).\
+        filter(lambda p: any(name in ("ipv4only.arpa", "ipv4only.arpa.") for name in verify_utils.as_list(p.dns.qry.name))).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NXDOMAIN).\
+        filter(lambda p: int(p.dns.count.answers) == 0).\
+        must_next()
+
+    # Check that no query was sent to Eth_1 (upstream)
+    pkts.copy().filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 0).\
+        filter(lambda p: any(name in ("ipv4only.arpa", "ipv4only.arpa.") for name in verify_utils.as_list(p.dns.qry.name))).\
+        filter(lambda p: p.eth.dst == "02:00:00:00:00:02").\
+        must_not_next()
+
+    # Step 14
+    #   Device: ED_1
+    #   Description (DNS-11.5): Harness instructs device to send DNS query with RR type in the
+    #     private use range Qtype=0xFF05 for name “threadgroup2.org”. To do this with OT CLI,
+    #     use the following: udp send <BR_1 ML-EID address> 53 -x
+    #     e13c010000010000000000000c74687265616467726f757032036f726700ff050001 “”
+    #   Pass Criteria:
+    #     - N/A
+    print("Step 14: ED_1 sends DNS query with RR type in private use range Qtype=0xFF05.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 0).\
+        filter(lambda p: any(name in ("threadgroup2.org", "threadgroup2.org.") for name in verify_utils.as_list(p.dns.qry.name))).\
+        filter(lambda p: any(65285 == t for t in verify_utils.as_list(p.dns.qry.type))).\
+        must_next()
+
+    # Step 15
+    #   Device: BR_1 (DUT)
+    #   Description (DNS-11.5): Automatically responds with the requested record.
+    #   Pass Criteria:
+    #     - The DUT MUST respond Rcode=0 (NoError) with answer record with binary data (4 bytes):
+    #       threadgroup2.org 0xFF05 0x12345678
+    print("Step 15: BR_1 responds with the requested record.")
+    pkts.filter(lambda p: getattr(p, 'dns', None) and p.dns.flags.response == 1).\
+        filter(lambda p: any(65285 == t for t in verify_utils.as_list(p.dns.qry.type))).\
+        filter(lambda p: p.dns.flags.rcode == consts.DNS_RCODE_NOERROR).\
+        filter(lambda p: int(p.dns.count.answers) > 0).\
+        filter(lambda p: any("12345678" in str(getattr(p.dns, 'data', '')) for _ in [0])).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_PIC_TC_1.py b/tests/nexus/verify_1_4_PIC_TC_1.py
new file mode 100644
index 0000000..02e3c02
--- /dev/null
+++ b/tests/nexus/verify_1_4_PIC_TC_1.py
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    # 10.1. IPv6 Connectivity using DHCPv6-PD delegated OMR prefix
+    #
+    # 10.1.1. Purpose
+    # - To verify that BR DUT:
+    #   - uses DHCPv6-PD client functionality to obtain an OMR prefix for its Thread Network from the upstream CPE
+    #     router
+    #   - Offers IPv6 public internet connectivity to/from Thread Devices that configured an OMR address
+    #   - Offers IPv6 local network connectivity to/from Thread Devices that configured an OMR address
+    #   - Operates DNS recursive resolver to look up IPv6 server addresses on public internet
+    #
+    # 10.1.2. Topology
+    # - BR_1 (DUT) - Border Router
+    # - Router_1 - Thread Router Reference Device, attached to BR_1
+    # - ED_1 - Thread Reference Device, End Device (e.g. FED/REED) role, attached to Router_1
+    # - Eth_1 - Adjacent Infrastructure Link Linux Reference Device
+    #   - Has HTTP web server with resource ‘/test2.html’.
+    # - Eth_2 - Adjacent Infrastructure Link SPIFF Reference Device
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1 = pv.vars['BR_1']
+    ROUTER_1 = pv.vars['ROUTER_1']
+    ED_1 = pv.vars['ED_1']
+    ETH_1_ADDR = pv.vars['ETH_1_ADDR']
+    ETH_2_ADDR = pv.vars['ETH_2_ADDR']
+    INTERNET_SERVER_ADDR = pv.vars['INTERNET_SERVER_ADDR']
+    DELEGATED_PREFIX = pv.vars['DELEGATED_PREFIX']
+
+    # Step 1
+    # - Device: Eth_1, Eth_2
+    # - Description (PIC-10.1): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: Enable Eth_1 and Eth_2")
+
+    # Step 2
+    # - Device: Eth_2 (SPIFF)
+    # - Description (PIC-10.1): Harness does not configure the device, other than the default configuration. Note: in
+    #   previous versions of the test plan, explicit configuration was provided in this step. Note: Eth_1 will
+    #   automatically configure a GUA using SLAAC and the RA-advertised prefix.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth_2 default configuration")
+
+    # Step 3
+    # - Device: BR_1 (DUT), Router_1, ED_1
+    # - Description (PIC-10.1): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 3: Enable BR_1, Router_1, ED_1")
+
+    # Step 4
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.1): Automatically uses DHCPv6-PD client function to obtain a delegated prefix from the
+    #   DHCPv6 server. It configures this prefix as the OMR prefix.
+    # - Pass Criteria:
+    #   - An OMR prefix OMR_1 MUST appear in Thread Network Data in a Prefix TLV:
+    #   - It MUST be subprefix of 2005:1234:abcd:0::/56.
+    #   - It MUST be a /64 prefix
+    #   - Border Router sub-TLV:
+    #     - Prf bits = P_preference = '00' (medium)
+    #     - R = P_default = '1'
+    print("Step 4: BR_1 obtains OMR prefix via DHCPv6-PD")
+    # Verify the prefix in Net Data is a subprefix of 2005:1234:abcd:0::/56
+    # 2005:1234:abcd:0001::/64 matches the first 8 bytes of the expanded address.
+    omr_prefix_pattern = Ipv6Addr("2005:1234:abcd:0001::")[:8]
+    omr_prefix_pkt = pkts.filter_wpan_src64(BR_1). \
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE). \
+        filter(lambda p: any(pre.startswith(omr_prefix_pattern) for pre in p.thread_nwd.tlv.prefix)). \
+        must_next()
+
+    OMR_PREFIX = None
+    for pre in omr_prefix_pkt.thread_nwd.tlv.prefix:
+        if pre.startswith(omr_prefix_pattern):
+            OMR_PREFIX = pre
+            break
+    assert OMR_PREFIX is not None
+
+    # Step 4b
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.1): Automatically advertises the route to its OMR prefix on the AIL, as a stub router (aka
+    #   IETF SNAC router). Note: see 9.6.2 for Stub Router / SNAC Router flag detail.The flag is bit 6 of RA flags as
+    #   specified by the TEMPORARY allocation made by IANA (link).
+    # - Pass Criteria:
+    #   - The DUT MUST advertise the route in multicast ND RA messages:
+    #   - IPv6 destination MUST be ff02::1
+    #   - SNAC Router flag set to '1'
+    #   - Route Information Option (RIO)
+    #     - Prefix: OMR_1
+    #     - Prf bits: '00' or '11'
+    #     - Route Lifetime > 0
+    print("Step 4b: BR_1 advertises route to OMR prefix on AIL")
+    pkts.filter_ipv6_dst("ff02::1"). \
+        filter_icmpv6_nd_ra(). \
+        filter(lambda p: verify_utils.check_ra_has_rio(p, OMR_PREFIX)). \
+        must_next()
+
+    # Step 5
+    # - Device: ED_1
+    # - Description (PIC-10.1): Harness instructs device to perform DNS query QType=AAAA, name “threadgroup.org”.
+    #   Automatically, the DNS query gets routed to BR_1.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 5: ED_1 performs DNS query for threadgroup.org")
+    dns_query = pkts.filter(lambda p: 'dns' in p.layer_names). \
+        filter(lambda p: any(name.rstrip('.') == "threadgroup.org" for name in verify_utils.as_list(p.dns.qry.name))). \
+        must_next()
+
+    # Step 6
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.1): Automatically processes the DNS query by requesting upstream to the Eth_2 DNS server.
+    #   Then, it responds back with the answer to ED_1.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 6: BR_1 processes DNS query upstream")
+    pkts.filter_ipv6_dst(ETH_2_ADDR). \
+        filter(lambda p: 'dns' in p.layer_names). \
+        filter(lambda p: any(name.rstrip('.') == "threadgroup.org" for name in verify_utils.as_list(p.dns.qry.name))). \
+        must_next()
+
+    # Step 7
+    # - Device: ED_1
+    # - Description (PIC-10.1): Successfully receives DNS query result: threadgroup.org AAAA 2002:1234::1234
+    # - Pass Criteria:
+    #   - ED_1 MUST receive DNS query answer from DUT.
+    print("Step 7: ED_1 receives DNS result")
+    # Just look for the response anywhere in the pcap after the query
+    pkts.filter(lambda p: 'dns' in p.layer_names). \
+        filter(lambda p: any(name.rstrip('.') == "threadgroup.org" for name in verify_utils.as_list(p.dns.resp.name))). \
+        must_next()
+
+    # Step 8
+    # - Device: ED_1
+    # - Description (PIC-10.1): Harness instructs device to send ICMpv6 ping request to internet server, using resolved
+    #   address above. It is routed via the DUT to the Eth_2 simulated network.
+    # - Pass Criteria:
+    #   - ED_1 MUST receive ICMPv6 ping response from simulated server.
+    print("Step 8: ED_1 pings internet server")
+    ping_req = pkts.filter_ipv6_dst(INTERNET_SERVER_ADDR). \
+        filter_ping_request(). \
+        must_next()
+    # Look for the reply from internet server
+    pkts.filter_ipv6_src(INTERNET_SERVER_ADDR). \
+        filter_ping_reply(identifier=ping_req.icmpv6.echo.identifier). \
+        must_next()
+    # ED_1 receives response
+    pkts.filter_ipv6_src(INTERNET_SERVER_ADDR). \
+        filter_ping_reply(identifier=ping_req.icmpv6.echo.identifier). \
+        must_next()
+
+    # Step 8a
+    # - Device: ED_1
+    # - Description (PIC-10.1): Repeats same using a UDP ping.
+    # - Pass Criteria:
+    #   - ED_1 MUST receive UDP ping response from simulated server.
+    print("Step 8a: ED_1 sends UDP ping to internet server")
+    pkts.filter_ipv6_dst(INTERNET_SERVER_ADDR). \
+        filter(lambda p: 'udp' in p.layer_names). \
+        must_next()
+
+    # Step 9
+    # - Device: ED_1
+    # - Description (PIC-10.1): Harness instructs device to sends HTTP GET request to server. If ED_1 is a Linux device,
+    #   it may use 'curl' to save the file: curl -o test.html http://threadgroup.org/test.html. In case ED_1 is a
+    #   non-Linux Thread CLI device, it can use the OT CLI commands to send the HTTP GET request: tcp init, tcp connect
+    #   2002:1234::1234 80, tcp send -x
+    #   474554202F746573742E68746D6C20485454502F312E310D0A486F73743A2074687265616467726F75702E6F72670D0A0D0A, tcp
+    #   sendend, tcp deinit
+    # - Pass Criteria:
+    #   - In case Linux 'curl' was used: File test.html MUST equal the test.html file as stored on the simulated server
+    #     on Eth_2.
+    #   - In case OT CLI was used: Output on the CLI MUST be the HTML file test.html as stored on the simulator server
+    #     on Eth_2.
+    print("Step 9: ED_1 sends HTTP GET to internet server")
+    pkts.filter_ipv6_dst(INTERNET_SERVER_ADDR). \
+        filter(lambda p: 'tcp' in p.layer_names). \
+        must_next()
+
+    # Step 10
+    # - Device: ED_1
+    # - Description (PIC-10.1): Harness instructs device to send ping request to local server Eth_1. Automatically, it
+    #   is routed via the DUT.
+    # - Pass Criteria:
+    #   - ED_1 MUST receive ping response from Eth_1.
+    print("Step 10: ED_1 pings local server Eth_1")
+    ping_req = pkts.filter_ipv6_dst(ETH_1_ADDR). \
+        filter_ping_request(). \
+        must_next()
+    pkts.filter_ipv6_src(ETH_1_ADDR). \
+        filter_ping_reply(identifier=ping_req.icmpv6.echo.identifier). \
+        must_next()
+
+    # Step 11
+    # - Device: ED_1
+    # - Description (PIC-10.1): Harness instructs device to send HTTP GET request to local server Eth_1. If ED_1 is a
+    #   Linux device, it may use 'curl' to save the file: curl -o test2.html http:///test2.html. In case ED_1 is a
+    #   non-Linux Thread CLI device, it can use the OT CLI commands to send the HTTP GET request: tcp init, tcp connect
+    #   <Eth_1_GUA_IPv6_addr> 80, tcp send -x
+    #   474554202F74657374322E68746D6C20485454502F312E310D0A486F73743A206C6F63616C746573742E6F72670D0A0D0A, tcp
+    #   sendend, tcp deinit
+    # - Pass Criteria:
+    #   - In case Linux 'curl' was used: File test2.html MUST equal the test2.html file as stored on the Eth_1 server.
+    #   - In case OT CLI was used: Output on the CLI MUST be the HTML file test.html as stored on the simulator server
+    #     on Eth_2.
+    print("Step 11: ED_1 sends HTTP GET to local server Eth_1")
+    pkts.filter_ipv6_dst(ETH_1_ADDR). \
+        filter(lambda p: 'tcp' in p.layer_names). \
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_PIC_TC_3.py b/tests/nexus/verify_1_4_PIC_TC_3.py
new file mode 100644
index 0000000..52c1a81
--- /dev/null
+++ b/tests/nexus/verify_1_4_PIC_TC_3.py
@@ -0,0 +1,365 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def check_nwd_has_route(packet, target_prefix, rloc16=None):
+    try:
+        types = verify_utils.as_list(packet.thread_nwd.tlv.type)
+        prefixes = verify_utils.as_list(packet.thread_nwd.tlv.prefix)
+    except (AttributeError, IndexError):
+        return False
+
+    prefix_idx = 0
+    hr_idx = 0
+    is_target = False
+    for t in types:
+        if t == consts.NWD_PREFIX_TLV:
+            is_target = (Ipv6Addr(prefixes[prefix_idx]) == Ipv6Addr(target_prefix))
+            prefix_idx += 1
+        elif t in (consts.NWD_COMMISSIONING_DATA_TLV, consts.NWD_SERVICE_TLV):
+            is_target = False
+        elif t == consts.NWD_HAS_ROUTER_TLV:
+            if is_target:
+                if rloc16 is not None:
+                    try:
+                        br_16s = verify_utils.as_list(packet.thread_nwd.tlv.has_route.br_16)
+                        if int(br_16s[hr_idx]) != rloc16:
+                            hr_idx += 1
+                            continue
+                    except (AttributeError, IndexError):
+                        hr_idx += 1
+                        continue
+
+                # Check for NP flag if available in pktverify
+                # Thread 1.4: Has Route sub-TLV (Type 0)
+                # Field order: RLOC16 (16), Flags (8)
+                # Flags: Prf (2), P (1), NP (1), R (1), O (1), Reserved (2)
+                try:
+                    nps = verify_utils.as_list(packet.thread_nwd.tlv.has_route.np)
+                    if int(nps[hr_idx]) != 0:
+                        hr_idx += 1
+                        continue
+                except (AttributeError, IndexError):
+                    # If field NP is not available, we skip the check
+                    pass
+
+                return True
+            hr_idx += 1
+
+    return False
+
+
+def check_nwd_prefix_flags_with_rloc(packet, target_prefix, rloc16, **expected_flags):
+    try:
+        types = verify_utils.as_list(packet.thread_nwd.tlv.type)
+        prefixes = verify_utils.as_list(packet.thread_nwd.tlv.prefix)
+    except (AttributeError, IndexError):
+        return False
+
+    prefix_idx = 0
+    br_idx = 0
+    is_target = False
+
+    for i, t in enumerate(types):
+        if t == consts.NWD_PREFIX_TLV:
+            is_target = (Ipv6Addr(prefixes[prefix_idx]) == Ipv6Addr(target_prefix))
+            prefix_idx += 1
+        elif t in (consts.NWD_COMMISSIONING_DATA_TLV, consts.NWD_SERVICE_TLV):
+            is_target = False
+        elif t == consts.NWD_BORDER_ROUTER_TLV:
+            if is_target:
+                try:
+                    br_16s = verify_utils.as_list(packet.thread_nwd.tlv.border_router_16)
+                    if int(br_16s[br_idx]) != rloc16:
+                        br_idx += 1
+                        continue
+
+                    # Now check flags using verify_utils.check_nwd_prefix_flags logic
+                    # Since we can't easily call it with our filtered indices, we'll do it here
+                    field_map = {
+                        'r': packet.thread_nwd.tlv.border_router.flag.r,
+                        'o': packet.thread_nwd.tlv.border_router.flag.o,
+                    }
+
+                    match = True
+                    for key, expected_val in expected_flags.items():
+                        if key in field_map:
+                            actual_val = int(verify_utils.as_list(field_map[key])[br_idx])
+                            if actual_val != expected_val:
+                                match = False
+                                break
+                    if match:
+                        return True
+                except (AttributeError, IndexError):
+                    pass
+            br_idx += 1
+
+    return False
+
+
+def verify(pv):
+    # 10.3. IPv6 default route advertisement
+    #
+    # 10.3.1. Purpose
+    # - To verify that the BR DUT:
+    #   - Correctly advertises a default route on the Thread Network, using the zero-length route ::/0 in a Prefix TLV.
+    #   - Does not withdraws the advertised default route when the infrastructure network disables the default route,
+    #     as long as there's still a non-ULA prefix active on the AIL.
+    #   - Still advertises the default route, when the infrastructure network re-enables the default route.
+    #   - TBD: a future test update needs to validate that when only a ULA prefix is set at the AIL, the default route
+    #     gets withdrawn and replaced by fc00::/7 route.
+    #
+    # 10.3.2. Topology
+    # - BR_1 (DUT) - Border Router
+    # - Router_1 - Thread Router Reference Device, attached to BR_1
+    # - ED_1 - Thread Reference Device, End Device (e.g. FED/REED) role, attached to Router_1
+    # - Eth_1 - Adjacent Infrastructure Link Linux Reference Device
+    # - Eth_2 - Adjacent Infrastructure Link SPIFF Reference Device
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1 = pv.vars['BR_1']
+    ED_1 = pv.vars['ED_1']
+    ETH_1_ADDR = pv.vars['ETH_1_ADDR']
+    INTERNET_SERVER_ADDR = pv.vars['INTERNET_SERVER_ADDR']
+
+    # Step 1
+    # - Device: Eth_1, Eth_2
+    # - Description (PIC-10.3): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: Enable Eth_1 and Eth_2")
+
+    # Step 2
+    # - Device: Eth_2
+    # - Description (PIC-10.3): Harness instructs device to: (only default configuration is applied)
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth_2 default configuration")
+
+    # Step 3
+    # - Device: BR_1 (DUT), Router_1, ED_1
+    # - Description (PIC-10.3): Enable
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 3: Enable BR_1, Router_1, ED_1")
+
+    # Step 4
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.3): Automatically uses DHCPv6-PD client function to obtain a delegated prefix from the
+    #   DHCPv6 server. It configures this prefix as the OMR prefix.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 4: BR_1 obtains OMR prefix via DHCPv6-PD")
+    # Verify DHCPv6-PD Reply from Eth_2 to BR_1
+    pkts.filter_ipv6_src(pv.vars['ETH_2_ADDR']).\
+        filter(lambda p: 'dhcpv6' in p.layer_names).\
+        must_next()
+
+    # Step 5
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.3): Automatically advertises a default route in its Thread Network Data.
+    # - Pass Criteria:
+    #   - Thread Network Data MUST contain: Prefix TLV with an OMR prefix OMR_1 that starts with 2005:1234:abcd:0::/56.
+    #   - Border Router sub-TLV MUST indicate the RLOC16 of BR_1, in a P_border_router_16 field.
+    #   - Flag R (P_default) MUST be ‘1’
+    #   - Flag O (P_on_mesh) MUST be ‘1’
+    #   - Thread Network Data MUST contain: Prefix TLV with the zero-length prefix ::/0
+    #   - Has Route sub-TLV Flag NP MUST be ‘0’
+    print("Step 5: BR_1 automatically advertises a default route in its Thread Network Data")
+    omr_prefix_pattern = Ipv6Addr("2005:1234:abcd:0::")[:7]
+    BR_1_RLOC16 = pv.vars['BR_1_RLOC16']
+
+    nwd_pkt = pkts.filter_wpan_src64(BR_1).\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: any(pre.startswith(omr_prefix_pattern) for pre in p.thread_nwd.tlv.prefix)).\
+        filter(lambda p: any(pre == Ipv6Addr("::") for pre in p.thread_nwd.tlv.prefix)).\
+        must_next()
+
+    OMR_PREFIX = None
+    for pre in nwd_pkt.thread_nwd.tlv.prefix:
+        if pre.startswith(omr_prefix_pattern):
+            OMR_PREFIX = pre
+            break
+    assert OMR_PREFIX is not None
+
+    # Verify OMR prefix flags and RLOC16
+    assert check_nwd_prefix_flags_with_rloc(nwd_pkt, OMR_PREFIX, rloc16=BR_1_RLOC16, r=1, o=1)
+
+    # Verify ::/0 prefix in Has Route TLV and RLOC16
+    assert check_nwd_has_route(nwd_pkt, "::", rloc16=BR_1_RLOC16)
+
+    # Step 6
+    # - Device: ED_1
+    # - Description (PIC-10.3): Harness instructs device to send ping to destination address 2002:1234::1234 with its
+    #   (default) OMR source address, to validate that default route works.
+    # - Pass Criteria:
+    #   - Ping response received by ED_1.
+    print("Step 6: ED_1 pings internet address")
+    ping_req = pkts.filter_ipv6_dst(INTERNET_SERVER_ADDR).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter_ipv6_src(INTERNET_SERVER_ADDR).\
+        filter_ping_reply(identifier=ping_req.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 7
+    # - Device: Eth_2
+    # - Description (PIC-10.3): Harnes instructs device to reconfigure ND RA daemon to stop advertising a default
+    #   route: “Router Lifetime” field set to 0 sec (indicating it is not a default router for IPv6).
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 7: Eth_2 stops advertising a default route")
+    ra_step7 = pkts.filter_icmpv6_nd_ra().\
+        filter(lambda p: p.icmpv6.nd.ra.router_lifetime == 0).\
+        must_next()
+
+    # Step 8
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.3): Does not stop advertising a default route in its Thread Network Data.
+    # - Pass Criteria:
+    #   - Thread Network Data MUST contain: Prefix TLV with same OMR_1.
+    #   - Border Router sub-TLV MUST indicate the RLOC16 of BR_1, in a P_border_router_16 field.
+    #   - Flag R (P_default) MUST be ‘1’
+    #   - Flag O (P_on_mesh) MUST be ‘1’
+    #   - Thread Network Data MUST contain: Prefix TLV with the zero-length prefix ::/0
+    #   - Has Route sub-TLV Flag NP MUST be ‘0’
+    print("Step 8: BR_1 does not stop advertising a default route")
+    # We check that BR_1 still advertises the default route in its next MLE Data Response.
+    nwd_pkt_8 = pkts.filter_wpan_src64(BR_1).\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: any(pre == Ipv6Addr("::") for pre in p.thread_nwd.tlv.prefix)).\
+        must_next()
+
+    # Verify OMR prefix flags and RLOC16
+    assert check_nwd_prefix_flags_with_rloc(nwd_pkt_8, OMR_PREFIX, rloc16=BR_1_RLOC16, r=1, o=1)
+
+    # Verify ::/0 prefix in Has Route TLV and RLOC16
+    assert check_nwd_has_route(nwd_pkt_8, "::", rloc16=BR_1_RLOC16)
+
+    # Step 9
+    # - Device: ED_1
+    # - Description (PIC-10.3): Harness instructs device to send ping to destination Eth_1 with its (default) OMR
+    #   source address, to validate that route to AIL still works.
+    # - Pass Criteria:
+    #   - Ping response received by ED_1.
+    print("Step 9: ED_1 pings Eth_1")
+    ping_req_9 = pkts.filter_ipv6_dst(ETH_1_ADDR).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter_ipv6_src(ETH_1_ADDR).\
+        filter_ping_reply(identifier=ping_req_9.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 10
+    # - Device: ED_1
+    # - Description (PIC-10.3): Harness instructs device to send ping to destination address 2002:1234::1234 with its
+    #   (default) OMR source address, to validate that default route does not work at the moment.
+    # - Pass Criteria:
+    #   - Ping request MUST NOT be sent by DUT onto the AIL.
+    #   - Ping response MUST NOT be received by ED_1.
+    print("Step 10: ED_1 pings internet address (should fail)")
+    _step10_start_idx = (pkts.index[0], 0)
+
+    # Find Step 11 RA first to define range for Step 10
+    ra_step11 = pkts.filter_icmpv6_nd_ra().\
+        filter(lambda p: p.icmpv6.nd.ra.router_lifetime > 0).\
+        must_next()
+
+    # No ping request on AIL for internet server in the range of Step 10.
+    # Packets on AIL do NOT have 'wpan' layer.
+    pkts.range(_step10_start_idx, (ra_step11.number, 0)).\
+        filter_ipv6_dst(INTERNET_SERVER_ADDR).\
+        filter_ping_request().\
+        filter(lambda p: 'wpan' not in p.layer_names).\
+        must_not_next()
+
+    # Step 11
+    # - Device: Eth_2
+    # - Description (PIC-10.3): Harness instructs device to reconfigure ND RA daemon to start advertising a default
+    #   route again: “Router Lifetime” field set to 875 sec (indicating it is a default router for IPv6).
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 11: Eth_2 starts advertising a default route again")
+    # ra_step11 already found
+
+    # Step 12
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.3): (As in step 5)
+    # - Pass Criteria:
+    #   - (As in step 5)
+    print("Step 12: BR_1 automatically advertises a default route (same as step 5)")
+    # Since the default route was never withdrawn (verified in Step 8), we check that
+    # it continues to be advertised (i.e., no MLE Data Response from BR_1 withdraws it).
+    pkts.filter_wpan_src64(BR_1).\
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE).\
+        filter(lambda p: any(pre == Ipv6Addr("::") for pre in p.thread_nwd.tlv.prefix) is False).\
+        must_not_next()
+
+    # Step 13
+    # - Device: ED_1
+    # - Description (PIC-10.3): (As in step 6)
+    # - Pass Criteria:
+    #   - (As in step 6)
+    print("Step 13: ED_1 pings internet address (same as step 6)")
+    ping_req_13 = pkts.filter_ipv6_dst(INTERNET_SERVER_ADDR).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter_ipv6_src(INTERNET_SERVER_ADDR).\
+        filter_ping_reply(identifier=ping_req_13.icmpv6.echo.identifier).\
+        must_next()
+
+    # Step 14
+    # - Device: ED_1
+    # - Description (PIC-10.3): (As in step 9)
+    # - Pass Criteria:
+    #   - (As in step 9)
+    print("Step 14: ED_1 pings Eth_1 (same as step 9)")
+    ping_req_14 = pkts.filter_ipv6_dst(ETH_1_ADDR).\
+        filter_ping_request().\
+        must_next()
+    pkts.filter_ipv6_src(ETH_1_ADDR).\
+        filter_ping_reply(identifier=ping_req_14.icmpv6.echo.identifier).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_PIC_TC_4.py b/tests/nexus/verify_1_4_PIC_TC_4.py
new file mode 100644
index 0000000..7085d99
--- /dev/null
+++ b/tests/nexus/verify_1_4_PIC_TC_4.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    # 10.4 IPv4 Connectivity using BR built-in NAT64
+    # 10.4.1. Purpose
+    # - To verify that BR DUT:
+    #   - Offers IPv4 internet connectivity to/from Thread Devices using its built-in NAT64 translator, when the
+    #     infrastructure network does not provide a NAT64 translator.
+    #   - Offers IPv4 local network connectivity to/from Thread Devices using its built-in NAT64 translator
+    #   - Operates DNS recursive resolver to look up IPv4 server addresses
+    #   - Withdraws its own NAT64 prefix when another Border Router advertises a preferred NAT64 prefix. TBD - to
+    #     be added.
+    # - Note: the present test requires a /96 NAT64 prefix, which is stricter than what the specification requires.
+    #   RFC 6052 (Section 2.2) allows other lengths as well; however the present test case is not yet written to
+    #   support all these different options.
+    # 10.4.2. Topology
+    # - BR_1 (DUT) - Border Router
+    # - Router_1 - Thread Router Reference Device
+    # - ED_1 - Thread Reference Device, End Device (e.g. FED/REED) role
+    # - Eth_1 - Adjacent Infrastructure Link Linux Reference Device on AIL
+    #   - Has HTTP web server with resource ‘/test2.html’.
+    # - Eth_2 - Adjacent Infrastructure Link SPIFF Reference Device on AIL
+    #   - Has HTTP web server on simulated-internet with resource ‘/testv4.html’
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_1 = pv.vars['BR_1']
+    ROUTER_1 = pv.vars['ROUTER_1']
+    ED_1 = pv.vars['ED_1']
+    ETH_2_ADDR = pv.vars['ETH_2_ADDR']
+    INTERNET_SERVER_ADDR = pv.vars['INTERNET_SERVER_ADDR']
+    LOCAL_SERVER_ADDR = pv.vars['LOCAL_SERVER_ADDR']
+    NAT64_PREFIX = pv.vars['NAT64_PREFIX']
+
+    # Step 1
+    # - Device: Eth_1, Eth_2
+    # - Description (PIC-10.4): Enable Harness configures Eth_1 with IPv4 address 192.168.1.4.
+    # - Pass Criteria: N/A
+    print("Step 1: Enable Eth_1 and Eth_2")
+
+    # Step 2
+    # - Device: Eth_2
+    # - Description (PIC-10.4): Harness instructs device to: Enable DHCP IPv4 server.
+    # - Pass Criteria: N/A
+    print("Step 2: Eth_2 default configuration")
+
+    # Step 3
+    # - Device: BR_1 (DUT), Router_1, ED_1
+    # - Description (PIC-10.4): Enable
+    # - Pass Criteria: N/A
+    print("Step 3: Enable BR_1, Router_1, ED_1")
+
+    # Step 4
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.4): Automatically configures an IPv4 address on the AIL; and identifies the IPv4 gateway
+    #   on Eth_2. Automatically configures a NAT64 prefix in the Thread Network Data. Note: the check for length 96
+    #   is not due to it being required by, but because the current test script is only designed to handle this
+    #   case. Other cases are TBD. See note in Section 10.4.1 above.
+    # - Pass Criteria:
+    #   - The DUT MUST add one NAT64 prefix in the Thread Network Data, which is a route entry published as follows:
+    #     - Prefix TLV
+    #       - Prefix Length field MUST be 96.
+    print("Step 4: BR_1 configures IPv4 address and NAT64 prefix")
+    nat64_prefix_pattern = Ipv6Addr(NAT64_PREFIX.split('/')[0])[:12]
+    pkts.filter_wpan_src64(BR_1). \
+        filter_mle_cmd(consts.MLE_DATA_RESPONSE). \
+        filter(lambda p: any(pre.startswith(nat64_prefix_pattern) for pre in p.thread_nwd.tlv.prefix)). \
+        must_next()
+
+    # Step 5
+    # - Device: ED_1
+    # - Description (PIC-10.4): Harness instructs device to perform DNS query QType=ANY, name “threadv4.org”.
+    #   Automatically, the DNS query gets routed to the DUT.
+    # - Pass Criteria: N/A
+    print("Step 5: ED_1 performs DNS query for threadv4.org")
+    dns_query = pkts.filter(lambda p: 'dns' in p.layer_names). \
+        filter(lambda p: any(name.rstrip('.') == "threadv4.org" for name in verify_utils.as_list(p.dns.qry.name))). \
+        filter(lambda p: any(t == consts.DNS_TYPE_ANY for t in verify_utils.as_list(p.dns.qry.type))). \
+        must_next()
+
+    # Step 6
+    # - Device: BR_1 (DUT)
+    # - Description (PIC-10.4): Automatically processes the DNS query by requesting upstream to the Eth_2 DNS server.
+    #   Then, it responds back with the answer to ED_1.
+    # - Pass Criteria: N/A
+    print("Step 6: BR_1 processes DNS query upstream")
+    pkts.filter_ipv6_dst(ETH_2_ADDR). \
+        filter(lambda p: 'dns' in p.layer_names). \
+        filter(lambda p: any(name.rstrip('.') == "threadv4.org" for name in verify_utils.as_list(p.dns.qry.name))). \
+        filter(lambda p: any(t == consts.DNS_TYPE_ANY for t in verify_utils.as_list(p.dns.qry.type))). \
+        must_next()
+
+    # Step 7
+    # - Device: ED_1
+    # - Description (PIC-10.4): Successfully receives DNS query result.
+    # - Pass Criteria:
+    #   - ED_1 MUST receive DNS query answer from DUT:
+    #     - threadv4.org A 38.106.217.165
+    print("Step 7: ED_1 receives DNS result")
+    # Verify the response is received
+    pkts.filter(lambda p: 'dns' in p.layer_names). \
+        filter(lambda p: any(name.rstrip('.') == "threadv4.org" for name in verify_utils.as_list(p.dns.resp.name))). \
+        must_next()
+
+    # Step 8
+    # - Device: ED_1
+    # - Description (PIC-10.4): Harness instructs device to send ICMPv6 ping request to internet server, using
+    #   synthesized IPv6 address: <NAT64-prefix>:266a:d9a5 Request is routed via the DUT to the Eth_2 simulated
+    #   network. The DUT translates the above IPv6 address into the IPv4 destination address 38.106.217.165.
+    # - Pass Criteria:
+    #   - ED_1 MUST receive ICMPv6 ping response from simulated server.
+    print("Step 8: ED_1 pings internet server")
+    ping_req = pkts.filter_ipv6_dst(INTERNET_SERVER_ADDR). \
+        filter_ping_request(). \
+        must_next()
+    # ED_1 receives response
+    pkts.filter_ipv6_src(INTERNET_SERVER_ADDR). \
+        filter_ping_reply(identifier=ping_req.icmpv6.echo.identifier). \
+        must_next()
+
+    # Step 8a
+    # - Device: ED_1
+    # - Description (PIC-10.4): Repeats same using a UDP ping.
+    # - Pass Criteria:
+    #   - ED_1 MUST receive UDP ping response from simulated server.
+    print("Step 8a: ED_1 sends UDP ping to internet server")
+    pkts.filter_ipv6_dst(INTERNET_SERVER_ADDR). \
+        filter(lambda p: 'udp' in p.layer_names). \
+        must_next()
+
+    # Step 9
+    # - Device: ED_1
+    # - Description (PIC-10.4): Harness instructs device to send HTTP GET request to server. If ED_1 is a Linux
+    #   device, it may use 'curl' to save the file: curl -o testv4.html http://threadv4.org/testv4.html In case ED_1
+    #   is a non-Linux Thread CLI device, it can use the OT CLI commands to send the HTTP GET request: tcp init
+    #   tcp connect <NAT64-prefix>:266a:d9a5 80 tcp send -x 474554202F74... tcp sendend tcp deinit
+    # - Pass Criteria:
+    #   - In case Linux 'curl' was used: File testv4.html MUST equal the testv4.html file as stored on the simulated
+    #     server on Eth_2.
+    #   - In case OT CLI was used: Output on the CLI MUST be the HTML file testv4.html as stored on the simulator
+    #     server on Eth_2.
+    print("Step 9: ED_1 sends HTTP GET to internet server")
+    pkts.filter_ipv6_dst(INTERNET_SERVER_ADDR). \
+        filter(lambda p: 'tcp' in p.layer_names). \
+        must_next()
+
+    # Step 10
+    # - Device: ED_1
+    # - Description (PIC-10.4): Harness instructs device to send ping request to local IPv4 server Eth_1 which has
+    #   IPv4 destination address 192.168.1.4; using synthesized IPv6 address: <NAT64-prefix>:c0a8:0104 Automatically,
+    #   it is routed via BR_1 DUT to Eth_1.
+    # - Pass Criteria:
+    #   - ED_1 MUST receive ping response from Eth_1.
+    print("Step 10: ED_1 pings local server Eth_1")
+    ping_req = pkts.filter_ipv6_dst(LOCAL_SERVER_ADDR). \
+        filter_ping_request(). \
+        must_next()
+    # ED_1 receives response
+    pkts.filter_ipv6_src(LOCAL_SERVER_ADDR). \
+        filter_ping_reply(identifier=ping_req.icmpv6.echo.identifier). \
+        must_next()
+
+    # Step 11
+    # - Device: ED_1
+    # - Description (PIC-10.4): Harness instructs device to send HTTP GET request to local server Eth_1. In case ED_1
+    #   is a Linux device, it can use 'curl' to save the file locally: curl -o test2.html http:///test2.html In case
+    #   ED_1 is a non-Linux Thread CLI device, it can use the OT CLI commands to send the HTTP GET request: tcp init
+    #   tcp connect <NAT64-prefix>:c0a8:0104 80 tcp send -x 474554202F... tcp sendend tcp deinit
+    # - Pass Criteria:
+    #   - In case Linux 'curl' was used: File test2.html MUST equal the test2.html file as stored on the Eth_1 server.
+    #   - In case OT CLI was used: Output on the CLI MUST be the HTML file test2.html as stored on the server on Eth_1.
+    print("Step 11: ED_1 sends HTTP GET to local server Eth_1")
+    pkts.filter_ipv6_dst(LOCAL_SERVER_ADDR). \
+        filter(lambda p: 'tcp' in p.layer_names). \
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_TREL_TC_1.py b/tests/nexus/verify_1_4_TREL_TC_1.py
new file mode 100644
index 0000000..3523f1b
--- /dev/null
+++ b/tests/nexus/verify_1_4_TREL_TC_1.py
@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 8.1. [1.4] [CERT] Attach and connectivity (unicast/broadcast) between multi-radio and single-radio devices
+    #
+    # 8.1.1. Purpose
+    # To test the following:
+    #   1. Attaching Thread Devices with different radio links.
+    #   2. Verifies the detection of radio link types supported by neighbors/children.
+    #   3. Verifies connectivity between nodes (unicast and broadcast frame exchange with multi-radio support).
+    #
+    # Spec Reference           | V1.1 Section | V1.3.0 Section
+    # -------------------------|--------------|---------------
+    # TREL / Multi-radio Links | N/A          | 8.1
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_DUT = pv.vars['BR_DUT']
+    ROUTER_1 = pv.vars['ROUTER_1']
+    ROUTER_2 = pv.vars['ROUTER_2']
+
+    BR_DUT_RLOC16 = pv.vars['BR_DUT_RLOC16']
+    ROUTER_1_RLOC16 = pv.vars['ROUTER_1_RLOC16']
+    ROUTER_2_RLOC16 = pv.vars['ROUTER_2_RLOC16']
+
+    # Step 1
+    # - Device: BR (DUT), Router_1, Router_2
+    # - Description (TREL-8.1): Form the topology. Wait for Router_1 and Router_2 to become routers. Note: Router_1
+    #   and Router_2 MUST NOT be able to communicate directly and not be in each other’s neighbor table (e.g. this
+    #   may be realized by using the allow/deny list mechanism).
+    # - Pass Criteria:
+    #   - Verify that topology is formed.
+    #   - The DUT MUST be the network Leader.
+    #   - Router_1 MUST only see Leader as its immediate neighbor (Router_2 MUST not be present in Router_1’s
+    #     neighbor table).
+    #   - Router_2 MUST only see Leader as its immediate neighbor and not Router_1.
+    print("Step 1: Form the topology")
+    # Verify DUT is leader
+    pkts.filter_wpan_src64(BR_DUT).\
+        filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
+        filter(lambda p: p.mle.tlv.leader_data.router_id == p.mle.tlv.source_addr >> 10).\
+        must_next()
+
+    # Verify Router_1 and Router_2 do not communicate directly on 15.4
+    pkts.filter_wpan_src64(ROUTER_1).filter_wpan_dst64(ROUTER_2).must_not_next()
+    pkts.filter_wpan_src64(ROUTER_2).filter_wpan_dst64(ROUTER_1).must_not_next()
+
+    # Step 2
+    # - Device: Router_1
+    # - Description (TREL-8.1): Harness instructs device to report its neighbor multi-radio info (which indicates
+    #   which radio links are detected per neighbor/parent)
+    # - Pass Criteria:
+    #   - Router_1 MUST have correctly detected that the DUT supports both TREL and 15.4 radios (multi-radio neighbor
+    #     info).
+    #   - The DUT MUST send TREL frames using the correct format
+    print("Step 2: Router_1 reports its neighbor multi-radio info")
+    # Verified by C++ code internally
+
+    # Step 3: Router_1 pings the DUT using link-local address
+    print("Step 3: Router_1 pings the DUT using link-local address")
+    # Verify ping request from ROUTER_1 to BR_DUT over TREL
+    # We can't easily decode ICMPv6 inside TREL if tshark fails, so we filter by UDP port and size.
+    pkts.filter_eth_src(pv.vars['ROUTER_1_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.udp).\
+        filter(lambda p: p.udp.dstport == pv.vars['BR_DUT_TREL_PORT']).\
+        filter(lambda p: int(p.udp.length) > 500).\
+        must_next()
+
+    _idx_req = pkts.index
+
+    # Verify ping reply from BR_DUT to ROUTER_1 over TREL
+    pkts.filter_eth_src(pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['ROUTER_1_ETH']).\
+        filter(lambda p: p.udp).\
+        filter(lambda p: p.udp.dstport == pv.vars['ROUTER_1_TREL_PORT']).\
+        filter(lambda p: int(p.udp.length) > 500).\
+        must_next()
+
+    _idx_reply = pkts.index
+
+    # Verify that there are no ping replies for this exchange on 15.4
+    pkts.range(_idx_req, _idx_reply).\
+        filter_ping_reply().\
+        filter(lambda p: p.wpan).\
+        must_not_next()
+
+    # Step 4: Router_2 pings the DUT using link-local address
+    print("Step 4: Router_2 pings the DUT using link-local address")
+    # Router_2 is 15.4 only, so ping should be over 15.4
+    _pkt = pkts.filter_ping_request().\
+        filter_ipv6_src(pv.vars['ROUTER_2_LLA']).\
+        filter_ipv6_dst(pv.vars['BR_DUT_LLA']).\
+        filter(lambda p: p.wpan).\
+        must_next()
+
+    pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+        filter_ipv6_src(pv.vars['BR_DUT_LLA']).\
+        filter_ipv6_dst(pv.vars['ROUTER_2_LLA']).\
+        filter(lambda p: p.wpan).\
+        must_next()
+
+    # Step 5: Router_1 pings Router_2 using mesh-local EID
+    print("Step 5: Router_1 pings Router_2 using mesh-local EID")
+    # Path: Router_1 -> BR_DUT (TREL) -> Router_2 (15.4)
+
+    # 1. Check forwarded request on 15.4 to get identifier
+    _pkt_154_req = pkts.filter_ping_request().\
+        filter_ipv6_src(pv.vars['ROUTER_1_MLEID']).\
+        filter_ipv6_dst(pv.vars['ROUTER_2_MLEID']).\
+        filter_wpan_src16_dst16(BR_DUT_RLOC16, ROUTER_2_RLOC16).\
+        filter(lambda p: p.wpan).\
+        must_next()
+
+    _identifier = _pkt_154_req.icmpv6.echo.identifier
+
+    # 2. Check original request on TREL (before the 15.4 one)
+    pkts.range((0, 0), pkts.index).\
+        filter_eth_src(pv.vars['ROUTER_1_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.udp).\
+        filter(lambda p: p.udp.dstport == pv.vars['BR_DUT_TREL_PORT']).\
+        must_next()
+
+    # 3. Check reply from Router_2 back to BR_DUT over 15.4
+    _pkt_154_reply = pkts.filter_ping_reply(identifier=_identifier).\
+        filter_ipv6_src(pv.vars['ROUTER_2_MLEID']).\
+        filter_ipv6_dst(pv.vars['ROUTER_1_MLEID']).\
+        filter_wpan_src16_dst16(ROUTER_2_RLOC16, BR_DUT_RLOC16).\
+        filter(lambda p: p.wpan).\
+        must_next()
+
+    # 4. Check forwarded reply on TREL (after the 15.4 one)
+    pkts.range(pkts.index).\
+        filter_eth_src(pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['ROUTER_1_ETH']).\
+        filter(lambda p: p.udp).\
+        filter(lambda p: p.udp.dstport == pv.vars['ROUTER_1_TREL_PORT']).\
+        must_next()
+
+    # Step 6: Router_2 pings Router_1 using mesh-local EID
+    print("Step 6: Router_2 pings Router_1 using mesh-local EID")
+    # Path: Router_2 -> BR_DUT (15.4) -> Router_1 (TREL)
+
+    # 1. Check request from Router_2 to BR_DUT over 15.4
+    _pkt_154_req = pkts.filter_ping_request().\
+        filter_ipv6_src(pv.vars['ROUTER_2_MLEID']).\
+        filter_ipv6_dst(pv.vars['ROUTER_1_MLEID']).\
+        filter_wpan_src16_dst16(ROUTER_2_RLOC16, BR_DUT_RLOC16).\
+        filter(lambda p: p.wpan).\
+        must_next()
+
+    _identifier = _pkt_154_req.icmpv6.echo.identifier
+
+    # 2. Check forwarded request on TREL
+    _idx_154_req = pkts.index
+    pkts.range(_idx_154_req).\
+        filter_eth_src(pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['ROUTER_1_ETH']).\
+        filter(lambda p: p.udp).\
+        filter(lambda p: p.udp.dstport == pv.vars['ROUTER_1_TREL_PORT']).\
+        must_next()
+
+    # 3. Check reply from Router_1 back to BR_DUT over TREL
+    _idx_trel_req = pkts.index
+    pkts.range(_idx_trel_req).\
+        filter_eth_src(pv.vars['ROUTER_1_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.udp).\
+        filter(lambda p: p.udp.dstport == pv.vars['BR_DUT_TREL_PORT']).\
+        must_next()
+
+    # 4. Check forwarded reply on 15.4
+    _idx_trel_reply = pkts.index
+    pkts.range(_idx_trel_reply).\
+        filter_ping_reply(identifier=_identifier).\
+        filter_ipv6_src(pv.vars['ROUTER_1_MLEID']).\
+        filter_ipv6_dst(pv.vars['ROUTER_2_MLEID']).\
+        filter_wpan_src16_dst16(BR_DUT_RLOC16, ROUTER_2_RLOC16).\
+        filter(lambda p: p.wpan).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_TREL_TC_2.py b/tests/nexus/verify_1_4_TREL_TC_2.py
new file mode 100644
index 0000000..2918f96
--- /dev/null
+++ b/tests/nexus/verify_1_4_TREL_TC_2.py
@@ -0,0 +1,495 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 8.2. [1.4] [CERT] Multi-hop routing with device (in the path) with different radio types and MTUs
+    #
+    # 8.2.1. Purpose
+    # This test covers the use of 6LoWPAN “Mesh Header” messages (messages sent over multi-hop) when
+    # underlying routes (in the path) can support different radio link types with different frame MTU sizes.
+    # Different types of devices (15.4, TREL, Router, End Device) are connected to the DUT.
+    #
+    # Spec Reference           | V1.1 Section | V1.3.0 Section
+    # -------------------------|--------------|---------------
+    # TREL / Multi-radio Links | N/A          | 8.2
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    ROUTER_1 = pv.vars['ROUTER_1']  # DUT
+    LEADER = pv.vars['LEADER']
+    ROUTER_2 = pv.vars['ROUTER_2']
+    ED_1 = pv.vars['ED_1']
+    ROUTER_3 = pv.vars['ROUTER_3']
+    ROUTER_4 = pv.vars['ROUTER_4']
+
+    ROUTER_1_RLOC16 = pv.vars['ROUTER_1_RLOC16']
+    LEADER_RLOC16 = pv.vars['LEADER_RLOC16']
+    ROUTER_2_RLOC16 = pv.vars['ROUTER_2_RLOC16']
+    ROUTER_3_RLOC16 = pv.vars['ROUTER_3_RLOC16']
+    ROUTER_4_RLOC16 = pv.vars['ROUTER_4_RLOC16']
+    ED_1_RLOC16 = pv.vars['ED_1_RLOC16']
+
+    ROUTER_1_ETH = pv.vars['ROUTER_1_ETH']
+    LEADER_ETH = pv.vars['LEADER_ETH']
+    ROUTER_4_ETH = pv.vars['ROUTER_4_ETH']
+
+    ROUTER_1_TREL_PORT = pv.vars['ROUTER_1_TREL_PORT']
+    LEADER_TREL_PORT = pv.vars['LEADER_TREL_PORT']
+    ROUTER_4_TREL_PORT = pv.vars['ROUTER_4_TREL_PORT']
+
+    ROUTER_1_MLEID = pv.vars['ROUTER_1_MLEID']
+    LEADER_MLEID = pv.vars['LEADER_MLEID']
+    ROUTER_2_MLEID = pv.vars['ROUTER_2_MLEID']
+    ED_1_MLEID = pv.vars['ED_1_MLEID']
+    ROUTER_3_MLEID = pv.vars['ROUTER_3_MLEID']
+    ROUTER_4_MLEID = pv.vars['ROUTER_4_MLEID']
+
+    # Step 1
+    # - Device: Leader, Router_1 (DUT), Router_2, ED_1, Router_3, Router_4
+    # - Description (TREL-8.2): Form the topology. Wait for Router_1, Router_2, Router_3 and Router_4 to become
+    #   routers. Note: any devices not connected by line in the topology figure MUST NOT be able to communicate
+    #   directly via a Thread Link or TREL link and not be in each other’s neighbor table (e.g. this may be realized
+    #   by using the allow/deny list mechanism). ED_1 MUST be configured as a non-sleepy MTD child (mode `rn`). Note:
+    #   ED_1 MUST attach to the Router_1 (DUT) as its parent and not the other routers (e.g., can be realized by using
+    #   denylist on ED_1). Note: The requirement for ED_1 to attach as a non-sleepy MTD child to Router_1 (DUT) is
+    #   important to ensure messages from ED_1 are sent to its parent as is without mesh header encapsulation, to
+    #   then be forwarded within the mesh and that the fragmentation and adding of 6LoWPAN “mesh header” is performed
+    #   by Router_1 (DUT) - which is being verified in the next step.
+    print("Step 1: Form the topology")
+    pkts.copy().\
+      filter_wpan_src64(ROUTER_2).\
+      filter_wpan_dst64(ROUTER_1).\
+      must_not_next()
+    pkts.copy().\
+      filter_wpan_src64(ROUTER_1).\
+      filter_wpan_dst64(ROUTER_2).\
+      must_not_next()
+
+    # Step 2
+    # - Device: ED_1
+    # - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more to
+    #   destination Router_2 using the ML-EID address of Router_2 as the destination address. Note: this verifies the
+    #   DUT behavior of forwarding 6LoWPAN fragmented frames from a Child (incoming over 15.4) over multiple hops.
+    # - Pass Criteria:
+    #   - ED_1 MUST successfully receive a ping reply from Router_2, routed via DUT.
+    #   - TREL UDP frames MUST be used for ping/ping-reply transport between DUT and Leader, and MUST be correct TREL
+    #     frames.
+    print("Step 2: ED_1 pings Router_2 ML-EID")
+    # Path: ED_1 -(15.4)-> ROUTER_1 -(TREL)-> LEADER -(15.4)-> ROUTER_2
+    # 1. Request from ED_1 to ROUTER_1 over 15.4
+    _pkt_154_req = pkts.copy().\
+      filter_ping_request().\
+      filter_ipv6_src_dst(ED_1_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(ED_1_RLOC16, ROUTER_1_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    _identifier = _pkt_154_req.icmpv6.echo.identifier
+
+    # 2. Forwarded request on TREL (ROUTER_1 to LEADER)
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_1_ETH).\
+      filter(lambda p: p.eth.dst == LEADER_ETH).\
+      filter(lambda p: p.udp.dstport == LEADER_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_1 and p.trel.destination_addr == LEADER).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_1_RLOC16 and p.trel.wpan.dst16 == LEADER_RLOC16).\
+      must_next()
+
+    # 3. Forwarded request on 15.4 (LEADER to ROUTER_2)
+    pkts.range(pkts.index).\
+      filter_ping_request(identifier=_identifier).\
+      filter_ipv6_src_dst(ED_1_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(LEADER_RLOC16, ROUTER_2_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 4. Reply from Router_2 to Leader over 15.4
+    pkts.copy().\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ED_1_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_2_RLOC16, LEADER_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 5. Forwarded reply on TREL (LEADER to ROUTER_1)
+    pkts.range(pkts.index).\
+      filter_eth_src(LEADER_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_1_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_1_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == LEADER and p.trel.destination_addr == ROUTER_1).\
+      filter(lambda p: p.trel.wpan.src16 == LEADER_RLOC16 and p.trel.wpan.dst16 == ROUTER_1_RLOC16).\
+      must_next()
+
+    # 6. Forwarded reply on 15.4 (ROUTER_1 to ED_1)
+    pkts.range(pkts.index).\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ED_1_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_1_RLOC16, ED_1_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # Step 3
+    # - Device: Router_2
+    # - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+    #   Router_2 to ED_1 using the ML-EID address of ED_1 as the destination address.
+    # - Pass Criteria:
+    #   - Router_2 MUST successfully receive a ping reply from ED_1, routed via the DUT.
+    #   - TREL UDP frames MUST be used for ping/ping-reply transport between DUT and Leader, and MUST be correct TREL
+    #     frames.
+    print("Step 3: Router_2 pings ED_1 ML-EID")
+    # Path: ROUTER_2 -(15.4)-> LEADER -(TREL)-> ROUTER_1 -(15.4)-> ED_1
+    # 1. Request from ROUTER_2 to LEADER over 15.4
+    _pkt_154_req = pkts.copy().\
+      filter_ping_request().\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ED_1_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_2_RLOC16, LEADER_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    _identifier = _pkt_154_req.icmpv6.echo.identifier
+
+    # 2. Forwarded request on TREL (LEADER to ROUTER_1)
+    pkts.range(pkts.index).\
+      filter_eth_src(LEADER_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_1_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_1_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == LEADER and p.trel.destination_addr == ROUTER_1).\
+      filter(lambda p: p.trel.wpan.src16 == LEADER_RLOC16 and p.trel.wpan.dst16 == ROUTER_1_RLOC16).\
+      must_next()
+
+    # 3. Forwarded request on 15.4 (ROUTER_1 to ED_1)
+    pkts.range(pkts.index).\
+      filter_ping_request(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ED_1_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_1_RLOC16, ED_1_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 4. Reply from ED_1 to ROUTER_1 over 15.4
+    pkts.copy().\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ED_1_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(ED_1_RLOC16, ROUTER_1_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 5. Forwarded reply on TREL (ROUTER_1 to LEADER)
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_1_ETH).\
+      filter(lambda p: p.eth.dst == LEADER_ETH).\
+      filter(lambda p: p.udp.dstport == LEADER_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_1 and p.trel.destination_addr == LEADER).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_1_RLOC16 and p.trel.wpan.dst16 == LEADER_RLOC16).\
+      must_next()
+
+    # 6. Forwarded reply on 15.4 (LEADER to ROUTER_2)
+    pkts.range(pkts.index).\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ED_1_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(LEADER_RLOC16, ROUTER_2_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # Step 4
+    # - Device: Router_4
+    # - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+    #   Router_4 to destination Router_2 using the ML-EID address of Router_2 as the destination address. Warning: see
+    #   TESTPLAN-626 for an open issue (TBD) in this step. Note: this intends to verify the DUT behavior of
+    #   forwarding 6LoWPAN “Mesh Header” messages, incoming over TREL, over multi-hop routes.
+    # - Pass Criteria:
+    #   - Router_4 MUST successfully receive a ping reply from Router_2, routed via the DUT.
+    #   - TREL UDP frames MUST be used for ping/ping-reply transport between DUT and Leader, and MUST be correct TREL
+    #     frames.
+    #   - TREL UDP frames MUST be used for ping/ping-reply transport between the DUT and Router_4, and MUST be correct
+    #     TREL frames.
+    print("Step 4: Router_4 pings Router_2 ML-EID")
+    # Path: ROUTER_4 -(TREL)-> ROUTER_1 -(TREL)-> LEADER -(15.4)-> ROUTER_2
+    # 1. Request from ROUTER_4 to ROUTER_1 over TREL
+    pkts.copy().\
+      filter_eth_src(ROUTER_4_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_1_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_1_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_4 and p.trel.destination_addr == ROUTER_1).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_4_RLOC16 and p.trel.wpan.dst16 == ROUTER_1_RLOC16).\
+      must_next()
+
+    # 2. Forwarded request from ROUTER_1 to LEADER over TREL
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_1_ETH).\
+      filter(lambda p: p.eth.dst == LEADER_ETH).\
+      filter(lambda p: p.udp.dstport == LEADER_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_1 and p.trel.destination_addr == LEADER).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_1_RLOC16 and p.trel.wpan.dst16 == LEADER_RLOC16).\
+      must_next()
+
+    # 3. Forwarded request on 15.4 (LEADER to ROUTER_2)
+    _pkt_154_req = pkts.range(pkts.index).\
+      filter_ping_request().\
+      filter_ipv6_src_dst(ROUTER_4_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(LEADER_RLOC16, ROUTER_2_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    _identifier = _pkt_154_req.icmpv6.echo.identifier
+
+    # 4. Reply from Router_2 to Leader over 15.4
+    pkts.copy().\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ROUTER_4_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_2_RLOC16, LEADER_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 5. Forwarded reply on TREL (LEADER to ROUTER_1)
+    pkts.range(pkts.index).\
+      filter_eth_src(LEADER_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_1_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_1_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == LEADER and p.trel.destination_addr == ROUTER_1).\
+      filter(lambda p: p.trel.wpan.src16 == LEADER_RLOC16 and p.trel.wpan.dst16 == ROUTER_1_RLOC16).\
+      must_next()
+
+    # 6. Forwarded reply on TREL (ROUTER_1 to ROUTER_4)
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_1_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_4_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_4_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_1 and p.trel.destination_addr == ROUTER_4).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_1_RLOC16 and p.trel.wpan.dst16 == ROUTER_4_RLOC16).\
+      must_next()
+
+    # Step 5
+    # - Device: Router_2
+    # - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+    #   Router_2 to Router_4 using the ML-EID address of Router_4 as the destination address.
+    # - Pass Criteria:
+    #   - Router_2 MUST successfully receive a ping reply from Router_4, routed via the DUT.
+    #   - TREL UDP frames MUST be used for ping/ping-reply transport between DUT and Leader, and MUST be correct TREL
+    #     frames.
+    #   - TREL UDP frames MUST be used for ping/ping-reply transport between the DUT and Router_4, and MUST be correct
+    #     TREL frames.
+    print("Step 5: Router_2 pings Router_4 ML-EID")
+    # Path: ROUTER_2 -(15.4)-> LEADER -(TREL)-> ROUTER_1 -(TREL)-> ROUTER_4
+    # 1. Request from ROUTER_2 to LEADER over 15.4
+    _pkt_154_req = pkts.copy().\
+      filter_ping_request().\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ROUTER_4_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_2_RLOC16, LEADER_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    _identifier = _pkt_154_req.icmpv6.echo.identifier
+
+    # 2. Forwarded request from LEADER to ROUTER_1 over TREL
+    pkts.range(pkts.index).\
+      filter_eth_src(LEADER_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_1_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_1_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == LEADER and p.trel.destination_addr == ROUTER_1).\
+      filter(lambda p: p.trel.wpan.src16 == LEADER_RLOC16 and p.trel.wpan.dst16 == ROUTER_1_RLOC16).\
+      must_next()
+
+    # 3. Forwarded request from ROUTER_1 to ROUTER_4 over TREL
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_1_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_4_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_4_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_1 and p.trel.destination_addr == ROUTER_4).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_1_RLOC16 and p.trel.wpan.dst16 == ROUTER_4_RLOC16).\
+      must_next()
+
+    # 4. Reply on TREL (ROUTER_4 to ROUTER_1)
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_4_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_1_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_1_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_4 and p.trel.destination_addr == ROUTER_1).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_4_RLOC16 and p.trel.wpan.dst16 == ROUTER_1_RLOC16).\
+      must_next()
+
+    # 5. Forwarded reply on TREL (ROUTER_1 to LEADER)
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_1_ETH).\
+      filter(lambda p: p.eth.dst == LEADER_ETH).\
+      filter(lambda p: p.udp.dstport == LEADER_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_1 and p.trel.destination_addr == LEADER).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_1_RLOC16 and p.trel.wpan.dst16 == LEADER_RLOC16).\
+      must_next()
+
+    # 6. Forwarded reply on 15.4 (LEADER to ROUTER_2)
+    pkts.range(pkts.index).\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_4_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(LEADER_RLOC16, ROUTER_2_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # Step 6
+    # - Device: Router_3
+    # - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+    #   Router_3 to destination Router_2 using the ML-EID address of Router_2 as the destination address. Note: this
+    #   verifies the DUT behavior of forwarding 6LoWPAN “Mesh Header” messages, incoming over 15.4, over multi-hop
+    #   routes.
+    # - Pass Criteria:
+    #   - Router_3 MUST successfully receive a ping reply from Router_2, routed via the DUT.
+    #   - TREL UDP frames MUST be used for ping/ping-reply transport between the DUT and Leader, and MUST be correct
+    #     TREL frames.
+    print("Step 6: Router_3 pings Router_2 ML-EID")
+    # Path: ROUTER_3 -(15.4)-> ROUTER_1 -(TREL)-> LEADER -(15.4)-> ROUTER_2
+    # 1. Request from ROUTER_3 to ROUTER_1 over 15.4
+    _pkt_154_req = pkts.copy().\
+      filter_ping_request().\
+      filter_ipv6_src_dst(ROUTER_3_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_3_RLOC16, ROUTER_1_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    _identifier = _pkt_154_req.icmpv6.echo.identifier
+
+    # 2. Check forwarded request on TREL from ROUTER_1 to LEADER
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_1_ETH).\
+      filter(lambda p: p.eth.dst == LEADER_ETH).\
+      filter(lambda p: p.udp.dstport == LEADER_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_1 and p.trel.destination_addr == LEADER).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_1_RLOC16 and p.trel.wpan.dst16 == LEADER_RLOC16).\
+      must_next()
+
+    # 3. Forwarded request on 15.4 (LEADER to ROUTER_2)
+    pkts.range(pkts.index).\
+      filter_ping_request(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_3_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(LEADER_RLOC16, ROUTER_2_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 4. Reply from Router_2 to Leader over 15.4
+    pkts.copy().\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ROUTER_3_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_2_RLOC16, LEADER_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 5. Check forwarded reply on TREL from LEADER to ROUTER_1
+    pkts.range(pkts.index).\
+      filter_eth_src(LEADER_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_1_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_1_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == LEADER and p.trel.destination_addr == ROUTER_1).\
+      filter(lambda p: p.trel.wpan.src16 == LEADER_RLOC16 and p.trel.wpan.dst16 == ROUTER_1_RLOC16).\
+      must_next()
+
+    # 6. Forwarded reply on 15.4 (ROUTER_1 to ROUTER_3)
+    pkts.range(pkts.index).\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ROUTER_3_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_1_RLOC16, ROUTER_3_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # Step 7
+    # - Device: Router_2
+    # - Description (TREL-8.2): Harness instructs device to send a ping of payload size of 500 bytes or more from
+    #   Router_2 to Router_3 using the ML-EID address of Router_3 as the destination address.
+    # - Pass Criteria:
+    #   - Router_2 MUST successfully receive a ping reply from Router_3, routed via the DUT.
+    #   - TREL UDP frames MUST be used for ping/ping-reply transport between the DUT and Leader, and MUST be correct
+    #     TREL frames.
+    print("Step 7: Router_2 pings Router_3 ML-EID")
+    # Path: ROUTER_2 -(15.4)-> LEADER -(TREL)-> ROUTER_1 -(15.4)-> ROUTER_3
+    # 1. Request from ROUTER_2 to LEADER over 15.4
+    _pkt_154_req = pkts.copy().\
+      filter_ping_request().\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ROUTER_3_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_2_RLOC16, LEADER_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    _identifier = _pkt_154_req.icmpv6.echo.identifier
+
+    # 2. Check forwarded request on TREL from LEADER to ROUTER_1
+    pkts.range(pkts.index).\
+      filter_eth_src(LEADER_ETH).\
+      filter(lambda p: p.eth.dst == ROUTER_1_ETH).\
+      filter(lambda p: p.udp.dstport == ROUTER_1_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == LEADER and p.trel.destination_addr == ROUTER_1).\
+      filter(lambda p: p.trel.wpan.src16 == LEADER_RLOC16 and p.trel.wpan.dst16 == ROUTER_1_RLOC16).\
+      must_next()
+
+    # 3. Forwarded request on 15.4 (ROUTER_1 to ROUTER_3)
+    pkts.range(pkts.index).\
+      filter_ping_request(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_2_MLEID, ROUTER_3_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_1_RLOC16, ROUTER_3_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 4. Reply from Router_3 to Router_1 over 15.4
+    pkts.copy().\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_3_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(ROUTER_3_RLOC16, ROUTER_1_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    # 5. Check forwarded reply on TREL from ROUTER_1 to LEADER
+    pkts.range(pkts.index).\
+      filter_eth_src(ROUTER_1_ETH).\
+      filter(lambda p: p.eth.dst == LEADER_ETH).\
+      filter(lambda p: p.udp.dstport == LEADER_TREL_PORT).\
+      filter(lambda p: p.trel.source_addr == ROUTER_1 and p.trel.destination_addr == LEADER).\
+      filter(lambda p: p.trel.wpan.src16 == ROUTER_1_RLOC16 and p.trel.wpan.dst16 == LEADER_RLOC16).\
+      must_next()
+
+    # 6. Forwarded reply on 15.4 (LEADER to ROUTER_2)
+    pkts.range(pkts.index).\
+      filter_ping_reply(identifier=_identifier).\
+      filter_ipv6_src_dst(ROUTER_3_MLEID, ROUTER_2_MLEID).\
+      filter_wpan_src16_dst16(LEADER_RLOC16, ROUTER_2_RLOC16).\
+      filter(lambda p: p.wpan).\
+      must_next()
+
+    print("All verification steps PASSED")
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_TREL_TC_3.py b/tests/nexus/verify_1_4_TREL_TC_3.py
new file mode 100644
index 0000000..5b717ad
--- /dev/null
+++ b/tests/nexus/verify_1_4_TREL_TC_3.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 8.3. [1.4] [CERT] Radio Link (Re)discovery using Probe Mechanism
+    #
+    # 8.3.1. Purpose
+    # This test covers the behavior of the device after TREL connection is temporarily disabled and rediscovery of TREL
+    #   radio using the multi-radio Probe mechanism.
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # TREL Radio Links | N/A          | 8.3
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_DUT = pv.vars['BR_DUT']
+    ROUTER = pv.vars['ROUTER']
+    ED = pv.vars['ED']
+
+    # - Step 1
+    #   - Device: BR (DUT), Router
+    #   - Description (TREL-8.3): Form the topology. Wait for Router to become Thread router. ED MUST attach to the
+    #     DUT as its parent (e.g. can be realized using allow/deny list).
+    #   - Pass Criteria:
+    #     - Verify that topology is formed.
+    #     - ED MUST attach to the DUT as its parent.
+    print(
+        "Step 1: Form the topology. Wait for Router to become Thread router. ED MUST attach to the DUT as its parent.")
+    # Verify DUT is leader
+    pkts.filter_wpan_src64(BR_DUT).\
+        filter_mle_cmd(consts.MLE_ADVERTISEMENT).\
+        filter(lambda p: p.mle.tlv.leader_data.router_id == p.mle.tlv.source_addr >> 10).\
+        must_next()
+
+    # Verify ED attached to BR
+    pkts.filter_wpan_src64(ED).\
+        filter_mle_cmd(consts.MLE_CHILD_ID_REQUEST).\
+        filter_wpan_dst64(BR_DUT).\
+        must_next()
+
+    # - Step 2
+    #   - Device: ED
+    #   - Description (TREL-8.3): Harness instructs device to send 10 pings to Router using its mesh-local IPv6
+    #     address as the destination. Note: this verifies the increase of TREL radio link preference due to its
+    #     successful use to exchange the ping messages.
+    #   - Pass Criteria:
+    #     - The ED MUST receive ping responses.
+    #     - The Router MUST have correctly detected that the DUT supports both TREL and 15.4 radios (from the
+    #       neighbor table entry info).
+    #     - Also TREL radio link MUST be preferred; that is, the preference value associated with TREL MUST be higher
+    #       than or equal to 15.4 radio for the BR (DUT) entry in the Router’s neighbor table multi-radio info.
+    #     - TREL frames sent by the DUT MUST use a correct TREL frame format.
+    print("Step 2: Harness instructs device to send 10 pings to Router using its mesh-local IPv6 address.")
+    for _ in range(10):
+        _pkt = pkts.filter_ping_request().\
+            filter_ipv6_src(pv.vars['ED_MLEID']).\
+            filter_ipv6_dst(pv.vars['ROUTER_MLEID']).\
+            must_next()
+
+        pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+            filter_ipv6_src(pv.vars['ROUTER_MLEID']).\
+            filter_ipv6_dst(pv.vars['ED_MLEID']).\
+            must_next()
+
+    # Verify TREL frames sent by DUT to Router
+    pkts.filter_eth_src(pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['ROUTER_ETH']).\
+        filter(lambda p: p.udp).\
+        filter(lambda p: p.udp.dstport == pv.vars['ROUTER_TREL_PORT']).\
+        must_next()
+
+    # - Step 3
+    #   - Device: Router
+    #   - Description (TREL-8.3): Harness disables the TREL connectivity on the Router. Note: This may be realized by
+    #     causing a disconnect on the infrastructure link (e.g. if the infra link is Wi-Fi, the Router device can be
+    #     disconnected from Wi-Fi AP). Alternatively this can be realized by specific APIs added on Router device
+    #     for the purpose of testing. Note: this may also be realized using the "trel filter" CLI command. See link.
+    #   - Pass Criteria:
+    #     - N/A
+    print("Step 3: Harness disables the TREL connectivity on the Router.")
+
+    # - Step 4
+    #   - Device: ED
+    #   - Description (TREL-8.3): Harness instructs device to send 10 pings to Router using its mesh-local IPv6
+    #     address as the destination. Note: some of the pings may fail, which is expected behavior. This step tests
+    #     the detection of a disconnect in a TREL link by the DUT.
+    #   - Pass Criteria:
+    #     - N/A
+    print("Step 4: Harness instructs device to send 10 pings to Router using its mesh-local IPv6 address.")
+    # Pings are sent in C++, we just advance the packet cursor
+    for _ in range(10):
+        pkts.filter_ping_request().\
+            filter_ipv6_src(pv.vars['ED_MLEID']).\
+            filter_ipv6_dst(pv.vars['ROUTER_MLEID']).\
+            must_next()
+
+    # - Step 5
+    #   - Device: Router
+    #   - Description (TREL-8.3): Harness instructs device to report its radio link preference from its neighbor
+    #     table.
+    #   - Pass Criteria:
+    #     - The Router MUST report that the DUT supports both TREL and 15.4 radios
+    #     - The TREL radio link preference MUST be set to zero (the DUT is no longer reachable on the TREL radio
+    #       link).
+    print("Step 5: Harness instructs device to report its radio link preference from its neighbor table.")
+
+    # - Step 6
+    #   - Device: ED
+    #   - Description (TREL-8.3): Harness instructs device to send 5 pings to Router using its mesh-local IPv6
+    #     address as the destination. Note: this step verifies that the DUT correctly falls back to using 15.4
+    #     radio on detection of TREL disconnect.
+    #   - Pass Criteria:
+    #     - The ED MUST receive all Ping responses from the Router successfully.
+    print("Step 6: Harness instructs device to send 5 pings to Router using its mesh-local IPv6 address.")
+    for _ in range(5):
+        _pkt = pkts.filter_ping_request().\
+            filter_ipv6_src(pv.vars['ED_MLEID']).\
+            filter_ipv6_dst(pv.vars['ROUTER_MLEID']).\
+            must_next()
+
+        # Responses must be on 15.4
+        pkts.filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier).\
+            filter_ipv6_src(pv.vars['ROUTER_MLEID']).\
+            filter_ipv6_dst(pv.vars['ED_MLEID']).\
+            filter(lambda p: p.wpan).\
+            must_next()
+
+    # - Step 7
+    #   - Device: Router
+    #   - Description (TREL-8.3): Harness re-enables TREL connectivity on the Router.
+    #   - Pass Criteria:
+    #     - N/A
+    print("Step 7: Harness re-enables TREL connectivity on the Router.")
+
+    # - Step 8
+    #   - Device: ED
+    #   - Description (TREL-8.3): Harness instructs device to send 300 UDP messages (small payload) to Router using
+    #     mesh-local IPv6 addresses as source and destination. Note: this step is intended to trigger and verify
+    #     the probe mechanism on the DUT.
+    #   - Pass Criteria:
+    #     - N/A
+    print("Step 8: Harness instructs device to send 300 UDP messages (small payload) to Router.")
+    # Verify that some TREL traffic resumed eventually
+    pkts.filter_eth_src(pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['ROUTER_ETH']).\
+        filter(lambda p: p.udp).\
+        filter(lambda p: p.udp.dstport == pv.vars['ROUTER_TREL_PORT']).\
+        must_next()
+
+    # - Step 9
+    #   - Device: Router
+    #   - Description (TREL-8.3): Harness instructs device to report its radio link preference from its neighbor
+    #     table.
+    #   - Pass Criteria:
+    #     - The Router MUST report that the DUT supports both TREL and 15.4 radios.
+    #     - The reported TREL radio link preference MUST be greater than zero (again reachable on the TREL radio
+    #       link), and higher or equal to the 15.4 radio link preference.
+    #     - TREL frames sent by the DUT MUST use a correct TREL frame format.
+    print("Step 9: Harness instructs device to report its radio link preference from its neighbor table.")
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_TREL_TC_4.py b/tests/nexus/verify_1_4_TREL_TC_4.py
new file mode 100644
index 0000000..7bbb493
--- /dev/null
+++ b/tests/nexus/verify_1_4_TREL_TC_4.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 8.4. [1.4] [CERT] Radio Link (Re)discovery through Receive
+    #
+    # 8.4.1. Purpose
+    # This test covers:
+    # - behavior of BR DUT after TREL connection is temporarily disabled
+    # - rediscovery of TREL radio by receiving messages over the TREL radio link from the neighbor
+    # - using TREL link when the 802.15.4 link becomes unavailable
+    #
+    # 8.4.2. Topology
+    # - BR Leader (DUT) - Support multi-radio (TREL and 15.4)
+    # - Router - Reference device that supports multi-radio (TREL and 15.4).
+    # - ED - Reference device that supports 15.4 radio only.
+    #
+    # Spec Reference                 | V1.1 Section | V1.4 Section
+    # -------------------------------|--------------|-------------
+    # Radio Link (Re)discovery (1.4) | N/A          | 8.4
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_ETH = pv.vars['BR_ETH']
+    ROUTER_ETH = pv.vars['ROUTER_ETH']
+
+    BR_MLEID = pv.vars['BR_MLEID']
+    ROUTER_MLEID = pv.vars['ROUTER_MLEID']
+    ED_MLEID = pv.vars['ED_MLEID']
+
+    BR_TREL_PORT = pv.vars['BR_TREL_PORT']
+    ROUTER_TREL_PORT = pv.vars['ROUTER_TREL_PORT']
+
+    # Step 1
+    # - Device: BR (DUT), Router, ED
+    # - Description (TREL-8.4): Form the topology. Wait for Router and BR (DUT) to become routers.
+    #   ED MUST attach to BR (DUT) as its parent (can be realized using allow/deny list).
+    # - Pass Criteria:
+    #   - Verify that topology is formed.
+    #   - ED MUST attach to the DUT as its parent.
+    print("Step 1: Form the topology.")
+    # Verify any Child ID Request exists to confirm topology formation started
+    pkts.copy().\
+        filter_mle_cmd(consts.MLE_CHILD_ID_REQUEST).\
+        must_next()
+
+    # Step 2
+    # - Device: Router
+    # - Description (TREL-8.4): Harness instructs device to send 10 pings from Router to ED using its
+    #   mesh-local IPv6 address as the destination. Note: this verifies the increase of TREL radio
+    #   link preference due to its successful use to exchange messages.
+    # - Pass Criteria:
+    #   - The Router MUST receive ping responses from ED.
+    #   - The Router MUST have correctly detected that the DUT supports both TREL and 15.4 radios
+    #     (from the neighbor table entry info).
+    #   - The TREL radio link MUST be preferred, i.e. the preference value associated with TREL
+    #     higher than 15.4 for DUR entry in the Router’s neighbor table.
+    print("Step 2: Send 10 pings from Router to ED.")
+    # Verify pings from Router to BR use TREL
+    pkts.copy().\
+        filter_eth_src(ROUTER_ETH).\
+        filter(lambda p: p.eth.dst == BR_ETH).\
+        filter(lambda p: hasattr(p, 'udp')).\
+        filter(lambda p: p.udp.dstport == BR_TREL_PORT).\
+        must_next()
+
+    # Step 3
+    # - Device: Router
+    # - Description (TREL-8.4): Harness disables the TREL connectivity on the Router.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 3: Disable TREL connectivity on the Router.")
+
+    # Step 4
+    # - Device: Router
+    # - Description (TREL-8.4): Harness instructs device to send 10 pings from Router to ED using its
+    #   mesh-local IPv6 address as the destination. Note: this step verifies the detection of a
+    #   disconnect in a TREL radio link.
+    # - Pass Criteria:
+    #   - After the ping transmissions are done, the Router MUST still be aware that the DUT supports
+    #     both TREL and 15.4 radios (from the neighbor table entry info).
+    #   - The TREL radio link preference MUST however be set to zero i.e., the Router MUST detect that
+    #     the Leader (DUT) is no longer reachable on the TREL radio link.
+    print("Step 4: Send 10 pings from Router to ED.")
+    # Pings should switch back to 15.4 (WPAN)
+    pkts.copy().\
+        filter_ping_request().\
+        filter_ipv6_src(ROUTER_MLEID).\
+        filter_ipv6_dst(ED_MLEID).\
+        filter(lambda p: hasattr(p, 'wpan')).\
+        must_next()
+
+    # Step 5
+    # - Device: Router
+    # - Description (TREL-8.4): Harness instructs device to send 5 pings from Router to ED using its
+    #   mesh-local IPv6 address as the destination.
+    # - Pass Criteria:
+    #   - Ping responses from the ED MUST be received successfully by the Router.
+    print("Step 5: Send 5 pings from Router to ED.")
+    pkts.copy().\
+        filter_ping_request().\
+        filter_ipv6_src(ROUTER_MLEID).\
+        filter_ipv6_dst(ED_MLEID).\
+        must_next()
+
+    # Step 6
+    # - Device: Router
+    # - Description (TREL-8.4): Harness re-enables TREL connectivity on the Router.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 6: Re-enable TREL connectivity on the Router.")
+
+    # Step 7
+    # - Device: Router
+    # - Description (TREL-8.4): Harness instructs device to send 300 UDP messages from Router to ED
+    #   using mesh-local IPv6 addresses as source and destination.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 7: Send 300 UDP messages from Router to ED.")
+    # Verify UDP messages (pings in our test) from Router to BR use TREL again (due to probe)
+    pkts.copy().\
+        filter_eth_src(ROUTER_ETH).\
+        filter(lambda p: p.eth.dst == BR_ETH).\
+        filter(lambda p: hasattr(p, 'udp')).\
+        filter(lambda p: p.udp.dstport == BR_TREL_PORT).\
+        must_next()
+
+    # Step 8
+    # - Device: Router
+    # - Description (TREL-8.4): Harness disables 15.4 radio link on Router.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 8: Disable 15.4 radio link on Router.")
+
+    # Step 9
+    # - Device: ED
+    # - Description (TREL-8.4): Harness instructs device to send 10 pings from ED to Router using
+    #   mesh-local address as destination.
+    # - Pass Criteria:
+    #   - The ED MUST receive ping responses from Router.
+    print("Step 9: Send 10 pings from ED to Router.")
+    # Path: ED -> BR (15.4) -> Router (TREL)
+    # BR -> Router (TREL)
+    pkts.copy().\
+        filter_eth_src(BR_ETH).\
+        filter(lambda p: p.eth.dst == ROUTER_ETH).\
+        filter(lambda p: hasattr(p, 'udp')).\
+        filter(lambda p: p.udp.dstport == ROUTER_TREL_PORT).\
+        must_next()
+    # Router -> BR (TREL)
+    pkts.copy().\
+        filter_eth_src(ROUTER_ETH).\
+        filter(lambda p: p.eth.dst == BR_ETH).\
+        filter(lambda p: hasattr(p, 'udp')).\
+        filter(lambda p: p.udp.dstport == BR_TREL_PORT).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_TREL_TC_5.py b/tests/nexus/verify_1_4_TREL_TC_5.py
new file mode 100644
index 0000000..d222aa8
--- /dev/null
+++ b/tests/nexus/verify_1_4_TREL_TC_5.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    # 8.5. [1.4] [CERT] Discover Scan over multi-radio
+    #
+    # 8.5.1. Purpose
+    # This test covers MLE discovery scan when nodes support different radio links.
+    #
+    # 8.5.2. Topology
+    # - 1. Node_1 BR (DUT) - Support multi-radio (15.4 and TREL).
+    # - 2. Node_2 - Reference device that supports multi-radio (15.4 and TREL).
+    # - 3. Node_3 - Reference device that supports 15.4 radio only.
+    # Devices using TREL MUST be connected to the same infrastructure link.
+    # Note: the infrastructure link is not shown in the figure below.
+    #
+    # Spec Reference   | V1.1 Section | V1.3.0 Section
+    # -----------------|--------------|---------------
+    # Discovery Scan   | 4.7.2.1      | 4.5.2.1
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    NODE_1 = pv.vars['NODE_1_DUT']
+    NODE_2 = pv.vars['NODE_2']
+    NODE_3 = pv.vars['NODE_3']
+
+    # Step 1
+    # - Device: Node_1 (DUT), Node_2, Node_3
+    # - Description (TREL-8.5): Form a network on Node_1. Form a different network on Node_2 and different one on
+    #   Node_3 (each node has its own network).
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: Form separate networks on Node_1, Node_2, and Node_3")
+    # Verified by C++ code internally
+
+    # Step 2
+    # - Device: Node_2
+    # - Description (TREL-8.5): Harness instructs device to perform MLE Discovery Scan.
+    # - Pass Criteria:
+    #   - Node_2 MUST see both the DUT and Node_3 in its scan result.
+    print("Step 2: Node_2 performs MLE Discovery Scan")
+    # Node_2 MUST send MLE Discovery Request on both 15.4 and TREL
+    # We verify at least one of them, but specifically check for TREL response from DUT
+    # and 15.4 response from Node_3.
+
+    # 1. Node_2 MUST send MLE Discovery Request on 15.4
+    pkts.copy().\
+        filter_wpan_src64(NODE_2).\
+        filter_mle_cmd(consts.MLE_DISCOVERY_REQUEST).\
+        filter(lambda p: p.wpan).\
+        must_next()
+
+    # 2. Node_2 MUST send MLE Discovery Request on TREL
+    pkts.copy().\
+        filter_eth_src(pv.vars['NODE_2_ETH']).\
+        filter(lambda p: p.udp.dstport == pv.vars['NODE_1_DUT_TREL_PORT'] or p.udp.dstport == pv.vars['NODE_2_TREL_PORT']).\
+        filter(lambda p: p.trel).\
+        must_next()
+
+    # Node_2 MUST see Node_1 (DUT) in its scan result (verify TREL response)
+    pkts.copy().\
+        filter_eth_src(pv.vars['NODE_1_DUT_ETH']).\
+        filter(lambda p: p.eth.dst == pv.vars['NODE_2_ETH']).\
+        filter(lambda p: p.udp.dstport == pv.vars['NODE_2_TREL_PORT']).\
+        filter(lambda p: p.trel).\
+        must_next()
+
+    # Node_2 MUST see Node_3 in its scan result (verify 15.4 response)
+    pkts.copy().\
+        filter_wpan_src64(NODE_3).\
+        filter_wpan_dst64(NODE_2).\
+        filter_mle_cmd(consts.MLE_DISCOVERY_RESPONSE).\
+        must_next()
+
+    # Step 3
+    # - Device: Node_3
+    # - Description (TREL-8.5): Harness instructs device to perform MLE Discovery Scan.
+    # - Pass Criteria:
+    #   - Node_3 MUST see both the DUT and Node_2 in its scan result.
+    print("Step 3: Node_3 performs MLE Discovery Scan")
+    # Node_3 MUST send MLE Discovery Request (15.4 only)
+    pkts.filter_wpan_src64(NODE_3).\
+        filter_mle_cmd(consts.MLE_DISCOVERY_REQUEST).\
+        must_next()
+
+    # Node_3 MUST see Node_1 (DUT) and Node_2 in its scan result (both on 15.4)
+    pkts.copy().\
+        filter_wpan_src64(NODE_1).\
+        filter_wpan_dst64(NODE_3).\
+        filter_mle_cmd(consts.MLE_DISCOVERY_RESPONSE).\
+        must_next()
+
+    pkts.copy().\
+        filter_wpan_src64(NODE_2).\
+        filter_wpan_dst64(NODE_3).\
+        filter_mle_cmd(consts.MLE_DISCOVERY_RESPONSE).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_1_4_TREL_TC_6.py b/tests/nexus/verify_1_4_TREL_TC_6.py
new file mode 100644
index 0000000..7840724
--- /dev/null
+++ b/tests/nexus/verify_1_4_TREL_TC_6.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+
+
+def verify(pv):
+    # 8.6. [1.4] [CERT] mDNS Discovery of TREL Service
+    #
+    # 8.6.1. Purpose
+    # This test covers mDNS discovery of TREL Service on the BR DUT, and validation of the DNS-SD parameters and fields
+    #   advertised.
+    #
+    # 8.6.2. Topology
+    # - 1. BR Leader (DUT) - Support multi-radio (TREL and 15.4)
+    # - 2. Eth_1 - Reference device on the Adjacent Infrastructure Link performing mDNS discovery
+    #
+    # Spec Reference   | Section
+    # -----------------|----------
+    # TREL Discovery   | 15.6.3.1
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    BR_DUT = pv.vars['BR_DUT']
+    Eth_1 = pv.vars['Eth_1']
+
+    TREL_SERVICE_TYPE = '_trel._udp.local'
+    TREL_INSTANCE_NAME = pv.vars['TREL_INSTANCE_NAME']
+    TREL_FULL_SERVICE_NAME = TREL_INSTANCE_NAME + '.' + TREL_SERVICE_TYPE
+
+    BR_EXT_ADDR_BYTES = bytes.fromhex(pv.vars['BR_EXT_ADDR'])
+    XPANID_BYTES = bytes.fromhex(pv.vars['XPANID'])
+
+    XA_RECORD = b'xa=' + BR_EXT_ADDR_BYTES
+    XP_RECORD = b'xp=' + XPANID_BYTES
+
+    GUA_PREFIX = '910b:1234::/64'
+
+    GUA_PREFIX_ADDR = Ipv6Addr(GUA_PREFIX.split('/')[0])
+
+    # Step 1
+    # - Device: Eth_1, BR (DUT)
+    # - Description (TREL-8.6): Form the topology. Eth_1 advertises an on-link prefix using multicast ND RA PIO, such
+    #   as 910b:1234::/64. Wait for the DUT to become Leader. The DUT automatically configures an IPv6 address on the
+    #   AIL based on the advertised prefix.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 1: Form the topology")
+
+    # Step 2
+    # - Device: Eth_1
+    # - Description (TREL-8.6): Harness instructs device to send multicast mDNS query QType=PTR for name
+    #   “_trel._udp.local”. Note: for test reliability purposes, it's allowed to retransmit this query one additional
+    #   time.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 2: Eth_1 sends mDNS query PTR for _trel._udp.local")
+    pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: TREL_SERVICE_TYPE in verify_utils.as_list(p.mdns.qry.name)).\
+        filter(lambda p: consts.DNS_TYPE_PTR in verify_utils.as_list(p.mdns.qry.type)).\
+        must_next()
+
+    # Step 3
+    # - Device: BR (DUT)
+    # - Description (TREL-8.6): Automatically responds with mDNS response. Note: the response can be unicast or
+    #   multicast.
+    # - Pass Criteria:
+    #   - The DUT MUST send an mDNS response (unicast and/or multicast).
+    #   - Eth_1 MUST receive the mDNS response containing the TREL service, as follows, in the Answer Record section:
+    #     $ORIGIN local. _trel._udp PTR <name>._trel._udp
+    #   - The response MAY contain the following in the Additional Record section or in the Answer Record section:
+    #     $ORIGIN local. <name>._trel._udp ( SRV <val1> <val2> <port> <hostname> TXT xa=<extaddr>;xp=<xpanid> )
+    #     <hostname> AAAA <link-local address> AAAA <global address>
+    #   - Where: <name> is a service instance name selected by BR DUT.
+    #   - Where: <val1> is a value >= 0.
+    #   - Where: <val2> is a value >= 0.
+    #   - Where: <port> is a port number selected by BR DUT.
+    #   - Where: <hostname> is a hostname selected by BR DUT.
+    #   - Where: <global address> is an address starting with prefix 910b:1234::/64.
+    #   - Where: <link-local address> is an IPv6 address starting with fe80::/10.
+    #   - Where: <extaddr> is the extended address of BR DUT, binary encoded. It MUST NOT be ASCII hexadecimal encoded.
+    #   - Where: <xpanid> is the extended PAN ID of the Thread Network, binary encoded. It MUST NOT be ASCII
+    #     hexadecimal encoded.
+    #   - Verify that <xpanid> in the TXT record equals the configured Extended PAN ID (XPANID) of the Thread Network.
+    print("Step 3: BR (DUT) responds with mDNS response")
+    pkts.filter_eth_src(pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.udp.srcport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: TREL_SERVICE_TYPE in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: TREL_FULL_SERVICE_NAME in verify_utils.as_list(p.mdns.ptr.domain_name)).\
+        filter(lambda p: TREL_FULL_SERVICE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: consts.DNS_TYPE_SRV in verify_utils.as_list(p.mdns.resp.type)).\
+        filter(lambda p: all(v >= 0 for v in verify_utils.as_list(p.mdns.srv.priority))).\
+        filter(lambda p: all(v >= 0 for v in verify_utils.as_list(p.mdns.srv.weight))).\
+        filter(lambda p: all(v > 0 for v in verify_utils.as_list(p.mdns.srv.port))).\
+        filter(lambda p: consts.DNS_TYPE_TXT in verify_utils.as_list(p.mdns.resp.type)).\
+        filter(lambda p: XA_RECORD in verify_utils.as_list(p.mdns.txt)).\
+        filter(lambda p: XP_RECORD in verify_utils.as_list(p.mdns.txt)).\
+        filter(lambda p: any(addr.is_link_local for addr in verify_utils.as_list(p.mdns.aaaa))).\
+        filter(lambda p: any(addr[:8] == GUA_PREFIX_ADDR[:8] for addr in verify_utils.as_list(p.mdns.aaaa))).\
+        must_next()
+
+    # Step 4
+    # - Device: Eth_1
+    # - Description (TREL-8.6): Harness instructs device to send multicast mDNS query QType=SRV for name
+    #   “<name>._trel._udp.local”, the service found in the previous step. Note: <name> is taken from the previous
+    #   step response for the PTR record. Note: for test reliability purposes, it's allowed to retransmit this query
+    #   one additional time.
+    # - Pass Criteria:
+    #   - N/A
+    print("Step 4: Eth_1 sends mDNS query SRV for service instance")
+    pkts.filter_eth_src(pv.vars['Eth_1_ETH']).\
+        filter(lambda p: p.udp.dstport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 0).\
+        filter(lambda p: TREL_FULL_SERVICE_NAME in verify_utils.as_list(p.mdns.qry.name)).\
+        filter(lambda p: consts.DNS_TYPE_SRV in verify_utils.as_list(p.mdns.qry.type)).\
+        must_next()
+
+    # Step 5
+    # - Device: BR (DUT)
+    # - Description (TREL-8.6): Automatically responds with mDNS response. Note: the response can be unicast or
+    #   multicast.
+    # - Pass Criteria:
+    #   - The DUT MUST send mDNS response (unicast and/or multicast)
+    #   - Eth_1 MUST receive the mDNS response containing the TREL service, as follows, in the Answer Record section:
+    #     $ORIGIN local. <name>._trel._udp ( SRV <val1> <val2> <port> <hostname> )
+    #   - It MAY contain in the Additional Record section or in the Answer Record section the following: <hostname>
+    #     AAAA <link-local address> AAAA <global address>
+    #   - Also it MAY contain in the Additional Record section or the Answer Record section the following:
+    #     <name>._trel._udp TXT xa=<extaddr>;xp=<xpanid>
+    #   - Where: all the names in pointy brackets are as specified in step 3. For all items present, the same
+    #     validation as in step 3 MUST be performed.
+    print("Step 5: BR (DUT) responds with mDNS response")
+    pkts.filter_eth_src(pv.vars['BR_DUT_ETH']).\
+        filter(lambda p: p.udp.srcport == 5353).\
+        filter(lambda p: p.mdns.flags.response == 1).\
+        filter(lambda p: TREL_FULL_SERVICE_NAME in verify_utils.as_list(p.mdns.resp.name)).\
+        filter(lambda p: consts.DNS_TYPE_SRV in verify_utils.as_list(p.mdns.resp.type)).\
+        filter(lambda p: all(v >= 0 for v in verify_utils.as_list(p.mdns.srv.priority))).\
+        filter(lambda p: all(v >= 0 for v in verify_utils.as_list(p.mdns.srv.weight))).\
+        filter(lambda p: all(v > 0 for v in verify_utils.as_list(p.mdns.srv.port))).\
+        filter(lambda p: not hasattr(p.mdns, 'txt') or (XA_RECORD in verify_utils.as_list(p.mdns.txt) and
+                                                        XP_RECORD in verify_utils.as_list(p.mdns.txt))).\
+        filter(lambda p: not hasattr(p.mdns, 'aaaa') or (any(addr.is_link_local for addr in verify_utils.as_list(p.mdns.aaaa)) and
+                                                         any(addr[:8] == GUA_PREFIX_ADDR[:8] for addr in verify_utils.as_list(p.mdns.aaaa)))).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_inform_previous_parent_on_reattach.py b/tests/nexus/verify_inform_previous_parent_on_reattach.py
new file mode 100644
index 0000000..ab81230
--- /dev/null
+++ b/tests/nexus/verify_inform_previous_parent_on_reattach.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv):
+    pkts = pv.pkts
+    pv.summary.show()
+
+    LEADER = pv.vars['LEADER']
+    ROUTER = pv.vars['ROUTER']
+    SED = pv.vars['SED']
+
+    # Step 1 & 2: SED joins LEADER
+    print("Step 1 & 2: SED joins LEADER")
+    pv.verify_attached('SED', 'LEADER', child_type='MTD')
+
+    # Step 3 & 4: SED re-attaches to ROUTER
+    print("Step 4: SED re-attaches to ROUTER")
+    pv.verify_attached('SED', 'ROUTER', child_type='MTD')
+
+    # Step 5: Verify SED informs previous parent (LEADER)
+    print("Step 5: Verify SED informs previous parent")
+    # SED should send empty IPv6 message to inform previous parent (LEADER).
+    # The next header is 59 (No Next Header), which is 0x3b.
+
+    LEADER_RLOC = pv.vars['LEADER_RLOC']
+
+    pkts.filter_wpan_src64(SED).\
+        filter(lambda p: p.ipv6.dst == LEADER_RLOC and p.ipv6.nxt == 59).\
+        must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_leader_reboot_multiple_link_request.py b/tests/nexus/verify_leader_reboot_multiple_link_request.py
new file mode 100644
index 0000000..d8c3cee
--- /dev/null
+++ b/tests/nexus/verify_leader_reboot_multiple_link_request.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.null_field import nullField
+
+
+def verify(pv):
+    # Test Leader Reboot Multiple Link Request
+    #
+    # Topology
+    # - Leader
+    # - Router
+    #
+    # Purpose & Description
+    # The purpose of this test case is to show that when the Leader is rebooted, it sends
+    # MLE_MAX_RESTORING_TRANSMISSION_COUNT MLE link request packets if no response is received.
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    LEADER = pv.vars['LEADER']
+    ROUTER = pv.vars['ROUTER']
+
+    # Step 1: Form topology
+    # - Verify topology is formed correctly.
+    pv.verify_attached('ROUTER', 'LEADER')
+
+    # Step 2: Reset Leader and isolate it from Router
+    # - The Leader MUST send MLE_MAX_RESTORING_TRANSMISSION_COUNT multicast Link Request
+    #   since it doesn't receive Link Accept from Router.
+
+    # The value of MLE_MAX_RESTORING_TRANSMISSION_COUNT is 4 by default.
+    MLE_MAX_RESTORING_TRANSMISSION_COUNT = 4
+
+    for i in range(MLE_MAX_RESTORING_TRANSMISSION_COUNT):
+        pkts.filter_wpan_src64(LEADER).\
+            filter_LLARMA().\
+            filter_mle_cmd(consts.MLE_LINK_REQUEST).\
+            filter(lambda p: {
+                              consts.CHALLENGE_TLV,
+                              consts.VERSION_TLV,
+                              consts.TLV_REQUEST_TLV,
+                              consts.ADDRESS16_TLV,
+                              consts.ROUTE64_TLV
+                              } <= set(p.mle.tlv.type) and\
+                   p.mle.tlv.addr16 is nullField and\
+                   p.mle.tlv.route64.id_mask is nullField).\
+            must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_mle_router_role_allowed.py b/tests/nexus/verify_mle_router_role_allowed.py
new file mode 100644
index 0000000..a0e1672
--- /dev/null
+++ b/tests/nexus/verify_mle_router_role_allowed.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+
+
+def verify(pv: verify_utils.PacketVerifier):
+    # Test differing behaviors based on the Router role allowed
+    #
+    # Purpose & Description
+    # This test validates that correctly-formatted transmissions occur based on whether the Router
+    # role is allowed.  This includes Advertisements (only from REEDs and Active Routers),
+    # which should not occur for devices with Router role disallowed.
+    # This test verifies Router role disallowed on FED and MED devices, with a duplicate
+    # pair used to demonstrate that these transmissions begin in the correct format when
+    # re-configured so that the Router role becomes allowed.
+
+    pv.summary.show()
+
+    LEADER = pv.vars['Leader']
+    ROUTER = pv.vars['Router']
+    REED = pv.vars['REED']
+    FED_ONLY = pv.vars['FED_ONLY']
+    MED_ONLY = pv.vars['MED_ONLY']
+    FED_ROUTER = pv.vars['FED_Router']
+    MED_ROUTER = pv.vars['MED_Router']
+
+    print("Node: Leader")
+    leader_packet_filter = pv.pkts.copy().filter_wpan_src64(LEADER)
+    # There was at least one advertisement from the Leader
+    leader_mle_advertisement_packet_filter = leader_packet_filter.copy().filter_mle_cmd(consts.MLE_ADVERTISEMENT)
+    leader_mle_advertisement_packets = [pkt for pkt in iter(leader_mle_advertisement_packet_filter.next, None)]
+    assert len(leader_mle_advertisement_packets) >= 1
+    # And all advertisements had a Router source address (with child id of 0)
+    assert all(pkt.mle.tlv.source_addr & 0x1FF == 0 for pkt in leader_mle_advertisement_packets)
+    # And contain a route64 TLV
+    assert all(
+        pkt.must_verify(lambda p: (consts.ROUTE64_TLV) in p.mle.tlv.type) for pkt in leader_mle_advertisement_packets)
+
+    print("Node: Router")
+    router_packet_filter = pv.pkts.copy().filter_wpan_src64(ROUTER)
+    # There was at least one advertisement from the Router
+    router_mle_advertisement_packet_filter = router_packet_filter.copy().filter_mle_cmd(consts.MLE_ADVERTISEMENT)
+    router_mle_advertisement_packets = [pkt for pkt in iter(router_mle_advertisement_packet_filter.next, None)]
+    assert len(router_mle_advertisement_packets) >= 1
+    # And all advertisements had a Router source address (with child id of 0)
+    assert all(pkt.mle.tlv.source_addr & 0x1FF == 0 for pkt in router_mle_advertisement_packets)
+    # And contain a route64 TLV
+    assert all(
+        pkt.must_verify(lambda p: (consts.ROUTE64_TLV) in p.mle.tlv.type) for pkt in router_mle_advertisement_packets)
+
+    print("Node: REED")
+    reed_packet_filter = pv.pkts.copy().filter_wpan_src64(REED)
+    reed_mle_advertisement_packet_filter = reed_packet_filter.copy().filter_mle_cmd(consts.MLE_ADVERTISEMENT)
+    reed_mle_advertisement_packets = [pkt for pkt in iter(reed_mle_advertisement_packet_filter.next, None)]
+    # There was at least one advertisement from the REED
+    assert len(reed_mle_advertisement_packets) >= 1
+    # And all advertisements had a Child source address (with child id > 0)
+    assert all(pkt.mle.tlv.source_addr & 0x1FF > 0 for pkt in reed_mle_advertisement_packets)
+    # And not contain a route64 TLV
+    assert all(
+        pkt.must_not_verify(lambda p: (consts.ROUTE64_TLV) in p.mle.tlv.type)
+        for pkt in reed_mle_advertisement_packets)
+
+    print("Node: FED only")
+    fed_only_packet_filter = pv.pkts.copy().filter_wpan_src64(FED_ONLY)
+    # When only a FED, no advertisements should be sent
+    fed_only_packet_filter.copy().filter_mle_cmd(consts.MLE_ADVERTISEMENT).must_not_next()
+
+    print("Node: MED only")
+    med_only_packet_filter = pv.pkts.copy().filter_wpan_src64(MED_ONLY)
+    # When only a FED, no advertisements should be sent
+    med_only_packet_filter.copy().filter_mle_cmd(consts.MLE_ADVERTISEMENT).must_not_next()
+
+    print("Node: FED and Router")
+    fed_and_router_packet_filter = pv.pkts.copy()
+    # Exactly one advertisement should have been sent when configured as a router
+    fed_and_router_mle_advertisement_packet_filter = fed_and_router_packet_filter.filter_mle_cmd(
+        consts.MLE_ADVERTISEMENT).filter_wpan_src64(FED_ROUTER)
+    fed_and_router_mle_advertisement_packets = [
+        pkt for pkt in iter(fed_and_router_mle_advertisement_packet_filter.next, None)
+    ]
+    # There was at least one advertisement as a Router
+    assert len(fed_and_router_mle_advertisement_packets) >= 1, f"{len(fed_and_router_mle_advertisement_packets)}"
+    # And all advertisements had a Router source address (with child id of 0)
+    assert all(pkt.mle.tlv.source_addr & 0x1FF == 0 for pkt in fed_and_router_mle_advertisement_packets)
+    # And contain a route64 TLV
+    assert all(
+        pkt.must_verify(lambda p: (consts.ROUTE64_TLV) in p.mle.tlv.type)
+        for pkt in fed_and_router_mle_advertisement_packets)
+
+    print("Node: MED and Router")
+    med_and_router_packet_filter = pv.pkts.copy()
+    # Exactly one advertisement should have been sent when configured as a router
+    med_and_router_mle_advertisement_packet_filter = med_and_router_packet_filter.filter_mle_cmd(
+        consts.MLE_ADVERTISEMENT).filter_wpan_src64(MED_ROUTER)
+    med_and_router_mle_advertisement_packets = [
+        pkt for pkt in iter(med_and_router_mle_advertisement_packet_filter.next, None)
+    ]
+    # There was at least one advertisement as a Router
+    assert len(med_and_router_mle_advertisement_packets) >= 1, f"{len(med_and_router_mle_advertisement_packets)}"
+    # And all advertisements had a Router source address (with child id of 0)
+    assert all(pkt.mle.tlv.source_addr & 0x1FF == 0 for pkt in med_and_router_mle_advertisement_packets)
+    # And contain a route64 TLV
+    assert all(
+        pkt.must_verify(lambda p: (consts.ROUTE64_TLV) in p.mle.tlv.type)
+        for pkt in med_and_router_mle_advertisement_packets)
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_pbbr_aloc.py b/tests/nexus/verify_pbbr_aloc.py
new file mode 100644
index 0000000..d4b7d25
--- /dev/null
+++ b/tests/nexus/verify_pbbr_aloc.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.addrs import Ipv6Addr
+from pktverify.bytes import Bytes
+
+
+def verify(pv):
+    # The purpose of this test is to verify ALOC connectivity and ensure no Address Query is sent.
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    # Get Mesh-Local Prefix
+    mesh_local_prefix = pv.vars.get('mesh_local_prefix')
+    if mesh_local_prefix:
+        prefix = Bytes(Ipv6Addr(mesh_local_prefix.split('/')[0]))[:8]
+    else:
+        # Fallback to default if not provided
+        prefix = consts.DEFAULT_MESH_LOCAL_PREFIX
+
+    # Get ALOCs
+    LEADER_ALOC = pv.vars.get('LEADER_ALOC') or Ipv6Addr(prefix + consts.LEADER_ALOC_IID)
+    PBBR_ALOC = pv.vars.get('PBBR_ALOC') or Ipv6Addr(prefix + consts.PBBR_ALOC_IID)
+
+    # Verify Leader ALOC ping from ROUTER
+    print(f"Verifying Leader ALOC ({LEADER_ALOC}) ping from ROUTER")
+    pkts.filter_ping_request().filter_ipv6_dst(LEADER_ALOC).must_next()
+    pkts.filter_ping_reply().filter_ipv6_src(LEADER_ALOC).must_next()
+
+    # Verify PBBR ALOC ping from ROUTER
+    print(f"Verifying PBBR ALOC ({PBBR_ALOC}) ping from ROUTER")
+    pkts.filter_ping_request().filter_ipv6_dst(PBBR_ALOC).must_next()
+    pkts.filter_ping_reply().filter_ipv6_src(PBBR_ALOC).must_next()
+
+    # Make sure no ADDR_QUERY.qry is ever sent
+    pkts.filter_coap_request(consts.ADDR_QRY_URI).must_not_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_retransmission_security.py b/tests/nexus/verify_retransmission_security.py
new file mode 100644
index 0000000..bdf5cd7
--- /dev/null
+++ b/tests/nexus/verify_retransmission_security.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.layer_fields_container import LayerFieldsContainer
+
+
+def get_val(field):
+    if field is None:
+        return None
+    s = str(field)
+    if s.lower() == 'null' or s == '':
+        return None
+    return field
+
+
+def get_int(field):
+    val = get_val(field)
+    if val is None:
+        return None
+    try:
+        return int(str(val), 0)
+    except ValueError:
+        return None
+
+
+def verify(pv):
+    pkts = pv.pkts
+
+    LEADER = pv.vars['LEADER']
+    SSED_1 = pv.vars['SSED_1']
+
+    LEADER_RLOC16 = get_int(pv.vars.get('LEADER_RLOC16'))
+    SSED_1_RLOC16 = get_int(pv.vars.get('SSED_1_RLOC16'))
+
+    LEADER_STR = str(LEADER).lower()
+    SSED_1_STR = str(SSED_1).lower()
+
+    print(f"LEADER: {LEADER}, RLOC16: {hex(LEADER_RLOC16) if LEADER_RLOC16 is not None else 'None'}")
+    print(f"SSED_1: {SSED_1}, RLOC16: {hex(SSED_1_RLOC16) if SSED_1_RLOC16 is not None else 'None'}")
+
+    groups = {}
+
+    # 1. Collect all MAC Data frames
+    for i in range(len(pkts)):
+        p = pkts[i]
+        if not hasattr(p, 'wpan') or get_int(p.wpan.frame_type) != consts.MAC_FRAME_TYPE_DATA:
+            continue
+
+        src16 = get_int(getattr(p.wpan, 'src16', None))
+        src64 = get_val(getattr(p.wpan, 'src64', None))
+        dst16 = get_int(getattr(p.wpan, 'dst16', None))
+        seq = get_int(p.wpan.seq_no)
+
+        if dst16 == 0xffff:  # Skip broadcast
+            continue
+
+        # Check if from LEADER or SSED_1
+        is_relevant_src = False
+        if src64 is not None:
+            src_str = str(src64).lower()
+            if src_str == LEADER_STR or src_str == SSED_1_STR:
+                is_relevant_src = True
+        elif src16 is not None:
+            if src16 == LEADER_RLOC16 or src16 == SSED_1_RLOC16:
+                is_relevant_src = True
+
+        if not is_relevant_src:
+            continue
+
+        # Group key identifies the (source, sequence) pair.
+        # Destination might change between RLOC16 and ExtAddr in some edge cases,
+        # but sequence numbers are usually unique enough within a short window.
+        src_id = str(src64).lower() if src64 is not None else hex(src16)
+        group_key = (src_id, seq)
+        if group_key not in groups:
+            groups[group_key] = []
+        groups[group_key].append(p)
+
+    # 2. Filter groups that are retransmissions (> 1 packet)
+    retry_groups = [g for k, g in groups.items() if len(g) > 1]
+    print(f"Found {len(retry_groups)} retransmission groups.")
+
+    if not retry_groups:
+        raise RuntimeError("No retransmissions detected in pcap. The test might not have triggered retries correctly.")
+
+    found_csl_retry = False
+
+    # 3. Verify each group
+    for group in retry_groups:
+        p0 = group[0]
+        src_id = str(p0.wpan.src64).lower() if get_val(getattr(p0.wpan, 'src64', None)) else hex(get_int(
+            p0.wpan.src16))
+        seq = get_int(p0.wpan.seq_no)
+
+        # Check for CSL IE in any packet of the group (it should be in all if it's a CSL retry)
+        has_csl = False
+        for p in group:
+            has_csl = p.wpan.has('header_ie.csl.period')
+            if has_csl:
+                csl_period = p.wpan.header_ie.csl.period
+                has_csl = str(csl_period) != 'null'
+
+            if has_csl:
+                break
+
+        type_str = "CSL" if has_csl else "Non-CSL"
+        print(f"Verifying {type_str} seq={seq} from {src_id} ({len(group)} attempts)")
+
+        if has_csl:
+            found_csl_retry = True
+
+        last_counter = -1
+        last_phase = -1
+        for i, p in enumerate(group):
+            counter = get_int(getattr(getattr(p.wpan, 'aux_sec', None), 'frame_counter', None))
+            has_header_ie = get_val(getattr(p.wpan, 'header_ie', None)) is not None
+
+            if has_csl:
+                print(f"  Attempt {i}: Counter={counter}, HeaderIE={has_header_ie}")
+                if counter is not None and has_header_ie:
+                    if last_counter != -1 and counter <= last_counter:
+                        raise RuntimeError(
+                            f"Nonce reuse detected! Frame counter {counter} did not increment for CSL seq {seq}")
+                    last_counter = counter
+
+                # Check CSL phase
+                try:
+                    phase = get_int(p.wpan.header_ie.csl.phase)
+                    if phase is not None:
+                        print(f"  Attempt {i}: CSL Phase={phase}")
+                        if phase == last_phase:
+                            print(
+                                f"  Warning: CSL phase did not update between attempts for seq {seq} (likely fast retry)"
+                            )
+                        last_phase = phase
+                    else:
+                        print(f"  Attempt {i}: CSL Phase parsed as None")
+                except (AttributeError, ValueError):
+                    print(f"  Attempt {i}: CSL Phase not parsed")
+            else:
+                # For non-CSL, same counter is expected/allowed in standard 15.4 retries
+                print(f"  Attempt {i}: Counter={counter} (Allowed to be same as previous)")
+                if counter is not None:
+                    if counter < last_counter:
+                        raise RuntimeError(f"Frame counter {counter} went backwards for Non-CSL seq {seq}")
+                    last_counter = counter
+
+    if not found_csl_retry:
+        raise RuntimeError("No CSL retransmissions found. Expected SSED to Leader retries with CSL IEs.")
+
+    print("All verification steps passed!")
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_router_reboot_multiple_link_request.py b/tests/nexus/verify_router_reboot_multiple_link_request.py
new file mode 100644
index 0000000..3c0ccd9
--- /dev/null
+++ b/tests/nexus/verify_router_reboot_multiple_link_request.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify import consts
+from pktverify.null_field import nullField
+
+
+def verify(pv):
+    # Test Router Reboot Multiple Link Request
+    #
+    # Topology
+    # - Leader
+    # - Router
+    # - MED (attached to Router)
+    #
+    # Purpose & Description
+    # The purpose of this test case is to show that when a router is rebooted, it sends
+    # MLE_MAX_RESTORING_TRANSMISSION_COUNT MLE link request packets if no response is received.
+
+    pkts = pv.pkts
+    pv.summary.show()
+
+    LEADER = pv.vars['LEADER']
+    ROUTER = pv.vars['ROUTER']
+    MED = pv.vars['MED']
+
+    # Step 1: Form topology
+    # - Verify topology is formed correctly.
+    pv.verify_attached('ROUTER', 'LEADER')
+    pv.verify_attached('MED', 'ROUTER', 'MTD')
+
+    # Step 2: Reset Router and isolate it from Leader
+    # - The router MUST send MLE_MAX_RESTORING_TRANSMISSION_COUNT multicast Link Request
+    #   since it doesn't receive Link Accept from Leader.
+
+    # The value of MLE_MAX_RESTORING_TRANSMISSION_COUNT is 4 by default.
+    MLE_MAX_RESTORING_TRANSMISSION_COUNT = 4
+
+    for i in range(MLE_MAX_RESTORING_TRANSMISSION_COUNT):
+        pkts.filter_wpan_src64(ROUTER).\
+            filter_LLARMA().\
+            filter_mle_cmd(consts.MLE_LINK_REQUEST).\
+            filter(lambda p: {
+                              consts.CHALLENGE_TLV,
+                              consts.VERSION_TLV,
+                              consts.TLV_REQUEST_TLV,
+                              consts.ADDRESS16_TLV,
+                              consts.ROUTE64_TLV
+                              } <= set(p.mle.tlv.type) and\
+                   p.mle.tlv.addr16 is nullField and\
+                   p.mle.tlv.route64.id_mask is nullField).\
+            must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_srp_client_change_lease.py b/tests/nexus/verify_srp_client_change_lease.py
new file mode 100644
index 0000000..b58f480
--- /dev/null
+++ b/tests/nexus/verify_srp_client_change_lease.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+#
+#  Copyright (c) 2026, The OpenThread Authors.
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#  1. Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+#  2. Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#  3. Neither the name of the copyright holder nor the
+#     names of its contributors may be used to endorse or promote products
+#     derived from this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+#  POSSIBILITY OF SUCH DAMAGE.
+#
+
+import sys
+import os
+
+# Add the current directory to sys.path to find verify_utils
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.append(CUR_DIR)
+
+import verify_utils
+from pktverify.packet_filter import PacketFilter
+
+DEFAULT_LEASE_TIME = 7200
+NEW_LEASE_TIME = 120
+NEW_TTL = 10
+
+
+def verify(pv):
+    pkts: PacketFilter = pv.pkts
+    pv.summary.show()
+
+    CLIENT_EXT_ADDR = pv.vars['SRP_CLIENT']
+
+    # 1. Register with default lease
+    pkts.filter_wpan_src64(CLIENT_EXT_ADDR).filter('dns.flags.response == 0 and dns.resp.ttl == {DEFAULT_LEASE_TIME}',
+                                                   DEFAULT_LEASE_TIME=DEFAULT_LEASE_TIME).must_next()
+
+    # 2. Change server lease range and client lease interval to NEW_LEASE_TIME
+    pkts.filter_wpan_src64(CLIENT_EXT_ADDR).filter('dns.flags.response == 0 and dns.resp.ttl == {NEW_LEASE_TIME}',
+                                                   NEW_LEASE_TIME=NEW_LEASE_TIME).must_next()
+
+    # 4. Change client TTL to NEW_TTL
+    pkts.filter_wpan_src64(CLIENT_EXT_ADDR).filter('dns.flags.response == 0 and dns.resp.ttl == {NEW_TTL}',
+                                                   NEW_TTL=NEW_TTL).must_next()
+
+    # 5. Set TTL to 0 (which should use NEW_LEASE_TIME)
+    pkts.filter_wpan_src64(CLIENT_EXT_ADDR).filter('dns.flags.response == 0 and dns.resp.ttl == {NEW_LEASE_TIME}',
+                                                   NEW_LEASE_TIME=NEW_LEASE_TIME).must_next()
+
+
+if __name__ == '__main__':
+    verify_utils.run_main(verify)
diff --git a/tests/nexus/verify_utils.py b/tests/nexus/verify_utils.py
index 3b83d0a..f72d731 100644
--- a/tests/nexus/verify_utils.py
+++ b/tests/nexus/verify_utils.py
@@ -291,6 +291,42 @@
     return rio_prefixes, pio_prefixes
 
 
+def check_ra_has_rio(packet, prefix, prf=None):
+    """
+    Check if an ICMPv6 RA contains a Route Information Option (RIO) with the given prefix.
+    """
+    if packet.icmpv6.type != ICMPV6_TYPE_ROUTER_ADVERTISEMENT:
+        return False
+
+    target_addr = Ipv6Addr(prefix)
+
+    try:
+        all_prefixes = as_list(packet.icmpv6.opt.prefix)
+        all_types = as_list(packet.icmpv6.opt.type)
+        all_prfs = as_list(packet.icmpv6.opt.route_info.flag.route_preference)
+    except (AttributeError, IndexError):
+        return False
+
+    ri_idx = 0
+    prefix_idx = 0
+    for opt_type in all_types:
+        if opt_type == ICMPV6_OPT_TYPE_RIO:
+            if prefix_idx < len(all_prefixes) and Ipv6Addr(all_prefixes[prefix_idx]) == target_addr:
+                if prf is not None:
+                    if not isinstance(prf, (list, tuple)):
+                        prf = [prf]
+                    if ri_idx < len(all_prfs) and int(all_prfs[ri_idx]) in prf:
+                        return True
+                else:
+                    return True
+            ri_idx += 1
+            prefix_idx += 1
+        elif opt_type == ICMPV6_OPT_TYPE_PIO:
+            prefix_idx += 1
+
+    return False
+
+
 def check_nwd_prefix_flags(packet, target_prefix, stable=None, **expected_flags):
     """
     Robustly check flags for a specific OMR prefix in Network Data.
@@ -310,12 +346,17 @@
     # We iterate through all TLVs to find the target prefix and its BR sub-TLV
     for i, t in enumerate(types):
         if t == consts.NWD_PREFIX_TLV:
-            current_prefix = prefixes[prefix_idx]
-            current_stable = stables[i]
+            is_target = False
+            if prefix_idx < len(prefixes) and prefixes[prefix_idx]:
+                current_prefix = prefixes[prefix_idx]
+                is_target = (Ipv6Addr(current_prefix) == Ipv6Addr(target_prefix))
+                if is_target and stable is not None:
+                    try:
+                        if stables[i] != stable:
+                            is_target = False
+                    except IndexError:
+                        is_target = False
             prefix_idx += 1
-            is_target = (Ipv6Addr(current_prefix) == Ipv6Addr(target_prefix))
-            if is_target and stable is not None and current_stable != stable:
-                is_target = False
         elif t in (consts.NWD_COMMISSIONING_DATA_TLV, consts.NWD_SERVICE_TLV):
             # These are also top-level TLVs, reset target prefix
             is_target = False
@@ -358,6 +399,57 @@
     return False
 
 
+def check_nwd_has_prefix(packet, prefix):
+    """Checks if Network Data contains the given prefix in a Prefix TLV."""
+    try:
+        if not hasattr(packet, 'thread_nwd') or not hasattr(packet.thread_nwd.tlv, 'prefix'):
+            return False
+        prefixes = as_list(packet.thread_nwd.tlv.prefix)
+        target_addr = Ipv6Addr(prefix)
+        return any(p and Ipv6Addr(p) == target_addr for p in prefixes)
+    except (AttributeError, IndexError):
+        return False
+
+
+def check_nwd_has_route(packet, target_prefix, pref=None):
+    """
+    Robustly check if Network Data has an External Route with the given prefix.
+    """
+    try:
+        types = as_list(packet.thread_nwd.tlv.type)
+        prefixes = as_list(packet.thread_nwd.tlv.prefix)
+    except (AttributeError, IndexError):
+        return False
+
+    prefix_idx = 0
+    hr_idx = 0
+    is_target = False
+
+    for i, t in enumerate(types):
+        if t == consts.NWD_PREFIX_TLV:
+            is_target = False
+            if prefix_idx < len(prefixes) and prefixes[prefix_idx]:
+                is_target = (Ipv6Addr(prefixes[prefix_idx]) == Ipv6Addr(target_prefix))
+            prefix_idx += 1
+        elif t in (consts.NWD_COMMISSIONING_DATA_TLV, consts.NWD_SERVICE_TLV):
+            is_target = False
+        elif t == consts.NWD_HAS_ROUTER_TLV:
+            if is_target:
+                if pref is not None:
+                    try:
+                        all_prefs = as_list(packet.thread_nwd.tlv.has_route.pref)
+                        allowed_prefs = pref if isinstance(pref, (list, tuple)) else [pref]
+                        if hr_idx < len(all_prefs) and int(all_prefs[hr_idx]) in allowed_prefs:
+                            return True
+                    except (AttributeError, IndexError, ValueError):
+                        pass
+                else:
+                    return True
+            hr_idx += 1
+
+    return False
+
+
 def is_leader_aloc_or_rloc(addr_str: str) -> bool:
     """Checks if an IPv6 address is a Leader ALOC or an RLOC."""
     addr = ipaddress.ip_address(addr_str)
@@ -369,12 +461,42 @@
     return is_aloc or is_rloc
 
 
+def is_dns_compression_used(p) -> bool:
+    """
+    Check if any DNS name in the packet is compressed.
+    """
+    dns = getattr(p, 'dns', None)
+    if not dns:
+        dns = getattr(p, 'mdns', None)
+    if not dns:
+        return False
+
+    try:
+        raw_layer = dns._layer
+        for field_name, field in raw_layer._all_fields.items():
+            # Check fields that might contain DNS names
+            if '.name' in field_name or '.target' in field_name or '.domain_name' in field_name:
+                fields = field.fields if hasattr(field, 'fields') else [field]
+                for f in fields:
+                    rv = f.raw_value
+                    if rv:
+                        for i in range(0, len(rv), 2):
+                            if int(rv[i:i + 2], 16) & 0xc0 == 0xc0:
+                                return True
+    except Exception:
+        pass
+
+    return False
+
+
 def apply_patches():
     CoapTlvParser.parse = staticmethod(thread_coap_tlv_parse)
 
     from pktverify import consts, layer_fields
     consts.VALID_LAYER_NAMES.add('wpan_tap')
     consts.VALID_LAYER_NAMES.add('wpan-tap')
+    consts.VALID_LAYER_NAMES.add('trel')
+    consts.REAL_LAYER_NAMES.add('trel')
 
     # Patch _get_candidate_layers to map wpan_tap to wpan-tap
     old_get_candidate_layers = layer_fields._get_candidate_layers
@@ -513,6 +635,15 @@
     layer_fields._LAYER_FIELDS['coap.tlv.bbr_seqno'] = layer_fields._auto
     layer_fields._LAYER_FIELDS['thread_meshcop.tlv.delay_timer'] = layer_fields._auto
     layer_fields._LAYER_FIELDS['mle.tlv.link_query_options'] = layer_fields._bytes
+    layer_fields._LAYER_FIELDS['dns.opt.data'] = layer_fields._list(layer_fields._bytes)
+    layer_fields._LAYER_FIELDS['dns.count.queries'] = layer_fields._auto
+    layer_fields._layer_containers.add('dns.opt')
+    layer_fields._layer_containers.add('dns.count')
+    layer_fields._LAYER_FIELDS['mdns.nsec'] = layer_fields._list(layer_fields._bytes)
+    layer_fields._LAYER_FIELDS['dns.a'] = layer_fields._list(layer_fields._str)
+    layer_fields._LAYER_FIELDS['mdns.a'] = layer_fields._list(layer_fields._str)
+    layer_fields._LAYER_FIELDS['dns.data'] = layer_fields._list(layer_fields._bytes)
+    layer_fields._LAYER_FIELDS['mdns.data'] = layer_fields._list(layer_fields._bytes)
 
     def which_tshark_patch():
         default_path = '/tmp/thread-wireshark/tshark'
@@ -577,6 +708,16 @@
             all_thread_nodes_mcast_addr[4:12] = prefix[0:8]
             consts.LINK_LOCAL_ALL_THREAD_NODES_MULTICAST_ADDRESS = Ipv6Addr(all_thread_nodes_mcast_addr)
 
+        # Add TREL UDP port variables before creating PacketVerifier (so PcapReader uses them)
+        # We only do this for TREL tests to avoid interference with other tests
+        # using similar UDP ports for regular Thread traffic.
+        testcase = data.get('testcase', '')
+        if 'TREL' in testcase:
+            for node_id, port in data.get('trel_udp_ports', {}).items():
+                port = int(port)
+                if port > 0:
+                    consts.WIRESHARK_DECODE_AS_ENTRIES[f'udp.port=={port}'] = 'trel'
+
         pv = PacketVerifier(json_file, wireshark_prefs=wireshark_prefs)
         pv.add_common_vars()
 
@@ -585,6 +726,13 @@
             name = pv.test_info.get_node_name(int(node_id))
             pv.add_vars(**{f'{name}_RLOC16': int(rloc16, 16)})
 
+        # Add TREL UDP port variables to pv.vars
+        for node_id, port in data.get('trel_udp_ports', {}).items():
+            name = pv.test_info.get_node_name(int(node_id))
+            port = int(port)
+            if port > 0:
+                pv.add_vars(**{f'{name}_TREL_PORT': port})
+
         # Add channel variables
         for node_id, channel in data.get('channels', {}).items():
             name = pv.test_info.get_node_name(int(node_id))
diff --git a/tests/scripts/expect/_common.exp b/tests/scripts/expect/_common.exp
index 7990880..988b016 100644
--- a/tests/scripts/expect/_common.exp
+++ b/tests/scripts/expect/_common.exp
@@ -118,6 +118,7 @@
             # avoid the error: "bind(sTxFd): Address already in use"
             sleep 0.2
             spawn /usr/bin/env GCOV_PREFIX=$gcov_prefix $ot_posix_apps/ot-cli $radio_url
+            sleep 0.1
             send "factoryreset\n"
             wait_for "state" "disabled"
             expect_line "Done"
@@ -127,6 +128,7 @@
         cli {
             spawn /usr/bin/env GCOV_PREFIX=$gcov_prefix $ot_simulation_apps/cli/ot-cli-ftd \
                 -L$ot_simulation_local_host $id
+            sleep 0.1
             send "factoryreset\n"
             wait_for "state" "disabled"
             expect_line "Done"
@@ -136,6 +138,7 @@
         mtd {
             spawn /usr/bin/env GCOV_PREFIX=$gcov_prefix $ot_simulation_apps/cli/ot-cli-mtd \
                 -L$ot_simulation_local_host $id
+            sleep 0.1
             send "factoryreset\n"
             wait_for "state" "disabled"
             expect_line "Done"
@@ -307,6 +310,8 @@
 
     send "tcat start\n"
     expect_line "Done"
+    send "tcat active\n"
+    expect_line "Done"
 
     spawn python "tools/tcat_ble_client/bbtc.py" --simulation $id --cert_path $cert_path
     expect_line "Done"
diff --git a/tests/scripts/expect/cli-tcat-decommission.exp b/tests/scripts/expect/cli-tcat-decommission.exp
index cfd5e58..3825dac 100755
--- a/tests/scripts/expect/cli-tcat-decommission.exp
+++ b/tests/scripts/expect/cli-tcat-decommission.exp
@@ -53,6 +53,9 @@
 expect_line "\tTYPE:\tRESPONSE_W_STATUS"
 expect_line "\tVALUE:\t0x00"
 
+send "disconnect\n"
+expect_line "Done"
+
 dispose_tcat_client 1
 
 switch_node 1
diff --git a/tests/scripts/expect/cli-tcat-hashes.exp b/tests/scripts/expect/cli-tcat-hashes.exp
index 631235f..5f7722c 100755
--- a/tests/scripts/expect/cli-tcat-hashes.exp
+++ b/tests/scripts/expect/cli-tcat-hashes.exp
@@ -31,6 +31,9 @@
 
 spawn_node 1 "cli"
 
+# Sleep > 50 seconds to ensure the maximum number of unsuccessful hash verification attempts (10) are permitted
+sleep 51
+
 spawn_tcat_client_for_node 1 tools/tcat_ble_client/auth-cert/CommCert2
 
 send "commission\n"
@@ -41,11 +44,6 @@
 expect_line "\tTYPE:\tRESPONSE_W_PAYLOAD"
 expect_line "\tLEN:\t8"
 
-send "peer_pskd_hash JJJJJJ\n"
-expect_line "Requested hash is valid."
-expect_line "\tTYPE:\tRESPONSE_W_PAYLOAD"
-expect_line "\tLEN:\t32"
-
 send "present_hash pskd AAAA\n"
 expect_line "\tTYPE:\tRESPONSE_W_STATUS"
 expect_line "\tVALUE:\t0x07"
@@ -74,6 +72,27 @@
 expect_line "\tTYPE:\tRESPONSE_W_STATUS"
 expect_line "\tVALUE:\t0x07"
 
+# Sleep >> 5 seconds to ensure a few more hash verification attempts are allowed
+sleep 20
+
+# Try hash verification >10 times to enforce rate limitation
+for {set i 0} {$i < 11} {incr i} {
+    send "present_hash pskd AAAA\n"
+    expect_line "\tTYPE:\tRESPONSE_W_STATUS"
+}
+
+send "present_hash pskd AAAA\n"
+expect_line "\tTYPE:\tRESPONSE_W_STATUS"
+expect_line "\tVALUE:\t0x05"
+
+# Sleep >5 seconds to ensure one more hash verification attempt is allowed
+sleep 5.5
+
+send "present_hash pskd AAAA\n"
+expect_line "\tTYPE:\tRESPONSE_W_STATUS"
+expect_line "\tVALUE:\t0x07"
+
+
 dispose_tcat_client 1
 
 switch_node 1
diff --git a/tests/scripts/expect/v1_5-cli-p2p-link.exp b/tests/scripts/expect/v1_5-cli-p2p-link.exp
deleted file mode 100755
index b8b0b3b..0000000
--- a/tests/scripts/expect/v1_5-cli-p2p-link.exp
+++ /dev/null
@@ -1,237 +0,0 @@
-#!/usr/bin/expect -f
-
-#
-#  Copyright (c) 2025, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-source "tests/scripts/expect/_common.exp"
-source "tests/scripts/expect/_multinode.exp"
-
-set WL1 1
-set WL2 2
-set WC1 3
-set WC2 4
-set WL1_EXT_ADDRESS "deadbeefcafe0001"
-set WL2_EXT_ADDRESS "deadbeefcafe0002"
-set WC1_EXT_ADDRESS "deadbeefcafe0003"
-set WC2_EXT_ADDRESS "deadbeefcafe0004"
-
-
-send_user "\n\n>>> Setup WL1\n"
-spawn_node $WL1
-
-setup_default_network
-
-send "mode rdn\n"
-expect_line "Done"
-
-send "extaddr $WL1_EXT_ADDRESS\n"
-expect_line "Done"
-
-send "wakeup channel 11\n"
-expect_line "Done"
-
-send "routerupgradethreshold 0\n"
-expect_line "Done"
-
-send "ifconfig up\n"
-expect_line "Done"
-
-send "thread start\n"
-expect_line "Done"
-
-send "wakeup listen enable\n"
-expect_line "Done"
-
-set wl1_link_local_addr [get_ipaddr linklocal]
-
-
-send_user "\n\n>>> Setup WL2\n"
-spawn_node $WL2
-
-setup_default_network
-
-send "mode rdn\n"
-expect_line "Done"
-
-send "extaddr $WL2_EXT_ADDRESS\n"
-expect_line "Done"
-
-send "wakeup channel 11\n"
-expect_line "Done"
-
-send "routerupgradethreshold 0\n"
-expect_line "Done"
-
-send "ifconfig up\n"
-expect_line "Done"
-
-send "thread start\n"
-expect_line "Done"
-
-send "wakeup listen enable\n"
-expect_line "Done"
-
-set wl2_link_local_addr [get_ipaddr linklocal]
-
-
-send_user "\n\n>>> Setup WC1\n"
-spawn_node $WC1
-setup_default_network
-
-send "mode rdn\n"
-expect_line "Done"
-
-send "extaddr $WC1_EXT_ADDRESS\n"
-expect_line "Done"
-
-send "wakeup channel 11\n"
-expect_line "Done"
-
-send "routerupgradethreshold 0\n"
-expect_line "Done"
-
-send "ifconfig up\n"
-expect_line "Done"
-
-send "thread start\n"
-expect_line "Done"
-
-set wc1_link_local_addr [get_ipaddr linklocal]
-
-
-send_user "\n\n>>> Setup WC2\n"
-spawn_node $WC2
-setup_default_network
-
-send "mode rdn\n"
-expect_line "Done"
-
-send "extaddr $WC2_EXT_ADDRESS\n"
-expect_line "Done"
-
-send "wakeup channel 11\n"
-expect_line "Done"
-
-send "routerupgradethreshold 0\n"
-expect_line "Done"
-
-send "ifconfig up\n"
-expect_line "Done"
-
-send "thread start\n"
-expect_line "Done"
-
-set wc2_link_local_addr [get_ipaddr linklocal]
-
-
-send_user "\n\n>>> WC1 establishes a P2P link with WL1\n"
-switch_node $WC1
-send "p2p link extaddr $WL1_EXT_ADDRESS\n"
-expect_line "Done"
-
-send_user "\n\n>>> WC1 pings WL1\n"
-send "ping $wl1_link_local_addr 20 5\n"
-for {set i 1} {$i <= 5} {incr i} {
-    expect "28 bytes from $wl1_link_local_addr: icmp_seq=$i"
-}
-
-sleep 1
-
-
-send_user "\n\n>>> WC1 establishes a P2P link with WL2\n"
-send "p2p link extaddr $WL2_EXT_ADDRESS\n"
-expect_line "Done"
-
-sleep 1
-
-send_user "\n\n>>> WC1 pings WL2\n"
-send "ping $wl2_link_local_addr 20 5\n"
-for {set i 6} {$i <= 10} {incr i} {
-    expect "28 bytes from $wl2_link_local_addr: icmp_seq=$i"
-}
-
-
-send_user "\n\n>>> WC2 establishes a P2P link with WL1\n"
-switch_node $WC2
-send "p2p link extaddr $WL1_EXT_ADDRESS\n"
-expect_line "Done"
-
-sleep 1
-
-send_user "\n\n>>> WC2 pings WL1\n"
-send "ping $wl1_link_local_addr 20 5\n"
-for {set i 1} {$i <= 5} {incr i} {
-    expect "28 bytes from $wl1_link_local_addr: icmp_seq=$i"
-}
-
-
-send_user "\n\n>>> WL1 pings WC1\n"
-switch_node $WL1
-
-send "ping $wc1_link_local_addr 20 5\n"
-for {set i 1} {$i <= 5} {incr i} {
-    expect "28 bytes from $wc1_link_local_addr: icmp_seq=$i"
-}
-
-
-send_user "\n\n>>> WL1 pings WC2\n"
-send "ping $wc2_link_local_addr 20 5\n"
-for {set i 6} {$i <= 10} {incr i} {
-    expect "28 bytes from $wc2_link_local_addr: icmp_seq=$i"
-}
-
-
-send_user "\n\n>>> WL2 pings WC1\n"
-switch_node $WL2
-
-send "ping $wc1_link_local_addr 20 5\n"
-for {set i 1} {$i <= 5} {incr i} {
-    expect "28 bytes from $wc1_link_local_addr: icmp_seq=$i"
-}
-
-
-send_user "\n\n>>> WC1 tears down the P2P link with WL1\n"
-switch_node $WC1
-send "p2p unlink $WL1_EXT_ADDRESS\n"
-expect_line "Done"
-
-
-send_user "\n\n>>> WC2 tears down the P2P link with WL1\n"
-switch_node $WC2
-send "p2p unlink $WL1_EXT_ADDRESS\n"
-expect_line "Done"
-
-
-send_user "\n\n>>> WL2 tears down the P2P link with WC1\n"
-switch_node $WL2
-send "p2p unlink $WC1_EXT_ADDRESS\n"
-expect_line "Done"
-
-sleep 1
-
-dispose_all
diff --git a/tests/scripts/thread-cert/backbone/bbr_5_11_01.py b/tests/scripts/thread-cert/backbone/bbr_5_11_01.py
deleted file mode 100755
index 63d2b08..0000000
--- a/tests/scripts/thread-cert/backbone/bbr_5_11_01.py
+++ /dev/null
@@ -1,172 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-import logging
-import unittest
-
-import config
-import thread_cert
-# Test description: Here is the test case `5.11.1 DUA-TC-04: DUA re-registration`
-#
-# Topology:
-#    -----------(eth)----------------
-#                  |     |
-#     Router_1----BR_1---HOST
-#        \     /
-#        Router_2
-#
-from pktverify.packet_verifier import PacketVerifier
-
-BR_1 = 1
-ROUTER1 = 2
-ROUTER2 = 3
-HOST = 4
-
-
-class BBR_5_11_01(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'allowlist': [ROUTER1, ROUTER2],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        ROUTER1: {
-            'name': 'Router_1',
-            'allowlist': [ROUTER2, BR_1],
-            'version': '1.2',
-        },
-        ROUTER2: {
-            'name': 'Router_2',
-            'allowlist': [ROUTER1, BR_1],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        self.nodes[HOST].start()
-        # P1: Router_1 is configured with leader weight of 72 in case the test is executed on a CCM network
-
-        self.nodes[ROUTER1].set_weight(72)
-        self.nodes[ROUTER1].start()
-
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[ROUTER1].get_state())
-
-        self.nodes[ROUTER2].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER2].get_state())
-
-        # The OTBR docker enables SRP Server by default, lets explicitly
-        # disable SRP server to avoid Network Data population.
-        # TODO: Enhance the test script to tolerate additional Sertivce TLV
-        # in Network Data.
-        self.nodes[BR_1].srp_server_set_enabled(False)
-        self.nodes[BR_1].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[BR_1].get_state())
-        self.nodes[BR_1].enable_backbone_router()
-        self.simulator.go(3)
-        self.assertTrue(self.nodes[BR_1].is_primary_backbone_router)
-        self.nodes[BR_1].add_prefix(config.DOMAIN_PREFIX, "parosD")
-        self.nodes[BR_1].register_netdata()
-
-        self.simulator.go(5)
-        self.assertIsNotNone(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        self.simulator.go(10)  # must wait for DUA_DAD_REPEATS to complete
-        logging.info("Host addresses: %r", self.nodes[HOST].get_addrs())
-        self.assertGreaterEqual(len(self.nodes[HOST].get_addrs()), 2)
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        Dg = self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.DUA)
-        self.collect_extra_vars(Dg=Dg)
-
-        logging.info("BR_1 addrs: %r", self.nodes[BR_1].get_addrs())
-        logging.info("Host addrs: %r", self.nodes[HOST].get_addrs())
-
-        # BR_1 and Host can ping each other on the Backbone link
-        self.assertTrue(self.nodes[HOST].ping(self.nodes[BR_1].get_ip6_address(config.ADDRESS_TYPE.BACKBONE_GUA),
-                                              backbone=True))
-        self.assertTrue(self.nodes[BR_1].ping(self.nodes[HOST].get_ip6_address(config.ADDRESS_TYPE.BACKBONE_GUA),
-                                              backbone=True))
-
-        # Step 23: Host sends ping packet to destination Dg (successful if DUA features work)
-        self.assertTrue(self.nodes[HOST].ping(Dg, backbone=True))
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-        pv.verify_attached('BR_1')
-
-        MM = pv.vars['MM_PORT']
-        BB = pv.vars['BB_PORT']
-        BR_1 = pv.vars['BR_1']
-        BR_1_ETH = pv.vars['BR_1_ETH']
-        Host_ETH = pv.vars['Host_ETH']
-        BR_1_BGUA = pv.vars['BR_1_BGUA']
-        Host_BGUA = pv.vars['Host_BGUA']
-        Dg = pv.vars['Dg']  # DUA of Router_2
-        ROUTER2 = pv.vars['Router_2']
-
-        # Step 3: BR_1: Checks received Network Data and determines that it needs to send its BBR Dataset to the
-        #               leader to become primary BBR.
-        pkts.filter_wpan_src64(BR_1).filter_coap_request('/a/sd', port=MM).must_next().must_verify("""
-            thread_nwd.tlv.server_16 is not null
-            and thread_nwd.tlv.service.s_data.seqno is not null
-            and thread_nwd.tlv.service.s_data.rrdelay is not null
-            and thread_nwd.tlv.service.s_data.mlrtimeout is not null
-        """)
-
-        pv.verify_dua_registration(ROUTER2, Dg, pbbr_eth=BR_1_ETH, pbbr_src64=BR_1)
-
-        # Verify Host ping BBR
-        pkts.filter_eth_src(Host_ETH).filter_ipv6_src_dst(Host_BGUA, BR_1_BGUA).filter_ping_request().must_next()
-        pkts.filter_eth_src(BR_1_ETH).filter_ipv6_src_dst(BR_1_BGUA, Host_BGUA).filter_ping_reply().must_next()
-
-        # Verify BR_1 ping Host
-        pkts.filter_eth_src(BR_1_ETH).filter_ipv6_src_dst(BR_1_BGUA, Host_BGUA).filter_ping_request().must_next()
-        pkts.filter_eth_src(Host_ETH).filter_ipv6_src_dst(Host_BGUA, BR_1_BGUA).filter_ping_reply().must_next()
-
-        # Step 16: Host: Queries DUA, Dg, with ND-NS
-        pkts.filter_eth_src(Host_ETH).filter_icmpv6_nd_ns(Dg).must_next()
-
-        # Step 17: BR_1: Responds with a neighbor advertisement.
-        pkts.filter_eth_src(BR_1_ETH).filter_icmpv6_nd_na(Dg).must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_bmlr.py b/tests/scripts/thread-cert/backbone/test_bmlr.py
deleted file mode 100755
index 335d905..0000000
--- a/tests/scripts/thread-cert/backbone/test_bmlr.py
+++ /dev/null
@@ -1,202 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that PBBR sends BMLR.ntf correctly when multicast addresses are registered.
-#
-# Topology:
-#    ---- -(eth)-------
-#       |        |
-#      PBBR----SBBR
-#        \     /
-#        Router1---Commissioner
-#
-
-import unittest
-
-import thread_cert
-import config
-from pktverify.packet_verifier import PacketVerifier
-
-PBBR = 1
-SBBR = 2
-ROUTER1 = 3
-COMMISSIONER = 4
-
-REREG_DELAY = 4  # Seconds
-MLR_TIMEOUT = 300  # Seconds
-CUSTOM_MLR_TIMEOUT = 1000  # Seconds
-
-MA1 = 'ff04::1'
-MA2 = 'ff04::2'
-MA3 = 'ff04::3'
-MA4 = 'ff04::4'
-MA5 = 'ff04::5'
-
-
-class BBR_5_11_01(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        PBBR: {
-            'name': 'PBBR',
-            'allowlist': [SBBR, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        SBBR: {
-            'name': 'SBBR',
-            'allowlist': [PBBR, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'allowlist': [PBBR, SBBR, COMMISSIONER],
-            'version': '1.2',
-        },
-        COMMISSIONER: {
-            'name': 'COMMISSIONER',
-            'allowlist': [ROUTER1],
-            'version': '1.2',
-        }
-    }
-
-    def test(self):
-        self.nodes[PBBR].start()
-        self.wait_node_state(PBBR, 'leader', 10)
-        self.nodes[PBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[PBBR].enable_backbone_router()
-        self.wait_until(lambda: self.nodes[PBBR].is_primary_backbone_router, 5)
-
-        self.nodes[SBBR].start()
-        self.wait_node_state(SBBR, 'router', 5)
-        self.nodes[SBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[SBBR].enable_backbone_router()
-        self.simulator.go(5)
-        self.assertFalse(self.nodes[SBBR].is_primary_backbone_router)
-
-        self.nodes[ROUTER1].start()
-        self.wait_node_state(ROUTER1, 'router', 5)
-
-        self.nodes[COMMISSIONER].start()
-        self.wait_node_state(COMMISSIONER, 'router', 5)
-
-        self.wait_route_established(COMMISSIONER, PBBR)
-
-        self.nodes[COMMISSIONER].commissioner_start()
-        self.simulator.go(10)
-        self.assertEqual('active', self.nodes[COMMISSIONER].commissioner_state())
-
-        self.nodes[PBBR].add_ipmaddr(MA1)
-        self.simulator.go(REREG_DELAY)
-        self.nodes[ROUTER1].add_ipmaddr(MA2)
-        self.simulator.go(REREG_DELAY)
-
-        # Commissioner registers MA3 with default timeout
-        self.assertEqual((0, []), self.nodes[COMMISSIONER].register_multicast_listener(MA3, timeout=None))
-        # Commissioner registers MA4 with a custom timeout
-        self.assertEqual((0, []), self.nodes[COMMISSIONER].register_multicast_listener(MA4,
-                                                                                       timeout=CUSTOM_MLR_TIMEOUT))
-        # Commissioner unregisters MA5
-        self.assertEqual((0, []), self.nodes[COMMISSIONER].register_multicast_listener(MA5, timeout=0))
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-        pv.verify_attached('ROUTER1')
-
-        ROUTER1 = pv.vars['ROUTER1']
-        COMMISSIONER = pv.vars['COMMISSIONER']
-        PBBR_ETH = pv.vars['PBBR_ETH']
-        SBBR_ETH = pv.vars['SBBR_ETH']
-
-        # Verify SBBR must not send `/b/bmr` during the test.
-        pkts.filter_eth_src(SBBR_ETH).filter_coap_request('/b/bmr').must_not_next()
-
-        # Verify PBBR sends `/b/bmr` on the Backbone link for MA1 with default timeout.
-        pkts.filter_eth_src(PBBR_ETH).filter_coap_request('/b/bmr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA1}']
-            and thread_bl.tlv.timeout == {MLR_TIMEOUT}
-            and ipv6.src.is_link_local
-        """)
-
-        # Router registers MA2 with default timeout
-        pkts.filter_wpan_src64(ROUTER1).filter_coap_request('/n/mr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA2}']
-            and thread_bl.tlv.timeout is null
-        """)
-        # Verify PBBR sends `/b/bmr` on the Backbone link for MA2 with default timeout.
-        pkts.filter_eth_src(PBBR_ETH).filter_coap_request('/b/bmr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA2}']
-            and thread_bl.tlv.timeout == {MLR_TIMEOUT}
-            and ipv6.src.is_link_local
-        """)
-
-        # Commissioner registers MA3 with default timeout
-        pkts.filter_wpan_src64(COMMISSIONER).filter_coap_request('/n/mr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA3}']
-            and thread_bl.tlv.timeout is null
-        """)
-        # Verify PBBR sends `/b/bmr` on the Backbone link for MA3 with default timeout.
-        pkts.filter_eth_src(PBBR_ETH).filter_coap_request('/b/bmr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA3}']
-            and thread_bl.tlv.timeout == {MLR_TIMEOUT}
-            and ipv6.src.is_link_local
-        """)
-
-        # Commissioner registers MA4 with custom timeout
-        pkts.filter_wpan_src64(COMMISSIONER).filter_coap_request('/n/mr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA4}']
-            and thread_nm.tlv.timeout == {CUSTOM_MLR_TIMEOUT}
-        """)
-        # Verify PBBR sends `/b/bmr` on the Backbone link for MA4 with custom timeout.
-        pkts.filter_eth_src(PBBR_ETH).filter_coap_request('/b/bmr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA4}']
-            and thread_bl.tlv.timeout == {CUSTOM_MLR_TIMEOUT}
-            and ipv6.src.is_link_local
-        """)
-
-        # Commissioner unregisters MA5
-        pkts.filter_wpan_src64(COMMISSIONER).filter_coap_request('/n/mr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA5}']
-            and thread_nm.tlv.timeout == 0
-        """)
-        # Verify PBBR sends `/b/bmr` on the Backbone link for MA5 with timeout equal to zero.
-        pkts.filter_eth_src(PBBR_ETH).filter_coap_request('/b/bmr').must_next().must_verify(f"""
-            thread_meshcop.tlv.ipv6_addr == ['{MA5}']
-            and thread_bl.tlv.timeout == 0
-            and ipv6.src.is_link_local
-        """)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_dua_dad.py b/tests/scripts/thread-cert/backbone/test_dua_dad.py
deleted file mode 100755
index 9fe2a87..0000000
--- a/tests/scripts/thread-cert/backbone/test_dua_dad.py
+++ /dev/null
@@ -1,312 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that PBBR sets DUA routes correctly.
-#
-import ipaddress
-import re
-import unittest
-
-import config
-import thread_cert
-from pktverify.packet_verifier import PacketVerifier
-
-# Use two channels
-CH1 = 11
-CH2 = 22
-
-PBBR = 1
-SBBR = 2
-ROUTER1 = 3
-HOST = 4
-PBBR2 = 5
-ROUTER2 = 6
-
-REREG_DELAY = 5  # Seconds
-MLR_TIMEOUT = 300  # Seconds
-WAIT_REDUNDANCE = 3
-
-DUPLICATE_DUA = 'fd00:7d03:7d03:7d03:11:2233:4455:6677'
-assert (re.match(config.DOMAIN_PREFIX_REGEX_PATTERN, DUPLICATE_DUA))
-
-DUPLICATE_IID = ''.join('%02x' % c for c in ipaddress.IPv6Address(DUPLICATE_DUA).packed[-8:])
-
-print('DUPLICATE_DUA: ', DUPLICATE_DUA)
-print('DUPLICATE_IID: ', DUPLICATE_IID)
-
-
-class TestDuaDad(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    # Topology:
-    #    ------(eth)----------------------
-    #       |        |       |      |
-    #      PBBR----SBBR    HOST   PBBR2
-    #        \ CH1 /               | CH2
-    #        ROUTER1             ROUTER2
-    #
-    # PBBR2 is in the secondary channel
-    #
-    TOPOLOGY = {
-        PBBR: {
-            'name': 'PBBR',
-            'allowlist': [SBBR, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-            'channel': CH1,
-            'prefer_router_id': 0x1,  # Use prefer_router_id to avoid Router ID conflicts in the two channels.
-        },
-        SBBR: {
-            'name': 'SBBR',
-            'allowlist': [PBBR, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-            'channel': CH1,
-            'prefer_router_id': 0x2,
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'allowlist': [PBBR, SBBR],
-            'version': '1.2',
-            'channel': CH1,
-            'prefer_router_id': 0x3,
-        },
-        HOST: {
-            'name': 'HOST',
-            'is_host': True
-        },
-        PBBR2: {
-            'name': 'PBBR2',
-            'is_otbr': True,
-            'version': '1.2',
-            'channel': CH2,
-            'prefer_router_id': 0x4,
-        },
-        ROUTER2: {
-            'name': 'ROUTER2',
-            'version': '1.2',
-            'channel': CH2,
-            'prefer_router_id': 0x5,
-        },
-    }
-
-    def _bootstrap(self):
-        # Bring up HOST
-        self.nodes[HOST].start()
-
-        # Bring up PBBR
-        self.nodes[PBBR].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR].get_state())
-        self.wait_node_state(PBBR, 'leader', 10)
-
-        self.nodes[PBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[PBBR].enable_backbone_router()
-        self.nodes[PBBR].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        self.simulator.go(5)
-        self.assertTrue(self.nodes[PBBR].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[PBBR].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up SBBR
-        self.nodes[SBBR].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[SBBR].get_state())
-
-        self.nodes[SBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[SBBR].enable_backbone_router()
-        self.simulator.go(5)
-        self.assertFalse(self.nodes[SBBR].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[SBBR].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up ROUTER1
-        self.nodes[ROUTER1].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER1].get_state())
-        self.simulator.go(config.DUA_DAD_DELAY + WAIT_REDUNDANCE)
-        self.assertIsNotNone(self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up PBBR2
-        self.nodes[PBBR2].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR2].get_state())
-        self.wait_node_state(PBBR2, 'leader', 10)
-
-        self.nodes[PBBR2].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[PBBR2].enable_backbone_router()
-        self.nodes[PBBR2].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        self.simulator.go(config.DUA_DAD_DELAY)
-        self.assertTrue(self.nodes[PBBR2].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[PBBR2].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up ROUTER2
-        self.nodes[ROUTER2].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER2].get_state())
-        self.simulator.go(config.DUA_DAD_DELAY + WAIT_REDUNDANCE)
-        self.assertIsNotNone(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-    def test(self):
-        self._bootstrap()
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-
-        self._test_extend_backbone_query()
-
-        self._test_dad_duplicate()
-
-        self._send_fake_pro_bb_ntf()
-
-    def _test_extend_backbone_query(self):
-        self.nodes[ROUTER2].ping(self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-    def _test_dad_duplicate(self):
-        self.nodes[ROUTER2].set_dua_iid(DUPLICATE_IID)
-        self.simulator.go(config.DUA_DAD_DELAY + WAIT_REDUNDANCE)
-        self.nodes[ROUTER1].set_dua_iid(DUPLICATE_IID)
-        self.simulator.go(config.DUA_DAD_DELAY + WAIT_REDUNDANCE)
-
-        # now Router1 should have generated new DUA
-        self.assertNotEqual(DUPLICATE_DUA, self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-    def _send_fake_pro_bb_ntf(self):
-        router1_dua = self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA)
-        self.nodes[PBBR2].send_proactive_backbone_notification(router1_dua, DUPLICATE_IID, 0)
-        self.simulator.go(config.DUA_DAD_DELAY + WAIT_REDUNDANCE)
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-
-        PBBR = pv.vars['PBBR']
-        ROUTER1 = pv.vars['ROUTER1']
-        ROUTER1_DUA = pv.vars['ROUTER1_DUA']
-        ROUTER2 = pv.vars['ROUTER2']
-        ROUTER2_DUA = pv.vars['ROUTER2_DUA']
-        PBBR_ETH = pv.vars['PBBR_ETH']
-        SBBR_ETH = pv.vars['SBBR_ETH']
-        PBBR2 = pv.vars['PBBR2']
-        PBBR2_ETH = pv.vars['PBBR2_ETH']
-
-        MM = pv.vars['MM_PORT']
-        BB = pv.vars['BB_PORT']
-
-        # Verify the whole DAD process for ROUTER1
-        pv.verify_dua_registration(ROUTER1, ROUTER1_DUA, pbbr_eth=PBBR_ETH, pbbr_src64=PBBR, sbbr_eth=SBBR_ETH)
-
-        # Verify the whole DAD process for ROUTER2
-        pv.verify_dua_registration(ROUTER2, ROUTER2_DUA, pbbr_eth=PBBR2_ETH, pbbr_src64=PBBR2)
-
-        ###############################################################################################################
-        # Now we verify Extending ADDR.qry to BB.qry works for the Ping Request from Router2 to Router1
-        ###############################################################################################################
-
-        # Router2 should send ADDR.qry in the Thread network for Router1's DUA
-        pkts.filter_wpan_src64(ROUTER2).filter_coap_request('/a/aq', port=MM).filter(
-            'thread_address.tlv.target_eid == {ROUTER1_DUA}', ROUTER1_DUA=ROUTER1_DUA).must_next()
-        # PBBR2 should extend ADDR.qry to BB.qry
-        pkts.filter_backbone_query(ROUTER1_DUA, eth_src=PBBR2_ETH, port=BB).must_next()
-        # SBBR should not answer with BB.ans
-        pkts.filter_backbone_query(ROUTER1_DUA, eth_src=SBBR_ETH, port=BB).must_not_next()
-        # PBBR1 should answer with BB.ans
-        pkts.filter_backbone_answer(ROUTER1_DUA, eth_src=PBBR_ETH, port=BB).must_next()
-        # PBBR2 should send ADDR.ntf to Router2
-        pkts.filter_wpan_src64(PBBR2).filter_coap_request('/a/an', port=MM).filter(
-            'thread_address.tlv.target_eid == {ROUTER1_DUA}', ROUTER1_DUA=ROUTER1_DUA).must_next()
-
-        # Now, Router2 should send the Ping Request to PBBR2
-        pkts.filter_wpan_src64(ROUTER2).filter_ping_request().filter_ipv6_dst(ROUTER1_DUA).must_next()
-
-        ###############################################################################################################
-        # Now we start to verify that DAD duplicate is handled correctly
-        ###############################################################################################################
-
-        # PBBR should send /b/bq for DUPLICATE_DUA
-        pkts.filter_backbone_query(DUPLICATE_DUA, eth_src=PBBR_ETH, port=BB).must_next()
-
-        # PBBR2 should send /b/ba for DUPLICATE_DUA
-        ba = pkts.filter_backbone_answer(DUPLICATE_DUA, eth_src=PBBR2_ETH, port=BB).must_next()
-        ba.must_verify("""
-            set(thread_bl.tlv.type) == {tlvs}
-            and thread_bl.tlv.last_transaction_time > 0
-            and thread_meshcop.tlv.net_name == [{NET_NAME}]
-        """,
-                       tlvs={0, 3, 6, 12},
-                       **pv.vars)
-
-        # PBBR should NOT send /b/bq for DUPLICATE_DUA anymore
-        pkts.filter_backbone_query(DUPLICATE_DUA, eth_src=PBBR_ETH, port=BB).must_not_next()
-
-        # PBBR should send ADDR_ERR.ntf to Router1
-        ROUTER1_RLOC = pv.vars['ROUTER1_RLOC']
-        pkts.filter_wpan_src64(PBBR).filter_ipv6_dst(ROUTER1_RLOC).filter_coap_request('/a/ae', port=MM).must_next() \
-            .must_verify("""
-                thread_address.tlv.target_eid == {DUPLICATE_DUA}
-                and thread_address.tlv.ml_eid == {ml_eid} 
-            """, DUPLICATE_DUA=DUPLICATE_DUA, ml_eid=ba.thread_bl.tlv.ml_eid)
-
-        # Router1 should generate new DUA and register again
-        # Router1 should generate and register a new DUA
-        with pkts.save_index():
-            new_dr = pkts.filter_wpan_src64(ROUTER1).filter_coap_request('/n/dr', port=MM).must_next().must_verify(
-                """
-                thread_nm.tlv.target_eid != {DUPLICATE_DUA}
-            """,
-                DUPLICATE_DUA=DUPLICATE_DUA)
-            router1_dua2 = new_dr.thread_nm.tlv.target_eid
-
-        # Verify the DAD process for the new DUA
-        pv.verify_dua_registration(ROUTER1, router1_dua2, pbbr_eth=PBBR_ETH, pbbr_src64=PBBR, sbbr_eth=SBBR_ETH)
-
-        ###############################################################################################################
-        # Now we start to verify that PRO_BB.ntf is handled correctly
-        ###############################################################################################################
-
-        # Scripted: PBBR2 should send PRO_BB.ntf for Router1's DUA
-        pkts.filter_eth_src(PBBR2_ETH).filter_backbone_answer(router1_dua2,
-                                                              port=BB,
-                                                              confirmable=False,
-                                                              mliid=DUPLICATE_IID).must_next()
-
-        # PBBR should broadcaset /a/ae to the Thread network
-        pkts.filter_wpan_src64(PBBR).filter_coap_request('/a/ae', port=MM,
-                                                         confirmable=False).filter_RLARMA().must_next()
-
-        # Router1 should generate and register a new DUA
-        with pkts.save_index():
-            new_dr = pkts.filter_wpan_src64(ROUTER1).filter_coap_request('/n/dr', port=MM).must_next()
-            router1_dua3 = new_dr.thread_nm.tlv.target_eid
-
-        # Verify the DAD process for the new DUA
-        pv.verify_dua_registration(ROUTER1, router1_dua3, pbbr_eth=PBBR_ETH, pbbr_src64=PBBR, sbbr_eth=SBBR_ETH)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_dua_routing.py b/tests/scripts/thread-cert/backbone/test_dua_routing.py
deleted file mode 100755
index fd469e1..0000000
--- a/tests/scripts/thread-cert/backbone/test_dua_routing.py
+++ /dev/null
@@ -1,249 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that PBBR sets DUA routes correctly.
-#
-import ipaddress
-import unittest
-
-import config
-import thread_cert
-from pktverify.packet_verifier import PacketVerifier
-
-# Use two channels
-CH1 = 11
-CH2 = 22
-
-PBBR = 1
-SBBR = 2
-ROUTER1 = 3
-HOST = 4
-PBBR2 = 5
-ROUTER2 = 6
-
-REREG_DELAY = 5  # Seconds
-MLR_TIMEOUT = 300  # Seconds
-WAIT_REDUNDANCE = 3
-
-
-class TestNdProxy(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    # Topology:
-    #    ------(eth)----------------------
-    #       |        |       |      |
-    #      PBBR----SBBR    HOST   PBBR2
-    #        \ CH1 /               | CH2
-    #        ROUTER1             ROUTER2
-    #
-    # PBBR2 is in the secondary channel
-    #
-    TOPOLOGY = {
-        PBBR: {
-            'name': 'PBBR',
-            'allowlist': [SBBR, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-            'channel': CH1,
-            'router_id_range': [0, 30],
-        },
-        SBBR: {
-            'name': 'SBBR',
-            'allowlist': [PBBR, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-            'channel': CH1,
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'allowlist': [PBBR, SBBR],
-            'version': '1.2',
-            'channel': CH1,
-        },
-        HOST: {
-            'name': 'HOST',
-            'is_host': True
-        },
-        PBBR2: {
-            'name': 'PBBR2',
-            'is_otbr': True,
-            'version': '1.2',
-            'channel': CH2,
-            'router_id_range': [31, 60],
-        },
-        ROUTER2: {
-            'name': 'ROUTER2',
-            'version': '1.2',
-            'channel': CH2,
-        },
-    }
-
-    def _bootstrap(self):
-        # Bring up HOST
-        self.nodes[HOST].start()
-
-        # Bring up PBBR
-        self.nodes[PBBR].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR].get_state())
-        self.wait_node_state(PBBR, 'leader', 10)
-
-        self.nodes[PBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[PBBR].enable_backbone_router()
-        self.nodes[PBBR].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        self.simulator.go(5)
-        self.assertTrue(self.nodes[PBBR].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[PBBR].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up SBBR
-        self.nodes[SBBR].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[SBBR].get_state())
-
-        self.nodes[SBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[SBBR].enable_backbone_router()
-        self.simulator.go(5)
-        self.assertFalse(self.nodes[SBBR].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[SBBR].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up ROUTER1
-        self.nodes[ROUTER1].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER1].get_state())
-        self.assertIsNotNone(self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up PBBR2
-        self.nodes[PBBR2].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR2].get_state())
-        self.wait_node_state(PBBR2, 'leader', 10)
-
-        self.nodes[PBBR2].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[PBBR2].enable_backbone_router()
-        self.nodes[PBBR2].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        self.simulator.go(5)
-        self.assertTrue(self.nodes[PBBR2].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[PBBR2].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up ROUTER2
-        self.nodes[ROUTER2].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER2].get_state())
-        self.assertIsNotNone(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-    def test(self):
-        self._bootstrap()
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Step 1: Host pings PBBR's DUA
-        self.assertTrue(self.nodes[HOST].ping(self.nodes[PBBR].get_ip6_address(config.ADDRESS_TYPE.DUA),
-                                              backbone=True))
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Step 2: Host pings SBBR's DUA
-        self.assertTrue(self.nodes[HOST].ping(self.nodes[SBBR].get_ip6_address(config.ADDRESS_TYPE.DUA),
-                                              backbone=True))
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Step 3: Host pings ROUTER1's DUA
-        self.assertTrue(self.nodes[HOST].ping(self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA),
-                                              backbone=True))
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Step 4: ROUTER2 pings ROUTER1 which should succeed
-        ROUTER1_DUA = self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA)
-        self.assertTrue(self.nodes[ROUTER2].ping(ROUTER1_DUA))
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def _get_mliid(self, nodeid: int) -> str:
-        mleid = self.nodes[nodeid].get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-        mliid = ipaddress.IPv6Address(mleid).packed[-8:]
-        return ''.join(['%02x' % b for b in mliid])
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-
-        PBBR = pv.vars['PBBR']
-        PBBR2 = pv.vars['PBBR2']
-        ROUTER1 = pv.vars['ROUTER1']
-        ROUTER2 = pv.vars['ROUTER2']
-        SBBR = pv.vars['SBBR']
-        PBBR_DUA = pv.vars['PBBR_DUA']
-        SBBR_DUA = pv.vars['SBBR_DUA']
-        ROUTER1_DUA = pv.vars['ROUTER1_DUA']
-        ROUTER2_DUA = pv.vars['ROUTER2_DUA']
-        HOST_BGUA = pv.vars['HOST_BGUA']
-        PBBR_ETH = pv.vars['PBBR_ETH']
-        PBBR2_ETH = pv.vars['PBBR2_ETH']
-
-        # Verify that SBBR should not forward any Ping Request to the Thread network.
-        # Use `ipv6.hlim == 63` to avoid false fails because SBBR might still forward Ping Request from PBBR to ROUTER1
-        pkts.filter_wpan_src64(SBBR).filter_ping_request().filter('ipv6.hlim == 63').must_not_next()
-
-        # Step 1: Host pings PBBR's DUA
-        # PBBR should not forward the Ping Request to Thread network
-        ping1_pkts = pkts.filter_ipv6_src_dst(HOST_BGUA, PBBR_DUA).filter_ping_request()
-        ping1_pkts.filter_eth().must_next()
-        ping1_pkts.filter_wpan().must_not_next()
-
-        # Step 2: Host pings SBBR's DUA
-        # PBBR should forward the Ping Request to Thread network according to DUA routes
-        ping2_pkts = pkts.filter_ipv6_src_dst(HOST_BGUA, SBBR_DUA).filter_ping_request()
-        ping2_pkts.filter_eth().must_next()
-        ping2_pkts.filter_wpan().must_next()
-
-        # Step 3: Host pings ROUTER1's DUA
-        # PBBR should forward the Ping Request to Thread network according to DUA routes
-        ping3_pkts = pkts.filter_ipv6_src_dst(HOST_BGUA, ROUTER1_DUA).filter_ping_request()
-        ping3_pkts.filter_eth().must_next()
-        ping3_pkts.filter_wpan().must_next()
-
-        # Step 4: ROUTER2 pings ROUTER1
-        # Verify Ping Request: ROUTER2 -> PBBR2 -> PBBR -> ROUTER1
-        ping4_request_pkts = pkts.filter_ipv6_src_dst(ROUTER2_DUA, ROUTER1_DUA)
-        ping4_id = ping4_request_pkts.filter_wpan_src64(
-            ROUTER2).filter_ping_request().must_next().icmpv6.echo.identifier
-        ping4_request_pkts.filter_eth_src(PBBR2_ETH).filter_ping_request(identifier=ping4_id).must_next()
-        ping4_request_pkts.filter_wpan_src64(PBBR).filter_ping_request(identifier=ping4_id).must_next()
-        # Verify Ping Reply: ROUTER1 -> PBBR -> PBBR2 -> ROUTER2
-        ping4_reply_pkts = pkts.filter_ipv6_src_dst(ROUTER1_DUA, ROUTER2_DUA).filter_ping_reply(identifier=ping4_id)
-        ping4_reply_pkts.filter_eth_src(PBBR_ETH).must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_dua_routing_med.py b/tests/scripts/thread-cert/backbone/test_dua_routing_med.py
deleted file mode 100755
index fd908b8..0000000
--- a/tests/scripts/thread-cert/backbone/test_dua_routing_med.py
+++ /dev/null
@@ -1,275 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that PBBR sets DUA routes correctly.
-#
-import ipaddress
-import unittest
-
-import config
-import thread_cert
-from pktverify.consts import ADDR_QRY_URI, BACKBONE_QUERY_URI, BACKBONE_ANSWER_URI, ADDR_NTF_URI
-from pktverify.packet_verifier import PacketVerifier
-
-# Use two channels
-CH1 = 11
-CH2 = 22
-
-PBBR = 1
-ROUTER1 = 2
-MED1 = 3
-HOST = 4
-PBBR2 = 5
-MED2 = 6
-
-REREG_DELAY = 5  # Seconds
-MLR_TIMEOUT = 300  # Seconds
-WAIT_REDUNDANCE = 3
-
-
-class TestDuaRoutingMed(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    # Topology:
-    #    ------(eth)----------------------
-    #       |        |       |
-    #      PBBR     HOST   PBBR2
-    #     / CH1\            | CH2
-    #   MED1  ROUTER1      MED2
-    #
-    # PBBR2 is in the secondary channel
-    #
-    TOPOLOGY = {
-        PBBR: {
-            'name': 'PBBR',
-            'allowlist': [MED1, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-            'channel': CH1,
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'allowlist': [PBBR],
-            'version': '1.2',
-            'channel': CH1,
-        },
-        MED1: {
-            'name': 'MED1',
-            'allowlist': [PBBR],
-            'version': '1.2',
-            'channel': CH1,
-            'mode': 'rn',
-        },
-        HOST: {
-            'name': 'HOST',
-            'is_host': True
-        },
-        PBBR2: {
-            'name': 'PBBR2',
-            'is_otbr': True,
-            'version': '1.2',
-            'channel': CH2,
-        },
-        MED2: {
-            'name': 'MED2',
-            'version': '1.2',
-            'channel': CH2,
-            'mode': 'rn',
-        },
-    }
-
-    def _bootstrap(self):
-        # Bring up HOST
-        self.nodes[HOST].start()
-
-        # Bring up PBBR
-        self.nodes[PBBR].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR].get_state())
-        self.wait_node_state(PBBR, 'leader', 10)
-
-        self.nodes[PBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[PBBR].enable_backbone_router()
-        self.nodes[PBBR].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        self.simulator.go(5)
-        self.assertTrue(self.nodes[PBBR].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[PBBR].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up ROUTER1
-        self.nodes[ROUTER1].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER1].get_state())
-        self.assertIsNotNone(self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up MED1
-        self.nodes[MED1].start()
-        self.simulator.go(5)
-        self.assertEqual('child', self.nodes[MED1].get_state())
-        self.assertIsNotNone(self.nodes[MED1].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up PBBR2
-        self.nodes[PBBR2].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR2].get_state())
-        self.wait_node_state(PBBR2, 'leader', 10)
-
-        self.nodes[PBBR2].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[PBBR2].enable_backbone_router()
-        self.nodes[PBBR2].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        self.simulator.go(5)
-        self.assertTrue(self.nodes[PBBR2].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[PBBR2].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up MED2
-        self.nodes[MED2].start()
-        self.simulator.go(5)
-        self.assertEqual('child', self.nodes[MED2].get_state())
-        self.assertIsNotNone(self.nodes[MED2].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-    def test(self):
-        self._bootstrap()
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-
-        self.simulator.go(10)
-
-        # PBBR2 pings MED2
-        self.assertTrue(self.nodes[PBBR2].ping(self.nodes[MED2].get_ip6_address(config.ADDRESS_TYPE.DUA)))
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # MED2 pings PBBR2
-        self.assertTrue(self.nodes[MED2].ping(self.nodes[PBBR2].get_ip6_address(config.ADDRESS_TYPE.DUA)))
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # PBBR pings PBBR2
-        self.nodes[PBBR].ping(self.nodes[PBBR2].get_ip6_address(config.ADDRESS_TYPE.DUA))
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # MED1 pings ROUTER1 should succeed
-        self.assertTrue(self.nodes[MED1].ping(self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA)))
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # MED1 pings MED2 which should succeed
-        MED2_DUA = self.nodes[MED2].get_ip6_address(config.ADDRESS_TYPE.DUA)
-        self.assertTrue(self.nodes[MED1].ping(MED2_DUA))
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def _get_mliid(self, nodeid: int) -> str:
-        mleid = self.nodes[nodeid].get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-        mliid = ipaddress.IPv6Address(mleid).packed[-8:]
-        return ''.join(['%02x' % b for b in mliid])
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-
-        PBBR = pv.vars['PBBR']
-        ROUTER1 = pv.vars['ROUTER1']
-        PBBR2 = pv.vars['PBBR2']
-        MED1 = pv.vars['MED1']
-        MED2 = pv.vars['MED2']
-        MED1_DUA = pv.vars['MED1_DUA']
-        ROUTER1_DUA = pv.vars['ROUTER1_DUA']
-        PBBR_DUA = pv.vars['PBBR_DUA']
-        PBBR2_DUA = pv.vars['PBBR2_DUA']
-        MED2_DUA = pv.vars['MED2_DUA']
-        PBBR_ETH = pv.vars['PBBR_ETH']
-        PBBR2_ETH = pv.vars['PBBR2_ETH']
-
-        # PBBR should never send Address Query for MED1 (Child)
-        pkts.filter_wpan_src64(PBBR).filter_RLARMA().filter_coap_request(ADDR_QRY_URI) \
-            .filter('thread_address.tlv.target_eid == {eid}', eid=MED1_DUA) \
-            .must_not_next()
-
-        # PBBR2 should never send Address Query for MED2 (Child)
-        pkts.filter_wpan_src64(PBBR2).filter_RLARMA().filter_coap_request(ADDR_QRY_URI) \
-            .filter('thread_address.tlv.target_eid == {eid}', eid=MED2_DUA) \
-            .must_not_next()
-
-        # MEDs should never send Address Query
-        pkts.filter_wpan_src64(MED1).filter_RLARMA().filter_coap_request(ADDR_QRY_URI).must_not_next()
-        pkts.filter_wpan_src64(MED2).filter_RLARMA().filter_coap_request(ADDR_QRY_URI).must_not_next()
-
-        # PBBR pings PBBR2 should succeed
-        ping_id = pkts.filter_ipv6_src_dst(
-            PBBR_DUA, PBBR2_DUA).filter_eth_src(PBBR_ETH).filter_ping_request().must_next().icmpv6.echo.identifier
-        pkts.filter_ipv6_src_dst(PBBR2_DUA,
-                                 PBBR_DUA).filter_eth_src(PBBR2_ETH).filter_ping_reply(identifier=ping_id).must_next()
-
-        # MED1 pings ROUTER1
-        ping_request_pkts = pkts.filter_ipv6_src_dst(MED1_DUA, ROUTER1_DUA)
-        # MED1 sends the Ping Request
-        ping_pkt = ping_request_pkts.filter_wpan_src64(MED1).filter_ping_request().must_next()
-        ping_id = ping_pkt.icmpv6.echo.identifier
-        # PBBR sends Address Query for ROUTER1_DUA
-        pkts.filter_wpan_src64(PBBR).filter_RLARMA().filter_coap_request(ADDR_QRY_URI) \
-            .filter('thread_address.tlv.target_eid == {eid}', eid=ROUTER1_DUA) \
-            .must_next()
-        # PBBR sends Backbone Query for ROUTER1_DUA
-        pkts.filter_eth_src(PBBR_ETH).filter_coap_request(BACKBONE_QUERY_URI) \
-            .filter('thread_bl.tlv.target_eid == {eid}', eid=ROUTER1_DUA) \
-            .must_next()
-        # ROUTER1 sends Address Answer for ROUTER1_DUA
-        pkts.filter_wpan_src64(ROUTER1).filter_coap_request(ADDR_NTF_URI, confirmable=True) \
-            .filter('thread_address.tlv.target_eid == {eid}', eid=ROUTER1_DUA) \
-            .must_next()
-        # PBBR forwards Ping Request to ROUTER1, decreasing TTL by 1
-        ping_request_pkts.filter_wpan_src64(PBBR).filter_ping_request(identifier=ping_id).must_next().must_verify(
-            'ipv6.hlim == {hlim}', hlim=ping_pkt.ipv6.hlim - 1)
-
-        # MED1 pings MED2
-        # Verify Ping Request: MED1 -> PBBR -> PBBR2 -> MED2
-        ping_request_pkts = pkts.filter_ipv6_src_dst(MED1_DUA, MED2_DUA)
-        # MED1 sends the Ping Request
-        ping_pkt = ping_request_pkts.filter_wpan_src64(MED1).filter_ping_request().must_next()
-        ping_id = ping_pkt.icmpv6.echo.identifier
-        # PBBR sends Address Query for MED2_DUA
-        pkts.filter_wpan_src64(PBBR).filter_RLARMA().filter_coap_request(ADDR_QRY_URI) \
-            .filter('thread_address.tlv.target_eid == {eid}', eid=MED2_DUA) \
-            .must_next()
-        # PBBR sends Backbone Query for MED2_DUA
-        pkts.filter_eth_src(PBBR_ETH).filter_coap_request(BACKBONE_QUERY_URI) \
-            .filter('thread_bl.tlv.target_eid == {eid}', eid=MED2_DUA) \
-            .must_next()
-        # PBBR2 sends Backbone Answer for MED2_DUA
-        pkts.filter_eth_src(PBBR2_ETH).filter_coap_request(BACKBONE_ANSWER_URI, confirmable=True) \
-            .filter('thread_bl.tlv.target_eid == {eid}', eid=MED2_DUA) \
-            .must_next()
-        # PBBR forwards Ping Request to Backbone link, decreasing TTL by 1
-        ping_request_pkts.filter_eth_src(PBBR_ETH).filter_ping_request(identifier=ping_id).must_next().must_verify(
-            'ipv6.hlim == {hlim}', hlim=ping_pkt.ipv6.hlim - 1)
-        ping_request_pkts.filter_wpan_src64(PBBR2).filter_ping_request(identifier=ping_id).must_next()
-        # Verify Ping Reply: MED2 -> PBBR2 -> PBBR -> MED1
-        ping_reply_pkts = pkts.filter_ipv6_src_dst(MED2_DUA, MED1_DUA).filter_ping_reply(identifier=ping_id)
-        ping_reply_pkts.filter_eth_src(PBBR2_ETH).must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_mle_must_not_send_icmpv6_destination_unreachable.py b/tests/scripts/thread-cert/backbone/test_mle_must_not_send_icmpv6_destination_unreachable.py
deleted file mode 100755
index 40221fd..0000000
--- a/tests/scripts/thread-cert/backbone/test_mle_must_not_send_icmpv6_destination_unreachable.py
+++ /dev/null
@@ -1,98 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that no ICMPv6 message is sent for MLE.
-#
-import unittest
-
-import thread_cert
-import config
-from pktverify.consts import ICMPV6_TYPE_DESTINATION_UNREACHABLE
-from pktverify.packet_verifier import PacketVerifier
-
-PBBR = 1
-ROUTER = 2
-
-
-class TestMleMustNotSendIcmpv6DestinationUnreachable(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    # Topology:
-    #
-    #    ------(eth)----------
-    #           |
-    #         PBBR---ROUTER
-    #
-    TOPOLOGY = {
-        PBBR: {
-            'name': 'PBBR',
-            'allowlist': [ROUTER],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        ROUTER: {
-            'name': 'ROUTER',
-            'allowlist': [PBBR],
-            'version': '1.2',
-        },
-    }
-
-    def test(self):
-        self.nodes[PBBR].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR].get_state())
-        self.nodes[PBBR].enable_backbone_router()
-        self.simulator.go(3)
-        self.assertTrue(self.nodes[PBBR].is_primary_backbone_router)
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER].get_state())
-
-        self.simulator.go(5)
-
-        self.collect_ipaddrs()
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-
-        with pkts.save_index():
-            pv.verify_attached('ROUTER')
-
-        PBBR = pv.vars['PBBR']
-        ROUTER = pv.vars['ROUTER']
-
-        # PBBR MUST NOT send ICMPv6 Destination Unreachable
-        pkts.filter_wpan_src64(PBBR).filter_wpan_dst64(ROUTER).filter(
-            f'icmpv6.type == {ICMPV6_TYPE_DESTINATION_UNREACHABLE}').must_not_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing.py b/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing.py
deleted file mode 100755
index 762ce13..0000000
--- a/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing.py
+++ /dev/null
@@ -1,217 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that the basic MLR feature works.
-#
-import logging
-import unittest
-
-import config
-import thread_cert
-from pktverify.packet_verifier import PacketVerifier
-
-PBBR = 1
-SBBR = 2
-ROUTER = 3
-LEADER = 4
-FED = 5
-MED = 6
-SED = 7
-HOST = 8
-
-SED_POLL_PERIOD = 1000  # Milliseconds
-
-MA1 = 'ff04::1234:777a:1'
-MA2 = 'ff05::1234:777a:2'
-MA3 = 'ff05::1234:777a:3'
-MA4 = 'ff05::1234:777a:4'
-MA5 = 'ff05::1234:777a:5'
-
-BBR_REGISTRATION_JITTER = 1
-WAIT_REDUNDANCE = 3
-
-
-class TestMlr(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    # Topology:
-    #   --------(eth)---------
-    #       |     |      |
-    #     PBBR---SBBR   HOST
-    #       \    /  \
-    #        \  /    \
-    #        ROUTER--LEADER
-    #       /  |  \
-    #      /   |   \
-    #    FED  MED  SED
-    #
-    # More links:
-    #    PBBR---LEADER
-    #
-    TOPOLOGY = {
-        PBBR: {
-            'name': 'PBBR',
-            'allowlist': [SBBR, ROUTER, LEADER],
-            'is_otbr': True,
-            'version': '1.2',
-            'bbr_registration_jitter': BBR_REGISTRATION_JITTER,
-        },
-        SBBR: {
-            'name': 'SBBR',
-            'allowlist': [PBBR, ROUTER, LEADER],
-            'is_otbr': True,
-            'version': '1.2',
-            'bbr_registration_jitter': BBR_REGISTRATION_JITTER,
-        },
-        ROUTER: {
-            'name': 'ROUTER',
-            'allowlist': [PBBR, SBBR, LEADER, FED, MED, SED],
-            'version': '1.2',
-        },
-        LEADER: {
-            'name': 'LEADER',
-            'allowlist': [PBBR, SBBR, ROUTER],
-            'version': '1.2',
-        },
-        FED: {
-            'name': 'FED',
-            'allowlist': [ROUTER],
-            'version': '1.2',
-            'router_upgrade_threshold': 0,
-        },
-        MED: {
-            'name': 'MED',
-            'allowlist': [ROUTER],
-            'version': '1.2',
-            'mode': 'rn',
-        },
-        SED: {
-            'name': 'SED',
-            'allowlist': [ROUTER],
-            'version': '1.2',
-            'mode': 'n'
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        # Bring up Host
-        self.nodes[HOST].start()
-
-        # Bring up Leader
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[LEADER].get_state())
-
-        # Bring up Router
-        self.nodes[ROUTER].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER].get_state())
-
-        # Bring up PBBR
-        self.nodes[PBBR].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[PBBR].get_state())
-        self.nodes[PBBR].enable_backbone_router()
-        self.simulator.go(10)
-        self.assertTrue(self.nodes[PBBR].is_primary_backbone_router)
-        self.nodes[PBBR].add_prefix(config.DOMAIN_PREFIX, "parosD")
-        self.nodes[PBBR].register_netdata()
-
-        # Bring up SBBR
-        self.nodes[SBBR].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[SBBR].get_state())
-        self.nodes[SBBR].enable_backbone_router()
-        self.simulator.go(10)
-        self.assertFalse(self.nodes[SBBR].is_primary_backbone_router)
-
-        # Bring up FED, MED, SED
-        self.nodes[FED].start()
-        self.nodes[MED].start()
-        self.nodes[SED].set_pollperiod(SED_POLL_PERIOD)
-        self.nodes[SED].start()
-        self.simulator.go(5)
-        self.assertEqual('child', self.nodes[FED].get_state())
-        self.assertEqual('child', self.nodes[MED].get_state())
-        self.assertEqual('child', self.nodes[SED].get_state())
-
-        # Verify Multicast Routing works for all devices
-        self._verify_multicast_routing(ROUTER, MA1)
-        self._verify_multicast_routing(LEADER, MA2)
-        self._verify_multicast_routing(FED, MA3)
-        self._verify_multicast_routing(MED, MA4, is_med=True)
-        self._verify_multicast_routing(SED, MA5, is_med=True, is_sed=True)
-
-        # Verify MA_scope2 is not reachable from Host
-        MA_scope2 = 'ff02::10'
-        self.nodes[ROUTER].add_ipmaddr(MA_scope2)
-        self.simulator.go(3)
-        self.assertFalse(self.nodes[HOST].ping(MA_scope2, backbone=True, ttl=10))
-        self.nodes[ROUTER].del_ipmaddr(MA_scope2)
-
-        # Verify MA_scope3 is not reachable from Host
-        MA_scope3 = 'ff03::1234:777a:5'
-        self.nodes[ROUTER].add_ipmaddr(MA_scope3)
-        self.simulator.go(3)
-        self.assertFalse(self.nodes[HOST].ping(MA_scope3, backbone=True, ttl=10))
-        self.nodes[ROUTER].del_ipmaddr(MA_scope3)
-
-        # Router subscribes MA2 and MA3 at the same time and verify that they are both reachable
-        self.nodes[ROUTER].add_ipmaddr(MA2)
-        self.nodes[ROUTER].add_ipmaddr(MA3)
-        self.simulator.go(3)
-        self.assertTrue(self.nodes[HOST].ping(MA2, backbone=True, ttl=10))
-        self.assertTrue(self.nodes[HOST].ping(MA3, backbone=True, ttl=10))
-        self.nodes[ROUTER].del_ipmaddr(MA2)
-        self.nodes[ROUTER].del_ipmaddr(MA3)
-        self.simulator.go(1)
-
-    def _verify_multicast_routing(self, nodeid: int, ma: str, is_med=False, is_sed=False):
-        logging.info('_verify_multicast_routing: nodeid=%d, MA=%s', nodeid, ma)
-        # Verify MA is not reachable from Host initially
-        self.assertFalse(self.nodes[HOST].ping(ma, backbone=True, ttl=10))
-
-        # Device subscribes MA
-        self.nodes[nodeid].add_ipmaddr(ma)
-        self.simulator.go(3 + (SED_POLL_PERIOD * 2 / 1000) * is_sed + config.PARENT_AGGREGATIOIN_DELAY * is_med +
-                          WAIT_REDUNDANCE)
-
-        # Verify MA is reachable from Host
-        self.assertTrue(self.nodes[HOST].ping(ma, backbone=True, ttl=10))
-
-        # Device unsubscribes MA
-        self.nodes[nodeid].del_ipmaddr(ma)
-        self.simulator.go(1)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_across_thread_pans.py b/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_across_thread_pans.py
deleted file mode 100755
index fd9a71a..0000000
--- a/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_across_thread_pans.py
+++ /dev/null
@@ -1,257 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that the basic MLR feature works.
-#
-import unittest
-
-import config
-import thread_cert
-from pktverify.packet_verifier import PacketVerifier
-
-CH1 = 11
-CH2 = 22
-
-PBBR1 = 1
-ROUTER1 = 2
-PBBR2 = 3
-ROUTER2 = 4
-HOST = 5
-
-MA1 = 'ff05::1234:777a:1'
-MA2 = 'ff05::1234:777a:2'
-
-BBR_REGISTRATION_JITTER = 1
-WAIT_REDUNDANCE = 3
-
-
-class TestMlr(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    # Topology:
-    #   --------(eth)-----------
-    #       |     |      |
-    #     PBBR  HOST   PBBR2
-    #      |            |
-    #    ROUTER1      ROUTER2
-    #
-    TOPOLOGY = {
-        PBBR1: {
-            'name': 'PBBR1',
-            'allowlist': [ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-            'bbr_registration_jitter': BBR_REGISTRATION_JITTER,
-            'channel': CH1,
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'allowlist': [PBBR1],
-            'version': '1.2',
-            'channel': CH1,
-        },
-        PBBR2: {
-            'name': 'PBBR2',
-            'allowlist': [ROUTER2],
-            'is_otbr': True,
-            'version': '1.2',
-            'bbr_registration_jitter': BBR_REGISTRATION_JITTER,
-            'channel': CH2,
-        },
-        ROUTER2: {
-            'name': 'ROUTER2',
-            'allowlist': [PBBR2],
-            'version': '1.2',
-            'channel': CH2,
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        # Bring up Host
-        self.nodes[HOST].start()
-        self.nodes[HOST].add_ipmaddr(MA2, backbone=True)
-
-        # Bring up PBBR1
-        self.nodes[PBBR1].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR1].get_state())
-
-        self.nodes[PBBR1].enable_backbone_router()
-        self.simulator.go(10)
-        self.assertTrue(self.nodes[PBBR1].is_primary_backbone_router)
-        self.nodes[PBBR1].add_prefix(config.DOMAIN_PREFIX, "parosD")
-        self.nodes[PBBR1].register_netdata()
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Bring up ROUTER1
-        self.nodes[ROUTER1].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER1].get_state())
-        self.nodes[ROUTER1].add_ipmaddr(MA1)
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Bring up PBBR2
-        self.nodes[PBBR2].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR2].get_state())
-
-        self.nodes[PBBR2].enable_backbone_router()
-        self.simulator.go(10)
-        self.assertTrue(self.nodes[PBBR2].is_primary_backbone_router)
-
-        self.nodes[PBBR2].add_prefix(config.DOMAIN_PREFIX, "parosD")
-        self.nodes[PBBR2].register_netdata()
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Bring up ROUTER2
-        self.nodes[ROUTER2].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER1].get_state())
-        self.nodes[ROUTER2].add_ipmaddr(MA1)
-        self.nodes[ROUTER2].add_ipmaddr(MA2)
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        self.collect_ipaddrs()
-        self.collect_rlocs()
-
-        # ping MA1 from Host could generate a reply from R1 and R2
-        self.assertTrue(self.nodes[HOST].ping(MA1, backbone=True, ttl=5))
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.verify_border_routing_counters(self.nodes[PBBR1], {'inbound_multicast': 1, 'outbound_unicast': 1})
-        self.verify_border_routing_counters(self.nodes[PBBR2], {'inbound_multicast': 1, 'outbound_unicast': 1})
-
-        # ping MA2 from R1 could generate a reply from Host and R2
-        self.assertTrue(self.nodes[ROUTER1].ping(MA2))
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.verify_border_routing_counters(self.nodes[PBBR1], {'inbound_unicast': 2, 'outbound_multicast': 1})
-        self.verify_border_routing_counters(self.nodes[PBBR2], {'inbound_multicast': 1, 'outbound_unicast': 1})
-
-        # ping MA2 from R1's MLE-ID shouldn't generate a reply from Host or R2
-        self.assertFalse(self.nodes[ROUTER1].ping(MA2, interface=self.nodes[ROUTER1].get_mleid()))
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # ping MA2 from R1's LLA shouldn't generate a reply from Host or R2
-        self.assertFalse(self.nodes[ROUTER1].ping(MA2, interface=self.nodes[ROUTER1].get_linklocal()))
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-
-        PBBR1 = pv.vars['PBBR1']
-        PBBR1_ETH = pv.vars['PBBR1_ETH']
-        PBBR2 = pv.vars['PBBR2']
-        PBBR2_ETH = pv.vars['PBBR2_ETH']
-        ROUTER1 = pv.vars['ROUTER1']
-        ROUTER2 = pv.vars['ROUTER2']
-        HOST_ETH = pv.vars['Host_ETH']
-        HOST_BGUA = pv.vars['Host_BGUA']
-
-        ROUTER1_DUA = pv.vars['ROUTER1_DUA']
-        ROUTER2_DUA = pv.vars['ROUTER2_DUA']
-
-        ROUTER1_RLOC = pv.vars['ROUTER1_RLOC']
-
-        #
-        # Verify Host ping MA1 to R1 and R2
-        #
-
-        # Host should send ping MA1 in the Backbone link
-        ping_ma1 = pkts.filter_eth_src(HOST_ETH).filter_ipv6_src_dst(HOST_BGUA, MA1).filter_ping_request().must_next()
-
-        with pkts.save_index():
-            # PBBR1 should forward ping reply to the Backbone link
-            pkts.filter_eth_src(PBBR1_ETH).filter_ipv6_src_dst(
-                ROUTER1_DUA, HOST_BGUA).filter_ping_reply(identifier=ping_ma1.icmpv6.echo.identifier).must_next()
-
-        # PBBR2 should forward ping reply to the Backbone link
-        pkts.filter_eth_src(PBBR2_ETH).filter_ipv6_src_dst(
-            ROUTER2_DUA, HOST_BGUA).filter_ping_reply(identifier=ping_ma1.icmpv6.echo.identifier).must_next()
-
-        #
-        # Verify R1 pings MA2 to Host and R2
-        #
-
-        # ROUTER1 should send the multicast ping request
-        ping_ma2 = pkts.filter_wpan_src64(ROUTER1).filter_AMPLFMA(
-            mpl_seed_id=ROUTER1_RLOC).filter_ping_request().must_next()
-
-        # PBBR1 should forward the multicast ping request to the Backbone link
-        pkts.filter_eth_src(PBBR1_ETH).filter_ipv6_src_dst(
-            ROUTER1_DUA, MA2).filter_ping_request(identifier=ping_ma2.icmpv6.echo.identifier).must_next()
-
-        with pkts.save_index():
-            # Host should send ping reply to Router1
-            pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(ROUTER1_DUA).filter_ping_reply(
-                identifier=ping_ma2.icmpv6.echo.identifier).must_next()
-
-        # PBBR2 should forward the multicast ping request to Thread network at CH2
-        pkts.filter_wpan_src64(PBBR2).filter_AMPLFMA().filter_ping_request(
-            identifier=ping_ma2.icmpv6.echo.identifier).must_next()
-
-        # ROUTER2 should send ping reply back to ROUTER1
-        pkts.filter_wpan_src64(ROUTER2).filter_ipv6_src_dst(
-            ROUTER2_DUA, ROUTER1_DUA).filter_ping_reply(identifier=ping_ma2.icmpv6.echo.identifier).must_next()
-
-        #
-        # Verify pinging MA2 from R1's MLE-ID will not be forwarded to the Backbone link
-        #
-
-        # ROUTER1 should send the multicast ping request
-        ping_ma2_2 = pkts.filter_wpan_src64(ROUTER1).filter_AMPLFMA(mpl_seed_id=ROUTER1_RLOC).filter_ping_request(
-            identifier=ping_ma2.icmpv6.echo.identifier + 1).must_next()
-
-        # PBBR1 shouldn't forward the multicast ping request to the Backbone link
-        pkts.filter_eth_src(PBBR1_ETH).filter_ping_request(ping_ma2_2.icmpv6.echo.identifier).must_not_next()
-
-        #
-        # Verify pinging MA2 from R1's Link-Local address will not be forwarded to the Backbone link
-        #
-
-        # ROUTER1 should send the multicast ping request
-        ping_ma2_3 = pkts.filter_wpan_src64(ROUTER1).filter_AMPLFMA(mpl_seed_id=ROUTER1_RLOC).filter_ping_request(
-            identifier=ping_ma2.icmpv6.echo.identifier + 1).must_next()
-
-        # PBBR1 shouldn't forward the multicast ping request to the Backbone link
-        pkts.filter_eth_src(PBBR1_ETH).filter_ping_request(ping_ma2_3.icmpv6.echo.identifier).must_not_next()
-
-    def verify_border_routing_counters(self, br, expect_delta):
-        delta_counters = br.read_border_routing_counters_delta()
-        self.assertEqual(set(delta_counters.keys()), set(expect_delta.keys()))
-        for key in delta_counters:
-            self.assertGreaterEqual(delta_counters[key][0], expect_delta[key])
-            self.assertGreater(delta_counters[key][1], 0)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_commissioner_timeout.py b/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_commissioner_timeout.py
deleted file mode 100755
index 1ced307..0000000
--- a/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_commissioner_timeout.py
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that the MLR timeout configured by Commissioner works for multicast routing.
-#
-import unittest
-import config
-
-from pktverify.consts import NM_COMMISSIONER_SESSION_ID_TLV
-from pktverify.packet_verifier import PacketVerifier
-from test_mlr_multicast_routing_timeout import ROUTER, HOST
-from test_mlr_multicast_routing_timeout import TestMlrTimeout, MLR_TIMEOUT, WAIT_REDUNDANCE, MA1
-
-COMMISSIONER_MLR_TIMEOUT = 360
-
-assert COMMISSIONER_MLR_TIMEOUT > MLR_TIMEOUT + WAIT_REDUNDANCE * 3, "COMMISSIONER_MLR_TIMEOUT should be significantly larger than MLR_TIMEOUT"
-
-
-class TestMlrCommissionerTimeout(TestMlrTimeout):
-
-    # Topology (same as TestMlrTimeout):
-    #   --------(eth)---------
-    #       |            |
-    #     PBBR(Leader)  HOST
-    #      |
-    #    ROUTER
-    #
-
-    def test(self):
-        self._bootstrap()
-
-        # Ping 1: PBBR should not forward to Thread network
-        self.assertFalse(self.nodes[HOST].ping(MA1, backbone=True, ttl=10))
-
-        # Router starts Commissioner
-        self.nodes[ROUTER].commissioner_start()
-        self.simulator.go(10)
-        self.assertEqual('active', self.nodes[ROUTER].commissioner_state())
-
-        # Router (Commissioner) registers MA1
-        self.nodes[ROUTER].register_multicast_listener(MA1, timeout=COMMISSIONER_MLR_TIMEOUT)
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Ping 2: PBBR should forward to Thread network
-        self.nodes[HOST].ping(MA1, backbone=True, ttl=10)
-
-        self.simulator.go(MLR_TIMEOUT + WAIT_REDUNDANCE)
-
-        # Ping 3: PBBR should forward to Thread network
-        self.nodes[HOST].ping(MA1, backbone=True, ttl=10)
-
-        self.simulator.go(COMMISSIONER_MLR_TIMEOUT - MLR_TIMEOUT)
-
-        # Ping 4: PBBR should NOT forward to Thread network
-        self.assertFalse(self.nodes[HOST].ping(MA1, backbone=True, ttl=10))
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-
-        ROUTER = pv.vars['ROUTER']
-        PBBR = pv.vars['PBBR']
-        MM = pv.vars['MM_PORT']
-        HOST_ETH = pv.vars['Host_ETH']
-        HOST_BGUA = pv.vars['Host_BGUA']
-
-        start = pkts.index
-
-        # Verify that Router sends MLR.req for MA1 with Commissioner timeout
-        pkts.filter_wpan_src64(ROUTER).filter_coap_request('/n/mr', port=MM).must_next().must_verify(
-            """
-                thread_meshcop.tlv.ipv6_addr == {ipv6_addr}
-                and thread_nm.tlv.timeout == {timeout}
-                and {NM_COMMISSIONER_SESSION_ID_TLV} in thread_nm.tlv.type
-            """,
-            ipv6_addr=[MA1],
-            timeout=COMMISSIONER_MLR_TIMEOUT,
-            NM_COMMISSIONER_SESSION_ID_TLV=NM_COMMISSIONER_SESSION_ID_TLV)
-
-        mr_index = pkts.index
-
-        # Ping 1: Host pings Router before MLR.reg
-        before_mr_pkts = pkts.range(start, mr_index, cascade=False)
-        ping1 = before_mr_pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(MA1).filter_ping_request().must_next()
-
-        # PBBR should not forward Ping 1 to Thread network
-        before_mr_pkts.filter_wpan_src64(PBBR).filter_ipv6_dst(MA1).filter_ping_request(
-            identifier=ping1.icmpv6.echo.identifier).must_not_next()
-
-        # Ping 2: Host pings Router after MLR.req
-        ping2 = pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(MA1).filter_ping_request().must_next()
-        # PBBR should forward this ping request to Thread network
-        pkts.filter_wpan_src64(PBBR).filter_AMPLFMA().filter_ping_request(
-            identifier=ping2.icmpv6.echo.identifier).must_next()
-
-        # Ping 3: Host pings Router after delayed MLR Timeout
-        ping3 = pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(MA1).filter_ping_request().must_next()
-        # PBBR should forward this ping request to Thread network because MA doesn't expire yet
-        pkts.filter_wpan_src64(PBBR).filter_AMPLFMA().filter_ping_request(
-            identifier=ping3.icmpv6.echo.identifier).must_next()
-
-        # Ping 4: Host pings Router after delayed Commissioner MLR Timeout
-        ping4 = pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(MA1).filter_ping_request().must_next()
-        # PBBR should NOT forward this ping request to Thread network because MLR has timeout
-        pkts.filter_wpan_src64(PBBR).filter_AMPLFMA().filter_ping_request(
-            identifier=ping4.icmpv6.echo.identifier).must_not_next()
-
-
-del TestMlrTimeout
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_timeout.py b/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_timeout.py
deleted file mode 100755
index 5929d96..0000000
--- a/tests/scripts/thread-cert/backbone/test_mlr_multicast_routing_timeout.py
+++ /dev/null
@@ -1,180 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies that the MLR timeout works for multicast routing.
-#
-import unittest
-
-import config
-import thread_cert
-from pktverify.packet_verifier import PacketVerifier
-
-PBBR = 1
-ROUTER = 2
-HOST = 3
-
-MA1 = 'ff05::1234:777a:1'
-
-BBR_REGISTRATION_JITTER = 1
-REG_DELAY = 10
-MLR_TIMEOUT = 300
-WAIT_REDUNDANCE = 3
-
-
-class TestMlrTimeout(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    # Topology:
-    #   --------(eth)---------
-    #       |            |
-    #     PBBR(Leader)  HOST
-    #      |
-    #    ROUTER
-    #
-    TOPOLOGY = {
-        PBBR: {
-            'name': 'PBBR',
-            'allowlist': [ROUTER],
-            'is_otbr': True,
-            'version': '1.2',
-            'bbr_registration_jitter': BBR_REGISTRATION_JITTER,
-        },
-        ROUTER: {
-            'name': 'ROUTER',
-            'allowlist': [PBBR],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def _bootstrap(self):
-        # Bring up Host
-        self.nodes[HOST].start()
-
-        # Bring up PBBR
-        self.nodes[PBBR].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR].get_state())
-        self.nodes[PBBR].enable_backbone_router()
-        self.nodes[PBBR].set_backbone_router(reg_delay=REG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.simulator.go(10)
-        self.assertTrue(self.nodes[PBBR].is_primary_backbone_router)
-        self.nodes[PBBR].add_prefix(config.DOMAIN_PREFIX, "parosD")
-        self.nodes[PBBR].register_netdata()
-
-        # Bring up Router
-        self.nodes[ROUTER].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER].get_state())
-
-        self.collect_ipaddrs()
-
-    def test(self):
-        self._bootstrap()
-
-        self.assertFalse(self.nodes[HOST].ping(MA1, backbone=True, ttl=10))
-
-        # Router subscribes MA
-        self.nodes[ROUTER].add_ipmaddr(MA1)
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Verify MA is reachable from Host
-        self.assertTrue(self.nodes[HOST].ping(MA1, backbone=True, ttl=10))
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Router unsubscribes MA
-        self.nodes[ROUTER].del_ipmaddr(MA1)
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Verify MA is not reachable from Host after unsubscribed
-        # But PBBR should still forward the Ping Requests to Thread network
-        self.nodes[HOST].ping(MA1, backbone=True, ttl=10)
-
-        self.simulator.go(MLR_TIMEOUT + WAIT_REDUNDANCE)
-
-        # Verify MA is not reachable from Host after MLR timeout
-        # PBBR should not forward the Ping Requests to Thread network
-        self.assertFalse(self.nodes[HOST].ping(MA1, backbone=True, ttl=10))
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-
-        ROUTER = pv.vars['ROUTER']
-        PBBR = pv.vars['PBBR']
-        MM = pv.vars['MM_PORT']
-        HOST_ETH = pv.vars['Host_ETH']
-        HOST_BGUA = pv.vars['Host_BGUA']
-
-        start = pkts.index
-
-        # Verify that Router sends MLR.req for MA1
-        pkts.filter_wpan_src64(ROUTER).filter_coap_request('/n/mr', port=MM).must_next().must_verify(
-            'thread_meshcop.tlv.ipv6_addr == {ipv6_addr}', ipv6_addr=[MA1])
-
-        mr_index = pkts.index
-
-        # Host pings Router before MLR.reg
-        before_mr_pkts = pkts.range(start, mr_index, cascade=False)
-        ping = before_mr_pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(MA1).filter_ping_request().must_next()
-
-        # PBBR should not forward this ping request to Thread network
-        before_mr_pkts.filter_wpan_src64(PBBR).filter_ipv6_dst(MA1).filter_ping_request(
-            identifier=ping.icmpv6.echo.identifier).must_not_next()
-
-        # Host pings Router after MLR.req
-        ping = pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(MA1).filter_ping_request().must_next()
-        # PBBR should forward this ping request to Thread network
-        pkts.filter_wpan_src64(PBBR).filter_AMPLFMA().filter_ping_request(
-            identifier=ping.icmpv6.echo.identifier).must_next()
-        # Router should reply
-        pkts.filter_wpan_src64(ROUTER).filter_ipv6_dst(HOST_BGUA).filter_ping_reply(
-            identifier=ping.icmpv6.echo.identifier).must_next()
-
-        # Host pings Router after Router unsubscribed MA
-        ping = pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(MA1).filter_ping_request().must_next()
-        # PBBR should forward this ping request to Thread network because MA doesn't expire yet
-        pkts.filter_wpan_src64(PBBR).filter_AMPLFMA().filter_ping_request(
-            identifier=ping.icmpv6.echo.identifier).must_next()
-        # Router should NOT reply because it has unsubscribed the multicast address
-        pkts.filter_wpan_src64(ROUTER).filter_ipv6_dst(HOST_BGUA).filter_ping_reply(
-            identifier=ping.icmpv6.echo.identifier).must_not_next()
-
-        # Host pings Router after MLR timeout
-        ping = pkts.filter_eth_src(HOST_ETH).filter_ipv6_dst(MA1).filter_ping_request().must_next()
-        # PBBR should NOT forward this ping request to Thread network because MLR has timeout
-        pkts.filter_wpan_src64(PBBR).filter_AMPLFMA().filter_ping_request(
-            identifier=ping.icmpv6.echo.identifier).must_not_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/backbone/test_ndproxy.py b/tests/scripts/thread-cert/backbone/test_ndproxy.py
deleted file mode 100755
index bab1c71..0000000
--- a/tests/scripts/thread-cert/backbone/test_ndproxy.py
+++ /dev/null
@@ -1,222 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This test verifies PBBR's ND Proxy feature:
-#   1. Sends ICMPv6 Neighbor Advertisement (NA) for ND Proxy
-#   2. Receive and handle ICMPv6 Neighbor Solicitation (NS) and sends NA accordingly.
-#
-import ipaddress
-import unittest
-
-import config
-import thread_cert
-from pktverify.packet_verifier import PacketVerifier
-from pktverify.consts import DUA_RECENT_TIME
-
-PBBR = 1
-SBBR = 2
-ROUTER1 = 3
-HOST = 4
-
-REREG_DELAY = 5  # Seconds
-MLR_TIMEOUT = 300  # Seconds
-WAIT_REDUNDANCE = 3
-
-
-class TestNdProxy(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    # Topology:
-    #    ---- -(eth)------------
-    #       |        |     |
-    #      PBBR----SBBR   HOST
-    #        \     /
-    #        Router1
-    #
-    TOPOLOGY = {
-        PBBR: {
-            'name': 'PBBR',
-            'allowlist': [SBBR, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        SBBR: {
-            'name': 'SBBR',
-            'allowlist': [PBBR, ROUTER1],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'allowlist': [PBBR, SBBR],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'HOST',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        # Bring up HOST
-        self.nodes[HOST].start()
-
-        # Bring up PBBR
-        self.nodes[PBBR].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', self.nodes[PBBR].get_state())
-        self.wait_node_state(PBBR, 'leader', 10)
-
-        self.nodes[PBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[PBBR].enable_backbone_router()
-        self.nodes[PBBR].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        self.simulator.go(5)
-        self.assertTrue(self.nodes[PBBR].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[PBBR].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up SBBR
-        self.nodes[SBBR].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[SBBR].get_state())
-
-        self.nodes[SBBR].set_backbone_router(reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[SBBR].enable_backbone_router()
-        self.simulator.go(5)
-        self.assertFalse(self.nodes[SBBR].is_primary_backbone_router)
-        self.assertIsNotNone(self.nodes[SBBR].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        # Bring up ROUTER1
-        self.nodes[ROUTER1].start()
-        self.simulator.go(5)
-        self.assertEqual('router', self.nodes[ROUTER1].get_state())
-        self.assertIsNotNone(self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.DUA))
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-
-        # Wait for DUA_RECENT_TIME
-        self.simulator.go(DUA_RECENT_TIME + WAIT_REDUNDANCE)
-
-        self._test_neighbor_solicitation(PBBR)
-        self._test_neighbor_solicitation(SBBR)
-        self._test_neighbor_solicitation(ROUTER1)
-
-    def _test_neighbor_solicitation(self, nodeid):
-        dua = self.nodes[nodeid].get_ip6_address(config.ADDRESS_TYPE.DUA)
-        print('Node DUA: ', dua)
-        self.nodes[HOST].ip_neighbors_flush()
-        self.nodes[HOST].ping(dua, backbone=True)
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.add_common_vars()
-        pv.summary.show()
-
-        PBBR_DUA = pv.vars['PBBR_DUA']
-        SBBR_DUA = pv.vars['SBBR_DUA']
-        ROUTER1_DUA = pv.vars['ROUTER1_DUA']
-        PBBR_ETH = pv.vars['PBBR_ETH']
-        SBBR_ETH = pv.vars['SBBR_ETH']
-        HOST_ETH = pv.vars['HOST_ETH']
-        HOST_BGUA = pv.vars['HOST_BGUA']
-
-        # SBBR must not send NA for any DUA
-        pkts.filter_eth_src(SBBR_ETH).filter_LLANMA().filter_icmpv6_nd_na(PBBR_DUA).must_not_next()
-        pkts.filter_eth_src(SBBR_ETH).filter_LLANMA().filter_icmpv6_nd_na(SBBR_DUA).must_not_next()
-        pkts.filter_eth_src(SBBR_ETH).filter_LLANMA().filter_icmpv6_nd_na(ROUTER1_DUA).must_not_next()
-
-        # PBBR must send unsolicited NA for PBBR's DUA
-        pkts.filter_eth_src(PBBR_ETH).filter_LLANMA().filter_icmpv6_nd_na(PBBR_DUA).filter(
-            'icmpv6.nd.na.flag.s == 0').must_next().must_verify("""
-            icmpv6.opt.linkaddr == {PBBR_ETH}
-            and icmpv6.nd.na.flag.r == 1
-            and icmpv6.nd.na.flag.o == 1
-            and icmpv6.nd.na.flag.rsv == 0
-        """,
-                                                                PBBR_ETH=PBBR_ETH)
-
-        # PBBR must send unsolicited NA for SBBR's DUA
-        pkts.filter_eth_src(PBBR_ETH).filter_LLANMA().filter_icmpv6_nd_na(SBBR_DUA).filter(
-            'icmpv6.nd.na.flag.s == 0').must_next().must_verify("""
-            icmpv6.opt.linkaddr == {PBBR_ETH}
-            and icmpv6.nd.na.flag.r == 1
-            and icmpv6.nd.na.flag.o == 1
-            and icmpv6.nd.na.flag.rsv == 0
-        """,
-                                                                PBBR_ETH=PBBR_ETH)
-
-        # PBBR must send unsolicited NA for ROUTER1's DUA
-        pkts.filter_eth_src(PBBR_ETH).filter_LLANMA().filter_icmpv6_nd_na(ROUTER1_DUA).filter(
-            'icmpv6.nd.na.flag.s == 0').must_next().must_verify("""
-            icmpv6.opt.linkaddr == {PBBR_ETH}
-            and icmpv6.nd.na.flag.r == 1
-            and icmpv6.nd.na.flag.o == 1
-            and icmpv6.nd.na.flag.rsv == 0
-        """,
-                                                                PBBR_ETH=PBBR_ETH)
-
-        # HOST should send NS for PBBR's DUA
-        pkts.filter_eth_src(HOST_ETH).filter_icmpv6_nd_ns(PBBR_DUA).must_next()
-        # PBBR must send solicited NA for PBBR's DUA
-        pkts.filter_eth_src(PBBR_ETH).filter_ipv6_dst(HOST_BGUA).filter_icmpv6_nd_na(PBBR_DUA).filter(
-            'icmpv6.nd.na.flag.s == 1').must_next().must_verify("""
-            icmpv6.opt.linkaddr == {PBBR_ETH}
-            and icmpv6.nd.na.flag.r == 1
-            and icmpv6.nd.na.flag.o == 0
-            and icmpv6.nd.na.flag.rsv == 0
-        """,
-                                                                PBBR_ETH=PBBR_ETH)
-
-        # Host should send NS for SBBR's DUA
-        pkts.filter_eth_src(HOST_ETH).filter_icmpv6_nd_ns(SBBR_DUA).must_next()
-        # PBBR must send solicited NA for SBBR's DUA
-        pkts.filter_eth_src(PBBR_ETH).filter_ipv6_dst(HOST_BGUA).filter_icmpv6_nd_na(SBBR_DUA).filter(
-            'icmpv6.nd.na.flag.s == 1').must_next().must_verify("""
-            icmpv6.opt.linkaddr == {PBBR_ETH}
-            and icmpv6.nd.na.flag.r == 1
-            and icmpv6.nd.na.flag.o == 0
-            and icmpv6.nd.na.flag.rsv == 0
-        """,
-                                                                PBBR_ETH=PBBR_ETH)
-
-        # Host should send NS for ROUTER1's DUA
-        pkts.filter_eth_src(HOST_ETH).filter_icmpv6_nd_ns(ROUTER1_DUA).must_next()
-        # PBBR must send solicited NA for ROUTER1's DUA
-        pkts.filter_eth_src(PBBR_ETH).filter_ipv6_dst(HOST_BGUA).filter_icmpv6_nd_na(ROUTER1_DUA).filter(
-            'icmpv6.nd.na.flag.s == 1').must_next().must_verify("""
-            icmpv6.opt.linkaddr == {PBBR_ETH}
-            and icmpv6.nd.na.flag.r == 1
-            and icmpv6.nd.na.flag.o == 0
-            and icmpv6.nd.na.flag.rsv == 0
-        """,
-                                                                PBBR_ETH=PBBR_ETH)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/MATN/MATN_02_MLRFirstUse.py b/tests/scripts/thread-cert/border_router/MATN/MATN_02_MLRFirstUse.py
deleted file mode 100755
index db215e6..0000000
--- a/tests/scripts/thread-cert/border_router/MATN/MATN_02_MLRFirstUse.py
+++ /dev/null
@@ -1,290 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier, packet_filter
-from pktverify.consts import MA1, MA1g, MA2, PBBR_ALOC
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test case is to verify that a Thread device is able to
-# register a multicast address with a Primary BBR and that the Primary BBR can
-# notify devices on the backbone link of the multicast address. The test also
-# verifies that an IPv6 multicast packet originating from the backbone is
-# correctly forwarded (in the Thread Network) to the device that registered the
-# multicast address.
-#
-# Topology:
-#    ----------------(eth)------------------
-#           |                  |     |
-#          BR1 (Leader) ----- BR2   HOST
-#           |                  |
-#           |                  |
-#          TD --------(when TD is router)
-#
-
-BR_1 = 1
-BR_2 = 2
-TD = 3
-HOST = 4
-
-CHANNEL1 = 18
-
-MLR_NOTIFICATION_TIMEOUT = 3600
-
-
-class MATN_02_MLRFirstUse(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'is_otbr': True,
-            'allowlist': [BR_2, TD],
-            'version': '1.2',
-        },
-        BR_2: {
-            'name': 'BR_2',
-            'allowlist': [BR_1, TD],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        TD: {
-            'name': 'TD',
-            'allowlist': [BR_1, BR_2],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR_1]
-        br2 = self.nodes[BR_2]
-        td = self.nodes[TD]
-        host = self.nodes[HOST]
-
-        br1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-        self.assertTrue(br1.is_primary_backbone_router)
-
-        td.start()
-        self.simulator.go(5)
-        self.assertEqual('router', td.get_state())
-
-        br2.start()
-        self.simulator.go(5)
-        self.assertEqual('router', br2.get_state())
-
-        host.start(start_radvd=False)
-        self.simulator.go(10)
-
-        # 1. TD registers for multicast address, MA1, at BR_1.
-        td.add_ipmaddr(MA1)
-        self.simulator.go(5)
-
-        # 7. Host sends a ping packet to the multicast address, MA1. TD should
-        # respond to the ping request.
-        self.assertTrue(
-            host.ping(MA1, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(5)
-
-        # 11. Host sends a ping packet to the multicast address, MA2. No one
-        # should respond.
-        self.assertFalse(
-            host.ping(MA2, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(5)
-
-        # 14. Host sends a ping packet to the multicast address, MA1g. No one
-        # should respond.
-        self.assertFalse(
-            host.ping(MA1g, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(5)
-
-        # 17. Host sends a ping packet to the global unicast address of BR2. BR2
-        # should respond.
-        self.assertTrue(host.ping(br2.get_ip6_address(config.ADDRESS_TYPE.BACKBONE_GUA), backbone=True, ttl=10))
-        self.simulator.go(5)
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_leader_aloc(BR_1)
-        self.collect_extra_vars()
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('TD', 'BR_1')
-        pv.verify_attached('BR_2')
-
-        # 1. TD Registers for multicast address, MA1, at BR_1.
-        # TD unicasts an MLR.req CoAP request to BR_1 as
-        # "coap://[<BR_1 RLOC or PBBR ALOC>]:MM/n/mr".
-        # The payload contains "IPv6Address TLV: MA1".
-        pkts.filter_wpan_src64(vars['TD']) \
-            .filter_ipv6_2dsts(vars['BR_1_RLOC'], PBBR_ALOC) \
-            .filter_coap_request('/n/mr') \
-            .filter(lambda p: p.thread_meshcop.tlv.ipv6_addr == [MA1]) \
-            .must_next()
-
-        # 3. BR_1 responds to the multicast registration.
-        # BR_1 unicasts an MLR.rsp CoAP response to TD as "2.04 changed".
-        # The payload contains "Status TLV: ST_MLR_SUCCESS".
-        pkts.copy().filter_wpan_src64(vars['BR_1']) \
-            .filter_ipv6_dst(vars['TD_RLOC']) \
-            .filter_coap_ack('/n/mr') \
-            .filter(lambda p: p.thread_nm.tlv.status == 0) \
-            .must_next()
-
-        # 3a. BR_2 does not respond to the multicast registration.
-        pkts.filter_wpan_src64(vars['BR_2']) \
-            .filter_ipv6_dst(vars['TD_RLOC']) \
-            .filter_coap_ack('/n/mr') \
-            .must_not_next()
-
-        # 4. BR_1 informs other BBRs on the network of multicast registration.
-        # BR_1 multicasts a BMLR.ntf CoAP request to the Backbone Link including
-        # to BR_2, as follows
-        pkts.filter_eth_src(vars['BR_1_ETH']) \
-            .filter_ipv6_dst(config.ALL_NETWORK_BBRS_ADDRESS) \
-            .filter_coap_request('/b/bmr') \
-            .filter(lambda
-                        p: p.thread_meshcop.tlv.ipv6_addr == [MA1] and
-                           p.thread_bl.tlv.timeout == MLR_NOTIFICATION_TIMEOUT) \
-            .must_next()
-
-        # 6. BR_1 multicasts an MLDv2 message to start listening to MA1.
-        # BR_1 multicasts an MLDv2 message of type “Version 2 Multicast Listener
-        # Report” (see [RFC 3810] Section 5.2). Where:
-        # Nr of Mcast Address at least 1 Records (M): Multicast Address Record
-        # The Multicast Address Record contains the following:
-        # Record Type: 4 (CHANGE_TO_EXCLUDE_MODE)
-        # Number of Sources (N): 0
-        # Multicast Address: MA1
-        # TODO: Implement this verification
-
-        # 7. Host sends a ping packet to the multicast address, MA1.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 8. BR_2 does not forward the ping packet with multicast address MA1 to
-        # its Thread Network.
-        pkts.filter_wpan_src64(vars['BR_2']) \
-            .filter_AMPLFMA(mpl_seed_id=(vars['BR_2_RLOC'])) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 9. BR_1 forwards the ping packet to its Thread Network.
-        # BR_1 forwards the ping packet with multicast address, MA1, to its
-        # Thread Network encapsulated in an MPL packet, where:
-        # MPL Seed ID:  If Source outer IP header = BR_1 RLOC, SeedID length = 0
-        #               Else, SeedID length = 1, and Seed ID = BR_1 RLOC16
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=(vars['BR_1_RLOC'])) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 10. TD receives the multicast ping packet and sends a ping response
-        # packet back to Host.
-        # TD receives the MPL packet containing an encapsulated ping packet to
-        # MA1, sent by Host, and unicasts a ping response packet back to Host.
-        pkts.filter_wpan_src64(vars['TD']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 11. Host sends a ping packet to the multicast address, MA2.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA2) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 12. BR_2 does not forward the ping packet with multicast address, MA2,
-        # to the Thread Network in whatever way.
-        pkts.filter_wpan_src64(vars['BR_2']) \
-            .filter_AMPLFMA(mpl_seed_id=(vars['BR_2_RLOC'])) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 13. BR_1 does not forward the ping packet with multicast address, MA2,
-        # to the Thread Network in whatever way.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=(vars['BR_1_RLOC'])) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 14. Host sends a ping packet to the multicast address, MA1g.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1g) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 15. BR_2 does not forward the ping packet with multicast address MA1g,
-        # to the Thread Network in whatever way.
-        pkts.filter_wpan_src64(vars['BR_2']) \
-            .filter_AMPLFMA(mpl_seed_id=(vars['BR_2_RLOC'])) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 16. BR_1 does not forward the ping packet with multicast address MA1g,
-        # to its Thread Network in whatever way.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=(vars['BR_1_RLOC'])) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 17. Host sends a ping packet to the BR_2's global unicast address, Gg.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(vars['BR_2_BGUA']) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 18. BR_2 receives and provides the ping response.
-        # BR_2 Must send back the ping response to the Host.
-        pkts.filter_eth_src(vars['BR_2_ETH']) \
-            .filter_ipv6_dst(vars['Host_BGUA']) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/MATN/MATN_03_InvalidCommissionerDeregistration.py b/tests/scripts/thread-cert/border_router/MATN/MATN_03_InvalidCommissionerDeregistration.py
deleted file mode 100755
index 1d9de65..0000000
--- a/tests/scripts/thread-cert/border_router/MATN/MATN_03_InvalidCommissionerDeregistration.py
+++ /dev/null
@@ -1,184 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import pktverify
-from pktverify import packet_verifier
-from pktverify.consts import MA1, PBBR_ALOC
-import unittest
-
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test case is to verify that a Primary BBR ignores a
-# Timeout TLV if included in an MLR.req that does not contain a Commissioner
-# Session ID TLV nor is signed by a Commissioner.
-#
-# Topology:
-#    ----------------(eth)------------------
-#           |                  |     |
-#          BR1 (Leader) ----- BR2   HOST
-#           |                  |
-#           |                  |
-#          Router_1------------+
-#
-
-BR_1 = 1
-BR_2 = 2
-ROUTER_1 = 3
-HOST = 4
-
-
-class MATN_03_InvalidCommissionerDeregistration(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'is_otbr': True,
-            'allowlist': [BR_2, ROUTER_1],
-            'version': '1.2',
-        },
-        BR_2: {
-            'name': 'BR_2',
-            'allowlist': [BR_1, ROUTER_1],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [BR_1, BR_2],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR_1]
-        br2 = self.nodes[BR_2]
-        router = self.nodes[ROUTER_1]
-        host = self.nodes[HOST]
-
-        br1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-        self.assertTrue(br1.is_primary_backbone_router)
-
-        router.start()
-        self.simulator.go(5)
-        self.assertEqual('router', router.get_state())
-
-        br2.start()
-        self.simulator.go(5)
-        self.assertEqual('router', router.get_state())
-        self.assertFalse(br2.is_primary_backbone_router)
-
-        host.start(start_radvd=False)
-        self.simulator.go(5)
-
-        router.add_ipmaddr(MA1)
-        self.simulator.go(5)
-
-        # Ensure that router can receive a multicast ping packet sent to MA1.
-        self.assertTrue(
-            host.ping(MA1, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(5)
-
-        # 1. Router attempts to deregister for multicast address, MA1, at BR_1.
-        router.register_multicast_listener(MA1, timeout=0)
-
-        # 3. Host sends a ping packet to the multicast address, MA1.
-        self.assertTrue(
-            host.ping(MA1, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(5)
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_leader_aloc(BR_1)
-        self.collect_extra_vars()
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('Router_1', 'BR_1')
-        pv.verify_attached('BR_2')
-
-        # 1. Router attempts to deregister for multicast address, MA1, at BR_1.
-        # Router unicasts an MLR.req CoAP request to BR_1 as follows:
-        # coap://[<BR_1 RLOC>]:MM/n/mr
-        # Where the payload contains:
-        # IPv6 Addresses TLV: MA1
-        # Timeout TLV: 0
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_2dsts(vars['BR_1_RLOC'], PBBR_ALOC) \
-            .filter_coap_request('/n/mr') \
-            .filter(lambda p: p.thread_meshcop.tlv.ipv6_addr == [MA1] and
-                              p.thread_nm.tlv.timeout == 0) \
-            .must_next()
-
-        # 2. BR_1 responds to the multicast registration successfully,
-        # ignoring the timeout value.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_ipv6_dst(vars['Router_1_RLOC']) \
-            .filter_coap_ack('/n/mr') \
-            .filter(lambda p: p.thread_nm.tlv.status == 0) \
-            .must_next()
-
-        # 3. Host sends a ping packet to the multicast address, MA1.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 4. BR_1 forwards the ping packet with multicast address, MA1, to its
-        # Thread Network encapsulated in an MPL packet.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['BR_1_RLOC']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 5. Router receives the MPL packet containing an encapsulated ping
-        # packet to MA1, sent by Host, and unicasts a ping response packet back
-        # to Host.
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/MATN/MATN_04_MulticastListenerTimeout.py b/tests/scripts/thread-cert/border_router/MATN/MATN_04_MulticastListenerTimeout.py
deleted file mode 100755
index 119529f..0000000
--- a/tests/scripts/thread-cert/border_router/MATN/MATN_04_MulticastListenerTimeout.py
+++ /dev/null
@@ -1,219 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier, packet_filter, consts
-from pktverify.consts import MA1
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test case is to verify that a Primary BBR can remove an
-# entry from a Multicast Listeners Table, when the entry expires. The test case
-# also verifies that the Primary BBR accepts a new registration to the
-# previously expired multicast group.
-#
-# Topology:
-#    ----------------(eth)------------------
-#           |                  |     |
-#          BR1 (Leader) ----- BR2   HOST
-#           |                  |
-#           |                  |
-#          Router_1------------+
-#
-
-BR_1 = 1
-BR_2 = 2
-ROUTER_1 = 3
-HOST = 4
-
-REG_DELAY = 10
-
-
-class MATN_04_MulticastListenerTimeout(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'is_otbr': True,
-            'allowlist': [BR_2, ROUTER_1],
-            'version': '1.2',
-        },
-        BR_2: {
-            'name': 'BR_2',
-            'allowlist': [BR_1, ROUTER_1],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [BR_1, BR_2],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR_1]
-        br2 = self.nodes[BR_2]
-        router = self.nodes[ROUTER_1]
-        host = self.nodes[HOST]
-
-        br1.set_backbone_router(reg_delay=REG_DELAY, mlr_timeout=consts.MLR_TIMEOUT_MIN)
-        br1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-        self.assertTrue(br1.is_primary_backbone_router)
-
-        router.start()
-        self.simulator.go(5)
-        self.assertEqual('router', router.get_state())
-
-        br2.start()
-        self.simulator.go(5)
-        self.assertEqual('router', br2.get_state())
-        self.assertFalse(br2.is_primary_backbone_router)
-
-        host.start(start_radvd=False)
-        self.simulator.go(10)
-
-        # Router_1 registers for multicast address, MA1, at BR_1.
-        router.add_ipmaddr(MA1)
-        self.simulator.go(5)
-
-        # 1. Host sends a ping packet to the multicast address, MA1.
-        self.assertTrue(
-            host.ping(MA1, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(5)
-
-        # 3a. By internal means, Router_1 stop listening to the multicast
-        # address, MA1.
-        router.del_ipmaddr(MA1)
-
-        # 4. After (MLR_TIMEOUT_MIN+2) seconds
-        self.simulator.go(consts.MLR_TIMEOUT_MIN + 2)
-
-        # 5. Host sends a ping packet to the multicast address, MA1.
-        self.assertFalse(
-            host.ping(MA1, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(5)
-
-        # 6a. Router_1 registers for multicast address, MA1, at BR_1.
-        router.add_ipmaddr(MA1)
-        self.simulator.go(5)
-
-        # 7. Host sends a ping packet to MA1.
-        self.assertTrue(
-            host.ping(MA1, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_leader_aloc(BR_1)
-        self.collect_extra_vars()
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('Router_1', 'BR_1')
-        pv.verify_attached('BR_2')
-
-        # 1. Host sends a ping packet to the multicast address, MA1.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 2. BR_1 forwards the ping packet with multicast address, MA1, to its
-        # Thread Network encapsulated in an MPL packet.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(vars['BR_1_RLOC']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 3. Router_1 receives the MPL packet containing an encapsulated ping
-        # packet to MA1, sent by Host, and unicasts a ping response packet back
-        # to Host.
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 4. After (MLR_TIMEOUT_MIN+2) seconds, BR_1 multicasts an MLDv2 message
-        # of type “Version 2 Multicast Listener Report” (see [RFC 3810] Section
-        # 5.2)
-        # TODO
-
-        # 5. Host sends a ping packet to the multicast address, MA1.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 6. BR_1 does not forward the ping packet with multicast address, MA1,
-        # to its Thread Network.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(vars['BR_1_RLOC']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 7. Host sends a ping packet to the multicast address, MA1.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 8. BR_1 forwards the ping packet with multicast address, MA1, to its
-        # Thread Network encapsulated in an MPL packet.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(vars['BR_1_RLOC']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 9. Router_1 receives the MPL packet containing an encapsulated ping
-        # packet to MA1, sent by Host, and unicasts a ping response packet back
-        # to Host.
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/MATN/MATN_05_ReregistrationToSameMulticastGroup.py b/tests/scripts/thread-cert/border_router/MATN/MATN_05_ReregistrationToSameMulticastGroup.py
deleted file mode 100755
index e931127..0000000
--- a/tests/scripts/thread-cert/border_router/MATN/MATN_05_ReregistrationToSameMulticastGroup.py
+++ /dev/null
@@ -1,242 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier, packet_filter, consts
-from pktverify.consts import MA1, PBBR_ALOC
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test case is to verify that a Primary BBR (DUT) can manage
-# a re-registration of a device on its network to remain receiving multicasts.
-# The test also verifies the usage of UDP multicast packets across backbone and
-# internal Thread network.
-#
-# Topology:
-#    ----------------(eth)------------------
-#           |                  |     |
-#          BR_1 (Leader) ---- BR_2  HOST
-#           |                  |
-#           |                  |
-#          Router_1 -----------+
-#
-
-BR_1 = 1
-BR_2 = 2
-ROUTER_1 = 3
-HOST = 4
-
-REG_DELAY = 10
-UDP_HEADER_LENGTH = 8
-
-
-class MATN_05_ReregistrationToSameMulticastGroup(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'is_otbr': True,
-            'allowlist': [BR_2, ROUTER_1],
-            'version': '1.2',
-        },
-        BR_2: {
-            'name': 'BR_2',
-            'allowlist': [BR_1, ROUTER_1],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [BR_1, BR_2],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR_1]
-        br2 = self.nodes[BR_2]
-        router1 = self.nodes[ROUTER_1]
-        host = self.nodes[HOST]
-
-        br1.set_backbone_router(reg_delay=REG_DELAY, mlr_timeout=consts.MLR_TIMEOUT_MIN)
-        br1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-        self.assertTrue(br1.is_primary_backbone_router)
-
-        router1.start()
-        self.simulator.go(10)
-        self.assertEqual('router', router1.get_state())
-
-        br2.start()
-        self.simulator.go(10)
-        self.assertEqual('router', br2.get_state())
-        self.assertFalse(br2.is_primary_backbone_router)
-
-        host.start(start_radvd=False)
-        self.simulator.go(10)
-
-        # Router_1 registers for multicast address, MA1, at BR_1.
-        router1.add_ipmaddr(MA1)
-        self.simulator.go(5)
-
-        # 1. Host sends a ping packet to the multicast address, MA1.
-        self.assertTrue(
-            host.ping(MA1, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-
-        # Ensure Router_1 renews its multicast registration
-        self.simulator.go(consts.MLR_TIMEOUT_MIN - 10)
-
-        # 4. Within MLR_TIMEOUT_MIN seconds, Host sends a ping packet to the
-        # multicast address, MA1. The destination port 5683 is used for the UDP
-        # Multicast packet transmission.
-        host.udp_send_host(data='PING', ipaddr=MA1, port=5683)
-        self.simulator.go(5)
-
-        # 6a. By internal means, Router_1 stops listening to the multicast
-        # address, MA1.
-        router1.del_ipmaddr(MA1)
-
-        # 7. After (MLR_TIMEOUT_MIN+2) seconds, Host multicasts a ping packet to
-        # multicast address, MA1, on the backbone link.
-        self.simulator.go(consts.MLR_TIMEOUT_MIN + 2)
-        self.assertFalse(
-            host.ping(MA1, backbone=True, ttl=10, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_leader_aloc(BR_1)
-        self.collect_extra_vars()
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-        logging.info(f'vars = {vars}')
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('Router_1', 'BR_1')
-        pv.verify_attached('BR_2')
-
-        # Initial registration
-        # Router_1 registers for multicast address, MA1, at BR_1.
-        # Router_1 unicasts an MLR.req CoAP request to BR_1 as
-        # "coap://[<BR_1 RLOC or PBBR ALOC>]:MM/n/mr".
-        # The payload contains "IPv6Address TLV: MA1".
-        initial_registration_pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_2dsts(vars['BR_1_RLOC'], PBBR_ALOC) \
-            .filter_coap_request('/n/mr') \
-            .filter(lambda p: p.thread_meshcop.tlv.ipv6_addr == [MA1]) \
-            .must_next()
-
-        # 1. Host sends a ping packet to the multicast address, MA1.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 2. BR_1 forwards the ping packet with multicast address, MA1, to its
-        # Thread Network encapsulated in an MPL packet.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['BR_1_RLOC']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 3. Router_1 receives the MPL packet containing an encapsulated ping
-        # packet to MA1, sent by Host, and unicasts a ping response packet back
-        # to Host.
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 3a. Within MLR_TIMEOUT_MIN seconds of initial registration, Router_1
-        # re-registers for multicast address, MA1, at BR_1.
-        # Router_1 unicasts an MLR.req CoAP request to BR_1 as
-        # "coap://[<BR_1 RLOC or PBBR ALOC>]:MM/n/mr".
-        # The payload contains "IPv6Address TLV: MA1".
-        pkts.copy() \
-            .filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_2dsts(vars['BR_1_RLOC'], PBBR_ALOC) \
-            .filter_coap_request('/n/mr') \
-            .filter(lambda p: p.thread_meshcop.tlv.ipv6_addr == [MA1] and
-                              p.sniff_timestamp <= initial_registration_pkt.sniff_timestamp + consts.MLR_TIMEOUT_MIN) \
-            .must_next()
-
-        # 4. Within MLR_TIMEOUT_MIN seconds, Host sends a ping packet to the
-        # multicast address, MA1. The destination port 5683 is used for the UDP
-        # Multicast packet transmission.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter(lambda p: p.udp.length == UDP_HEADER_LENGTH + len('PING')
-                              and p.udp.dstport == 5683) \
-            .must_next()
-
-        # 5. BR_1 forwards the UDP ping packet with multicast address, MA1, to
-        # its Thread Network encapsulated in an MPL packet.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['BR_1_RLOC']) \
-            .filter(lambda p: p.udp.length == _pkt.udp.length) \
-            .must_next()
-
-        # 6. Router_1 receives the ping packet.
-        # Use the port 5683 (CoAP port) to verify that the
-        # UDP Multicast packet is received.
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter(
-            lambda p: p.udp.length == _pkt.udp.length and p.udp.dstport == 5683) \
-            .must_next()
-
-        # 7. After (MLR_TIMEOUT_MIN+2) seconds, Host multicasts a ping packet to
-        # multicast address, MA1, on the backbone link.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 8. BR_1 does not forward the ping packet with multicast address, MA1,
-        # to its Thread Network.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['BR_1_RLOC']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/MATN/MATN_09_DefaultBRMulticastForwarding.py b/tests/scripts/thread-cert/border_router/MATN/MATN_09_DefaultBRMulticastForwarding.py
deleted file mode 100755
index 5e92a37..0000000
--- a/tests/scripts/thread-cert/border_router/MATN/MATN_09_DefaultBRMulticastForwarding.py
+++ /dev/null
@@ -1,191 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier, packet_filter, consts
-from pktverify.consts import MA1
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test case is to verify that a Secondary BBR can take over
-# forwarding of outbound multicast transmissions from the Thread Network when
-# the Primary BBR disconnects. The Secondary in that case becomes Primary.
-#
-# Topology:
-#    ----------------(eth)------------------
-#           |                  |
-#         BR_1 (Leader) ---- BR_2
-#           |                  |
-#           |                  |
-#          ROUTER_1 -----------+
-#
-from pktverify.null_field import nullField
-
-BR_1 = 1
-BR_2 = 2
-ROUTER_1 = 3
-
-
-class MATN_09_FailureOfPrimaryBBROutboundMulticast(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'is_otbr': True,
-            'allowlist': [BR_2, ROUTER_1],
-            'version': '1.2',
-        },
-        BR_2: {
-            'name': 'BR_2',
-            'is_otbr': True,
-            'allowlist': [BR_1, ROUTER_1],
-            'version': '1.2',
-        },
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [BR_1, BR_2],
-            'version': '1.2',
-            'partition_id': 1,
-            'network_id_timeout': 150,
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR_1]
-        br2 = self.nodes[BR_2]
-        router1 = self.nodes[ROUTER_1]
-
-        br1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-        self.assertTrue(br1.is_primary_backbone_router)
-
-        router1.start()
-        self.simulator.go(10)
-        self.assertEqual('router', router1.get_state())
-
-        br2.start()
-        self.simulator.go(5)
-        self.assertEqual('router', br2.get_state())
-        self.assertFalse(br2.is_primary_backbone_router)
-
-        # 1. Router sends a ping packet to the multicast address, MA1,
-        # encapsulated in an MPL packet
-        self.assertFalse(router1.ping(MA1))
-        self.simulator.go(5)
-
-        # 4a. Switch off BR_1
-        br1.disable_backbone_router()
-        br1.thread_stop()
-        br1.interface_down()
-        self.simulator.go(180)
-
-        # 4b. BR_2 detects the missing Primary BBR and becomes the the Leader of
-        # the Thread Network.
-        self.assertEqual('disabled', br1.get_state())
-        self.assertEqual('leader', br2.get_state())
-        self.assertEqual('router', router1.get_state())
-        self.assertFalse(br1.is_primary_backbone_router)
-        self.assertTrue(br2.is_primary_backbone_router)
-
-        # 5. Router_1 sends a ping packet to the multicast address, MA1,
-        # encapsulated in an MPL packet.
-        self.assertFalse(router1.ping(MA1))
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_leader_aloc(BR_2)
-        self.collect_extra_vars()
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-        logging.info(f'vars = {vars}')
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('Router_1', 'BR_1')
-        pv.verify_attached('BR_2')
-
-        # 1. Router_1 sends a ping packet to the multicast address, MA1,
-        # encapsulated in an MPL packet.
-        _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['Router_1_RLOC']) \
-            .filter_ping_request() \
-            .must_next()
-
-        initial_identifier = _pkt.icmpv6.echo.identifier
-
-        # 2. BR_1 forwards the multicast ping packet with multicast address,
-        # MA1, to the LAN.
-        pkts.filter_eth_src(vars['BR_1_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        with pkts.save_index():
-            pv.verify_attached('Router_1', 'BR_2')
-
-        # 4b. BR_2 detects the missing Primary BBR and becomes the Leader of the
-        # Thread Network, distributing its BBR dataset.
-        # Verify that Router_1 has received the new BBR Dataset from BR_2,
-        # where:
-        # • RLOC16 in Server TLV == The RLOC16 of BR_2
-        # All fields in the Service TLV contain valid values.
-        pkts.filter_wpan_src64(vars['BR_2']) \
-            .filter_mle() \
-            .filter(
-            lambda p: p.thread_nwd.tlv.server_16 is not nullField and
-                      vars['BR_2_RLOC16'] in p.thread_nwd.tlv.server_16) \
-            .must_next()
-
-        # 5.Router_1 sends a ping packet to the multicast address, MA1,
-        # encapsulated in an MPL packet.
-        _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['Router_1_RLOC']) \
-            .filter_ping_request() \
-            .filter(lambda p: p.icmpv6.echo.identifier != initial_identifier) \
-            .must_next()
-
-        # 6. BR_2 forwards the multicast ping packet with multicast address,
-        # MA1, to the LAN.
-        pkts.filter_eth_src(vars['BR_2_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/MATN/MATN_12_HopLimitProcessing.py b/tests/scripts/thread-cert/border_router/MATN/MATN_12_HopLimitProcessing.py
deleted file mode 100755
index c51c048..0000000
--- a/tests/scripts/thread-cert/border_router/MATN/MATN_12_HopLimitProcessing.py
+++ /dev/null
@@ -1,276 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier, packet_filter, consts
-from pktverify.consts import MA1, MA2
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test case is to verify that a Primary BBR correctly
-# decrements the Hop Limit field by 1 if packet is forwarded with MPL, and if
-# forwarded from Thread Network (using MPL) to LAN. This test also verifies that
-# the BR drops a packet with Hop Limit 0. It also checks the use of IPv6 packets
-# that span multiple 6LoWPAN fragments.
-#
-# Topology:
-#    ----------------(eth)------------------
-#           |                        |
-#         BR_1 (Leader)              |
-#           |                       HOST
-#           |
-#          ROUTER_1
-#
-
-BR_1 = 1
-ROUTER_1 = 2
-HOST = 3
-ICMP_HEADER_LEN = 8
-
-
-class MATN_12_HopLimitProcessing(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'is_otbr': True,
-            'allowlist': [ROUTER_1],
-            'version': '1.2',
-        },
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [BR_1],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True,
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR_1]
-        router1 = self.nodes[ROUTER_1]
-        host = self.nodes[HOST]
-
-        br1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-        self.assertTrue(br1.is_primary_backbone_router)
-
-        router1.start()
-        self.simulator.go(5)
-        self.assertEqual('router', router1.get_state())
-
-        host.start(start_radvd=False)
-        self.simulator.go(10)
-
-        # Router_1 registers a multicast address, MA1.
-        router1.add_ipmaddr(MA1)
-        self.simulator.go(5)
-
-        # 1. Host multicasts a ping packet to the multicast address, MA1, with
-        # the IPv6 Hop Limit field set to 59. The size of the payload is 130
-        # bytes.
-        self.assertTrue(
-            host.ping(MA1,
-                      backbone=True,
-                      ttl=59,
-                      size=130,
-                      interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(5)
-
-        # 4. Host multicasts a ping packet to the multicast address, MA1, with
-        # the IPv6 Hop Limit field set to 1. The size of the payload is 130
-        # bytes.
-        host.ping(MA1,
-                  backbone=True,
-                  ttl=1,
-                  size=130,
-                  interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0])
-        self.simulator.go(5)
-
-        # 6. Router_1 sends a ping packet encapsulated in an MPL packet to the
-        # multicast address, MA2, with the Hop Limit field of the inner packet
-        # set to 159. The size of the payload is 130 bytes.
-        self.assertFalse(router1.ping(MA2, hoplimit=159, size=130))
-        self.simulator.go(5)
-
-        # 8. Router_1 sends a ping packet encapsulated in an MPL multicast
-        # packet to the multicast address, MA2, with the Hop Limit field of
-        # the inner (encapsulated) packet set to 2. The size of the payload is
-        # 130 bytes.
-        self.assertFalse(router1.ping(MA2, hoplimit=2, size=130))
-        self.simulator.go(5)
-
-        # 10. Router_1 sends a ping packet encapsulated in an MPL multicast
-        # packet to the multicast address, MA2, with the Hop Limit field of the
-        # inner packet set to 1. The size of the payload is 130 bytes.
-        self.assertFalse(router1.ping(MA2, hoplimit=1, size=130))
-        self.simulator.go(5)
-
-        # 11. Router_1 sends a ping packet encapsulated in an MPL multicast
-        # packet to the multicast address, MA2, with the Hop Limit field of the
-        # inner packet set to 0.
-        self.assertFalse(router1.ping(MA2, hoplimit=0, size=130))
-        self.simulator.go(5)
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_extra_vars()
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-        logging.info(f'vars = {vars}')
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('Router_1', 'BR_1')
-
-        # 1. Host multicasts a ping packet to the multicast address, MA1, with
-        # the IPv6 Hop Limit field set to 59. The size of the payload is 130
-        # bytes.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .filter(lambda
-                        p: p.ipv6.hlim == 59 and p.ipv6.plen == 130 + ICMP_HEADER_LEN) \
-            .must_next()
-
-        # 2. BR_1 forwards the ping packet to Router_1 as an MPL packet
-        # encapsulating the IPv6 packet with the Hop Limit field of the inner
-        # packet set to 58.
-        _pkt2 = pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['BR_1_RLOC']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .filter(lambda
-                        p: p.ipv6inner.hlim == 58 and p.ipv6inner.plen == 130 + ICMP_HEADER_LEN) \
-            .must_next()
-
-        # 3. Router_1 receives the multicast ping packet.
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 4. Host multicasts a ping packet to the multicast address, MA1, with
-        # the IPv6 Hop Limit field set to 1. The size of the payload is 130
-        # bytes.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA1) \
-            .filter_ping_request() \
-            .filter(
-            lambda
-                p: p.ipv6.hlim == 1 and p.ipv6.plen == 130 + ICMP_HEADER_LEN) \
-            .must_next()
-
-        # 5. BR_1 does not forward the ping packet.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['BR_1']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 6. Router_1 sends a ping packet encapsulated in an MPL packet to the
-        # multicast address, MA2, with the Hop Limit field of the inner
-        # packet set to 159. The size of the payload is 130 bytes.
-        _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['Router_1_RLOC']) \
-            .filter_ping_request() \
-            .filter(lambda p: p.ipv6inner.dst == MA2 and
-                              p.ipv6inner.hlim == 159 and
-                              p.ipv6inner.plen == 130 + ICMP_HEADER_LEN) \
-            .must_next()
-
-        # 7. BR_1 forwards the multicast ping packet to the LAN with the Hop
-        # Limit field set to 158.
-        pkts.filter_eth_src(vars['BR_1_ETH']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .filter(lambda p: p.ipv6.hlim == 158) \
-            .must_next()
-
-        # 8. Router_1 sends a ping packet encapsulated in an MPL multicast
-        # packet to the multicast address, MA2, with the Hop Limit field of the
-        # inner packet set to 2. The size of the payload is 130 bytes
-        _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['Router_1_RLOC']) \
-            .filter_ping_request() \
-            .filter(lambda p: p.ipv6inner.dst == MA2 and
-                              p.ipv6inner.hlim == 2 and
-                              p.ipv6inner.plen == 130 + ICMP_HEADER_LEN) \
-            .must_next()
-
-        # 9. BR_1 forwards the multicast packet to the LAN with the Hop Limit
-        # field set to 1.
-        pkts.filter_eth_src(vars['BR_1_ETH']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .filter(lambda p: p.ipv6.hlim == 1) \
-            .must_next()
-
-        # 10. Router_1 sends a ping packet encapsulated in an MPL multicast
-        # packet to the multicast address, MA2, with the Hop Limit field of the
-        # inner packet set to 1. The size of the payload is 130 bytes.
-        _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['Router_1_RLOC']) \
-            .filter_ping_request() \
-            .filter(lambda p: p.ipv6inner.dst == MA2 and
-                              p.ipv6inner.hlim == 1 and
-                              p.ipv6inner.plen == 130 + ICMP_HEADER_LEN) \
-            .must_next()
-
-        # 11. BR_1 does not forward the ping packet to the LAN.
-        pkts.filter_eth_src(vars['BR_1_ETH']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 12. Router_1 sends a ping packet encapsulated in an MPL multicast
-        # packet to the multicast address, MA2, with the Hop Limit field of the
-        # inner packet set to 0.
-        _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_AMPLFMA(mpl_seed_id=vars['Router_1_RLOC']) \
-            .filter_ping_request() \
-            .filter(lambda p: p.ipv6inner.dst == MA2 and
-                              p.ipv6inner.hlim == 0 and
-                              p.ipv6inner.plen == 130 + ICMP_HEADER_LEN) \
-            .must_next()
-
-        # 13. BR_1 does not forward the ping packet to the LAN.
-        pkts.filter_eth_src(vars['BR_1_ETH']) \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/MATN/MATN_15_ChangeOfPrimaryBBRTriggersRegistration.py b/tests/scripts/thread-cert/border_router/MATN/MATN_15_ChangeOfPrimaryBBRTriggersRegistration.py
deleted file mode 100755
index 71c7da2..0000000
--- a/tests/scripts/thread-cert/border_router/MATN/MATN_15_ChangeOfPrimaryBBRTriggersRegistration.py
+++ /dev/null
@@ -1,199 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier, packet_filter, consts
-from pktverify.consts import MA1, MA2
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test case is to verify that a Thread End Device detects a
-# change of Primary BBR device and triggers a re-registration to its multicast
-# groups.
-#
-# Topology:
-#
-#
-#    ----------------(eth)--------------------
-#           |
-#         BR_1 (Leader) -----BR_2
-#           |                  |
-#           |                  |
-#           |                  |
-#         Router_1-------------+
-#           |
-#           |
-#          TD
-#
-
-BR_1 = 1
-BR_2 = 2
-ROUTER_1 = 3
-TD = 4
-
-REG_DELAY = 10
-
-NETWORK_ID_TIMEOUT = 120
-WAIT_TIME_ALLOWANCE = 60
-
-
-class MATN_15_ChangeOfPrimaryBBRTriggersRegistration(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'is_otbr': True,
-            'allowlist': [BR_2, ROUTER_1],
-            'version': '1.2',
-        },
-        BR_2: {
-            'name': 'BR_2',
-            'is_otbr': True,
-            'allowlist': [BR_1, ROUTER_1],
-            'version': '1.2',
-        },
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [BR_1, BR_2, TD],
-            'version': '1.2',
-            'partition_id': 1,
-        },
-        TD: {
-            'name': 'TD',
-            'allowlist': [ROUTER_1],
-            'router_eligible': False,
-            'version': '1.2',
-            'partition_id': 1,
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR_1]
-        br2 = self.nodes[BR_2]
-        router1 = self.nodes[ROUTER_1]
-        td = self.nodes[TD]
-
-        br1.set_backbone_router(reg_delay=REG_DELAY, mlr_timeout=consts.MLR_TIMEOUT_MIN)
-        br1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-        self.assertTrue(br1.is_primary_backbone_router)
-
-        br2.set_backbone_router(reg_delay=REG_DELAY, mlr_timeout=consts.MLR_TIMEOUT_MIN)
-        router1.start()
-        self.simulator.go(5)
-        self.assertEqual('router', router1.get_state())
-
-        td.start()
-        self.simulator.go(5)
-        self.assertEqual('child', td.get_state())
-
-        br2.start()
-        self.simulator.go(5)
-        self.assertEqual('router', br2.get_state())
-        self.assertFalse(br2.is_primary_backbone_router)
-
-        # TD registers for a multicast address, MA1.
-        td.add_ipmaddr(MA1)
-        self.simulator.go(20)
-
-        # 1. Disable BR_1 such that BR_2 becomes the Primary BBR for the Thread
-        # Network.
-        br1.disable_backbone_router()
-        br1.thread_stop()
-        br1.interface_down()
-        self.simulator.go(NETWORK_ID_TIMEOUT + WAIT_TIME_ALLOWANCE)
-
-        # Make sure that BR_2 becomes the primary BBR
-        self.assertEqual('disabled', br1.get_state())
-        self.assertFalse(br1.is_primary_backbone_router)
-        self.assertTrue(br2.is_primary_backbone_router)
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_extra_vars()
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('Router_1', 'BR_1')
-
-        # 2. TD automatically detects the Primary BBR change and registers for
-        # multicast address, MA1, at BR_2.
-        # TD unicasts an MLR.req CoAP request as follows:
-        # coap://[<BR_2 RLOC or PBBR ALOC>]:MM/n/mr
-        # Where the payload contains:
-        # IPv6 Addresses TLV: MA1
-        _pkt = pkts.filter_wpan_src64(vars['TD']) \
-            .filter_ipv6_2dsts(vars['BR_2_RLOC'], consts.PBBR_ALOC) \
-            .filter_coap_request('/n/mr') \
-            .filter(lambda p: p.thread_meshcop.tlv.ipv6_addr == [MA1]) \
-            .must_next()
-
-        # 3. Router_1 forwards the registration request to BR_2.
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_2dsts(vars['BR_2_RLOC'], consts.PBBR_ALOC) \
-            .filter_coap_request('/n/mr') \
-            .filter(lambda
-                        p: p.thread_meshcop.tlv.ipv6_addr == [MA1] and
-                           p.coap.mid == _pkt.coap.mid) \
-            .must_next()
-
-        # 4. BR_2 unicasts an MLR.rsp CoAP response to TD as: 2.04 changed.
-        # Where the payload contains:
-        # Status TLV: ST_MLR_SUCCESS
-        pkts.filter_wpan_src64(vars['BR_2']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_coap_ack('/n/mr') \
-            .filter(lambda
-                        p: p.coap.mid == _pkt.coap.mid and
-                           p.thread_nm.tlv.status == 0) \
-            .must_next()
-
-        # 5. Router_1 forwards the response to TD.
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_coap_ack('/n/mr') \
-            .filter(lambda
-                        p: p.coap.mid == _pkt.coap.mid and
-                           p.thread_nm.tlv.status == 0) \
-            .must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/MATN/MATN_16_LargeNumberOfMulticastGroupSubscriptionsToBBR.py b/tests/scripts/thread-cert/border_router/MATN/MATN_16_LargeNumberOfMulticastGroupSubscriptionsToBBR.py
deleted file mode 100755
index d5a2ae1..0000000
--- a/tests/scripts/thread-cert/border_router/MATN/MATN_16_LargeNumberOfMulticastGroupSubscriptionsToBBR.py
+++ /dev/null
@@ -1,229 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier, packet_filter, consts
-from pktverify.consts import MAS, MA2
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test case is to verify that the Primary BBR can handle a
-# large number (75) of subscriptions to different multicast groups. Multicast
-# registrations are performed each time with 15 multicast addresses per
-# registration request.
-#
-# Topology:
-#
-#
-#    ----------------(eth)--------------------
-#           |                     |
-#         BR_1 (Leader)           |
-#           |                   Host
-#           |
-#           |
-#         Router_1
-#
-#
-#
-
-BR_1 = 1
-ROUTER_1 = 2
-HOST = 3
-
-REG_DELAY = 10
-
-
-class MATN_16_LargeNumberOfMulticastGroupSubscriptionsToBBR(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR_1: {
-            'name': 'BR_1',
-            'is_otbr': True,
-            'allowlist': [ROUTER_1],
-            'version': '1.2',
-        },
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [BR_1],
-            'version': '1.2',
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True,
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR_1]
-        router1 = self.nodes[ROUTER_1]
-        host = self.nodes[HOST]
-
-        br1.set_backbone_router(reg_delay=REG_DELAY, mlr_timeout=consts.MLR_TIMEOUT_MIN)
-        br1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-        self.assertTrue(br1.is_primary_backbone_router)
-
-        router1.start()
-        self.simulator.go(5)
-        self.assertEqual('router', router1.get_state())
-
-        host.start(start_radvd=False)
-        self.simulator.go(5)
-
-        # Loop steps 1 to 4 with i := 1 to 5.
-        for i in range(1, 6):
-            # 1. Router_1 registers for 15 multicast addresses, MASi.
-            self.assertEqual((0, []), router1.register_multicast_listener(*(MAS[i][1:]),
-                                                                          timeout=consts.MLR_TIMEOUT_MIN))
-        self.simulator.go(5)
-
-        # Loop steps 5 to 6 with i := 1 to 5.
-        for i in range(1, 6):
-            # 5. Host sends a ping packet to the multicast address,
-            # MASi[ 3 * i - 1], on the backbone link.
-            host.ping(MAS[i][i * 3 - 1],
-                      backbone=True,
-                      ttl=64,
-                      interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0])
-
-            self.simulator.go(3)
-
-        # 7. Host multicasts a packet to the multicast addresses, MA2.
-        self.assertFalse(
-            host.ping(MA2, backbone=True, ttl=64, interface=host.get_ip6_address(config.ADDRESS_TYPE.ONLINK_ULA)[0]))
-        self.simulator.go(3)
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_extra_vars()
-        self.collect_leader_aloc(BR_1)
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('Router_1', 'BR_1')
-
-        # Loop steps 1 to 4 with i := 1 to 5.
-        for i in range(1, 6):
-            # 1. Router_1 registers for 15 multicast addresses, MASi.
-            # [Automatic result:]
-            # Unicasts an MLR.req CoAP request to BR_1 as follows:
-            # coap://[<BR_1 RLOC> or <PBBR ALOC>]:MM/n/mr
-            # Where the payload contains:
-            # IPv6 Addresses TLV: MASi (15 addresses)
-            _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-                .filter_ipv6_2dsts(vars['BR_1_RLOC'], consts.PBBR_ALOC) \
-                .filter_coap_request('/n/mr') \
-                .filter(lambda p: set(p.thread_meshcop.tlv.ipv6_addr) == set(MAS[i][1:])) \
-                .must_next()
-
-            # 2. BR_1 Responds to the multicast registration.
-            # BR_1 unicasts an MLR.rsp CoAP response to Router_1 as follows:
-            # 2.04 changed
-            # Where the payload contains:
-            # Status TLV: ST_MLR_SUCCESS
-            pkts.copy().filter_wpan_src64(vars['BR_1']) \
-                .filter_ipv6_dst(_pkt.ipv6.src) \
-                .filter_coap_ack('/n/mr') \
-                .filter(lambda p: p.coap.mid == _pkt.coap.mid and p.thread_nm.tlv.status == 0) \
-                .must_next()
-
-            # 3. BR_1 informs other BBRs on the network of the multicast
-            # registrations.
-            # BR_1 multicasts a BMLR.ntf CoAP request to the Backbone Link,
-            # as follows:
-            # coap://[<All network BBRs multicast>]:BB/b/bmr
-            # Where the payload contains:
-            # IPv6 Addresses TLV:  MASi (15 addresses)
-            # Timeout TLV:  default MLR timeout of BR_1
-            pkts.filter_eth_src(vars['BR_1_ETH']) \
-                .filter_ipv6_dst(config.ALL_NETWORK_BBRS_ADDRESS) \
-                .filter_coap_request('/b/bmr') \
-                .filter(
-                lambda p: set(p.thread_meshcop.tlv.ipv6_addr) == set(MAS[i][1:]) and
-                          p.thread_bl.tlv.timeout == consts.MLR_TIMEOUT_MIN) \
-                .must_next()
-
-            # 4. BR_1 multicasts an MLDv2 message of type “Version 2
-            # Multicast Listener Report” (see [RFC 3810] Section 5.2).
-            # Where:
-            # Nr of Mcast Address Records(M) >= 15
-            # Multicast Address Record[j]:
-            # Each of the j := 1 ... 15 Multicast Address Record containing an
-            # address of the set MASi contains the following:
-            # Record Type: 4 (CHANGE_TO_EXCLUDE_MODE)
-            # Number of Sources (N): 0
-            # Multicast Address: MASi[j]
-            # Alternatively, the DUT may also send multiple of above messages
-            # each with a portion of the 15 addresses MASi.
-            # In this case the Nr of Mcast Address Records can be < 15
-            # but the sum over all messages MUST be >= 15.
-            # TODO
-
-        # Loop steps 5 to 6 with i := 1 to 5.
-        for i in range(1, 6):
-            # 5. Host sends a ping packet to the multicast address,
-            # MASi[ 3 * i - 1], on the backbone link.
-            _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-                .filter_ipv6_dst(MAS[i][3 * i - 1]) \
-                .filter_ping_request() \
-                .must_next()
-
-            # 6. BR_1 forwards the ping packet of the previous step to its
-            # Thread Network encapsulated in an MPL packet.
-            pkts.copy().filter_wpan_src64(vars['BR_1']) \
-                .filter_AMPLFMA(mpl_seed_id=vars['BR_1_RLOC']) \
-                .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-                .must_next()
-
-        # 7. Host multicasts a packet to the multicast addresses, MA2.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']) \
-            .filter_ipv6_dst(MA2) \
-            .filter_ping_request() \
-            .must_next()
-
-        # 8. BR_1 does not forward the packet to its Thread Network.
-        pkts.filter_wpan_src64(vars['BR_1']) \
-            .filter_AMPLFMA() \
-            .filter_ping_request(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/test_firewall.py b/tests/scripts/thread-cert/border_router/test_firewall.py
index d037d90..054a1ad 100755
--- a/tests/scripts/thread-cert/border_router/test_firewall.py
+++ b/tests/scripts/thread-cert/border_router/test_firewall.py
@@ -100,7 +100,7 @@
         self.assertEqual('router', router1.get_state())
         self.assertEqual('router', router2.get_state())
 
-        br1.set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
+        br1.add_prefix(config.DOMAIN_PREFIX, 'pros')
         br1.register_netdata()
 
         router1.add_ipmaddr(MA1)
@@ -127,9 +127,6 @@
         # 1. Host pings router1's OMR from host's infra address.
         self.assertTrue(host_ping_ether(router1.get_ip6_address(config.ADDRESS_TYPE.OMR)[0], interface=host.ETH_DEV))
 
-        # 2. Host pings router1's DUA from host's infra address.
-        self.assertTrue(host_ping_ether(router1.get_ip6_address(config.ADDRESS_TYPE.DUA), interface=host.ETH_DEV))
-
         # 3. Host pings router1's OMR from router1's RLOC.
         self.assertFalse(
             host_ping_ether(router1.get_ip6_address(config.ADDRESS_TYPE.OMR)[0],
@@ -215,7 +212,6 @@
         self.collect_rloc16s()
         self.collect_extra_vars()
         self.collect_omrs()
-        self.collect_duas()
 
     def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
         pkts = pv.pkts
@@ -231,12 +227,6 @@
         pkts.filter_wpan_src64(vars['BR_1']).filter_wpan_dst16(
             vars['Router_1_RLOC16']).filter_ping_request(identifier=_pkt.icmpv6.echo.identifier).must_next()
 
-        # 2. Host pings router1's DUA from host's infra address.
-        _pkt = pkts.filter_eth_src(vars['Host_ETH']).filter_ipv6_dst(
-            vars['Router_1_DUA']).filter_ping_request().must_next()
-        pkts.filter_wpan_src64(vars['BR_1']).filter_wpan_dst16(
-            vars['Router_1_RLOC16']).filter_ping_request(identifier=_pkt.icmpv6.echo.identifier).must_next()
-
         # 3. Host pings router1's OMR from router1's RLOC.
         _pkt = pkts.filter_eth_src(vars['Host_ETH']).filter_ipv6_src_dst(
             vars['Router_1_RLOC'], vars['Router_1_OMR'][0]).filter_ping_request().must_next()
diff --git a/tests/scripts/thread-cert/border_router/test_publish_meshcop_service.py b/tests/scripts/thread-cert/border_router/test_publish_meshcop_service.py
deleted file mode 100755
index bceecea..0000000
--- a/tests/scripts/thread-cert/border_router/test_publish_meshcop_service.py
+++ /dev/null
@@ -1,288 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-import binascii
-import ipaddress
-import logging
-import unittest
-
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies that OTBR publishes the meshcop service using a proper
-#   configuration.
-#
-# Topology:
-#    ----------------(eth)-----------------------------
-#           |             |                    |
-#          BR1           BR2           HOST (mDNS Browser)
-#
-#
-
-BR1 = 1
-BR2 = 2
-HOST = 3
-
-
-class PublishMeshCopService(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        BR1: {
-            'name': 'BR_1',
-            'allowlist': [],
-            'is_otbr': True,
-            'version': '1.2',
-            'network_name': 'ot-br1',
-            'boot_delay': 5,
-        },
-        BR2: {
-            'name': 'BR_2',
-            'allowlist': [],
-            'is_otbr': True,
-            'version': '1.2',
-            'network_name': 'ot-br2',
-            'boot_delay': 5,
-        },
-        HOST: {
-            'name': 'Host',
-            'is_host': True
-        },
-    }
-
-    def test(self):
-        host = self.nodes[HOST]
-        br1 = self.nodes[BR1]
-        br2 = self.nodes[BR2]
-        br2.disable_br()
-
-        # Use different network names to distinguish meshcop services
-        br1.set_active_dataset(updateExisting=True, network_name='ot-br1')
-        br2.set_active_dataset(updateExisting=True, network_name='ot-br2')
-
-        host.start(start_radvd=False)
-        self.simulator.go(20)
-
-        self.assertEqual(br1.get_state(), 'disabled')
-        # TODO enable this line when renaming with mDNSResponder is stable
-        # self.check_meshcop_service(br1, host)
-        br1.start()
-        self.simulator.go(config.BORDER_ROUTER_STARTUP_DELAY)
-        self.assertEqual('leader', br1.get_state())
-
-        # start to test ephemeral key mode (ePSKc)
-        br1.ephemeral_key_enabled = True
-        self.assertTrue(br1.ephemeral_key_enabled)
-        self.simulator.go(10)
-        self.check_meshcop_service(br1, host)
-
-        # activate ePSKc mode
-        lifetime = 500_000
-        ephemeral_key = br1.activate_ephemeral_key_mode(lifetime)
-        self.assertEqual(len(ephemeral_key), 9)
-        self.assertEqual(br1.get_ephemeral_key_state(), 'Started')
-        # check Meshcop-e service
-        self.check_meshcop_e_service(host, True)
-
-        # deactivate ePSKc mode in force
-        br1.deactivate_ephemeral_key_mode(retain_active_session=False)
-        self.assertEqual(br1.get_ephemeral_key_state(), 'Stopped')
-        self.simulator.go(10)
-        # check Meshcop-e service
-        self.check_meshcop_e_service(host, False)
-
-        # activate ePSKc mode by default lifetime
-        lifetime = 0
-        ephemeral_key = br1.activate_ephemeral_key_mode(lifetime)
-        self.assertEqual(len(ephemeral_key), 9)
-        self.assertEqual(br1.get_ephemeral_key_state(), 'Started')
-        # check Meshcop-e service
-        self.check_meshcop_e_service(host, True)
-
-        # deactivate ePSKc mode NOT in force
-        br1.deactivate_ephemeral_key_mode(retain_active_session=True)
-        self.assertEqual(br1.get_ephemeral_key_state(), 'Stopped')
-        self.simulator.go(10)
-        # check Meshcop-e service
-        self.check_meshcop_e_service(host, False)
-
-        # change ephemeral key mode (ePSKc) and check Meshcop
-        br1.ephemeral_key_enabled = False
-        self.assertFalse(br1.ephemeral_key_enabled)
-        self.simulator.go(10)
-        self.check_meshcop_service(br1, host)
-        # check Meshcop-e format
-        self.check_meshcop_e_service(host, False)
-        # end of ephemeral key mode (ePSKc) test
-
-        br1.disable_backbone_router()
-        self.simulator.go(10)
-        self.check_meshcop_service(br1, host)
-
-        br1.stop()
-        br1.set_active_dataset(updateExisting=True, network_name='ot-br1-1')
-        br1.start()
-        self.simulator.go(config.LEADER_REBOOT_DELAY)
-        self.check_meshcop_service(br1, host)
-
-        # verify that there are two meshcop services
-        br2.set_active_dataset(updateExisting=True, network_name='ot-br2-1')
-        br2.start()
-        br2.disable_backbone_router()
-        br2.enable_br()
-        self.simulator.go(config.LEADER_REBOOT_DELAY)
-
-        service_instances = host.browse_mdns_services('_meshcop._udp')
-        self.assertEqual(len(service_instances), 2)
-        br1_service = self.check_meshcop_service(br1, host)
-        br2_service = self.check_meshcop_service(br2, host)
-        self.assertNotEqual(br1_service['host'], br2_service['host'])
-
-        br1.disable_border_agent()
-        self.simulator.go(5)
-        br1.stop_otbr_service()
-        self.simulator.go(5)
-        br2.enable_backbone_router()
-        self.simulator.go(5)
-        self.assertEqual(len(host.browse_mdns_services('_meshcop._udp')), 1)
-        br1.start_otbr_service()
-        self.simulator.go(10)
-        br1.enable_border_agent()
-        self.simulator.go(5)
-        self.assertEqual(len(host.browse_mdns_services('_meshcop._udp')), 2)
-        self.check_meshcop_service(br1, host)
-        self.check_meshcop_service(br2, host)
-
-        br1.disable_border_agent()
-        self.simulator.go(5)
-        br1.factory_reset()
-
-        dataset = {
-            'timestamp': 1,
-            'channel': config.CHANNEL,
-            'channel_mask': config.CHANNEL_MASK,
-            'extended_panid': config.EXTENDED_PANID,
-            'mesh_local_prefix': config.MESH_LOCAL_PREFIX.split('/')[0],
-            'network_key': config.DEFAULT_NETWORK_KEY,
-            'network_name': 'ot-br-1-3',
-            'panid': config.PANID,
-            'pskc': config.PSKC,
-            'security_policy': config.SECURITY_POLICY,
-        }
-
-        br1.set_active_dataset(**dataset)
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-
-        # Since `br1` is factory reset, the Border Agent and other
-        # functions are not given the chance to stop properly and
-        # remove previously registered mDNS services. This can result
-        # in stale entries remaining in the mDNS cache, leading to
-        # more services appearing in `browse` results. Therefore, we
-        # use `assertGreaterEqual()` here.
-
-        self.assertGreaterEqual(len(host.browse_mdns_services('_meshcop._udp')), 2)
-        self.check_meshcop_service(br1, host)
-        self.check_meshcop_service(br2, host)
-
-    def check_meshcop_service(self, br, host):
-        services = self.discover_all_meshcop_services(host)
-        for service in services:
-            if service['txt']['nn'] == br.get_network_name():
-                self.check_meshcop_service_by_data(br, service)
-                return service
-        self.fail('MeshCoP service not found')
-
-    def check_meshcop_service_by_data(self, br, service_data):
-        sb_data = service_data['txt']['sb'].encode('raw_unicode_escape')
-        state_bitmap = int.from_bytes(sb_data, byteorder='big')
-        logging.info(bin(state_bitmap))
-        if br.get_ba_state() == 'Active':
-            self.assertEqual((state_bitmap & 7), 1)  # connection mode = PskC
-        else:
-            self.assertEqual((state_bitmap & 7), 0)  # connection mode = Disabled
-        sb_thread_interface_status = state_bitmap >> 3 & 3
-        sb_thread_role = state_bitmap >> 9 & 3
-        device_role = br.get_state()
-
-        if device_role == 'disabled':
-            # Thread interface is not active and is not initialized with a set of valid operation network parameters
-            self.assertEqual(sb_thread_interface_status, 0)
-            self.assertEqual(sb_thread_role, 0)  # Thread role is disabled
-
-        elif device_role == 'detached':
-            # Thread interface is initialized with a set of valid operation network parameters, but is not actively participating in a network
-            self.assertEqual(sb_thread_interface_status, 1)
-            self.assertEqual(sb_thread_role, 0)  # Thread role is detached
-
-        elif device_role == 'child':
-            # Thread interface is initialized with a set of valid operation network parameters, and is actively part of a Network
-            self.assertEqual(sb_thread_interface_status, 2)
-            self.assertEqual(sb_thread_role, 1)  # Thread role is child
-
-        elif device_role == 'router':
-            # Thread interface is initialized with a set of valid operation network parameters, and is actively part of a Network
-            self.assertEqual(sb_thread_interface_status, 2)
-            self.assertEqual(sb_thread_role, 2)  # Thread role is router
-
-        elif device_role == 'leader':
-            # Thread interface is initialized with a set of valid operation network parameters, and is actively part of a Network
-            self.assertEqual(sb_thread_interface_status, 2)
-            self.assertEqual(sb_thread_role, 3)  # Thread role is leader
-
-        self.assertEqual((state_bitmap >> 5 & 3), 1)  # high availability
-        self.assertEqual((state_bitmap >> 7 & 1), device_role not in ['disabled', 'detached'] and
-                         br.get_backbone_router_state() != 'Disabled')  # BBR is enabled or not
-        self.assertEqual((state_bitmap >> 8 & 1), device_role not in ['disabled', 'detached'] and
-                         br.get_backbone_router_state() == 'Primary')  # BBR is primary or not
-        self.assertEqual(bool(state_bitmap >> 11 & 1), br.ephemeral_key_enabled)  # ePSKc is supported or not
-        self.assertEqual(service_data['txt']['nn'], br.get_network_name())
-        self.assertEqual(service_data['txt']['rv'], '1')
-        self.assertIn(service_data['txt']['tv'], ['1.1.0', '1.1.1', '1.2.0', '1.3.0', '1.4.0'])
-
-    def discover_services(self, host, type):
-        instance_names = host.browse_mdns_services(type)
-        services = []
-        for instance_name in instance_names:
-            services.append(host.discover_mdns_service(instance_name, type, None))
-        return services
-
-    def discover_all_meshcop_services(self, host):
-        return self.discover_services(host, '_meshcop._udp')
-
-    def check_meshcop_e_service(self, host, isactive):
-        services = self.discover_services(host, '_meshcop-e._udp')
-        # TODO: Meshcop-e port check etc.
-        if isactive:
-            self.assertTrue(len(services) > 0, msg='Meshcop-e service not found')
-        else:
-            self.assertEqual(len(services), 0, msg='Meshcop-e service still found after disabled')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/test_srp_register_500_services_br.py b/tests/scripts/thread-cert/border_router/test_srp_register_500_services_br.py
deleted file mode 100755
index 5576cc8..0000000
--- a/tests/scripts/thread-cert/border_router/test_srp_register_500_services_br.py
+++ /dev/null
@@ -1,81 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-from test_srp_register_500_services import CLIENT_IDS, INSTANCE_IDS, SrpRegister500Services, SERVER, SERVICE_NAME
-
-# Test description:
-#   This test verifies SRP function that SRP clients can register up to 500 services to one SRP server.
-#
-#   This test is same as test_srp_register_500_services.py, with the only difference that the SRP server
-#   is running on an OTBR (Docker).
-#
-
-HOST = SERVER + 1
-
-
-class SrpRegister500ServicesBR(SrpRegister500Services):
-    TOPOLOGY = SrpRegister500Services.TOPOLOGY
-    TOPOLOGY[SERVER]['is_otbr'] = True
-    TOPOLOGY[HOST] = {
-        'name': 'Host',
-        'is_host': True,
-    }
-
-    def test(self):
-        self.nodes[HOST].start(start_radvd=False)
-
-        self._test_impl()
-
-        # Verify all SRP services are resolvable via mDNS
-        host = self.nodes[HOST]
-
-        browse_retry = 3
-        for i in range(browse_retry):
-            try:
-                service_instances = set(host.browse_mdns_services(SERVICE_NAME, timeout=10))
-                print(service_instances)
-
-                for clientid in CLIENT_IDS:
-                    for instanceid in INSTANCE_IDS:
-                        self.assertIn(f'client{clientid}_{instanceid}', service_instances)
-
-                break
-
-            except Exception:
-                if i < browse_retry - 1:
-                    self.simulator.go(3)
-                    continue
-
-
-del SrpRegister500Services
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/border_router/test_trel_connectivity.py b/tests/scripts/thread-cert/border_router/test_trel_connectivity.py
deleted file mode 100755
index 42a3a18..0000000
--- a/tests/scripts/thread-cert/border_router/test_trel_connectivity.py
+++ /dev/null
@@ -1,182 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-import unittest
-
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies TREL connectivity.
-#
-# Topology:
-#    ----------------(eth)--------------------------
-#                 |                  |
-#       FED1 --- BR1 (Leader) ----- BR2 --- ROUTER2
-#                /\
-#               /  \
-#             MED1 SED1
-#
-from config import PACKET_VERIFICATION_TREL
-from pktverify.packet_filter import PacketFilter
-from pktverify.packet_verifier import PacketVerifier
-
-BR1 = 1
-FED1 = 2
-MED1 = 3
-SED1 = 4
-BR2 = 5
-ROUTER2 = 6
-
-
-class TestTrelConnectivity(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    PACKET_VERIFICATION = PACKET_VERIFICATION_TREL
-
-    TOPOLOGY = {
-        BR1: {
-            'name': 'BR1',
-            'allowlist': [BR2, FED1, MED1, SED1],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        FED1: {
-            'name': 'ROUTER1',
-            'allowlist': [BR1],
-            'version': '1.2',
-            'router_eligible': False,
-        },
-        MED1: {
-            'name': 'MED1',
-            'allowlist': [BR1],
-            'version': '1.2',
-            'mode': 'rn',
-        },
-        SED1: {
-            'name': 'SED1',
-            'allowlist': [BR1],
-            'version': '1.2',
-            'mode': 'n',
-        },
-        BR2: {
-            'name': 'BR2',
-            'allowlist': [BR1, ROUTER2],
-            'is_otbr': True,
-            'version': '1.2',
-        },
-        ROUTER2: {
-            'name': 'ROUTER2',
-            'allowlist': [BR2],
-            'version': '1.2',
-        },
-    }
-
-    def test(self):
-        br1 = self.nodes[BR1]
-        fed1 = self.nodes[FED1]
-        med1 = self.nodes[MED1]
-        sed1 = self.nodes[SED1]
-        br2 = self.nodes[BR2]
-        router2 = self.nodes[ROUTER2]
-
-        if br1.is_trel_enabled() is None:
-            self.skipTest("TREL is not supported")
-
-        if br1.is_trel_enabled() == False:
-            br1.enable_trel()
-
-        if br2.is_trel_enabled() == False:
-            br2.enable_trel()
-
-        br1.start()
-        self.wait_node_state(br1, 'leader', 10)
-
-        fed1.start()
-        self.wait_node_state(fed1, 'child', 10)
-
-        med1.start()
-        self.wait_node_state(med1, 'child', 10)
-
-        sed1.start()
-        self.wait_node_state(sed1, 'child', 10)
-
-        br2.start()
-        self.wait_node_state(br2, 'router', 10)
-
-        router2.start()
-        self.wait_node_state(router2, 'router', 10)
-
-        # Allow the network to stabilize
-        self.simulator.go(config.BORDER_ROUTER_STARTUP_DELAY)
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-
-        router2_mleid = router2.get_mleid()
-        self.assertTrue(br1.ping(router2_mleid))
-        self.assertTrue(fed1.ping(router2_mleid))
-        self.assertTrue(med1.ping(router2_mleid))
-        self.assertTrue(sed1.ping(router2_mleid))
-
-        counters = br1.get_trel_counters()
-        print('br1 trel counters', counters)
-        self.assertTrue(counters['Inbound']['packets'] > 0)
-        self.assertTrue(counters['Inbound']['bytes'] > 0)
-        self.assertTrue(counters['Outbound']['packets'] > 0)
-        self.assertTrue(counters['Outbound']['bytes'] > 0)
-        self.assertTrue(counters['Outbound']['failures'] >= 0)
-
-        br1.reset_trel_counters()
-        counters = br1.get_trel_counters()
-        print('br1 trel counters after reset', counters)
-        self.assertTrue(counters['Inbound']['packets'] == 0)
-        self.assertTrue(counters['Inbound']['bytes'] == 0)
-        self.assertTrue(counters['Outbound']['packets'] == 0)
-        self.assertTrue(counters['Outbound']['bytes'] == 0)
-        self.assertTrue(counters['Outbound']['failures'] == 0)
-
-        self.assertGreater(br1.get_trel_port(), 0)
-
-    def verify(self, pv: PacketVerifier):
-        pkts: PacketFilter = pv.pkts
-        BR1_RLOC16 = pv.vars['BR1_RLOC16']
-        BR2_RLOC16 = pv.vars['BR2_RLOC16']
-
-        print('BR1_RLOC16:', hex(BR1_RLOC16))
-        print('BR2_RLOC16:', hex(BR2_RLOC16))
-
-        # Make sure BR1 and BR2 always use TREL for transmitting ping request and reply
-        pkts.filter_wpan_src16_dst16(BR1_RLOC16, BR2_RLOC16).filter_ping_request().must_not_next()
-        pkts.filter_wpan_src16_dst16(BR1_RLOC16, BR2_RLOC16).filter_ping_reply().must_not_next()
-
-        pkts.filter_wpan_src16_dst16(BR2_RLOC16, BR1_RLOC16).filter_ping_request().must_not_next()
-        pkts.filter_wpan_src16_dst16(BR2_RLOC16, BR1_RLOC16).filter_ping_reply().must_not_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/command.py b/tests/scripts/thread-cert/command.py
index 09b7423..7c0f8f6 100644
--- a/tests/scripts/thread-cert/command.py
+++ b/tests/scripts/thread-cert/command.py
@@ -557,7 +557,7 @@
 
         Args:
             command_msg (MleMessage) : The Mle message to check.
-            cid (int): The context id of the domain prefix.
+            cid (int): The context id of the prefix.
             iid (string): The Interface Identifier.
             cid_present_once(boolean): True if cid entry should appear only once in AR Tlv.
                                        False otherwise.
diff --git a/tests/scripts/thread-cert/config.py b/tests/scripts/thread-cert/config.py
index ad4bfa2..4767787 100644
--- a/tests/scripts/thread-cert/config.py
+++ b/tests/scripts/thread-cert/config.py
@@ -60,8 +60,6 @@
 TMF_PORT = 61631
 
 DOMAIN_PREFIX = 'fd00:7d03:7d03:7d03::/64'
-DOMAIN_PREFIX_REGEX_PATTERN = '^fd00:7d03:7d03:7d03:'
-DOMAIN_PREFIX_ALTER = 'fd00:7d04:7d04:7d04::/64'
 
 PORT_OFFSET = int(os.getenv('PORT_OFFSET', '0'))
 BACKBONE_IPV6_ADDR_START = f'{0x9100 + PORT_OFFSET:04x}'
@@ -77,9 +75,6 @@
 
 ALL_NETWORK_BBRS_ADDRESS = 'ff32:40:fd00:db8:0:0:0:3'
 
-ALL_DOMAIN_BBRS_ADDRESS = 'ff32:40:fd00:7d03:7d03:7d03:0:3'
-ALL_DOMAIN_BBRS_ADDRESS_ALTER = 'ff32:40:fd00:7d04:7d04:7d04:0:3'
-
 ONLINK_GUA_PREFIX = '2021::/64'
 
 # Any address starts with 'fd' are considered on-link address.
@@ -94,7 +89,6 @@
     RLOC = 'RLOC'
     ALOC = 'ALOC'
     ML_EID = 'ML_EID'
-    DUA = 'DUA'
     BACKBONE_GUA = 'BACKBONE_GUA'
     BACKBONE_LINK_LOCAL = 'BACKBONE_LINK_LOCAL'
     OMR = 'OMR'
@@ -122,9 +116,10 @@
 LEADER_STARTUP_DELAY = 12
 ROUTER_STARTUP_DELAY = 10
 # See logic of RouterRoleRestorer
-# ((kMaxTxCount=6) - 1) * 1.1 * (kMulticastRetxDelay=5) + 2 + ROUTER_STARTUP_DELAY
-ROUTER_RESTORE_DELAY = 40
-LEADER_REBOOT_DELAY = 40
+# (MLE_MAX_RESTORING_TRANSMISSION_COUNT - 1) * 1.1 * (kMulticastRetxDelay=5s) + 2s + ROUTER_STARTUP_DELAY
+ROUTER_RESTORE_DELAY = 29
+# (MLE_MAX_RESTORING_TRANSMISSION_COUNT - 1) * 1.1 * (kLinkRequestTimeout=2s) + 2s + LEADER_STARTUP_DELAY
+LEADER_REBOOT_DELAY = 21
 ED_STARTUP_DELAY = 5
 BORDER_ROUTER_STARTUP_DELAY = 20
 MAX_NEIGHBOR_AGE = 100
@@ -138,7 +133,6 @@
 DEFAULT_CHILD_TIMEOUT = 6
 VIRTUAL_TIME = int(os.getenv('VIRTUAL_TIME', 0))
 PARENT_AGGREGATIOIN_DELAY = 5
-DUA_DAD_DELAY = 5
 DEFAULT_BBR_REGISTRATION_JITTER = 2
 DEFAULT_ROUTER_SELECTION_JITTER = 1
 
@@ -153,11 +147,11 @@
 PACKET_VERIFICATION_DEFAULT = 1
 PACKET_VERIFICATION_TREL = 2
 
-# After leader reset it may retransmit link request 6 times with max 5.5s interval
-LEADER_RESET_DELAY = 41
-# After router reset it may retransmit link request 3 times with max 5.5s interval
-ROUTER_RESET_DELAY = 23
-MLE_MAX_CRITICAL_TRANSMISSION_COUNT = 6
+# After leader reset it may retransmit link request 4 times with max 2.2s interval
+LEADER_RESET_DELAY = 17
+# After router reset it may retransmit link request 4 times with max 5.5s interval
+ROUTER_RESET_DELAY = 30
+MLE_MAX_RESTORING_TRANSMISSION_COUNT = 4
 MLE_MAX_TRANSMISSION_COUNT = 3
 
 
diff --git a/tests/scripts/thread-cert/node.py b/tests/scripts/thread-cert/node.py
index f8602c0..723bfe6 100755
--- a/tests/scripts/thread-cert/node.py
+++ b/tests/scripts/thread-cert/node.py
@@ -1608,40 +1608,6 @@
         self.send_command(cmd)
         self._expect_done()
 
-    def set_domain_prefix(self, prefix, flags='prosD'):
-        self.add_prefix(prefix, flags)
-        self.register_netdata()
-
-    def remove_domain_prefix(self, prefix):
-        self.remove_prefix(prefix)
-        self.register_netdata()
-
-    def set_next_dua_response(self, status: Union[str, int], iid=None):
-        # Convert 5.00 to COAP CODE 160
-        if isinstance(status, str):
-            assert '.' in status
-            status = status.split('.')
-            status = (int(status[0]) << 5) + int(status[1])
-
-        cmd = 'bbr mgmt dua {}'.format(status)
-        if iid is not None:
-            cmd += ' ' + str(iid)
-        self.send_command(cmd)
-        self._expect_done()
-
-    def set_dua_iid(self, iid: str):
-        assert len(iid) == 16
-        int(iid, 16)
-
-        cmd = 'dua iid {}'.format(iid)
-        self.send_command(cmd)
-        self._expect_done()
-
-    def clear_dua_iid(self):
-        cmd = 'dua iid clear'
-        self.send_command(cmd)
-        self._expect_done()
-
     def multicast_listener_list(self) -> Dict[IPv6Address, int]:
         cmd = 'bbr mgmt mlr listener'
         self.send_command(cmd)
@@ -2153,7 +2119,7 @@
         omr_addrs = []
         for addr in self.get_addrs():
             for prefix in prefixes:
-                if (addr.startswith(prefix)) and (addr != self.__getDua()):
+                if addr.startswith(prefix):
                     omr_addrs.append(addr)
                     break
 
@@ -2202,13 +2168,6 @@
 
         return None
 
-    def __getDua(self) -> Optional[str]:
-        for ip6Addr in self.get_addrs():
-            if re.match(config.DOMAIN_PREFIX_REGEX_PATTERN, ip6Addr, re.I):
-                return ip6Addr
-
-        return None
-
     def get_ip6_address_by_prefix(self, prefix: Union[str, IPv6Network]) -> List[IPv6Address]:
         """Get addresses matched with given prefix.
 
@@ -2244,8 +2203,6 @@
             return self.__getAloc()
         elif address_type == config.ADDRESS_TYPE.ML_EID:
             return self.__getMleid()
-        elif address_type == config.ADDRESS_TYPE.DUA:
-            return self.__getDua()
         elif address_type == config.ADDRESS_TYPE.BACKBONE_GUA:
             return self._getBackboneGua()
         elif address_type == config.ADDRESS_TYPE.OMR:
diff --git a/tests/scripts/thread-cert/pktverify/addrs.py b/tests/scripts/thread-cert/pktverify/addrs.py
index cd47b21..147c06c 100644
--- a/tests/scripts/thread-cert/pktverify/addrs.py
+++ b/tests/scripts/thread-cert/pktverify/addrs.py
@@ -110,14 +110,6 @@
         return True
 
     @property
-    def is_dua(self) -> bool:
-        """
-        Returns if the Ip6 address is Domain Unicast Address.
-        """
-        from pktverify import consts
-        return self.startswith(consts.DOMAIN_PREFIX)
-
-    @property
     def is_backbone_gua(self) -> bool:
         """
         Returns if the Ip6 address is Backbone address.
diff --git a/tests/scripts/thread-cert/pktverify/consts.py b/tests/scripts/thread-cert/pktverify/consts.py
index 06510f5..68a64fe 100644
--- a/tests/scripts/thread-cert/pktverify/consts.py
+++ b/tests/scripts/thread-cert/pktverify/consts.py
@@ -30,7 +30,6 @@
 from pktverify.addrs import Ipv6Addr
 from pktverify.bytes import Bytes
 
-DOMAIN_PREFIX = Bytes('fd00:7d03:7d03:7d03')
 BACKBONE_IPV6_PREFIX = Bytes('91')
 
 LINK_LOCAL_ALL_THREAD_NODES_MULTICAST_ADDRESS = Ipv6Addr('ff32:40:fd00:db8::1')
@@ -279,8 +278,6 @@
 LM_FORWARD_PROBING_REGISTRATION_SUB_TLV = 3
 LM_ENHANCED_ACK_CONFIGURATION_SUB_TLV = 7
 
-# DUA related constants
-
 ADDRESS_QUERY_INITIAL_RETRY_DELAY = 15
 ADDRESS_QUERY_MAX_RETRY_DELAY = 8
 ADDRESS_QUERY_TIMEOUT = 3
@@ -291,10 +288,6 @@
 
 DATA_RESUBMIT_DELAY = 300
 
-DUA_DAD_PERIOD = 100
-DUA_DAD_QUERY_TIMEOUT = 1.0
-DUA_DAD_REPEATS = 2
-DUA_RECENT_TIME = 20
 FAILED_ROUTER_TRANSMISSIONS = 4
 ID_REUSE_DELAY = 100
 ID_SEQUENCE_PERIOD = 10
@@ -324,7 +317,7 @@
     'dns',
     'igmp',
     'mdns',
-    'dns',
+    'trel',
 }
 
 FAKE_LAYER_NAMES = {'thread_nwd', 'thread_meshcop', 'ipv6inner'}
@@ -351,6 +344,7 @@
 }
 
 WIRESHARK_DECODE_AS_ENTRIES = {
+    'udp.port==53': 'dns',
     'udp.port==61631': 'coap',
     # SRP server ports for dissecting SRP requests and responses
     'udp.port==53535': 'dns',
@@ -376,8 +370,6 @@
 }
 
 TIMEOUT_JOIN_NETWORK = 10
-TIMEOUT_DUA_REGISTRATION = 10
-TIMEOUT_DUA_DAD = 15
 TIMEOUT_HOST_READY = 10
 TIMEOUT_CHILD_DETACH = 120
 TIMEOUT_REGISTER_MA = 5
@@ -406,7 +398,7 @@
 THREAD_VERSION_1_3 = 4
 THREAD_VERSION_1_4 = 5
 
-# ICMPv6 Types
+# ICMPV6 Types
 ICMPV6_TYPE_DESTINATION_UNREACHABLE = 1
 ICMPV6_TYPE_PACKET_TO_BIG = 2
 ICMPV6_TYPE_TIME_EXCEEDED = 3
@@ -414,6 +406,17 @@
 ICMPV6_TYPE_ECHO_REQUEST = 128
 ICMPV6_TYPE_ECHO_REPLY = 129
 
+# DNS constants
+DNS_OPCODE_UPDATE = 5
+DNS_TYPE_A = 1
+DNS_TYPE_PTR = 12
+DNS_TYPE_TXT = 16
+DNS_TYPE_AAAA = 28
+DNS_TYPE_SRV = 33
+DNS_TYPE_ANY = 255
+DNS_RCODE_NOERROR = 0
+DNS_RCODE_NXDOMAIN = 3
+
 ICMPV6_RA_OPT_TYPE_RIO = 24
 
 THREAD_ALLOWED_ICMPV6_TYPES = [
diff --git a/tests/scripts/thread-cert/pktverify/layer_fields.py b/tests/scripts/thread-cert/pktverify/layer_fields.py
index 59a6242..5e2881a 100644
--- a/tests/scripts/thread-cert/pktverify/layer_fields.py
+++ b/tests/scripts/thread-cert/pktverify/layer_fields.py
@@ -646,6 +646,9 @@
     'thread_nwd.tlv.stable': _list(_auto),
     'thread_nwd.tlv.service.t': _auto,
     'thread_nwd.tlv.service.s_id': _auto,
+    'thread_nwd.tlv.service.s_enterprise_number': _list(_auto),
+    'thread_nwd.tlv.service.srp_dataset_identifier': _list(_hex),
+    'thread_nwd.tlv.service.s_data_bin': _list(_bytes),
     'thread_nwd.tlv.service.s_data_len': _auto,
     'thread_nwd.tlv.service.s_data.seqno': _auto,
     'thread_nwd.tlv.service.s_data.rrdelay': _auto,
@@ -654,10 +657,11 @@
     'thread_nwd.tlv.border_router_16': _list(_auto),
     'thread_nwd.tlv.has_route.br_16': _list(_auto),
     'thread_nwd.tlv.has_route.pref': _list(_auto),
+    'thread_nwd.tlv.has_route.np': _list(_auto),
     'thread_nwd.tlv.sub_tlvs': _list(_str),
     # TODO: support thread_nwd.tlv.prefix.length and thread_nwd.tlv.prefix.domain_id
     'thread_nwd.tlv.prefix': _list(_ipv6_addr),
-    'thread_nwd.tlv.border_router.pref': _auto,
+    'thread_nwd.tlv.border_router.pref': _list(_auto),
     'thread_nwd.tlv.border_router.flag.s': _list(_auto),
     'thread_nwd.tlv.border_router.flag.r': _list(_auto),
     'thread_nwd.tlv.border_router.flag.p': _list(_auto),
@@ -677,26 +681,46 @@
     'thread_diagnostic.tlv.general': _list(_str),
 
     # DNS
-    'dns.resp.ttl': _auto,
+    'dns.flags.opcode': _auto,
+    'dns.flags.rcode': _auto,
     'dns.flags.response': _auto,
     'dns.count.answers': _auto,
+    'dns.qry.name': _list(_str),
+    'dns.qry.type': _list(_auto),
+    'dns.resp.ttl': _auto,
     'dns.resp.name': _list(_str),
     'dns.resp.type': _list(_auto),
+    'dns.aaaa': _list(_ipv6_addr),
     'dns.txt': _list(_bytes),
+    'dns.srv.priority': _list(_auto),
+    'dns.srv.weight': _list(_auto),
     'dns.srv.port': _list(_auto),
     'dns.srv.target': _list(_str),
     'dns.ptr.domain_name': _list(_str),
 
     # MDNS
-    'mdns.resp.ttl': _auto,
+    'mdns.flags.opcode': _auto,
+    'mdns.flags.rcode': _auto,
     'mdns.flags.response': _auto,
     'mdns.count.answers': _auto,
+    'mdns.qry.name': _list(_str),
+    'mdns.qry.type': _list(_auto),
+    'mdns.resp.ttl': _auto,
     'mdns.resp.name': _list(_str),
     'mdns.resp.type': _list(_auto),
+    'mdns.aaaa': _list(_ipv6_addr),
     'mdns.txt': _list(_bytes),
+    'mdns.srv.priority': _list(_auto),
+    'mdns.srv.weight': _list(_auto),
     'mdns.srv.port': _list(_auto),
     'mdns.srv.target': _list(_str),
     'mdns.ptr.domain_name': _list(_str),
+
+    # TREL
+    'trel.source_addr': _ext_addr,
+    'trel.destination_addr': _ext_addr,
+    'trel.wpan.src16': _hex,
+    'trel.wpan.dst16': _hex,
 }
 
 _layer_containers = set()
diff --git a/tests/scripts/thread-cert/pktverify/packet_verifier.py b/tests/scripts/thread-cert/pktverify/packet_verifier.py
index 7b5a1c9..e05edcb 100644
--- a/tests/scripts/thread-cert/pktverify/packet_verifier.py
+++ b/tests/scripts/thread-cert/pktverify/packet_verifier.py
@@ -125,9 +125,7 @@
             name = self.test_info.get_node_name(i)
             self._vars[name + '_IPADDRS'] = addrs
             for addr in addrs:
-                if addr.is_dua:
-                    key = name + '_DUA'
-                elif addr.is_backbone_gua:
+                if addr.is_backbone_gua:
                     key = name + '_BGUA'
                 elif addr.is_link_local and (name + '_BGUA') in self._vars:
                     # FIXME: assume the link-local address after Backbone GUA is the Backbone Link Local address
@@ -161,10 +159,6 @@
             key = self.test_info.get_node_name(i) + '_OMR'
             self._vars[key] = omr
 
-        for i, dua in self.test_info.duas.items():
-            key = self.test_info.get_node_name(i) + '_DUA'
-            self._vars[key] = dua
-
         if self.test_info.leader_aloc:
             self._vars['LEADER_ALOC'] = self.test_info.leader_aloc
 
@@ -216,72 +210,6 @@
 
         return result
 
-    def verify_ping(self, src: str, dst: str, bbr: str = None, pkts: 'PacketVerifier' = None) -> VerifyResult:
-        """
-        Verify the ping process.
-
-        :param src: The source device name.
-        :param dst: The destination device name.
-        :param bbr: The Backbone Router name.
-                    If specified, this method also verifies that the ping request and reply be forwarded by the Backbone Router.
-        :param pkts: The PacketFilter to search.
-
-        :return: The verification result.
-        """
-        if bbr:
-            assert not (self.is_thread_device(src) and self.is_thread_device(dst)), \
-                f"both {src} and {dst} are WPAN devices"
-            assert not (self.is_backbone_device(src) and self.is_backbone_device(dst)), \
-                f"both {src} and {dst} are ETH devices"
-
-        if pkts is None:
-            pkts = self.pkts
-
-        src_dua = self.vars[src + '_DUA']
-        dst_dua = self.vars[dst + '_DUA']
-        if bbr:
-            bbr_ext = self.vars[bbr]
-            bbr_eth = self.vars[bbr + '_ETH']
-
-        result = VerifyResult()
-        ping_req = pkts.filter_ping_request().filter_ipv6_dst(dst_dua)
-        if self.is_backbone_device(src):
-            p = ping_req.filter_eth_src(self.vars[src + '_ETH']).must_next()
-        else:
-            p = ping_req.filter_wpan_src64(self.vars[src]).must_next()
-
-        # pkts.last().show()
-        ping_id = p.icmpv6.echo.identifier
-        logging.info("verify_ping: ping_id=%x", ping_id)
-        result.record_last('ping_request', pkts)
-        ping_req = ping_req.filter(lambda p: p.icmpv6.echo.identifier == ping_id)
-
-        # BBR unicasts the ping packet to TD.
-        if bbr:
-            if self.is_backbone_device(src):
-                ping_req.filter_wpan_src64(bbr_ext).must_next()
-            else:
-                ping_req.filter_eth_src(bbr_eth).must_next()
-
-        ping_reply = pkts.filter_ping_reply().filter_ipv6_dst(src_dua).filter(
-            lambda p: p.icmpv6.echo.identifier == ping_id)
-        # TD receives ping packet and responds back to Host via SBBR.
-        if self.is_thread_device(dst):
-            ping_reply.filter_wpan_src64(self.vars[dst]).must_next()
-        else:
-            ping_reply.filter_eth_src(self.vars[dst + '_ETH']).must_next()
-
-        result.record_last('ping_reply', pkts)
-
-        if bbr:
-            # SBBR forwards the ping response packet to Host.
-            if self.is_thread_device(dst):
-                ping_reply.filter_eth_src(bbr_eth).must_next()
-            else:
-                ping_reply.filter_wpan_src64(bbr_ext).must_next()
-
-        return result
-
     def is_thread_device(self, name: str) -> bool:
         """
         Returns if the device is an WPAN device.
@@ -314,45 +242,3 @@
             eth_idx = max(eth_idx, ei)
 
         return wpan_idx, eth_idx
-
-    def verify_dua_registration(self, src64, dua, *, pbbr_eth, sbbr_eth=None, pbbr_src64=None):
-        pv, pkts = self, self.pkts
-        MM = pv.vars['MM_PORT']
-        BB = pv.vars['BB_PORT']
-
-        # Router1 should send /n/dr for DUA registration
-        dr = pkts.filter_wpan_src64(src64).filter_coap_request('/n/dr', port=MM).filter(
-            'thread_nm.tlv.target_eid == {ROUTER1_DUA}', ROUTER1_DUA=dua).must_next()
-
-        # SBBR should not send /b/bq for Router1's DUA
-        if sbbr_eth is not None:
-            pkts.filter_backbone_query(dua, eth_src=sbbr_eth, port=BB).must_not_next()
-
-        # PBBR should respond to /n/dr
-        if pbbr_src64 is not None:
-            pkts.filter_wpan_src64(pbbr_src64).filter_coap_ack(
-                '/n/dr', port=MM).must_next().must_verify('thread_nm.tlv.status == 0')
-
-        # PBBR should send /b/bq for Router1's DUA (1st time)
-        bq1 = pkts.filter_backbone_query(dua, eth_src=pbbr_eth, port=BB).must_next()
-        bq1_index = pkts.index
-
-        assert bq1.sniff_timestamp - dr.sniff_timestamp <= 1.01, bq1.sniff_timestamp - dr.sniff_timestamp
-
-        # PBBR should send /b/bq for Router1's DUA (2nd time)
-        bq2 = pkts.filter_backbone_query(dua, eth_src=pbbr_eth, port=BB).must_next()
-
-        assert 0.9 < bq2.sniff_timestamp - bq1.sniff_timestamp < 1.1, bq2.sniff_timestamp - bq1.sniff_timestamp
-
-        # PBBR should send /b/bq for Router1's DUA (3rd time)
-        bq3 = pkts.filter_backbone_query(dua, eth_src=pbbr_eth, port=BB).must_next()
-
-        assert 0.9 < bq3.sniff_timestamp - bq2.sniff_timestamp < 1.1, bq3.sniff_timestamp - bq2.sniff_timestamp
-
-        # PBBR should send PRO_BB.ntf for Router's DUA when DAD completed
-        pkts.filter_eth_src(pbbr_eth).filter_backbone_answer(dua, port=BB, confirmable=False).must_next().show()
-
-        # PBBR should not recv /b/ba response from other BBRs during this period
-        pkts.range(bq1_index, pkts.index,
-                   cascade=False).filter('eth.src != {PBBR_ETH}',
-                                         PBBR_ETH=pbbr_eth).filter_backbone_answer(dua, port=BB).must_not_next()
diff --git a/tests/scripts/thread-cert/pktverify/test_info.py b/tests/scripts/thread-cert/pktverify/test_info.py
index 937929b..4a26f45 100644
--- a/tests/scripts/thread-cert/pktverify/test_info.py
+++ b/tests/scripts/thread-cert/pktverify/test_info.py
@@ -54,7 +54,6 @@
             self.rlocs = {int(k): Ipv6Addr(v) for k, v in test_info.get('rlocs', {}).items()}
             self.rloc16s = self._convert_hex_values(self._convert_keys_to_ints(test_info.get('rloc16s', {})))
             self.omrs = {int(k): [Ipv6Addr(x) for x in l] for k, l in test_info.get('omrs', {}).items()}
-            self.duas = {int(k): Ipv6Addr(v) for k, v in test_info.get('duas', {}).items()}
             self.extra_vars = test_info.get('extra_vars', {})
             self.leader_aloc = Ipv6Addr(test_info.get('leader_aloc')) if 'leader_aloc' in test_info else ''
 
diff --git a/tests/scripts/thread-cert/requirements.txt b/tests/scripts/thread-cert/requirements.txt
index f4e3f1b..dfaeb2b 100644
--- a/tests/scripts/thread-cert/requirements.txt
+++ b/tests/scripts/thread-cert/requirements.txt
@@ -4,9 +4,11 @@
 #
 #    pip-compile requirements.in
 #
+appdirs==1.4.4
+    # via pyshark
 ipaddress==1.0.23
     # via -r requirements.in
-lxml==5.3.0
+lxml==6.1.0
     # via pyshark
 packaging==23.0
     # via pyshark
@@ -14,9 +16,9 @@
     # via -r requirements.in
 ptyprocess==0.7.0
     # via pexpect
-py==1.11.0
-    # via pyshark
 pycryptodome==3.19.1
     # via -r requirements.in
 pyshark==0.6
     # via -r requirements.in
+termcolor==3.3.0
+    # via pyshark
diff --git a/tests/scripts/thread-cert/run_cert_suite.py b/tests/scripts/thread-cert/run_cert_suite.py
index e8ba94d..4c318b1 100755
--- a/tests/scripts/thread-cert/run_cert_suite.py
+++ b/tests/scripts/thread-cert/run_cert_suite.py
@@ -31,6 +31,7 @@
 import os
 import queue
 import subprocess
+import sys
 import time
 import traceback
 from collections import Counter, defaultdict
@@ -42,8 +43,6 @@
 VIRTUAL_TIME = int(os.getenv('VIRTUAL_TIME', '1'))
 MAX_JOBS = int(os.getenv('MAX_JOBS', (multiprocessing.cpu_count() * 2 if VIRTUAL_TIME else 10)))
 
-_BACKBONE_TESTS_DIR = 'tests/scripts/thread-cert/backbone'
-
 _COLOR_PASS = '\033[0;32m'
 _COLOR_FAIL = '\033[0;31m'
 _COLOR_NONE = '\033[0m'
@@ -98,31 +97,13 @@
 pool = multiprocessing.Pool(processes=MAX_JOBS)
 
 
-def cleanup_backbone_env():
-    logging.info("Cleaning up Backbone testing environment ...")
-    bash('pkill socat 2>/dev/null || true')
-    bash('pkill dumpcap 2>/dev/null || true')
-    bash(f'docker rm -f $(docker ps -a -q -f "name=otbr_") 2>/dev/null || true')
-    bash(f'docker network rm $(docker network ls -q -f "name=backbone") 2>/dev/null || true')
-
-
-def setup_backbone_env():
-    if THREAD_VERSION == '1.1':
-        raise RuntimeError('Backbone tests do not work with THREAD_VERSION=1.1')
-
-    if VIRTUAL_TIME:
-        raise RuntimeError('Backbone tests only work with VIRTUAL_TIME=0')
-
-    bash(f'docker image inspect {config.OTBR_DOCKER_IMAGE} >/dev/null')
-
-
 def parse_args():
     import argparse
     parser = argparse.ArgumentParser(description='Process some integers.')
     parser.add_argument('--multiply', type=int, default=1, help='run each test for multiple times')
     parser.add_argument('--timeout', type=int, default=0, help='timeout in seconds per test, zero means no timeout')
     parser.add_argument('--run-directory', type=str, default=None, help='run each test in the specified directory')
-    parser.add_argument("scripts", nargs='+', type=str, help='specify Backbone test scripts')
+    parser.add_argument("scripts", nargs='+', type=str, help='specify test scripts')
 
     args = parser.parse_args()
     logging.info("Max jobs: %d", MAX_JOBS)
@@ -133,15 +114,6 @@
     return args
 
 
-def check_has_backbone_tests(scripts):
-    for script in scripts:
-        relpath = os.path.relpath(script, _BACKBONE_TESTS_DIR)
-        if not relpath.startswith('..'):
-            return True
-
-    return False
-
-
 class PortOffsetPool:
 
     def __init__(self, size: int):
@@ -205,19 +177,8 @@
 def main():
     args = parse_args()
 
-    has_backbone_tests = check_has_backbone_tests(args.scripts)
-    logging.info('Has Backbone tests: %s', has_backbone_tests)
-
-    if has_backbone_tests:
-        cleanup_backbone_env()
-        setup_backbone_env()
-
-    try:
-        fail_count = run_tests(args.scripts, args.multiply, args.run_directory, args.timeout)
-        exit(fail_count)
-    finally:
-        if has_backbone_tests:
-            cleanup_backbone_env()
+    fail_count = run_tests(args.scripts, args.multiply, args.run_directory, args.timeout)
+    sys.exit(1 if fail_count else 0)
 
 
 if __name__ == '__main__':
diff --git a/tests/scripts/thread-cert/test_anycast.py b/tests/scripts/thread-cert/test_anycast.py
deleted file mode 100755
index c557555..0000000
--- a/tests/scripts/thread-cert/test_anycast.py
+++ /dev/null
@@ -1,140 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-ROUTER1 = 1
-ROUTER2 = 2
-ROUTER3 = 3
-ROUTER4 = 4
-ROUTER5 = 5
-
-TEST_PREFIX = '2001:dead:beef:cafe::/64'
-TEST_UDP_BYTES = 16
-TEST_UDP_PORT = 12345
-
-
-class TestAnycast(thread_cert.TestCase):
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        ROUTER1: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER2],
-        },
-        ROUTER2: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER1, ROUTER3],
-        },
-        ROUTER3: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER2, ROUTER4],
-        },
-        ROUTER4: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER3, ROUTER5],
-        },
-        ROUTER5: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER4],
-        },
-    }
-
-    def test(self):
-        self.nodes[ROUTER1].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER1].get_state(), 'leader')
-        self.nodes[ROUTER1].udp_start('::', TEST_UDP_PORT)
-
-        for i in range(ROUTER2, ROUTER5 + 1):
-            self.nodes[i].start()
-            self.simulator.go(config.ROUTER_STARTUP_DELAY)
-            self.assertEqual(self.nodes[i].get_state(), 'router')
-            self.nodes[i].udp_start('::', TEST_UDP_PORT)
-
-        self.__test('ds')  # DHCPv6 Agent (IPv6 Address)
-        self.__test('cs')  # DHCPv6 Agent (Other)
-        self.__test('ns')  # Neighbor Discovery Agent
-
-    def __test(self, borderRouterFlags):
-        # Single Server
-        self.nodes[ROUTER2].add_prefix(TEST_PREFIX, borderRouterFlags)
-        self.nodes[ROUTER2].register_netdata()
-        self.simulator.go(5)
-        print(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC))
-        self.assertTrue(len(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)) == 1)
-
-        self.nodes[ROUTER3].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0],
-                                     TEST_UDP_PORT)
-        self.simulator.go(1)
-        self.nodes[ROUTER2].udp_check_rx(TEST_UDP_BYTES)
-
-        # Multiple Servers
-        self.nodes[ROUTER5].add_prefix(TEST_PREFIX, borderRouterFlags)
-        self.nodes[ROUTER5].register_netdata()
-        self.simulator.go(5)
-        self.assertTrue(len(self.nodes[ROUTER5].get_ip6_address(config.ADDRESS_TYPE.ALOC)) == 1)
-
-        self.nodes[ROUTER3].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0],
-                                     TEST_UDP_PORT)
-        self.simulator.go(1)
-        self.nodes[ROUTER2].udp_check_rx(TEST_UDP_BYTES)
-
-        self.nodes[ROUTER4].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0],
-                                     TEST_UDP_PORT)
-        self.simulator.go(1)
-        self.nodes[ROUTER5].udp_check_rx(TEST_UDP_BYTES)
-
-        # Single Server
-        self.nodes[ROUTER2].remove_prefix(TEST_PREFIX)
-        self.nodes[ROUTER2].register_netdata()
-        self.simulator.go(5)
-        self.assertTrue(len(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)) == 0)
-
-        self.nodes[ROUTER3].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER5].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0],
-                                     TEST_UDP_PORT)
-        self.simulator.go(1)
-        self.nodes[ROUTER5].udp_check_rx(TEST_UDP_BYTES)
-
-        self.nodes[ROUTER4].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER5].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0],
-                                     TEST_UDP_PORT)
-        self.simulator.go(1)
-        self.nodes[ROUTER5].udp_check_rx(TEST_UDP_BYTES)
-
-        self.nodes[ROUTER5].remove_prefix(TEST_PREFIX)
-        self.nodes[ROUTER5].register_netdata()
-        self.simulator.go(5)
-        self.assertTrue(len(self.nodes[ROUTER5].get_ip6_address(config.ADDRESS_TYPE.ALOC)) == 0)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_anycast_locator.py b/tests/scripts/thread-cert/test_anycast_locator.py
deleted file mode 100755
index 7d0883f..0000000
--- a/tests/scripts/thread-cert/test_anycast_locator.py
+++ /dev/null
@@ -1,167 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import config
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies Anycast Locator functionality
-#
-# Topology:
-#
-#   LEADER -- ROUTER1 -- ROUTER2 -- ROUTER3 -- ROUTER4
-#
-
-LEADER = 1
-ROUTER1 = 2
-ROUTER2 = 3
-ROUTER3 = 4
-ROUTER4 = 5
-
-SRV_ENT_NUMBER = '44970'
-SRV_SERVICE_DATA = '571234'
-SRV_SERVER_DATA = '00'
-
-
-class AnycastLocator(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-            'allowlist': [ROUTER1]
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'mode': 'rdn',
-            'allowlist': [LEADER, ROUTER2]
-        },
-        ROUTER2: {
-            'name': 'ROUTER2',
-            'mode': 'rdn',
-            'allowlist': [ROUTER1, ROUTER3]
-        },
-        ROUTER3: {
-            'name': 'ROUTER3',
-            'mode': 'rdn',
-            'allowlist': [ROUTER2, ROUTER4]
-        },
-        ROUTER4: {
-            'name': 'ROUTER4',
-            'mode': 'rdn',
-            'allowlist': [ROUTER3]
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router1 = self.nodes[ROUTER1]
-        router2 = self.nodes[ROUTER2]
-        router3 = self.nodes[ROUTER3]
-        router4 = self.nodes[ROUTER4]
-        nodes = [leader, router1, router2, router3, router4]
-
-        #
-        # 0. Start the leader and routers
-        #
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        for node in nodes[1:]:
-            node.start()
-            self.simulator.go(config.ROUTER_STARTUP_DELAY)
-            self.assertEqual(node.get_state(), 'router')
-
-        #
-        # 1. Locate leader ALOC from all nodes
-        #
-
-        leader_aloc = leader.get_addr_leader_aloc()
-
-        for node in nodes:
-            result = node.locate(leader_aloc)
-            self.assertEqual(result[0], leader.get_mleid())
-            self.assertEqual(int(result[1], 0), leader.get_addr16())
-
-        #
-        # 2. Add a service on router4 and locate its ALOC
-        #
-
-        router4.add_service(SRV_ENT_NUMBER, SRV_SERVICE_DATA, SRV_SERVER_DATA)
-        router4.register_netdata()
-        self.simulator.go(5)
-        services = leader.get_services()
-        self.assertEqual(len(services), 1)
-
-        service_aloc = router4.get_ip6_address(config.ADDRESS_TYPE.ALOC)[0]
-
-        for node in nodes:
-            result = node.locate(service_aloc)
-            self.assertEqual(result[0], router4.get_mleid())
-            self.assertEqual(int(result[1], 0), router4.get_addr16())
-
-        #
-        # 3. Add same service on leader and ensure we locate the closest ALOC destination
-        #
-
-        leader.add_service(SRV_ENT_NUMBER, SRV_SERVICE_DATA, SRV_SERVER_DATA)
-        leader.register_netdata()
-        self.simulator.go(5)
-        services = leader.get_services()
-        self.assertEqual(len(services), 2)
-
-        # leader and router1 should locate leader as closest service ALOC,
-        # router3 and router4 should locate router4. router2 is in middle
-        # and can locate either one.
-        for node in [leader, router1]:
-            result = node.locate(service_aloc)
-            self.assertEqual(result[0], leader.get_mleid())
-            self.assertEqual(int(result[1], 0), leader.get_addr16())
-
-        for node in [router3, router4]:
-            result = node.locate(service_aloc)
-            self.assertEqual(result[0], router4.get_mleid())
-            self.assertEqual(int(result[1], 0), router4.get_addr16())
-
-        result = router2.locate(service_aloc)
-        self.assertTrue(result[0] in [leader.get_mleid(), router4.get_mleid()])
-        self.assertTrue(int(result[1], 0) in [leader.get_addr16(), router4.get_addr16()])
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_br_upgrade_router_role.py b/tests/scripts/thread-cert/test_br_upgrade_router_role.py
deleted file mode 100755
index 91b1fa9..0000000
--- a/tests/scripts/thread-cert/test_br_upgrade_router_role.py
+++ /dev/null
@@ -1,158 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#
-# This test verifies behavior of Border Routers (which provide IP connectivity) requesting router role within Thread
-# mesh.
-
-# Topology:
-#
-#  5 FTD nodes, all connected.
-#
-#  Topology is expected to change during execution of test.
-#
-
-LEADER = 1
-ROUTER = 2
-BR1 = 3
-BR2 = 4
-BR3 = 5
-
-
-class BrUpgradeRouterRole(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'leader',
-            'mode': 'rdn',
-        },
-        ROUTER: {
-            'name': 'reader',
-            'mode': 'rdn',
-        },
-        BR1: {
-            'name': 'br-1',
-            'mode': 'rdn',
-        },
-        BR2: {
-            'name': 'br-2',
-            'mode': 'rdn',
-        },
-        BR3: {
-            'name': 'br-3',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router = self.nodes[ROUTER]
-        br1 = self.nodes[BR1]
-        br2 = self.nodes[BR2]
-        br3 = self.nodes[BR3]
-
-        #-------------------------------------------------------------------------------------
-        # Set the router upgrade threshold to 2 on all nodes.
-
-        for node in [leader, router, br1, br2, br3]:
-            node.set_router_upgrade_threshold(2)
-
-        #-------------------------------------------------------------------------------------
-        # Start the leader and router.
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        router.start()
-        self.simulator.go(125)
-        self.assertEqual(router.get_state(), 'router')
-
-        #-------------------------------------------------------------------------------------
-        # Start all three BRs, we expect all to stay as child since there are already two
-        # routers in the Thread mesh.
-
-        br1.start()
-        br2.start()
-        br3.start()
-        self.simulator.go(125)
-        self.assertEqual(br1.get_state(), 'child')
-        self.assertEqual(br2.get_state(), 'child')
-        self.assertEqual(br3.get_state(), 'child')
-
-        #-------------------------------------------------------------------------------------
-        # Add an external route on `br1`, it should now try to become a router requesting
-        # with status BorderRouterRequest. Verify that leader allows it to become router.
-
-        br1.add_route('2001:dead:beef:cafe::/64', stable=True)
-        br1.register_netdata()
-        self.simulator.go(15)
-        self.assertEqual(br1.get_state(), 'router')
-
-        #-------------------------------------------------------------------------------------
-        # Add a prefix with default route on `br2`, it should also become a router.
-
-        br2.add_prefix('2001:dead:beef:2222::/64', 'paros')
-        br2.register_netdata()
-        self.simulator.go(15)
-        self.assertEqual(br2.get_state(), 'router')
-
-        #-------------------------------------------------------------------------------------
-        # Add an external route on `br3`, it should not become a router since we already have
-        # two that requested router role upgrade as border router reason.
-
-        br3.add_route('2001:dead:beef:cafe::/64', stable=True)
-        br3.register_netdata()
-        self.simulator.go(120)
-        self.assertEqual(br3.get_state(), 'child')
-
-        #-------------------------------------------------------------------------------------
-        # Remove the external route on `br1`. This should now trigger `br3` to request a
-        # router role upgrade since number of BRs acting as router in network data is now
-        # below the threshold of two.
-
-        br1.remove_route('2001:dead:beef:cafe::/64')
-        br1.register_netdata()
-        self.simulator.go(15)
-        self.assertEqual(br1.get_state(), 'router')
-        self.assertEqual(br3.get_state(), 'router')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_child_supervision.py b/tests/scripts/thread-cert/test_child_supervision.py
deleted file mode 100755
index 0418f80..0000000
--- a/tests/scripts/thread-cert/test_child_supervision.py
+++ /dev/null
@@ -1,189 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test verifies behavior child supervision.
-#
-#
-# Topology:
-#
-#  Parent (leader)
-#   |
-#   |
-#  Child (sleepy).
-
-PARENT = 1
-CHILD = 2
-
-
-class ChildSupervision(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        PARENT: {
-            'name': 'PARENT',
-            'mode': 'rdn',
-        },
-        CHILD: {
-            'name': 'CHILD',
-            'is_mtd': True,
-            'mode': 'n',
-        },
-    }
-
-    def test(self):
-        parent = self.nodes[PARENT]
-        child = self.nodes[CHILD]
-
-        # Form the network.
-
-        parent.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(parent.get_state(), 'leader')
-
-        child.start()
-        self.simulator.go(5)
-        self.assertEqual(child.get_state(), 'child')
-        child.set_pollperiod(500)
-
-        self.assertEqual(int(child.get_child_supervision_check_failure_counter()), 0)
-
-        # Check the parent's child table.
-
-        table = parent.get_child_table()
-        self.assertEqual(len(table), 1)
-        self.assertEqual(table[1]['suprvsn'], int(child.get_child_supervision_interval()))
-
-        # Change the supervision interval on child. This should trigger an
-        # MLE Child Update exchange from child to parent so to inform parent
-        # about the change. Verify that parent is notified by checking the
-        # parent's child table.
-
-        child.set_child_supervision_interval(20)
-
-        self.simulator.go(2)
-
-        self.assertEqual(int(child.get_child_supervision_interval()), 20)
-        table = parent.get_child_table()
-        self.assertEqual(len(table), 1)
-        self.assertEqual(table[1]['suprvsn'], int(child.get_child_supervision_interval()))
-
-        # Change supervision check timeout on the child.
-
-        child.set_child_supervision_check_timeout(25)
-        self.assertEqual(int(child.get_child_supervision_check_timeout()), 25)
-
-        # Wait for multiple supervision intervals and ensure that child
-        # stays attached (child supervision working as expected).
-
-        self.simulator.go(110)
-
-        self.assertEqual(child.get_state(), 'child')
-        table = parent.get_child_table()
-        self.assertEqual(len(table), 1)
-        self.assertEqual(int(child.get_child_supervision_check_failure_counter()), 0)
-
-        # Disable supervision check on child.
-
-        child.set_child_supervision_check_timeout(0)
-
-        # Enable allowlist on parent without adding the child. After child
-        # timeout expires, the parent should remove the child from its child
-        # table.
-
-        parent.clear_allowlist()
-        parent.enable_allowlist()
-
-        table = parent.get_child_table()
-        child_timeout = table[1]['timeout']
-
-        self.simulator.go(child_timeout + 1)
-        table = parent.get_child_table()
-        self.assertEqual(len(table), 0)
-
-        # Since supervision check is disabled on the child, it should
-        # continue to stay attached to parent (since data polls are acked by
-        # radio driver).
-
-        self.assertEqual(child.get_state(), 'child')
-        self.assertEqual(int(child.get_child_supervision_check_failure_counter()), 0)
-
-        # Re-enable supervision check on child. After the check timeout the
-        # child must try to exchange "Child Update" messages with parent and
-        # then detect that parent is not responding and detach.
-
-        child.set_child_supervision_check_timeout(25)
-
-        self.simulator.go(35)
-        self.assertEqual(child.get_state(), 'detached')
-        self.assertTrue(int(child.get_child_supervision_check_failure_counter()) > 0)
-
-        # Disable allowlist on parent. Child should be able to attach again.
-
-        parent.disable_allowlist()
-        self.simulator.go(30)
-        self.assertEqual(child.get_state(), 'child')
-        child.reset_child_supervision_check_failure_counter()
-        self.assertEqual(int(child.get_child_supervision_check_failure_counter()), 0)
-
-        # Set the supervision interval to zero on child (child is asking
-        # parent not to supervise it anymore). This practically behaves
-        # the same as if parent does not support child supervision
-        # feature.
-
-        child.set_child_supervision_interval(0)
-        child.set_child_supervision_check_timeout(25)
-        self.simulator.go(2)
-
-        self.assertEqual(int(child.get_child_supervision_interval()), 0)
-        self.assertEqual(int(child.get_child_supervision_check_timeout()), 25)
-
-        table = parent.get_child_table()
-        self.assertEqual(len(table), 1)
-        self.assertEqual(table[2]['suprvsn'], int(child.get_child_supervision_interval()))
-
-        # Wait for multiple check timeouts. The child should still stay
-        # attached to parent.
-
-        self.simulator.go(100)
-        self.assertEqual(child.get_state(), 'child')
-        self.assertEqual(len(parent.get_child_table()), 1)
-        self.assertTrue(int(child.get_child_supervision_check_failure_counter()) > 0)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_coap_block.py b/tests/scripts/thread-cert/test_coap_block.py
deleted file mode 100755
index 672b963..0000000
--- a/tests/scripts/thread-cert/test_coap_block.py
+++ /dev/null
@@ -1,108 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import pexpect
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-
-
-class TestCoapBlockTransfer(thread_cert.TestCase):
-    """
-    Test suite for CoAP Block-Wise Transfers (RFC7959).
-    """
-
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'whitelist': [ROUTER]
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'whitelist': [LEADER]
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router = self.nodes[ROUTER]
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        router.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router.get_state(), 'router')
-
-        leader_mleid = leader.get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-        router_mleid = router.get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-
-        leader.coap_set_resource_path_block('test', 3)
-
-        leader.coap_start()
-        router.coap_start()
-
-        for method in ['GET', 'PUT', 'POST']:
-            if method == 'GET':
-                response = router.coap_get_block(leader_mleid, 'test', size=32)
-            elif method == 'PUT':
-                response = router.coap_put_block(leader_mleid, 'test', size=256, count=2)
-            elif method == 'POST':
-                response = router.coap_post_block(leader_mleid, 'test', size=1024, count=2)
-            else:
-                raise ValueError(method)
-
-            self.assertEqual(response['source'], leader_mleid)
-
-            request = leader.coap_wait_request()
-            self.assertEqual(request['method'], method)
-            self.assertEqual(request['source'], router_mleid)
-
-            if method == 'GET':
-                self.assertIsNotNone(response['payload'])
-                self.assertIsNone(request['payload'])
-            else:
-                self.assertIsNone(response['payload'])
-                self.assertIsNotNone(request['payload'])
-
-            self.simulator.go(10)
-
-        router.coap_stop()
-        leader.coap_stop()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_coap_observe.py b/tests/scripts/thread-cert/test_coap_observe.py
deleted file mode 100755
index 5aa89db..0000000
--- a/tests/scripts/thread-cert/test_coap_observe.py
+++ /dev/null
@@ -1,142 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import pexpect
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-
-
-class TestCoapObserve(thread_cert.TestCase):
-    """
-    Test suite for CoAP Observations (RFC7641).
-    """
-
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER]
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'allowlist': [LEADER]
-        },
-    }
-
-    def _do_notification_test(self, con):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        mleid = self.nodes[LEADER].get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-
-        self.nodes[LEADER].coap_start()
-        self.nodes[LEADER].coap_set_resource_path('test')
-        self.nodes[LEADER].coap_set_content('Test123')
-
-        self.nodes[ROUTER].coap_start()
-        response = self.nodes[ROUTER].coap_observe(mleid, 'test', con=con)
-
-        first_observe = response['observe']
-        self.assertIsNotNone(first_observe)
-        self.assertEqual(response['payload'], 'Test123')
-        self.assertEqual(response['source'], mleid)
-
-        # This should have been emitted already, so should return immediately
-        self.nodes[LEADER].coap_wait_subscribe()
-
-        # Now change the content on the leader and wait for it to show up
-        # on the router.  We will do this a few times with a short delay.
-        for n in range(0, 5):
-            content = 'msg%d' % n
-
-            self.nodes[LEADER].coap_set_content(content)
-
-            response = self.nodes[ROUTER].coap_wait_response()
-            self.assertGreater(response['observe'], first_observe)
-            self.assertEqual(response['payload'], content)
-            self.assertEqual(response['source'], mleid)
-
-        # Stop subscription
-        self.nodes[ROUTER].coap_cancel()
-
-        # We should see the response, but with no Observe option
-        response = self.nodes[ROUTER].coap_wait_response()
-        self.assertIsNone(response['observe'])
-        # Content won't have changed.
-        self.assertEqual(response['payload'], content)
-
-        # Make another change, no notification should be sent
-        self.nodes[LEADER].coap_set_content('LastNote')
-
-        # This should time out!
-        try:
-            self.nodes[ROUTER].coap_wait_response()
-            self.fail('Should not have received notification')
-        except pexpect.exceptions.TIMEOUT:
-            pass
-
-        self.nodes[ROUTER].coap_stop()
-        self.nodes[LEADER].coap_stop()
-
-    def test_con(self):
-        """
-        Test notification using CON messages.
-        """
-        for trial in range(0, 3):
-            try:
-                self._do_notification_test(con=True)
-                break
-            except (AssertionError, pexpect.exceptions.TIMEOUT):
-                continue
-
-    def test_non(self):
-        """
-        Test notification using NON messages.
-        """
-        for trial in range(0, 3):
-            try:
-                self._do_notification_test(con=False)
-                break
-            except (AssertionError, pexpect.exceptions.TIMEOUT):
-                continue
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_coaps.py b/tests/scripts/thread-cert/test_coaps.py
deleted file mode 100755
index 50e3255..0000000
--- a/tests/scripts/thread-cert/test_coaps.py
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2018, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-
-
-class TestCoaps(thread_cert.TestCase):
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER]
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'allowlist': [LEADER]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        mleid = self.nodes[LEADER].get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-
-        self.nodes[LEADER].coaps_start_psk('psk', 'pskIdentity')
-        self.nodes[LEADER].coaps_set_resource_path('test')
-        self.nodes[ROUTER].coaps_start_psk('psk', 'pskIdentity')
-        self.nodes[ROUTER].coaps_connect(mleid)
-        self.nodes[ROUTER].coaps_get()
-        self.nodes[ROUTER].coaps_disconnect()
-        self.nodes[ROUTER].coaps_stop()
-        self.nodes[LEADER].coaps_stop()
-
-        self.nodes[LEADER].coaps_start_x509()
-        # self.nodes[LEADER].coaps_set_resource_path('test')
-        self.nodes[ROUTER].coaps_start_x509()
-        self.nodes[ROUTER].coaps_connect(mleid)
-        self.nodes[ROUTER].coaps_get()
-        self.nodes[ROUTER].coaps_disconnect()
-        self.nodes[ROUTER].coaps_stop()
-        self.nodes[LEADER].coaps_stop()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_dataset_updater.py b/tests/scripts/thread-cert/test_dataset_updater.py
deleted file mode 100755
index ba3bc9f..0000000
--- a/tests/scripts/thread-cert/test_dataset_updater.py
+++ /dev/null
@@ -1,118 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-MED = 3
-SED = 4
-
-
-class TestDatasetUpdater(thread_cert.TestCase):
-    SUPPORT_NCP = False
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'channel': 11,
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'channel': 11,
-        },
-        MED: {
-            'mode': 'rn',
-            'channel': 11,
-            'allowlist': [ROUTER],
-        },
-        SED: {
-            'mode': '-',
-            'channel': 11,
-            'timeout': config.DEFAULT_CHILD_TIMEOUT,
-            'allowlist': [ROUTER],
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        self.nodes[MED].start()
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[MED].get_state(), 'child')
-
-        self.nodes[SED].start()
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[SED].get_state(), 'child')
-
-        self.verify_state(11)
-
-        # update initiated by LEADER
-        self.nodes[LEADER].start_dataset_updater(channel=12)
-        self.simulator.go(120)
-        self.verify_state(12)
-
-        # update initiated by ROUTER
-        self.nodes[ROUTER].start_dataset_updater(channel=13)
-        self.simulator.go(120)
-        self.verify_state(13)
-
-        # update initiated by LEADER overridden by ROUTER
-        self.nodes[LEADER].start_dataset_updater(channel=14)
-        self.simulator.go(20)
-        self.nodes[ROUTER].start_dataset_updater(channel=15)
-        self.simulator.go(120)
-        self.verify_state(15)
-
-        # update initiated by ROUTER overridden by LEADER
-        self.nodes[ROUTER].start_dataset_updater(channel=16)
-        self.simulator.go(10)
-        self.nodes[LEADER].start_dataset_updater(channel=17)
-        self.simulator.go(120)
-        self.verify_state(17)
-
-    def verify_state(self, channel):
-        self.assertEqual(self.nodes[LEADER].get_channel(), channel)
-        self.assertEqual(self.nodes[ROUTER].get_channel(), channel)
-        self.assertEqual(self.nodes[MED].get_channel(), channel)
-        self.assertEqual(self.nodes[SED].get_channel(), channel)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_detach.py b/tests/scripts/thread-cert/test_detach.py
deleted file mode 100755
index 39f3801..0000000
--- a/tests/scripts/thread-cert/test_detach.py
+++ /dev/null
@@ -1,184 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import thread_cert
-import config
-from pktverify.consts import MLE_CHILD_UPDATE_REQUEST, TIMEOUT_TLV, ADDR_REL_URI
-from pktverify.packet_verifier import PacketVerifier
-
-# Test description:
-#   This test verifies that detaching function can send correct "goodbye" messages.
-#
-# Topology:
-#
-#   CHILD_1 ----- ROUTER_1 ----- LEADER
-#
-#
-
-LEADER = 1
-ROUTER_1 = 2
-CHILD_1 = 3
-
-
-class TestDetach(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'Leader',
-            'allowlist': [ROUTER_1],
-            'mode': 'rdn',
-        },
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [LEADER, CHILD_1],
-            'mode': 'rdn',
-        },
-        CHILD_1: {
-            'name': 'Child_1',
-            'is_mtd': True,
-            'allowlist': [ROUTER_1],
-            'mode': '-',
-            'timeout': 10,
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router1 = self.nodes[ROUTER_1]
-        child1 = self.nodes[CHILD_1]
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        router1.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router1.get_state(), 'router')
-        router1_rloc16 = router1.get_addr16()
-        self.assertTrue(list(filter(lambda x: x[1]['rloc16'] == router1_rloc16, leader.router_table().items())))
-
-        self.collect_rloc16s()
-
-        child1.start()
-        self.simulator.go(7)
-        self.assertEqual(child1.get_state(), 'child')
-        child_table = router1.get_child_table()
-        self.assertEqual(len(child_table), 1)
-        self.assertEqual(child_table[1]['timeout'], 10)
-
-        child1.detach()
-        self.assertEqual(child1.get_state(), 'disabled')
-        self.simulator.go(2)  # The router processes its child table every second; wait 2s to be safe.
-        self.assertFalse(router1.get_child_table())
-
-        router1.detach()
-        self.assertEqual(router1.get_state(), 'disabled')
-        self.assertFalse(list(filter(lambda x: x[1]['rloc16'] == router1_rloc16, leader.router_table().items())))
-
-        router1.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router1.get_state(), 'router')
-
-        child1.start()
-        self.simulator.go(7)
-        self.assertEqual(child1.get_state(), 'child')
-        child_table = router1.get_child_table()
-        self.assertEqual(len(child_table), 1)
-        self.assertEqual(child_table[2]['timeout'], 10)
-
-        router1.thread_stop()
-        self.assertEqual(router1.get_state(), 'disabled')
-        child1.detach()
-        self.assertEqual(child1.get_state(), 'disabled')
-
-        router1.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router1.get_state(), 'router')
-
-        child1.start()
-        self.simulator.go(7)
-        self.assertEqual(child1.get_state(), 'child')
-
-        leader.detach()
-        self.assertEqual(leader.get_state(), 'disabled')
-
-        self.assertTrue(child1.ping(router1.get_mleid(), timeout=20))
-
-        router1.detach()
-        self.assertEqual(router1.get_state(), 'disabled')
-
-        leader.detach()
-        self.assertEqual(leader.get_state(), 'disabled')
-
-        leader.start()
-        self.assertEqual(leader.get_state(), 'detached')
-        leader.detach()
-        self.assertEqual(leader.get_state(), 'disabled')
-
-        leader.start()
-        self.simulator.go(config.LEADER_RESET_DELAY / 2)
-        self.assertEqual(leader.get_state(), 'detached')
-        self.simulator.go(config.LEADER_RESET_DELAY / 2)
-        self.assertEqual(leader.get_state(), 'leader')
-        router1.start()
-        self.simulator.go(config.ROUTER_RESET_DELAY)
-        self.assertEqual(router1.get_state(), 'router')
-
-        leader.thread_stop()
-        router1.detach(is_async=True)
-        self.assertEqual(router1.get_state(), 'router')
-        router1.thread_stop()
-        self.assertEqual(router1.get_state(), 'disabled')
-        router1.detach()
-        self.assertEqual(router1.get_state(), 'disabled')
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        pv.summary.show()
-
-        leader = pv.vars['Leader']
-        router1 = pv.vars['Router_1']
-        child1 = pv.vars['Child_1']
-        leader_rloc16 = pv.vars['Leader_RLOC16']
-
-        pkts.filter_wpan_src64(child1).filter_mle_cmd(MLE_CHILD_UPDATE_REQUEST).filter_wpan_dst64(
-            router1).must_next().must_verify(lambda p: TIMEOUT_TLV in set(p.mle.tlv.type) and p.mle.tlv.timeout == 0)
-        pkts.filter_wpan_src64(router1).filter_coap_request(ADDR_REL_URI).filter_wpan_dst16(leader_rloc16).must_next()
-        pkts.filter_wpan_src64(child1).filter_mle_cmd(MLE_CHILD_UPDATE_REQUEST).filter_wpan_dst64(
-            router1).must_next().must_verify(lambda p: TIMEOUT_TLV in set(p.mle.tlv.type) and p.mle.tlv.timeout == 0)
-        pkts.filter_wpan_src64(leader).filter_coap_request(ADDR_REL_URI).must_not_next()
-        pkts.filter_wpan_src64(router1).filter_coap_request(ADDR_REL_URI).filter_wpan_dst16(leader_rloc16).must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_dns_client_config_auto_start.py b/tests/scripts/thread-cert/test_dns_client_config_auto_start.py
deleted file mode 100755
index 9b9a2e7..0000000
--- a/tests/scripts/thread-cert/test_dns_client_config_auto_start.py
+++ /dev/null
@@ -1,150 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test verifies the behavior of DNS client feature where the SRP
-#   server address (from SRP client auto-start feature) is used as the
-#   default IPv6 address for DNS resolver config when user does not
-#   explicitly set a default address
-#
-# Topology: two nodes
-#
-#   leader (SRP server & client & DNS client) --- router (SRP client & DNS client)
-#
-
-LEADER = 1
-ROUTER = 2
-
-DEFAULT_ADDRESS = 'fd00:1:2:3:4:5:6:7'
-
-
-class DnsClientConfigAutoStart(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-        },
-        ROUTER: {
-            'name': 'ROUTER',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router = self.nodes[ROUTER]
-
-        # Form network.
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        router.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router.get_state(), 'router')
-
-        # On leader set DNS config explicitly.
-
-        leader.dns_set_config(DEFAULT_ADDRESS)
-        dns_config = leader.dns_get_config()
-        self.assertEqual(dns_config['Server'], '[{}]:53'.format(DEFAULT_ADDRESS))
-
-        # Start leader to act as SRP server.
-
-        leader.srp_server_set_enabled(True)
-
-        # Enable SRP client auto start on both nodes.
-
-        leader.srp_client_enable_auto_start_mode()
-        router.srp_client_enable_auto_start_mode()
-        self.simulator.go(5)
-
-        # Verify that on router the default DNS config is changed
-        # to the same address being used by SRP client.
-
-        srp_server_address = router.srp_client_get_server_address()
-        self.assertEqual(leader.srp_client_get_server_address(), srp_server_address)
-
-        dns_config = router.dns_get_config()
-        self.assertEqual(dns_config['Server'], '[{}]:53'.format(srp_server_address))
-
-        # Verify that on leader the default DNS config remains
-        # as before (the address explicitly set earlier).
-
-        dns_config = leader.dns_get_config()
-        self.assertEqual(dns_config['Server'], '[{}]:53'.format(DEFAULT_ADDRESS))
-
-        # On leader clear DNS config (the explicitly set address)
-        # and verify that it adopts the SRP server address.
-
-        leader.dns_set_config("::")
-        dns_config = leader.dns_get_config()
-        self.assertEqual(dns_config['Server'], '[{}]:53'.format(srp_server_address))
-
-        # On leader set DNS config explicitly again.
-
-        leader.dns_set_config(DEFAULT_ADDRESS)
-        dns_config = leader.dns_get_config()
-        self.assertEqual(dns_config['Server'], '[{}]:53'.format(DEFAULT_ADDRESS))
-
-        # Stop SRP server on leader and start it on router.
-
-        leader.srp_server_set_enabled(False)
-        router.srp_server_set_enabled(True)
-        self.simulator.go(5)
-
-        # Verify that SRP client on router switched to new SRP server.
-
-        self.assertNotEqual(srp_server_address, router.srp_client_get_server_address())
-        self.assertEqual(router.srp_client_get_server_address(), leader.srp_client_get_server_address())
-
-        # Verify that config on router gets changed while on leader
-        # it remains unchanged.
-
-        dns_config = router.dns_get_config()
-        srp_server_address = router.srp_client_get_server_address()
-        self.assertEqual(dns_config['Server'], '[{}]:53'.format(srp_server_address))
-
-        dns_config = leader.dns_get_config()
-        self.assertEqual(dns_config['Server'], '[{}]:53'.format(DEFAULT_ADDRESS))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_dnssd.py b/tests/scripts/thread-cert/test_dnssd.py
deleted file mode 100755
index 5642a0e..0000000
--- a/tests/scripts/thread-cert/test_dnssd.py
+++ /dev/null
@@ -1,297 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-import ipaddress
-import typing
-import unittest
-
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test verifies DNS-SD server and DNS client behavior
-#   (browsing for services and/or subtype services, resolving an
-#   address, or resolving a service). It also indirectly covers the SRP
-#   client and server behavior and the interactions of DNS-SD server
-#   with SRP server).
-#
-# Topology:
-#    Four nodes, leader acting as SRP and DNS-SD servers, with 3 router
-#    nodes acting as SRP and DNS clients.
-#
-
-SERVER = 1
-CLIENT1 = 2
-CLIENT2 = 3
-CLIENT3 = 4
-
-DOMAIN = 'default.service.arpa.'
-SERVICE = '_ipps._tcp'
-
-
-class TestDnssd(thread_cert.TestCase):
-    SUPPORT_NCP = False
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'mode': 'rdn',
-        },
-        CLIENT1: {
-            'mode': 'rdn',
-        },
-        CLIENT2: {
-            'mode': 'rdn',
-        },
-        CLIENT3: {
-            'mode': 'rdn',
-        }
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client1 = self.nodes[CLIENT1]
-        client2 = self.nodes[CLIENT2]
-        client3 = self.nodes[CLIENT3]
-
-        #---------------------------------------------------------------
-        # Start the server & client devices.
-
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-        server.srp_server_set_enabled(True)
-
-        client1.start()
-        client2.start()
-        client3.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client1.get_state(), 'router')
-        self.assertEqual(client2.get_state(), 'router')
-        self.assertEqual(client3.get_state(), 'router')
-
-        #---------------------------------------------------------------
-        # Register services on clients
-
-        client1_addrs = [client1.get_mleid(), client1.get_rloc()]
-        client2_addrs = [client2.get_mleid(), client2.get_rloc()]
-        client3_addrs = [client3.get_mleid(), client2.get_rloc()]
-
-        self._config_srp_client_services(client1, server, 'ins1', 'host1', 11111, 1, 1, client1_addrs, ",_s1,_s2")
-        self._config_srp_client_services(client2, server, 'ins2', 'HOST2', 22222, 2, 2, client2_addrs)
-        self._config_srp_client_services(client3, server, 'ins3', 'host3', 33333, 3, 3, client3_addrs, ",_S1")
-
-        #---------------------------------------------------------------
-        # Resolve address (AAAA records)
-
-        answers = client1.dns_resolve(f"host1.{DOMAIN}".upper(), server.get_mleid(), 53)
-        self.assertEqual(set(ipaddress.IPv6Address(ip) for ip, _ in answers),
-                         set(map(ipaddress.IPv6Address, client1_addrs)))
-
-        answers = client1.dns_resolve(f"host2.{DOMAIN}", server.get_mleid(), 53)
-        self.assertEqual(set(ipaddress.IPv6Address(ip) for ip, _ in answers),
-                         set(map(ipaddress.IPv6Address, client2_addrs)))
-
-        #---------------------------------------------------------------
-        # Browsing for services
-
-        instance1_verify_info = {
-            'port': 11111,
-            'priority': 1,
-            'weight': 1,
-            'host': 'host1.default.service.arpa.',
-            'address': client1_addrs,
-            'txt_data': '',
-            'srv_ttl': lambda x: x > 0,
-            'txt_ttl': lambda x: x > 0,
-            'aaaa_ttl': lambda x: x > 0,
-        }
-
-        instance2_verify_info = {
-            'port': 22222,
-            'priority': 2,
-            'weight': 2,
-            'host': 'host2.default.service.arpa.',
-            'address': client2_addrs,
-            'txt_data': '',
-            'srv_ttl': lambda x: x > 0,
-            'txt_ttl': lambda x: x > 0,
-            'aaaa_ttl': lambda x: x > 0,
-        }
-
-        instance3_verify_info = {
-            'port': 33333,
-            'priority': 3,
-            'weight': 3,
-            'host': 'host3.default.service.arpa.',
-            'address': client3_addrs,
-            'txt_data': '',
-            'srv_ttl': lambda x: x > 0,
-            'txt_ttl': lambda x: x > 0,
-            'aaaa_ttl': lambda x: x > 0,
-        }
-
-        instance4_verify_info = {
-            'port': 44444,
-            'priority': 4,
-            'weight': 4,
-            'host': 'host3.default.service.arpa.',
-            'address': client3_addrs,
-            'txt_data': 'KEY=414243',  # KEY=ABC
-            'srv_ttl': lambda x: x > 0,
-            'txt_ttl': lambda x: x > 0,
-            'aaaa_ttl': lambda x: x > 0,
-        }
-
-        # Browse for main service
-        service_instances = client1.dns_browse(f'{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self.assertEqual({'ins1', 'ins2', 'ins3'}, set(service_instances.keys()))
-
-        # Browse for service sub-type _s1.
-        service_instances = client1.dns_browse(f'_s1._sub.{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self.assertEqual({'ins1', 'ins3'}, set(service_instances.keys()))
-
-        # Browse for service sub-type _s2.
-        # Since there is only one matching instance, validate that
-        # server included the service info in additional section.
-        service_instances = client1.dns_browse(f'_s2._sub.{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self.assertEqual({'ins1'}, set(service_instances.keys()))
-        self._assert_service_instance_equal(service_instances['ins1'], instance1_verify_info)
-
-        #---------------------------------------------------------------
-        # Resolve service
-
-        service_instance = client1.dns_resolve_service('ins1', f'{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self._assert_service_instance_equal(service_instance, instance1_verify_info)
-
-        service_instance = client1.dns_resolve_service('ins2', f'{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self._assert_service_instance_equal(service_instance, instance2_verify_info)
-
-        service_instance = client1.dns_resolve_service('ins3', f'{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self._assert_service_instance_equal(service_instance, instance3_verify_info)
-
-        #---------------------------------------------------------------
-        # Add another service with TXT entries to the existing host and
-        # verify that it is properly merged.
-
-        client3.srp_client_add_service('ins4', (SERVICE + ",_s1").upper(), 44444, 4, 4, txt_entries=['KEY=ABC'])
-        self.simulator.go(5)
-
-        service_instances = client1.dns_browse(f'{SERVICE}.{DOMAIN}', server.get_mleid(), 53)
-        self.assertEqual({'ins1', 'ins2', 'ins3', 'ins4'}, set(service_instances.keys()))
-
-        service_instance = client1.dns_resolve_service('ins4', f'{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self._assert_service_instance_equal(service_instance, instance4_verify_info)
-
-        #---------------------------------------------------------------
-        # Query for KEY record for `ins1` service name
-
-        records = client1.dns_query(25, 'ins1', f'{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self.assertEqual(len(records), 1)
-        record = records[0]
-        self.assertEqual(int(record['RecordType']), 25)
-        self.assertEqual(int(record['RecordLength']), 78)
-        self.assertTrue(int(record['TTL']) > 0)
-        self.assertEqual(record['Section'], 'answer')
-        self.assertEqual(record['Name'].lower(), 'ins1._ipps._tcp.default.service.arpa.')
-        self.assertIn('RecordData', record)
-
-        #---------------------------------------------------------------
-        # Query for SRV record for `ins1` service name
-
-        records = client1.dns_query(33, 'ins1', f'{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self.assertEqual(len(records), 4)
-
-        # SRV record in answer section
-        record = records[0]
-        self.assertEqual(int(record['RecordType']), 33)
-        self.assertTrue(int(record['RecordLength']) > 0)
-        self.assertTrue(int(record['TTL']) > 0)
-        self.assertEqual(record['Section'], 'answer')
-        self.assertEqual(record['Name'].lower(), 'ins1._ipps._tcp.default.service.arpa.')
-        self.assertIn('RecordData', record)
-
-        # Other records TXT and A in additional section
-        for record in records[1:]:
-            self.assertTrue(int(record['RecordLength']) > 0)
-            self.assertIn('RecordData', record)
-            self.assertTrue(int(record['TTL']) > 0)
-            self.assertEqual(record['Section'], 'additional')
-            rrtype = int(record['RecordType'])
-            self.assertIn(rrtype, [16, 28])  # TXT and AAAA
-            if rrtype == 16:
-                self.assertEqual(record['Name'].lower(), 'ins1._ipps._tcp.default.service.arpa.')
-                self.assertEqual(record['RecordData'], '[00]')
-            else:
-                self.assertEqual(record['Name'].lower(), 'host1.default.service.arpa.')
-
-        #---------------------------------------------------------------
-        # Query for non-existing A record for `ins1` service name
-
-        records = client1.dns_query(1, 'ins1', f'{SERVICE}.{DOMAIN}'.upper(), server.get_mleid(), 53)
-        self.assertEqual(len(records), 0)
-
-    def _assert_service_instance_equal(self, instance, info):
-        self.assertEqual(instance['host'].lower(), info['host'].lower(), instance)
-        for f in ('port', 'priority', 'weight', 'txt_data'):
-            self.assertEqual(instance[f], info[f], instance)
-
-        verify_addresses = info['address']
-        if not isinstance(verify_addresses, typing.Collection):
-            verify_addresses = [verify_addresses]
-        self.assertIn(ipaddress.IPv6Address(instance['address']), map(ipaddress.IPv6Address, verify_addresses),
-                      instance)
-
-        for ttl_f in ('srv_ttl', 'txt_ttl', 'aaaa_ttl'):
-            check_ttl = info[ttl_f]
-            if not callable(check_ttl):
-                check_ttl = lambda x: x == check_ttl
-
-            self.assertTrue(check_ttl(instance[ttl_f]), instance)
-
-    def _config_srp_client_services(self,
-                                    client,
-                                    server,
-                                    instancename,
-                                    hostname,
-                                    port,
-                                    priority,
-                                    weight,
-                                    addrs,
-                                    subtypes=''):
-        client.srp_client_set_host_name(hostname)
-        client.srp_client_set_host_address(*addrs)
-        client.srp_client_add_service(instancename, SERVICE + subtypes, port, priority, weight)
-
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_dnssd_name_with_special_chars.py b/tests/scripts/thread-cert/test_dnssd_name_with_special_chars.py
deleted file mode 100755
index 62c13e7..0000000
--- a/tests/scripts/thread-cert/test_dnssd_name_with_special_chars.py
+++ /dev/null
@@ -1,117 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-import logging
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies SRP/DNS client/server support for Service Instance names with special and unicode characters.
-#
-# Topology:
-#
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-
-SERVER = 1
-CLIENT = 2
-
-# TODO: support Instance names with dots (.)
-SPECIAL_INSTANCE_NAME = 'O\\T 网关'
-
-
-class TestDnssdNameWithSpecialChars(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SERVER',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'CLIENT',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        # Start the server & client devices.
-
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        server.srp_server_set_enabled(True)
-        client.srp_client_enable_auto_start_mode()
-        self.simulator.go(15)
-
-        # Register a single service with the instance name containing special chars
-        client.srp_client_set_host_name('host1')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service(SPECIAL_INSTANCE_NAME, '_srv._udp', 1977)
-        self.simulator.go(5)
-        self.__check_service_discovery(server, client)
-
-    def __check_service_discovery(self, server, client):
-        # Check the service on client
-        client.dns_set_config(server.get_rloc())
-        services = client.dns_browse('_srv._udp.default.service.arpa')
-        self.assertEqual(1, len(services))
-        self.assertIn(SPECIAL_INSTANCE_NAME, services)
-        self.__verify_service(services[SPECIAL_INSTANCE_NAME])
-
-        service = client.dns_resolve_service(SPECIAL_INSTANCE_NAME, '_srv._udp.default.service.arpa')
-        self.__verify_service(service)
-
-        service = client.dns_resolve_service(SPECIAL_INSTANCE_NAME.lower(), '_srv._udp.default.service.arpa')
-        self.__verify_service(service)
-
-    def __verify_service(self, service):
-        logging.info('service discovered: %r', service)
-        self.assertEqual(service['port'], 1977)
-        self.assertEqual(service['host'], 'host1.default.service.arpa.')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_history_tracker.py b/tests/scripts/thread-cert/test_history_tracker.py
deleted file mode 100755
index 2424f2a..0000000
--- a/tests/scripts/thread-cert/test_history_tracker.py
+++ /dev/null
@@ -1,212 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import os
-import unittest
-import sys
-
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies History Tracker behavior.
-#
-# Topology:
-#
-#     LEADER
-#       |
-#       |
-#     CHILD
-#
-
-LEADER = 1
-CHILD = 2
-
-SHORT_WAIT = 5
-ONE_DAY = 24 * 60 * 60
-MAX_AGE_IN_DAYS = 49
-
-
-class TestHistoryTracker(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'Leader',
-            'mode': 'rdn',
-        },
-        CHILD: {
-            'name': 'Child',
-            'mode': 'n',
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        child = self.nodes[CHILD]
-
-        # Start the leader and verify that 'netinfo' history
-        # is updated correctly.
-
-        leader.start()
-        self.simulator.go(SHORT_WAIT * 2)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        netinfo = leader.history_netinfo()
-        self.assertEqual(len(netinfo), 2)
-        self.assertEqual(netinfo[0]['role'], 'leader')
-        self.assertEqual(netinfo[0]['mode'], 'rdn')
-        self.assertEqual(int(netinfo[0]['rloc16'], 16), leader.get_addr16())
-        self.assertEqual(netinfo[0]['partition-id'], leader.get_partition_id())
-        self.assertEqual(netinfo[1]['role'], 'detached')
-
-        # Stop the leader
-
-        leader.thread_stop()
-        leader.interface_down()
-        self.simulator.go(SHORT_WAIT)
-        netinfo = leader.history_netinfo(2)
-        self.assertEqual(len(netinfo), 2)
-        self.assertEqual(netinfo[0]['role'], 'disabled')
-        self.assertEqual(netinfo[1]['role'], 'leader')
-
-        # Wait for one day, two days, then up to max age and verify that
-        # `netinfo` entry age is updated correctly.
-        #
-        # Since we want to wait for long duration (49 days), to speed up
-        # the simulation time, we disable leader to avoid the need to
-        # to simulate all the message/events (e.g. MLE adv) while thread
-        # is operational.
-
-        self.simulator.go(ONE_DAY)
-        netinfo = leader.history_netinfo(1)
-        self.assertTrue(netinfo[0]['age'].startswith('1 day'))
-
-        self.simulator.go(ONE_DAY)
-        netinfo = leader.history_netinfo(1)
-        self.assertTrue(netinfo[0]['age'].startswith('2 days'))
-
-        self.simulator.go((MAX_AGE_IN_DAYS - 3) * ONE_DAY)
-        netinfo = leader.history_netinfo(1)
-        self.assertTrue(netinfo[0]['age'].startswith('{} days'.format(MAX_AGE_IN_DAYS - 1)))
-
-        self.simulator.go(ONE_DAY)
-        netinfo = leader.history_netinfo(1)
-        self.assertTrue(netinfo[0]['age'].startswith('more than {} days'.format(MAX_AGE_IN_DAYS)))
-
-        self.simulator.go(2 * ONE_DAY)
-        netinfo = leader.history_netinfo(1)
-        self.assertTrue(netinfo[0]['age'].startswith('more than {} days'.format(MAX_AGE_IN_DAYS)))
-
-        # Start leader and child
-
-        leader.start()
-        self.simulator.go(config.LEADER_RESET_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        child.start()
-        self.simulator.go(SHORT_WAIT)
-        self.assertEqual(child.get_state(), 'child')
-
-        child_rloc16 = child.get_addr16()
-        leader_rloc16 = leader.get_addr16()
-
-        # Verify the `netinfo` history on child
-
-        netinfo = child.history_netinfo(2)
-        self.assertEqual(len(netinfo), 2)
-        self.assertEqual(netinfo[0]['role'], 'child')
-        self.assertEqual(netinfo[0]['mode'], 'n')
-        self.assertEqual(int(netinfo[0]['rloc16'], 16), child_rloc16)
-        self.assertEqual(netinfo[0]['partition-id'], leader.get_partition_id())
-        self.assertEqual(netinfo[1]['role'], 'detached')
-
-        # Change the child mode and verify that `netinfo` history
-        # records this change.
-
-        child.set_mode('rn')
-        self.simulator.go(SHORT_WAIT)
-        netinfo = child.history_netinfo(1)
-        self.assertEqual(len(netinfo), 1)
-        self.assertEqual(netinfo[0]['mode'], 'rn')
-
-        # Ping from leader to child and check the RX and TX history
-        # on child and leader.
-
-        child_mleid = child.get_mleid()
-        leader_mleid = leader.get_mleid()
-
-        ping_sizes = [10, 100, 1000]
-        num_msgs = len(ping_sizes)
-
-        for size in ping_sizes:
-            leader.ping(child_mleid, size=size)
-
-        leader_tx = leader.history_tx(num_msgs)
-        leader_rx = leader.history_rx(num_msgs)
-        child_tx = child.history_tx(num_msgs)
-        child_rx = child.history_rx(num_msgs)
-
-        for index in range(num_msgs):
-            self.assertEqual(leader_tx[index]['type'], 'ICMP6(EchoReqst)')
-            self.assertEqual(leader_tx[index]['sec'], 'yes')
-            self.assertEqual(leader_tx[index]['prio'], 'norm')
-            self.assertEqual(leader_tx[index]['tx-success'], 'yes')
-            self.assertEqual(leader_tx[index]['radio'], '15.4')
-            self.assertEqual(int(leader_tx[index]['to'], 16), child_rloc16)
-            self.assertEqual(leader_tx[index]['src'][1:-3], leader_mleid)
-            self.assertEqual(leader_tx[index]['dst'][1:-3], child_mleid)
-
-            self.assertEqual(child_rx[index]['type'], 'ICMP6(EchoReqst)')
-            self.assertEqual(child_rx[index]['sec'], 'yes')
-            self.assertEqual(child_rx[index]['prio'], 'norm')
-            self.assertEqual(child_rx[index]['radio'], '15.4')
-            self.assertEqual(int(child_rx[index]['from'], 16), leader_rloc16)
-            self.assertEqual(child_rx[index]['src'][1:-3], leader_mleid)
-            self.assertEqual(child_rx[index]['dst'][1:-3], child_mleid)
-
-            self.assertEqual(leader_rx[index]['type'], 'ICMP6(EchoReply)')
-            self.assertEqual(child_tx[index]['type'], 'ICMP6(EchoReply)')
-
-            self.assertEqual(leader_tx[index]['len'], child_rx[index]['len'])
-            self.assertEqual(leader_rx[index]['len'], child_tx[index]['len'])
-
-
-if __name__ == '__main__':
-    #  FIXME: We skip the test under distcheck build (the simulation
-    #  under this build for some reason cannot seem to handle longer
-    #  wait times - days up to 50 days in this test). We return error
-    #  code 77 which indicates that this test case was skipped (in
-    #  automake).
-
-    if os.getenv('DISTCHECK_BUILD') == '1':
-        sys.exit(77)
-
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_inform_previous_parent_on_reattach.py b/tests/scripts/thread-cert/test_inform_previous_parent_on_reattach.py
deleted file mode 100755
index 308bab3..0000000
--- a/tests/scripts/thread-cert/test_inform_previous_parent_on_reattach.py
+++ /dev/null
@@ -1,142 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import os
-import unittest
-
-import config
-import thread_cert
-
-from pktverify.packet_filter import PacketFilter
-
-# Test description:
-# The purpose of this test is to verify a MED will inform its previous parent when re-attaches to another parent.
-#
-# Initial Topology:
-#
-#  LEADER ----- ROUTER
-#    |
-#   MED
-#
-
-LEADER = 1
-ROUTER = 2
-MED = 3
-
-LONG_CHILD_TIMEOUT = 120
-
-
-class TestReset(thread_cert.TestCase):
-    SUPPORT_NCP = False
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-            'allowlist': [ROUTER, MED]
-        },
-        ROUTER: {
-            'name': 'ROUTER',
-            'mode': 'rdn',
-            'allowlist': [LEADER]
-        },
-        MED: {
-            'name': 'MED',
-            'is_mtd': True,
-            'mode': 'rn',
-            'allowlist': [LEADER],
-            'timeout': LONG_CHILD_TIMEOUT,
-        },
-    }
-
-    def test(self):
-        if 'posix' in os.getenv('OT_CLI_PATH', ''):
-            self.skipTest("skip for posix tests")
-
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        self.nodes[MED].start()
-        self.simulator.go(7)
-        self.assertEqual(self.nodes[MED].get_state(), 'child')
-
-        self.assertIsChildOf(MED, LEADER)
-
-        self.nodes[LEADER].remove_allowlist(self.nodes[MED].get_addr64())
-        self.nodes[MED].remove_allowlist(self.nodes[LEADER].get_addr64())
-
-        self.nodes[ROUTER].add_allowlist(self.nodes[MED].get_addr64())
-        self.nodes[MED].add_allowlist(self.nodes[ROUTER].get_addr64())
-
-        self.nodes[MED].set_timeout(config.DEFAULT_CHILD_TIMEOUT)
-
-        self.simulator.go(config.DEFAULT_CHILD_TIMEOUT * 2)
-        self.assertIsChildOf(MED, ROUTER)
-
-        # Verify MED is not in the LEADER's Child Table.
-        med_extaddr = self.nodes[MED].get_addr64()
-        self.assertFalse(any(info['extaddr'] == med_extaddr for info in self.nodes[LEADER].get_child_table().values()))
-
-        self.collect_ipaddrs()
-        self.collect_rlocs()
-
-    def verify(self, pv):
-        pkts: PacketFilter = pv.pkts
-        pv.summary.show()
-
-        MED = pv.vars['MED']
-        LEADER_RLOC = pv.vars['LEADER_RLOC']
-
-        # MED should attach to LEADER first.
-        pv.verify_attached('MED', 'LEADER', child_type='MTD')
-        # MED should re-attach to ROUTER.
-        pv.verify_attached('MED', 'ROUTER', child_type='MTD')
-        # MED should send empty IPv6 message to inform previous parent (LEADER).
-        pkts.filter_wpan_src64(MED).filter('lowpan.dst == {LEADER_RLOC} and lowpan.next == 0x3b',
-                                           LEADER_RLOC=LEADER_RLOC).must_next()
-
-    def assertIsChildOf(self, childid, parentid):
-        childRloc16 = self.nodes[childid].get_addr16()
-        parentRloc16 = self.nodes[parentid].get_addr16()
-        self.assertEqual(parentRloc16 & 0xfc00, parentRloc16)
-        self.assertEqual(childRloc16 & 0xfc00, parentRloc16)
-
-        child_extaddr = self.nodes[childid].get_addr64()
-        self.assertTrue(
-            any(info['extaddr'] == child_extaddr for info in self.nodes[parentid].get_child_table().values()))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_ipv6_fragmentation.py b/tests/scripts/thread-cert/test_ipv6_fragmentation.py
deleted file mode 100755
index 46739d6..0000000
--- a/tests/scripts/thread-cert/test_ipv6_fragmentation.py
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2019, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import common
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-
-
-class TestIPv6Fragmentation(thread_cert.TestCase):
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'panid': 0xcafe,
-            'allowlist': [ROUTER]
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'panid': 0xcafe,
-            'allowlist': [LEADER]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        mleid_leader = self.nodes[LEADER].get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-        mleid_router = self.nodes[ROUTER].get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-
-        self.nodes[LEADER].udp_start("::", common.UDP_TEST_PORT)
-        self.nodes[ROUTER].udp_start("::", common.UDP_TEST_PORT)
-
-        self.nodes[LEADER].udp_send(1952, mleid_router, common.UDP_TEST_PORT)
-        self.simulator.go(5)
-        self.nodes[ROUTER].udp_check_rx(1952)
-
-        self.nodes[ROUTER].udp_send(1831, mleid_leader, common.UDP_TEST_PORT)
-        self.simulator.go(5)
-        self.nodes[LEADER].udp_check_rx(1831)
-
-        self.nodes[ROUTER].udp_stop()
-        self.nodes[LEADER].udp_stop()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_ipv6_source_selection.py b/tests/scripts/thread-cert/test_ipv6_source_selection.py
deleted file mode 100755
index 0985e23..0000000
--- a/tests/scripts/thread-cert/test_ipv6_source_selection.py
+++ /dev/null
@@ -1,135 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2019, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import ipv6
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-
-
-class TestIPv6SourceSelection(thread_cert.TestCase):
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'panid': 0xcafe,
-            'allowlist': [ROUTER]
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'panid': 0xcafe,
-            'allowlist': [LEADER]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        leader_aloc = self.nodes[LEADER].get_addr_leader_aloc()
-        leader_mleid = self.nodes[LEADER].get_mleid()
-        leader_rloc = self.nodes[LEADER].get_rloc()
-        leader_linklocal = self.nodes[LEADER].get_linklocal()
-        multicast_addr = 'ff03::1'
-        external_addr = '2007::1'
-
-        router_rloc = self.nodes[ROUTER].get_rloc()
-        router_linklocal = self.nodes[ROUTER].get_linklocal()
-        router_mleid = self.nodes[ROUTER].get_mleid()
-
-        # Source check - RLOC source for RLOC destination
-        self.assertTrue(self.nodes[ROUTER].ping(leader_rloc))
-        router_msgs = self.simulator.get_messages_sent_by(ROUTER)
-        msg = router_msgs.get_icmp_message(ipv6.ICMP_ECHO_REQUEST)
-        msg.assertSentFromSourceAddress(router_rloc)
-
-        # Source check - ML-EID source for ALOC destination
-        self.assertTrue(self.nodes[ROUTER].ping(leader_aloc))
-        router_msgs = self.simulator.get_messages_sent_by(ROUTER)
-        msg = router_msgs.get_icmp_message(ipv6.ICMP_ECHO_REQUEST)
-        msg.assertSentFromSourceAddress(router_mleid)
-
-        # Source check - ML-EID source for ML-EID destination
-        self.assertTrue(self.nodes[ROUTER].ping(leader_mleid))
-        router_msgs = self.simulator.get_messages_sent_by(ROUTER)
-        msg = router_msgs.get_icmp_message(ipv6.ICMP_ECHO_REQUEST)
-        msg.assertSentFromSourceAddress(router_mleid)
-
-        # Source check - link local source source for link local address
-        self.assertTrue(self.nodes[ROUTER].ping(leader_linklocal))
-        router_msgs = self.simulator.get_messages_sent_by(ROUTER)
-        msg = router_msgs.get_icmp_message(ipv6.ICMP_ECHO_REQUEST)
-        msg.assertSentFromSourceAddress(router_linklocal)
-
-        # Source check - ML-EID source for realmlocal multicast destination
-        self.assertTrue(self.nodes[ROUTER].ping(multicast_addr))
-        router_msgs = self.simulator.get_messages_sent_by(ROUTER)
-        msg = router_msgs.get_icmp_message(ipv6.ICMP_ECHO_REQUEST)
-        msg.assertSentFromSourceAddress(router_mleid)
-
-        # GUA and default gateway
-        self.nodes[LEADER].add_prefix('2001::/64', 'paros')
-        self.nodes[LEADER].register_netdata()
-        self.simulator.go(5)
-
-        # Set lowpan context of sniffer
-        self.simulator.set_lowpan_context(1, '2001::/64')
-
-        # Flushes message queue before next ping
-        router_msgs = self.simulator.get_messages_sent_by(ROUTER)
-
-        # Source check - GUA source for GUA destination
-        leader_gua = self.nodes[LEADER].get_addr("2001::/64")
-        router_gua = self.nodes[ROUTER].get_addr("2001::/64")
-        self.assertTrue(leader_gua is not None)
-        self.assertTrue(router_gua is not None)
-        self.assertTrue(self.nodes[ROUTER].ping(leader_gua))
-        router_msgs = self.simulator.get_messages_sent_by(ROUTER)
-        msg = router_msgs.get_icmp_message(ipv6.ICMP_ECHO_REQUEST)
-        msg.assertSentFromSourceAddress(router_gua)
-
-        # Source check - GUA source for external address (default route)
-        self.nodes[ROUTER].ping(external_addr)
-        router_msgs = self.simulator.get_messages_sent_by(ROUTER)
-        msg = router_msgs.get_icmp_message(ipv6.ICMP_ECHO_REQUEST)
-        msg.assertSentFromSourceAddress(router_gua)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_key_rotation_and_key_guard_time.py b/tests/scripts/thread-cert/test_key_rotation_and_key_guard_time.py
deleted file mode 100755
index 595ffea..0000000
--- a/tests/scripts/thread-cert/test_key_rotation_and_key_guard_time.py
+++ /dev/null
@@ -1,171 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2024, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-import math
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test verifies key rotation and key guard time mechanisms.
-#
-#
-# Topology:
-#
-#   leader ---  router
-#    |    \
-#    |     \
-#  child   reed
-#
-
-LEADER = 1
-CHILD = 2
-REED = 3
-ROUTER = 4
-
-
-class MleMsgKeySeqJump(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-        },
-        CHILD: {
-            'name': 'CHILD',
-            'is_mtd': True,
-            'mode': 'rn',
-        },
-        REED: {
-            'name': 'REED',
-            'mode': 'rn'
-        },
-        ROUTER: {
-            'name': 'ROUTER',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        child = self.nodes[CHILD]
-        reed = self.nodes[REED]
-        router = self.nodes[ROUTER]
-
-        nodes = [leader, child, reed, router]
-
-        #-------------------------------------------------------------------
-        # Form the network.
-
-        for node in nodes:
-            node.set_key_sequence_counter(0)
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        child.start()
-        reed.start()
-        self.simulator.go(5)
-        self.assertEqual(child.get_state(), 'child')
-        self.assertEqual(reed.get_state(), 'child')
-
-        router.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router.get_state(), 'router')
-
-        #-------------------------------------------------------------------
-        # Validate the initial key seq counter and key switch guard time
-
-        for node in nodes:
-            self.assertEqual(node.get_key_sequence_counter(), 0)
-            self.assertEqual(node.get_key_switch_guardtime(), 624)
-
-        #-------------------------------------------------------------------
-        # Change the key rotation time a bunch of times and make sure that
-        # the key switch guard time is properly changed (should be set
-        # to 93% of the rotation time).
-
-        for rotation_time in [100, 1, 10, 888, 2]:
-            reed.start_dataset_updater(security_policy=[rotation_time, 'onrc'])
-            guardtime = math.floor(rotation_time * 93 / 100) if rotation_time >= 2 else 1
-            self.simulator.go(100)
-            for node in nodes:
-                self.assertEqual(node.get_key_switch_guardtime(), guardtime)
-
-        #-------------------------------------------------------------------
-        # Wait for key rotation time (2 hours) and check that all nodes
-        # moved to the next key seq counter
-
-        self.simulator.go(2 * 60 * 60)
-        for node in nodes:
-            self.assertEqual(node.get_key_sequence_counter(), 1)
-
-        #-------------------------------------------------------------------
-        # Manually increment the key sequence counter on leader and make
-        # sure other nodes are not updated due to key guard time.
-
-        router.set_key_sequence_counter(2)
-
-        self.simulator.go(50 * 60)
-
-        self.assertEqual(router.get_key_sequence_counter(), 2)
-
-        for node in [leader, reed, child]:
-            self.assertEqual(node.get_key_sequence_counter(), 1)
-
-        #-------------------------------------------------------------------
-        # Make sure nodes can communicate with each other.
-
-        self.assertTrue(leader.ping(router.get_mleid()))
-        self.assertTrue(router.ping(child.get_mleid()))
-
-        #-------------------------------------------------------------------
-        # Wait for rotation time to expire. Validate that the `router`
-        # has moved to key seq `3` and all other nodes also followed.
-
-        self.simulator.go(75 * 60)
-
-        self.assertEqual(router.get_key_sequence_counter(), 3)
-
-        for node in nodes:
-            self.assertEqual(node.get_key_sequence_counter(), 3)
-
-        self.assertTrue(leader.ping(router.get_mleid()))
-        self.assertTrue(router.ping(child.get_mleid()))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_leader_reboot_multiple_link_request.py b/tests/scripts/thread-cert/test_leader_reboot_multiple_link_request.py
deleted file mode 100755
index ad9f00e..0000000
--- a/tests/scripts/thread-cert/test_leader_reboot_multiple_link_request.py
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2023, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import copy
-import unittest
-
-import command
-import config
-import mle
-import thread_cert
-from pktverify.consts import MLE_PARENT_REQUEST, MLE_LINK_REQUEST, MLE_LINK_ACCEPT, MLE_LINK_ACCEPT_AND_REQUEST, SOURCE_ADDRESS_TLV, CHALLENGE_TLV, RESPONSE_TLV, LINK_LAYER_FRAME_COUNTER_TLV, ROUTE64_TLV, ADDRESS16_TLV, LEADER_DATA_TLV, TLV_REQUEST_TLV, VERSION_TLV
-from pktverify.packet_verifier import PacketVerifier
-from pktverify.null_field import nullField
-
-DUT_LEADER = 1
-DUT_ROUTER1 = 2
-
-# Test Purpose and Description:
-# -----------------------------
-# The purpose of this test case is to show that when the Leader is rebooted, it sends MLE_MAX_CRITICAL_TRANSMISSION_COUNT MLE link request packets if no response is received.
-#
-# Test Topology:
-# -------------
-#   Leader
-#     |
-#   Router
-#
-# DUT Types:
-# ----------
-#  Leader
-#  Router
-
-
-class Test_LeaderRebootMultipleLinkRequest(thread_cert.TestCase):
-    #USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        DUT_LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-            'allowlist': [DUT_ROUTER1]
-        },
-        DUT_ROUTER1: {
-            'name': 'ROUTER',
-            'mode': 'rdn',
-            'allowlist': [DUT_LEADER]
-        },
-    }
-
-    def _setUpLeader(self):
-        self.nodes[DUT_LEADER].add_allowlist(self.nodes[DUT_ROUTER1].get_addr64())
-        self.nodes[DUT_LEADER].enable_allowlist()
-
-    def test(self):
-        self.nodes[DUT_LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[DUT_LEADER].get_state(), 'leader')
-
-        self.nodes[DUT_ROUTER1].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[DUT_ROUTER1].get_state(), 'router')
-
-        leader_rloc = self.nodes[DUT_LEADER].get_ip6_address(config.ADDRESS_TYPE.RLOC)
-
-        leader_rloc16 = self.nodes[DUT_LEADER].get_addr16()
-        self.nodes[DUT_LEADER].reset()
-        self.assertFalse(self.nodes[DUT_ROUTER1].ping(leader_rloc))
-        self._setUpLeader()
-
-        # Router1 will not reply to leader's link request
-        self.nodes[DUT_ROUTER1].clear_allowlist()
-
-        self.nodes[DUT_LEADER].start()
-
-        self.simulator.go(config.LEADER_RESET_DELAY)
-
-    def verify(self, pv):
-        pkts = pv.pkts
-        pv.summary.show()
-
-        LEADER = pv.vars['LEADER']
-        ROUTER = pv.vars['ROUTER']
-
-        # Verify topology is formed correctly.
-        pv.verify_attached('ROUTER', 'LEADER')
-
-        # The DUT MUST send properly formatted MLE Advertisements with
-        # an IP Hop Limit of 255 to the Link-Local All Nodes multicast
-        # address (FF02::1).
-        #  The following TLVs MUST be present in the MLE Advertisements:
-        #      - Leader Data TLV
-        #      - Route64 TLV
-        #      - Source Address TLV
-        with pkts.save_index():
-            pkts.filter_wpan_src64(LEADER).\
-                filter_mle_advertisement('Leader').\
-                must_next()
-        pkts.filter_wpan_src64(ROUTER).\
-            filter_mle_advertisement('Router').\
-            must_next()
-
-        pkts.filter_ping_request().\
-            filter_wpan_src64(ROUTER).\
-            must_next()
-
-        # The Leader MUST send MLE_MAX_CRITICAL_TRANSMISSION_COUNT multicast Link Request
-        # The following TLVs MUST be present in the Link Request:
-        #     - Challenge TLV
-        #     - Version TLV
-        #     - TLV Request TLV: Address16 TLV, Route64 TLV
-        for i in range(0, config.MLE_MAX_CRITICAL_TRANSMISSION_COUNT):
-            pkts.filter_wpan_src64(LEADER).\
-                filter_LLARMA().\
-                filter_mle_cmd(MLE_LINK_REQUEST).\
-                filter(lambda p: {
-                                CHALLENGE_TLV,
-                                VERSION_TLV,
-                                TLV_REQUEST_TLV,
-                                ADDRESS16_TLV,
-                                ROUTE64_TLV
-                                } <= set(p.mle.tlv.type) and\
-                    p.mle.tlv.addr16 is nullField and\
-                    p.mle.tlv.route64.id_mask is nullField
-                    ).\
-                must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_mac_scan.py b/tests/scripts/thread-cert/test_mac_scan.py
deleted file mode 100755
index 7c023c4..0000000
--- a/tests/scripts/thread-cert/test_mac_scan.py
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2016, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-import logging
-import unittest
-
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-
-CHANNEL = 12
-
-
-class Test_MacScan(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'channel': CHANNEL,
-            'mode': 'rdn',
-            'network_name': 'OpenThread',
-            'allowlist': [ROUTER]
-        },
-        ROUTER: {
-            'channel': CHANNEL,
-            'mode': 'rdn',
-            'network_name': 'OpenThread',
-            'allowlist': [LEADER]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.nodes[LEADER].set_state('leader')
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        results = self.nodes[LEADER].scan(result=True)
-        logging.info('scan result: %r', results)
-        self.assertEqual(len(results), 1)
-        network = results[0]
-        self.assertEqual(network['extaddr'], self.nodes[ROUTER].get_addr64())
-        self.assertEqual(network['channel'], CHANNEL)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_mle_msg_key_seq_jump.py b/tests/scripts/thread-cert/test_mle_msg_key_seq_jump.py
deleted file mode 100755
index 4113c42..0000000
--- a/tests/scripts/thread-cert/test_mle_msg_key_seq_jump.py
+++ /dev/null
@@ -1,266 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test verifies behavior of MLE related to handling of received
-#   larger key sequence based on the MLE message class (authoritative,
-#   or peer).
-#
-#
-# Topology:
-#
-#   leader ---  router
-#    |    \
-#    |     \
-#  child   reed
-#
-
-LEADER = 1
-CHILD = 2
-REED = 3
-ROUTER = 4
-
-
-class MleMsgKeySeqJump(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-        },
-        CHILD: {
-            'name': 'CHILD',
-            'is_mtd': True,
-            'mode': 'rn',
-        },
-        REED: {
-            'name': 'REED',
-            'mode': 'rn'
-        },
-        ROUTER: {
-            'name': 'ROUTER',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        child = self.nodes[CHILD]
-        reed = self.nodes[REED]
-        router = self.nodes[ROUTER]
-
-        nodes = [leader, child, reed, router]
-
-        #-------------------------------------------------------------------
-        # Form the network.
-
-        for node in nodes:
-            node.set_key_sequence_counter(0)
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        child.start()
-        reed.start()
-        self.simulator.go(5)
-        self.assertEqual(child.get_state(), 'child')
-        self.assertEqual(reed.get_state(), 'child')
-
-        router.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router.get_state(), 'router')
-
-        #-------------------------------------------------------------------
-        # Validate the initial key seq counter on all nodes
-
-        for node in nodes:
-            self.assertEqual(node.get_key_sequence_counter(), 0)
-
-        #-------------------------------------------------------------------
-        # Manually increase the key seq on child. Then change MLE mode on
-        # child which triggers a "Child Update Request" to its parent
-        # (leader). The key jump noticed on parent side would trigger an
-        # authoritative MLE Child Update exchange (including challenge and
-        # response TLVs) and causes the parent (leader) to also adopt the
-        # larger key seq.
-
-        child.set_key_sequence_counter(5)
-        self.assertEqual(child.get_key_sequence_counter(), 5)
-
-        child.set_mode('r')
-        self.simulator.go(1)
-
-        self.assertEqual(child.get_key_sequence_counter(), 5)
-        self.assertEqual(leader.get_key_sequence_counter(), 5)
-
-        #-------------------------------------------------------------------
-        # Wait long enough for MLE Advertisement to be sent. This would
-        # trigger reed and router to also notice key seq jump and try to
-        # re-establish link again (using authoritative exchanges). Validate
-        # that all nodes are using the new key seq.
-
-        self.simulator.go(52)
-        for node in nodes:
-            self.assertEqual(node.get_key_sequence_counter(), 5)
-
-        #-------------------------------------------------------------------
-        # Manually increase the key seq on leader. Wait for max time between
-        # advertisements. This would trigger both reed and router
-        # to notice key seq jump and try to re-establish link (link
-        # request/accept exchange). Validate that they all adopt the new
-        # key seq.
-
-        leader.set_key_sequence_counter(10)
-        self.assertEqual(leader.get_key_sequence_counter(), 10)
-
-        self.simulator.go(52)
-
-        self.assertEqual(router.get_key_sequence_counter(), 10)
-        self.assertEqual(reed.get_key_sequence_counter(), 10)
-
-        #-------------------------------------------------------------------
-        # Change MLE mode on child to trigger a "Child Update Request" exchange
-        # which should then update the key seq on child as well.
-
-        child.set_mode('rn')
-        self.simulator.go(5)
-        self.assertEqual(child.get_key_sequence_counter(), 10)
-
-        #-------------------------------------------------------------------
-        # Stop all other nodes except for leader. Move the leader key seq
-        # forward and then restart all other node. Validate that router,
-        # reed and child all re-attach successfully and adopt the higher key
-        # sequence.
-
-        router.stop()
-        reed.stop()
-        child.stop()
-
-        leader.set_key_sequence_counter(15)
-        self.assertEqual(leader.get_key_sequence_counter(), 15)
-
-        child.start()
-        reed.start()
-        router.start()
-        self.simulator.go(5)
-
-        self.assertEqual(child.get_state(), 'child')
-        self.assertEqual(reed.get_state(), 'child')
-        self.assertEqual(router.get_state(), 'router')
-
-        for node in nodes:
-            self.assertEqual(node.get_key_sequence_counter(), 15)
-
-        #-------------------------------------------------------------------
-        # Stop all other nodes except for leader. Move the child key seq
-        # forward and then restart child. Ensure it re-attached successfully
-        # to leader and that leader adopts the higher key seq counter.
-
-        router.stop()
-        reed.stop()
-        child.stop()
-
-        child.set_key_sequence_counter(20)
-        self.assertEqual(child.get_key_sequence_counter(), 20)
-
-        child.start()
-        self.simulator.go(5)
-
-        self.assertEqual(child.get_state(), 'child')
-        self.assertEqual(leader.get_key_sequence_counter(), 20)
-
-        #-------------------------------------------------------------------
-        # Restart router and reed and ensure they are re-attached and get the
-        # higher key seq counter.
-
-        router.start()
-        reed.start()
-
-        self.simulator.go(5)
-        self.assertEqual(router.get_state(), 'router')
-        self.assertEqual(reed.get_state(), 'child')
-
-        self.assertEqual(router.get_key_sequence_counter(), 20)
-        self.assertEqual(reed.get_key_sequence_counter(), 20)
-
-        #-------------------------------------------------------------------
-        # Move forward the key seq counter by two on router. Wait for max
-        # time between advertisements. Validate that leader adopts the higher
-        # counter value.
-
-        router.set_key_sequence_counter(22)
-        self.assertEqual(router.get_key_sequence_counter(), 22)
-
-        self.simulator.go(52)
-        self.assertEqual(leader.get_key_sequence_counter(), 22)
-        self.assertEqual(reed.get_key_sequence_counter(), 22)
-
-        child.set_mode('r')
-        self.simulator.go(2)
-        self.assertEqual(child.get_key_sequence_counter(), 22)
-
-        #-------------------------------------------------------------------
-        # Force a reattachment from the child with a higher key seq counter,
-        # so that the leader generated a fragmented Child Id Response. Ensure
-        # the child becomes attached on first attempt while the leader adopts
-        # the higher counter value.
-
-        router.stop()
-        reed.stop()
-
-        child.factory_reset()
-        self.assertEqual(child.get_state(), 'disabled')
-        child.set_mode('r')
-
-        child.set_active_dataset(channel=leader.get_channel(),
-                                 network_key=leader.get_networkkey(),
-                                 panid=leader.get_panid())
-        child.set_key_sequence_counter(25)
-        self.assertEqual(child.get_key_sequence_counter(), 25)
-
-        child.start()
-        self.simulator.go(5)
-
-        self.assertEqual(child.get_state(), 'child')
-        self.assertEqual(leader.get_key_sequence_counter(), 25)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_netdata_publisher.py b/tests/scripts/thread-cert/test_netdata_publisher.py
deleted file mode 100755
index 4d49de0..0000000
--- a/tests/scripts/thread-cert/test_netdata_publisher.py
+++ /dev/null
@@ -1,693 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies network data publisher behavior with DNS/SRP service entries and on-mesh prefix and external
-#   route entries.
-#
-# Topology:
-#
-#   1 leader, 5 routers and 5 end-devices all connected.
-#
-
-LEADER = 1
-ROUTER1 = 2
-ROUTER2 = 3
-ROUTER3 = 4
-ROUTER4 = 5
-ROUTER5 = 6
-END_DEV1 = 7
-END_DEV2 = 8
-END_DEV3 = 9
-END_DEV4 = 10
-END_DEV5 = 11
-
-WAIT_TIME = 55
-
-ON_MESH_PREFIX = 'fd00:1234:0:0::/64'
-ON_MESH_FLAGS = 'paso'
-
-EXTERNAL_ROUTE = 'fd00:abce:0:0::/64'
-EXTERNAL_FLAGS = 's'
-
-ANYCAST_SEQ_NUM = 4
-
-DNSSRP_ADDRESS = 'fd00::cdef'
-DNSSRP_PORT = 49152
-
-# The desired number of entries (based on related config).
-DESIRED_NUM_DNSSRP_ANYCAST = 8
-DESIRED_NUM_DNSSRP_UNICAST = 2
-DESIRED_NUM_ON_MESH_PREFIX = 3
-DESIRED_NUM_EXTERNAL_ROUTE = 10
-
-THREAD_ENTERPRISE_NUMBER = 44970
-ANYCAST_SERVICE_NUM = 0x5c
-UNICAST_SERVICE_NUM = 0x5d
-
-
-class NetDataPublisher(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'mode': 'rdn',
-        },
-        ROUTER2: {
-            'name': 'ROUTER2',
-            'mode': 'rdn',
-        },
-        ROUTER3: {
-            'name': 'ROUTER3',
-            'mode': 'rdn',
-        },
-        ROUTER4: {
-            'name': 'ROUTER4',
-            'mode': 'rdn',
-        },
-        ROUTER5: {
-            'name': 'ROUTER5',
-            'mode': 'rdn',
-        },
-        END_DEV1: {
-            'name': 'END_DEV1',
-            'mode': 'rn',
-        },
-        END_DEV2: {
-            'name': 'END_DEV2',
-            'mode': 'rn',
-        },
-        END_DEV3: {
-            'name': 'END_DEV3',
-            'mode': 'rn',
-        },
-        END_DEV4: {
-            'name': 'END_DEV4',
-            'mode': 'rn',
-        },
-        END_DEV5: {
-            'name': 'END_DEV5',
-            'mode': 'rn',
-        },
-    }
-
-    def verify_anycast_service(self, service):
-        # Verify the data in a single anycast `service` from `get_services()`
-        # Example of `service`: ['44970', '5c04', '', 's', 'bc00']
-        self.assertEqual(int(service[0]), THREAD_ENTERPRISE_NUMBER)
-        # Check service data
-        service_data = bytes.fromhex(service[1])
-        self.assertTrue(len(service_data) >= 2)
-        self.assertEqual(service_data[0], ANYCAST_SERVICE_NUM)
-        self.assertEqual(service_data[1], int(ANYCAST_SEQ_NUM))
-        # Verify that it stable
-        self.assertEqual(service[3], 's')
-
-    def verify_anycast_services(self, services):
-        # Verify a list of anycast `services` from `get_services()`
-        for service in services:
-            self.verify_anycast_service(service)
-
-    def verify_unicast_service(self, service):
-        # Verify the data in a single unicast `service` from `get_services()`
-        # Example of `service`: ['44970', '5d', 'fd000db800000000c6b0e5ee81f940e8223d', 's', '7000']
-        self.assertEqual(int(service[0]), THREAD_ENTERPRISE_NUMBER)
-        # Check service data
-        service_data = bytes.fromhex(service[1])
-        self.assertTrue(len(service_data) >= 1)
-        self.assertEqual(service_data[0], UNICAST_SERVICE_NUM)
-        # Verify that it stable
-        self.assertEqual(service[3], 's')
-
-    def verify_unicast_services(self, services):
-        # Verify a list of unicast `services` from `get_services()`
-        for service in services:
-            self.verify_unicast_service(service)
-
-    def check_num_of_prefixes(self, prefixes, num_low, num_med, num_high):
-        # Check and validate the prefix entries in network data (from
-        # `prefixes`) based on number of published prefix entries at
-        # different preference levels given by `num_low`, `num_med`,
-        # `num_high`. Prefixes is a list of the format
-        # 'fd00:1234:0:0::/64 paos low a802'.
-        self.assertEqual(len(prefixes), min(num_high + num_med + num_low, DESIRED_NUM_ON_MESH_PREFIX))
-        prfs = [prefix.split(' ')[2] for prefix in prefixes]
-        self.assertEqual(prfs.count('high'), min(num_high, DESIRED_NUM_ON_MESH_PREFIX))
-        self.assertEqual(prfs.count('med'), min(num_med, max(0, DESIRED_NUM_ON_MESH_PREFIX - num_high)))
-        self.assertEqual(prfs.count('low'), min(num_low, max(0, DESIRED_NUM_ON_MESH_PREFIX - num_high - num_med)))
-
-    def check_num_of_routes(self, routes, num_low, num_med, num_high):
-        # Check and validate the prefix entries in network data (from
-        # `routes`) based on number of published prefix entries at
-        # different preference levels given by `num_low`, `num_med`,
-        # `num_high`. Prefixes is a list of the format
-        # 'fd00:abce:0:0::/64 s med 6c01'.
-        self.assertEqual(len(routes), min(num_high + num_med + num_low, DESIRED_NUM_EXTERNAL_ROUTE))
-        prfs = [route.split(' ')[2] for route in routes]
-        self.assertEqual(prfs.count('high'), min(num_high, DESIRED_NUM_EXTERNAL_ROUTE))
-        self.assertEqual(prfs.count('med'), min(num_med, max(0, DESIRED_NUM_EXTERNAL_ROUTE - num_high)))
-        self.assertEqual(prfs.count('low'), min(num_low, max(0, DESIRED_NUM_EXTERNAL_ROUTE - num_high - num_med)))
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router1 = self.nodes[ROUTER1]
-        router2 = self.nodes[ROUTER2]
-        router3 = self.nodes[ROUTER3]
-        router4 = self.nodes[ROUTER4]
-        router5 = self.nodes[ROUTER5]
-        end_dev1 = self.nodes[END_DEV1]
-        end_dev2 = self.nodes[END_DEV2]
-        end_dev3 = self.nodes[END_DEV3]
-        end_dev4 = self.nodes[END_DEV4]
-        end_dev5 = self.nodes[END_DEV5]
-
-        nodes = self.nodes.values()
-        routers = [router1, router2, router3, router4, router5]
-        end_devs = [end_dev1, end_dev2, end_dev3, end_dev4, end_dev5]
-
-        # Start the nodes
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        for router in routers:
-            router.start()
-            self.simulator.go(config.ROUTER_STARTUP_DELAY)
-            self.assertEqual(router.get_state(), 'router')
-
-        for end_dev in end_devs:
-            end_dev.start()
-            self.simulator.go(5)
-            self.assertEqual(end_dev.get_state(), 'child')
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP anycast entries - equal version number
-
-        # Publish DNS/SRP anycast on leader and all routers (6 nodes).
-
-        leader.netdata_publish_dnssrp_anycast(ANYCAST_SEQ_NUM)
-        for node in routers:
-            node.netdata_publish_dnssrp_anycast(ANYCAST_SEQ_NUM)
-        self.simulator.go(WAIT_TIME)
-
-        # Check all entries are present in the network data
-
-        services = leader.get_services()
-        self.assertEqual(len(services), min(1 + len(routers), DESIRED_NUM_DNSSRP_ANYCAST))
-        self.verify_anycast_services(services)
-
-        # Publish same entry on all end-devices (5 nodes).
-
-        for node in end_devs:
-            node.netdata_publish_dnssrp_anycast(ANYCAST_SEQ_NUM)
-            print(node.name)
-        self.simulator.go(WAIT_TIME)
-
-        # Check number of entries in the network data is limited to
-        # the desired number (8 entries).
-
-        services = leader.get_services()
-        self.assertEqual(len(leader.get_services()), min(len(nodes), DESIRED_NUM_DNSSRP_ANYCAST))
-        self.verify_anycast_services(services)
-
-        # Unpublish the entry from nodes one by one starting from leader
-        # and check that number of entries is correct in each step.
-
-        num = len(nodes)
-        for node in nodes:
-            node.netdata_unpublish_dnssrp()
-            self.simulator.go(WAIT_TIME)
-            num -= 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_ANYCAST))
-            self.verify_anycast_services(services)
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP anycast entries - different version numbers
-
-        # Publish DNS/SRP anycast on leader and all routers (6 nodes).
-
-        version = 0
-        leader.netdata_publish_dnssrp_anycast(ANYCAST_SEQ_NUM, version)
-        num = 1
-
-        for node in routers:
-            version += 1
-            node.netdata_publish_dnssrp_anycast(ANYCAST_SEQ_NUM, version)
-            num += 1
-
-        self.simulator.go(WAIT_TIME)
-
-        # Check all entries are present in the network data
-
-        services = leader.get_services()
-        self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_ANYCAST))
-        self.verify_anycast_services(services)
-
-        # Publish same entry with same version on all end-devices (5 nodes).
-
-        for node in end_devs:
-            node.netdata_publish_dnssrp_anycast(ANYCAST_SEQ_NUM, version)
-            num += 1
-            print(node.name)
-            self.simulator.go(WAIT_TIME)
-            # Check number of entries in the network data is limited
-            # to the desired number (8 entries). All new entries use
-            # higher version and should be preferred. Validate that
-            # the 'services' list contains the new services.
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_ANYCAST))
-            self.verify_anycast_services(services)
-            node_rloc16 = node.get_addr16()
-            self.assertTrue(any(int(service[4], 16) == node_rloc16 for service in services))
-
-        # Unpublish the entry from nodes one by one starting from leader
-        # and check that number of entries is correct in each step.
-
-        for node in nodes:
-            node.netdata_unpublish_dnssrp()
-            self.simulator.go(WAIT_TIME)
-            num -= 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_ANYCAST))
-            self.verify_anycast_services(services)
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP service data unicast entries - equal version number
-
-        num = 0
-        for node in routers:
-            node.netdata_publish_dnssrp_unicast(DNSSRP_ADDRESS, DNSSRP_PORT)
-            self.simulator.go(WAIT_TIME)
-            num += 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-
-        for node in routers:
-            node.srp_server_set_enabled(True)
-            self.simulator.go(WAIT_TIME)
-
-        self.assertEqual(sum(node.srp_server_get_state() == 'running' for node in routers),
-                         min(len(routers), DESIRED_NUM_DNSSRP_UNICAST))
-        self.assertEqual(sum(node.srp_server_get_state() == 'stopped' for node in routers),
-                         max(len(routers) - DESIRED_NUM_DNSSRP_UNICAST, 0))
-
-        for node in routers:
-            node.netdata_unpublish_dnssrp()
-            self.simulator.go(WAIT_TIME)
-            num -= 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-        for node in routers:
-            node.srp_server_set_enabled(False)
-            self.assertEqual(node.srp_server_get_state(), 'disabled')
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP service data unicast entries - different version numbers
-
-        num = 0
-        for node in routers:
-            # Use `num` as version.
-            node.netdata_publish_dnssrp_unicast(DNSSRP_ADDRESS, DNSSRP_PORT, num)
-            self.simulator.go(WAIT_TIME)
-            num += 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-            # The most recent service should win as it uses a higher version
-            # number. Validate that the 'services' list contains the service
-            # from this node by checking the service RLOC16.
-            node_rloc16 = node.get_addr16()
-            self.assertTrue(any(int(service[4], 16) == node_rloc16 for service in services))
-
-        for node in reversed(routers):
-            node.netdata_unpublish_dnssrp()
-            self.simulator.go(WAIT_TIME)
-            num -= 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP server data unicast entries - equal version number
-
-        num = 0
-        for node in routers:
-            node.netdata_publish_dnssrp_unicast_mleid(DNSSRP_PORT)
-            self.simulator.go(WAIT_TIME)
-            num += 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-
-        for node in routers:
-            node.srp_server_set_enabled(True)
-            self.simulator.go(WAIT_TIME)
-        self.assertEqual(sum(node.srp_server_get_state() == 'running' for node in routers),
-                         min(len(routers), DESIRED_NUM_DNSSRP_UNICAST))
-        self.assertEqual(sum(node.srp_server_get_state() == 'stopped' for node in routers),
-                         max(len(routers) - DESIRED_NUM_DNSSRP_UNICAST, 0))
-
-        for node in routers:
-            node.netdata_unpublish_dnssrp()
-            self.simulator.go(WAIT_TIME)
-            num -= 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-        for node in routers:
-            node.srp_server_set_enabled(False)
-            self.assertEqual(node.srp_server_get_state(), 'disabled')
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP server data unicast entries - different version numbers
-
-        num = 0
-        for node in routers:
-            node.netdata_publish_dnssrp_unicast_mleid(DNSSRP_PORT, num)
-            self.simulator.go(WAIT_TIME)
-            num += 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-            # The most recent service should win as it uses a higher version
-            # number. Validate that the 'services' list contains the service
-            # from this node by checking the service RLOC16.
-            node_rloc16 = node.get_addr16()
-            self.assertTrue(any(int(service[4], 16) == node_rloc16 for service in services))
-
-        for node in reversed(routers):
-            node.netdata_unpublish_dnssrp()
-            self.simulator.go(WAIT_TIME)
-            num -= 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-
-        # Repeat the same test steps, but start with larger version
-        # numbers first.
-
-        num = 0
-        for node in routers:
-            node.netdata_publish_dnssrp_unicast_mleid(DNSSRP_PORT, 20 - num)
-            self.simulator.go(WAIT_TIME)
-            num += 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-            # The service from first router should win as it uses the highest
-            # version number.
-            first_router_rloc16 = routers[0].get_addr16()
-            self.assertTrue(any(int(service[4], 16) == first_router_rloc16 for service in services))
-
-        for node in routers:
-            node.netdata_unpublish_dnssrp()
-            self.simulator.go(WAIT_TIME)
-            num -= 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP server data unicast vs anycast
-
-        num = 0
-        for node in routers:
-            node.netdata_publish_dnssrp_unicast_mleid(DNSSRP_PORT)
-            self.simulator.go(WAIT_TIME)
-            num += 1
-            services = leader.get_services()
-            self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-            self.verify_unicast_services(services)
-
-        # Verify that publishing an anycast entry will update the
-        # limit for the server data unicast address entry and all are
-        # removed quickly. We validate this by waiting for a short
-        # time (15 msec) after publishing anycast.
-
-        leader.netdata_publish_dnssrp_anycast(ANYCAST_SEQ_NUM)
-        self.simulator.go(15)
-        services = leader.get_services()
-        self.assertEqual(len(services), 1)
-        self.verify_anycast_services(services)
-
-        # Removing the anycast entry will cause the lower priority
-        # server data unicast entries to be added again.
-
-        leader.netdata_unpublish_dnssrp()
-        self.simulator.go(WAIT_TIME)
-
-        services = leader.get_services()
-        self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-        self.verify_unicast_services(services)
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP server data unicast vs service data unicast
-
-        leader.netdata_publish_dnssrp_unicast(DNSSRP_ADDRESS, DNSSRP_PORT)
-        self.simulator.go(WAIT_TIME)
-        services = leader.get_services()
-        self.assertEqual(len(services), 1)
-        self.verify_unicast_services(services)
-
-        # Removing the service data unicast entry will cause the lower
-        # priority server data unicast entries to be added again.
-
-        leader.netdata_unpublish_dnssrp()
-        self.simulator.go(WAIT_TIME)
-
-        services = leader.get_services()
-        self.assertEqual(len(services), min(num, DESIRED_NUM_DNSSRP_UNICAST))
-        self.verify_unicast_services(services)
-
-        for node in routers:
-            node.netdata_unpublish_dnssrp()
-
-        #---------------------------------------------------------------------------------
-        # DNS/SRP entries: Verify publisher preference when removing
-        # entries.
-        #
-        # Publish DNS/SRP anycast on 8 nodes: leader, router1,
-        # router2, and all 5 end-devices. Afterwards, manually add
-        # the same service entry in Network Data on router3, router4,
-        # and router5 and at each step check that entry from one of
-        # the end-devices is removed (publisher prefers
-        # entries from routers over the ones from end-devices).
-
-        num = 0
-        test_routers = [leader, router1, router2]
-        for node in test_routers + end_devs:
-            node.netdata_publish_dnssrp_anycast(ANYCAST_SEQ_NUM)
-            self.simulator.go(WAIT_TIME)
-            num += 1
-            services = leader.get_services()
-            self.assertEqual(len(services), num)
-            self.verify_anycast_services(services)
-
-        self.assertEqual(num, DESIRED_NUM_DNSSRP_ANYCAST)
-
-        service_data = '%02x%02x' % (ANYCAST_SERVICE_NUM, int(ANYCAST_SEQ_NUM))
-        for node in [router3, router4, router5]:
-            node.add_service(str(THREAD_ENTERPRISE_NUMBER), service_data, '00')
-            node.register_netdata()
-            self.simulator.go(WAIT_TIME)
-
-            services = leader.get_services()
-            self.assertEqual(len(services), num)
-            self.verify_anycast_services(services)
-
-            service_rlocs = [int(service[4], 16) for service in services]
-            test_routers.append(node)
-
-            for router in test_routers:
-                self.assertIn(router.get_addr16(), service_rlocs)
-
-        #---------------------------------------------------------------------------------
-        # On-mesh prefix
-
-        # Publish the same on-mesh prefix on different nodes (low
-        # preference on end-devices, medium preference on routers, and
-        # high on leader) one by one and then unpublish them one by one.
-        # Verify that at each step the entries in the network data are
-        # correct. Particularly verify that that higher preference
-        # entries replace lower preference ones even when there are
-        # already desired number in network data.
-
-        num_low = 0
-        num_med = 0
-        num_high = 0
-
-        for node in end_devs:
-            node.netdata_publish_prefix(ON_MESH_PREFIX, ON_MESH_FLAGS, 'low')
-            self.simulator.go(WAIT_TIME)
-            num_low += 1
-            prefixes = leader.get_prefixes()
-            self.check_num_of_prefixes(prefixes, num_low, num_med, num_high)
-
-        # Now add the entry as 'med' on routers and check that we see those in the list.
-        for node in routers:
-            node.netdata_publish_prefix(ON_MESH_PREFIX, ON_MESH_FLAGS, 'med')
-            self.simulator.go(WAIT_TIME)
-            num_med += 1
-            prefixes = leader.get_prefixes()
-            self.check_num_of_prefixes(prefixes, num_low, num_med, num_high)
-
-        leader.netdata_publish_prefix(ON_MESH_PREFIX, ON_MESH_FLAGS, 'high')
-        self.simulator.go(WAIT_TIME)
-        num_high += 1
-        prefixes = leader.get_prefixes()
-        self.check_num_of_prefixes(prefixes, num_low, num_med, num_high)
-
-        for node in routers:
-            node.netdata_unpublish_prefix(ON_MESH_PREFIX)
-            self.simulator.go(WAIT_TIME)
-            num_med -= 1
-            prefixes = leader.get_prefixes()
-            self.check_num_of_prefixes(prefixes, num_low, num_med, num_high)
-
-        leader.netdata_unpublish_prefix(ON_MESH_PREFIX)
-        self.simulator.go(WAIT_TIME)
-        num_high -= 1
-        prefixes = leader.get_prefixes()
-        self.check_num_of_prefixes(prefixes, num_low, num_med, num_high)
-
-        for node in end_devs:
-            node.netdata_unpublish_prefix(ON_MESH_PREFIX)
-            self.simulator.go(WAIT_TIME)
-            num_low -= 1
-            prefixes = leader.get_prefixes()
-            self.check_num_of_prefixes(prefixes, num_low, num_med, num_high)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Verify that when removing extra entries, non-preferred entries
-        # are removed first over preferred ones. Entries from routers are
-        # preferred over similar entries from end-devices.
-
-        # Publish prefix entry on `end_dev1` and verify that it is added.
-
-        end_dev1.netdata_publish_prefix(ON_MESH_PREFIX, ON_MESH_FLAGS, 'med')
-        self.simulator.go(WAIT_TIME)
-        prefixes = leader.get_prefixes()
-        self.check_num_of_prefixes(prefixes, 0, 1, 0)
-
-        # Publish same prefix on all routers (again as `med` preference).
-        # Verify that we reach the desired number of prefix entries in network
-        # data and that the entry from `end_dev1` is present in network data.
-
-        for node in routers:
-            node.netdata_publish_prefix(ON_MESH_PREFIX, ON_MESH_FLAGS, 'med')
-            self.simulator.go(WAIT_TIME)
-        prefixes = leader.get_prefixes()
-        self.check_num_of_prefixes(prefixes, 0, 1 + len(routers), 0)
-        self.assertTrue(1 + len(routers) >= DESIRED_NUM_ON_MESH_PREFIX)
-        # `prefixes` is a list of format 'fd00:1234:0:0::/64 paos low a802'
-        rlocs = [int(prefix.split(' ')[3], 16) for prefix in prefixes]
-        self.assertTrue(rlocs.count(end_dev1.get_addr16()) == 1)
-
-        # Publish same prefix now with `high` preference on leader.
-        # Since it is `high` preference, it is added to network data
-        # which leads to total number of entries to go above the desired
-        # number temporarily and trigger other nodes to try to remove
-        # their entry. The entries from routers should be preferred over
-        # the one from `end_dev1` so that is the one we expect to be
-        # removed. We check that this is the case (i.e., the entry from
-        # `end_dev1` is no longer present in network data).
-
-        leader.netdata_publish_prefix(ON_MESH_PREFIX, ON_MESH_FLAGS, 'high')
-        self.simulator.go(WAIT_TIME)
-        prefixes = leader.get_prefixes()
-        self.check_num_of_prefixes(prefixes, 0, 1 + len(routers), 1)
-        rlocs = [int(prefix.split(' ')[3], 16) for prefix in prefixes]
-        self.assertTrue(rlocs.count(end_dev1.get_addr16()) == 0)
-
-        #---------------------------------------------------------------------------------
-        # External route
-
-        # Publish same external route on all nodes with low preference.
-
-        num = 0
-        for node in nodes:
-            node.netdata_publish_route(EXTERNAL_ROUTE, EXTERNAL_FLAGS, 'low')
-            self.simulator.go(WAIT_TIME)
-            num += 1
-            routes = leader.get_routes()
-            self.check_num_of_routes(routes, num, 0, 0)
-
-        # Change the preference level of the existing entry on leader to high.
-
-        leader.netdata_publish_route(EXTERNAL_ROUTE, EXTERNAL_FLAGS, 'high')
-        self.simulator.go(WAIT_TIME)
-        routes = leader.get_routes()
-        self.check_num_of_routes(routes, num - 1, 0, 1)
-
-        # Replace the published route on leader with '::/0'.
-        leader.netdata_publish_replace(EXTERNAL_ROUTE, '::/0', EXTERNAL_FLAGS, 'med')
-        self.simulator.go(1)
-        routes = leader.get_routes()
-        self.assertEqual([route.split(' ')[0] == '::/0' for route in routes].count(True), 1)
-
-        # Replace it back to the original route.
-        leader.netdata_publish_replace('::/0', EXTERNAL_ROUTE, EXTERNAL_FLAGS, 'high')
-        self.simulator.go(WAIT_TIME)
-        routes = leader.get_routes()
-        self.assertEqual([route.split(' ')[0] == '::/0' for route in routes].count(True), 0)
-        self.check_num_of_routes(routes, num - 1, 0, 1)
-
-        # Publish the same prefix on leader as an on-mesh prefix. Make
-        # sure it is removed from external routes and now seen in the
-        # prefix list.
-
-        leader.netdata_publish_prefix(EXTERNAL_ROUTE, ON_MESH_FLAGS, 'low')
-        self.simulator.go(WAIT_TIME)
-        routes = leader.get_routes()
-        self.check_num_of_routes(routes, num - 1, 0, 0)
-
-        prefixes = leader.get_prefixes()
-        print(prefixes)
-        self.assertIn(EXTERNAL_ROUTE, [prefix.split()[0] for prefix in prefixes])
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_on_mesh_prefix.py b/tests/scripts/thread-cert/test_on_mesh_prefix.py
deleted file mode 100755
index 83dd732..0000000
--- a/tests/scripts/thread-cert/test_on_mesh_prefix.py
+++ /dev/null
@@ -1,137 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-ED1 = 3
-SED1 = 4
-
-MTDS = [ED1, SED1]
-
-
-class Test_OnMeshPrefix(thread_cert.TestCase):
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER]
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'allowlist': [LEADER, ED1, SED1]
-        },
-        ED1: {
-            'is_mtd': True,
-            'mode': 'rn',
-            'allowlist': [ROUTER]
-        },
-        SED1: {
-            'is_mtd': True,
-            'mode': '-',
-            'timeout': config.DEFAULT_CHILD_TIMEOUT,
-            'allowlist': [ROUTER]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        self.nodes[ED1].start()
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[ED1].get_state(), 'child')
-
-        self.nodes[SED1].start()
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[SED1].get_state(), 'child')
-
-        self.nodes[ROUTER].add_prefix('2001:2:0:1::/64', 'paros')
-        self.nodes[ROUTER].add_prefix('2001:2:0:2::/64', 'paro')
-        self.nodes[ROUTER].register_netdata()
-
-        # Set lowpan context of sniffer
-        self.simulator.set_lowpan_context(1, '2001:2:0:1::/64')
-        self.simulator.set_lowpan_context(2, '2001:2:0:2::/64')
-
-        self.simulator.go(10)
-
-        addrs = self.nodes[ED1].get_addrs()
-        self.assertTrue(any('2001:2:0:1' in addr[0:10] for addr in addrs))
-        self.assertTrue(any('2001:2:0:2' in addr[0:10] for addr in addrs))
-        for addr in addrs:
-            if addr[0:3] == '200':
-                self.assertTrue(self.nodes[LEADER].ping(addr))
-
-        addrs = self.nodes[SED1].get_addrs()
-        self.assertTrue(any('2001:2:0:1' in addr[0:10] for addr in addrs))
-        self.assertFalse(any('2001:2:0:2' in addr[0:10] for addr in addrs))
-        for addr in addrs:
-            if addr[0:3] == '200':
-                self.assertTrue(self.nodes[LEADER].ping(addr))
-
-        self.nodes[ROUTER].add_prefix('2002:2:0:3::/64', 'pars')
-        self.nodes[ROUTER].register_netdata()
-
-        # Set lowpan context of sniffer
-        self.simulator.set_lowpan_context(3, '2002:2:0:3::/64')
-
-        self.simulator.go(10)
-
-        addrs = self.nodes[ED1].get_addrs()
-        self.assertTrue(any('2001:2:0:1' in addr[0:10] for addr in addrs))
-        self.assertTrue(any('2001:2:0:2' in addr[0:10] for addr in addrs))
-        self.assertTrue(any('2002:2:0:3' in addr[0:10] for addr in addrs))
-        for addr in addrs:
-            if addr[0:4] == '2001':
-                self.assertTrue(self.nodes[LEADER].ping(addr))
-            elif addr[0:4] == '2002':
-                self.assertFalse(self.nodes[LEADER].ping(addr))
-
-        addrs = self.nodes[SED1].get_addrs()
-        self.assertTrue(any('2001:2:0:1' in addr[0:10] for addr in addrs))
-        self.assertFalse(any('2001:2:0:2' in addr[0:10] for addr in addrs))
-        self.assertTrue(any('2002:2:0:3' in addr[0:10] for addr in addrs))
-        for addr in addrs:
-            if addr[0:4] == '2001':
-                self.assertTrue(self.nodes[LEADER].ping(addr))
-            elif addr[0:4] == '2002':
-                self.assertFalse(self.nodes[LEADER].ping(addr))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_pbbr_aloc.py b/tests/scripts/thread-cert/test_pbbr_aloc.py
deleted file mode 100755
index 0a4ee71..0000000
--- a/tests/scripts/thread-cert/test_pbbr_aloc.py
+++ /dev/null
@@ -1,117 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-import re
-import unittest
-
-import config
-import thread_cert
-from config import MESH_LOCAL_PREFIX_REGEX_PATTERN, ROUTING_LOCATOR_REGEX_PATTERN
-from pktverify.packet_verifier import PacketVerifier
-
-# Test description:
-# The purpose of this test is to verify ALOC connectivity.
-#
-# Topology:
-#
-#
-#  PBBR ----- LEADER ---- ROUTER
-#
-#
-
-LEADER = 1
-PBBR = 2
-ROUTER = 3
-
-
-class TestPing(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-    SUPPORT_THREAD_1_1 = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'Router_1',
-            'allowlist': [PBBR, ROUTER],
-        },
-        PBBR: {
-            'name': 'Router_2',
-            'allowlist': [LEADER],
-            'is_bbr': True,
-        },
-        ROUTER: {
-            'name': 'Router_3',
-            'allowlist': [LEADER],
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        pbbr = self.nodes[PBBR]
-        router = self.nodes[ROUTER]
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', leader.get_state())
-
-        pbbr.enable_backbone_router()
-        pbbr.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual('router', pbbr.get_state())
-        self.assertTrue(pbbr.is_primary_backbone_router)
-
-        router.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual('router', router.get_state())
-
-        for node in (leader, pbbr):
-            for addr in self.get_alocs(node):
-                self.assertTrue(router.ping(addr))
-
-    def verify(self, pv: PacketVerifier):
-        pkts = pv.pkts
-        # Must sure no ADDR_QUERY.qry is ever sent
-        pkts.filter_coap_request('/a/aq').must_not_next()
-
-    def get_alocs(self, node):
-        mleid = node.get_mleid()
-        for ip in node.get_addrs():
-            if not re.match(MESH_LOCAL_PREFIX_REGEX_PATTERN, ip):
-                continue
-
-            if ip == mleid:
-                continue
-
-            self.assertIsNotNone(re.match(ROUTING_LOCATOR_REGEX_PATTERN, ip), ip)
-            locator = int(ip.split(':')[-1], 16)
-            if locator >= 0xfc00:
-                yield ip
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_ping.py b/tests/scripts/thread-cert/test_ping.py
deleted file mode 100755
index 3494611..0000000
--- a/tests/scripts/thread-cert/test_ping.py
+++ /dev/null
@@ -1,199 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier
-from pktverify.consts import MA1, MA1g, MA2
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test is to verify the functionality of ping command.
-#
-# Topology:
-#
-#
-#  ROUTER_2 ----- ROUTER_1 ---- ROUTER_3
-#
-#
-
-ROUTER_1 = 1
-ROUTER_2 = 2
-ROUTER_3 = 3
-
-
-class TestPing(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [ROUTER_2, ROUTER_3],
-        },
-        ROUTER_2: {
-            'name': 'Router_2',
-            'allowlist': [ROUTER_1],
-        },
-        ROUTER_3: {
-            'name': 'Router_3',
-            'allowlist': [ROUTER_1],
-        },
-    }
-
-    def test(self):
-        router1 = self.nodes[ROUTER_1]
-        router2 = self.nodes[ROUTER_2]
-        router3 = self.nodes[ROUTER_3]
-
-        router1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', router1.get_state())
-
-        router2.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual('router', router2.get_state())
-
-        router3.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual('router', router3.get_state())
-
-        # 1. ROUTER_1 pings ROUTER_2.
-        self.assertTrue(router1.ping(router2.get_ip6_address(config.ADDRESS_TYPE.RLOC)))
-
-        # 2. ROUTER_1 pings ROUTER_2 multiple times.
-        self.assertTrue(router1.ping(router2.get_ip6_address(config.ADDRESS_TYPE.RLOC), count=5))
-
-        # 3. ROUTER_2 pings ROUTER_1 from the link-local address to the
-        # link-local address.
-        self.assertTrue(
-            router2.ping(router1.get_ip6_address(config.ADDRESS_TYPE.LINK_LOCAL),
-                         interface=router2.get_ip6_address(config.ADDRESS_TYPE.LINK_LOCAL)))
-
-        # 4. ROUTER_2 pings ROUTER_3 using the RLOC.
-        self.assertTrue(router2.ping(router3.get_ip6_address(config.ADDRESS_TYPE.RLOC)))
-
-        # 5. ROUTER_2 pings ROUTER_3's link-local address. The ping should fail.
-        self.assertFalse(router2.ping(router3.get_ip6_address(config.ADDRESS_TYPE.LINK_LOCAL)))
-
-        # 6. ROUTER_2 pings ROUTER_3's RLOC from the link-local address. The
-        # ping should fail.
-        self.assertFalse(
-            router2.ping(router3.get_ip6_address(config.ADDRESS_TYPE.RLOC),
-                         interface=router2.get_ip6_address(config.ADDRESS_TYPE.LINK_LOCAL)))
-
-        # 7. ROUTER_2 pings ROUTER_3's RLOC from an non-existent address. The
-        # ping command should be rejected by CLI.
-        self.assertFalse(router2.ping(router3.get_ip6_address(config.ADDRESS_TYPE.RLOC), interface='1::1'))
-
-        self.collect_ipaddrs()
-        self.collect_rloc16s()
-        self.collect_rlocs()
-        self.collect_extra_vars()
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        vars = pv.vars
-        pv.summary.show()
-
-        logging.info(f'vars = {vars}')
-
-        # Ensure the topology is formed correctly
-        pv.verify_attached('Router_2', 'Router_1')
-        pv.verify_attached('Router_3', 'Router_1')
-
-        # 1. Router_1 pings Router_2.
-        _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_2dsts(vars['Router_2_RLOC'], vars['Router_2_LLA']) \
-            .filter_ping_request() \
-            .must_next()
-
-        pkts.filter_wpan_src64(vars['Router_2']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 2. Router_1 pings Router_2 multiple times.
-        for i in range(5):
-            _pkt = pkts.filter_wpan_src64(vars['Router_1']) \
-                .filter_ipv6_2dsts(vars['Router_2_RLOC'], vars['Router_2_LLA']) \
-                .filter_ping_request() \
-                .must_next()
-            pkts.filter_wpan_src64(vars['Router_2']) \
-                .filter_ipv6_dst(_pkt.ipv6.src) \
-                .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-                .must_next()
-
-        # 3. Router_2 pings Router_1 from the link-local address to the
-        # link-local address.
-        _pkt = pkts.filter_wpan_src64(vars['Router_2']) \
-            .filter_ipv6_src_dst(vars['Router_2_LLA'], vars['Router_1_LLA']) \
-            .filter_ping_request() \
-            .must_next()
-
-        pkts.filter_wpan_src64(vars['Router_1']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 4. Router_2 pings Router_3 using the RLOC.
-        _pkt = pkts.filter_wpan_src64(vars['Router_2']) \
-            .filter_ipv6_dst(vars['Router_3_RLOC']) \
-            .filter_ping_request() \
-            .must_next()
-
-        pkts.filter_wpan_src64(vars['Router_3']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_next()
-
-        # 5. Router_2 pings Router_3's link-local address. The ping should fail.
-        _pkt = pkts.filter_wpan_src64(vars['Router_2']) \
-            .filter_ipv6_dst(vars['Router_3_LLA']) \
-            .filter_ping_request() \
-            .must_next()
-
-        pkts.filter_wpan_src64(vars['Router_3']) \
-            .filter_ipv6_dst(_pkt.ipv6.src) \
-            .filter_ping_reply(identifier=_pkt.icmpv6.echo.identifier) \
-            .must_not_next()
-
-        # 5. Router_2 pings Router_3's RLOC from the link-local address. The
-        # ping should fail.
-        _pkt = pkts.filter_wpan_src64(vars['Router_2']) \
-            .filter_ipv6_src_dst(vars['Router_2_LLA'], vars['Router_3_RLOC']) \
-            .filter_ping_request() \
-            .must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_ping_lla_src.py b/tests/scripts/thread-cert/test_ping_lla_src.py
deleted file mode 100755
index e6f9b26..0000000
--- a/tests/scripts/thread-cert/test_ping_lla_src.py
+++ /dev/null
@@ -1,119 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import logging
-import unittest
-
-import pktverify
-from pktverify import packet_verifier
-import config
-import thread_cert
-
-# Test description:
-# The purpose of this test is to verify the Router's routing behavior using LLA as source address.
-#
-# Topology:
-#
-#
-#  ROUTER_2 ----- ROUTER_1 ---- ROUTER_3
-#
-#
-
-ROUTER_1 = 1
-ROUTER_2 = 2
-ROUTER_3 = 3
-
-
-class TestPing(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        ROUTER_1: {
-            'name': 'Router_1',
-            'allowlist': [ROUTER_2, ROUTER_3],
-        },
-        ROUTER_2: {
-            'name': 'Router_2',
-            'allowlist': [ROUTER_1],
-        },
-        ROUTER_3: {
-            'name': 'Router_3',
-            'allowlist': [ROUTER_1],
-        },
-    }
-
-    def test(self):
-        router1 = self.nodes[ROUTER_1]
-        router2 = self.nodes[ROUTER_2]
-        router3 = self.nodes[ROUTER_3]
-
-        router1.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual('leader', router1.get_state())
-
-        router2.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual('router', router2.get_state())
-
-        router3.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual('router', router3.get_state())
-
-        self.simulator.go(10)
-
-        left, center, right = router2, router1, router3
-
-        left_lla = left.get_ip6_address(config.ADDRESS_TYPE.LINK_LOCAL)
-
-        # Ping a neighbor with LLA src should succeed.
-        self.assertTrue(left.ping(center.get_mleid(), interface=left_lla))
-        self.simulator.go(3)
-
-        # Ping a non-neighbor with LLA src should fail.
-        self.assertFalse(left.ping(right.get_mleid(), interface=left_lla))
-        self.simulator.go(3)
-
-        center.add_route('2001::/64', stable=True)
-        center.register_netdata()
-
-        self.simulator.go(3)
-
-        # Make sure external routes are not used for LLA src.
-        self.assertFalse(left.ping("2001::1", interface=left_lla))
-        self.simulator.go(3)
-
-    def verify(self, pv: pktverify.packet_verifier.PacketVerifier):
-        pkts = pv.pkts
-        # Make sure the Ping Request to 2001::1 was not sent
-        pkts.filter_ipv6_dst('2001::1').filter_ping_request().must_not_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_radio_filter.py b/tests/scripts/thread-cert/test_radio_filter.py
deleted file mode 100755
index 7ce082b..0000000
--- a/tests/scripts/thread-cert/test_radio_filter.py
+++ /dev/null
@@ -1,151 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import config
-import command
-import thread_cert
-
-# Test description:
-#
-# This test verifies the radio filter mechanism.
-#
-# Topology:
-#
-#   Leader -- Router
-#     |
-#     |
-#   Child
-#
-#
-
-LEADER = 1
-ROUTER = 2
-SED = 3
-
-WAIT_TIME = 5
-
-
-class RadioFilter(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER, SED]
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'allowlist': [LEADER]
-        },
-        SED: {
-            'is_mtd': True,
-            'mode': '-',
-            'timeout': config.DEFAULT_CHILD_TIMEOUT,
-            'allowlist': [LEADER]
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router = self.nodes[ROUTER]
-        sed = self.nodes[SED]
-
-        nodes = [leader, router, sed]
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        router.start()
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(router.get_state(), 'router')
-
-        sed.start()
-        sed.set_pollperiod(40)
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(sed.get_state(), 'child')
-
-        # Validate initial state of `radiofilter` upon start
-        for node in nodes:
-            self.assertFalse(node.radiofilter_is_enabled())
-
-        leader_mleid = leader.get_mleid()
-        router_mleid = router.get_mleid()
-        sed_mleid = sed.get_mleid()
-
-        # Validate connections by pinging from leader, SED, and router
-        self.assertTrue(leader.ping(router_mleid))
-        self.assertTrue(sed.ping(leader_mleid))
-        self.assertTrue(router.ping(leader_mleid))
-
-        # Validate behavior when `radiofilter` is enabled on router
-        router.radiofilter_enable()
-        self.assertTrue(router.radiofilter_is_enabled())
-        self.assertFalse(leader.ping(router_mleid))
-        self.assertFalse(router.ping(leader_mleid))
-
-        # Validate behavior when `radiofilter` is disabled on router
-        router.radiofilter_disable()
-        self.assertFalse(router.radiofilter_is_enabled())
-        self.assertTrue(leader.ping(router_mleid))
-        self.assertTrue(router.ping(leader_mleid))
-
-        # Validate `radiofilter` behavior on sed.
-        self.assertTrue(sed.ping(leader_mleid))
-
-        sed.radiofilter_enable()
-        self.assertTrue(sed.radiofilter_is_enabled())
-        self.assertFalse(sed.ping(leader_mleid))
-
-        self.assertTrue(sed.radiofilter_is_enabled())
-        self.assertEqual(sed.get_state(), 'detached')
-
-        sed.radiofilter_disable()
-        self.assertFalse(sed.radiofilter_is_enabled())
-
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(sed.get_state(), 'child')
-        self.assertTrue(leader.ping(sed_mleid))
-        self.assertTrue(sed.ping(leader_mleid))
-
-        # Validate energy scan when `radiofilter` is enabled
-        scan_result = leader.scan_energy()
-        self.assertTrue(len(scan_result) > 0)
-        leader.radiofilter_enable()
-        self.assertTrue(leader.radiofilter_is_enabled())
-        scan_result = leader.scan_energy()
-        self.assertTrue(len(scan_result) == 0)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_reed_address_solicit_rejected.py b/tests/scripts/thread-cert/test_reed_address_solicit_rejected.py
deleted file mode 100755
index 508d0c9..0000000
--- a/tests/scripts/thread-cert/test_reed_address_solicit_rejected.py
+++ /dev/null
@@ -1,108 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import config
-import re
-import unittest
-
-import thread_cert
-
-LEADER = 1
-REED = 2
-
-SRV_0_ID = 0
-SRV_0_ENT_NUMBER = '123'
-SRV_0_SERVICE_DATA = '123'
-SRV_0_SERVER_DATA = 'abc'
-
-# Topology:
-# LEADER -- REED
-#
-
-
-class TestREEDAddressSolicitRejected(thread_cert.TestCase):
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-        },
-        REED: {
-            'mode': 'rdn',
-        },
-    }
-
-    def testAddressSolicitRejectedBeforeSvrData(self):
-        self.nodes[LEADER].start()
-        self.nodes[LEADER].set_router_upgrade_threshold(1)
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[REED].start()
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[REED].get_state(), 'child')
-
-        self.nodes[REED].add_service(SRV_0_ENT_NUMBER, SRV_0_SERVICE_DATA, SRV_0_SERVER_DATA)
-        self.nodes[REED].register_netdata()
-        self.simulator.go(2)
-
-        self.assertEqual(self.hasAloc(REED, SRV_0_ID), True)
-
-    def testAddressSolicitRejectedAfterSvrData(self):
-        self.nodes[LEADER].start()
-        self.nodes[LEADER].set_router_upgrade_threshold(1)
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[REED].set_router_selection_jitter(120)
-        self.nodes[REED].start()
-        # set routerupgradethreshold=1 on REED so that it won't send ADDR_SOL.req
-        self.nodes[REED].set_router_upgrade_threshold(1)
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[REED].get_state(), 'child')
-
-        # restore routerupgradethreshold to 16 and add service
-        self.nodes[REED].set_router_upgrade_threshold(16)
-        self.nodes[REED].add_service(SRV_0_ENT_NUMBER, SRV_0_SERVICE_DATA, SRV_0_SERVER_DATA)
-        self.nodes[REED].register_netdata()
-        self.simulator.go(130)
-        self.assertEqual(self.hasAloc(REED, SRV_0_ID), True)
-
-    def hasAloc(self, node_id, service_id):
-        for addr in self.nodes[node_id].get_ip6_address(config.ADDRESS_TYPE.ALOC):
-            m = re.match('.*:fc(..)$', addr, re.I)
-            if m is not None:
-                if m.group(1) == str(service_id + 10):  # for service_id=3 look for '...:fc13'
-                    return True
-
-        return False
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_reset.py b/tests/scripts/thread-cert/test_reset.py
deleted file mode 100755
index b23e58d..0000000
--- a/tests/scripts/thread-cert/test_reset.py
+++ /dev/null
@@ -1,94 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import config
-import unittest
-import thread_cert
-
-LEADER = 1
-ROUTER = 2
-ED = 3
-SED = 4
-
-
-class TestReset(thread_cert.TestCase):
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER]
-        },
-        ROUTER: {
-            'mode': 'rdn',
-            'allowlist': [LEADER, ED]
-        },
-        ED: {
-            'is_mtd': True,
-            'mode': 'rn',
-            'allowlist': [ROUTER]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        self.nodes[ED].start()
-        self.simulator.go(7)
-        self.assertEqual(self.nodes[ED].get_state(), 'child')
-
-        for i in range(0, 1010):
-            self.assertTrue(self.nodes[ED].ping(self.nodes[LEADER].get_ip6_address(config.ADDRESS_TYPE.RLOC)))
-        self.simulator.go(1)
-
-        self.nodes[LEADER].reset()
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER].reset()
-        self.nodes[ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        self.nodes[ED].reset()
-        self.nodes[LEADER].add_allowlist(self.nodes[ROUTER].get_addr64())
-        self.nodes[LEADER].enable_allowlist()
-        self.nodes[ED].start()
-        self.simulator.go(7)
-        self.assertEqual(self.nodes[ED].get_state(), 'child')
-        self.assertTrue(self.nodes[ED].ping(self.nodes[LEADER].get_ip6_address(config.ADDRESS_TYPE.RLOC)))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_route_table.py b/tests/scripts/thread-cert/test_route_table.py
deleted file mode 100755
index f213aaa..0000000
--- a/tests/scripts/thread-cert/test_route_table.py
+++ /dev/null
@@ -1,87 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2019, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER1 = 2
-ROUTER2 = 3
-
-# Topology:
-# LEADER -- ROUTER1 -- ROUTER2
-#
-
-
-class TestRouteTable(thread_cert.TestCase):
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER1]
-        },
-        ROUTER1: {
-            'mode': 'rdn',
-            'allowlist': [LEADER, ROUTER2]
-        },
-        ROUTER2: {
-            'mode': 'rdn',
-            'allowlist': [ROUTER1]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER1].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER1].get_state(), 'router')
-
-        self.nodes[ROUTER2].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER2].get_state(), 'router')
-
-        self.simulator.go(100)
-
-        router_ids = set(_node.get_router_id() for _node in self.nodes.values())
-        for _node in self.nodes.values():
-            self.assertEqual(set(_node.router_list()), router_ids)
-
-        for _node in self.nodes.values():
-            router_table = _node.router_table()
-            self.assertEqual(set(router_table), router_ids)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_router_downgrade_on_sec_policy_change.py b/tests/scripts/thread-cert/test_router_downgrade_on_sec_policy_change.py
deleted file mode 100755
index 5241ad1..0000000
--- a/tests/scripts/thread-cert/test_router_downgrade_on_sec_policy_change.py
+++ /dev/null
@@ -1,149 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2023, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies leader and router downgrade delay on security policy version threshold change.
-#
-# Topology:
-#
-#     LEADER
-#       |
-#       |
-#     ROUTER
-#
-
-LEADER = 1
-ROUTER = 2
-
-
-class DowngradeOnSecPolicyChange(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-        },
-        ROUTER: {
-            'name': 'ROUTER',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router = self.nodes[ROUTER]
-
-        # Start the leader & router devices.
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        router.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router.get_state(), 'router')
-
-        self.assertTrue(leader.get_router_eligible())
-        self.assertTrue(router.get_router_eligible())
-
-        # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Change security policy, disable `R` bit and set version threshold to max value (7).
-
-        leader.send_mgmt_active_set(
-            active_timestamp=30,
-            security_policy=[3600, 'onc', 7],
-        )
-
-        self.assertEqual(leader.get_state(), 'leader')
-        self.assertEqual(router.get_state(), 'router')
-
-        # Leader should take at least 10 seconds before downgrading.
-
-        self.simulator.go(8)
-        self.assertEqual(leader.get_state(), 'leader')
-        self.assertFalse(leader.get_router_eligible())
-        self.assertFalse(router.get_router_eligible())
-
-        # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Change back security policy. This should cancel the ongoing downgrade delay.
-
-        leader.send_mgmt_active_set(
-            active_timestamp=60,
-            security_policy=[3600, 'ornc', 0],
-        )
-
-        self.simulator.go(1)
-        self.assertEqual(leader.get_state(), 'leader')
-        self.assertTrue(leader.get_router_eligible())
-
-        # Make sure both device stay as leader and router.
-
-        self.simulator.go(600)
-        self.assertEqual(leader.get_state(), 'leader')
-        self.assertEqual(router.get_state(), 'router')
-        self.assertTrue(leader.get_router_eligible())
-        self.assertTrue(router.get_router_eligible())
-
-        # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Change security policy again, disable `R` bit and set version threshold to max value (7).
-
-        leader.send_mgmt_active_set(
-            active_timestamp=90,
-            security_policy=[3600, 'onc', 7],
-        )
-
-        self.assertEqual(leader.get_state(), 'leader')
-
-        # Leader should take at least 10 seconds before downgrading.
-
-        self.simulator.go(8)
-        self.assertEqual(leader.get_state(), 'leader')
-        self.assertFalse(leader.get_router_eligible())
-
-        # Make sure both leader and router are downgraded and are now `detached`.
-
-        self.simulator.go(80)
-        self.assertEqual(leader.get_state(), 'detached')
-        self.assertEqual(router.get_state(), 'detached')
-
-        self.assertFalse(leader.get_router_eligible())
-        self.assertFalse(router.get_router_eligible())
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_router_multicast_link_request.py b/tests/scripts/thread-cert/test_router_multicast_link_request.py
deleted file mode 100755
index fb36605..0000000
--- a/tests/scripts/thread-cert/test_router_multicast_link_request.py
+++ /dev/null
@@ -1,122 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-import logging
-import unittest
-
-import config
-import thread_cert
-from pktverify.consts import MLE_LINK_REQUEST
-from pktverify.packet_verifier import PacketVerifier
-
-LEADER = 1
-REED = 2
-ROUTER1, ROUTER2, ROUTER3 = 3, 4, 5
-
-# Test Purpose and Description:
-# -----------------------------
-# This test verifies if a device can quickly and efficiently establish links with
-# neighboring routers by sending a multicast Link Request message after becoming
-# a router
-#
-# Test Topology:
-# -------------
-#     Leader------+
-#    |     \       \
-# Router1 Router2 Router3
-#    \     |        |
-#     +---REED-----+
-#
-
-# REED should establish links to the three Routers within the delay threshold.
-# The max delay consists:
-#   Leader may delay 1 second for MLE Advertisement
-#   Router may delay 1 second for MLE Advertisement
-#   Router may delay 1 second for MLE Link Accept after receiving MLE Link Request (multicast)
-LINK_ESTABLISH_DELAY_THRESHOLD = 3
-
-
-class TestRouterMulticastLinkRequest(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-            'allowlist': [ROUTER1, ROUTER2, ROUTER3]
-        },
-        ROUTER1: {
-            'name': 'ROUTER1',
-            'mode': 'rdn',
-            'allowlist': [LEADER, REED]
-        },
-        ROUTER2: {
-            'name': 'ROUTER2',
-            'mode': 'rdn',
-            'allowlist': [LEADER, REED]
-        },
-        ROUTER3: {
-            'name': 'ROUTER3',
-            'mode': 'rdn',
-            'allowlist': [LEADER, REED]
-        },
-        REED: {
-            'name': 'REED',
-            'mode': 'rdn',
-            'allowlist': [ROUTER1, ROUTER2, ROUTER3]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        for routerid in (ROUTER1, ROUTER2, ROUTER3):
-            self.nodes[routerid].start()
-            self.simulator.go(config.ROUTER_STARTUP_DELAY)
-            self.assertEqual(self.nodes[routerid].get_state(), 'router')
-
-        # Wait for the network to stabilize
-        self.simulator.go(60)
-
-        self.nodes[REED].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[REED].get_state(), 'router')
-        self.simulator.go(LINK_ESTABLISH_DELAY_THRESHOLD + 3)
-
-        # Verify that REED has established link with all routers
-        reed_table = self.nodes[REED].router_table()
-        reed_id = self.nodes[REED].get_router_id()
-        for router in [self.nodes[ROUTER1], self.nodes[ROUTER2], self.nodes[ROUTER3]]:
-            self.assertEqual(reed_table[router.get_router_id()]['link'], 1)
-            self.assertEqual(router.router_table()[reed_id]['link'], 1)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_router_reattach.py b/tests/scripts/thread-cert/test_router_reattach.py
deleted file mode 100755
index 2cb4b5b..0000000
--- a/tests/scripts/thread-cert/test_router_reattach.py
+++ /dev/null
@@ -1,226 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-
-class test_router_reattach(thread_cert.TestCase):
-    TOPOLOGY = {
-        1: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        2: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        3: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        4: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        5: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        6: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        7: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        8: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        9: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        10: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        11: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        12: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        13: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        14: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        15: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        16: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        17: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        18: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        19: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        20: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        21: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        22: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        23: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        24: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        25: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        26: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        27: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        28: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        29: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        30: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        31: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-        32: {
-            'mode': 'rdn',
-            'router_downgrade_threshold': 32,
-            'router_upgrade_threshold': 32
-        },
-    }
-
-    def test(self):
-        self.nodes[1].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[1].get_state(), 'leader')
-
-        for i in range(2, 33):
-            self.nodes[i].start()
-            self.simulator.go(config.ROUTER_STARTUP_DELAY)
-            self.assertEqual(self.nodes[i].get_state(), 'router')
-
-        self.nodes[2].reset()
-        self.nodes[2].set_router_selection_jitter(3)
-        self.nodes[2].set_router_upgrade_threshold(32)
-        self.nodes[2].set_router_downgrade_threshold(32)
-
-        self.nodes[2].start()
-        self.assertEqual(self.nodes[2].get_router_downgrade_threshold(), 32)
-        # Verify that the node restored as Router.
-        self.simulator.go(1)
-        self.assertEqual(self.nodes[2].get_state(), 'router')
-        # Verify that the node does not downgrade after Router Selection Jitter.
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[2].get_state(), 'router')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_router_reboot_multiple_link_request.py b/tests/scripts/thread-cert/test_router_reboot_multiple_link_request.py
deleted file mode 100755
index 6773b65..0000000
--- a/tests/scripts/thread-cert/test_router_reboot_multiple_link_request.py
+++ /dev/null
@@ -1,176 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2023, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import copy
-import unittest
-
-import command
-import config
-import mle
-import thread_cert
-from pktverify.consts import MLE_PARENT_REQUEST, MLE_LINK_REQUEST, MLE_LINK_ACCEPT, MLE_LINK_ACCEPT_AND_REQUEST, SOURCE_ADDRESS_TLV, CHALLENGE_TLV, RESPONSE_TLV, LINK_LAYER_FRAME_COUNTER_TLV, ROUTE64_TLV, ADDRESS16_TLV, LEADER_DATA_TLV, TLV_REQUEST_TLV, VERSION_TLV
-from pktverify.packet_verifier import PacketVerifier
-from pktverify.null_field import nullField
-
-LEADER = 1
-DUT_ROUTER = 2
-MED1 = 3
-MED2 = 4
-MED3 = 5
-MED4 = 6
-MED5 = 7
-MED6 = 8
-
-# Test Purpose and Description:
-# -----------------------------
-# The purpose of this test case is to show that when a router with > 5 children is rebooted, it sends MLE_MAX_CRITICAL_TRANSMISSION_COUNT MLE link request packets if no response is received.
-#
-# Test Topology:
-# -------------
-#   Leader
-#     |
-#   Router ------------------------+
-#   |       |     |     |    |     |
-#   MED1  MED2  MED3  MED4  MED5  MED6
-#
-# DUT Types:
-# ----------
-#  Router
-
-
-class Test_LeaderRebootMultipleLinkRequest(thread_cert.TestCase):
-    #USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-            'allowlist': [DUT_ROUTER]
-        },
-        DUT_ROUTER: {
-            'name': 'ROUTER',
-            'mode': 'rdn',
-            'allowlist': [LEADER, MED1, MED2, MED3, MED4, MED5, MED6]
-        },
-        MED1: {
-            'name': 'MED1',
-            'mode': 'rn',
-            'allowlist': [DUT_ROUTER]
-        },
-        MED2: {
-            'name': 'MED2',
-            'mode': 'rn',
-            'allowlist': [DUT_ROUTER]
-        },
-        MED3: {
-            'name': 'MED3',
-            'mode': 'rn',
-            'allowlist': [DUT_ROUTER]
-        },
-        MED4: {
-            'name': 'MED4',
-            'mode': 'rn',
-            'allowlist': [DUT_ROUTER]
-        },
-        MED5: {
-            'name': 'MED5',
-            'mode': 'rn',
-            'allowlist': [DUT_ROUTER]
-        },
-        MED6: {
-            'name': 'MED6',
-            'mode': 'rn',
-            'allowlist': [DUT_ROUTER]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[DUT_ROUTER].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[DUT_ROUTER].get_state(), 'router')
-
-        for medid in range(MED1, MED6 + 1):
-            self.nodes[medid].start()
-            self.simulator.go(config.ED_STARTUP_DELAY)
-            self.assertEqual(self.nodes[medid].get_state(), 'child')
-
-        self.simulator.go(config.MAX_ADVERTISEMENT_INTERVAL)
-
-        self.nodes[DUT_ROUTER].reset()
-        # Leader will not reply to router's link request
-        self.nodes[LEADER].clear_allowlist()
-
-        self.nodes[DUT_ROUTER].start()
-
-        self.simulator.go(config.LEADER_RESET_DELAY)
-
-    def verify(self, pv):
-        pkts = pv.pkts
-        pv.summary.show()
-
-        LEADER = pv.vars['LEADER']
-        ROUTER = pv.vars['ROUTER']
-
-        # Verify topology is formed correctly.
-        pv.verify_attached('ROUTER', 'LEADER')
-        for i in range(1, 7):
-            pv.verify_attached('MED%d' % i, 'ROUTER', 'MTD')
-
-        pkts.filter_wpan_src64(ROUTER).\
-            filter_mle_advertisement('Router').\
-            must_next()
-
-        # The router MUST send MLE_MAX_CRITICAL_TRANSMISSION_COUNT multicast Link Request
-        # The following TLVs MUST be present in the Link Request:
-        #     - Challenge TLV
-        #     - Version TLV
-        #     - TLV Request TLV: Address16 TLV, Route64 TLV
-        for i in range(0, config.MLE_MAX_CRITICAL_TRANSMISSION_COUNT):
-            pkts.filter_wpan_src64(ROUTER).\
-                filter_LLARMA().\
-                filter_mle_cmd(MLE_LINK_REQUEST).\
-                filter(lambda p: {
-                                CHALLENGE_TLV,
-                                VERSION_TLV,
-                                TLV_REQUEST_TLV,
-                                ADDRESS16_TLV,
-                                ROUTE64_TLV
-                                } <= set(p.mle.tlv.type) and\
-                    p.mle.tlv.addr16 is nullField and\
-                    p.mle.tlv.route64.id_mask is nullField
-                    ).\
-                must_next()
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_router_upgrade.py b/tests/scripts/thread-cert/test_router_upgrade.py
deleted file mode 100755
index a9c1e1a..0000000
--- a/tests/scripts/thread-cert/test_router_upgrade.py
+++ /dev/null
@@ -1,99 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import mle
-import network_layer
-import thread_cert
-
-LEADER = 1
-REED = 2
-
-RSSI_LOW = -95
-RSSI_HIGH = -45
-
-ROUTER_UPGRADE_DELAY = 150
-
-# Test Purpose and Description:
-# -----------------------------
-# The purpose of this test case is to show that a REED does not
-# upgrade to router if it does not have a neighbor with link margin
-# above threshold.
-#
-# Test Topology:
-# -------------
-# Leader
-#    |
-# REED
-#
-# DUT Types:
-# ----------
-#  Leader
-#  REED
-
-
-class TestRouterUpgrade(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-            'allowlist': [REED]
-        },
-        REED: {
-            'name': 'REED',
-            'mode': 'rdn',
-            'allowlist': [LEADER]
-        },
-    }
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[REED].add_allowlist(self.nodes[LEADER].get_addr64(), rssi=RSSI_LOW)
-        self.nodes[REED].enable_allowlist()
-
-        self.nodes[REED].start()
-        self.simulator.go(ROUTER_UPGRADE_DELAY)
-        self.assertEqual(self.nodes[REED].get_state(), 'child')
-
-        self.nodes[REED].add_allowlist(self.nodes[LEADER].get_addr64(), rssi=RSSI_HIGH)
-        self.nodes[REED].enable_allowlist()
-
-        self.simulator.go(ROUTER_UPGRADE_DELAY)
-        self.assertEqual(self.nodes[REED].get_state(), 'router')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_service.py b/tests/scripts/thread-cert/test_service.py
deleted file mode 100755
index 6032f6c..0000000
--- a/tests/scripts/thread-cert/test_service.py
+++ /dev/null
@@ -1,196 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2017, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import re
-import unittest
-
-import config
-import thread_cert
-
-LEADER = 1
-ROUTER1 = 2
-ROUTER2 = 3
-
-SRV_0_ID = 0
-SRV_0_ENT_NUMBER = '123'
-SRV_0_SERVICE_DATA = '123'
-SRV_0_SERVER_DATA = 'abc'
-
-SRV_1_ID = 1
-SRV_1_ENT_NUMBER = '234'
-SRV_1_SERVICE_DATA = '456'
-SRV_1_SERVER_DATA = 'def'
-
-
-class Test_Service(thread_cert.TestCase):
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'channel': 12,
-            'mode': 'rdn',
-            'network_name': 'OpenThread',
-            'allowlist': [ROUTER1, ROUTER2]
-        },
-        ROUTER1: {
-            'channel': 12,
-            'mode': 'rdn',
-            'network_name': 'OpenThread',
-            'allowlist': [LEADER, ROUTER2]
-        },
-        ROUTER2: {
-            'channel': 12,
-            'mode': 'rdn',
-            'network_name': 'OpenThread',
-            'allowlist': [LEADER, ROUTER1]
-        },
-    }
-
-    def hasAloc(self, node_id, service_id):
-        for addr in self.nodes[node_id].get_ip6_address(config.ADDRESS_TYPE.ALOC):
-            m = re.match('.*:fc(..)$', addr, re.I)
-            if m is not None:
-                if m.group(1) == str(service_id + 10):  # for service_id=3 look for '...:fc13'
-                    return True
-
-        return False
-
-    def pingFromAll(self, addr):
-        for n in list(self.nodes.values()):
-            self.assertTrue(n.ping(addr))
-
-    def failToPingFromAll(self, addr):
-        for n in list(self.nodes.values()):
-            self.assertFalse(n.ping(addr, timeout=3))
-
-    def test(self):
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER1].start()
-        self.nodes[ROUTER2].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER1].get_state(), 'router')
-        self.assertEqual(self.nodes[ROUTER2].get_state(), 'router')
-
-        self.assertEqual(self.hasAloc(LEADER, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(LEADER, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_1_ID), False)
-
-        self.nodes[ROUTER1].add_service(SRV_0_ENT_NUMBER, SRV_0_SERVICE_DATA, SRV_0_SERVER_DATA)
-        self.nodes[ROUTER1].register_netdata()
-        self.simulator.go(2)
-
-        self.assertEqual(self.hasAloc(LEADER, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(LEADER, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_0_ID), True)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_1_ID), False)
-
-        aloc0 = self.nodes[ROUTER1].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0]
-        self.pingFromAll(aloc0)
-
-        self.nodes[LEADER].add_service(SRV_0_ENT_NUMBER, SRV_0_SERVICE_DATA, SRV_0_SERVER_DATA)
-        self.nodes[LEADER].register_netdata()
-        self.simulator.go(2)
-
-        self.assertEqual(self.hasAloc(LEADER, SRV_0_ID), True)
-        self.assertEqual(self.hasAloc(LEADER, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_0_ID), True)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_1_ID), False)
-
-        self.pingFromAll(aloc0)
-
-        self.nodes[ROUTER2].add_service(SRV_1_ENT_NUMBER, SRV_1_SERVICE_DATA, SRV_1_SERVER_DATA)
-        self.nodes[ROUTER2].register_netdata()
-        self.simulator.go(2)
-
-        self.assertEqual(self.hasAloc(LEADER, SRV_0_ID), True)
-        self.assertEqual(self.hasAloc(LEADER, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_0_ID), True)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_1_ID), True)
-
-        aloc1 = self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0]
-        self.pingFromAll(aloc0)
-        self.pingFromAll(aloc1)
-
-        self.nodes[ROUTER1].remove_service(SRV_0_ENT_NUMBER, SRV_0_SERVICE_DATA)
-        self.nodes[ROUTER1].register_netdata()
-        self.simulator.go(2)
-
-        self.assertEqual(self.hasAloc(LEADER, SRV_0_ID), True)
-        self.assertEqual(self.hasAloc(LEADER, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_1_ID), True)
-
-        self.pingFromAll(aloc0)
-        self.pingFromAll(aloc1)
-
-        self.nodes[LEADER].remove_service(SRV_0_ENT_NUMBER, SRV_0_SERVICE_DATA)
-        self.nodes[LEADER].register_netdata()
-        self.simulator.go(2)
-
-        self.assertEqual(self.hasAloc(LEADER, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(LEADER, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_1_ID), True)
-
-        self.failToPingFromAll(aloc0)
-        self.pingFromAll(aloc1)
-
-        self.nodes[ROUTER2].remove_service(SRV_1_ENT_NUMBER, SRV_1_SERVICE_DATA)
-        self.nodes[ROUTER2].register_netdata()
-        self.simulator.go(2)
-
-        self.assertEqual(self.hasAloc(LEADER, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(LEADER, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER1, SRV_1_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_0_ID), False)
-        self.assertEqual(self.hasAloc(ROUTER2, SRV_1_ID), False)
-
-        self.failToPingFromAll(aloc0)
-        self.failToPingFromAll(aloc1)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_set_mliid.py b/tests/scripts/thread-cert/test_set_mliid.py
deleted file mode 100755
index 292b6ae..0000000
--- a/tests/scripts/thread-cert/test_set_mliid.py
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-import ipaddress
-
-import config
-import thread_cert
-
-LEADER = 1
-
-
-class Test_SetMlIid(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        # Set ML-IID before Thread is enabled.
-        self.nodes[LEADER].set_mliid('1122334455667788')
-
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        # Ensure set ML-IID was effective.
-        mleid = self.nodes[LEADER].get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-        self.assertEqual(b'\x11\x22\x33\x44\x55\x66\x77\x88', ipaddress.IPv6Address(mleid).packed[-8:])
-
-        # Ensure set ML-IID fail after Thread is enabled.
-        self.assertRaises(Exception, lambda: self.nodes[LEADER].set_mliid('5566778811223344'))
-
-        self.nodes[LEADER].reset()
-
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_RESET_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        # Ensure ML-IID is persistent after reset.
-        mleid = self.nodes[LEADER].get_ip6_address(config.ADDRESS_TYPE.ML_EID)
-        self.assertEqual(b'\x11\x22\x33\x44\x55\x66\x77\x88', ipaddress.IPv6Address(mleid).packed[-8:])
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_auto_host_address.py b/tests/scripts/thread-cert/test_srp_auto_host_address.py
deleted file mode 100755
index b672730..0000000
--- a/tests/scripts/thread-cert/test_srp_auto_host_address.py
+++ /dev/null
@@ -1,341 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies SRP client auto host address mode.
-#
-# Topology:
-#     SRP client (leader)
-#       |
-#       |
-#     SRP server (router)
-#
-
-CLIENT = 1
-SERVER = 2
-
-
-class SrpAutoHostAddress(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'mode': 'rdn',
-        },
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        client = self.nodes[CLIENT]
-        server = self.nodes[SERVER]
-
-        # Deprecation interval of an SLAAC address before removal.
-        deprecate_time = 300
-
-        #-------------------------------------------------------------------
-        # Form the network.
-
-        client.srp_server_set_enabled(False)
-        client.start()
-        self.simulator.go(15)
-        self.assertEqual(client.get_state(), 'leader')
-        client.srp_client_stop()
-
-        server.start()
-        self.simulator.go(5)
-        self.assertEqual(server.get_state(), 'router')
-
-        #-------------------------------------------------------------------
-        # Enable SRP server
-
-        server.srp_server_set_enabled(True)
-        self.simulator.go(5)
-
-        #-------------------------------------------------------------------
-        # Check auto start mode on SRP client
-
-        client.srp_client_enable_auto_start_mode()
-        self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-        self.simulator.go(15)
-
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-
-        #-------------------------------------------------------------------
-        # Set host name and enable auto host address on client
-
-        client.srp_client_set_host_name('host')
-        client.srp_client_enable_auto_host_address()
-
-        #-------------------------------------------------------------------
-        # Register a service on client
-
-        client.srp_client_add_service('test_srv', '_test._udo', 12345, 0, 0)
-        self.simulator.go(2)
-        self.check_registered_addresses(client, server)
-
-        #-------------------------------------------------------------------
-        # Add an address and check the SRP client re-registered and updated
-        # server with new address.
-
-        client.add_ipaddr('fd00:1:2:3:4:5:6:7')
-
-        self.simulator.go(5)
-        client_addresses = [addr.strip() for addr in client.get_addrs()]
-        self.assertIn('fd00:1:2:3:4:5:6:7', client_addresses)
-        self.check_registered_addresses(client, server)
-
-        #-------------------------------------------------------------------
-        # Remove the address and check the SRP client re-registered and updated
-        # server.
-
-        client.del_ipaddr('fd00:1:2:3:4:5:6:7')
-
-        self.simulator.go(5)
-        client_addresses = [addr.strip() for addr in client.get_addrs()]
-        self.assertNotIn('fd00:1:2:3:4:5:6:7', client_addresses)
-        self.check_registered_addresses(client, server)
-
-        #-------------------------------------------------------------------
-        # Add an SLAAC on-mesh prefix (which will trigger an address to be
-        # added) and check that the SRP client re-registered and updated
-        # server with the new address.
-
-        client.add_prefix('fd00:abba:cafe:bee::/64', 'paos')
-        client.register_netdata()
-        self.simulator.go(15)
-
-        slaac_addr = [addr.strip() for addr in client.get_addrs() if addr.strip().startswith('fd00:abba:cafe:bee:')]
-        self.assertEqual(len(slaac_addr), 1)
-        self.check_registered_addresses(client, server)
-
-        #-------------------------------------------------------------------
-        # Add another SLAAC on-mesh prefix and check that the SRP client
-        # re-registered and updated server with all address.
-
-        client.add_prefix('fd00:9:8:7::/64', 'paos')
-        client.register_netdata()
-        self.simulator.go(15)
-
-        slaac_addr = [addr.strip() for addr in client.get_addrs() if addr.strip().startswith('fd00:9:8:7:')]
-        self.assertEqual(len(slaac_addr), 1)
-        self.check_registered_addresses(client, server)
-
-        #-------------------------------------------------------------------
-        # Add a non-preferred SLAAC on-mesh prefix and check that the
-        # set of registered addresses remains unchanged and that the
-        # non-preferred address is not registered by SRP client.
-
-        client.add_prefix('fd00:a:b:c::/64', 'aos')
-        client.register_netdata()
-        self.simulator.go(15)
-
-        slaac_addr = [addr.strip() for addr in client.get_addrs() if addr.strip().startswith('fd00:a:b:c:')]
-        self.assertEqual(len(slaac_addr), 1)
-        self.check_registered_addresses(client, server)
-
-        #-------------------------------------------------------------------
-        # Remove the on-mesh prefix. This should trigger the
-        # associated SLAAC address to be deprecated, but it should
-        # not yet cause the client to re-register. Verify that the
-        # registered addresses on server remain unchanged.
-
-        old_registered_addresses = self.get_registered_host_addresses_from_server(server)
-
-        client.remove_prefix('fd00:abba:cafe:bee::/64')
-        client.register_netdata()
-
-        self.simulator.go(15)
-        self.assertEqual(old_registered_addresses, self.get_registered_host_addresses_from_server(server))
-
-        # Wait until the SLAAC address deprecation time has elapsed
-        # and the address is removed. Verify that the SRP client
-        # re-registers and updates the server with the remaining
-        # address.
-
-        self.simulator.go(deprecate_time)
-
-        self.check_registered_addresses(client, server)
-
-        #-------------------------------------------------------------------
-        # Remove the next on-mesh prefix. Verify that the client does
-        # not re-register while the address is deprecating. After the
-        # address is removed, confirm that the SRP client
-        # re-registers using only the ML-EID.
-
-        old_registered_addresses = self.get_registered_host_addresses_from_server(server)
-
-        client.remove_prefix('fd00:9:8:7::/64')
-        client.register_netdata()
-
-        self.simulator.go(15)
-        self.assertEqual(old_registered_addresses, self.get_registered_host_addresses_from_server(server))
-
-        self.simulator.go(deprecate_time)
-
-        self.check_registered_addresses(client, server)
-
-        #-------------------------------------------------------------------
-        # Add and remove the on-mesh prefix again. However, before the
-        # address deprecation time elapses and the address is removed,
-        # restart the server. This should trigger the client to
-        # re-register. Verify that the client re-registers with the
-        # most up-to-date addresses and does not register the deprecating
-        # address.
-
-        client.add_prefix('fd00:9:8:7::/64', 'paos')
-        client.register_netdata()
-        self.simulator.go(15)
-
-        slaac_addr = [addr.strip() for addr in client.get_addrs() if addr.strip().startswith('fd00:9:8:7:')]
-        self.assertEqual(len(slaac_addr), 1)
-        self.check_registered_addresses(client, server)
-
-        # Remove the prefix and verify that client does not
-        # register while the SLAAC address is deprecating.
-
-        old_registered_addresses = self.get_registered_host_addresses_from_server(server)
-
-        client.remove_prefix('fd00:9:8:7::/64')
-        client.register_netdata()
-
-        self.simulator.go(15)
-        self.assertEqual(old_registered_addresses, self.get_registered_host_addresses_from_server(server))
-
-        # Disable and re-enable the server. This should trigger the
-        # client to re-register. Verify that the ML-EID address is
-        # now registered.
-
-        server.srp_server_set_enabled(False)
-        server.srp_server_set_enabled(True)
-
-        self.simulator.go(20)
-
-        self.check_registered_addresses(client, server)
-
-        registered_addresses = self.get_registered_host_addresses_from_server(server)
-        self.assertEqual(len(registered_addresses), 1)
-        self.assertEqual(registered_addresses[0], client.get_mleid())
-
-        # Check that SLAAC address is still deprecating.
-
-        slaac_addr = [addr.strip() for addr in client.get_addrs() if addr.strip().startswith('fd00:9:8:7:')]
-        self.assertEqual(len(slaac_addr), 1)
-
-        #-------------------------------------------------------------------
-        # Explicitly set the host addresses (which disables the auto host
-        # address mode) and check that only the new addresses are registered.
-
-        client.srp_client_set_host_address('fd00:f:e:d:c:b:a:9')
-        self.simulator.go(5)
-
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-        server_hosts = server.srp_server_get_hosts()
-        self.assertEqual(len(server_hosts), 1)
-        server_host = server_hosts[0]
-        self.assertEqual(server_host['deleted'], 'false')
-        self.assertEqual(server_host['fullname'], 'host.default.service.arpa.')
-        host_addresses = [addr.strip() for addr in server_host['addresses']]
-        self.assertEqual(len(host_addresses), 1)
-        self.assertEqual(host_addresses[0], 'fd00:f:e:d:c:b:a:9')
-
-        #-------------------------------------------------------------------
-        # Re-enable auto host address mode and check that addresses are
-        # updated and registered properly.
-
-        client.srp_client_enable_auto_host_address()
-        self.simulator.go(5)
-        self.check_registered_addresses(client, server)
-
-    def get_registered_host_addresses_from_server(self, server):
-        # Check the host info on server.
-        server_hosts = server.srp_server_get_hosts()
-        self.assertEqual(len(server_hosts), 1)
-        server_host = server_hosts[0]
-        self.assertEqual(server_host['deleted'], 'false')
-        self.assertEqual(server_host['fullname'], 'host.default.service.arpa.')
-        return [addr.strip() for addr in server_host['addresses']]
-
-    def check_registered_addresses(self, client, server):
-        # Ensure client has registered successfully.
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-        # Check the host addresses on server to match client.
-
-        host_addresses = self.get_registered_host_addresses_from_server(server)
-
-        client_mleid = client.get_mleid()
-        client_addresses = [addr.split(' ')[0] for addr in client.get_addrs(verbose=True) if 'preferred:1' in addr]
-        client_addresses += [client_mleid]
-
-        # All registered addresses must be in client list of addresses.
-
-        for addr in host_addresses:
-            self.assertIn(addr, client_addresses)
-
-        # All preferred addresses on client excluding link-local and
-        # mesh-local addresses must be seen on server side. But if there
-        # was no address, then mesh-local address should be the only
-        # one registered.
-
-        checked_address = False
-
-        for addr in client_addresses:
-            if not self.is_address_link_local(addr) and not self.is_address_locator(addr) and addr != client_mleid:
-                self.assertIn(addr, host_addresses)
-                checked_address = True
-
-        if not checked_address:
-            self.assertEqual(len(host_addresses), 1)
-            self.assertIn(client_mleid, host_addresses)
-
-    def is_address_locator(self, addr):
-        # Checks if an IPv6 address is a locator (IID should match `0:ff:fe00:xxxx`)
-        u32s = addr.split(':')
-        self.assertEqual(len(u32s), 8)
-        return ':'.join(u32s[4:]).startswith('0:ff:fe00:')
-
-    def is_address_link_local(self, addr):
-        # Checks if an IPv6 address is link-local
-        return addr.startswith('fe80:')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_auto_start_mode.py b/tests/scripts/thread-cert/test_srp_auto_start_mode.py
deleted file mode 100755
index ee0cfab..0000000
--- a/tests/scripts/thread-cert/test_srp_auto_start_mode.py
+++ /dev/null
@@ -1,287 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies SRP client auto-start functionality that SRP client can
-#   correctly discovers and connects to SRP server.
-#
-# Topology:
-#
-#   Four routers, one acting as SRP client, others as SRP server.
-#
-
-CLIENT = 1
-SERVER1 = 2
-SERVER2 = 3
-SERVER3 = 4
-
-
-class SrpAutoStartMode(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'mode': 'rdn',
-        },
-        SERVER1: {
-            'name': 'SRP_SERVER1',
-            'mode': 'rdn',
-        },
-        SERVER2: {
-            'name': 'SRP_SERVER2',
-            'mode': 'rdn',
-        },
-        SERVER3: {
-            'name': 'SRP_SERVER3',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        client = self.nodes[CLIENT]
-        server1 = self.nodes[SERVER1]
-        server2 = self.nodes[SERVER2]
-        server3 = self.nodes[SERVER3]
-
-        #-------------------------------------------------------------------
-        # Form the network.
-
-        client.srp_server_set_enabled(False)
-        client.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'leader')
-
-        server1.start()
-        server2.start()
-        server3.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(server1.get_state(), 'router')
-        self.assertEqual(server2.get_state(), 'router')
-        self.assertEqual(server3.get_state(), 'router')
-
-        server1_mleid = server1.get_mleid()
-        server2_mleid = server2.get_mleid()
-        server3_mleid = server3.get_mleid()
-        anycast_port = 53
-
-        #-------------------------------------------------------------------
-        # Enable server1 with unicast address mode
-
-        server1.srp_server_set_addr_mode('unicast')
-        server1.srp_server_set_enabled(True)
-        self.simulator.go(5)
-
-        #-------------------------------------------------------------------
-        # Check auto start mode on client and check that server1 is selected
-
-        self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-        self.simulator.go(2)
-
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertEqual(client.srp_client_get_server_address(), server1_mleid)
-
-        #-------------------------------------------------------------------
-        # Disable server1 and check client is stopped/disabled.
-
-        server1.srp_server_set_enabled(False)
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_state(), 'Disabled')
-
-        #-------------------------------------------------------------------
-        # Enable server2 with unicast address mode and check client starts
-        # again.
-
-        server1.srp_server_set_addr_mode('unicast')
-        server2.srp_server_set_enabled(True)
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertEqual(client.srp_client_get_server_address(), server2_mleid)
-
-        #-------------------------------------------------------------------
-        # Enable server1 and check that client stays with server2
-
-        server1.srp_server_set_enabled(True)
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertEqual(client.srp_client_get_server_address(), server2_mleid)
-
-        #-------------------------------------------------------------------
-        # Disable server2 and check client switches to server1.
-
-        server2.srp_server_set_enabled(False)
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertEqual(client.srp_client_get_server_address(), server1_mleid)
-
-        #-------------------------------------------------------------------
-        # Enable server2 with anycast mode seq-num 1, and check that client
-        # switched to it.
-
-        server2.srp_server_set_addr_mode('anycast')
-        server2.srp_server_set_anycast_seq_num(1)
-        server2.srp_server_set_enabled(True)
-        self.simulator.go(5)
-        server2_alocs = server2.get_ip6_address(config.ADDRESS_TYPE.ALOC)
-        self.assertEqual(server2.srp_server_get_anycast_seq_num(), 1)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertIn(client.srp_client_get_server_address(), server2_alocs)
-        self.assertEqual(client.srp_client_get_server_port(), anycast_port)
-
-        #-------------------------------------------------------------------
-        # Enable server3 with anycast mode seq-num 2, and check that client
-        # switched to it since seq number is higher.
-
-        server3.srp_server_set_addr_mode('anycast')
-        server3.srp_server_set_anycast_seq_num(2)
-        server3.srp_server_set_enabled(True)
-        self.simulator.go(5)
-        server3_alocs = server3.get_ip6_address(config.ADDRESS_TYPE.ALOC)
-        self.assertEqual(server3.srp_server_get_anycast_seq_num(), 2)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertIn(client.srp_client_get_server_address(), server3_alocs)
-        self.assertEqual(client.srp_client_get_server_port(), anycast_port)
-
-        #-------------------------------------------------------------------
-        # Disable server3 and check that client goes back to server2.
-
-        server3.srp_server_set_enabled(False)
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertIn(client.srp_client_get_server_address(), server2_alocs)
-        self.assertEqual(client.srp_client_get_server_port(), anycast_port)
-
-        #-------------------------------------------------------------------
-        # Enable server3 with anycast mode seq-num 0 (which is smaller than
-        # server2 seq-num 1) and check that client stays with server2.
-
-        server3.srp_server_set_anycast_seq_num(0)
-        server3.srp_server_set_enabled(True)
-        self.simulator.go(5)
-        self.assertEqual(server3.srp_server_get_anycast_seq_num(), 0)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertIn(client.srp_client_get_server_address(), server2_alocs)
-        self.assertEqual(client.srp_client_get_server_port(), anycast_port)
-
-        #-------------------------------------------------------------------
-        # Disable server2 and check that client goes back to server3.
-
-        server2.srp_server_set_enabled(False)
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        server3_alocs = server3.get_ip6_address(config.ADDRESS_TYPE.ALOC)
-        self.assertIn(client.srp_client_get_server_address(), server3_alocs)
-        self.assertEqual(client.srp_client_get_server_port(), anycast_port)
-
-        #-------------------------------------------------------------------
-        # Disable server3 and check that client goes back to server1 with
-        # unicast address.
-
-        server3.srp_server_set_enabled(False)
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertEqual(client.srp_client_get_server_address(), server1_mleid)
-
-        #-------------------------------------------------------------------
-        # Enable server2 with anycast mode seq-num 5, and check that client
-        # switched to it.
-
-        server2.srp_server_set_addr_mode('anycast')
-        server2.srp_server_set_anycast_seq_num(5)
-        server2.srp_server_set_enabled(True)
-        self.simulator.go(5)
-        server2_alocs = server2.get_ip6_address(config.ADDRESS_TYPE.ALOC)
-        self.assertEqual(server2.srp_server_get_anycast_seq_num(), 5)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertIn(client.srp_client_get_server_address(), server2_alocs)
-        self.assertEqual(client.srp_client_get_server_port(), anycast_port)
-
-        #-------------------------------------------------------------------
-        # Publish an entry on server3 with specific unicast address
-        # This entry should be now preferred over anycast of server2.
-
-        unicast_addr3 = 'fd00:0:0:0:0:3333:beef:cafe'
-        unicast_port3 = 1234
-        server3.netdata_publish_dnssrp_unicast(unicast_addr3, unicast_port3)
-        self.simulator.go(65)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertEqual(client.srp_client_get_server_address(), unicast_addr3)
-        self.assertEqual(client.srp_client_get_server_port(), unicast_port3)
-
-        #-------------------------------------------------------------------
-        # Publish an entry on server1 with specific unicast address
-        # Client should still stay with server3 which it originally selected.
-
-        unicast_addr1 = 'fd00:0:0:0:0:2222:beef:cafe'
-        unicast_port1 = 10203
-        server1.srp_server_set_enabled(False)
-        server1.netdata_publish_dnssrp_unicast(unicast_addr1, unicast_port1)
-        self.simulator.go(65)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertEqual(client.srp_client_get_server_address(), unicast_addr3)
-        self.assertEqual(client.srp_client_get_server_port(), unicast_port3)
-
-        #-------------------------------------------------------------------
-        # Unpublish the entry on server3. Now client should switch to entry
-        # from server1.
-
-        server3.netdata_unpublish_dnssrp()
-        self.simulator.go(65)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertEqual(client.srp_client_get_server_address(), unicast_addr1)
-        self.assertEqual(client.srp_client_get_server_port(), unicast_port1)
-
-        #-------------------------------------------------------------------
-        # Unpublish the entry on server1 and check client goes back to anycast
-        # entry from server2.
-
-        server1.netdata_unpublish_dnssrp()
-        self.simulator.go(65)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertIn(client.srp_client_get_server_address(), server2_alocs)
-        self.assertEqual(client.srp_client_get_server_port(), anycast_port)
-
-        #-------------------------------------------------------------------
-        # Finally disable server2, and check that client is disabled.
-
-        server2.srp_server_set_enabled(False)
-        self.simulator.go(5)
-        self.assertEqual(client.srp_client_get_state(), 'Disabled')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_client_change_lease.py b/tests/scripts/thread-cert/test_srp_client_change_lease.py
deleted file mode 100755
index 4c98c8c..0000000
--- a/tests/scripts/thread-cert/test_srp_client_change_lease.py
+++ /dev/null
@@ -1,161 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies the SRP client lease changing works as expected.
-#
-# Topology:
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-from pktverify.packet_filter import PacketFilter
-
-SERVER = 1
-CLIENT = 2
-
-DEFAULT_LEASE_TIME = 7200
-LEASE_TIME = 60
-NEW_LEASE_TIME = 120
-KEY_LEASE_TIME = 240
-NEW_TTL = 10
-
-assert LEASE_TIME < KEY_LEASE_TIME
-assert NEW_LEASE_TIME < KEY_LEASE_TIME
-assert NEW_TTL < NEW_LEASE_TIME
-
-
-class SrpClientChangeLeaseTime(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        #
-        # 0. Start the server and client devices.
-        #
-
-        server.srp_server_set_enabled(True)
-        server.srp_server_set_lease_range(LEASE_TIME, LEASE_TIME, KEY_LEASE_TIME, KEY_LEASE_TIME)
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-        self.simulator.go(5)
-
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        #
-        # 1. Register a single service and verify that it works.
-        #
-
-        self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-
-        client.srp_client_set_host_name('my-host')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service('my-service', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        self.check_host_and_service(server, client)
-
-        server.srp_server_set_lease_range(NEW_LEASE_TIME, NEW_LEASE_TIME, KEY_LEASE_TIME, KEY_LEASE_TIME)
-        client.srp_client_set_lease_interval(NEW_LEASE_TIME)
-        self.simulator.go(NEW_LEASE_TIME)
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-        self.simulator.go(KEY_LEASE_TIME * 2)
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-        client.srp_client_set_ttl(NEW_TTL)
-        self.simulator.go(NEW_LEASE_TIME)
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-        client.srp_client_set_ttl(0)
-        self.simulator.go(NEW_LEASE_TIME)
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-    def verify(self, pv):
-        pkts: PacketFilter = pv.pkts
-        pv.summary.show()
-
-        CLIENT_SRC64 = pv.vars['SRP_CLIENT']
-
-        pkts.filter_wpan_src64(CLIENT_SRC64).filter('dns.flags.response == 0 and dns.resp.ttl == {DEFAULT_LEASE_TIME}',
-                                                    DEFAULT_LEASE_TIME=DEFAULT_LEASE_TIME).must_next()
-        pkts.filter_wpan_src64(CLIENT_SRC64).filter('dns.flags.response == 0 and dns.resp.ttl == {NEW_LEASE_TIME}',
-                                                    NEW_LEASE_TIME=NEW_LEASE_TIME).must_next()
-        pkts.filter_wpan_src64(CLIENT_SRC64).filter('dns.flags.response == 0 and dns.resp.ttl == {NEW_TTL}',
-                                                    NEW_TTL=NEW_TTL).must_next()
-        pkts.filter_wpan_src64(CLIENT_SRC64).filter('dns.flags.response == 0 and dns.resp.ttl == {NEW_LEASE_TIME}',
-                                                    NEW_LEASE_TIME=NEW_LEASE_TIME).must_next()
-
-    def check_host_and_service(self, server, client):
-        """Check that we have properly registered host and service instance.
-        """
-
-        client_services = client.srp_client_get_services()
-        print(client_services)
-        self.assertEqual(len(client_services), 1)
-        client_service = client_services[0]
-
-        # Verify that the client possesses correct service resources.
-        self.assertEqual(client_service['instance'], 'my-service')
-        self.assertEqual(client_service['name'], '_ipps._tcp')
-        self.assertEqual(int(client_service['port']), 12345)
-        self.assertEqual(int(client_service['priority']), 0)
-        self.assertEqual(int(client_service['weight']), 0)
-
-        # Verify that the client received a SUCCESS response for the server.
-        self.assertEqual(client_service['state'], 'Registered')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_client_remove_host.py b/tests/scripts/thread-cert/test_srp_client_remove_host.py
deleted file mode 100755
index 6818939..0000000
--- a/tests/scripts/thread-cert/test_srp_client_remove_host.py
+++ /dev/null
@@ -1,176 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies SRP client `RemoveHostAndServices()` behavior when host info is not yet registered.
-#
-# Topology:
-#
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-
-SERVER = 1
-CLIENT = 2
-
-
-class SrpRemoveHost(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        #-------------------------------------------------------------------------------------
-        # Start the server & client devices.
-
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        server.srp_server_set_enabled(True)
-        client.srp_client_enable_auto_start_mode()
-        self.simulator.go(15)
-
-        #-------------------------------------------------------------------------------------
-        # Register a single service and verify that it worked.
-
-        client.srp_client_set_host_name('host')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service('ins', '_srv._udp', 1977)
-        self.simulator.go(2)
-
-        client_services = client.srp_client_get_services()
-        self.assertEqual(len(client_services), 1)
-        client_service = client_services[0]
-        self.assertEqual(client_service['instance'], 'ins')
-        self.assertEqual(client_service['name'], '_srv._udp')
-        self.assertEqual(int(client_service['port']), 1977)
-        self.assertEqual(int(client_service['priority']), 0)
-        self.assertEqual(int(client_service['weight']), 0)
-        self.assertEqual(client_service['state'], 'Registered')
-
-        server_hosts = server.srp_server_get_hosts()
-        self.assertEqual(len(server_hosts), 1)
-        server_host = server_hosts[0]
-        self.assertEqual(server_host['fullname'], 'host.default.service.arpa.')
-        self.assertEqual(server_host['deleted'], 'false')
-        self.assertEqual(len(server_host['addresses']), 1)
-        self.assertIn('2001:0:0:0:0:0:0:1', server_host['addresses'])
-
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-        self.assertEqual(server_service['fullname'], 'ins._srv._udp.default.service.arpa.')
-        self.assertEqual(server_service['instance'], 'ins')
-        self.assertEqual(server_service['name'], '_srv._udp')
-        self.assertEqual(server_service['deleted'], 'false')
-        self.assertEqual(int(server_service['port']), 1977)
-        self.assertEqual(int(server_service['priority']), 0)
-        self.assertEqual(int(server_service['weight']), 0)
-        self.assertEqual(server_service['host'], 'host')
-
-        #-------------------------------------------------------------------------------------
-        # Clear the info on client, and verify that it is still present on server.
-
-        client.srp_client_clear_host()
-        self.simulator.go(2)
-
-        client_services = client.srp_client_get_services()
-        self.assertEqual(len(client_services), 0)
-        self.assertIsNone(client.srp_client_get_host_name())
-        self.assertIsNone(client.srp_client_get_host_address())
-
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 1)
-
-        #-------------------------------------------------------------------------------------
-        # Now set the host name and request "host remove" requiring that client updates server
-        # even if host is not yet registered. Verify that client updates the server by checking
-        # that the host and service entries are marked as "deleted" on the server (i.e. deleted
-        # by the name (and associated key) are retained).
-
-        client.srp_client_set_host_name('host')
-        client.srp_client_remove_host(send_unreg_to_server=True)
-        self.simulator.go(2)
-
-        self.assertIsNone(client.srp_client_get_host_name())
-
-        server_hosts = server.srp_server_get_hosts()
-        self.assertEqual(len(server_hosts), 1)
-        server_host = server_hosts[0]
-        self.assertEqual(server_host['fullname'], 'host.default.service.arpa.')
-        self.assertEqual(server_host['deleted'], 'true')
-
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-        self.assertEqual(server_service['fullname'], 'ins._srv._udp.default.service.arpa.')
-        self.assertEqual(server_service['deleted'], 'true')
-
-        #-------------------------------------------------------------------------------------
-        # Again request "host remove" but this time request `remove_key`. Verify that entries
-        # on the server are fully removed .
-
-        client.srp_client_set_host_name('host')
-        client.srp_client_remove_host(remove_key=True, send_unreg_to_server=True)
-        self.simulator.go(2)
-
-        self.assertIsNone(client.srp_client_get_host_name())
-        self.assertEqual(len(server.srp_server_get_services()), 0)
-        self.assertEqual(len(server.srp_server_get_hosts()), 0)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_client_save_server_info.py b/tests/scripts/thread-cert/test_srp_client_save_server_info.py
deleted file mode 100755
index 094205a..0000000
--- a/tests/scripts/thread-cert/test_srp_client_save_server_info.py
+++ /dev/null
@@ -1,162 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies SRP client behavior to save the selected server address info (unicast) by
-#   auto-start feature.
-#
-# Topology:
-#
-#   4 node (SRP client as leader, 3 SRP servers).
-#
-
-CLIENT = 1
-SERVER1 = 2
-SERVER2 = 3
-SERVER3 = 4
-
-WAIT_TIME = 16
-MAX_ITER = 5
-
-
-class SrpAutoStartMode(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-        SERVER1: {
-            'name': 'SRP_SERVER1',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rn',
-        },
-        SERVER2: {
-            'name': 'SRP_SERVER2',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rn',
-        },
-        SERVER3: {
-            'name': 'SRP_SERVER3',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rn',
-        },
-    }
-
-    def test(self):
-        client = self.nodes[CLIENT]
-        server1 = self.nodes[SERVER1]
-        server2 = self.nodes[SERVER2]
-        server3 = self.nodes[SERVER3]
-
-        # Start the server & client devices.
-
-        client.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'leader')
-
-        for node in [server1, server2, server3]:
-            node.start()
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(node.get_state(), 'child')
-
-        # Enable auto start mode on client and check that server1 is used.
-
-        server1.srp_server_set_enabled(True)
-        self.simulator.go(WAIT_TIME)
-
-        client.srp_client_set_host_name('host')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service('my-service', '_ipps._tcp', 12345)
-
-        self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-        self.simulator.go(WAIT_TIME)
-
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertTrue(server1.has_ipaddr(client.srp_client_get_server_address()))
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-        # Enable server2 and server3 and check that server1 is still used.
-
-        server2.srp_server_set_enabled(True)
-        server3.srp_server_set_enabled(True)
-        self.simulator.go(WAIT_TIME)
-        self.assertTrue(server1.has_ipaddr(client.srp_client_get_server_address()))
-
-        # Stop and restart the client (multiple times) and verify that
-        # server1 is always picked.
-
-        for iter in range(0, MAX_ITER):
-            client.srp_client_stop()
-            client.srp_client_enable_auto_start_mode()
-            self.simulator.go(WAIT_TIME)
-            self.assertTrue(server1.has_ipaddr(client.srp_client_get_server_address()))
-            self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-        # Stop the client, then stop server1 and restart client and
-        # verify that server1 is no longer used.
-
-        client.srp_client_stop()
-        server1.srp_server_set_enabled(False)
-        self.simulator.go(WAIT_TIME)
-
-        client.srp_client_enable_auto_start_mode()
-        self.simulator.go(WAIT_TIME)
-        server_address = client.srp_client_get_server_address()
-        self.assertFalse(server1.has_ipaddr(server_address))
-        self.assertTrue(server2.has_ipaddr(server_address) or server3.has_ipaddr(server_address))
-        self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-        # Start back server1, then restart client and verify that now we remain
-        # with the new saved server info.
-
-        server1.srp_server_set_enabled(True)
-        self.simulator.go(WAIT_TIME)
-
-        for iter in range(0, MAX_ITER):
-            client.srp_client_stop()
-            self.simulator.go(WAIT_TIME)
-            client.srp_client_enable_auto_start_mode()
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(client.srp_client_get_server_address(), server_address)
-            self.assertEqual(client.srp_client_get_host_state(), 'Registered')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_lease.py b/tests/scripts/thread-cert/test_srp_lease.py
deleted file mode 100755
index fa5864f..0000000
--- a/tests/scripts/thread-cert/test_srp_lease.py
+++ /dev/null
@@ -1,240 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies the SRP server and client properly handle SRP host
-#   and service instance lease.
-#
-# Topology:
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-
-SERVER = 1
-CLIENT = 2
-LEASE = 10  # Seconds
-KEY_LEASE = 20  # Seconds
-
-
-class SrpRegisterSingleService(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        #
-        # 0. Start the server and client devices.
-        #
-
-        server.srp_server_set_enabled(True)
-        server.srp_server_set_lease_range(LEASE, LEASE, KEY_LEASE, KEY_LEASE)
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-        self.simulator.go(5)
-
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        #
-        # 1. Register a single service and verify that it works.
-        #
-
-        self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-
-        client.srp_client_set_host_name('my-host')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service('my-service', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        self.check_host_and_service(server, client)
-
-        #
-        # 2. Stop the client and wait for the service instance LEASE to expire.
-        #
-
-        client.srp_client_stop()
-        self.simulator.go(LEASE + 1)
-
-        # The SRP server should remove the host and service but retain their names
-        # since the the KEY LEASE hasn't expired yet.
-        self.assertEqual(server.srp_server_get_host('my-host')['deleted'], 'true')
-        self.assertEqual(server.srp_server_get_service('my-service', '_ipps._tcp')['deleted'], 'true')
-
-        # Start the client again, the same service should be successfully registered.
-        client.srp_client_enable_auto_start_mode()
-        self.simulator.go(15)
-
-        self.check_host_and_service(server, client)
-
-        #
-        # 3. Stop the client and wait for the KEY LEASE to expire.
-        #    The host and service instance should be fully removed by the SRP server.
-        #
-
-        client.srp_client_stop()
-        self.simulator.go(KEY_LEASE + 1)
-
-        # The host and service are expected to be fully removed.
-        self.assertEqual(len(server.srp_server_get_hosts()), 0)
-        self.assertEqual(len(server.srp_server_get_services()), 0)
-
-        # Start the client again, the same service should be successfully registered.
-        client.srp_client_enable_auto_start_mode()
-        self.simulator.go(15)
-
-        self.check_host_and_service(server, client)
-
-        #
-        # 4. Clear the first service, lengthen the lease time and register a second service.
-        #    Verify that the lease time of the first service is not affected by new SRP
-        #    registrations.
-        #
-
-        client.srp_client_clear_service('my-service', '_ipps._tcp')
-        server.srp_server_set_lease_range(50 * LEASE, 50 * LEASE, 50 * KEY_LEASE, 50 * KEY_LEASE)
-        client.srp_client_add_service('my-service2', '_ipps._tcp', 12345)
-
-        # Wait for the first service to expire.
-        self.simulator.go(LEASE + 2)
-        self.assertEqual(server.srp_server_get_service('my-service', '_ipps._tcp')['deleted'], 'true')
-
-        # Wait for the first service to be fully removed.
-        self.simulator.go(KEY_LEASE - LEASE + 2)
-        self.assertEqual(len(server.srp_server_get_services()), 1)
-        self.assertEqual(len(server.srp_server_get_hosts()), 1)
-
-        #
-        # 5. Clear the second service, shorten the lease time and register a third service.
-        #    Verify that the lease time of the second service is not affected by new SRP
-        #    registrations.
-        #
-
-        client.srp_client_clear_service('my-service2', '_ipps._tcp')
-        server.srp_server_set_lease_range(LEASE, LEASE, KEY_LEASE, KEY_LEASE)
-        client.srp_client_add_service('my-service3', '_ipps._tcp', 12345)
-
-        # The second service has lease time of 50 * LEASE and should not expire.
-        self.simulator.go(LEASE + 2)
-        self.assertEqual(server.srp_server_get_service('my-service2', '_ipps._tcp')['deleted'], 'false')
-
-        # The second service has key-lease time of 50 * KEY_LEASE and should not expire.
-        self.simulator.go(KEY_LEASE - LEASE + 2)
-        self.assertEqual(len(server.srp_server_get_services()), 2)
-        self.assertEqual(len(server.srp_server_get_hosts()), 1)
-
-        #
-        # 6. Clear the third service. The host and services should expire in lease time.
-        #    Verify that the second service and the third service are removed when their host
-        #    expires.
-        #
-        client.srp_client_clear_service('my-service3', '_ipps._tcp')
-        self.simulator.go(LEASE + 2)
-        self.assertEqual(len(server.srp_server_get_services()), 2)
-        self.assertEqual(server.srp_server_get_service('my-service2', '_ipps._tcp')['deleted'], 'true')
-        self.assertEqual(server.srp_server_get_service('my-service3', '_ipps._tcp')['deleted'], 'true')
-        self.assertEqual(len(server.srp_server_get_hosts()), 1)
-        self.assertEqual(server.srp_server_get_host('my-host')['deleted'], 'true')
-
-    def check_host_and_service(self, server, client):
-        """Check that we have properly registered host and service instance.
-        """
-
-        client_services = client.srp_client_get_services()
-        print(client_services)
-        self.assertEqual(len(client_services), 1)
-        client_service = client_services[0]
-
-        # Verify that the client possesses correct service resources.
-        self.assertEqual(client_service['instance'], 'my-service')
-        self.assertEqual(client_service['name'], '_ipps._tcp')
-        self.assertEqual(int(client_service['port']), 12345)
-        self.assertEqual(int(client_service['priority']), 0)
-        self.assertEqual(int(client_service['weight']), 0)
-
-        # Verify that the client received a SUCCESS response for the server.
-        self.assertEqual(client_service['state'], 'Registered')
-
-        # Wait for a KEY LEASE period to make sure that the client has refreshed
-        # the host and service instance.
-        self.simulator.go(KEY_LEASE + 1)
-
-        server_services = server.srp_server_get_services()
-        print(server_services)
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-
-        # Verify that the server accepted the SRP registration and stored
-        # the same service resources.
-        self.assertEqual(server_service['deleted'], 'false')
-        self.assertEqual(server_service['instance'], client_service['instance'])
-        self.assertEqual(server_service['name'], client_service['name'])
-        self.assertEqual(int(server_service['port']), int(client_service['port']))
-        self.assertEqual(int(server_service['priority']), int(client_service['priority']))
-        self.assertEqual(int(server_service['weight']), int(client_service['weight']))
-        self.assertEqual(server_service['host'], 'my-host')
-
-        server_hosts = server.srp_server_get_hosts()
-        print(server_hosts)
-        self.assertEqual(len(server_hosts), 1)
-        server_host = server_hosts[0]
-
-        self.assertEqual(server_host['deleted'], 'false')
-        self.assertEqual(server_host['fullname'], server_service['host_fullname'])
-        self.assertEqual(len(server_host['addresses']), 1)
-        self.assertEqual(ipaddress.ip_address(server_host['addresses'][0]), ipaddress.ip_address('2001::1'))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_many_services_mtu_check.py b/tests/scripts/thread-cert/test_srp_many_services_mtu_check.py
deleted file mode 100755
index 37221c5..0000000
--- a/tests/scripts/thread-cert/test_srp_many_services_mtu_check.py
+++ /dev/null
@@ -1,141 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test verifies SRP client behavior when there are many services
-#   causing SRP Update message going over the IPv6 MTU size. In such
-#   a case the SRP client attempts to register a single service at
-#   a time.
-#
-# Topology:
-#
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-
-SERVER = 1
-CLIENT = 2
-
-
-class SrpManyServicesMtuCheck(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        # Start the server & client devices.
-
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        server.srp_server_set_enabled(True)
-        client.srp_client_enable_auto_start_mode()
-        self.simulator.go(15)
-
-        # Register 8 services with long name, 6 sub-types and long txt record.
-        # The 8 services won't be fit in a single MTU (1280 bytes) UDP message
-        # SRP Client would then register services one by one.
-
-        num_services = 8
-
-        client.srp_client_set_host_name('hosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthos')
-        client.srp_client_set_host_address('2001::1')
-
-        txt_info = ["xyz=987654321", "uvw=abcdefghijklmnopqrstu", "qwp=564321abcdefghij"]
-
-        for num in range(num_services):
-            name = chr(ord('a') + num) * 63
-            client.srp_client_add_service(
-                name,
-                '_longlongsrvname._udp,_subtype1,_subtype2,_subtype3,_subtype4,_subtype5,_subtype6',
-                1977,
-                txt_entries=txt_info)
-
-        self.simulator.go(10)
-
-        # Make sure all services are successfully registered
-        # (from both client and server perspectives).
-
-        client_services = client.srp_client_get_services()
-        self.assertEqual(len(client_services), num_services)
-
-        for service in client_services:
-            self.assertEqual(service['state'], 'Registered')
-
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), num_services)
-
-        # Remove all 8 services.
-
-        for num in range(num_services):
-            name = chr(ord('a') + num) * 63
-            client.srp_client_remove_service(name, '_longlongsrvname._udp')
-
-        self.simulator.go(10)
-
-        client_services = client.srp_client_get_services()
-        self.assertEqual(len(client_services), 0)
-
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), num_services)
-
-        for service in server_services:
-            self.assertEqual(service['deleted'], 'true')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_name_conflicts.py b/tests/scripts/thread-cert/test_srp_name_conflicts.py
deleted file mode 100755
index 4f41258..0000000
--- a/tests/scripts/thread-cert/test_srp_name_conflicts.py
+++ /dev/null
@@ -1,305 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies if the SRP server can handle name conflicts correctly.
-#
-# Topology:
-#            LEADER (SRP server)
-#           /      \
-#          /        \
-#         /          \
-#     ROUTER1      ROUTER2
-#
-
-SERVER = 1
-CLIENT1 = 2
-CLIENT2 = 3
-
-
-class SrpNameConflicts(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-        CLIENT1: {
-            'name': 'SRP_CLIENT1',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-        CLIENT2: {
-            'name': 'SRP_CLIENT2',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client_1 = self.nodes[CLIENT1]
-        client_2 = self.nodes[CLIENT2]
-
-        #
-        # 0. Start the server & client devices.
-        #
-
-        server.srp_server_set_enabled(True)
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-        self.simulator.go(5)
-
-        client_1.srp_server_set_enabled(False)
-        client_1.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client_1.get_state(), 'router')
-
-        client_2.srp_server_set_enabled(False)
-        client_2.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client_2.get_state(), 'router')
-
-        #
-        # 1. Register a single service and verify that it works.
-        #
-
-        self.assertEqual(client_1.srp_client_get_auto_start_mode(), 'Enabled')
-
-        client_1.srp_client_set_host_name('my-host-1')
-        client_1.srp_client_set_host_address('2001::1')
-        client_1.srp_client_add_service('my-service-1', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        # Verify that the client possesses correct service resources.
-        client_1_service = client_1.srp_client_get_services()[0]
-        self.assertEqual(client_1_service['instance'], 'my-service-1')
-        self.assertEqual(client_1_service['name'], '_ipps._tcp')
-        self.assertEqual(int(client_1_service['port']), 12345)
-        self.assertEqual(int(client_1_service['priority']), 0)
-        self.assertEqual(int(client_1_service['weight']), 0)
-
-        # Verify that the client receives a SUCCESS response for the server.
-        self.assertEqual(client_1_service['state'], 'Registered')
-
-        # Verify that the server accepts the SRP registration and stored
-        # the same service resources.
-        server_service = server.srp_server_get_services()[0]
-        self.assertEqual(server_service['deleted'], 'false')
-        self.assertEqual(server_service['instance'], client_1_service['instance'])
-        self.assertEqual(server_service['name'], client_1_service['name'])
-        self.assertEqual(int(server_service['port']), int(client_1_service['port']))
-        self.assertEqual(int(server_service['priority']), int(client_1_service['priority']))
-        self.assertEqual(int(server_service['weight']), int(client_1_service['weight']))
-        self.assertEqual(server_service['host'], 'my-host-1')
-
-        server_host = server.srp_server_get_hosts()[0]
-        self.assertEqual(server_host['deleted'], 'false')
-        self.assertEqual(server_host['fullname'], server_service['host_fullname'])
-        self.assertEqual(len(server_host['addresses']), 1)
-        self.assertEqual(ipaddress.ip_address(server_host['addresses'][0]), ipaddress.ip_address('2001::1'))
-
-        #
-        # 2. Register with the same host name from the second client and it should fail.
-        #
-
-        self.assertEqual(client_2.srp_client_get_auto_start_mode(), 'Enabled')
-
-        client_2.srp_client_set_host_name('my-host-1')
-        client_2.srp_client_set_host_address('2001::2')
-        client_2.srp_client_add_service('my-service-2', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        # It is expected that the registration will be rejected.
-        client_2_service = client_2.srp_client_get_services()[0]
-        self.assertNotEqual(client_2_service['state'], 'Registered')
-        self.assertNotEqual(client_2.srp_client_get_host_state(), 'Registered')
-
-        self.assertEqual(len(server.srp_server_get_services()), 1)
-        self.assertEqual(len(server.srp_server_get_hosts()), 1)
-
-        client_2.srp_client_clear_host()
-        client_2.srp_client_stop()
-
-        #
-        # 3. Register with the same service name from the second client and it should fail.
-        #
-
-        client_2.srp_client_enable_auto_start_mode()
-        client_2.srp_client_set_host_name('my-host-2')
-        client_2.srp_client_set_host_address('2001::2')
-        client_2.srp_client_add_service('my-service-1', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        # It is expected that the registration will be rejected.
-        client_2_service = client_2.srp_client_get_services()[0]
-        self.assertNotEqual(client_2_service['state'], 'Registered')
-        self.assertNotEqual(client_2.srp_client_get_host_state(), 'Registered')
-
-        self.assertEqual(len(server.srp_server_get_services()), 1)
-        self.assertEqual(len(server.srp_server_get_hosts()), 1)
-
-        client_2.srp_client_clear_host()
-        client_2.srp_client_stop()
-
-        #
-        # 4. Register the same service instance label with a different service name
-        # from the second client and it should pass.
-        #
-
-        client_2.srp_client_enable_auto_start_mode()
-        client_2.srp_client_set_host_name('my-host-2')
-        client_2.srp_client_set_host_address('2001::2')
-        client_2.srp_client_add_service('my-service-1', '_ipps2._tcp', 12345)
-        self.simulator.go(2)
-
-        # It is expected that the registration will be accepted.
-        client_2_service = client_2.srp_client_get_services()[0]
-        self.assertEqual(client_2_service['state'], 'Registered')
-        self.assertEqual(client_2.srp_client_get_host_state(), 'Registered')
-
-        self.assertEqual(len(server.srp_server_get_services()), 2)
-        self.assertEqual(len(server.srp_server_get_hosts()), 2)
-        self.assertEqual(server.srp_server_get_host('my-host-2')['deleted'], 'false')
-        self.assertEqual(server.srp_server_get_service('my-service-1', '_ipps2._tcp')['deleted'], 'false')
-
-        # Remove the host and all services registered on the SRP server.
-        client_2.srp_client_remove_host(remove_key=True)
-        self.simulator.go(2)
-
-        client_2.srp_client_clear_host()
-        client_2.srp_client_stop()
-
-        #
-        # 5. Register with different host & service instance name, it should succeed.
-        #
-
-        client_2.srp_client_enable_auto_start_mode()
-        client_2.srp_client_set_host_name('my-host-2')
-        client_2.srp_client_set_host_address('2001::2')
-        client_2.srp_client_add_service('my-service-2', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        # It is expected that the registration will be accepted.
-        client_2_service = client_2.srp_client_get_services()[0]
-        self.assertEqual(client_2_service['state'], 'Registered')
-        self.assertEqual(client_2.srp_client_get_host_state(), 'Registered')
-
-        self.assertEqual(len(server.srp_server_get_services()), 2)
-        self.assertEqual(len(server.srp_server_get_hosts()), 2)
-        self.assertEqual(server.srp_server_get_host('my-host-2')['deleted'], 'false')
-        self.assertEqual(server.srp_server_get_service('my-service-2', '_ipps._tcp')['deleted'], 'false')
-
-        # Remove the host and all services registered on the SRP server.
-        client_2.srp_client_remove_host(remove_key=True)
-        self.simulator.go(2)
-
-        client_2.srp_client_clear_host()
-        client_2.srp_client_stop()
-
-        #
-        # 6. Register with the same service instance full name before its KEY LEASE expires,
-        #    it is expected to fail.
-        #
-
-        # Remove the service instance from SRP server but retains its name.
-        client_1.srp_client_remove_service('my-service-1', '_ipps._tcp')
-        self.simulator.go(2)
-
-        client_2.srp_client_enable_auto_start_mode()
-        client_2.srp_client_set_host_name('my-host-2')
-        client_2.srp_client_set_host_address('2001::2')
-        client_2.srp_client_add_service('my-service-1', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        # It is expected that the registration will be rejected.
-        client_2_service = client_2.srp_client_get_services()[0]
-        self.assertNotEqual(client_2_service['state'], 'Registered')
-        self.assertNotEqual(client_2.srp_client_get_host_state(), 'Registered')
-
-        # The service 'my-service-1' is removed but its name is retained.
-        # This is why we can see the service record on the SRP server.
-        self.assertEqual(len(server.srp_server_get_services()), 1)
-        self.assertEqual(len(server.srp_server_get_hosts()), 1)
-        self.assertEqual(server.srp_server_get_host('my-host-1')['deleted'], 'false')
-        self.assertEqual(server.srp_server_get_service('my-service-1', '_ipps._tcp')['deleted'], 'true')
-
-        client_2.srp_client_clear_host()
-        client_2.srp_client_stop()
-
-        #
-        # 7. The service instance name can be re-used by another client when
-        #    the service has been permanently removed (the KEY resource is
-        #    removed) from the host.
-        #
-
-        # Client 1 adds back the service, it should success.
-        client_1.srp_client_add_service('my-service-1', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-        self.assertEqual(len(server.srp_server_get_services()), 1)
-        self.assertEqual(len(server.srp_server_get_hosts()), 1)
-        self.assertEqual(server.srp_server_get_host('my-host-1')['deleted'], 'false')
-        self.assertEqual(server.srp_server_get_service('my-service-1', '_ipps._tcp')['deleted'], 'false')
-
-        # Permanently removes the service instance.
-        client_1.srp_client_remove_host(remove_key=True)
-        self.simulator.go(2)
-        self.assertEqual(len(server.srp_server_get_services()), 0)
-        self.assertEqual(len(server.srp_server_get_hosts()), 0)
-
-        # Client 2 registers the same host & service instance name with Client 1.
-        client_2.srp_client_stop()
-        client_2.srp_client_enable_auto_start_mode()
-        client_2.srp_client_clear_host()
-        client_2.srp_client_set_host_name('my-host-1')
-        client_2.srp_client_set_host_address('2001::2')
-        client_2.srp_client_add_service('my-service-1', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        # It is expected that client 2 will success because those names has been
-        # released by client 1.
-        self.assertEqual(len(server.srp_server_get_services()), 1)
-        self.assertEqual(len(server.srp_server_get_hosts()), 1)
-        self.assertEqual(server.srp_server_get_host('my-host-1')['deleted'], 'false')
-        self.assertEqual(server.srp_server_get_service('my-service-1', '_ipps._tcp')['deleted'], 'false')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_register_500_services.py b/tests/scripts/thread-cert/test_srp_register_500_services.py
deleted file mode 100755
index a350f7b..0000000
--- a/tests/scripts/thread-cert/test_srp_register_500_services.py
+++ /dev/null
@@ -1,191 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies SRP function that SRP clients can register up to 500 services to one SRP server.
-#
-# Topology:
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER1 ... ROUTER12 ROUTER13     (SRP clients)
-#       |            |
-#      FED14  ...   FED25               (SRP clients)
-#
-
-NUM_SRP_CLIENTS = 25
-INSTANCE_NUM_PER_CLIENT = 20
-NUM_ROUTERS = (NUM_SRP_CLIENTS // 2) + (NUM_SRP_CLIENTS % 2)
-NUM_FEDS = NUM_SRP_CLIENTS - NUM_ROUTERS
-assert (NUM_FEDS <= NUM_ROUTERS)
-
-SERVER = NUM_SRP_CLIENTS + 1
-CLIENT1 = 1
-
-ROUTER_IDS = tuple(range(1, NUM_ROUTERS + 1))
-FED_IDS = tuple(range(NUM_ROUTERS + 1, NUM_SRP_CLIENTS + 1))
-assert len(FED_IDS) == NUM_FEDS
-CLIENT_IDS = tuple(range(1, NUM_SRP_CLIENTS + 1))
-INSTANCE_IDS = tuple(range(1, INSTANCE_NUM_PER_CLIENT + 1))
-SERVICE_NAME = '_srpclient._tcp'
-SERVICE_PORT = 12345
-
-
-class SrpRegister500Services(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'mode': 'rdn',
-            'allowlist': [],
-        },
-    }
-
-    for nodeid in ROUTER_IDS:
-        TOPOLOGY[nodeid] = {
-            'name': f'SRP_CLIENT_{nodeid}',
-            'mode': 'rdn',
-            'allowlist': [SERVER],
-        }
-        TOPOLOGY[SERVER]['allowlist'].append(nodeid)
-
-    for nodeid in FED_IDS:
-        parentid = nodeid - NUM_ROUTERS
-        TOPOLOGY[nodeid] = {
-            'name': f'SRP_CLIENT_{nodeid}',
-            'mode': 'rdn',
-            'router_eligible': False,
-            'allowlist': [parentid],
-        }
-        TOPOLOGY[parentid]['allowlist'].append(nodeid)
-
-    def test(self):
-        self._test_impl()
-
-    def _test_impl(self):
-        server = self.nodes[SERVER]
-
-        def routers():
-            for nodeid in ROUTER_IDS:
-                yield self.nodes[nodeid]
-
-        def feds():
-            for nodeid in FED_IDS:
-                yield self.nodes[nodeid]
-
-        def clients():
-            for nodeid in CLIENT_IDS:
-                yield self.nodes[nodeid]
-
-        # Start the server & clients.
-        server.srp_server_set_enabled(True)
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-
-        for router in routers():
-            router.srp_server_set_enabled(False)
-            router.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-
-        for router in routers():
-            self.assertEqual(router.get_state(), 'router')
-
-        for fed in feds():
-            fed.srp_server_set_enabled(False)
-            fed.start()
-        self.simulator.go(3)
-
-        for fed in feds():
-            self.assertEqual(fed.get_state(), 'child')
-
-        for clientid in CLIENT_IDS:
-            client = self.nodes[clientid]
-            self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-            client.srp_client_set_host_name(f'client{clientid}')
-            client.srp_client_set_host_address(f'2001::{clientid}')
-
-            for instanceid in INSTANCE_IDS:
-                client.srp_client_add_service(f'client{clientid}_{instanceid}', SERVICE_NAME, SERVICE_PORT)
-
-                if instanceid % 10 == 0 or instanceid == INSTANCE_IDS[-1]:
-                    self._wait_client_all_services_registered(client)
-
-            self._check_client_host_and_services(client, clientid)
-
-        # Make sure all service are registered successfully
-        self._check_server_hosts_and_services(server)
-
-    def _wait_client_all_services_registered(self, client, timeout=60):
-        all_registered = False
-        while timeout > 0:
-            self.simulator.go(3)
-            timeout -= 3
-
-            client_services = client.srp_client_get_services()
-            all_registered = all(service['state'] == 'Registered' for service in client_services)
-            if all_registered:
-                break
-
-        self.assertTrue(all_registered, "Some services are not registered successfully")
-
-    def _check_client_host_and_services(self, client, clientid):
-        client_services = client.srp_client_get_services()
-        self.assertEqual(len(client_services), INSTANCE_NUM_PER_CLIENT)
-        service_instance_names = {f'client{clientid}_{instanceid}' for instanceid in INSTANCE_IDS}
-        for service in client_services:
-            self.assertEqual(service['name'], SERVICE_NAME)
-            self.assertEqual(service['state'], 'Registered')
-            self.assertEqual(int(service['port']), SERVICE_PORT)
-            self.assertIn(service['instance'], service_instance_names)
-            service_instance_names.discard(service['instance'])
-
-    def _check_server_hosts_and_services(self, server):
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), NUM_SRP_CLIENTS * INSTANCE_NUM_PER_CLIENT)
-
-        service_instance_names = {
-            f'client{clientid}_{instanceid}' for clientid in CLIENT_IDS for instanceid in INSTANCE_IDS
-        }
-
-        for service in server_services:
-            self.assertEqual(service['name'], SERVICE_NAME)
-            self.assertIn(service['instance'], service_instance_names)
-            service_instance_names.discard(service['instance'])
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_register_services_diff_lease.py b/tests/scripts/thread-cert/test_srp_register_services_diff_lease.py
deleted file mode 100755
index 3399dd3..0000000
--- a/tests/scripts/thread-cert/test_srp_register_services_diff_lease.py
+++ /dev/null
@@ -1,481 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test verifies the SRP client and server behavior when services
-#   with different lease (and/or key lease) intervals are registered.
-#
-# Topology:
-#
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-
-SERVER = 1
-CLIENT = 2
-
-
-class SrpRegisterServicesDiffLease(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Start the server and client.
-
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        server.srp_server_set_enabled(True)
-        client.srp_client_enable_auto_start_mode()
-
-        self.simulator.go(15)
-
-        client.srp_client_set_host_name('host')
-        client.srp_client_enable_auto_host_address()
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Add a service with specific lease and key lease and verify that
-        # it is successfully registered and seen with same lease/key-lease
-        # on server.
-
-        client.srp_client_add_service('ins1', '_test._udp', 1111, lease=60, key_lease=800)
-
-        self.simulator.go(5)
-
-        self.check_services_on_client(client, 1)
-        services = server.srp_server_get_services()
-        self.assertEqual(len(services), 1)
-        service = services[0]
-        self.assertEqual(service['fullname'], 'ins1._test._udp.default.service.arpa.')
-        self.assertEqual(service['deleted'], 'false')
-        self.assertEqual(int(service['ttl']), 60)
-        self.assertEqual(int(service['lease']), 60)
-        self.assertEqual(int(service['key-lease']), 800)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Register two more services with different lease intervals.
-
-        client.srp_client_add_service('ins2', '_test._udp', 2222, lease=30, key_lease=200)
-        client.srp_client_add_service('ins3', '_test._udp', 3333, lease=100, key_lease=1000)
-
-        self.simulator.go(10)
-
-        self.check_services_on_client(client, 3)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 3)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 60)
-                self.assertEqual(int(service['lease']), 60)
-                self.assertEqual(int(service['key-lease']), 800)
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 30)
-                self.assertEqual(int(service['lease']), 30)
-                self.assertEqual(int(service['key-lease']), 200)
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 100)
-                self.assertEqual(int(service['lease']), 100)
-                self.assertEqual(int(service['key-lease']), 1000)
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Wait for longest lease time to validate that all services renew their
-        # lease successfully.
-
-        self.simulator.go(105)
-
-        self.check_services_on_client(client, 3)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 3)
-        for service in server_services:
-            self.assertEqual(service['deleted'], 'false')
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Remove two services.
-
-        client.srp_client_remove_service('ins2', '_test._udp')
-        client.srp_client_remove_service('ins3', '_test._udp')
-
-        self.simulator.go(10)
-
-        self.check_services_on_client(client, 1)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 3)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 60)
-                self.assertEqual(int(service['lease']), 60)
-                self.assertEqual(int(service['key-lease']), 800)
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Wait for longer than key-lease of `ins2` service and check that it is
-        # removed on server.
-
-        self.simulator.go(201)
-
-        self.check_services_on_client(client, 1)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 2)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 60)
-                self.assertEqual(int(service['lease']), 60)
-                self.assertEqual(int(service['key-lease']), 800)
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Add both services again now with same lease intervals.
-
-        client.srp_client_add_service('ins2', '_test._udp', 2222, lease=30, key_lease=100)
-        client.srp_client_add_service('ins3', '_test._udp', 3333, lease=30, key_lease=100)
-
-        self.simulator.go(10)
-
-        self.check_services_on_client(client, 3)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 3)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 60)
-                self.assertEqual(int(service['lease']), 60)
-                self.assertEqual(int(service['key-lease']), 800)
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 30)
-                self.assertEqual(int(service['lease']), 30)
-                self.assertEqual(int(service['key-lease']), 100)
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 30)
-                self.assertEqual(int(service['lease']), 30)
-                self.assertEqual(int(service['key-lease']), 100)
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Remove `ins1` while adding a new service with same key-lease as
-        # `ins1` but different lease interval.
-
-        client.srp_client_remove_service('ins1', '_test._udp')
-        client.srp_client_add_service('ins4', '_test._udp', 4444, lease=90, key_lease=800)
-
-        self.simulator.go(5)
-
-        self.check_services_on_client(client, 3)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 4)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 30)
-                self.assertEqual(int(service['lease']), 30)
-                self.assertEqual(int(service['key-lease']), 100)
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 30)
-                self.assertEqual(int(service['lease']), 30)
-                self.assertEqual(int(service['key-lease']), 100)
-            elif service['fullname'] == 'ins4._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 90)
-                self.assertEqual(int(service['lease']), 90)
-                self.assertEqual(int(service['key-lease']), 800)
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Remove two services `ins2` and `ins3` (they now have same key lease).
-
-        client.srp_client_remove_service('ins2', '_test._udp')
-        client.srp_client_remove_service('ins3', '_test._udp')
-
-        self.simulator.go(10)
-
-        self.check_services_on_client(client, 1)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 4)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            elif service['fullname'] == 'ins4._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 90)
-                self.assertEqual(int(service['lease']), 90)
-                self.assertEqual(int(service['key-lease']), 800)
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Add `ins1` with key-lease smaller than lease and check that
-        # client handles this properly (uses the lease value for
-        # key-lease).
-
-        client.srp_client_add_service('ins1', '_test._udp', 1111, lease=100, key_lease=90)
-
-        self.simulator.go(10)
-
-        self.check_services_on_client(client, 2)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 4)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 100)
-                self.assertEqual(int(service['lease']), 100)
-                self.assertEqual(int(service['key-lease']), 100)
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'true')
-            elif service['fullname'] == 'ins4._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 90)
-                self.assertEqual(int(service['lease']), 90)
-                self.assertEqual(int(service['key-lease']), 800)
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Change default lease and key-lease intervals on client.
-
-        client.srp_client_set_lease_interval(40)
-        self.assertEqual(client.srp_client_get_lease_interval(), 40)
-
-        client.srp_client_set_key_lease_interval(330)
-        self.assertEqual(client.srp_client_get_key_lease_interval(), 330)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Add `ins2` and `ins3`. `ins2` specifies the key-lease explicitly but
-        # leaves lease as default. `ins3` does the opposite.
-
-        client.srp_client_add_service('ins2', '_test._udp', 2222, key_lease=330)
-        client.srp_client_add_service('ins3', '_test._udp', 3333, lease=40)
-
-        self.simulator.go(10)
-
-        self.check_services_on_client(client, 4)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 4)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 100)
-                self.assertEqual(int(service['lease']), 100)
-                self.assertEqual(int(service['key-lease']), 100)
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 40)
-                self.assertEqual(int(service['lease']), 40)
-                self.assertEqual(int(service['key-lease']), 330)
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 40)
-                self.assertEqual(int(service['lease']), 40)
-                self.assertEqual(int(service['key-lease']), 330)
-            elif service['fullname'] == 'ins4._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 90)
-                self.assertEqual(int(service['lease']), 90)
-                self.assertEqual(int(service['key-lease']), 800)
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Change the default lease to 50 and wait for long enough for `ins2`
-        # and `ins3` to do lease refresh. Validate that `ins2` now requests
-        # new default lease of 50 while `ins3` should stay as before.
-
-        client.srp_client_set_lease_interval(50)
-        self.assertEqual(client.srp_client_get_lease_interval(), 50)
-
-        self.simulator.go(45)
-
-        self.check_services_on_client(client, 4)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 4)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 100)
-                self.assertEqual(int(service['lease']), 100)
-                self.assertEqual(int(service['key-lease']), 100)
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 50)
-                self.assertEqual(int(service['lease']), 50)
-                self.assertEqual(int(service['key-lease']), 330)
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 40)
-                self.assertEqual(int(service['lease']), 40)
-                self.assertEqual(int(service['key-lease']), 330)
-            elif service['fullname'] == 'ins4._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 90)
-                self.assertEqual(int(service['lease']), 90)
-                self.assertEqual(int(service['key-lease']), 800)
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Change the default key lease to 30. `ins3` should adopt this but
-        # since it is shorter than its explicitly specified lease the
-        # client should use same value for both lease and key-lease.
-
-        client.srp_client_set_key_lease_interval(35)
-        self.assertEqual(client.srp_client_get_key_lease_interval(), 35)
-
-        self.simulator.go(45)
-
-        self.check_services_on_client(client, 4)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 4)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 100)
-                self.assertEqual(int(service['lease']), 100)
-                self.assertEqual(int(service['key-lease']), 100)
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 50)
-                self.assertEqual(int(service['lease']), 50)
-                self.assertEqual(int(service['key-lease']), 330)
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 40)
-                self.assertEqual(int(service['lease']), 40)
-                self.assertEqual(int(service['key-lease']), 40)
-            elif service['fullname'] == 'ins4._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 90)
-                self.assertEqual(int(service['lease']), 90)
-                self.assertEqual(int(service['key-lease']), 800)
-            else:
-                self.assertTrue(False)
-
-        #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-        # Change the requested TTL. Wait for long enough for all
-        # services to refresh and check that the new TTL is correctly
-        # requested by the client (when it is not larger than
-        # service lease).
-
-        client.srp_client_set_ttl(65)
-        self.assertEqual(client.srp_client_get_ttl(), 65)
-
-        self.simulator.go(110)
-
-        self.check_services_on_client(client, 4)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 4)
-        for service in server_services:
-            if service['fullname'] == 'ins1._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 65)
-                self.assertEqual(int(service['lease']), 100)
-                self.assertEqual(int(service['key-lease']), 100)
-            elif service['fullname'] == 'ins2._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 50)
-                self.assertEqual(int(service['lease']), 50)
-                self.assertEqual(int(service['key-lease']), 330)
-            elif service['fullname'] == 'ins3._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 40)
-                self.assertEqual(int(service['lease']), 40)
-                self.assertEqual(int(service['key-lease']), 40)
-            elif service['fullname'] == 'ins4._test._udp.default.service.arpa.':
-                self.assertEqual(service['deleted'], 'false')
-                self.assertEqual(int(service['ttl']), 65)
-                self.assertEqual(int(service['lease']), 90)
-                self.assertEqual(int(service['key-lease']), 800)
-            else:
-                self.assertTrue(False)
-
-    def check_services_on_client(self, client, expected_num_services):
-        services = client.srp_client_get_services()
-        self.assertEqual(len(services), expected_num_services)
-        for service in client.srp_client_get_services():
-            self.assertIn(service['state'], ['Registered', 'ToRefresh', 'Refreshing'])
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_register_single_service.py b/tests/scripts/thread-cert/test_srp_register_single_service.py
deleted file mode 100755
index f9a43ee..0000000
--- a/tests/scripts/thread-cert/test_srp_register_single_service.py
+++ /dev/null
@@ -1,221 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies basic SRP function that a service can be registered to
-#   and unregistered from the SRP server.
-#
-# Topology:
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-
-SERVER = 1
-CLIENT = 2
-
-
-class SrpRegisterSingleService(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'network_key': '00112233445566778899aabbccddeeff',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        #
-        # 0. Start the server & client devices.
-        #
-
-        server.srp_server_set_enabled(True)
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-        self.simulator.go(5)
-
-        client.srp_server_set_enabled(False)
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        #
-        # 1. Register a single service and verify that it works.
-        #
-
-        self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-
-        client.srp_client_set_host_name('my-host')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service('my-service', '_ipps._tcp', 12345, 0, 0, ['abc', 'def=', 'xyz=XYZ'])
-        self.simulator.go(2)
-
-        self.check_host_and_service(server, client, '2001::1')
-
-        #
-        # 2. Unregister a service but retain the name. The service name should be
-        #    retained on the server.
-        #
-
-        client.srp_client_remove_service('my-service', '_ipps._tcp')
-        self.simulator.go(2)
-
-        client_services = client.srp_client_get_services()
-        print(client_services)
-        self.assertEqual(len(client_services), 0)
-
-        server_services = server.srp_server_get_services()
-        print(server_services)
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-
-        # Verify that the service has been successfully removed on the server side.
-        self.assertEqual(server_service['deleted'], 'true')
-
-        server_hosts = server.srp_server_get_hosts()
-        print(server_hosts)
-        self.assertEqual(len(server_hosts), 1)
-        server_host = server_hosts[0]
-
-        # The registered host should not be touched.
-        self.assertEqual(server_host['deleted'], 'false')
-        self.assertEqual(server_host['name'], 'my-host')
-        self.assertEqual(len(server_host['addresses']), 1)
-        self.assertEqual(ipaddress.ip_address(server_host['addresses'][0]), ipaddress.ip_address('2001::1'))
-
-        #
-        # 3. Register the same service again. It should succeed and the name should be
-        #    reused.
-        #
-
-        client.srp_client_add_service('my-service', '_ipps._tcp', 12345, 0, 0, ['abc', 'def=', 'xyz=XYZ'])
-        self.simulator.go(2)
-
-        self.check_host_and_service(server, client, '2001::1')
-
-        #
-        # 4. Update the SRP host address and make sure it will succeed.
-        #
-
-        client.srp_client_set_host_address('2001::2')
-        self.simulator.go(2)
-
-        self.check_host_and_service(server, client, '2001::2')
-
-        #
-        # 5. Fully remove the host and all its service instances.
-        #
-
-        client.srp_client_remove_host(remove_key=True)
-        self.simulator.go(2)
-
-        client_services = client.srp_client_get_services()
-        print(client_services)
-        self.assertEqual(len(client_services), 0)
-
-        print(client.srp_client_get_host_state())
-
-        server_services = server.srp_server_get_services()
-        print(server_services)
-        self.assertEqual(len(server_services), 0)
-
-        server_hosts = server.srp_server_get_hosts()
-        print(server_hosts)
-        self.assertEqual(len(server_hosts), 0)
-
-    def check_host_and_service(self, server, client, host_addr):
-        """Check that we have properly registered host and service instance.
-        """
-
-        client_services = client.srp_client_get_services()
-        print(client_services)
-        self.assertEqual(len(client_services), 1)
-        client_service = client_services[0]
-
-        # Verify that the client possesses correct service resources.
-        self.assertEqual(client_service['instance'], 'my-service')
-        self.assertEqual(client_service['name'], '_ipps._tcp')
-        self.assertEqual(int(client_service['port']), 12345)
-        self.assertEqual(int(client_service['priority']), 0)
-        self.assertEqual(int(client_service['weight']), 0)
-
-        # Verify that the client received a SUCCESS response for the server.
-        self.assertEqual(client_service['state'], 'Registered')
-
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-
-        # Verify that the server accepted the SRP registration and stores
-        # the same service resources.
-        self.assertEqual(server_service['deleted'], 'false')
-        self.assertEqual(server_service['instance'], client_service['instance'])
-        self.assertEqual(server_service['name'], client_service['name'])
-        self.assertEqual(server_service['subtypes'], '(null)')
-        self.assertEqual(int(server_service['port']), int(client_service['port']))
-        self.assertEqual(int(server_service['priority']), int(client_service['priority']))
-        self.assertEqual(int(server_service['weight']), int(client_service['weight']))
-        # We output value of TXT entry as HEX string.
-        print(server_service['TXT'])
-        self.assertEqual(server_service['TXT'], ['abc', 'def=', 'xyz=58595a'])
-        self.assertEqual(server_service['host'], 'my-host')
-
-        server_hosts = server.srp_server_get_hosts()
-        print(server_hosts)
-        self.assertEqual(len(server_hosts), 1)
-        server_host = server_hosts[0]
-
-        self.assertEqual(server_host['deleted'], 'false')
-        self.assertEqual(server_host['fullname'], server_service['host_fullname'])
-        self.assertEqual(len(server_host['addresses']), 1)
-        self.assertEqual(ipaddress.ip_address(server_host['addresses'][0]), ipaddress.ip_address(host_addr))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_server_anycast_mode.py b/tests/scripts/thread-cert/test_srp_server_anycast_mode.py
deleted file mode 100755
index 7bae2e7..0000000
--- a/tests/scripts/thread-cert/test_srp_server_anycast_mode.py
+++ /dev/null
@@ -1,201 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test covers the SRP server's behavior using different address
-#   modes (unicast or anycast). The address mode indicates how the SRP
-#   server determines its address and port and how this info is
-#   published in the Thread Network Data. When using anycast address
-#   mode, the SRP server and DNS server/resolver will both listen on port
-#   53 and they re-use the same socket. This test verifies the behavior of
-#   both modules in such a situation.
-#
-#
-# Topology:
-#
-#   One leader and two router nodes, all connected. The leader acts as SRP
-#   server and DNS resolver. One router acts as SRP client, and the
-#   other acts as DNS client browsing for registered service names.
-#
-
-SERVER = 1
-CLIENT = 2
-BROWSER = 3
-
-DOMAIN = 'default.service.arpa.'
-HOST = 'host'
-INSTANCE = 'ins1'
-SERVICE = '_srv._udp'
-SERVICE_PORT = 1313
-
-SRP_SERVER_ANYCAST_PORT = 53
-SRP_SERVER_ANYCAST_SEQ_NUM = 17
-DNS_RESOLVER_PORT = 53
-
-THREAD_ENTERPRISE_NUMBER = 44970
-ANYCAST_SERVICE_NUM = 0x5c
-UNICAST_SERVICE_NUM = 0x5d
-
-
-class TestSrpServerAnycastMode(thread_cert.TestCase):
-    SUPPORT_NCP = False
-    USE_MESSAGE_FACTORY = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'mode': 'rdn',
-        },
-        BROWSER: {
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-        browser = self.nodes[BROWSER]
-
-        #-------------------------------------------------------------------
-        # Form the network.
-
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-
-        client.start()
-        browser.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-        self.assertEqual(browser.get_state(), 'router')
-
-        #-------------------------------------------------------------------
-        # Go through the entire test twice, first time using anycast address
-        # mode and second time using unicast address mode.
-
-        for addr_mode in ['anycast', 'unicast']:
-
-            #---------------------------------------------------------------
-            # Set the SRP server address mode and start the SRP server.
-
-            server.srp_server_set_addr_mode(addr_mode)
-
-            if addr_mode == 'anycast':
-                server.srp_server_set_anycast_seq_num(SRP_SERVER_ANYCAST_SEQ_NUM)
-                self.assertEqual(server.srp_server_get_anycast_seq_num(), SRP_SERVER_ANYCAST_SEQ_NUM)
-
-            self.assertEqual(server.srp_server_get_addr_mode(), addr_mode)
-            server.srp_server_set_enabled(True)
-            self.simulator.go(5)
-
-            #---------------------------------------------------------------
-            # Verify the published SRP server info in the Network Data.
-
-            netdata_services = client.get_services()
-            self.assertEqual(len(netdata_services), 1)
-            netdata_service = netdata_services[0]
-
-            self.assertEqual(int(netdata_service[0]), THREAD_ENTERPRISE_NUMBER)
-            data = bytes.fromhex(netdata_service[1])
-            self.assertEqual(netdata_service[3], 's')
-
-            if addr_mode == 'anycast':
-                self.assertTrue(len(data) >= 2)
-                self.assertEqual(data[0], ANYCAST_SERVICE_NUM)
-                self.assertEqual(data[1], SRP_SERVER_ANYCAST_SEQ_NUM)
-            else:
-                self.assertTrue(len(data) >= 1)
-                self.assertEqual(data[0], UNICAST_SERVICE_NUM)
-                self.assertEqual(netdata_service[3], 's')
-
-            #---------------------------------------------------------------
-            # Enable auto-start on SRP client. Verify that it does find the
-            # server and uses the proper address and port number.
-
-            client.srp_client_enable_auto_start_mode()
-            self.simulator.go(15)
-
-            if addr_mode == 'anycast':
-                server_alocs = server.get_ip6_address(config.ADDRESS_TYPE.ALOC)
-                self.assertEqual(client.srp_client_get_state(), 'Enabled')
-                self.assertIn(client.srp_client_get_server_address(), server_alocs)
-                self.assertEqual(client.srp_client_get_server_port(), SRP_SERVER_ANYCAST_PORT)
-            else:
-                self.assertIn(client.srp_client_get_server_address(), server.get_mleid())
-                self.assertEqual(client.srp_client_get_server_port(), server.get_srp_server_port())
-
-            #---------------------------------------------------------------
-            # Add a service on the SRP client and verify its successful
-            # registration with SRP server.
-
-            client.srp_client_set_host_name(HOST)
-            client.srp_client_set_host_address(client.get_mleid())
-            client.srp_client_add_service(INSTANCE, SERVICE, SERVICE_PORT)
-            self.simulator.go(5)
-
-            client_services = client.srp_client_get_services()
-            self.assertEqual(len(client_services), 1)
-            client_service = client_services[0]
-            self.assertEqual(client_service['instance'], INSTANCE)
-            self.assertEqual(client_service['name'], SERVICE)
-            self.assertEqual(int(client_service['port']), SERVICE_PORT)
-            self.assertEqual(client_service['state'], 'Registered')
-
-            #---------------------------------------------------------------
-            # Browse for a matching service name and verify that the registered
-            # service is successfully found. Since there is only one match the
-            # server should include the service info in additional section.
-
-            service_instances = browser.dns_browse(f'{SERVICE}.{DOMAIN}', server.get_mleid(), DNS_RESOLVER_PORT)
-            self.assertEqual({INSTANCE}, set(service_instances.keys()))
-
-            service_instance = service_instances[INSTANCE]
-            self.assertEqual(service_instance['host'], f'{HOST}.{DOMAIN}')
-            self.assertEqual(int(service_instance['port']), SERVICE_PORT)
-            self.assertEqual(service_instance['address'], client.get_mleid())
-
-            #---------------------------------------------------------------
-            # Stop SRP client and server and clear host (and service) on the
-            # client.
-
-            client.srp_client_clear_host()
-            client.srp_client_stop()
-            server.srp_server_set_enabled(False)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_server_reboot_port.py b/tests/scripts/thread-cert/test_srp_server_reboot_port.py
deleted file mode 100755
index d7f0a4c..0000000
--- a/tests/scripts/thread-cert/test_srp_server_reboot_port.py
+++ /dev/null
@@ -1,189 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import logging
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies when an SRP server restarts, it will listen to a UDP port
-#   that wasn't used in the last time.
-#
-# Topology:
-#
-#   LEADER (SRP client) -- ROUTER (SRP server)
-#
-
-CLIENT = 1
-SERVER = 2
-
-REBOOT_TIMES = 25
-
-
-class SrpServerRebootPort(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'mode': 'rdn',
-        },
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        client = self.nodes[CLIENT]
-        server = self.nodes[SERVER]
-
-        #
-        # 0. Start the server & client devices.
-        #
-
-        client.srp_server_set_enabled(False)
-        client.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'leader')
-
-        server.srp_server_set_enabled(True)
-        server.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'router')
-
-        #
-        # 1. Check auto start mode on client and check that server is used.
-        #
-
-        self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-        self.simulator.go(2)
-        self.assertEqual(client.srp_client_get_state(), 'Enabled')
-        self.assertTrue(server.has_ipaddr(client.srp_client_get_server_address()))
-
-        #
-        # 2. Reboot the server without any service registered. The server should
-        # switch to a new port after re-enabling.
-        #
-        old_port = server.get_srp_server_port()
-        server.srp_server_set_enabled(False)
-        self.simulator.go(5)
-        server.srp_server_set_enabled(True)
-        self.simulator.go(5)
-        self.assertNotEqual(old_port, server.get_srp_server_port())
-
-        #
-        # 3. Register a service
-        #
-        client.srp_client_set_host_name('my-host')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service('my-service', '_ipps._tcp', 12345, 0, 0, ['abc', 'def=', 'xyz=XYZ'])
-        self.simulator.go(16)
-        self.check_host_and_service(server, client, '2001::1')
-
-        ports = [server.get_srp_server_port()]
-
-        # Reboot the SRP server several times
-        for i in range(REBOOT_TIMES):
-            #
-            # 4. Disable server and check client is stopped/disabled.
-            #
-            old_port = server.get_srp_server_port()
-            server.srp_server_set_enabled(False)
-            self.simulator.go(5)
-
-            #
-            # 5. Enable server and check client starts again. Verify that the
-            # server is using a different port, and the service have been
-            # re-registered.
-            #
-            server.srp_server_set_enabled(True)
-            self.simulator.go(16)
-            self.assertEqual(client.srp_client_get_state(), 'Enabled')
-            self.assertEqual(client.srp_client_get_server_address(), server.get_mleid())
-            self.assertNotEqual(old_port, server.get_srp_server_port())
-            self.check_host_and_service(server, client, '2001::1')
-            ports.append(server.get_srp_server_port())
-        logging.info(f'ports = {ports}')
-
-    def check_host_and_service(self, server, client, host_addr):
-        # Check that we have properly registered host and service instance.
-        # Originally used in test_srp_register_single_service.py.
-
-        client_services = client.srp_client_get_services()
-        print(client_services)
-        self.assertEqual(len(client_services), 1)
-        client_service = client_services[0]
-
-        # Verify that the client possesses correct service resources.
-        self.assertEqual(client_service['instance'], 'my-service')
-        self.assertEqual(client_service['name'], '_ipps._tcp')
-        self.assertEqual(int(client_service['port']), 12345)
-        self.assertEqual(int(client_service['priority']), 0)
-        self.assertEqual(int(client_service['weight']), 0)
-
-        # Verify that the client received a SUCCESS response for the server.
-        self.assertEqual(client_service['state'], 'Registered')
-
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-
-        # Verify that the server accepted the SRP registration and stores
-        # the same service resources.
-        self.assertEqual(server_service['deleted'], 'false')
-        self.assertEqual(server_service['instance'], client_service['instance'])
-        self.assertEqual(server_service['name'], client_service['name'])
-        self.assertEqual(server_service['subtypes'], '(null)')
-        self.assertEqual(int(server_service['port']), int(client_service['port']))
-        self.assertEqual(int(server_service['priority']), int(client_service['priority']))
-        self.assertEqual(int(server_service['weight']), int(client_service['weight']))
-        # We output value of TXT entry as HEX string.
-        print(server_service['TXT'])
-        self.assertEqual(server_service['TXT'], ['abc', 'def=', 'xyz=58595a'])
-        self.assertEqual(server_service['host'], 'my-host')
-
-        server_hosts = server.srp_server_get_hosts()
-        print(server_hosts)
-        self.assertEqual(len(server_hosts), 1)
-        server_host = server_hosts[0]
-
-        self.assertEqual(server_host['deleted'], 'false')
-        self.assertEqual(server_host['fullname'], server_service['host_fullname'])
-        self.assertEqual(len(server_host['addresses']), 1)
-        self.assertEqual(ipaddress.ip_address(server_host['addresses'][0]), ipaddress.ip_address(host_addr))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_sub_type.py b/tests/scripts/thread-cert/test_srp_sub_type.py
deleted file mode 100755
index bbd8efa..0000000
--- a/tests/scripts/thread-cert/test_srp_sub_type.py
+++ /dev/null
@@ -1,142 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies SRP client and server support for registering sub-type services.
-#
-# Topology:
-#
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-
-SERVER = 1
-CLIENT = 2
-
-
-class SrpSubType(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        # Start the server & client devices.
-
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        server.srp_server_set_enabled(True)
-        client.srp_client_enable_auto_start_mode()
-        self.simulator.go(15)
-
-        # Register a single service with 3 subtypes and verify that it worked.
-
-        client.srp_client_set_host_name('host1')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service('ins1', '_srv._udp,_s1,_s2,_s3', 1977)
-        self.simulator.go(2)
-        self.check_service_on_client_and_server(server, client)
-
-        # Remove the service on client
-
-        client.srp_client_remove_service('ins1', '_srv._udp')
-        self.simulator.go(2)
-
-        client_services = client.srp_client_get_services()
-        self.assertEqual(len(client_services), 0)
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-        self.assertEqual(server_service['fullname'], 'ins1._srv._udp.default.service.arpa.')
-        self.assertEqual(server_service['instance'], 'ins1')
-        self.assertEqual(server_service['name'], '_srv._udp')
-        self.assertEqual(server_service['deleted'], 'true')
-
-        # Register the same service again.
-
-        client.srp_client_add_service('ins1', '_srv._udp,_s1,_s2,_s3', 1977)
-        self.simulator.go(2)
-        self.check_service_on_client_and_server(server, client)
-
-    def check_service_on_client_and_server(self, server, client):
-        # Check the service on client
-        client_services = client.srp_client_get_services()
-        self.assertEqual(len(client_services), 1)
-        client_service = client_services[0]
-        self.assertEqual(client_service['instance'], 'ins1')
-        self.assertEqual(client_service['name'], '_srv._udp,_s1,_s2,_s3')
-        self.assertEqual(int(client_service['port']), 1977)
-        self.assertEqual(int(client_service['priority']), 0)
-        self.assertEqual(int(client_service['weight']), 0)
-        self.assertEqual(client_service['state'], 'Registered')
-
-        # Check the service on server
-        server_services = server.srp_server_get_services()
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-        self.assertEqual(server_service['fullname'], 'ins1._srv._udp.default.service.arpa.')
-        self.assertEqual(server_service['instance'], 'ins1')
-        self.assertEqual(server_service['name'], '_srv._udp')
-        self.assertEqual(server_service['deleted'], 'false')
-        self.assertEqual(set(server_service['subtypes'].split(',')), {'_s1', '_s2', '_s3'})
-        self.assertEqual(int(server_service['port']), 1977)
-        self.assertEqual(int(server_service['priority']), 0)
-        self.assertEqual(int(server_service['weight']), 0)
-        self.assertEqual(server_service['host'], 'host1')
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_srp_ttl.py b/tests/scripts/thread-cert/test_srp_ttl.py
deleted file mode 100755
index 70a6072..0000000
--- a/tests/scripts/thread-cert/test_srp_ttl.py
+++ /dev/null
@@ -1,153 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2022, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import unittest
-
-import command
-import config
-import thread_cert
-
-# Test description:
-#   This test verifies the SRP server and client properly handle SRP host
-#   and service instance TTLs.
-#
-# Topology:
-#     LEADER (SRP server)
-#       |
-#       |
-#     ROUTER (SRP client)
-#
-
-SERVER = 1
-CLIENT = 2
-KEY_LEASE = 240  # Seconds
-
-
-class SrpTtl(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        SERVER: {
-            'name': 'SRP_SERVER',
-            'mode': 'rdn',
-        },
-        CLIENT: {
-            'name': 'SRP_CLIENT',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        server = self.nodes[SERVER]
-        client = self.nodes[CLIENT]
-
-        #
-        # Start the server and client devices.
-        #
-
-        server.srp_server_set_enabled(True)
-        server.srp_server_set_lease_range(120, 240, KEY_LEASE, KEY_LEASE)
-        server.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(server.get_state(), 'leader')
-        self.simulator.go(5)
-
-        client.srp_server_set_enabled(False)
-        client.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(client.get_state(), 'router')
-
-        self.simulator.go(15)
-        self.assertEqual(client.srp_client_get_auto_start_mode(), 'Enabled')
-
-        client.srp_client_set_host_name('my-host')
-        client.srp_client_set_host_address('2001::1')
-        client.srp_client_add_service('my-service', '_ipps._tcp', 12345)
-        self.simulator.go(2)
-
-        #
-        # CLIENT_TTL < TTL_MIN < LEASE_MAX ==> TTL_MIN
-        #
-
-        client.srp_client_set_ttl(100)
-        server.srp_server_set_ttl_range(120, 240)
-        server.srp_server_set_lease_range(120, 240, KEY_LEASE, KEY_LEASE)
-        self.simulator.go(KEY_LEASE)
-        self.check_ttl(120)
-
-        #
-        # TTL_MIN < CLIENT_TTL < TTL_MAX < LEASE_MAX ==> CLIENT_TTL
-        #
-
-        client.srp_client_set_ttl(100)
-        server.srp_server_set_ttl_range(60, 120)
-        server.srp_server_set_lease_range(120, 240, KEY_LEASE, KEY_LEASE)
-        self.simulator.go(KEY_LEASE)
-        self.check_ttl(100)
-
-        #
-        # TTL_MAX < LEASE_MAX < CLIENT_TTL ==> TTL_MAX
-        #
-
-        client.srp_client_set_ttl(240)
-        server.srp_server_set_ttl_range(60, 120)
-        server.srp_server_set_lease_range(120, 240, KEY_LEASE, KEY_LEASE)
-        self.simulator.go(KEY_LEASE)
-        self.check_ttl(120)
-
-        #
-        # LEASE_MAX < TTL_MAX < CLIENT_TTL ==> LEASE_MAX
-        #
-
-        client.srp_client_set_ttl(240)
-        server.srp_server_set_ttl_range(60, 120)
-        server.srp_server_set_lease_range(30, 60, KEY_LEASE, KEY_LEASE)
-        self.simulator.go(KEY_LEASE)
-        self.check_ttl(60)
-
-    def check_ttl(self, ttl):
-        """Check that we have properly registered host and service instance.
-        """
-
-        server = self.nodes[SERVER]
-
-        server_services = server.srp_server_get_services()
-        print(server_services)
-        self.assertEqual(len(server_services), 1)
-        server_service = server_services[0]
-
-        # Verify that the server accepted the SRP registration and stored
-        # the same service resources.
-        self.assertEqual(int(server_service['ttl']), ttl)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/test_zero_len_external_route.py b/tests/scripts/thread-cert/test_zero_len_external_route.py
deleted file mode 100755
index b48dff8..0000000
--- a/tests/scripts/thread-cert/test_zero_len_external_route.py
+++ /dev/null
@@ -1,146 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2021, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-# Test description:
-#
-#   This test verifies the behavior of the network when a zero length external route prefix "::/0" is added.
-#
-# Topology:
-#
-#    leader --- router1
-#       \         /
-#        \       /
-#         router2
-#
-
-LEADER = 1
-ROUTER1 = 2
-ROUTER2 = 3
-
-
-class ZeroLengthExternalRoute(thread_cert.TestCase):
-    USE_MESSAGE_FACTORY = False
-    SUPPORT_NCP = False
-
-    TOPOLOGY = {
-        LEADER: {
-            'name': 'LEADER',
-            'mode': 'rdn',
-        },
-        ROUTER1: {
-            'name': 'ROUTER2',
-            'mode': 'rdn',
-        },
-        ROUTER2: {
-            'name': 'ROUTER2',
-            'mode': 'rdn',
-        },
-    }
-
-    def test(self):
-        leader = self.nodes[LEADER]
-        router1 = self.nodes[ROUTER1]
-        router2 = self.nodes[ROUTER2]
-
-        # Start the nodes and form the network.
-
-        leader.start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(leader.get_state(), 'leader')
-
-        router1.start()
-        router2.start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(router1.get_state(), 'router')
-        self.assertEqual(router2.get_state(), 'router')
-
-        # Add an on-mesh prefix with SLAAC on router2 (without default
-        # route flag).
-
-        router2.add_prefix('fd00:1234::/64', 'paos')
-        router2.register_netdata()
-
-        # Add a zero length external route on router1. Also add an IPv6
-        # address on router1.
-
-        router1.add_route('::/0', stable=True)
-        router1.add_ipaddr('fd00:abcd::1')
-        router1.register_netdata()
-        self.simulator.go(5)
-
-        # Ping from leader the address added on router1. The zero
-        # length external route should ensure the message is routed
-        # to router1.
-
-        self.assertTrue(leader.ping('fd00:abcd::1'))
-
-        # Change the on-mesh prefix on router2 to now also have
-        # the default route flag.
-
-        router2.remove_prefix('fd00:1234::/64')
-        router2.add_prefix('fd00:5678::/64', 'paros')
-        router2.register_netdata()
-        self.simulator.go(5)
-
-        # Again ping from leader the same address. The explicit
-        # external route (even with zero length) on router1 should
-        # still be preferred over the default route flag of the
-        # on-mesh prefix from router2.
-
-        self.assertTrue(leader.ping('fd00:abcd::1'))
-
-        # Remove the external route on router1.
-
-        router1.remove_route("::/0")
-        router1.register_netdata()
-        self.simulator.go(5)
-
-        # Now the ping should fail since the message would be routed
-        # to router2 (due to its on-mesh prefix with default route
-        # flag).
-
-        self.assertFalse(leader.ping('fd00:abcd::1'))
-
-        # Remove the address from router1 and add it on router2 and
-        # ping it again from leader to verify that the message is
-        # being routed to router2.
-
-        router1.del_ipaddr('fd00:abcd::1')
-        router2.add_ipaddr('fd00:abcd::1')
-        self.simulator.go(5)
-        self.assertTrue(leader.ping('fd00:abcd::1'))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/thread_cert.py b/tests/scripts/thread-cert/thread_cert.py
index 05bfc0c..07c0941 100644
--- a/tests/scripts/thread-cert/thread_cert.py
+++ b/tests/scripts/thread-cert/thread_cert.py
@@ -410,19 +410,6 @@
 
             test_info['omrs'][i] = node.get_ip6_address(config.ADDRESS_TYPE.OMR)
 
-    def collect_duas(self):
-        if not self._do_packet_verification:
-            return
-
-        test_info = self._test_info
-        test_info['duas'] = {}
-
-        for i, node in self.nodes.items():
-            if node.is_host:
-                continue
-
-            test_info['duas'][i] = node.get_ip6_address(config.ADDRESS_TYPE.DUA)
-
     def collect_leader_aloc(self, node):
         if not self._do_packet_verification:
             return
@@ -461,7 +448,6 @@
                 'interface': config.BACKBONE_DOCKER_NETWORK_NAME,
                 'prefix': config.BACKBONE_PREFIX,
             },
-            'domain_prefix': config.DOMAIN_PREFIX,
             'env': {
                 'PORT_OFFSET': config.PORT_OFFSET,
             },
diff --git a/tests/scripts/thread-cert/v1_2_router_5_1_1.py b/tests/scripts/thread-cert/v1_2_router_5_1_1.py
deleted file mode 100755
index 146b2b5..0000000
--- a/tests/scripts/thread-cert/v1_2_router_5_1_1.py
+++ /dev/null
@@ -1,153 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2019, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import config
-import thread_cert
-import mle
-import network_layer
-
-LEADER = 1
-ROUTER_1 = 2
-
-
-class Router_5_1_01(thread_cert.TestCase):
-    TOPOLOGY = {
-        LEADER: {
-            'version': '1.2'
-        },
-        ROUTER_1: {
-            'version': '1.2'
-        },
-    }
-    """All nodes are created with default configurations"""
-
-    def test(self):
-        self.nodes[ROUTER_1].set_router_selection_jitter(1)
-
-        self.nodes[LEADER].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER].get_state(), 'leader')
-
-        self.nodes[ROUTER_1].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER_1].get_state(), 'router')
-
-        leader_messages = self.simulator.get_messages_sent_by(LEADER)
-        router_messages = self.simulator.get_messages_sent_by(ROUTER_1)
-
-        # 1 - Leader transmits MLE advertisements
-        msg = leader_messages.next_mle_message(mle.CommandType.ADVERTISEMENT)
-        msg.assertSentWithHopLimit(255)
-        msg.assertSentToDestinationAddress('ff02::1')
-        msg.assertMleMessageContainsTlv(mle.SourceAddress)
-        msg.assertMleMessageContainsTlv(mle.LeaderData)
-        msg.assertMleMessageContainsTlv(mle.Route64)
-
-        # 2 - Router_1 begins attach process by sending a multicast MLE Parent Request
-        msg = router_messages.next_mle_message(mle.CommandType.PARENT_REQUEST)
-        msg.assertSentWithHopLimit(255)
-        msg.assertSentToDestinationAddress('ff02::2')
-        msg.assertMleMessageContainsTlv(mle.Mode)
-        msg.assertMleMessageContainsTlv(mle.Challenge)
-        msg.assertMleMessageContainsTlv(mle.ScanMask)
-        msg.assertMleMessageContainsTlv(mle.Version)
-        self.assertGreaterEqual(msg.get_mle_message_tlv(mle.Version).version, config.THREAD_VERSION_1_2)
-
-        scan_mask_tlv = msg.get_mle_message_tlv(mle.ScanMask)
-        self.assertEqual(1, scan_mask_tlv.router)
-        self.assertEqual(0, scan_mask_tlv.end_device)
-
-        # 3 - Leader sends a MLE Parent Response
-        msg = leader_messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        msg.assertSentToNode(self.nodes[ROUTER_1])
-        msg.assertMleMessageContainsTlv(mle.SourceAddress)
-        msg.assertMleMessageContainsTlv(mle.LeaderData)
-        msg.assertMleMessageContainsTlv(mle.LinkLayerFrameCounter)
-        msg.assertMleMessageContainsOptionalTlv(mle.MleFrameCounter)
-        msg.assertMleMessageContainsTlv(mle.Response)
-        msg.assertMleMessageContainsTlv(mle.Challenge)
-        msg.assertMleMessageContainsTlv(mle.LinkMargin)
-        msg.assertMleMessageContainsTlv(mle.Connectivity)
-        msg.assertMleMessageContainsTlv(mle.Version)
-        self.assertGreaterEqual(msg.get_mle_message_tlv(mle.Version).version, config.THREAD_VERSION_1_2)
-
-        # 4 - Router_1 receives the MLE Parent Response and sends a Child ID Request
-        msg = router_messages.next_mle_message(mle.CommandType.CHILD_ID_REQUEST)
-        msg.assertSentToNode(self.nodes[LEADER])
-        msg.assertMleMessageContainsTlv(mle.Response)
-        msg.assertMleMessageContainsTlv(mle.LinkLayerFrameCounter)
-        msg.assertMleMessageContainsOptionalTlv(mle.MleFrameCounter)
-        msg.assertMleMessageContainsTlv(mle.Mode)
-        msg.assertMleMessageContainsTlv(mle.Timeout)
-        msg.assertMleMessageContainsTlv(mle.Version)
-        msg.assertMleMessageContainsTlv(mle.TlvRequest)
-        msg.assertMleMessageDoesNotContainTlv(mle.AddressRegistration)
-        self.assertGreaterEqual(msg.get_mle_message_tlv(mle.Version).version, config.THREAD_VERSION_1_2)
-
-        # 5 - Leader responds with a Child ID Response
-        msg = leader_messages.next_mle_message(mle.CommandType.CHILD_ID_RESPONSE)
-        msg.assertSentToNode(self.nodes[ROUTER_1])
-        msg.assertMleMessageContainsTlv(mle.SourceAddress)
-        msg.assertMleMessageContainsTlv(mle.LeaderData)
-        msg.assertMleMessageContainsTlv(mle.Address16)
-        msg.assertMleMessageContainsOptionalTlv(mle.NetworkData)
-        msg.assertMleMessageContainsOptionalTlv(mle.Route64)
-        msg.assertMleMessageContainsOptionalTlv(mle.AddressRegistration)
-
-        # 6 - Router_1 sends an Address Solicit Request
-        msg = router_messages.next_coap_message('0.02')
-        msg.assertCoapMessageRequestUriPath('/a/as')
-        msg.assertCoapMessageContainsTlv(network_layer.MacExtendedAddress)
-        msg.assertCoapMessageContainsTlv(network_layer.Status)
-
-        # 7 - Leader sends an Address Solicit Response
-        msg = leader_messages.next_coap_message('2.04')
-        msg.assertCoapMessageContainsTlv(network_layer.Status)
-        msg.assertCoapMessageContainsOptionalTlv(network_layer.RouterMask)
-
-        status_tlv = msg.get_coap_message_tlv(network_layer.Status)
-        self.assertEqual(network_layer.StatusValues.SUCCESS, status_tlv.status)
-
-        # 8 - Router_1 Transmit MLE advertisements
-        msg = router_messages.next_mle_message(mle.CommandType.ADVERTISEMENT)
-        msg.assertSentWithHopLimit(255)
-        msg.assertSentToDestinationAddress('ff02::1')
-        msg.assertMleMessageContainsTlv(mle.SourceAddress)
-        msg.assertMleMessageContainsTlv(mle.LeaderData)
-        msg.assertMleMessageContainsTlv(mle.Route64)
-
-        # 9 - Verify connectivity by sending an ICMPv6 Echo Request to the DUT link local address
-        self.assertTrue(self.nodes[LEADER].ping(self.nodes[ROUTER_1].get_linklocal()))
-        self.assertTrue(self.nodes[ROUTER_1].ping(self.nodes[LEADER].get_linklocal()))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/v1_2_test_backbone_router_service.py b/tests/scripts/thread-cert/v1_2_test_backbone_router_service.py
deleted file mode 100755
index c6d410b..0000000
--- a/tests/scripts/thread-cert/v1_2_test_backbone_router_service.py
+++ /dev/null
@@ -1,255 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2019, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import thread_cert
-import config
-
-LEADER_1_1 = 1
-BBR_1 = 2
-BBR_2 = 3
-
-WAIT_ATTACH = 5
-WAIT_REDUNDANCE = 3
-ROUTER_SELECTION_JITTER = 1
-BBR_REGISTRATION_JITTER = 5
-"""
- Topology
-
- LEADER_1_1 --- BBR_1
-          \        |
-            \      |
-              \    |
-                BBR_2
-
- 1) Bring up Leader_1_1 and then BBR_1, BBR_1 becomes Primary Backbone Router.
- 2) Reset BBR_1, if bring back soon, it could restore the Backbone Router Service
-    from the network, after increasing sequence number, it will reregister its
-    Backbone Router Service to the Leader and become Primary.
- 3) Reset BBR_1, if bring back after it is released in the network, BBR_1 will
-    choose a random sequence number, register its Backbone Router Service to
-    Leader and become Primary.
- 4) Configure BBR_2 with highest sequence number and explicitly trigger SRV_DATA.ntf.
-    BBR_2 would become Primary and BBR_1 would change to Secondary with sequence
-    number increased by 1.
-    a) Check communication via DUA.
- 5) Stop BBR_2, BBR_1 would become Primary after detecting there is no available
-    Backbone Router Service in Thread Network.
- 6) Bring back BBR_2, and it would become Secondary.
-    a) Check the uniqueness of DUA by comparing the one in above 4a).
-    b) Check communication via DUA.
-
-"""
-
-
-class TestBackboneRouterService(thread_cert.TestCase):
-    TOPOLOGY = {
-        LEADER_1_1: {
-            'version': '1.1',
-            'allowlist': [BBR_1, BBR_2],
-        },
-        BBR_1: {
-            'version': '1.2',
-            'allowlist': [LEADER_1_1, BBR_2],
-            'is_bbr': True
-        },
-        BBR_2: {
-            'version': '1.2',
-            'allowlist': [LEADER_1_1, BBR_1],
-            'is_bbr': True
-        },
-    }
-    """All nodes are created with default configurations"""
-
-    def test(self):
-
-        self.nodes[LEADER_1_1].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME * 2)
-        self.assertEqual(self.nodes[LEADER_1_1].get_state(), 'leader')
-        self.simulator.set_lowpan_context(1, config.DOMAIN_PREFIX)
-
-        # 1) First Backbone Router would become the Primary.
-        self.nodes[BBR_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[BBR_1].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-        self.nodes[BBR_1].set_backbone_router(seqno=1)
-        self.nodes[BBR_1].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_1].get_state(), 'router')
-        self.nodes[BBR_1].enable_backbone_router()
-        WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-
-        self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX)
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        # 2) Reset BBR_1 and bring it back soon.
-        # Verify that it restores Primary State with sequence number
-        # increased by 1.
-        self.nodes[BBR_1].reset()
-        self.nodes[BBR_1].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-        self.nodes[BBR_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX)
-        self.nodes[BBR_1].enable_backbone_router()
-        self.nodes[BBR_1].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_1].get_state(), 'router')
-        WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router()['seqno'], 2)
-
-        # 3) Reset BBR_1 and bring it back after its original router id is released
-        # 200s (100s MaxNeighborAge + 90s InfiniteCost + 10s redundance)
-        # Verify it becomes Primary again.
-        # Note: To ensure test in next step, here Step 3) will repeat until
-        # the random sequence number is not the highest value 255.
-        while True:
-            self.nodes[BBR_1].reset()
-            WAIT_TIME = 200
-            self.simulator.go(WAIT_TIME)
-            self.nodes[BBR_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-            self.nodes[BBR_1].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-            self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX)
-            self.nodes[BBR_1].enable_backbone_router()
-            self.nodes[BBR_1].start()
-            WAIT_TIME = config.ROUTER_RESET_DELAY
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(self.nodes[BBR_1].get_state(), 'router')
-            WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-            BBR_1_SEQNO = self.nodes[BBR_1].get_backbone_router()['seqno']
-            if (BBR_1_SEQNO != 255):
-                break
-
-        #4) Configure BBR_2 with highest sequence number (255) and
-        #   explicitly trigger SRV_DATA.ntf.
-        #   Verify BBR_2 would become Primary and BBR_1 would change to
-        #   Secondary with sequence number increased by 1.
-
-        # Bring up BBR_2, it becomes Router with backbone function disabled
-        # by default.
-        self.nodes[BBR_2].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[BBR_2].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-        self.nodes[BBR_2].set_domain_prefix(config.DOMAIN_PREFIX)
-        self.nodes[BBR_2].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_2].get_state(), 'router')
-        WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_2].get_backbone_router_state(), 'Disabled')
-
-        # Enable Backbone function, it will stay at Secondary state as
-        # there is Primary Backbone Router already.
-        # Here removes the Domain Prefix before enabling backbone function
-        # intentionally to avoid SRV_DATA.ntf due to prefix inconsistency.
-        self.nodes[BBR_2].remove_domain_prefix(config.DOMAIN_PREFIX)
-        self.nodes[BBR_2].enable_backbone_router()
-        self.nodes[BBR_2].set_backbone_router(seqno=255)
-        WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_2].get_backbone_router_state(), 'Secondary')
-
-        # Check no SRV_DATA.ntf.
-        messages = self.simulator.get_messages_sent_by(BBR_2)
-        msg = messages.next_coap_message('0.02', '/a/sd', False)
-        self.assertIsNone(msg)
-
-        # Flush relative message queue.
-        self.flush_nodes([BBR_1])
-
-        # BBR_2 registers SRV_DATA.ntf explicitly.
-        self.nodes[BBR_2].register_backbone_router()
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_2].get_backbone_router_state(), 'Primary')
-
-        # Verify BBR_1 becomes Secondary and sends SRV_DATA.ntf to deregister
-        # its service.
-        messages = self.simulator.get_messages_sent_by(BBR_1)
-        messages.next_coap_message('0.02', '/a/sd', True)
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Secondary')
-
-        # Verify Sequence number increases when become Secondary from Primary.
-        if (BBR_1_SEQNO == 126) or (BBR_1_SEQNO == 127):
-            next_seqno = 0
-        elif (BBR_1_SEQNO == 254) or (BBR_1_SEQNO == 255):
-            next_seqno = 128
-        else:
-            next_seqno = BBR_1_SEQNO + 1
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router()['seqno'], next_seqno)
-
-        # 4a) Check communication via DUA.
-        bbr2_dua = self.nodes[BBR_2].get_addr(config.DOMAIN_PREFIX)
-        self.assertTrue(self.nodes[BBR_1].ping(bbr2_dua))
-
-        # 5) Stop BBR_2, BBR_1 becomes Primary after detecting there is no
-        #    available Backbone Router Service.
-        self.nodes[BBR_2].reset()
-        self.nodes[LEADER_1_1].release_router_id(self.nodes[BBR_2].get_router_id())
-        # Wait for the dissemination of Network Data without Backbone Router service
-        self.simulator.go(10)
-
-        # BBR_1 becomes Primary.
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-        messages = self.simulator.get_messages_sent_by(BBR_1)
-        messages.next_coap_message('0.02', '/a/sd', True)
-
-        # 6) Bring back BBR_2.
-        #    Verify that BBR_2 stays at Secondary.
-        self.nodes[BBR_2].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[BBR_2].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-        self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX)
-        self.nodes[BBR_2].enable_backbone_router()
-        self.nodes[BBR_2].interface_up()
-        self.nodes[BBR_2].thread_start()
-        WAIT_TIME = config.ROUTER_RESET_DELAY
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_2].get_state(), 'router')
-        WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_2].get_backbone_router_state(), 'Secondary')
-
-        # 6a) Check the uniqueness of DUA by comparing the one in above 4a).
-        bbr2_dua2 = self.nodes[BBR_2].get_addr(config.DOMAIN_PREFIX)
-        self.assertEqual(bbr2_dua, bbr2_dua2)
-
-        # 6b) Check communication via DUA
-        self.assertTrue(self.nodes[BBR_1].ping(bbr2_dua))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/v1_2_test_domain_unicast_address.py b/tests/scripts/thread-cert/v1_2_test_domain_unicast_address.py
deleted file mode 100755
index 81c9054..0000000
--- a/tests/scripts/thread-cert/v1_2_test_domain_unicast_address.py
+++ /dev/null
@@ -1,320 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import command
-import config
-import ipaddress
-import mle
-import thread_cert
-
-BBR_1 = 1  # Collapsed with Leader Role
-ROUTER_1_1 = 2
-ROUTER_1_2 = 3
-MED_1_2 = 4
-SED_1_2 = 5
-
-WAIT_ATTACH = 5
-WAIT_REDUNDANCE = 3
-ROUTER_SELECTION_JITTER = 1
-BBR_REGISTRATION_JITTER = 5
-SED_POLL_PERIOD = 2000  # 2s
-MED_TIMEOUT = 20  # 20s
-
-DUA_IID_MANUAL1 = '4444333322221111'
-DUA_IID_MANUAL2 = '5555444433332222'
-
-TEST_PREFIX1 = '2001:0:0:1::/64'
-TEST_PREFIX2 = '2001:0:0:2::/64'
-TEST_PREFIX3 = '2001:0:0:3::/64'
-"""
- Topology
-
-
-   SED_1_2
-     |
-     |
- ROUTER_1_1         MED_1_2
-     |                 |
-     |                 |
- BBR_1 (LEADER) --- ROUTER_1_2
-
-
- 1) Bring up BBR_1, BBR_1 becomes Leader and Primary Backbone Router, with Domain
-    Prefix without `P_slaac`.
-
- 2) Bring up ROUTER_1_1, no DUA was added due to that `P_slaac` flag is not set.
-
- 3) Bring up ROUTER_1_2, verify that it has DUA generated.
-
- 4) Bring up MED_1_2 with DUA_IID_MANUAL1 set in advance, verify
-    a) DUA_IID_MANUAL1 is registered in Address Registration TLV via Child Update Request.
-    b) Remove DUA_IID_MANUAL1, a new DUA generated via SLAAC would be registered in Address
-       Registration TLV via Child Update Request.
-    c) Set DUA_IID_MANUAL2 which should override the generated one and be registered in Address
-       Registration TLV via Child Update Request.
-    d) Remove DUA_IID_MANUAL2, a new DUA generated via SLAAC, the same as in above b) would
-       be registered in Address Registration TLV via  Child Update Request.
-
- 5) Change BBR_1 from config.DOMAIN_PREFIX to config.DOMAIN_PRFIX_ALTER. Verify that MED_1_2
-    generate a new Interface Identifier different from the one generated in 4d) due to the
-    Domain Prefix change.
-
- 6) Recover config.Domain_Prefix on BBR_1. Verify that MED_1_2 generates and registers the same
-    DUA as in step 4b).
-
- 7) Configure ROUTER_1_1 as Border Router with 3 SLAAC prefixes, verify MED_1_2 would register
-    its DUA in Address Registration TLV.
-
- 8) Bring up SED_1_2, verify it generates one DUA, and registers it to its parent, though the parent
-    is a Thread 1.1 device.
-
-"""
-
-
-class TestDomainUnicastAddress(thread_cert.TestCase):
-    TOPOLOGY = {
-        BBR_1: {
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1, ROUTER_1_2],
-            'is_bbr': True
-        },
-        ROUTER_1_1: {
-            'version': '1.1',
-            'allowlist': [BBR_1, SED_1_2]
-        },
-        ROUTER_1_2: {
-            'version': '1.2',
-            'allowlist': [BBR_1, MED_1_2]
-        },
-        MED_1_2: {
-            'mode': 'rn',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2],
-        },
-        SED_1_2: {
-            'mode': 'n',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1],
-        },
-    }
-    """All nodes are created with default configurations"""
-
-    def __get_iid(self, address):
-        ''' Get the interface identifier of an IPv6 address.
-
-        Args:
-            address (string): An IPv6 address;
-        '''
-        return ''.join(ipaddress.ip_address(address).exploded.split(':')[4:])
-
-    def __check_dua_registration(self, node, iid, dp_cid):
-        ''' Check whether or not the specified Domain Unicast Address is registered in Address
-        Registration TLV.
-
-        Args:
-            node (int) : The device id
-            iid (string): The Interface Identifier
-            dp_cid (int): The context id of the domain prefix.
-        '''
-
-        messages = self.simulator.get_messages_sent_by(node)
-        msg = messages.next_mle_message(mle.CommandType.CHILD_UPDATE_REQUEST)
-        command.check_compressed_address_registration_tlv(msg, dp_cid, iid, cid_present_once=True)
-
-    def test(self):
-        # starting context id
-        context_id = 1
-
-        # 1) Bring up BBR_1, BBR_1 becomes Leader and Primary Backbone Router, with Domain
-        #    Prefix without `P_slaac`.
-        self.nodes[BBR_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[BBR_1].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-        self.nodes[BBR_1].set_backbone_router(seqno=1)
-        self.nodes[BBR_1].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME * 2)
-        self.assertEqual(self.nodes[BBR_1].get_state(), 'leader')
-        self.nodes[BBR_1].enable_backbone_router()
-        WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-
-        self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        self.simulator.set_lowpan_context(context_id, config.DOMAIN_PREFIX)
-        domain_prefix_cid = context_id
-
-        # 2) Bring up ROUTER_1_1, no DUA was added due to that `P_slaac` flag is not set.
-        self.nodes[ROUTER_1_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.nodes[ROUTER_1_1].start()
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[ROUTER_1_1].get_state(), 'router')
-        dua = self.nodes[ROUTER_1_1].get_addr(config.DOMAIN_PREFIX)
-        assert not dua, 'Error: Unexpected DUA ({})'.format(dua)
-
-        # 3) Bring up ROUTER_1_2, verify that it has DUA generated.
-        self.nodes[ROUTER_1_2].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[ROUTER_1_2].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[ROUTER_1_2].get_state(), 'router')
-        dua = self.nodes[ROUTER_1_2].get_addr(config.DOMAIN_PREFIX)
-        assert dua, 'Error: Expected DUA not found'
-        self.assertTrue(self.nodes[BBR_1].ping(dua))
-
-        # 4) Bring up MED_1_2 with DUA_IID_MANUAL1 set in advance
-        self.nodes[MED_1_2].set_dua_iid(DUA_IID_MANUAL1)
-        self.nodes[MED_1_2].set_timeout(MED_TIMEOUT)
-        self.nodes[MED_1_2].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[MED_1_2].get_state(), 'child')
-
-        # 4a) DUA_IID_MANUAL1 is registered in Address Registration TLV via Child Update Request.
-        self.__check_dua_registration(MED_1_2, DUA_IID_MANUAL1, domain_prefix_cid)
-
-        # 4b) Remove DUA_IID_MANUAL1, a new DUA generated via SLAAC would be registered in Address
-        #   Registration TLV via Child Update Request.
-
-        # Flush relative message queues.
-        messages = self.simulator.get_messages_sent_by(MED_1_2)
-
-        self.nodes[MED_1_2].clear_dua_iid()
-        WAIT_TIME = MED_TIMEOUT + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        med_1_2_dua = self.nodes[MED_1_2].get_addr(config.DOMAIN_PREFIX)
-        assert med_1_2_dua, 'Error: Expected DUA not found'
-
-        med_1_2_dua_iid = self.__get_iid(med_1_2_dua)
-        self.__check_dua_registration(MED_1_2, med_1_2_dua_iid, domain_prefix_cid)
-
-        # 4c) Set DUA_IID_MANUAL2 which should override the generated one and be registered in Address
-        #   Registration TLV via Child Update Request.
-
-        # Flush relative message queues.
-        messages = self.simulator.get_messages_sent_by(MED_1_2)
-        self.nodes[MED_1_2].set_dua_iid(DUA_IID_MANUAL2)
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        dua = self.nodes[MED_1_2].get_addr(config.DOMAIN_PREFIX)
-
-        self.__check_dua_registration(MED_1_2, DUA_IID_MANUAL2, domain_prefix_cid)
-
-        # 4d) Remove DUA_IID_MANUAL2, a new DUA generated via SLAAC, the same as in above b) would
-        #     be registered in Address Registration TLV via  Child Update Request.
-
-        # Flush relative message queues.
-        messages = self.simulator.get_messages_sent_by(MED_1_2)
-        self.nodes[MED_1_2].clear_dua_iid()
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        dua = self.nodes[MED_1_2].get_addr(config.DOMAIN_PREFIX)
-        self.assertEqual(ipaddress.ip_address(dua), ipaddress.ip_address(med_1_2_dua))
-        self.assertEqual(ipaddress.ip_address(med_1_2_dua), ipaddress.ip_address(dua))
-
-        self.__check_dua_registration(MED_1_2, med_1_2_dua_iid, domain_prefix_cid)
-
-        # 5) Change BBR_1 from config.DOMAIN_PREFIX to config.DOMAIN_PRFIX_ALTER. Verify that MED_1_2
-        #   generates a new Interface Identifier different from the one generated in 4d) due to the
-        #   Domain Prefix change.
-        context_id += 1
-        self.simulator.set_lowpan_context(context_id, config.DOMAIN_PREFIX_ALTER)
-        self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX_ALTER)
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        med_1_2_dua2 = self.nodes[MED_1_2].get_addr(config.DOMAIN_PREFIX_ALTER)
-        med_1_2_dua2_iid = self.__get_iid(med_1_2_dua2)
-        self.__check_dua_registration(MED_1_2, med_1_2_dua2_iid, context_id)
-
-        #6) Recover config.Domain_Prefix on BBR_1. Verify that MED_1_2 generates and registers the same
-        #   DUA as in step 4b).
-        self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX)
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        dua = self.nodes[MED_1_2].get_addr(config.DOMAIN_PREFIX)
-        assert dua, 'Error: Expected DUA not found'
-        self.assertEqual(ipaddress.ip_address(med_1_2_dua), ipaddress.ip_address(dua))
-
-        self.__check_dua_registration(MED_1_2, med_1_2_dua_iid, domain_prefix_cid)
-
-        #7) Configure ROUTER_1_1 as Border Router with 3 SLAAC prefixes, verify MED_1_2 would register
-        #   its DUA in Address Registration TLV.
-
-        # Flush relative message queues.
-        messages = self.simulator.get_messages_sent_by(MED_1_2)
-
-        context_id += 1
-        self.simulator.set_lowpan_context(context_id, TEST_PREFIX1)
-        self.nodes[ROUTER_1_1].add_prefix(TEST_PREFIX1)
-
-        context_id += 1
-        self.simulator.set_lowpan_context(context_id, TEST_PREFIX2)
-        self.nodes[ROUTER_1_1].add_prefix(TEST_PREFIX2)
-        context_id += 1
-        self.simulator.set_lowpan_context(context_id, TEST_PREFIX3)
-        self.nodes[ROUTER_1_1].add_prefix(TEST_PREFIX3)
-        self.nodes[ROUTER_1_1].register_netdata()
-
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        WAIT_TIME = MED_TIMEOUT
-        self.simulator.go(WAIT_TIME)
-        dua = self.nodes[MED_1_2].get_addr(config.DOMAIN_PREFIX)
-        assert dua, 'Error: Expected DUA not found'
-        self.assertEqual(ipaddress.ip_address(med_1_2_dua), ipaddress.ip_address(dua))
-
-        self.__check_dua_registration(MED_1_2, med_1_2_dua_iid, domain_prefix_cid)
-
-        #8) Bring up SED_1_2, verify that it generates one DUA, and registers it to its parent, though the parent
-        #   is a Thread 1.1 device.
-        self.nodes[SED_1_2].set_pollperiod(SED_POLL_PERIOD)
-        self.nodes[SED_1_2].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-
-        dua = self.nodes[SED_1_2].get_addr(config.DOMAIN_PREFIX)
-        assert dua, 'Error: Expected DUA not found'
-
-        dua_iid = self.__get_iid(dua)
-        self.__check_dua_registration(SED_1_2, dua_iid, domain_prefix_cid)
-
-        self.assertTrue(self.nodes[BBR_1].ping(dua))
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/v1_2_test_domain_unicast_address_registration.py b/tests/scripts/thread-cert/v1_2_test_domain_unicast_address_registration.py
deleted file mode 100755
index 76772ab..0000000
--- a/tests/scripts/thread-cert/v1_2_test_domain_unicast_address_registration.py
+++ /dev/null
@@ -1,370 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import command
-import config
-import ipaddress
-import mle
-import network_layer
-import thread_cert
-
-BBR_1 = 1  # Collapsed with Leader Role
-ROUTER_1_1 = 2
-ROUTER_1_2 = 3
-
-FED_1_2_1 = 4
-MED_1_2_1 = 5
-SED_1_2_1 = 6
-
-FED_1_2_2 = 7
-MED_1_2_2 = 8
-SED_1_2_2 = 9
-
-WAIT_ATTACH = 5
-WAIT_REDUNDANCE = 3
-ROUTER_SELECTION_JITTER = 1
-BBR_REGISTRATION_JITTER = 5
-SED_POLL_PERIOD = 2000  # 2s
-MED_TIMEOUT = 20  # 20s
-PARENT_AGGREGATE_DELAY = 5  # 5s
-
-DUA_IID_MANUAL1 = '4444333322221111'
-
-ST_DUA_SUCCESS = 0
-ST_DUA_REREGISTER = 1
-ST_DUA_INVALID = 2
-ST_DUA_DUPLICATE = 3
-ST_DUA_NO_RESOURCES = 4
-ST_DUA_BBR_NOT_PRIMARY = 5
-ST_DUA_GENERAL_FAILURE = 6
-
-BBR_REREGISTRATION_DELAY = 10
-"""
- Topology
-
-
-    MED_1_2_1   SED_1_2_1
-            \     |
-             \    |
-FED_1_2_1 --- ROUTER_1_1         FED_1_2_2  MED_1_2_2
-                 |                 |        /
-                 |                 |       /
-             BBR_1 (LEADER) --- ROUTER_1_2 --- SED_1_2_2
-
-
- 1) Bring up BBR_1, BBR_1 becomes Leader and Primary Backbone Router, with Domain
-    Prefix without `P_slaac`.
-
- 2) Test behaviors of ROUTER_1_2 under various response status:
-    a) Bring up ROUTER_1_2 with DUA_IID_MANUAL1, one DUA.req should happen to register DUA.
-    b) Remove DUA_IID_MANUAL1, one DUA.req should happen for the new generated DUA via SLAAC.
-    c) Configure BBR_1 to respond with the fatal error ST_DUA_INVALID, update BBR_1 with
-       BBR_REREGISTRATION_DELAY, ROUTER_1_2 should re-register its DUA within BBR_REREGISTRATION_DELAY.
-         - ROUTER_1_2 should remove its dua
-         - update network data, ROUTER_1_2 would regenerate and register the same dua
-    d) Configure BBR_1 to respond with the fatal error ST_DUA_DUPLICATE, update seqno to trigger reregistration.
-       After received DUA.rsp with ST_DUA_DUPLICATE, ROUTER_1_2 should
-       - increase dad counter
-       - regenerate a new DUA
-       - send DUA.req
-    e) (repeated) Configure BBR_1 to respond with per remaining error status:
-       - increase BBR seqno to trigger reregistration
-       - ROUTER_1_2 should re-register within BBR_REREGISTRATION_DELAY. For the not fatal errors, ROUTER_1_2
-         should re-register within another BBR_REREGISTRATION_DELAY (with least delay if ST_DUA_REREGISTER)
- 3) Bring up FED_1_2_1, MED_1_2_1, SED_1_2_1, they should send DUA.req by themselves as the parent
-    is of Thread 1.1 version.
- 4) Bring up FED_1_2_2, it sends DUA.req itself as it it FTD.
- 5) MED_1_2_2, SED_1_2_2, MTDs should should register their DUA to their parent
-    by Child Update Request, and the parent would send DUA.req for them on behalf.
- 6) Increase seqno on BBR_1, within  BBR_REREGISTRATION_DELAY, there should be one DUA.req from
-    per [FED_1_2_1, MED_1_2_1, SED_1_2_1, FED_1_2_2], and 3 DUA.req from ROUTER_1_2 among which
-    2 DUA.req are for its MTD children.
-
-"""
-
-
-class TestDomainUnicastAddressRegistration(thread_cert.TestCase):
-    TOPOLOGY = {
-        BBR_1: {
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1, ROUTER_1_2],
-            'is_bbr': True
-        },
-        ROUTER_1_1: {
-            'version': '1.1',
-            'allowlist': [BBR_1, FED_1_2_1, MED_1_2_1, SED_1_2_1]
-        },
-        ROUTER_1_2: {
-            'version': '1.2',
-            'allowlist': [BBR_1, FED_1_2_2, MED_1_2_2, SED_1_2_2]
-        },
-        FED_1_2_1: {
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1],
-        },
-        MED_1_2_1: {
-            'mode': 'rn',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1],
-        },
-        SED_1_2_1: {
-            'mode': 'n',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1],
-        },
-        FED_1_2_2: {
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2],
-        },
-        MED_1_2_2: {
-            'mode': 'rn',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2],
-        },
-        SED_1_2_2: {
-            'mode': 'n',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2],
-        },
-    }
-    """All nodes are created with default configurations"""
-
-    def __get_iid(self, address):
-        ''' Get the interface identifier of an IPv6 address.
-
-        Args:
-            address (string): An IPv6 address;
-        '''
-        return ''.join(ipaddress.ip_address(address).exploded.split(':')[4:])
-
-    def __check_dua_registration_tmf(self, node, occurrences=1, ml_eid=None):
-
-        messages = self.simulator.get_messages_sent_by(node)
-        for i in range(occurrences):
-            msg = messages.next_coap_message('0.02', '/n/dr', False)
-            assert msg, 'Expected {}, but {}th not found\n node: {}(extaddr: {})'.format(
-                occurrences, i + 1, node, self.nodes[node].get_addr64())
-            if ml_eid:
-                ml_eid_tlv = msg.get_coap_message_tlv(network_layer.MlEid)
-                self.assertEqual(ml_eid, ml_eid_tlv.ml_eid.hex())
-
-    def test(self):
-        # starting context id
-        context_id = 1
-        seq_num = 1
-
-        # 1) Bring up BBR_1, BBR_1 becomes Leader and Primary Backbone Router, with Domain
-        #    Prefix without `P_slaac`.
-        self.nodes[BBR_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[BBR_1].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-        self.nodes[BBR_1].set_backbone_router(seqno=seq_num, reg_delay=BBR_REREGISTRATION_DELAY)
-        self.nodes[BBR_1].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME * 2)
-        self.assertEqual(self.nodes[BBR_1].get_state(), 'leader')
-        self.nodes[BBR_1].enable_backbone_router()
-        WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-
-        self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        self.simulator.set_lowpan_context(context_id, config.DOMAIN_PREFIX)
-        domain_prefix_cid = context_id
-
-        # 2) Test behaviors of ROUTER_1_2 under various response status:
-        #  a) Bring up ROUTER_1_2 with DUA_IID_MANUAL1, one DUA.req should happen to register DUA.
-
-        # Flush relative message queues.
-        self.flush_nodes([ROUTER_1_2])
-
-        self.nodes[ROUTER_1_2].set_dua_iid(DUA_IID_MANUAL1)
-        self.nodes[ROUTER_1_2].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[ROUTER_1_2].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[ROUTER_1_2].get_state(), 'router')
-
-        mliid = self.__get_iid(self.nodes[ROUTER_1_2].get_mleid())
-
-        WAIT_TIME = WAIT_ATTACH + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.__check_dua_registration_tmf(ROUTER_1_2, 1, self.nodes[ROUTER_1_2].get_mleid_iid())
-
-        #  b) Remove DUA_IID_MANUAL1, one DUA.req should happen for the new generated DUA via SLAAC.
-
-        # Flush relative message queues.
-        self.flush_nodes([ROUTER_1_2])
-        self.nodes[ROUTER_1_2].clear_dua_iid()
-        WAIT_TIME = WAIT_ATTACH + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.__check_dua_registration_tmf(ROUTER_1_2, 1, self.nodes[ROUTER_1_2].get_mleid_iid())
-
-        #c) Configure BBR_1 to respond with the fatal error ST_DUA_INVALID, update BBR_1 with
-        #   BBR_REREGISTRATION_DELAY, ROUTER_1_2 should re-register its DUA within BBR_REREGISTRATION_DELAY.
-        #     - ROUTER_1_2 should remove its dua
-        #     - update network data, ROUTER_1_2 would regenerate and register the same dua
-
-        # Flush relative message queues.
-        self.flush_nodes([ROUTER_1_2])
-        seq_num = seq_num + 1
-        self.nodes[BBR_1].set_next_dua_response(ST_DUA_INVALID, mliid)
-        self.nodes[BBR_1].set_backbone_router(seqno=seq_num)
-        WAIT_TIME = BBR_REREGISTRATION_DELAY + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        self.__check_dua_registration_tmf(ROUTER_1_2, 1, self.nodes[ROUTER_1_2].get_mleid_iid())
-        dua = self.nodes[ROUTER_1_2].get_addr(config.DOMAIN_PREFIX)
-        assert not dua, 'Error: Unexpected DUA ({}) found'.format(dua)
-
-        # Retry after new network data is available
-        seq_num = seq_num + 1
-        dua = self.nodes[ROUTER_1_2].get_addr(config.DOMAIN_PREFIX)
-        self.nodes[BBR_1].set_backbone_router(seqno=seq_num)
-        WAIT_TIME = BBR_REREGISTRATION_DELAY + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        self.__check_dua_registration_tmf(ROUTER_1_2, 1, self.nodes[ROUTER_1_2].get_mleid_iid())
-        dua = self.nodes[ROUTER_1_2].get_addr(config.DOMAIN_PREFIX)
-        assert dua, 'Error: Expected DUA ({}) not found'.format(dua)
-
-        #d) Configure BBR_1 to respond with the fatal error ST_DUA_DUPLICATE, update seqno to trigger reregistration.
-        #   After received DUA.rsp with ST_DUA_DUPLICATE, ROUTER_1_2 should
-        #   - increase dad counter
-        #   - regenerate a new DUA
-        #   - send DUA.req
-
-        # Flush relative message queues.
-        self.flush_nodes([ROUTER_1_2])
-        seq_num = seq_num + 1
-        self.nodes[BBR_1].set_next_dua_response(ST_DUA_DUPLICATE, mliid)
-        self.nodes[BBR_1].set_backbone_router(seqno=seq_num)
-        WAIT_TIME = BBR_REREGISTRATION_DELAY * 2 + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        self.__check_dua_registration_tmf(ROUTER_1_2, 2, self.nodes[ROUTER_1_2].get_mleid_iid())
-
-        dua2 = self.nodes[ROUTER_1_2].get_addr(config.DOMAIN_PREFIX)
-        assert dua2, 'Error: Expected DUA ({}) not found'.format(dua2)
-        self.assertNotEqual(dua2, dua)
-
-        # e) (repeated) Configure BBR_1 to respond with per remaining error status:
-        #   - increase BBR seqno to trigger reregistration
-        #   - ROUTER_1_2 should re-register within BBR_REREGISTRATION_DELAY. For the not fatal errors, ROUTER_1_2
-        #     should re-register within another BBR_REREGISTRATION_DELAY (with least delay if ST_DUA_REREGISTER)
-        for status in ['5.00', ST_DUA_REREGISTER, ST_DUA_NO_RESOURCES, ST_DUA_BBR_NOT_PRIMARY, ST_DUA_GENERAL_FAILURE]:
-            print(f'Testing Status {status}...')
-            # Flush relative message queues.
-            self.flush_nodes([ROUTER_1_2])
-            seq_num = seq_num + 1
-            self.nodes[BBR_1].set_next_dua_response(status, mliid)
-            self.nodes[BBR_1].set_backbone_router(seqno=seq_num)
-            WAIT_TIME = BBR_REREGISTRATION_DELAY + WAIT_REDUNDANCE
-            if status != ST_DUA_REREGISTER:
-                WAIT_TIME += BBR_REREGISTRATION_DELAY
-
-            self.simulator.go(WAIT_TIME)
-
-            self.__check_dua_registration_tmf(ROUTER_1_2, 2, self.nodes[ROUTER_1_2].get_mleid_iid())
-
-        # Bring up Router_1_1
-        self.nodes[ROUTER_1_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[ROUTER_1_1].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[ROUTER_1_1].get_state(), 'router')
-
-        dua = self.nodes[ROUTER_1_1].get_addr(config.DOMAIN_PREFIX)
-        assert not dua, 'Error: Unexpected DUA ({}) found'.format(dua)
-
-        # Configure children
-        for node in [FED_1_2_1, FED_1_2_2]:
-            self.nodes[node].set_routereligible(False)
-
-        for node in [SED_1_2_1, SED_1_2_2]:
-            self.nodes[node].set_pollperiod(SED_POLL_PERIOD)
-
-        for node in [MED_1_2_1, MED_1_2_2]:
-            self.nodes[node].set_timeout(MED_TIMEOUT)
-
-        # 3) Bring up FED_1_2_1, MED_1_2_1, SED_1_2_1, they should send DUA.req by themselves as the parent
-        #    is of Thread 1.1 version.
-        # 4) Bring up FED_1_2_2, it sends DUA.req itself as it it FTD.
-        for node in [FED_1_2_1, MED_1_2_1, SED_1_2_1, FED_1_2_2]:
-            print("Starting child {} (extaddr: {})...".format(node, self.nodes[node].get_addr64()))
-            # Flush all message queues.
-            self.flush_all()
-            self.nodes[node].start()
-            WAIT_TIME = WAIT_ATTACH
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(self.nodes[node].get_state(), 'child')
-            WAIT_TIME = PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE
-            self.simulator.go(WAIT_TIME)
-            self.__check_dua_registration_tmf(node, 1, self.nodes[node].get_mleid_iid())
-
-        # 5) MED_1_2_2, SED_1_2_2, MTDs should should register their DUA to their parent
-        #    by Child Update Request, and the parent would send DUA.req for them on behalf.
-        for node in [MED_1_2_2, SED_1_2_2]:
-            print("Starting child {} (extaddr: {})...".format(node, self.nodes[node].get_addr64()))
-            # Flush all message queues.
-            self.flush_all()
-            self.nodes[node].start()
-            WAIT_TIME = WAIT_ATTACH
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(self.nodes[node].get_state(), 'child')
-
-            WAIT_TIME = PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE
-            print("waiting {}".format(WAIT_TIME))
-            self.simulator.go(WAIT_TIME)
-            self.__check_dua_registration_tmf(ROUTER_1_2, 1, self.nodes[node].get_mleid_iid())
-
-        # 6) Increase seqno on BBR_1, within  BBR_REREGISTRATION_DELAY, there should be one DUA.req from
-        #    per [FED_1_2_1, MED_1_2_1, SED_1_2_1, FED_1_2_2], and 3 DUA.req from ROUTER_1_2 among which
-        #    2 DUA.req are for its MTD children.
-
-        # Flush all message queues.
-        self.flush_all()
-        seq_num = seq_num + 1
-        self.nodes[BBR_1].set_backbone_router(seqno=seq_num)
-        WAIT_TIME = BBR_REREGISTRATION_DELAY + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        WAIT_TIME = BBR_REREGISTRATION_DELAY + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        for node in [FED_1_2_1, MED_1_2_1, SED_1_2_1, FED_1_2_2]:
-            self.__check_dua_registration_tmf(node, 1, self.nodes[node].get_mleid_iid())
-
-        self.__check_dua_registration_tmf(ROUTER_1_2, 3)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/v1_2_test_dua_handle_address_error.py b/tests/scripts/thread-cert/v1_2_test_dua_handle_address_error.py
deleted file mode 100755
index ea36c69..0000000
--- a/tests/scripts/thread-cert/v1_2_test_dua_handle_address_error.py
+++ /dev/null
@@ -1,148 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-# This script tests how node would handle duplicated Domain Unicast Address.
-#
-
-import unittest
-
-import config
-import thread_cert
-
-BBR_1 = 1  # Collapsed with Leader Role
-ROUTER = 2
-FED = 3
-MED = 4
-
-WAIT_ATTACH = 5
-WAIT_REDUNDANCE = 3
-BBR_REGISTRATION_JITTER = 5
-SED_POLL_PERIOD = 2000  # 2s
-MED_TIMEOUT = 20  # 20s
-"""
- Topology
-
-  BBR_1 (Leader)
-     |
-  ROUTER_1---FED
-     |
-    MED
-"""
-
-WAIT_REDUNDANCE = 3
-REG_DELAY = 5
-
-
-class TestDomainUnicastAddress(thread_cert.TestCase):
-    TOPOLOGY = {
-        BBR_1: {
-            'version': '1.2',
-            'allowlist': [ROUTER],
-            'is_bbr': True
-        },
-        ROUTER: {
-            'version': '1.2',
-            'allowlist': [BBR_1, FED, MED],
-        },
-        FED: {
-            'version': '1.2',
-            'allowlist': [ROUTER],
-            'router_eligible': False,
-            'mode': 'rdn',
-        },
-        MED: {
-            'version': '1.2',
-            'is_mtd': True,
-            'allowlist': [ROUTER],
-            'mode': 'rn',
-        },
-    }
-
-    def test(self):
-        self.simulator.set_lowpan_context(1, config.DOMAIN_PREFIX)
-
-        # 1) Bring up BBR_1, BBR_1 becomes Leader and Primary Backbone Router, with Domain
-        #    Prefix without `P_slaac`.
-        self.nodes[BBR_1].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-        self.nodes[BBR_1].set_backbone_router(seqno=1, reg_delay=REG_DELAY)
-        self.nodes[BBR_1].start()
-
-        self.simulator.go(WAIT_ATTACH * 2 + config.DEFAULT_ROUTER_SELECTION_JITTER)
-        self.assertEqual(self.nodes[BBR_1].get_state(), 'leader')
-        self.nodes[BBR_1].enable_backbone_router()
-        self.simulator.go(BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE)
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-
-        self.nodes[BBR_1].set_domain_prefix(config.DOMAIN_PREFIX, 'prosD')
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # 2) Bring up ROUTER_1
-        self.nodes[ROUTER].start()
-        self.simulator.go(WAIT_ATTACH + REG_DELAY + WAIT_REDUNDANCE)
-        self.assertEqual(self.nodes[ROUTER].get_state(), 'router')
-
-        # Bring up FED
-        self.nodes[FED].start()
-        self.simulator.go(WAIT_ATTACH + REG_DELAY + WAIT_REDUNDANCE)
-        self.assertEqual(self.nodes[FED].get_state(), 'child')
-
-        # Bring up MED
-        self.nodes[MED].start()
-        self.simulator.go(WAIT_ATTACH + config.PARENT_AGGREGATIOIN_DELAY + REG_DELAY + WAIT_REDUNDANCE)
-        self.assertEqual(self.nodes[MED].get_state(), 'child')
-
-        self._verify_dua_handle_address_error(ROUTER)
-        self._verify_dua_handle_address_error(FED)
-        self._verify_dua_handle_address_error(MED, is_med=True)
-
-    def _verify_dua_handle_address_error(self, nodeid, is_med=False):
-        dua = self.nodes[nodeid].get_addr(config.DOMAIN_PREFIX)
-        self.assertIsNotNone(dua)
-
-        # Ping the DUA to verify reachability, and also fill the EID cache on BBR_1
-        self.assertTrue(self.nodes[BBR_1].ping(dua))
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        # Send fake /a/an from ROUTER to BBR_1 for the node's DUA
-        pbbr_rloc = self.nodes[BBR_1].get_ip6_address(config.ADDRESS_TYPE.RLOC)
-        self.nodes[ROUTER].send_address_notification(pbbr_rloc, dua, f'000000000000{nodeid:04x}')
-
-        self.simulator.go(config.PARENT_AGGREGATIOIN_DELAY * is_med + REG_DELAY + WAIT_REDUNDANCE + 50)
-
-        # Make sure device handles /a/ae correctly by generating new DUA
-        new_dua = self.nodes[nodeid].get_addr(config.DOMAIN_PREFIX)
-        self.assertNotEqual(dua, new_dua)
-        self.assertTrue(self.nodes[BBR_1].ping(new_dua))
-        self.assertFalse(self.nodes[BBR_1].ping(dua))
-
-        self.simulator.go(3)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/v1_2_test_multicast_listener_registration.py b/tests/scripts/thread-cert/v1_2_test_multicast_listener_registration.py
deleted file mode 100755
index ac974de..0000000
--- a/tests/scripts/thread-cert/v1_2_test_multicast_listener_registration.py
+++ /dev/null
@@ -1,937 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import ipaddress
-import logging
-import time
-import unittest
-from typing import Union, List
-
-import config
-import network_layer
-import thread_cert
-
-logging.basicConfig(level=logging.DEBUG)
-
-_, BBR_1, BBR_2, ROUTER_1_2, ROUTER_1_1, SED_1, MED_1, MED_2, FED_1 = range(9)
-
-WAIT_ATTACH = 5
-WAIT_REDUNDANCE = 3
-ROUTER_SELECTION_JITTER = 1
-BBR_REGISTRATION_JITTER = 5
-SED_POLL_PERIOD = 1000  # 1s
-
-REREG_DELAY = 10
-MLR_TIMEOUT = 300
-PARENT_AGGREGATE_DELAY = 5
-
-MA1 = 'ff04::1234:777a:1'
-MA1g = 'ff0e::1234:777a:1'
-MA2 = 'ff05::1234:777a:2'
-MA3 = 'ff0e::1234:777a:3'
-MA4 = 'ff05::1234:777a:4'
-
-MA5 = 'ff03::1234:777a:5'
-MA6 = 'ff02::10'
-"""
- Initial topology:
-
-   BBR_1---BBR_2
-     |     /   \                       |
-     |    /     \                      |
-   ROUTER_1_2  ROUTER_1_1
-    |     |  \____                     |
-    |     |       \                    |
-  SED_1  MED_1/2  FED_1
-"""
-
-
-class TestMulticastListenerRegistration(thread_cert.TestCase):
-    TOPOLOGY = {
-        BBR_1: {
-            'version': '1.2',
-            'allowlist': [BBR_2, ROUTER_1_2],
-            'is_bbr': True,
-        },
-        BBR_2: {
-            'version': '1.2',
-            'allowlist': [BBR_1, ROUTER_1_2, ROUTER_1_1],
-            'is_bbr': True,
-        },
-        ROUTER_1_2: {
-            'version': '1.2',
-            'allowlist': [BBR_1, BBR_2, SED_1, MED_1, MED_2, FED_1],
-        },
-        ROUTER_1_1: {
-            'version': '1.1',
-            'allowlist': [BBR_2],
-        },
-        MED_1: {
-            'mode': 'rn',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2],
-            'timeout': config.DEFAULT_CHILD_TIMEOUT,
-        },
-        MED_2: {
-            'mode': 'rn',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2],
-            'timeout': config.DEFAULT_CHILD_TIMEOUT,
-        },
-        SED_1: {
-            'mode': 'n',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2],
-            'timeout': config.DEFAULT_CHILD_TIMEOUT,
-        },
-        FED_1: {
-            'mode': 'rdn',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2],
-            'router_eligible': False,
-            'timeout': config.DEFAULT_CHILD_TIMEOUT,
-        },
-    }
-    """All nodes are created with default configurations"""
-
-    def _bootstrap(self, bbr_1_enable_backbone_router=True, turn_on_bbr_2=True, turn_on_router_1_1=True):
-        assert (turn_on_bbr_2 or not turn_on_router_1_1)  # ROUTER_1_1 needs BBR_2
-
-        # starting context id
-        t0 = time.time()
-        context_id = 1
-
-        # Bring up BBR_1, BBR_1 becomes Leader and Primary Backbone Router
-        self.nodes[BBR_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[BBR_1].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-
-        self.nodes[BBR_1].set_backbone_router(seqno=1, reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-        self.nodes[BBR_1].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME * 2)
-        self.assertEqual(self.nodes[BBR_1].get_state(), 'leader')
-
-        if bbr_1_enable_backbone_router:
-            self.nodes[BBR_1].enable_backbone_router()
-            WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-
-        self.pbbr_seq = 1
-        self.pbbr_id = BBR_1
-
-        if turn_on_bbr_2:
-            # Bring up BBR_2, BBR_2 becomes Router and Secondary Backbone Router
-            self.nodes[BBR_2].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-            self.nodes[BBR_2].set_bbr_registration_jitter(BBR_REGISTRATION_JITTER)
-            self.nodes[BBR_2].set_backbone_router(seqno=2, reg_delay=REREG_DELAY, mlr_timeout=MLR_TIMEOUT)
-            self.nodes[BBR_2].start()
-            WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(self.nodes[BBR_2].get_state(), 'router')
-            self.nodes[BBR_2].enable_backbone_router()
-            WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(self.nodes[BBR_2].get_backbone_router_state(), 'Secondary')
-
-        self.simulator.set_lowpan_context(context_id, config.DOMAIN_PREFIX)
-        domain_prefix_cid = context_id
-
-        # Bring up ROUTER_1_2
-        self.nodes[ROUTER_1_2].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[ROUTER_1_2].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[ROUTER_1_2].get_state(), 'router')
-
-        if turn_on_router_1_1:
-            # Bring up ROUTER_1_1
-            self.nodes[ROUTER_1_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-            self.nodes[ROUTER_1_1].start()
-            WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-            self.simulator.go(WAIT_TIME)
-            self.assertEqual(self.nodes[ROUTER_1_1].get_state(), 'router')
-
-        # Bring up FED_1
-        self.nodes[FED_1].start()
-        self.simulator.go(WAIT_ATTACH)
-        self.assertEqual(self.nodes[FED_1].get_state(), 'child')
-
-        # Bring up MED_1
-        self.nodes[MED_1].start()
-        self.simulator.go(WAIT_ATTACH)
-        self.assertEqual(self.nodes[MED_1].get_state(), 'child')
-
-        # Bring up MED_2
-        self.nodes[MED_2].start()
-        self.simulator.go(WAIT_ATTACH)
-        self.assertEqual(self.nodes[MED_2].get_state(), 'child')
-
-        # Bring up SED_1
-        self.nodes[SED_1].set_pollperiod(SED_POLL_PERIOD)
-        self.nodes[SED_1].start()
-        self.simulator.go(WAIT_ATTACH)
-        self.assertEqual(self.nodes[SED_1].get_state(), 'child')
-
-        logging.info("bootstrap takes %f seconds", time.time() - t0)
-
-    def test(self):
-        self._bootstrap()
-
-        # Verify MLR.req for each device when parent is 1.2
-        self.__check_mlr_ok(ROUTER_1_2, is_ftd=True)
-        self.__check_mlr_ok(FED_1, is_ftd=True)
-        self.__check_mlr_ok(MED_1, is_ftd=False)
-        self.__check_mlr_ok(SED_1, is_ftd=False)
-
-        # Switch to parent 1.1
-        self.__switch_to_1_1_parent()
-
-        # Verify MLR.req for each device when parent is 1.1
-        self.__check_mlr_ok(FED_1, is_ftd=True, is_parent_1p1=True)
-        self.__check_mlr_ok(MED_1, is_ftd=False, is_parent_1p1=True)
-        self.__check_mlr_ok(SED_1, is_ftd=False, is_parent_1p1=True)
-
-        # Switch to parent 1.2
-        self.__switch_to_1_2_parent()
-
-    def testParentMergeMedMlrReq(self):
-        self._bootstrap()
-
-        # Make sure Parent registers multiple MAs of MED Children in one MLR.req
-        self.__check_parent_merge_med_mlr_req([MED_1, MED_2], ROUTER_1_2)
-
-    def testNotSendMlrReqIfSubscribed(self):
-        self._bootstrap()
-
-        # Make sure Parent does not send MLR.req of Child if it's already subscribed by Netif or other Children
-        self.__check_not_send_mlr_req_if_subscribed([MED_1, MED_2], ROUTER_1_2)
-
-    def testIpmaddrAddBeforeBBREnable(self):
-        self._bootstrap(bbr_1_enable_backbone_router=False, turn_on_bbr_2=False, turn_on_router_1_1=False)
-
-        self.flush_all()
-
-        # Subscribing to MAs when there is no PBBR should not send MLR.req
-        self.nodes[ROUTER_1_2].add_ipmaddr("ff04::1")
-        self.nodes[FED_1].add_ipmaddr("ff04::2")
-        self.nodes[MED_1].add_ipmaddr("ff04::3")
-        self.nodes[SED_1].add_ipmaddr("ff04::4")
-
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-        router_reg = ["ff04::1", "ff04::2", "ff04::3", "ff04::4"]
-        self.__check_send_mlr_req(ROUTER_1_2, router_reg, should_send=False)
-
-        self.flush_all()
-
-        # Turn on PBBR
-        self.nodes[BBR_1].enable_backbone_router()
-
-        WAIT_TIME = BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-
-        self.simulator.go(REREG_DELAY)
-        # Expect MLR.req sent by ROUTER_1_2 and FED_1
-        self.__check_send_mlr_req(ROUTER_1_2, router_reg, should_send=True, expect_mlr_rsp=True)
-
-    def testMulticastListenersTableAdd(self):
-        self._bootstrap()
-        self.__test_multicast_listeners_table_add()
-
-    def testMulticastListenersTableExpire(self):
-        self._bootstrap()
-        self.__test_multicast_listeners_table_expire()
-
-    def testMulticastListenersTableFull(self):
-        self._bootstrap()
-        self.__test_multicast_listeners_table_full()
-
-    def testMulticastListenersTableTwoFreeSlot(self):
-        self._bootstrap()
-        self.__test_multicast_listeners_table_two_free_slots()
-
-    def testMulticastListenerTableAPI(self):
-        self._bootstrap()
-        self.__test_multicast_listeners_table_api()
-
-    def testMlrConfigResponse(self):
-        self._bootstrap()
-        self.__test_mlr_config_response()
-
-    def __test_mlr_config_response(self):
-        bbr = self.nodes[BBR_1]
-        router = self.nodes[ROUTER_1_2]
-
-        self.flush_all()
-
-        # Configure next response to 0
-        bbr.set_next_mlr_response(0)
-        router.add_ipmaddr("ff04::1")
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(ROUTER_1_2, ["ff04::1"],
-                                  should_send=True,
-                                  expect_mlr_rsp=True,
-                                  expect_mlr_rsp_status=0)
-        self.assertNotIn(ipaddress.IPv6Address("ff04::1"), bbr.multicast_listener_list())
-
-        router.del_ipmaddr("ff04::1")
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.flush_all()
-
-        # Configure next response to 2
-        bbr.set_next_mlr_response(2)
-        router.add_ipmaddr("ff04::2")
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(ROUTER_1_2, ["ff04::2"],
-                                  should_send=True,
-                                  expect_mlr_rsp=True,
-                                  expect_mlr_rsp_status=2)
-
-        router.del_ipmaddr("ff04::2")
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.flush_all()
-
-        # Configure next response to 4
-        bbr.set_next_mlr_response(4)
-        router.add_ipmaddr("ff04::4")
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(ROUTER_1_2, ["ff04::4"],
-                                  should_send=True,
-                                  expect_mlr_rsp=True,
-                                  expect_mlr_rsp_status=4)
-
-        # The MA should be eventually registered after reregistration
-        self.simulator.go(REREG_DELAY + WAIT_REDUNDANCE)
-        self.assertIn(ipaddress.IPv6Address("ff04::4"), bbr.multicast_listener_list())
-
-        router.del_ipmaddr("ff04::4")
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.flush_all()
-
-    def testCommissionerRegisterMulticastListeners(self):
-        self._bootstrap()
-
-        # Use ROUTER_1_2 as the Commissioner
-        commissioiner = self.nodes[ROUTER_1_2]
-
-        self.assertEqual((0, []), commissioiner.register_multicast_listener("ff04::1"))
-
-        commissioiner.commissioner_start()
-        self.simulator.go(10)
-
-        # Now the Commissioner should be able to register MAs
-        for ip in ["ff04::1", "ff04::2"]:
-            status, failed_ips = commissioiner.register_multicast_listener(ip)
-            self.assertTrue(status == 0 and not failed_ips)
-            self.__check_multicast_listener(ip, expect_mlr_timeout_range=[290, 300])
-
-        # Register existing MA with a new timeout should be able to update the timeout
-        for ip in ["ff04::1", "ff04::2"]:
-            status, failed_ips = commissioiner.register_multicast_listener(ip, timeout=1000)
-            self.assertTrue(status == 0 and not failed_ips)
-            self.__check_multicast_listener(ip, expect_mlr_timeout_range=[990, 1000])
-
-        # Register MAs with given timeouts
-        for ip, timeout in [("ff05::1", 400), ("ff05::2", 500), ("ff05::3", 600)]:
-            status, failed_ips = commissioiner.register_multicast_listener(ip, timeout=timeout)
-            self.assertTrue(status == 0 and not failed_ips)
-            self.__check_multicast_listener(ip, expect_mlr_timeout_range=[timeout - 10, timeout])
-
-        # Register multiple MAs with one call
-        ips = ["ff05::4", "ff05::5", "ff05::6"]
-        status, failed_ips = commissioiner.register_multicast_listener(*ips, timeout=700)
-        self.assertTrue(status == 0 and not failed_ips)
-        for ip in ips:
-            self.__check_multicast_listener(ip, expect_mlr_timeout_range=[690, 700])
-
-        # Register multiple MAs with one call (without timeout)
-        ips = ["ff05::7", "ff05::8", "ff05::9"]
-        status, failed_ips = commissioiner.register_multicast_listener(*ips)
-        self.assertTrue(status == 0 and not failed_ips)
-        for ip in ips:
-            self.__check_multicast_listener(ip, expect_mlr_timeout_range=[290, 300])
-
-        # Unregister MAs using timeout=0
-        for ip in ["ff05::1", "ff05::2", "ff05::3"]:
-            status, failed_ips = commissioiner.register_multicast_listener(ip, timeout=0)
-            self.assertTrue(status == 0 and not failed_ips)
-            self.__check_multicast_listener(ip, expect_not_present=True)
-
-        # Unregister multiple MAs
-        ips = ["ff05::4", "ff05::5", "ff05::6"]
-        status, failed_ips = commissioiner.register_multicast_listener(*ips, timeout=0)
-        self.assertTrue(status == 0 and not failed_ips)
-        self.__check_multicast_listener(ip, expect_not_present=True)
-
-        # Remove MAs that are not subscribed should not fail
-        ips = ["ff06::1", "ff02::1", "2001::1"]
-        status, failed_ips = commissioiner.register_multicast_listener(*ips, timeout=0)
-        self.assertTrue(status == 0 and not failed_ips)
-        self.__check_multicast_listener(ip, expect_not_present=True)
-
-        # Register invalid MAs should fail
-        for ip in ["ff02::1", "ff03::1", "2001::1"]:
-            status, failed_ips = commissioiner.register_multicast_listener(ip)
-            self.assertEqual(status, 2)
-            self.assertEqual(set(failed_ips), {ipaddress.IPv6Address(ip)})
-            self.__check_multicast_listener(ip, expect_not_present=True)
-
-        # Register valid MAs with invalid MAs should succeed partially
-        ips = ["ff05::1", "ff05::2", "ff02::1", "2001::1"]
-        status, failed_ips = commissioiner.register_multicast_listener(*ips)
-        self.assertEqual(status, 2)
-        self.assertEqual(set(failed_ips), {ipaddress.IPv6Address("ff02::1"), ipaddress.IPv6Address("2001::1")})
-        self.__check_multicast_listener("ff05::1")
-        self.__check_multicast_listener("ff05::2")
-        self.__check_multicast_listener("ff02::1", expect_not_present=True)
-        self.__check_multicast_listener("2001::1", expect_not_present=True)
-
-        # Registering persistent MAs should fail for now
-        status, failed_ips = commissioiner.register_multicast_listener("ff06::1", timeout=0xffffffff)
-        self.assertEqual(status, 3)
-        # "ff06::1" should not be included in failed IPs because all IPs failed
-        self.assertTrue(not failed_ips == 0)
-
-    def __check_multicast_listener(self, *addrs, expect_mlr_timeout_range=None, expect_not_present=False):
-        addrs = map(ipaddress.IPv6Address, addrs)
-        listeners = self.nodes[BBR_1].multicast_listener_list()
-        logging.info("__check_multicast_listener get listeners: %s", listeners)
-
-        for addr in addrs:
-            if not expect_not_present:
-                self.assertIn(addr, listeners)
-                if expect_mlr_timeout_range is not None:
-                    self.assertGreaterEqual(listeners[addr], expect_mlr_timeout_range[0])
-                    self.assertLessEqual(listeners[addr], expect_mlr_timeout_range[1])
-            else:
-                self.assertNotIn(addr, listeners)
-
-    def __test_multicast_listeners_table_api(self):
-        self.assertTrue(self.nodes[BBR_1].multicast_listener_list() == {})
-
-        self.nodes[BBR_1].multicast_listener_add("ff04::1")
-        self.assertEqual(1, len(self.nodes[BBR_1].multicast_listener_list()))
-        self.nodes[BBR_1].multicast_listener_add("ff04::2", 300)
-        self.assertEqual(2, len(self.nodes[BBR_1].multicast_listener_list()))
-        self.nodes[BBR_1].multicast_listener_clear()
-        self.assertTrue(self.nodes[BBR_1].multicast_listener_list() == {})
-
-    def __test_multicast_listeners_table_add(self):
-        self.assertTrue(self.nodes[BBR_1].multicast_listener_list() == {})
-
-        all_mas = set()
-
-        CHECK_LIST = [(ROUTER_1_2, "ff04::1"), (FED_1, "ff04::2"), (MED_1, "ff04::3"), (SED_1, "ff04::4")]
-
-        for id, ip in CHECK_LIST:
-            self.nodes[id].add_ipmaddr(ip)
-            self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-            all_mas.add(ipaddress.IPv6Address(ip))
-            self.assertEqual(all_mas, set(self.nodes[BBR_1].multicast_listener_list().keys()))
-
-        # restore
-        for id, ip in CHECK_LIST:
-            self.nodes[id].del_ipmaddr(ip)
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def __test_multicast_listeners_table_expire(self):
-        self.assertEqual({}, self.nodes[BBR_1].multicast_listener_list())
-
-        all_mas = set()
-
-        CHECK_LIST = [(ROUTER_1_2, "ff04::1"), (FED_1, "ff04::2"), (MED_1, "ff04::3"), (SED_1, "ff04::4")]
-
-        for id, ip in CHECK_LIST:
-            self.nodes[id].add_ipmaddr(ip)
-            self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-            all_mas.add(ipaddress.IPv6Address(ip))
-            self.assertEqual(all_mas, set(self.nodes[BBR_1].multicast_listener_list().keys()))
-
-        # remove MAs from nodes, and wait for Multicast Listeners to expire on BBR_1
-        for id, ip in CHECK_LIST:
-            self.nodes[id].del_ipmaddr(ip)
-
-        # Wait for MLR_TIMEOUT/3, and expect Multicast Listeners not to expire.
-        self.simulator.go(MLR_TIMEOUT / 3)
-        self.assertEqual(all_mas, set(self.nodes[BBR_1].multicast_listener_list().keys()))
-
-        # Wait for MLR_TIMEOUT*2/3, and expect all Multicast Listeners to expire.
-        self.simulator.go(MLR_TIMEOUT * 2 / 3 + WAIT_REDUNDANCE)
-        self.assertEqual({}, self.nodes[BBR_1].multicast_listener_list())
-
-    def __test_multicast_listeners_table_full(self):
-        self.assertTrue(self.nodes[BBR_1].multicast_listener_list() == {})
-
-        table = set()
-
-        # Add to Multicast Listeners Table until it's full
-        for i in range(1, 76):
-            self.nodes[BBR_1].multicast_listener_add(f"ff04::{i}")
-            table.add(ipaddress.IPv6Address(f"ff04::{i}"))
-            self.assertEqual(table, set(self.nodes[BBR_1].multicast_listener_list().keys()))
-
-        # Add when Multicast Listeners Table is full should not succeed
-        self.nodes[BBR_1].multicast_listener_add(f"ff05::1")
-        self.assertEqual(table, set(self.nodes[BBR_1].multicast_listener_list().keys()))
-
-        self.flush_all()
-
-        # Expect PBBR to respond with MLR_NO_RESOURCES Multicast Listeners Table when it's full
-        self.nodes[ROUTER_1_2].add_ipmaddr("ff06::1")
-        self.simulator.go(WAIT_REDUNDANCE)
-
-        self.__check_send_mlr_req(ROUTER_1_2,
-                                  "ff06::1",
-                                  should_send=True,
-                                  expect_mlr_rsp=True,
-                                  expect_mlr_rsp_status=4)
-
-        self.assertEqual(table, set(self.nodes[BBR_1].multicast_listener_list().keys()))
-
-        self.nodes[MED_1].add_ipmaddr("ff06::2")
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-
-        self.__check_send_mlr_req(ROUTER_1_2,
-                                  "ff06::2",
-                                  should_send=True,
-                                  expect_mlr_rsp=True,
-                                  expect_mlr_rsp_status=4)
-
-        self.assertEqual(table, set(self.nodes[BBR_1].multicast_listener_list().keys()))
-
-        # the ROUTER_1_2 should be resending both ff06::1 and ff06::2
-        for i in range(3):
-            self.simulator.go(REREG_DELAY + WAIT_REDUNDANCE)
-            self.__check_send_mlr_req(ROUTER_1_2, ['ff06::1', 'ff06::2'],
-                                      should_send=True,
-                                      expect_mlr_rsp=True,
-                                      expect_mlr_rsp_status=4)
-
-        # Restore
-        self.nodes[ROUTER_1_2].del_ipmaddr("ff06::1")
-        self.nodes[MED_1].del_ipmaddr("ff06::2")
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def __test_multicast_listeners_table_two_free_slots(self):
-
-        # Add to Multicast Listeners Table until there is only two free slots
-        for i in range(1, 74):
-            self.nodes[BBR_1].multicast_listener_add(f"ff04::{i}")
-
-        self.assertEqual(73, len(self.nodes[BBR_1].multicast_listener_list()))
-
-        self.nodes[MED_1].add_ipmaddr("ff05::1")
-        self.nodes[MED_1].add_ipmaddr("ff05::2")
-        self.nodes[MED_2].add_ipmaddr("ff05::3")
-        self.nodes[MED_2].add_ipmaddr("ff05::4")
-        self.nodes[SED_1].add_ipmaddr("ff05::5")
-        self.nodes[SED_1].add_ipmaddr("ff05::6")
-
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(ROUTER_1_2, ["ff05::1", "ff05::2", "ff05::3", "ff05::4", "ff05::5", "ff05::6"])
-
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-        self.flush_all()
-        # two addresses should be registered, others can not
-        for i in range(3):
-            self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-            self.assertEqual(4, len(set(self.__get_registered_MAs(ROUTER_1_2))))
-
-        # Restore
-        self.nodes[MED_1].del_ipmaddr("ff05::1")
-        self.nodes[MED_1].del_ipmaddr("ff05::2")
-        self.nodes[MED_2].del_ipmaddr("ff05::3")
-        self.nodes[MED_2].del_ipmaddr("ff05::4")
-        self.nodes[SED_1].del_ipmaddr("ff05::5")
-        self.nodes[SED_1].del_ipmaddr("ff05::6")
-
-    def __check_mlr_ok(self, id, is_ftd, is_parent_1p1=False):
-        """Check if MLR works for the node"""
-        # Add MA1 and send MLR.req
-        logging.info("======== checking MLR: Node%d (%s), Parent=%s ========" %
-                     (id, 'FTD' if is_ftd else 'MTD', '1.1' if is_parent_1p1 else '1.2'))
-        expect_mlr_req = is_ftd or is_parent_1p1
-
-        if id == ROUTER_1_2:
-            parent_id = None
-        else:
-            parent_id = ROUTER_1_1 if is_parent_1p1 else ROUTER_1_2
-
-        for addr in [MA1, MA1g, MA2, MA3, MA4]:
-            self.__check_ipmaddr_add(id,
-                                     parent_id,
-                                     addr,
-                                     expect_mlr_req=expect_mlr_req,
-                                     expect_mlr_req_proxied=(not expect_mlr_req))
-
-        for addr in [MA5, MA6]:
-            self.__check_ipmaddr_add(id, parent_id, addr, expect_mlr_req=False, expect_mlr_req_proxied=False)
-        logging.info('=' * 120)
-
-    def __check_ipmaddr_add(self, id, parent_id, addr, expect_mlr_req=True, expect_mlr_req_proxied=False):
-        """Check MLR works for the added multicast address"""
-        logging.info("Node %d: ipmaddr %s" % (id, addr))
-        self.flush_all()
-        self.nodes[id].add_ipmaddr(addr)
-        self.assertTrue(self.nodes[id].has_ipmaddr(addr))
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-
-        self.__check_send_mlr_req(id, addr, should_send=expect_mlr_req, expect_mlr_rsp=expect_mlr_req)
-        # Parent should either forward or proxy the MLR.req
-        if parent_id:
-            self.__check_send_mlr_req(parent_id,
-                                      addr,
-                                      should_send=expect_mlr_req or expect_mlr_req_proxied,
-                                      expect_mlr_rsp=expect_mlr_req_proxied)
-
-        self.__check_rereg(id,
-                           parent_id,
-                           addr,
-                           expect_mlr_req=expect_mlr_req,
-                           expect_mlr_req_proxied=expect_mlr_req_proxied)
-        self.__check_renewing(id,
-                              parent_id,
-                              addr,
-                              expect_mlr_req=expect_mlr_req,
-                              expect_mlr_req_proxied=expect_mlr_req_proxied)
-
-        self.nodes[id].del_ipmaddr(addr)
-        self.simulator.go(1)
-
-    def __check_send_mlr_req(self,
-                             id,
-                             addrs: Union[List[str], str],
-                             should_send=True,
-                             expect_mlr_rsp=False,
-                             expect_mlr_rsp_status=0,
-                             expect_mlr_req_num=None,
-                             expect_unique_reg=False):
-        if isinstance(addrs, str):
-            addrs = [addrs]
-
-        message_ids = []
-        reg_mas = self.__get_registered_MAs(id, expect_mlr_req_num=expect_mlr_req_num, message_ids=message_ids)
-        if should_send:
-            for addr in addrs:
-                self.assertIn(ipaddress.IPv6Address(addr), reg_mas)
-                if expect_unique_reg:
-                    self.assertEqual(1, reg_mas.count(ipaddress.IPv6Address(addr)))
-
-            # BBR should send MLR.rsp ACK
-            if expect_mlr_rsp:
-                message_id = message_ids[0]
-                rsp = self.__expect_MLR_rsp(message_id)
-
-                logging.info('MLR.rsp %s uri_path=%s, payload=%s', rsp, rsp.coap.uri_path, rsp.coap.payload)
-
-                statusTlv = None
-                for tlv in rsp.coap.payload:
-                    if isinstance(tlv, network_layer.Status):
-                        statusTlv = tlv
-                        break
-
-                self.assertIsNotNone(statusTlv)
-                self.assertEqual(expect_mlr_rsp_status, statusTlv.status)
-
-        else:
-            for addr in addrs:
-                self.assertNotIn(ipaddress.IPv6Address(addr), reg_mas)
-
-    def __expect_MLR_rsp(self, message_id):
-        logging.info("Expecting MLR.rsp with message ID = %s", message_id)
-        messages = self.simulator.get_messages_sent_by(self.pbbr_id)
-        logging.info("PBBR %d messages: %s", self.pbbr_id, messages)
-
-        while True:
-            msg = messages.next_coap_message('2.04')
-            logging.info('Check ACK for %s: %s, %s, %s, %s', message_id, msg, msg.coap.message_id, msg.coap.uri_path,
-                         msg.coap.payload)
-            if msg.coap.message_id == message_id:
-                return msg
-
-    def __get_registered_MAs(self, id, expect_mlr_req_num=None, message_ids=None):
-        """Get MAs registered via MLR.req by the node"""
-        messages = self.simulator.get_messages_sent_by(id)
-        reg_mas = []
-        while True:
-            msg = messages.next_coap_message('0.02', '/n/mr', assert_enabled=False)
-            if not msg:
-                break
-
-            logging.info("MLR.req: %s %s" % (msg.coap.message_id, msg.coap.payload))
-            addrs = msg.get_coap_message_tlv(network_layer.IPv6Addresses)
-            reg_mas.append(addrs)
-            if message_ids is not None:
-                message_ids.append(msg.coap.message_id)
-
-        logging.info('Node %d registered MAs: %s' % (id, reg_mas))
-
-        if expect_mlr_req_num is not None:
-            self.assertEqual(len(reg_mas), expect_mlr_req_num)
-
-        # expand from [[...], [...], ...] to [...]
-        reg_mas = [ma for mas in reg_mas for ma in mas]
-
-        return reg_mas
-
-    def __check_renewing(self, id, parent_id, addr, expect_mlr_req=True, expect_mlr_req_proxied=False):
-        """Check if MLR works that a node can renew it's registered MAs"""
-        self.assertEqual(self.pbbr_id, BBR_1)
-        self.flush_all()
-        self.simulator.go(MLR_TIMEOUT + WAIT_REDUNDANCE)
-
-        self.__check_send_mlr_req(id, addr, should_send=expect_mlr_req, expect_mlr_rsp=expect_mlr_req)
-        # Parent should either forward or proxy the MLR.req
-        if parent_id:
-            self.__check_send_mlr_req(parent_id,
-                                      addr,
-                                      should_send=expect_mlr_req or expect_mlr_req_proxied,
-                                      expect_mlr_rsp=expect_mlr_req_proxied)
-
-    def __check_rereg(self, id, parent_id, addr, expect_mlr_req=True, expect_mlr_req_proxied=False):
-        """Check if MLR works that a node can do MLR reregistration when necessary"""
-        self.__check_rereg_seqno(id,
-                                 parent_id,
-                                 addr,
-                                 expect_mlr_req=expect_mlr_req,
-                                 expect_mlr_req_proxied=expect_mlr_req_proxied)
-        self.__check_rereg_pbbr_change(id,
-                                       parent_id,
-                                       addr,
-                                       expect_mlr_req=expect_mlr_req,
-                                       expect_mlr_req_proxied=expect_mlr_req_proxied)
-
-    def __check_rereg_seqno(self, id, parent_id, addr, expect_mlr_req=True, expect_mlr_req_proxied=False):
-        """Check if MLR works that a node can do MLR reregistration when PBBR seqno changes"""
-        # Change seq on PBBR and expect MLR.req within REREG_DELAY
-        self.flush_all()
-        self.pbbr_seq = (self.pbbr_seq + 10) % 256
-        self.nodes[BBR_1].set_backbone_router(seqno=self.pbbr_seq)
-        self.simulator.go(REREG_DELAY + WAIT_REDUNDANCE)
-
-        self.__check_send_mlr_req(id, addr, should_send=expect_mlr_req, expect_mlr_rsp=expect_mlr_req)
-        # Parent should either forward or proxy the MLR.req
-        if parent_id:
-            self.__check_send_mlr_req(parent_id,
-                                      addr,
-                                      should_send=expect_mlr_req or expect_mlr_req_proxied,
-                                      expect_mlr_rsp=expect_mlr_req_proxied)
-
-    def __check_rereg_pbbr_change(self, id, parent_id, addr, expect_mlr_req=True, expect_mlr_req_proxied=False):
-        """Check if MLR works that a node can do MLR reregistration when PBBR changes"""
-        # Make BBR_2 to be Primary and expect MLR.req within REREG_DELAY
-        self.assertEqual(self.pbbr_id, BBR_1)
-
-        self.flush_all()
-        self.nodes[BBR_1].disable_backbone_router()
-        self.simulator.go(BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE)
-        self.assertEqual(self.nodes[BBR_2].get_backbone_router_state(), 'Primary')
-        self.pbbr_id = BBR_2
-
-        self.simulator.go(REREG_DELAY + WAIT_REDUNDANCE)
-
-        self.__check_send_mlr_req(id, addr, should_send=expect_mlr_req, expect_mlr_rsp=expect_mlr_req)
-        # Parent should either forward or proxy the MLR.req
-        if parent_id:
-            self.__check_send_mlr_req(parent_id,
-                                      addr,
-                                      should_send=expect_mlr_req or expect_mlr_req_proxied,
-                                      expect_mlr_rsp=expect_mlr_req_proxied)
-
-        # Restore BBR_1 to be Primary and BBR_2 to be Secondary
-        self.nodes[BBR_2].disable_backbone_router()
-        self.nodes[BBR_1].enable_backbone_router()
-        self.simulator.go(BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE)
-        self.assertEqual(self.nodes[BBR_1].get_backbone_router_state(), 'Primary')
-        self.nodes[BBR_2].enable_backbone_router()
-        self.simulator.go(BBR_REGISTRATION_JITTER + WAIT_REDUNDANCE)
-        self.assertEqual(self.nodes[BBR_2].get_backbone_router_state(), 'Secondary')
-        self.pbbr_id = BBR_1
-
-    def __switch_to_1_1_parent(self):
-        """Check if MLR works when nodes are switching to a 1.1 parent"""
-        # Add MA1 to EDs to prepare for parent switching
-        logging.info("=" * 10 + " switching to 1.1 parent " + '=' * 10)
-
-        self.flush_all()
-
-        self.nodes[FED_1].add_ipmaddr(MA1)
-        self.nodes[MED_1].add_ipmaddr(MA1)
-        self.nodes[SED_1].add_ipmaddr(MA1)
-
-        self.simulator.go(REREG_DELAY + WAIT_REDUNDANCE)
-        self.assertIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(FED_1))
-        self.assertNotIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(MED_1))
-        self.assertNotIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(SED_1))
-
-        self.flush_all()
-
-        # Turn off Router 1.2 and turn on Router 1.1
-        self.nodes[ROUTER_1_2].stop()
-        for id in [FED_1, MED_1, SED_1]:
-            self.nodes[ROUTER_1_1].add_allowlist(self.nodes[id].get_addr64())
-            self.nodes[id].add_allowlist(self.nodes[ROUTER_1_1].get_addr64())
-            self.simulator.go(config.DEFAULT_CHILD_TIMEOUT + WAIT_REDUNDANCE)
-
-            self.assertEqual(self.nodes[id].get_state(), 'child')
-            self.assertEqual(self.nodes[id].get_router_id(), self.nodes[ROUTER_1_1].get_router_id())
-
-        self.simulator.go(REREG_DELAY + WAIT_REDUNDANCE)
-
-        # Verify all FED send MLR.req within REREG_DELAY when parent is 1.1
-        self.assertIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(FED_1))
-        self.assertIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(MED_1))
-        self.assertIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(SED_1))
-
-        self.nodes[FED_1].del_ipmaddr(MA1)
-        self.nodes[MED_1].del_ipmaddr(MA1)
-        self.nodes[SED_1].del_ipmaddr(MA1)
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def __switch_to_1_2_parent(self):
-        """Check if MLR works when nodes are switching to a 1.2 parent"""
-        # Add MA1 to EDs to prepare for parent switching
-        logging.info("=" * 10, "switching to 1.2 parent", '=' * 10)
-
-        self.flush_all()
-
-        self.nodes[FED_1].add_ipmaddr(MA1)
-        self.nodes[MED_1].add_ipmaddr(MA1)
-        self.nodes[SED_1].add_ipmaddr(MA1)
-
-        self.simulator.go(REREG_DELAY + WAIT_REDUNDANCE)
-        self.assertIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(FED_1))
-        self.assertIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(MED_1))
-        self.assertIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(SED_1))
-
-        self.flush_all()
-
-        # Turn off Router 1.1 and turn on Router 1.2
-        self.nodes[ROUTER_1_1].stop()
-        self.nodes[ROUTER_1_2].start()
-        self.simulator.go(config.ROUTER_RESET_DELAY)
-        for id in [FED_1, MED_1, SED_1]:
-            self.simulator.go(config.DEFAULT_CHILD_TIMEOUT + WAIT_REDUNDANCE)
-
-            self.assertEqual(self.nodes[id].get_state(), 'child')
-            self.assertEqual(self.nodes[id].get_router_id(), self.nodes[ROUTER_1_2].get_router_id())
-
-        self.simulator.go(REREG_DELAY + WAIT_REDUNDANCE)
-
-        # Verify only FTD sends MLR.req within REREG_DELAY when parent is 1.2
-        self.assertIn(ipaddress.IPv6Address(MA1), self.__get_registered_MAs(FED_1))
-
-        # MED and SED might still send MLR.req during this period because it could be sending to it's 1.2 parent.
-
-        self.nodes[FED_1].del_ipmaddr(MA1)
-        self.nodes[MED_1].del_ipmaddr(MA1)
-        self.nodes[SED_1].del_ipmaddr(MA1)
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def __check_parent_merge_med_mlr_req(self, meds, parent_id):
-        """Check that the 1.2 parent merge multiple multicast addresses for MED children."""
-        self.flush_all()
-        for med in meds:
-            self.nodes[med].add_ipmaddr(MA1)
-
-        self.nodes[meds[0]].add_ipmaddr(MA2)
-        self.nodes[meds[1]].add_ipmaddr(MA3)
-
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-
-        self.__check_send_mlr_req(parent_id, [MA1, MA2, MA3],
-                                  should_send=True,
-                                  expect_mlr_rsp=True,
-                                  expect_mlr_req_num=1,
-                                  expect_unique_reg=True)
-
-        # restore
-        self.nodes[meds[0]].del_ipmaddr(MA2)
-        self.nodes[meds[1]].del_ipmaddr(MA3)
-        for med in meds:
-            self.nodes[med].del_ipmaddr(MA1)
-
-        self.simulator.go(WAIT_REDUNDANCE)
-
-    def __check_not_send_mlr_req_if_subscribed(self, meds, parent_id):
-        """Check that the 1.2 parent does not send MLR.req if the MA is already subscribed."""
-        # Parent should register MA1 on Netif
-        self.flush_all()
-        self.nodes[parent_id].add_ipmaddr(MA1)
-        self.simulator.go(WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(parent_id, MA1, should_send=True, expect_mlr_rsp=True)
-
-        # Parent should not register MA1 of Child 1 because it's already registered
-        self.flush_all()
-        self.nodes[meds[0]].add_ipmaddr(MA1)
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(parent_id, MA1, should_send=False)
-
-        # Parent should register MA2 of Child 1 because it's new
-        self.flush_all()
-        self.nodes[meds[0]].add_ipmaddr(MA2)
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(parent_id, MA2, should_send=True)
-
-        # Parent should not register MA2 of Child 2 because it's already registered for Child 1
-        self.flush_all()
-        self.nodes[meds[1]].add_ipmaddr(MA2)
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(parent_id, MA2, should_send=False)
-
-        # Parent should register MA3 of Child 2 because it's new
-        self.flush_all()
-        self.nodes[meds[1]].add_ipmaddr(MA3)
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(parent_id, MA3, should_send=True)
-
-        # Parent should not register MA2 and MA3 because they are already registered for Child2 itself
-        self.flush_all()
-        self.nodes[meds[1]].del_ipmaddr(MA2)
-        self.nodes[meds[1]].del_ipmaddr(MA3)
-        self.nodes[meds[1]].add_ipmaddr(MA2)
-        self.nodes[meds[1]].add_ipmaddr(MA3)
-        self.simulator.go(PARENT_AGGREGATE_DELAY + WAIT_REDUNDANCE)
-        self.__check_send_mlr_req(parent_id, [MA2, MA3], should_send=False)
-
-        # Restore
-        self.nodes[parent_id].del_ipmaddr(MA1)
-        self.nodes[meds[0]].del_ipmaddr(MA1)
-        self.nodes[meds[0]].del_ipmaddr(MA2)
-        self.nodes[meds[1]].del_ipmaddr(MA2)
-        self.nodes[meds[1]].del_ipmaddr(MA3)
-        self.simulator.go(WAIT_REDUNDANCE)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/v1_2_test_multicast_registration.py b/tests/scripts/thread-cert/v1_2_test_multicast_registration.py
deleted file mode 100755
index 8001261..0000000
--- a/tests/scripts/thread-cert/v1_2_test_multicast_registration.py
+++ /dev/null
@@ -1,300 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2020, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import command
-import config
-import mle
-import thread_cert
-
-LEADER_1_2 = 1
-MED_1_2 = 2
-SED_1_2 = 3
-MED_1_1 = 4
-SED_1_1 = 5
-ROUTER_1_1 = 6
-MED_1_2_2 = 7
-SED_1_2_2 = 8
-
-WAIT_ATTACH = 5
-WAIT_REDUNDANCE = 3
-ROUTER_SELECTION_JITTER = 1
-
-MA1_LINKLOCAL = 'ff02::1:2:3:4'
-MA2_ADMINSCOPE = 'ff04::1:2:3:4'
-"""
- Topology
-
-             SED_1_2
-                |
-                |
-MED_1_2 --- LEADER_1_2 --- MED_1_1
-                |     \
-                |       \
-             SED_1_1     ROUTER_1_1 --- MED_1_2_2
-                            |
-                            |
-                         SED_1_2_2
-
- 1) Bring up Leader_1_2.
-
- 2) Bring up MED_1_2, which attaches to Thread 1.2 parent, only register MA with scope larger than realm local.
-    a) add MA1_LINKLOCAL which would NOT be registered in AddressRegistrationTLV of Child Update Request.
-    b) add MA2_ADMINSCOPE which would be registered in AddressRegistrationTLV of Child Update Request.
-
- 3) Bring up SED_1_2, which attaches to Thread 1.2 parent, register any external MA for indirect transmission.
-    a) add MA1_LINKLOCAL which would be registered in AddressRegistrationTLV of Child Update Request
-    b) add MA2_ADMINSCOPE which would be registered in AddressRegistrationTLV of Child Update Request.
-
- 4) Bring up MED_1_1, which attaches to Thread 1.2 parent, not register any external MA.
-    a) add MA1_LINKLOCAL which would NOT be registered in AddressRegistrationTLV of Child Update Request.
-    b) add MA2_ADMINSCOPE which would NOT be registered in AddressRegistrationTLV of Child Update Request.
-
- 5) Bring up SED_1_1, which attaches to Thread 1.2 parent, register any external MA for indirect transmission.
-    a) add MA1_LINKLOCAL which would be registered in AddressRegistrationTLV of Child Update Request
-    b) add MA2_ADMINSCOPE which would be registered in AddressRegistrationTLV of Child Update Request.
-
- 6) Bring up ROUTER_1_1.
-
- 7) Bring up MED_1_2_2 which attaches to Thread 1.1 parent, not register any external MA.
-    a) add MA1_LINKLOCAL which would NOT be registered in AddressRegistrationTLV of Child Update Request
-    b) add MA2_ADMINSCOPE which would NOT be registered in AddressRegistrationTLV of Child Update Request.
-
- 8) Bring up SED_1_2_2 which attaches to Thread 1.1 parent, register any external MA for indirect transmission.
-    a) add MA1_LINKLOCAL which would be registered in AddressRegistrationTLV of Child Update Request
-    b) add MA2_ADMINSCOPE which would be registered in AddressRegistrationTLV of Child Update Request.
-
-"""
-
-
-class TestMulticastRegistration(thread_cert.TestCase):
-    TOPOLOGY = {
-        LEADER_1_2: {
-            'version': '1.2',
-            'allowlist': [MED_1_2, SED_1_2, MED_1_1, SED_1_1, ROUTER_1_1],
-        },
-        MED_1_2: {
-            'mode': 'rn',
-            'version': '1.2',
-            'allowlist': [LEADER_1_2],
-        },
-        SED_1_2: {
-            'mode': 'n',
-            'version': '1.2',
-            'allowlist': [LEADER_1_2],
-        },
-        MED_1_1: {
-            'mode': 'rn',
-            'version': '1.1',
-            'allowlist': [LEADER_1_2],
-        },
-        SED_1_1: {
-            'mode': 'n',
-            'version': '1.1',
-            'allowlist': [LEADER_1_2],
-        },
-        ROUTER_1_1: {
-            'version': '1.1',
-            'allowlist': [LEADER_1_2, MED_1_2_2, SED_1_2_2],
-        },
-        MED_1_2_2: {
-            'mode': 'rn',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1],
-        },
-        SED_1_2_2: {
-            'mode': 'n',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1],
-        },
-    }
-    """All nodes are created with default configurations"""
-
-    def __check_multicast_registration(self,
-                                       node,
-                                       multicast_address,
-                                       child_update_request_assert=True,
-                                       in_address_registration=True):
-        ''' Check whether or not the addition of the multicast address on the specific node
-        would trigger Child Update Request for multicast address registration via Address
-        Registration TLV.
-
-        Args:
-            node (int) : The device id
-            multicast_address (string): The multicast address
-            child_update_request_assert (bool): whether or not the addition should trigger Child Update Request
-            in_address_registration (bool): Whether or not the multicast_address should be registered
-        '''
-        # Flush relative message queues.
-        self.flush_nodes([node])
-
-        self.nodes[node].add_ipmaddr(multicast_address)
-        WAIT_TIME = WAIT_REDUNDANCE
-        self.simulator.go(WAIT_TIME)
-
-        messages = self.simulator.get_messages_sent_by(node)
-
-        msg = messages.next_mle_message(mle.CommandType.CHILD_UPDATE_REQUEST,
-                                        assert_enabled=child_update_request_assert)
-
-        if msg:
-            is_in = command.check_address_registration_tlv(msg, multicast_address)
-
-            if in_address_registration:
-                assert is_in, 'Error: Expected {} in AddressRegistrationTLV not found'.format(multicast_address)
-            else:
-                assert not is_in, 'Error: Unexpected {} in AddressRegistrationTLV'.format(multicast_address)
-
-    def test(self):
-
-        # 1) Bring up Leader_1_2.
-        self.nodes[LEADER_1_2].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME * 2)
-        self.assertEqual(self.nodes[LEADER_1_2].get_state(), 'leader')
-
-        # 2) Bring up MED_1_2, which attaches to Thread 1.2 parent, only register MA with scope larger than realm local.
-        self.nodes[MED_1_2].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[MED_1_2].get_state(), 'child')
-
-        # 2a) add MA1_LINKLOCAL which would NOT be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(MED_1_2,
-                                            MA1_LINKLOCAL,
-                                            child_update_request_assert=False,
-                                            in_address_registration=False)
-
-        # 2b) add MA2_ADMINSCOPE which would be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(MED_1_2,
-                                            MA2_ADMINSCOPE,
-                                            child_update_request_assert=True,
-                                            in_address_registration=True)
-
-        # 3) Bring up SED_1_2, which attaches to Thread 1.2 parent, register any external MA for indirect transmission.
-        self.nodes[SED_1_2].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[SED_1_2].get_state(), 'child')
-
-        # 3a) add MA1_LINKLOCAL which would be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(SED_1_2,
-                                            MA1_LINKLOCAL,
-                                            child_update_request_assert=True,
-                                            in_address_registration=True)
-
-        # 3b) add MA2_ADMINSCOPE which would be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(SED_1_2,
-                                            MA2_ADMINSCOPE,
-                                            child_update_request_assert=True,
-                                            in_address_registration=True)
-
-        # 4) Bring up MED_1_1, which attaches to Thread 1.2 parent, not register any external MA.
-        self.nodes[MED_1_1].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[MED_1_1].get_state(), 'child')
-
-        # 4a) add MA1_LINKLOCAL which would NOT be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(MED_1_1,
-                                            MA1_LINKLOCAL,
-                                            child_update_request_assert=False,
-                                            in_address_registration=False)
-
-        # 4b) add MA2_ADMINSCOPE which would NOT be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(MED_1_1,
-                                            MA2_ADMINSCOPE,
-                                            child_update_request_assert=False,
-                                            in_address_registration=False)
-
-        # 5) Bring up SED_1_1, which attaches to Thread 1.2 parent, register any external MA for indirect transmission.
-        self.nodes[SED_1_1].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[SED_1_1].get_state(), 'child')
-
-        # 5a) add MA1_LINKLOCAL which would be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(SED_1_1,
-                                            MA1_LINKLOCAL,
-                                            child_update_request_assert=True,
-                                            in_address_registration=True)
-
-        # 5b) add MA2_ADMINSCOPE which would be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(SED_1_1,
-                                            MA2_ADMINSCOPE,
-                                            child_update_request_assert=True,
-                                            in_address_registration=True)
-
-        #6) Bring up ROUTER_1_1.
-        self.nodes[ROUTER_1_1].set_router_selection_jitter(ROUTER_SELECTION_JITTER)
-        self.nodes[ROUTER_1_1].start()
-        WAIT_TIME = WAIT_ATTACH + ROUTER_SELECTION_JITTER
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[ROUTER_1_1].get_state(), 'router')
-
-        # 7) Bring up MED_1_2_2 which attaches to Thread 1.1 parent, not register any external MA.
-        self.nodes[MED_1_2_2].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[MED_1_2_2].get_state(), 'child')
-
-        # 7a) add MA1_LINKLOCAL which would NOT be registered in AddressRegistrationTLV of Child Update Request
-        self.__check_multicast_registration(MED_1_2_2,
-                                            MA1_LINKLOCAL,
-                                            child_update_request_assert=False,
-                                            in_address_registration=False)
-
-        # 7b) add MA2_ADMINSCOPE which would NOT be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(MED_1_2_2,
-                                            MA2_ADMINSCOPE,
-                                            child_update_request_assert=False,
-                                            in_address_registration=False)
-
-        # 8) Bring up SED_1_2_2 which attaches to Thread 1.1 parent, register any external MA for indirect transmission.
-        self.nodes[SED_1_2_2].start()
-        WAIT_TIME = WAIT_ATTACH
-        self.simulator.go(WAIT_TIME)
-        self.assertEqual(self.nodes[SED_1_2_2].get_state(), 'child')
-
-        # 8a) add MA1_LINKLOCAL which would be registered in AddressRegistrationTLV of Child Update Request
-        self.__check_multicast_registration(SED_1_2_2,
-                                            MA1_LINKLOCAL,
-                                            child_update_request_assert=True,
-                                            in_address_registration=True)
-
-        # 8b) add MA2_ADMINSCOPE which would be registered in AddressRegistrationTLV of Child Update Request.
-        self.__check_multicast_registration(SED_1_2_2,
-                                            MA2_ADMINSCOPE,
-                                            child_update_request_assert=True,
-                                            in_address_registration=True)
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/scripts/thread-cert/v1_2_test_parent_selection.py b/tests/scripts/thread-cert/v1_2_test_parent_selection.py
deleted file mode 100755
index caf979f..0000000
--- a/tests/scripts/thread-cert/v1_2_test_parent_selection.py
+++ /dev/null
@@ -1,301 +0,0 @@
-#!/usr/bin/env python3
-#
-#  Copyright (c) 2019, The OpenThread Authors.
-#  All rights reserved.
-#
-#  Redistribution and use in source and binary forms, with or without
-#  modification, are permitted provided that the following conditions are met:
-#  1. Redistributions of source code must retain the above copyright
-#     notice, this list of conditions and the following disclaimer.
-#  2. Redistributions in binary form must reproduce the above copyright
-#     notice, this list of conditions and the following disclaimer in the
-#     documentation and/or other materials provided with the distribution.
-#  3. Neither the name of the copyright holder nor the
-#     names of its contributors may be used to endorse or promote products
-#     derived from this software without specific prior written permission.
-#
-#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
-#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-#  POSSIBILITY OF SUCH DAMAGE.
-#
-
-import unittest
-
-import thread_cert
-import config
-import mle
-
-LEADER_1_2 = 1
-ROUTER_1_1 = 2
-REED_1_2 = 3
-ROUTER_1_2 = 4
-REED_1_1 = 5
-MED_1_1 = 6
-MED_1_2 = 7
-
-# Topology
-#               (lq:2)  (pp:1)
-#     REED_1_2  ----- ROUTER_1_2
-#        |     \    /     |      \
-#        |       \/    REED_1_1    \
-# (lq:2) |      /  \  /  `router`    \
-#        | (lq:2)    \                 \
-#        |  /      /   \                 \
-#       LEADER_1_2 --- ROUTER_1_1 -- MED_1_2
-#                \        |
-#                  \      |
-#                    \    |
-#                       MED_1_1
-#
-# 1) Bring up LEADER_1_2 and ROUTER_1_1,
-# 2) Config link quality (LEADER_1_2->REED_1_2) as 2, bring up REED_1_2 which would attach to ROUTER_1_1
-#    due to higher two-way link quality,
-# 3) Config link quality(LEADER_1_2->ROUTER_1_2) and link quality(REED_1_2->ROUTER_1_2) as 2, bring up
-#    ROUTER_1_2 which would attach to LEADER_1_2 due to active router is preferred,
-# 4) Config parent priority as 1 on ROUTER_1_2, bring up REED_1_1 which would attach to ROUTER_1_2 due to
-#    higher parent priority,
-# 5) Upgrade REED_1_1 to `router` role, bring up MED_1_1 which would attach to LEADER_1_2 which has higher
-#    link quality of 3,
-# 6) Config parent priority as 1 on ROUTER_1_1, bring up MED_1_2 which would attach to ROUTER_1_2 due to
-#    higher version
-#
-
-
-class TestParentSelection(thread_cert.TestCase):
-    TOPOLOGY = {
-        LEADER_1_2: {
-            'version': '1.2',
-            'allowlist': [REED_1_2, ROUTER_1_2, REED_1_1, ROUTER_1_1, MED_1_1],
-        },
-        ROUTER_1_1: {
-            'version': '1.1',
-            'allowlist': [LEADER_1_2, REED_1_2, MED_1_2, MED_1_1],
-        },
-        REED_1_2: {
-            'version': '1.2',
-            'allowlist': [ROUTER_1_2, ROUTER_1_1, LEADER_1_2],
-        },
-        ROUTER_1_2: {
-            'version': '1.2',
-            'allowlist': [REED_1_2, MED_1_2, REED_1_1, LEADER_1_2],
-        },
-        REED_1_1: {
-            'version': '1.1',
-            'allowlist': [ROUTER_1_2, LEADER_1_2]
-        },
-        MED_1_1: {
-            'mode': 'r',
-            'version': '1.1',
-            'allowlist': [LEADER_1_2, ROUTER_1_1],
-        },
-        MED_1_2: {
-            'mode': 'r',
-            'version': '1.2',
-            'allowlist': [ROUTER_1_1, ROUTER_1_2],
-        },
-    }
-    """All nodes are created with default configurations"""
-
-    def test(self):
-
-        self.nodes[LEADER_1_2].start()
-        self.simulator.go(config.LEADER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[LEADER_1_2].get_state(), 'leader')
-
-        self.nodes[ROUTER_1_1].set_router_selection_jitter(1)
-        self.nodes[ROUTER_1_1].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER_1_1].get_state(), 'router')
-
-        # Mesh Impacting Criteria - Highest Two-way link quality
-        # REED_1_2 would attach to ROUTER_1_1
-        # Attach to ROUTER_1_1 which has highest two-way link quality
-
-        # Flush relative message queues
-        self.flush_nodes([LEADER_1_2, ROUTER_1_1])
-
-        self.nodes[LEADER_1_2].set_link_quality(self.nodes[REED_1_2].get_addr64(), 2)
-        self.nodes[REED_1_2].set_router_selection_jitter(1)
-        self.nodes[REED_1_2].set_router_upgrade_threshold(1)
-        self.nodes[REED_1_2].start()
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[REED_1_2].get_state(), 'child')
-
-        # Check Parent Response
-        messages = self.simulator.get_messages_sent_by(ROUTER_1_1)
-        parent_prefer = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_prefer), "Error: Expected parent response not found"
-
-        messages = self.simulator.get_messages_sent_by(LEADER_1_2)
-        parent_cmp = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_cmp), "Error: Expected parent response not found"
-
-        # Known that link margin for link quality 3 is 80 and link quality 2 is 15
-        self.assertGreater((parent_prefer.get_mle_message_tlv(mle.LinkMargin).link_margin -
-                            parent_cmp.get_mle_message_tlv(mle.LinkMargin).link_margin), 20)
-
-        # Check Child Id Request
-        messages = self.simulator.get_messages_sent_by(REED_1_2)
-        msg = messages.next_mle_message(mle.CommandType.CHILD_ID_REQUEST)
-        msg.assertSentToNode(self.nodes[ROUTER_1_1])
-
-        # Mesh Impacting Criteria - Active Routers over REEDs
-        # ROUTER_1_2 would attach to LEADER_1_2
-        # Link quality configuration, so that REED_1_2 has the chance to respond
-
-        # Flush relative message queues
-        self.flush_nodes([LEADER_1_2, REED_1_2])
-
-        self.nodes[LEADER_1_2].set_link_quality(self.nodes[ROUTER_1_2].get_addr64(), 2)
-        self.nodes[REED_1_2].set_link_quality(self.nodes[ROUTER_1_2].get_addr64(), 2)
-        self.nodes[ROUTER_1_2].set_router_selection_jitter(1)
-        self.nodes[ROUTER_1_2].start()
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[ROUTER_1_2].get_state(), 'router')
-
-        # Check Parent Response
-        messages = self.simulator.get_messages_sent_by(LEADER_1_2)
-
-        # Skip first response for first parent request
-        assert messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-
-        parent_prefer = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_prefer), "Error: Expected parent response not found"
-
-        messages = self.simulator.get_messages_sent_by(REED_1_2)
-        parent_cmp = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_cmp), "Error: Expected parent response not found"
-
-        self.assertEqual(
-            parent_prefer.get_mle_message_tlv(mle.LinkMargin).link_margin,
-            parent_cmp.get_mle_message_tlv(mle.LinkMargin).link_margin)
-
-        # Check Child Id Request
-        messages = self.simulator.get_messages_sent_by(ROUTER_1_2)
-        msg = messages.next_mle_message(mle.CommandType.CHILD_ID_REQUEST)
-        msg.assertSentToNode(self.nodes[LEADER_1_2])
-
-        # Mesh Impacting Criteria - Highest Parent Priority value in the Connectivity TLV
-        # REED_1_1 would attach to ROUTER_1_2
-
-        # Flush relative message queues
-        self.flush_nodes([LEADER_1_2, ROUTER_1_2])
-
-        self.nodes[ROUTER_1_2].set_parent_priority(1)
-        self.nodes[REED_1_1].set_router_selection_jitter(1)
-        self.nodes[REED_1_1].set_router_upgrade_threshold(1)
-        self.nodes[REED_1_1].start()
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[REED_1_1].get_state(), 'child')
-
-        # Check Parent Response
-        messages = self.simulator.get_messages_sent_by(ROUTER_1_2)
-        parent_prefer = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_prefer), "Error: Expected parent response not found"
-
-        messages = self.simulator.get_messages_sent_by(LEADER_1_2)
-        parent_cmp = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_cmp), "Error: Expected parent response not found"
-
-        self.assertEqual(
-            parent_prefer.get_mle_message_tlv(mle.LinkMargin).link_margin,
-            parent_cmp.get_mle_message_tlv(mle.LinkMargin).link_margin)
-
-        self.assertGreater(
-            parent_prefer.get_mle_message_tlv(mle.Connectivity).pp,
-            parent_cmp.get_mle_message_tlv(mle.Connectivity).pp)
-
-        # Check Child Id Request
-        messages = self.simulator.get_messages_sent_by(REED_1_1)
-        msg = messages.next_mle_message(mle.CommandType.CHILD_ID_REQUEST)
-        msg.assertSentToNode(self.nodes[ROUTER_1_2])
-
-        # Mesh Impacting Criteria - Router with the most high-quality neighbors
-        # (Link Quality 3 field in the Connectivity TLV)
-        # MED_1_1 would attach to LEADER_1_2
-
-        self.nodes[REED_1_1].set_state('router')
-        self.simulator.go(config.ROUTER_STARTUP_DELAY)
-        self.assertEqual(self.nodes[REED_1_1].get_state(), 'router')
-
-        # Flush relative message queues
-        self.flush_nodes([LEADER_1_2, ROUTER_1_1])
-
-        self.nodes[MED_1_1].start()
-        self.simulator.go(5)
-        self.assertEqual(self.nodes[MED_1_1].get_state(), 'child')
-
-        # Check Parent Response
-        messages = self.simulator.get_messages_sent_by(LEADER_1_2)
-        parent_prefer = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_prefer), "Error: Expected parent response not found"
-
-        messages = self.simulator.get_messages_sent_by(ROUTER_1_1)
-        parent_cmp = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_cmp), "Error: Expected parent response not found"
-
-        self.assertEqual(
-            parent_prefer.get_mle_message_tlv(mle.LinkMargin).link_margin,
-            parent_cmp.get_mle_message_tlv(mle.LinkMargin).link_margin)
-        self.assertEqual(
-            parent_prefer.get_mle_message_tlv(mle.Connectivity).pp,
-            parent_cmp.get_mle_message_tlv(mle.Connectivity).pp)
-        self.assertGreater(
-            parent_prefer.get_mle_message_tlv(mle.Connectivity).link_quality_3,
-            parent_cmp.get_mle_message_tlv(mle.Connectivity).link_quality_3)
-
-        # Check Child Id Request
-        messages = self.simulator.get_messages_sent_by(MED_1_1)
-        msg = messages.next_mle_message(mle.CommandType.CHILD_ID_REQUEST)
-        msg.assertSentToNode(self.nodes[LEADER_1_2])
-
-        # Child Impacting Criteria - A Version number in the Version TLV
-        # equal to or higher than the version that implements features
-        # desirable to the Child MED_1_2 would attach to ROUTER_1_2
-
-        # Flush relative message queues
-        self.flush_nodes([ROUTER_1_2, ROUTER_1_1])
-
-        self.nodes[ROUTER_1_1].set_parent_priority(1)
-        self.nodes[MED_1_2].start()
-        self.simulator.go(15)
-        self.assertEqual(self.nodes[MED_1_2].get_state(), 'child')
-
-        # Check Parent Response
-        messages = self.simulator.get_messages_sent_by(ROUTER_1_2)
-        parent_prefer = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_prefer), "Error: Expected parent response not found"
-
-        messages = self.simulator.get_messages_sent_by(ROUTER_1_1)
-        parent_cmp = messages.next_mle_message(mle.CommandType.PARENT_RESPONSE)
-        assert (parent_cmp), "Error: Expected parent response not found"
-
-        self.assertEqual(
-            parent_prefer.get_mle_message_tlv(mle.LinkMargin).link_margin,
-            parent_cmp.get_mle_message_tlv(mle.LinkMargin).link_margin)
-        self.assertEqual(
-            parent_prefer.get_mle_message_tlv(mle.Connectivity).pp,
-            parent_cmp.get_mle_message_tlv(mle.Connectivity).pp)
-        self.assertEqual(
-            parent_prefer.get_mle_message_tlv(mle.Connectivity).link_quality_3,
-            parent_cmp.get_mle_message_tlv(mle.Connectivity).link_quality_3)
-        self.assertGreater(
-            parent_prefer.get_mle_message_tlv(mle.Version).version,
-            parent_cmp.get_mle_message_tlv(mle.Version).version)
-
-        # Check Child Id Request
-        messages = self.simulator.get_messages_sent_by(MED_1_2)
-        msg = messages.next_mle_message(mle.CommandType.CHILD_ID_REQUEST)
-        msg.assertSentToNode(self.nodes[ROUTER_1_2])
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/tests/toranj/cli/cli.py b/tests/toranj/cli/cli.py
index aec0b9d..d1c42b2 100644
--- a/tests/toranj/cli/cli.py
+++ b/tests/toranj/cli/cli.py
@@ -421,6 +421,12 @@
     def set_vendor_app_url(self, url):
         return self._cli_no_output('vendor appurl', url)
 
+    def get_vendor_oui(self):
+        return self._cli_single_output('vendor oui')
+
+    def set_vendor_oui(self, oui):
+        return self._cli_no_output('vendor oui', oui)
+
     #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     # netdata
 
diff --git a/tests/toranj/cli/test-008-multicast-traffic.py b/tests/toranj/cli/test-008-multicast-traffic.py
index d83bd1f..7b3dc5a 100755
--- a/tests/toranj/cli/test-008-multicast-traffic.py
+++ b/tests/toranj/cli/test-008-multicast-traffic.py
@@ -324,12 +324,23 @@
 
 # sed  =>> site-local mcast addr (on r1, r2, sed) without `multicast-loop`. Expected to receive from [r1, r2].
 
+old_counters = sed.get_ip_counters()
 outputs = sed.cli('ping', mcast_addr)
-verify(len(outputs) >= 3)
+new_counters = sed.get_ip_counters()
+
+verify(len(outputs) == 3)
 for node in [r1, r2]:
     ml_addr = node.get_mleid_ip_addr()
     verify(any(ml_addr in line for line in outputs))
 
+# Validate that the `RxSuccess` counter increased by exactly two (for the
+# two ping replies). This confirms the parent did not forward the original
+# echo request back to the SED, even though the SED is also subscribed to
+# this address. The parent must not forward a multicast packet back to an
+# SED if the packet originated from that SED itself.
+
+verify(int(new_counters['RxSuccess']) == int(old_counters['RxSuccess']) + 2)
+
 # -----------------------------------------------------------------------------------------------------------------------
 # Test finished
 
diff --git a/tests/toranj/cli/test-020-net-diag-vendor-info.py b/tests/toranj/cli/test-020-net-diag-vendor-info.py
index a25e507..141f8bd 100755
--- a/tests/toranj/cli/test-020-net-diag-vendor-info.py
+++ b/tests/toranj/cli/test-020-net-diag-vendor-info.py
@@ -70,6 +70,7 @@
 THREAD_STACK_VERSION_TLV = 28
 MLE_COUNTERS_TLV = 34
 VENDOR_APP_URL = 35
+VENDOR_OUI = 44
 NON_PREFERRED_CHANNELS_TLV = 36
 
 #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -79,11 +80,13 @@
 r1.set_vendor_model('marble')
 r1.set_vendor_sw_version('ot-1.4')
 r1.set_vendor_app_url('https://example.com/vendor-app')
+r1.set_vendor_oui(0x1a2b3c)
 
 verify(r1.get_vendor_name() == 'RD:nest')
 verify(r1.get_vendor_model() == 'marble')
 verify(r1.get_vendor_sw_version() == 'ot-1.4')
 verify(r1.get_vendor_app_url() == 'https://example.com/vendor-app')
+verify(r1.get_vendor_oui() == '1A-2B-3C')
 
 #- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 # Check invalid names (too long)
@@ -178,6 +181,13 @@
 verify(result[1].startswith("Vendor App URL:"))
 verify(result[1].split(':', 1)[1].strip() == r1.get_vendor_app_url())
 
+# Get vendor OUI (TLV 44)
+
+result = r2.cli('networkdiagnostic get', r1_rloc, VENDOR_OUI)
+verify(len(result) == 2)
+verify(result[1].startswith("Vendor OUI:"))
+verify(result[1].split(':', 1)[1].strip() == r1.get_vendor_oui())
+
 # Get thread stack version (TLV 30)
 
 result = r2.cli('networkdiagnostic get', r1_rloc, THREAD_STACK_VERSION_TLV)
diff --git a/tests/toranj/openthread-core-toranj-config-posix.h b/tests/toranj/openthread-core-toranj-config-posix.h
index f7c6768..c73eb4e 100644
--- a/tests/toranj/openthread-core-toranj-config-posix.h
+++ b/tests/toranj/openthread-core-toranj-config-posix.h
@@ -66,6 +66,8 @@
 #define OPENTHREAD_CONFIG_PLATFORM_UDP_ENABLE 0
 #endif
 
+#define OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE 1
+
 #define OPENTHREAD_CONFIG_PLATFORM_NETIF_ENABLE 1
 
 #define OPENTHREAD_CONFIG_JOINER_ADV_EXPERIMENTAL_ENABLE 1
diff --git a/tests/toranj/openthread-core-toranj-config-simulation.h b/tests/toranj/openthread-core-toranj-config-simulation.h
index b85ab97..5ec453d 100644
--- a/tests/toranj/openthread-core-toranj-config-simulation.h
+++ b/tests/toranj/openthread-core-toranj-config-simulation.h
@@ -89,6 +89,8 @@
 
 #define OPENTHREAD_CONFIG_LOG_OUTPUT OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
 
+#define OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE 1
+
 #define OPENTHREAD_CONFIG_CLI_LOG_INPUT_OUTPUT_ENABLE 1
 
 #define OPENTHREAD_CONFIG_CLI_LOG_INPUT_OUTPUT_LEVEL OT_LOG_LEVEL_INFO
@@ -99,6 +101,8 @@
 
 #define OPENTHREAD_CONFIG_DNSSD_DISCOVERY_PROXY_ENABLE OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE
 
+#define OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE 1
+
 #define OPENTHREAD_CONFIG_HEAP_EXTERNAL_ENABLE 1
 
 #define OPENTHREAD_CONFIG_BORDER_ROUTING_USE_HEAP_ENABLE 1
diff --git a/tests/toranj/openthread-core-toranj-config.h b/tests/toranj/openthread-core-toranj-config.h
index cacbe72..67fd177 100644
--- a/tests/toranj/openthread-core-toranj-config.h
+++ b/tests/toranj/openthread-core-toranj-config.h
@@ -141,12 +141,16 @@
 
 #define OPENTHREAD_CONFIG_IP6_SLAAC_DEPRECATION_INTERVAL 30
 
+#define OPENTHREAD_CONFIG_TCP_ENABLE 0
+
 #define OPENTHREAD_CONFIG_MAC_FILTER_ENABLE 1
 
 #define OPENTHREAD_CONFIG_MAC_FILTER_SIZE 80
 
 #define OPENTHREAD_CONFIG_LOG_LEVEL_DYNAMIC_ENABLE 1
 
+#define OPENTHREAD_CONFIG_LOG_LEVEL_OVERRIDE_ENABLE 1
+
 #define OPENTHREAD_CONFIG_LOG_PREPEND_UPTIME 1
 
 #define OPENTHREAD_CONFIG_LOG_PREPEND_LEVEL 1
@@ -197,6 +201,8 @@
 
 #define OPENTHREAD_CONFIG_SRP_CLIENT_AUTO_START_DEFAULT_MODE 0
 
+#define OPENTHREAD_CONFIG_SNTP_CLIENT_ENABLE 1
+
 #define OPENTHREAD_CONFIG_HISTORY_TRACKER_ENABLE 1
 
 #define OPENTHREAD_CONFIG_DNSSD_SERVER_ENABLE 1
diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt
index fbd1da0..bf27495 100644
--- a/tests/unit/CMakeLists.txt
+++ b/tests/unit/CMakeLists.txt
@@ -202,6 +202,7 @@
 ot_unit_test(array)
 ot_unit_test(binary_search)
 ot_unit_test(bit_utils)
+ot_unit_test(bit_set)
 ot_unit_test(checksum)
 ot_unit_test(child)
 ot_unit_test(child_table)
@@ -242,15 +243,16 @@
 ot_unit_test(msg_backed_array)
 ot_unit_test(multicast_listeners_table)
 ot_unit_test(nat64)
-ot_unit_test(ndproxy_table)
 ot_unit_test(netif)
 ot_unit_test(network_data)
 ot_unit_test(network_name)
 ot_unit_test(offset_range)
 ot_unit_test(pool)
+ot_unit_test(plat_tcp)
 ot_unit_test(power_calibration)
 ot_unit_test(priority_queue)
 ot_unit_test(pskc)
+ot_unit_test(random)
 ot_unit_test(routing_manager)
 ot_unit_test(seeker)
 ot_unit_test(serial_number)
@@ -269,6 +271,7 @@
 ot_unit_test(toolchain test_toolchain_c.c)
 ot_unit_test(trickle_timer)
 ot_unit_test(url)
+ot_unit_test(vendor_oui)
 
 ot_unit_ncp_test(cli)
 ot_unit_ncp_test(dnssd)
diff --git a/tests/unit/test_aes.cpp b/tests/unit/test_aes.cpp
index f82f28a..507b4e6 100644
--- a/tests/unit/test_aes.cpp
+++ b/tests/unit/test_aes.cpp
@@ -47,7 +47,7 @@
 
     uint8_t test[] = {0x08, 0xD0, 0x84, 0x21, 0x43, 0x01, 0x00, 0x00, 0x00, 0x00, 0x48, 0xDE,
                       0xAC, 0x02, 0x05, 0x00, 0x00, 0x00, 0x55, 0xCF, 0x00, 0x00, 0x51, 0x52,
-                      0x53, 0x54, 0x22, 0x3B, 0xC1, 0xEC, 0x84, 0x1A, 0xB5, 0x53};
+                      0x53, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
 
     uint8_t encrypted[] = {0x08, 0xD0, 0x84, 0x21, 0x43, 0x01, 0x00, 0x00, 0x00, 0x00, 0x48, 0xDE,
                            0xAC, 0x02, 0x05, 0x00, 0x00, 0x00, 0x55, 0xCF, 0x00, 0x00, 0x51, 0x52,
@@ -57,34 +57,39 @@
                            0xAC, 0x02, 0x05, 0x00, 0x00, 0x00, 0x55, 0xCF, 0x00, 0x00, 0x51, 0x52,
                            0x53, 0x54, 0x22, 0x3B, 0xC1, 0xEC, 0x84, 0x1A, 0xB5, 0x53};
 
-    otInstance    *instance = testInitInstance();
-    Crypto::AesCcm aesCcm;
+    otInstance    *instance      = testInitInstance();
     uint32_t       headerLength  = sizeof(test) - 8;
     uint32_t       payloadLength = 0;
     uint8_t        tagLength     = 8;
+    Crypto::AesCcm aesCcm;
 
     uint8_t nonce[] = {
         0xAC, 0xDE, 0x48, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x05, 0x02,
     };
 
+    printf("TestMacBeaconFrame\n");
+
     VerifyOrQuit(instance != nullptr);
 
     aesCcm.SetKey(key, sizeof(key));
-    aesCcm.Init(headerLength, payloadLength, tagLength, nonce, sizeof(nonce));
-    aesCcm.Header(test, headerLength);
-    VerifyOrQuit(aesCcm.GetTagLength() == tagLength);
-    aesCcm.Finalize(test + headerLength);
-
+    aesCcm.SetNonce(nonce, sizeof(nonce));
+    aesCcm.SetAuthData(test, headerLength);
+    aesCcm.SetTagLength(tagLength);
+    SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kEncrypt, &test[headerLength], payloadLength));
+    DumpBuffer("encrypted", test, sizeof(test));
     VerifyOrQuit(memcmp(test, encrypted, sizeof(encrypted)) == 0);
 
-    aesCcm.Init(headerLength, payloadLength, tagLength, nonce, sizeof(nonce));
-    aesCcm.Header(test, headerLength);
-    VerifyOrQuit(aesCcm.GetTagLength() == tagLength);
-    aesCcm.Finalize(test + headerLength);
-
+    aesCcm.SetKey(key, sizeof(key));
+    aesCcm.SetNonce(nonce, sizeof(nonce));
+    aesCcm.SetAuthData(test, headerLength);
+    aesCcm.SetTagLength(tagLength);
+    SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kDecrypt, &test[headerLength], payloadLength));
+    DumpBuffer("decrypted", test, sizeof(test));
     VerifyOrQuit(memcmp(test, decrypted, sizeof(decrypted)) == 0);
 
     testFreeInstance(instance);
+
+    printf("\nTestMacBeaconFrame PASSED\n\n");
 }
 
 /**
@@ -122,28 +127,34 @@
         0xAC, 0xDE, 0x48, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x05, 0x06,
     };
 
-    uint8_t tag[kTagLength];
-
     Instance      *instance = testInitInstance();
     Message       *message;
     Crypto::AesCcm aesCcm;
+    uint8_t        plaintext[kPayloadLength] = {0xCE};
+    uint8_t        ciphertext[kPayloadLength];
+    uint8_t        tag[kTagLength];
+    Crypto::Key    cryptoKey;
+
+    printf("TestMacBeaconCommand\n");
 
     VerifyOrQuit(instance != nullptr);
 
+    // Encrypt
     aesCcm.SetKey(key, sizeof(key));
-    aesCcm.Init(kHeaderLength, kPayloadLength, kTagLength, nonce, sizeof(nonce));
-    aesCcm.Header(test, kHeaderLength);
-    aesCcm.Payload(test + kHeaderLength, test + kHeaderLength, kPayloadLength, Crypto::AesCcm::kEncrypt);
-    VerifyOrQuit(aesCcm.GetTagLength() == kTagLength);
-    aesCcm.Finalize(test + kHeaderLength + kPayloadLength);
+    aesCcm.SetNonce(nonce, sizeof(nonce));
+    aesCcm.SetAuthData(test, kHeaderLength);
+    aesCcm.SetTagLength(kTagLength);
+    SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kEncrypt, test + kHeaderLength, kPayloadLength));
+    DumpBuffer("encrypted", test, sizeof(test));
     VerifyOrQuit(memcmp(test, encrypted, sizeof(encrypted)) == 0);
 
-    aesCcm.Init(kHeaderLength, kPayloadLength, kTagLength, nonce, sizeof(nonce));
-    aesCcm.Header(test, kHeaderLength);
-    aesCcm.Payload(test + kHeaderLength, test + kHeaderLength, kPayloadLength, Crypto::AesCcm::kDecrypt);
-    VerifyOrQuit(aesCcm.GetTagLength() == kTagLength);
-    aesCcm.Finalize(test + kHeaderLength + kPayloadLength);
-
+    // Decrypt
+    aesCcm.SetKey(key, sizeof(key));
+    aesCcm.SetNonce(nonce, sizeof(nonce));
+    aesCcm.SetAuthData(test, kHeaderLength);
+    aesCcm.SetTagLength(kTagLength);
+    SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kDecrypt, test + kHeaderLength, kPayloadLength));
+    DumpBuffer("decrypted", test, sizeof(test));
     VerifyOrQuit(memcmp(test, decrypted, sizeof(decrypted)) == 0);
 
     // Verify encryption/decryption in place within a message.
@@ -153,34 +164,53 @@
 
     SuccessOrQuit(message->AppendBytes(test, kHeaderLength + kPayloadLength));
 
-    aesCcm.Init(kHeaderLength, kPayloadLength, kTagLength, nonce, sizeof(nonce));
-    aesCcm.Header(test, kHeaderLength);
-
-    aesCcm.Payload(*message, kHeaderLength, kPayloadLength, Crypto::AesCcm::kEncrypt);
-    VerifyOrQuit(aesCcm.GetTagLength() == kTagLength);
-    aesCcm.Finalize(tag);
-    SuccessOrQuit(message->Append(tag));
+    aesCcm.SetKey(key, sizeof(key));
+    aesCcm.SetNonce(nonce, sizeof(nonce));
+    aesCcm.SetAuthData(test, kHeaderLength);
+    aesCcm.SetTagLength(kTagLength);
+    SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kEncrypt, *message, kHeaderLength));
     VerifyOrQuit(message->GetLength() == sizeof(encrypted));
     VerifyOrQuit(message->Compare(0, encrypted));
 
-    aesCcm.Init(kHeaderLength, kPayloadLength, kTagLength, nonce, sizeof(nonce));
-    aesCcm.Header(test, kHeaderLength);
-    aesCcm.Payload(*message, kHeaderLength, kPayloadLength, Crypto::AesCcm::kDecrypt);
-
-    VerifyOrQuit(message->GetLength() == sizeof(encrypted));
-    VerifyOrQuit(message->Compare(0, decrypted));
+    aesCcm.SetKey(key, sizeof(key));
+    aesCcm.SetNonce(nonce, sizeof(nonce));
+    aesCcm.SetAuthData(test, kHeaderLength);
+    aesCcm.SetTagLength(kTagLength);
+    SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kDecrypt, *message, kHeaderLength));
+    VerifyOrQuit(message->GetLength() == sizeof(decrypted) - kTagLength);
+    VerifyOrQuit(message->CompareBytes(0, decrypted, sizeof(decrypted) - kTagLength));
 
     message->Free();
+
+    // Verify static `Perform()` with separate buffers
+    cryptoKey.Set(key, sizeof(key));
+
+    // Encrypt
+    Crypto::AesCcm::Perform(Crypto::AesCcm::kEncrypt, cryptoKey, kTagLength, nonce, sizeof(nonce), test, kHeaderLength,
+                            plaintext, ciphertext, kPayloadLength, tag);
+
+    VerifyOrQuit(memcmp(ciphertext, encrypted + kHeaderLength, kPayloadLength) == 0);
+    VerifyOrQuit(memcmp(tag, encrypted + kHeaderLength + kPayloadLength, kTagLength) == 0);
+
+    // Decrypt
+    memset(plaintext, 0, sizeof(plaintext));
+    Crypto::AesCcm::Perform(Crypto::AesCcm::kDecrypt, cryptoKey, kTagLength, nonce, sizeof(nonce), test, kHeaderLength,
+                            plaintext, ciphertext, kPayloadLength, tag);
+
+    VerifyOrQuit(memcmp(plaintext, decrypted + kHeaderLength, kPayloadLength) == 0);
+    VerifyOrQuit(memcmp(tag, decrypted + kHeaderLength + kPayloadLength, kTagLength) == 0);
+
     testFreeInstance(instance);
+
+    printf("\nTestMacBeaconCommand PASSED\n\n");
 }
 
-/**
- * Verifies in-place encryption/decryption.
- */
-void TestInPlaceAesCcmProcessing(void)
+void TestAesCcmMessageProcessing(void)
 {
-    static constexpr uint16_t kTagLength    = 4;
-    static constexpr uint32_t kHeaderLength = 19;
+    static constexpr uint16_t kTagLength      = 4;
+    static constexpr uint32_t kAuthDataLength = 19;
+    static constexpr uint16_t kMaxBufferSize  = 1000;
+    static constexpr uint16_t kNumIters       = 16;
 
     static const uint8_t kKey[] = {
         0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf,
@@ -192,13 +222,14 @@
 
     static uint16_t kMessageLengths[] = {30, 400, 800};
 
-    uint8_t tag[kTagLength];
-    uint8_t header[kHeaderLength];
-
-    Crypto::AesCcm aesCcm;
     Instance      *instance = testInitInstance();
+    uint16_t       payloadOffset;
     Message       *message;
     Message       *messageClone;
+    uint8_t        buffer[kMaxBufferSize];
+    Crypto::AesCcm aesCcm;
+
+    printf("\nTestAesCcmMessageProcessing");
 
     VerifyOrQuit(instance != nullptr);
 
@@ -206,45 +237,97 @@
     VerifyOrQuit(message != nullptr);
 
     aesCcm.SetKey(kKey, sizeof(kKey));
+    aesCcm.SetNonce(kNonce, sizeof(kNonce));
+    aesCcm.SetTagLength(kTagLength);
 
     for (uint16_t msgLength : kMessageLengths)
     {
-        printf("msgLength %d\n", msgLength);
+        printf("\n\n- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -");
+        printf("\nmsgLength %u\n", msgLength);
 
         SuccessOrQuit(message->SetLength(0));
 
-        for (uint16_t i = msgLength; i != 0; i--)
+        for (uint16_t i = 0; i < msgLength; i++)
         {
-            SuccessOrQuit(message->Append<uint8_t>(i & 0xff));
+            SuccessOrQuit(message->Append<uint8_t>(static_cast<uint8_t>(i & 0xff) ^ static_cast<uint8_t>(i >> 8)));
         }
 
         messageClone = message->Clone<kNoReservedHeader>();
         VerifyOrQuit(messageClone != nullptr);
         VerifyOrQuit(messageClone->GetLength() == msgLength);
 
-        SuccessOrQuit(message->Read(0, header));
+        SuccessOrQuit(message->Read(0, buffer, msgLength));
+        payloadOffset = kAuthDataLength;
 
-        // Encrypt in place
-        aesCcm.Init(kHeaderLength, msgLength - kHeaderLength, kTagLength, kNonce, sizeof(kNonce));
-        aesCcm.Header(header);
-        aesCcm.Payload(*message, kHeaderLength, msgLength - kHeaderLength, Crypto::AesCcm::kEncrypt);
-
-        // Append the tag
-        aesCcm.Finalize(tag);
-        SuccessOrQuit(message->Append(tag));
-
+        printf("\nEncrypt `message`");
+        aesCcm.SetAuthData(buffer, kAuthDataLength);
+        SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kEncrypt, *message, payloadOffset));
         VerifyOrQuit(message->GetLength() == msgLength + kTagLength);
 
-        // Decrypt in place
-        aesCcm.Init(kHeaderLength, msgLength - kHeaderLength, kTagLength, kNonce, sizeof(kNonce));
-        aesCcm.Header(header);
-        aesCcm.Payload(*message, kHeaderLength, msgLength - kHeaderLength, Crypto::AesCcm::kDecrypt);
+        printf("\nEncrypt same content in `buffer`");
+        SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kEncrypt, buffer + payloadOffset, msgLength - payloadOffset));
 
-        // Check the tag against what is the message
-        aesCcm.Finalize(tag);
-        VerifyOrQuit(message->Compare(msgLength, tag));
+        printf("\nValidate the two ways result in the same encrypted content and tag\n");
+        VerifyOrQuit(message->CompareBytes(0, buffer, msgLength + kTagLength));
 
-        // Check that decrypted message is the same as original (cloned) message
+        // Corrupt the message by modifying a byte at some offset
+        // (random offset or start/end of the tag)and
+        // validate the decryption fails
+
+        for (uint16_t iter = 0; iter < kNumIters; iter++)
+        {
+            Message *corruptedMsg = message->Clone<kNoReservedHeader>();
+            uint16_t offset;
+            uint8_t  byte;
+            uint8_t  randomByte;
+
+            VerifyOrQuit(corruptedMsg != nullptr);
+            VerifyOrQuit(corruptedMsg->GetLength() == message->GetLength());
+
+            switch (iter)
+            {
+            case 0:
+                offset = msgLength; // Start of the tag
+                break;
+            case 1:
+                offset = msgLength + kTagLength - 1; // End of the tag
+                break;
+            case 2:
+                offset = msgLength + kTagLength / 2; // Middle of the tag
+                break;
+            default:
+                offset = Random::NonCrypto::GenerateFromMinUpToExcluding(payloadOffset, msgLength);
+                break;
+            }
+
+            SuccessOrQuit(corruptedMsg->Read<uint8_t>(offset, byte));
+
+            do
+            {
+                randomByte = Random::NonCrypto::Generate<uint8_t>();
+            } while (randomByte == byte);
+
+            corruptedMsg->Write<uint8_t>(offset, randomByte);
+
+            printf("\nCorrupt the message content - modify byte at offset %3u from 0x%02x to 0x%02x", offset, byte,
+                   randomByte);
+
+            VerifyOrQuit(aesCcm.Process(Crypto::AesCcm::kDecrypt, *corruptedMsg, payloadOffset) == kErrorSecurity);
+
+            corruptedMsg->Free();
+        }
+
+        printf("\n\nDecrypt `message`");
+        SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kDecrypt, *message, payloadOffset));
+        VerifyOrQuit(message->GetLength() == msgLength);
+
+        printf("\nDecrypt same content in `buffer`");
+        SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kDecrypt, buffer + payloadOffset, msgLength - payloadOffset));
+
+        printf("\nValidate the two ways result in the same decrypted content");
+        VerifyOrQuit(message->CompareBytes(0, buffer, msgLength));
+
+        printf("\nCheck that decrypted message is the same as original (cloned) message");
         VerifyOrQuit(message->CompareBytes(0, *messageClone, 0, msgLength));
 
         messageClone->Free();
@@ -252,15 +335,129 @@
 
     message->Free();
     testFreeInstance(instance);
+
+    printf("\nTestAesCcmMessageProcessing PASSED\n\n");
 }
 
+#if OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+
+/**
+ * Verifies `otPlatCryptoAesCcmProcessOneShot` directly and via `AesCcm::Process`,
+ * using IEEE 802.15.4-2006 Annex C Section C.2.3
+ * (MAC command frame: 29-byte header, 1-byte payload, 8-byte MIC).
+ */
+void TestPlatformCcmSinglePart(void)
+{
+    static const uint8_t kKey[] = {
+        0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf,
+    };
+
+    static const uint8_t kNonce[] = {
+        0xAC, 0xDE, 0x48, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x05, 0x06,
+    };
+
+    static constexpr uint32_t kHeaderLength  = 29;
+    static constexpr uint32_t kPayloadLength = 1;
+    static constexpr uint8_t  kTagLength     = 8;
+    static constexpr uint32_t kFrameLength   = kHeaderLength + kPayloadLength + kTagLength;
+
+    static const uint8_t kPlainFrame[kHeaderLength + kPayloadLength] = {
+        0x2B, 0xDC, 0x84, 0x21, 0x43, 0x02, 0x00, 0x00, 0x00, 0x00, 0x48, 0xDE, 0xAC, 0xFF, 0xFF,
+        0x01, 0x00, 0x00, 0x00, 0x00, 0x48, 0xDE, 0xAC, 0x06, 0x05, 0x00, 0x00, 0x00, 0x01, 0xCE,
+    };
+
+    static const uint8_t kEncryptedFrame[kFrameLength] = {
+        0x2B, 0xDC, 0x84, 0x21, 0x43, 0x02, 0x00, 0x00, 0x00, 0x00, 0x48, 0xDE, 0xAC,
+        0xFF, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x48, 0xDE, 0xAC, 0x06, 0x05, 0x00,
+        0x00, 0x00, 0x01, 0xD8, 0x4F, 0xDE, 0x52, 0x90, 0x61, 0xF9, 0xC6, 0xF1,
+    };
+
+    otInstance              *instance = testInitInstance();
+    uint8_t                  frame[kFrameLength];
+    otPlatCryptoAesCcmConfig config;
+
+    printf("TestPlatformCcmSinglePart\n");
+
+    VerifyOrQuit(instance != nullptr);
+
+    config.mKey.mKey        = kKey;
+    config.mKey.mKeyLength  = sizeof(kKey);
+    config.mKey.mKeyRef     = 0;
+    config.mNonce           = kNonce;
+    config.mNonceLength     = sizeof(kNonce);
+    config.mTagLength       = kTagLength;
+    config.mHeaderLength    = kHeaderLength;
+    config.mPlainTextLength = kPayloadLength;
+
+    // Direct encrypt/decrypt round-trip through the platform hook.
+    memcpy(frame, kPlainFrame, sizeof(kPlainFrame));
+    memset(frame + kHeaderLength + kPayloadLength, 0, kTagLength);
+    SuccessOrQuit(otPlatCryptoAesCcmProcessOneShot(true, &config, frame, frame + kHeaderLength));
+    DumpBuffer("encrypted", frame, sizeof(frame));
+    VerifyOrQuit(memcmp(frame, kEncryptedFrame, kFrameLength) == 0);
+
+    SuccessOrQuit(otPlatCryptoAesCcmProcessOneShot(false, &config, frame, frame + kHeaderLength));
+    DumpBuffer("decrypted", frame, kHeaderLength + kPayloadLength);
+    VerifyOrQuit(memcmp(frame, kPlainFrame, kHeaderLength + kPayloadLength) == 0);
+
+    // Tag corruption must be rejected.
+    memcpy(frame, kPlainFrame, sizeof(kPlainFrame));
+    memset(frame + kHeaderLength + kPayloadLength, 0, kTagLength);
+    SuccessOrQuit(otPlatCryptoAesCcmProcessOneShot(true, &config, frame, frame + kHeaderLength));
+    frame[kHeaderLength + kPayloadLength] ^= 0xFF;
+    VerifyOrQuit(otPlatCryptoAesCcmProcessOneShot(false, &config, frame, frame + kHeaderLength) == kErrorSecurity);
+
+    memcpy(frame, kPlainFrame, sizeof(kPlainFrame));
+    memset(frame + kHeaderLength + kPayloadLength, 0, kTagLength);
+    SuccessOrQuit(otPlatCryptoAesCcmProcessOneShot(true, &config, frame, frame + kHeaderLength));
+    frame[kFrameLength - 1] ^= 0x01;
+    VerifyOrQuit(otPlatCryptoAesCcmProcessOneShot(false, &config, frame, frame + kHeaderLength) == kErrorSecurity);
+
+    // AesCcm::Process must produce the same result as the direct platform call.
+    {
+        uint8_t        directResult[kFrameLength];
+        uint8_t        aesCcmResult[kFrameLength];
+        Crypto::AesCcm aesCcm;
+
+        memcpy(directResult, kPlainFrame, sizeof(kPlainFrame));
+        memset(directResult + kHeaderLength + kPayloadLength, 0, kTagLength);
+        SuccessOrQuit(otPlatCryptoAesCcmProcessOneShot(true, &config, directResult, directResult + kHeaderLength));
+
+        memcpy(aesCcmResult, kPlainFrame, sizeof(kPlainFrame));
+        memset(aesCcmResult + kHeaderLength + kPayloadLength, 0, kTagLength);
+        aesCcm.SetKey(kKey, sizeof(kKey));
+        aesCcm.SetNonce(kNonce, sizeof(kNonce));
+        aesCcm.SetAuthData(aesCcmResult, kHeaderLength);
+        aesCcm.SetTagLength(kTagLength);
+        SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kEncrypt, aesCcmResult + kHeaderLength, kPayloadLength));
+        VerifyOrQuit(memcmp(directResult, aesCcmResult, kFrameLength) == 0);
+        VerifyOrQuit(memcmp(aesCcmResult, kEncryptedFrame, kFrameLength) == 0);
+
+        aesCcm.SetKey(kKey, sizeof(kKey));
+        aesCcm.SetNonce(kNonce, sizeof(kNonce));
+        aesCcm.SetAuthData(aesCcmResult, kHeaderLength);
+        aesCcm.SetTagLength(kTagLength);
+        SuccessOrQuit(aesCcm.Process(Crypto::AesCcm::kDecrypt, aesCcmResult + kHeaderLength, kPayloadLength));
+        VerifyOrQuit(memcmp(aesCcmResult, kPlainFrame, kHeaderLength + kPayloadLength) == 0);
+    }
+
+    testFreeInstance(instance);
+
+    printf("\nTestPlatformCcmSinglePart PASSED\n\n");
+}
+
+#endif // OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+
 } // namespace ot
 
 int main(void)
 {
     ot::TestMacBeaconFrame();
     ot::TestMacCommandFrame();
-    ot::TestInPlaceAesCcmProcessing();
+    ot::TestAesCcmMessageProcessing();
+#if OPENTHREAD_CONFIG_CRYPTO_PLATFORM_CCM_ONE_SHOT_ENABLE
+    ot::TestPlatformCcmSinglePart();
+#endif
     printf("All tests passed\n");
     return 0;
 }
diff --git a/tests/unit/test_bit_set.cpp b/tests/unit/test_bit_set.cpp
new file mode 100644
index 0000000..2dcee7a
--- /dev/null
+++ b/tests/unit/test_bit_set.cpp
@@ -0,0 +1,212 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <openthread/config.h>
+
+#include "test_platform.h"
+#include "test_util.hpp"
+
+#include "common/bit_set.hpp"
+
+namespace ot {
+
+template <uint16_t kNumBits> void TestBitSetProperties(void)
+{
+    BitSet<kNumBits> bitSet;
+    uint16_t         i;
+
+    printf("TestBitSetProperties<%u>\r\n", kNumBits);
+
+    bitSet.Clear();
+    VerifyOrQuit(bitSet.IsEmpty());
+    VerifyOrQuit(bitSet.CountElements() == 0);
+    VerifyOrQuit(bitSet.GetNumBits() == kNumBits);
+    VerifyOrQuit(bitSet.GetMaskSize() == BytesForBitSize(kNumBits));
+
+    for (i = 0; i < kNumBits; i++)
+    {
+        VerifyOrQuit(!bitSet.Has(i));
+    }
+
+    bitSet.Add(0);
+    VerifyOrQuit(!bitSet.IsEmpty());
+    VerifyOrQuit(bitSet.CountElements() == 1);
+    VerifyOrQuit(bitSet.Has(0));
+
+    bitSet.Remove(0);
+    VerifyOrQuit(bitSet.IsEmpty());
+    VerifyOrQuit(!bitSet.Has(0));
+
+    for (i = 0; i < kNumBits; i++)
+    {
+        bitSet.Add(i);
+        VerifyOrQuit(bitSet.Has(i));
+        VerifyOrQuit(bitSet.CountElements() == i + 1);
+    }
+
+    bitSet.Clear();
+    VerifyOrQuit(bitSet.IsEmpty());
+
+    for (i = 0; i < kNumBits; i++)
+    {
+        VerifyOrQuit(!bitSet.Has(i));
+    }
+
+    bitSet.Update(0, true);
+    VerifyOrQuit(bitSet.Has(0));
+    bitSet.Update(0, false);
+    VerifyOrQuit(!bitSet.Has(0));
+}
+
+void TestBitSetOperations(void)
+{
+    BitSet<10> set1;
+    BitSet<10> set2;
+    BitSet<10> unionSet;
+    BitSet<10> intersectSet;
+    BitSet<10> subtractSet;
+
+    printf("TestBitSetOperations\r\n");
+
+    set1.Clear();
+    set2.Clear();
+
+    VerifyOrQuit(set1.IsSubsetOf(set2));
+    VerifyOrQuit(set1.IsSupersetOf(set2));
+    VerifyOrQuit(set1 == set2);
+
+    set1.Add(1);
+    set1.Add(3);
+    set1.Add(8);
+
+    VerifyOrQuit(!set1.IsEmpty());
+    VerifyOrQuit(set1.CountElements() == 3);
+
+    VerifyOrQuit(!set1.IsSubsetOf(set2));
+    VerifyOrQuit(set2.IsSubsetOf(set1));
+    VerifyOrQuit(set1.IsSupersetOf(set2));
+    VerifyOrQuit(set1 != set2);
+
+    VerifyOrQuit(set1.IsSubsetOf(set1));
+    VerifyOrQuit(set1.IsSupersetOf(set1));
+
+    set2.Add(1);
+    set2.Add(8);
+
+    VerifyOrQuit(set2.IsSubsetOf(set1));
+    VerifyOrQuit(!set1.IsSubsetOf(set2));
+    VerifyOrQuit(set1.IsSupersetOf(set2));
+
+    unionSet = set1;
+    unionSet.UnionWith(set2);
+    VerifyOrQuit(unionSet == set1);
+
+    set2.Add(5);
+    unionSet = set1;
+    unionSet.UnionWith(set2);
+    VerifyOrQuit(unionSet.CountElements() == 4);
+    VerifyOrQuit(unionSet.Has(1) && unionSet.Has(3) && unionSet.Has(5) && unionSet.Has(8));
+
+    intersectSet = set1;
+    intersectSet.IntersectWith(set2);
+    VerifyOrQuit(intersectSet.CountElements() == 2);
+    VerifyOrQuit(intersectSet.Has(1) && intersectSet.Has(8));
+    VerifyOrQuit(!intersectSet.Has(3) && !intersectSet.Has(5));
+
+    subtractSet = set1;
+    subtractSet.SubtractWith(set2);
+    VerifyOrQuit(subtractSet.CountElements() == 1);
+    VerifyOrQuit(subtractSet.Has(3));
+}
+
+void TestBitSetComplementAndTidy(void)
+{
+    BitSet<10>     bitSet;
+    const uint8_t *bytes;
+    uint16_t       i;
+    uint8_t        dirtyMask[2];
+
+    printf("TestBitSetComplementAndTidy\r\n");
+
+    bitSet.Clear();
+    bitSet.Complement();
+
+    for (i = 0; i < 10; i++)
+    {
+        VerifyOrQuit(bitSet.Has(i));
+    }
+
+    bytes = bitSet.GetMaskBytes();
+    VerifyOrQuit(bitSet.GetMaskSize() == 2);
+    VerifyOrQuit(bytes[0] == 0xff);
+    VerifyOrQuit(bytes[1] == 0xc0);
+
+    bitSet.Complement();
+    VerifyOrQuit(bitSet.IsEmpty());
+    VerifyOrQuit(bytes[0] == 0x00);
+    VerifyOrQuit(bytes[1] == 0x00);
+
+    dirtyMask[0] = 0xff;
+    dirtyMask[1] = 0xff;
+    bitSet.SetMask(dirtyMask);
+
+    for (i = 0; i < 10; i++)
+    {
+        VerifyOrQuit(bitSet.Has(i));
+    }
+
+    VerifyOrQuit(bitSet.CountElements() == 10);
+
+    bytes = bitSet.GetMaskBytes();
+    VerifyOrQuit(bytes[0] == 0xff);
+    VerifyOrQuit(bytes[1] == 0xc0);
+}
+
+void TestBitSet(void)
+{
+    TestBitSetProperties<1>();
+    TestBitSetProperties<7>();
+    TestBitSetProperties<8>();
+    TestBitSetProperties<9>();
+    TestBitSetProperties<16>();
+    TestBitSetProperties<17>();
+    TestBitSetProperties<100>();
+
+    TestBitSetOperations();
+    TestBitSetComplementAndTidy();
+}
+
+} // namespace ot
+
+int main(void)
+{
+    ot::TestBitSet();
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/unit/test_bit_utils.cpp b/tests/unit/test_bit_utils.cpp
index a141a85..dd10cc9 100644
--- a/tests/unit/test_bit_utils.cpp
+++ b/tests/unit/test_bit_utils.cpp
@@ -37,23 +37,28 @@
 
 void TestCountBitsInMask(void)
 {
-    VerifyOrQuit(CountBitsInMask<uint8_t>(0) == 0);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(1) == 1);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(2) == 1);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(3) == 2);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(4) == 1);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(7) == 3);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(11) == 3);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(15) == 4);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(0x11) == 2);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(0xef) == 7);
-    VerifyOrQuit(CountBitsInMask<uint8_t>(0xff) == 8);
+    VerifyOrQuit(CountBitsInMask(0) == 0);
+    VerifyOrQuit(CountBitsInMask(1) == 1);
+    VerifyOrQuit(CountBitsInMask(2) == 1);
+    VerifyOrQuit(CountBitsInMask(3) == 2);
+    VerifyOrQuit(CountBitsInMask(4) == 1);
+    VerifyOrQuit(CountBitsInMask(7) == 3);
+    VerifyOrQuit(CountBitsInMask(11) == 3);
+    VerifyOrQuit(CountBitsInMask(15) == 4);
+    VerifyOrQuit(CountBitsInMask(0x11) == 2);
+    VerifyOrQuit(CountBitsInMask(0xef) == 7);
+    VerifyOrQuit(CountBitsInMask(0xff) == 8);
 
-    VerifyOrQuit(CountBitsInMask<uint16_t>(0) == 0);
-    VerifyOrQuit(CountBitsInMask<uint16_t>(0xff00) == 8);
-    VerifyOrQuit(CountBitsInMask<uint16_t>(0xff) == 8);
-    VerifyOrQuit(CountBitsInMask<uint16_t>(0xaa55) == 8);
-    VerifyOrQuit(CountBitsInMask<uint16_t>(0xffff) == 16);
+    VerifyOrQuit(CountBitsInMask(0xff00) == 8);
+    VerifyOrQuit(CountBitsInMask(0xaa55) == 8);
+    VerifyOrQuit(CountBitsInMask(0xffff) == 16);
+
+    VerifyOrQuit(CountBitsInMask(0x10000) == 1);
+    VerifyOrQuit(CountBitsInMask(0xff0055) == 12);
+    VerifyOrQuit(CountBitsInMask(0xaa0055) == 8);
+    VerifyOrQuit(CountBitsInMask(0xaa007700) == 10);
+    VerifyOrQuit(CountBitsInMask(0xffff0000) == 16);
+    VerifyOrQuit(CountBitsInMask(0xffffffff) == 32);
 
     printf("TestCountBitsInMask() passed\n");
 }
@@ -130,6 +135,110 @@
     printf("TestCountMatchingBitsExamples() passed\n");
 }
 
+void TestDetermineMinBitSize(void)
+{
+    VerifyOrQuit(DetermineMinBitSizeFor(0) == 1);
+    VerifyOrQuit(DetermineMinBitSizeFor(1) == 1);
+
+    VerifyOrQuit(DetermineMinBitSizeFor(2) == 2);
+    VerifyOrQuit(DetermineMinBitSizeFor(3) == 2);
+
+    VerifyOrQuit(DetermineMinBitSizeFor(4) == 3);
+    VerifyOrQuit(DetermineMinBitSizeFor(6) == 3);
+    VerifyOrQuit(DetermineMinBitSizeFor(7) == 3);
+
+    VerifyOrQuit(DetermineMinBitSizeFor(8) == 4);
+    VerifyOrQuit(DetermineMinBitSizeFor(11) == 4);
+    VerifyOrQuit(DetermineMinBitSizeFor(15) == 4);
+
+    VerifyOrQuit(DetermineMinBitSizeFor(16) == 5);
+    VerifyOrQuit(DetermineMinBitSizeFor(30) == 5);
+    VerifyOrQuit(DetermineMinBitSizeFor(32) == 6);
+
+    VerifyOrQuit(DetermineMinBitSizeFor(127) == 7);
+    VerifyOrQuit(DetermineMinBitSizeFor(128) == 8);
+    VerifyOrQuit(DetermineMinBitSizeFor(255) == 8);
+    VerifyOrQuit(DetermineMinBitSizeFor(256) == 9);
+    VerifyOrQuit(DetermineMinBitSizeFor(500) == 9);
+
+    VerifyOrQuit(DetermineMinBitSizeFor(1000) == 10);
+    VerifyOrQuit(DetermineMinBitSizeFor(1023) == 10);
+    VerifyOrQuit(DetermineMinBitSizeFor(1024) == 11);
+    VerifyOrQuit(DetermineMinBitSizeFor(2000) == 11);
+
+    VerifyOrQuit(DetermineMinBitSizeFor(0xffff) == 16);
+    VerifyOrQuit(DetermineMinBitSizeFor(0x10000) == 17);
+
+    VerifyOrQuit(DetermineMinBitSizeFor(0x7fffffff) == 31);
+    VerifyOrQuit(DetermineMinBitSizeFor(0x80000000) == 32);
+    VerifyOrQuit(DetermineMinBitSizeFor(0xffffffff) == 32);
+
+    printf("TestDetermineMinBitSize() passed\n");
+}
+
+void TestMaskForBitSize(void)
+{
+    VerifyOrQuit(MaskForBitSize<uint8_t>(0) == 0);
+    VerifyOrQuit(MaskForBitSize<uint8_t>(1) == 0x01);
+    VerifyOrQuit(MaskForBitSize<uint8_t>(2) == 0x03);
+    VerifyOrQuit(MaskForBitSize<uint8_t>(3) == 0x07);
+    VerifyOrQuit(MaskForBitSize<uint8_t>(4) == 0x0f);
+    VerifyOrQuit(MaskForBitSize<uint8_t>(7) == 0x7f);
+    VerifyOrQuit(MaskForBitSize<uint8_t>(8) == 0xff);
+    VerifyOrQuit(MaskForBitSize<uint8_t>(9) == 0xff);
+
+    VerifyOrQuit(MaskForBitSize<uint16_t>(0) == 0);
+    VerifyOrQuit(MaskForBitSize<uint16_t>(7) == 0x007f);
+    VerifyOrQuit(MaskForBitSize<uint16_t>(8) == 0x00ff);
+    VerifyOrQuit(MaskForBitSize<uint16_t>(11) == 0x07ff);
+    VerifyOrQuit(MaskForBitSize<uint16_t>(16) == 0xffff);
+    VerifyOrQuit(MaskForBitSize<uint16_t>(20) == 0xffff);
+
+    VerifyOrQuit(MaskForBitSize<uint32_t>(0) == 0);
+    VerifyOrQuit(MaskForBitSize<uint32_t>(16) == 0x0000ffff);
+    VerifyOrQuit(MaskForBitSize<uint32_t>(31) == 0x7fffffff);
+    VerifyOrQuit(MaskForBitSize<uint32_t>(32) == 0xffffffff);
+    VerifyOrQuit(MaskForBitSize<uint32_t>(33) == 0xffffffff);
+
+    VerifyOrQuit(MaskForBitSize<uint64_t>(0) == 0);
+    VerifyOrQuit(MaskForBitSize<uint64_t>(32) == 0xffffffffULL);
+    VerifyOrQuit(MaskForBitSize<uint64_t>(63) == 0x7fffffffffffffffULL);
+    VerifyOrQuit(MaskForBitSize<uint64_t>(64) == 0xffffffffffffffffULL);
+    VerifyOrQuit(MaskForBitSize<uint64_t>(65) == 0xffffffffffffffffULL);
+
+    // Compile-time checks
+
+    static_assert(MaskForBitSize<uint8_t>(0) == 0, "MaskForBitSize<uint8_t>(0) failed");
+    static_assert(MaskForBitSize<uint8_t>(1) == 0x01, "MaskForBitSize<uint8_t>(1) failed");
+    static_assert(MaskForBitSize<uint8_t>(2) == 0x03, "MaskForBitSize<uint8_t>(2) failed");
+    static_assert(MaskForBitSize<uint8_t>(3) == 0x07, "MaskForBitSize<uint8_t>(3) failed");
+    static_assert(MaskForBitSize<uint8_t>(4) == 0x0f, "MaskForBitSize<uint8_t>(4) failed");
+    static_assert(MaskForBitSize<uint8_t>(7) == 0x7f, "MaskForBitSize<uint8_t>(7) failed");
+    static_assert(MaskForBitSize<uint8_t>(8) == 0xff, "MaskForBitSize<uint8_t>(8) failed");
+    static_assert(MaskForBitSize<uint8_t>(9) == 0xff, "MaskForBitSize<uint8_t>(9) failed");
+
+    static_assert(MaskForBitSize<uint16_t>(0) == 0, "MaskForBitSize<uint16_t>(0) failed");
+    static_assert(MaskForBitSize<uint16_t>(7) == 0x007f, "MaskForBitSize<uint16_t>(7) failed");
+    static_assert(MaskForBitSize<uint16_t>(8) == 0x00ff, "MaskForBitSize<uint16_t>(8) failed");
+    static_assert(MaskForBitSize<uint16_t>(11) == 0x07ff, "MaskForBitSize<uint16_t>(11) failed");
+    static_assert(MaskForBitSize<uint16_t>(16) == 0xffff, "MaskForBitSize<uint16_t>(16) failed");
+    static_assert(MaskForBitSize<uint16_t>(20) == 0xffff, "MaskForBitSize<uint16_t>(20) failed");
+
+    static_assert(MaskForBitSize<uint32_t>(0) == 0, "MaskForBitSize<uint32_t>(0) failed");
+    static_assert(MaskForBitSize<uint32_t>(16) == 0x0000ffff, "MaskForBitSize<uint32_t>(16) failed");
+    static_assert(MaskForBitSize<uint32_t>(31) == 0x7fffffff, "MaskForBitSize<uint32_t>(31) failed");
+    static_assert(MaskForBitSize<uint32_t>(32) == 0xffffffff, "MaskForBitSize<uint32_t>(32) failed");
+    static_assert(MaskForBitSize<uint32_t>(33) == 0xffffffff, "MaskForBitSize<uint32_t>(33) failed");
+
+    static_assert(MaskForBitSize<uint64_t>(0) == 0, "MaskForBitSize<uint64_t>(0) failed");
+    static_assert(MaskForBitSize<uint64_t>(32) == 0xffffffffULL, "MaskForBitSize<uint64_t>(32) failed");
+    static_assert(MaskForBitSize<uint64_t>(63) == 0x7fffffffffffffffULL, "MaskForBitSize<uint64_t>(63) failed");
+    static_assert(MaskForBitSize<uint64_t>(64) == 0xffffffffffffffffULL, "MaskForBitSize<uint64_t>(64) failed");
+    static_assert(MaskForBitSize<uint64_t>(65) == 0xffffffffffffffffULL, "MaskForBitSize<uint64_t>(65) failed");
+
+    printf("TestMaskForBitSize() passed\n");
+}
+
 } // namespace ot
 
 int main(void)
@@ -137,6 +246,8 @@
     ot::TestCountBitsInMask();
     ot::TestCountMatchingBitsAllCombinations();
     ot::TestCountMatchingBitsExamples();
+    ot::TestDetermineMinBitSize();
+    ot::TestMaskForBitSize();
 
     printf("All tests passed\n");
     return 0;
diff --git a/tests/unit/test_checksum.cpp b/tests/unit/test_checksum.cpp
index 2d09071..afc0247 100644
--- a/tests/unit/test_checksum.cpp
+++ b/tests/unit/test_checksum.cpp
@@ -159,11 +159,11 @@
     uint8_t  bitOffset;
     uint8_t  byte;
 
-    byteOffset = Random::NonCrypto::GetUint16InRange(0, aMessage.GetLength());
+    byteOffset = Random::NonCrypto::GenerateUpToExcluding<uint16_t>(aMessage.GetLength());
 
     SuccessOrQuit(aMessage.Read(byteOffset, byte));
 
-    bitOffset = Random::NonCrypto::GetUint8InRange(0, kBitsPerByte);
+    bitOffset = Random::NonCrypto::GenerateUpToExcluding<uint8_t>(kBitsPerByte);
 
     byte ^= (1 << bitOffset);
 
@@ -172,7 +172,7 @@
 
 void TestUdpMessageChecksum(void)
 {
-    constexpr uint16_t kMinSize = sizeof(Ip6::Udp::Header);
+    constexpr uint16_t kMinSize = sizeof(Ip6::UdpHeader);
     constexpr uint16_t kMaxSize = Buffer::kSize * 3 + 24;
 
     const char *kSourceAddress = "fd00:1122:3344:5566:7788:99aa:bbcc:ddee";
@@ -185,7 +185,7 @@
     for (uint16_t size = kMinSize; size <= kMaxSize; size++)
     {
         Message         *message = instance->Get<Ip6::Ip6>().NewMessage();
-        Ip6::Udp::Header udpHeader;
+        Ip6::UdpHeader   udpHeader;
         Ip6::MessageInfo messageInfo;
 
         VerifyOrQuit(message != nullptr, "Ip6::NewMesssage() failed");
@@ -240,7 +240,7 @@
 
 void TestIcmp6MessageChecksum(void)
 {
-    constexpr uint16_t kMinSize = sizeof(Ip6::Icmp::Header);
+    constexpr uint16_t kMinSize = sizeof(Ip6::Icmp6Header);
     constexpr uint16_t kMaxSize = Buffer::kSize * 3 + 24;
 
     const char *kSourceAddress = "fd00:feef:dccd:baab:9889:7667:5444:3223";
@@ -252,9 +252,9 @@
 
     for (uint16_t size = kMinSize; size <= kMaxSize; size++)
     {
-        Message          *message = instance->Get<Ip6::Ip6>().NewMessage();
-        Ip6::Icmp::Header icmp6Header;
-        Ip6::MessageInfo  messageInfo;
+        Message         *message = instance->Get<Ip6::Ip6>().NewMessage();
+        Ip6::Icmp6Header icmp6Header;
+        Ip6::MessageInfo messageInfo;
 
         VerifyOrQuit(message != nullptr, "Ip6::NewMesssage() failed");
         SuccessOrQuit(message->SetLength(size));
@@ -309,7 +309,7 @@
 
 void TestTcp4MessageChecksum(void)
 {
-    constexpr size_t kMinSize = sizeof(Ip4::Tcp::Header);
+    constexpr size_t kMinSize = sizeof(Ip4::TcpHeader);
     constexpr size_t kMaxSize = Buffer::kSize * 3 + 24;
 
     const char *kSourceAddress = "12.34.56.78";
@@ -327,8 +327,8 @@
 
     for (uint16_t size = kMinSize; size <= kMaxSize; size++)
     {
-        Message         *message = instance->Get<Ip6::Ip6>().NewMessage();
-        Ip4::Tcp::Header tcpHeader;
+        Message       *message = instance->Get<Ip6::Ip6>().NewMessage();
+        Ip4::TcpHeader tcpHeader;
 
         VerifyOrQuit(message != nullptr, "Ip6::NewMesssage() failed");
         SuccessOrQuit(message->SetLength(size));
@@ -364,7 +364,7 @@
 
 void TestUdp4MessageChecksum(void)
 {
-    constexpr uint16_t kMinSize = sizeof(Ip4::Udp::Header);
+    constexpr uint16_t kMinSize = sizeof(Ip4::UdpHeader);
     constexpr uint16_t kMaxSize = Buffer::kSize * 3 + 24;
 
     const char *kSourceAddress = "12.34.56.78";
@@ -382,8 +382,8 @@
 
     for (uint16_t size = kMinSize; size <= kMaxSize; size++)
     {
-        Message         *message = instance->Get<Ip6::Ip6>().NewMessage();
-        Ip4::Udp::Header udpHeader;
+        Message       *message = instance->Get<Ip6::Ip6>().NewMessage();
+        Ip4::UdpHeader udpHeader;
 
         VerifyOrQuit(message != nullptr, "Ip6::NewMesssage() failed");
         SuccessOrQuit(message->SetLength(size));
@@ -432,8 +432,8 @@
     Ip4::Address source;
     Ip4::Address dest;
 
-    uint8_t           mPayload[sizeof(kExampleIcmpMessage)];
-    Ip4::Icmp::Header icmpHeader;
+    uint8_t          mPayload[sizeof(kExampleIcmpMessage)];
+    Ip4::Icmp4Header icmpHeader;
 
     SuccessOrQuit(message->AppendBytes(kExampleIcmpMessage, sizeof(kExampleIcmpMessage)));
 
diff --git a/tests/unit/test_child.cpp b/tests/unit/test_child.cpp
index ccecdc2..3d3c934 100644
--- a/tests/unit/test_child.cpp
+++ b/tests/unit/test_child.cpp
@@ -39,10 +39,7 @@
 
 static Instance *sInstance;
 
-enum
-{
-    kMaxChildIp6Addresses = OPENTHREAD_CONFIG_MLE_IP_ADDRS_PER_CHILD,
-};
+constexpr uint16_t kMaxChildIp6Addresses = OPENTHREAD_CONFIG_MLE_IP_ADDRS_PER_CHILD;
 
 void VerifyChildIp6Addresses(const Child &aChild, uint8_t aAddressListLength, const Ip6::Address aAddressList[])
 {
@@ -112,7 +109,7 @@
     const uint8_t            meshLocalIidArray[] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88};
     Ip6::InterfaceIdentifier meshLocalIid;
 
-    meshLocalIid.SetBytes(meshLocalIidArray);
+    meshLocalIid.InitFrom(meshLocalIidArray);
 
     sInstance = testInitInstance();
     VerifyOrQuit(sInstance != nullptr);
@@ -126,9 +123,7 @@
     numAddresses = 0;
 
     // First addresses uses the mesh local prefix (mesh-local address).
-    addresses[numAddresses] = sInstance->Get<Mle::Mle>().GetMeshLocalEid();
-    addresses[numAddresses].SetIid(meshLocalIid);
-
+    sInstance->Get<Mle::Mle>().ComposeMeshLocalAddress(meshLocalIid, addresses[numAddresses]);
     numAddresses++;
 
     for (const char *ip6Address : ip6Addresses)
diff --git a/tests/unit/test_child_table.cpp b/tests/unit/test_child_table.cpp
index ad4c69a..9c9af1d 100644
--- a/tests/unit/test_child_table.cpp
+++ b/tests/unit/test_child_table.cpp
@@ -40,10 +40,7 @@
 
 static Instance *sInstance;
 
-enum
-{
-    kMaxChildren = OPENTHREAD_CONFIG_MLE_MAX_CHILDREN,
-};
+constexpr uint16_t kMaxChildren = OPENTHREAD_CONFIG_MLE_MAX_CHILDREN;
 
 struct TestChild
 {
diff --git a/tests/unit/test_dhcp6_pd_client.cpp b/tests/unit/test_dhcp6_pd_client.cpp
index 8cc7644..a1748d3 100644
--- a/tests/unit/test_dhcp6_pd_client.cpp
+++ b/tests/unit/test_dhcp6_pd_client.cpp
@@ -69,11 +69,11 @@
 extern "C" {
 
 #if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
-void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+void otPlatLogOutput(otInstance *, otLogLevel, const char *aLogLine) { printf("   %s\n", aLogLine); }
+#else
+void otPlatLog(otLogLevel, otLogRegion, const char *aFormat, ...)
 {
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-
     va_list args;
 
     printf("   ");
@@ -83,6 +83,7 @@
     printf("\n");
 }
 #endif
+#endif
 
 //----------------------------------------------------------------------------------------------------------------------
 // otPlatAlarams
@@ -197,7 +198,6 @@
     bool                 mHasServerId : 1;
     bool                 mHasOptionRequest : 1;
     bool                 mHasPreference : 1;
-    bool                 mHasServerUnicast : 1;
     bool                 mHasSolMaxRt : 1;
     uint16_t             mStatusCode;
     uint16_t             mElapsedTime;
@@ -205,7 +205,6 @@
     Duid                 mServerDuid;
     ReqOptionArray       mRequestedOptions;
     uint8_t              mPreference;
-    Ip6::Address         mServerAddress;
     uint16_t             mSolMaxRt;
     IaPdArray            mIaPds;
 };
@@ -291,11 +290,6 @@
         Log("  %-13s : %u", "Preference", mPreference);
     }
 
-    if (mHasServerUnicast)
-    {
-        Log("  %-13s : %s", "ServerAddr", mServerAddress.ToString().AsCString());
-    }
-
     if (mHasSolMaxRt)
     {
         Log("  %-13s : %lu", "SolMaxRt", ToUlong(mSolMaxRt));
@@ -328,23 +322,21 @@
     IaPrefix     *iaPrefix;
     union
     {
-        Dhcp6::Option              option;
-        Dhcp6::StatusCodeOption    statusOption;
-        Dhcp6::ElapsedTimeOption   elapsedTimeOption;
-        Dhcp6::PreferenceOption    preferenceOption;
-        Dhcp6::ServerUnicastOption serverUnicastOption;
-        Dhcp6::SolMaxRtOption      solMaxRtOption;
-        Dhcp6::IaPdOption          iaPdOption;
-        Dhcp6::IaPrefixOption      iaPrefixOption;
+        Dhcp6::Option            option;
+        Dhcp6::StatusCodeOption  statusOption;
+        Dhcp6::ElapsedTimeOption elapsedTimeOption;
+        Dhcp6::PreferenceOption  preferenceOption;
+        Dhcp6::SolMaxRtOption    solMaxRtOption;
+        Dhcp6::IaPdOption        iaPdOption;
+        Dhcp6::IaPrefixOption    iaPrefixOption;
     };
 
     Clear();
     offsetRange.InitFromMessageFullLength(aMessage);
 
-    SuccessOrQuit(aMessage.Read(offsetRange, header));
+    SuccessOrQuit(aMessage.ReadAndAdvance(offsetRange, header));
     mMsgType       = header.GetMsgType();
     mTransactionId = header.GetTransactionId();
-    offsetRange.AdvanceOffset(sizeof(header));
 
     while (!offsetRange.IsEmpty())
     {
@@ -416,13 +408,6 @@
             mPreference = preferenceOption.GetPreference();
             break;
 
-        case Dhcp6::Option::kServerUnicast:
-            VerifyOrQuit(!mHasServerUnicast);
-            mHasServerUnicast = true;
-            SuccessOrQuit(aMessage.Read(optionOffsetRange, serverUnicastOption));
-            mServerAddress = serverUnicastOption.GetServerAddress();
-            break;
-
         case Dhcp6::Option::kSolMaxRt:
             VerifyOrQuit(!mHasSolMaxRt);
             mHasSolMaxRt = true;
@@ -431,8 +416,7 @@
             break;
 
         case Dhcp6::Option::kIaPd:
-            SuccessOrQuit(aMessage.Read(optionOffsetRange, iaPdOption));
-            optionOffsetRange.AdvanceOffset(sizeof(iaPdOption));
+            SuccessOrQuit(aMessage.ReadAndAdvance(optionOffsetRange, iaPdOption));
             VerifyOrQuit(!mIaPds.ContainsMatching(iaPdOption.GetIaid()));
 
             iaPd = mIaPds.PushBack();
@@ -492,14 +476,13 @@
     Dhcp6::Header header;
     union
     {
-        Dhcp6::Option              option;
-        Dhcp6::StatusCodeOption    statusOption;
-        Dhcp6::ElapsedTimeOption   elapsedTimeOption;
-        Dhcp6::PreferenceOption    preferenceOption;
-        Dhcp6::ServerUnicastOption serverUnicastOption;
-        Dhcp6::SolMaxRtOption      solMaxRtOption;
-        Dhcp6::IaPdOption          iaPdOption;
-        Dhcp6::IaPrefixOption      iaPrefixOption;
+        Dhcp6::Option            option;
+        Dhcp6::StatusCodeOption  statusOption;
+        Dhcp6::ElapsedTimeOption elapsedTimeOption;
+        Dhcp6::PreferenceOption  preferenceOption;
+        Dhcp6::SolMaxRtOption    solMaxRtOption;
+        Dhcp6::IaPdOption        iaPdOption;
+        Dhcp6::IaPrefixOption    iaPrefixOption;
     };
 
     header.SetMsgType(static_cast<Dhcp6::MsgType>(mMsgType));
@@ -553,13 +536,6 @@
         SuccessOrQuit(aMessage.Append(preferenceOption));
     }
 
-    if (mHasServerUnicast)
-    {
-        serverUnicastOption.Init();
-        serverUnicastOption.SetServerAddress(mServerAddress);
-        SuccessOrQuit(aMessage.Append(serverUnicastOption));
-    }
-
     if (mHasSolMaxRt)
     {
         solMaxRtOption.Init();
@@ -618,19 +594,13 @@
 public:
     void ValidateAsSolicit(void) const;
 
-    void ValidateAsRequest(const Ip6::Prefix     &aPrefix,
-                           const Mac::ExtAddress &aServerMacAddr,
-                           const Ip6::Address    *aServerIp6Addr = nullptr) const;
+    void ValidateAsRequest(const Ip6::Prefix &aPrefix, const Mac::ExtAddress &aServerMacAddr) const;
 
-    void ValidateAsRenew(const Ip6::Prefix     &aPrefix,
-                         const Mac::ExtAddress &aServerMacAddr,
-                         const Ip6::Address    *aServerIp6Addr = nullptr) const;
+    void ValidateAsRenew(const Ip6::Prefix &aPrefix, const Mac::ExtAddress &aServerMacAddr) const;
 
     void ValidateAsRebind(const Ip6::Prefix &aPrefix) const;
 
-    void ValidateAsRelease(const Ip6::Prefix     &aPrefix,
-                           const Mac::ExtAddress &aServerMacAddr,
-                           const Ip6::Address    *aServerIp6Addr = nullptr) const;
+    void ValidateAsRelease(const Ip6::Prefix &aPrefix, const Mac::ExtAddress &aServerMacAddr) const;
 
     uint32_t     mRxTime;
     Ip6::Address mDstAddr;
@@ -638,8 +608,7 @@
 private:
     void Validate(Dhcp6::MsgType         aMsgType,
                   const Ip6::Prefix     &aPrefix,
-                  const Mac::ExtAddress *aServerMacAddr = nullptr,
-                  const Ip6::Address    *aServerIp6Addr = nullptr) const;
+                  const Mac::ExtAddress *aServerMacAddr = nullptr) const;
 };
 
 Ip6::Prefix PrefixFromString(const char *aString, uint8_t aPrefixLength)
@@ -674,48 +643,33 @@
     Validate(Dhcp6::kMsgTypeSolicit, prefix);
 }
 
-void Dhcp6RxMsg::ValidateAsRequest(const Ip6::Prefix     &aPrefix,
-                                   const Mac::ExtAddress &aServerMacAddr,
-                                   const Ip6::Address    *aServerIp6Addr) const
+void Dhcp6RxMsg::ValidateAsRequest(const Ip6::Prefix &aPrefix, const Mac::ExtAddress &aServerMacAddr) const
 {
-    Validate(Dhcp6::kMsgTypeRequest, aPrefix, &aServerMacAddr, aServerIp6Addr);
+    Validate(Dhcp6::kMsgTypeRequest, aPrefix, &aServerMacAddr);
 }
 
-void Dhcp6RxMsg::ValidateAsRenew(const Ip6::Prefix     &aPrefix,
-                                 const Mac::ExtAddress &aServerMacAddr,
-                                 const Ip6::Address    *aServerIp6Addr) const
+void Dhcp6RxMsg::ValidateAsRenew(const Ip6::Prefix &aPrefix, const Mac::ExtAddress &aServerMacAddr) const
 {
-    Validate(Dhcp6::kMsgTypeRenew, aPrefix, &aServerMacAddr, aServerIp6Addr);
+    Validate(Dhcp6::kMsgTypeRenew, aPrefix, &aServerMacAddr);
 }
 
 void Dhcp6RxMsg::ValidateAsRebind(const Ip6::Prefix &aPrefix) const { Validate(Dhcp6::kMsgTypeRebind, aPrefix); }
 
-void Dhcp6RxMsg::ValidateAsRelease(const Ip6::Prefix     &aPrefix,
-                                   const Mac::ExtAddress &aServerMacAddr,
-                                   const Ip6::Address    *aServerIp6Addr) const
+void Dhcp6RxMsg::ValidateAsRelease(const Ip6::Prefix &aPrefix, const Mac::ExtAddress &aServerMacAddr) const
 {
-    Validate(Dhcp6::kMsgTypeRelease, aPrefix, &aServerMacAddr, aServerIp6Addr);
+    Validate(Dhcp6::kMsgTypeRelease, aPrefix, &aServerMacAddr);
 }
 
 void Dhcp6RxMsg::Validate(Dhcp6::MsgType         aMsgType,
                           const Ip6::Prefix     &aPrefix,
-                          const Mac::ExtAddress *aServerMacAddr,
-                          const Ip6::Address    *aServerIp6Addr) const
+                          const Mac::ExtAddress *aServerMacAddr) const
 {
     VerifyOrQuit(mMsgType == aMsgType);
 
-    if (aServerIp6Addr != nullptr)
-    {
-        VerifyOrQuit(mDstAddr == *aServerIp6Addr);
-    }
-    else
-    {
-        VerifyOrQuit(mDstAddr == AddressFromString("ff02::1:2"));
-    }
+    VerifyOrQuit(mDstAddr == AddressFromString("ff02::1:2"));
 
     VerifyOrQuit(!mHasStatus);
     VerifyOrQuit(!mHasPreference);
-    VerifyOrQuit(!mHasServerUnicast);
     VerifyOrQuit(!mHasSolMaxRt);
 
     VerifyOrQuit(mHasElapsedTime);
@@ -764,19 +718,13 @@
         Ip6::Prefix mPrefix;
     };
 
-    void PrepareAdvertise(const Dhcp6RxMsg      &aClientMsg,
-                          const Mac::ExtAddress &aServerMacAddr,
-                          const Ip6::Address    *aServerIp6Addr = nullptr);
-    void PrepareReply(const Dhcp6RxMsg      &aClientMsg,
-                      const Mac::ExtAddress &aServerMacAddr,
-                      const Ip6::Address    *aServerIp6Addr = nullptr);
+    void PrepareAdvertise(const Dhcp6RxMsg &aClientMsg, const Mac::ExtAddress &aServerMacAddr);
+    void PrepareReply(const Dhcp6RxMsg &aClientMsg, const Mac::ExtAddress &aServerMacAddr);
     void AddIaPrefix(const PrefixInfo &aInfo);
     void Send(void);
 };
 
-void Dhcp6TxMsg::PrepareAdvertise(const Dhcp6RxMsg      &aClientMsg,
-                                  const Mac::ExtAddress &aServerMacAddr,
-                                  const Ip6::Address    *aServerIp6Addr)
+void Dhcp6TxMsg::PrepareAdvertise(const Dhcp6RxMsg &aClientMsg, const Mac::ExtAddress &aServerMacAddr)
 {
     Clear();
     mMsgType       = Dhcp6::kMsgTypeAdvertise;
@@ -786,17 +734,9 @@
     mClientDuid    = aClientMsg.mClientDuid;
     mServerDuid.mShared.mEui64.Init(aServerMacAddr);
     mServerDuid.mLength = sizeof(Dhcp6::Eui64Duid);
-
-    if (aServerIp6Addr != nullptr)
-    {
-        mHasServerUnicast = true;
-        mServerAddress    = *aServerIp6Addr;
-    }
 }
 
-void Dhcp6TxMsg::PrepareReply(const Dhcp6RxMsg      &aClientMsg,
-                              const Mac::ExtAddress &aServerMacAddr,
-                              const Ip6::Address    *aServerIp6Addr)
+void Dhcp6TxMsg::PrepareReply(const Dhcp6RxMsg &aClientMsg, const Mac::ExtAddress &aServerMacAddr)
 {
     Clear();
     mMsgType       = Dhcp6::kMsgTypeReply;
@@ -806,12 +746,6 @@
     mClientDuid    = aClientMsg.mClientDuid;
     mServerDuid.mShared.mEui64.Init(aServerMacAddr);
     mServerDuid.mLength = sizeof(Dhcp6::Eui64Duid);
-
-    if (aServerIp6Addr != nullptr)
-    {
-        mHasServerUnicast = true;
-        mServerAddress    = *aServerIp6Addr;
-    }
 }
 
 void Dhcp6TxMsg::AddIaPrefix(const PrefixInfo &aInfo)
@@ -959,7 +893,7 @@
 
 //---------------------------------------------------------------------------------------------------------------------
 
-void TestDhcp6PdPrefixDelegation(bool aShortPrefix, bool aAddServerUnicastOption)
+void TestDhcp6PdPrefixDelegation(bool aShortPrefix)
 {
     uint16_t               heapAllocations;
     Dhcp6TxMsg             txMsg;
@@ -968,12 +902,9 @@
     Ip6::Prefix            adjustedPrefix;
     Mac::ExtAddress        serverMacAddr;
     const DelegatedPrefix *delegatedPrefix;
-    Ip6::Address           serverAddr;
-    const Ip6::Address    *serverIp6Addr;
 
     Log("--------------------------------------------------------------------------------------------");
-    Log("TestDhcp6PdPrefixDelegation(aShortPrefix:%u, aAddServerUnicastOption:%u)", aShortPrefix,
-        aAddServerUnicastOption);
+    Log("TestDhcp6PdPrefixDelegation(aShortPrefix:%u)", aShortPrefix);
 
     InitTest();
 
@@ -992,15 +923,6 @@
         adjustedPrefix = prefix;
     }
 
-    // If `aAddServerUnicastOption` is enabled, we add `ServerUnicastOption`
-    // to Advertise and Reply messages. This prompts the client to use a
-    // specific server unicast address instead of the all-servers multicast
-    // address. The client's use of this address is then validated when it
-    // sends Request or Renew messages throughout all the test steps.
-
-    serverAddr    = AddressFromString("fe80::1");
-    serverIp6Addr = (aAddServerUnicastOption ? &serverAddr : 0);
-
     Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
     Log("Start the client and wait for the first two Solicit messages");
 
@@ -1018,7 +940,7 @@
     Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
     Log("Send Advertisement");
 
-    txMsg.PrepareAdvertise(sDhcp6RxMsgs[0], serverMacAddr, serverIp6Addr);
+    txMsg.PrepareAdvertise(sDhcp6RxMsgs[0], serverMacAddr);
 
     prefixInfo.mIaid              = sDhcp6RxMsgs[0].mIaPds[0].mIaid;
     prefixInfo.mT1                = 2000;
@@ -1040,7 +962,7 @@
     Log("Validate Request message is received");
 
     VerifyOrQuit(sDhcp6RxMsgs.GetLength() == 1);
-    sDhcp6RxMsgs[0].ValidateAsRequest(prefix, serverMacAddr, serverIp6Addr);
+    sDhcp6RxMsgs[0].ValidateAsRequest(prefix, serverMacAddr);
 
     for (uint16_t iter = 0; iter < 3; iter++)
     {
@@ -1049,7 +971,7 @@
 
         sDhcp6RxMsgs.Clear();
 
-        txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr, serverIp6Addr);
+        txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr);
         txMsg.AddIaPrefix(prefixInfo);
 
         txMsg.Send();
@@ -1082,7 +1004,7 @@
         AdvanceTime(5);
         VerifyOrQuit(sDhcp6RxMsgs.GetLength() == 1);
 
-        sDhcp6RxMsgs[0].ValidateAsRenew(prefix, serverMacAddr, serverIp6Addr);
+        sDhcp6RxMsgs[0].ValidateAsRenew(prefix, serverMacAddr);
         VerifyOrQuit(sDhcp6RxMsgs[0].mElapsedTime == 0);
     }
 
@@ -1094,7 +1016,7 @@
 
     for (uint16_t index = 2; index < sDhcp6RxMsgs.GetLength(); index++)
     {
-        sDhcp6RxMsgs[index].ValidateAsRenew(prefix, serverMacAddr, serverIp6Addr);
+        sDhcp6RxMsgs[index].ValidateAsRenew(prefix, serverMacAddr);
         VerifyOrQuit(sDhcp6RxMsgs[index].mTransactionId == sDhcp6RxMsgs[0].mTransactionId);
         VerifyOrQuit(sDhcp6RxMsgs[index].mElapsedTime > 0);
     }
@@ -1123,7 +1045,7 @@
     Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
     Log("Send Reply to Rebind");
 
-    txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr, serverIp6Addr);
+    txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr);
     txMsg.AddIaPrefix(prefixInfo);
 
     sDhcp6RxMsgs.Clear();
@@ -1158,7 +1080,7 @@
     AdvanceTime(5);
     VerifyOrQuit(sDhcp6RxMsgs.GetLength() == 1);
 
-    sDhcp6RxMsgs[0].ValidateAsRenew(prefix, serverMacAddr, serverIp6Addr);
+    sDhcp6RxMsgs[0].ValidateAsRenew(prefix, serverMacAddr);
     VerifyOrQuit(sDhcp6RxMsgs[0].mElapsedTime == 0);
 
     Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
@@ -1169,7 +1091,7 @@
 
     for (uint16_t index = 2; index < sDhcp6RxMsgs.GetLength(); index++)
     {
-        sDhcp6RxMsgs[index].ValidateAsRenew(prefix, serverMacAddr, serverIp6Addr);
+        sDhcp6RxMsgs[index].ValidateAsRenew(prefix, serverMacAddr);
         VerifyOrQuit(sDhcp6RxMsgs[index].mTransactionId == sDhcp6RxMsgs[0].mTransactionId);
         VerifyOrQuit(sDhcp6RxMsgs[index].mElapsedTime > 0);
     }
@@ -1237,7 +1159,7 @@
     Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
     Log("Send Advertisement, check Request message and respond with Reply");
 
-    txMsg.PrepareAdvertise(sDhcp6RxMsgs[0], serverMacAddr, serverIp6Addr);
+    txMsg.PrepareAdvertise(sDhcp6RxMsgs[0], serverMacAddr);
     txMsg.AddIaPrefix(prefixInfo);
     sDhcp6RxMsgs.Clear();
     txMsg.Send();
@@ -1245,9 +1167,9 @@
     AdvanceTime(1);
 
     VerifyOrQuit(sDhcp6RxMsgs.GetLength() == 1);
-    sDhcp6RxMsgs[0].ValidateAsRequest(prefix, serverMacAddr, serverIp6Addr);
+    sDhcp6RxMsgs[0].ValidateAsRequest(prefix, serverMacAddr);
 
-    txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr, serverIp6Addr);
+    txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr);
     txMsg.AddIaPrefix(prefixInfo);
     sDhcp6RxMsgs.Clear();
     txMsg.Send();
@@ -1273,12 +1195,12 @@
     sInstance->Get<BorderRouter::Dhcp6PdClient>().Stop();
 
     VerifyOrQuit(sDhcp6RxMsgs.GetLength() == 1);
-    sDhcp6RxMsgs[0].ValidateAsRelease(prefix, serverMacAddr, serverIp6Addr);
+    sDhcp6RxMsgs[0].ValidateAsRelease(prefix, serverMacAddr);
 
     Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
     Log("Send Reply to Release message and check that no more messages is received");
 
-    txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr, serverIp6Addr);
+    txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr);
     txMsg.AddIaPrefix(prefixInfo);
     sDhcp6RxMsgs.Clear();
     txMsg.Send();
@@ -1291,8 +1213,7 @@
 
     VerifyOrQuit(heapAllocations == sHeapAllocatedPtrs.GetLength());
 
-    Log("End of TestDhcp6PdPrefixDelegation(aShortPrefix:%u, aAddServerUnicastOption:%u)", aShortPrefix,
-        aAddServerUnicastOption);
+    Log("End of TestDhcp6PdPrefixDelegation(aShortPrefix:%u)", aShortPrefix);
 
     FinalizeTest();
 }
@@ -2548,99 +2469,6 @@
 
 //---------------------------------------------------------------------------------------------------------------------
 
-void TestDhcp6PdServerStatusCodeUseMulticast(void)
-{
-    uint16_t               heapAllocations;
-    Dhcp6TxMsg             txMsg;
-    Dhcp6TxMsg::PrefixInfo prefixInfo;
-    Ip6::Prefix            prefix;
-    Ip6::Prefix            adjustedPrefix;
-    Mac::ExtAddress        serverMacAddr;
-    const DelegatedPrefix *delegatedPrefix;
-    Ip6::Address           serverIp6Addr;
-
-    Log("--------------------------------------------------------------------------------------------");
-    Log("TestDhcp6PdServerStatusCodeUseMulticast()");
-
-    InitTest();
-
-    heapAllocations = sHeapAllocatedPtrs.GetLength();
-
-    serverMacAddr.GenerateRandom();
-
-    prefix         = PrefixFromString("2001:f57c::", 48);
-    adjustedPrefix = PrefixFromString("2001:f57c::", 64);
-
-    serverIp6Addr = AddressFromString("fe80::2");
-
-    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
-    Log("Start the client and wait for the first two Solicit messages");
-
-    VerifyOrQuit(!sDhcp6ListeningEnabled);
-    sInstance->Get<BorderRouter::Dhcp6PdClient>().Start();
-    VerifyOrQuit(sDhcp6ListeningEnabled);
-
-    AdvanceTime(2200);
-
-    VerifyOrQuit(sDhcp6RxMsgs.GetLength() == 2);
-    sDhcp6RxMsgs[0].ValidateAsSolicit();
-    sDhcp6RxMsgs[1].ValidateAsSolicit();
-    VerifyOrQuit(sDhcp6RxMsgs[0].mTransactionId == sDhcp6RxMsgs[1].mTransactionId);
-
-    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
-    Log("Send Advertisement");
-
-    txMsg.PrepareAdvertise(sDhcp6RxMsgs[0], serverMacAddr, &serverIp6Addr);
-
-    prefixInfo.mIaid              = sDhcp6RxMsgs[0].mIaPds[0].mIaid;
-    prefixInfo.mT1                = 2000;
-    prefixInfo.mT2                = 3200;
-    prefixInfo.mPreferredLifetime = 3600;
-    prefixInfo.mValidLifetime     = 4000;
-    prefixInfo.mPrefix            = prefix;
-    txMsg.AddIaPrefix(prefixInfo);
-
-    sDhcp6RxMsgs.Clear();
-
-    txMsg.Send();
-
-    AdvanceTime(1);
-
-    VerifyOrQuit(sInstance->Get<BorderRouter::Dhcp6PdClient>().GetDelegatedPrefix() == nullptr);
-
-    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
-    Log("Validate Request message is received using unicast address of server");
-
-    VerifyOrQuit(sDhcp6RxMsgs.GetLength() == 1);
-    sDhcp6RxMsgs[0].ValidateAsRequest(prefix, serverMacAddr, &serverIp6Addr);
-
-    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
-    Log("Send Reply message with status code UseMulticast");
-
-    sDhcp6RxMsgs.Clear();
-
-    txMsg.PrepareReply(sDhcp6RxMsgs[0], serverMacAddr);
-    txMsg.mHasStatus  = true;
-    txMsg.mStatusCode = Dhcp6::StatusCodeOption::kUseMulticast;
-
-    sDhcp6RxMsgs.Clear();
-    txMsg.Send();
-
-    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
-    Log("Validate Request message is sent again now as a multicast");
-
-    VerifyOrQuit(sDhcp6RxMsgs.GetLength() == 1);
-    sDhcp6RxMsgs[0].ValidateAsRequest(prefix, serverMacAddr, nullptr);
-
-    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ");
-
-    VerifyOrQuit(heapAllocations == sHeapAllocatedPtrs.GetLength());
-
-    Log("End of TestDhcp6PdServerStatusCodeUseMulticast");
-
-    FinalizeTest();
-}
-
 //---------------------------------------------------------------------------------------------------------------------
 
 void TestDhcp6PdServerReplyWithNoBindingToRelease(void)
@@ -2770,10 +2598,8 @@
 int main(void)
 {
 #if OT_CONFIG_DHCP6_PD_CLIENT_ENABLE
-    ot::TestDhcp6PdPrefixDelegation(/* aShortPrefix */ false, /* aAddServerUnicastOption */ false);
-    ot::TestDhcp6PdPrefixDelegation(/* aShortPrefix */ false, /* aAddServerUnicastOption */ true);
-    ot::TestDhcp6PdPrefixDelegation(/* aShortPrefix */ true, /* aAddServerUnicastOption */ false);
-    ot::TestDhcp6PdPrefixDelegation(/* aShortPrefix */ true, /* aAddServerUnicastOption */ true);
+    ot::TestDhcp6PdPrefixDelegation(/* aShortPrefix */ false);
+    ot::TestDhcp6PdPrefixDelegation(/* aShortPrefix */ true);
     ot::TestDhcp6PdSolicitRetries();
     ot::TestDhcp6PdRequestRetries();
     ot::TestDhcp6PdSelectBetweenMultipleServers();
@@ -2784,7 +2610,6 @@
     ot::TestDhcp6PdServerVoidingLeaseDuringRenew();
     ot::TestDhcp6PdServerNotExtendingLeaseDuringRenew();
     ot::TestDhcp6PdServerReplacingPrefix();
-    ot::TestDhcp6PdServerStatusCodeUseMulticast();
     ot::TestDhcp6PdServerReplyWithNoBindingToRelease();
 
     printf("All tests passed\n");
diff --git a/tests/unit/test_dns.cpp b/tests/unit/test_dns.cpp
index 7889d1a..c0240c0 100644
--- a/tests/unit/test_dns.cpp
+++ b/tests/unit/test_dns.cpp
@@ -42,11 +42,8 @@
 
 void TestDnsName(void)
 {
-    enum
-    {
-        kMaxSize       = 300,
-        kMaxNameLength = Dns::Name::kMaxNameSize - 1,
-    };
+    static constexpr uint16_t kMaxSize       = 300;
+    static constexpr uint16_t kMaxNameLength = Dns::Name::kMaxNameSize - 1;
 
     struct TestName
     {
@@ -164,6 +161,14 @@
         {"_s1._sub._srv._udp.default.service.arpa.", "_s1", "_sub._srv._udp", "default.service.arpa.", true},
     };
 
+    typedef uint8_t EncodedNameWithNullChar[6];
+
+    static const EncodedNameWithNullChar kEncodedNamesWithNullChar[] = {
+        {4, 0, 'a', 'b', 'c', 0}, // Null char at the start of the label
+        {4, 'a', 0, 'c', 'd', 0}, // Null char in the middle of the label
+        {4, 'a', 'b', 'c', 0, 0}  // Null char in the end of the label
+    };
+
     printf("================================================================\n");
     printf("TestDnsName()\n");
 
@@ -629,25 +634,36 @@
                                          "012345678901234567890123456789012345678901234567890123456789012") ==
                  kErrorInvalidArgs);
 
+    printf("----------------------------------------------------------------\n");
+    printf("Name::ReadLabel() when there is null-char \'\\0\' in the label\n");
+
+    for (const EncodedNameWithNullChar &encodedName : kEncodedNamesWithNullChar)
+    {
+        SuccessOrQuit(message->SetLength(0));
+        SuccessOrQuit(message->AppendBytes(encodedName, sizeof(encodedName)));
+
+        SuccessOrQuit(message->Read(0, buffer, message->GetLength()));
+        DumpBuffer("EncodedName", buffer, message->GetLength());
+
+        offset      = 0;
+        labelLength = sizeof(label);
+        VerifyOrQuit(Dns::Name::ReadLabel(*message, offset, label, labelLength) == kErrorParse);
+    }
+
     message->Free();
     testFreeInstance(instance);
 }
 
 void TestDnsCompressedName(void)
 {
-    enum
-    {
-        kHeaderOffset   = 10,
-        kGuardBlockSize = 20,
-        kMaxBufferSize  = 100,
-        kLabelSize      = 64,
-        kNameSize       = 256,
-
-        kName2EncodedSize = 4 + 2,  // encoded "FOO" + pointer label (2 bytes)
-        kName3EncodedSize = 2,      // pointer label (2 bytes)
-        kName4EncodedSize = 15 + 2, // encoded "Human.Readable" + pointer label (2 bytes).
-
-    };
+    static constexpr uint8_t  kHeaderOffset     = 10;
+    static constexpr uint8_t  kGuardBlockSize   = 20;
+    static constexpr uint16_t kMaxBufferSize    = 100;
+    static constexpr uint16_t kLabelSize        = 64;
+    static constexpr uint16_t kNameSize         = 256;
+    static constexpr uint16_t kName2EncodedSize = 4 + 2;  // encoded "FOO" + pointer label (2 bytes)
+    static constexpr uint16_t kName3EncodedSize = 2;      // pointer label (2 bytes)
+    static constexpr uint16_t kName4EncodedSize = 15 + 2; // encoded "Human.Readable" + pointer label (2 bytes).
 
     const char kName[]          = "F.ISI.ARPA";
     const char kLabel1[]        = "FOO";
@@ -1155,19 +1171,16 @@
 
 void TestHeaderAndResourceRecords(void)
 {
-    enum
-    {
-        kHeaderOffset    = 0,
-        kQuestionCount   = 1,
-        kAnswerCount     = 2,
-        kAdditionalCount = 6,
-        kTtl             = 7200,
-        kTxtTtl          = 7300,
-        kSrvPort         = 1234,
-        kSrvPriority     = 1,
-        kSrvWeight       = 2,
-        kMaxSize         = 600,
-    };
+    static constexpr uint8_t  kHeaderOffset    = 0;
+    static constexpr uint16_t kQuestionCount   = 1;
+    static constexpr uint16_t kAnswerCount     = 2;
+    static constexpr uint16_t kAdditionalCount = 6;
+    static constexpr uint32_t kTtl             = 7200;
+    static constexpr uint32_t kTxtTtl          = 7300;
+    static constexpr uint16_t kSrvPort         = 1234;
+    static constexpr uint16_t kSrvPriority     = 1;
+    static constexpr uint16_t kSrvWeight       = 2;
+    static constexpr uint16_t kMaxSize         = 600;
 
     const char    kMessageString[]  = "DnsMessage";
     const char    kDomainName[]     = "example.com.";
diff --git a/tests/unit/test_dns_client.cpp b/tests/unit/test_dns_client.cpp
index 40d211a..eb5c3e8 100644
--- a/tests/unit/test_dns_client.cpp
+++ b/tests/unit/test_dns_client.cpp
@@ -138,11 +138,11 @@
 #endif
 
 #if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
-void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+void otPlatLogOutput(otInstance *, otLogLevel, const char *aLogLine) { printf("   %s\n", aLogLine); }
+#else
+void otPlatLog(otLogLevel, otLogRegion, const char *aFormat, ...)
 {
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-
     va_list args;
 
     printf("   ");
@@ -152,6 +152,7 @@
     printf("\n");
 }
 #endif
+#endif
 
 } // extern "C"
 
diff --git a/tests/unit/test_dnssd_discovery_proxy.cpp b/tests/unit/test_dnssd_discovery_proxy.cpp
index d242f44..eeaa708 100644
--- a/tests/unit/test_dnssd_discovery_proxy.cpp
+++ b/tests/unit/test_dnssd_discovery_proxy.cpp
@@ -139,11 +139,11 @@
 #endif
 
 #if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
-void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+void otPlatLogOutput(otInstance *, otLogLevel, const char *aLogLine) { printf("   %s\n", aLogLine); }
+#else
+void otPlatLog(otLogLevel, otLogRegion, const char *aFormat, ...)
 {
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-
     va_list args;
 
     printf("   ");
@@ -153,6 +153,7 @@
     printf("\n");
 }
 #endif
+#endif
 
 } // extern "C"
 
diff --git a/tests/unit/test_frame_builder.cpp b/tests/unit/test_frame_builder.cpp
index 8c648e3..74c30f4 100644
--- a/tests/unit/test_frame_builder.cpp
+++ b/tests/unit/test_frame_builder.cpp
@@ -49,6 +49,8 @@
     uint8_t      buffer[kMaxBufferSize];
     uint8_t      zeroBuffer[kMaxBufferSize];
     FrameBuilder frameBuilder;
+    uint16_t     u16;
+    uint32_t     u32;
 
     printf("TestFrameBuilder\n");
 
@@ -93,14 +95,17 @@
     VerifyOrQuit(!frameBuilder.CanAppend(sizeof(buffer) - sizeof(kData1) + 1));
 
     SuccessOrQuit(frameBuilder.AppendUint8(0x01));
-    SuccessOrQuit(frameBuilder.AppendBigEndianUint16(0x0203));
-    SuccessOrQuit(frameBuilder.AppendLittleEndianUint16(0x0504));
+    u16 = 0x203;
+    SuccessOrQuit(frameBuilder.AppendUint<kBigEndian>(u16));
+    u16 = 0x0504;
+    SuccessOrQuit(frameBuilder.AppendUint<kLittleEndian>(u16));
     VerifyOrQuit(frameBuilder.GetLength() == sizeof(kData1) * 2);
     VerifyOrQuit(frameBuilder.GetBytes() == buffer);
     VerifyOrQuit(memcmp(buffer, kData1, sizeof(kData1)) == 0);
     VerifyOrQuit(memcmp(buffer + sizeof(kData1), kData1, sizeof(kData1)) == 0);
 
-    SuccessOrQuit(frameBuilder.AppendBigEndianUint32(0x01020304));
+    u32 = 0x01020304;
+    SuccessOrQuit(frameBuilder.AppendUint<kBigEndian>(u32));
     SuccessOrQuit(frameBuilder.AppendUint8(0x05));
     VerifyOrQuit(frameBuilder.GetLength() == sizeof(kData1) * 3);
     VerifyOrQuit(frameBuilder.GetBytes() == buffer);
@@ -124,7 +129,8 @@
     VerifyOrQuit(frameBuilder.GetLength() == 0);
     VerifyOrQuit(frameBuilder.GetMaxLength() == sizeof(buffer));
 
-    SuccessOrQuit(frameBuilder.AppendLittleEndianUint32(0x04030201));
+    u32 = 0x04030201;
+    SuccessOrQuit(frameBuilder.AppendUint<kLittleEndian>(u32));
     SuccessOrQuit(frameBuilder.AppendUint8(0x05));
     VerifyOrQuit(frameBuilder.GetLength() == sizeof(kData1));
     VerifyOrQuit(frameBuilder.GetBytes() == buffer);
diff --git a/tests/unit/test_hdlc.cpp b/tests/unit/test_hdlc.cpp
index 4e96310..e8caa7d 100644
--- a/tests/unit/test_hdlc.cpp
+++ b/tests/unit/test_hdlc.cpp
@@ -38,20 +38,16 @@
 namespace ot {
 namespace Ncp {
 
-enum
-{
-    kBufferSize        = 1500,  // Frame buffer size
-    kMaxFrameLength    = 500,   // Maximum allowed frame length (used when randomly generating frames)
-    kFuzzTestIteration = 50000, // Number of iteration during fuzz test (randomly generating frames)
-    kFrameHeaderSize   = 4,     // Frame header size
+constexpr uint16_t kBufferSize        = 1500;  // Frame buffer size
+constexpr uint16_t kMaxFrameLength    = 500;   // Maximum allowed frame length (used when randomly generating frames)
+constexpr uint32_t kFuzzTestIteration = 50000; // Number of iteration during fuzz test (randomly generating frames)
+constexpr uint16_t kFrameHeaderSize   = 4;     // Frame header size
 
-    kFlagXOn        = 0x11,
-    kFlagXOff       = 0x13,
-    kFlagSequence   = 0x7e, ///< HDLC Flag value
-    kEscapeSequence = 0x7d, ///< HDLC Escape value
-    kFlagSpecial    = 0xf8,
-
-};
+constexpr uint8_t kFlagXOn        = 0x11;
+constexpr uint8_t kFlagXOff       = 0x13;
+constexpr uint8_t kFlagSequence   = 0x7e; // HDLC Flag value
+constexpr uint8_t kEscapeSequence = 0x7d; // HDLC Escape value
+constexpr uint8_t kFlagSpecial    = 0xf8;
 
 static const uint8_t sOpenThreadText[] = "OpenThread Rocks";
 static const uint8_t sHelloText[]      = "Hello there!";
diff --git a/tests/unit/test_hkdf_sha256.cpp b/tests/unit/test_hkdf_sha256.cpp
index 2b3c01d..d111ce6 100644
--- a/tests/unit/test_hkdf_sha256.cpp
+++ b/tests/unit/test_hkdf_sha256.cpp
@@ -52,11 +52,8 @@
 
 void TestHkdfSha256(void)
 {
-    enum
-    {
-        kMaxOuttKey = 128,
-        kFillByte   = 0x77,
-    };
+    static constexpr uint16_t kMaxOuttKey = 128;
+    static constexpr uint8_t  kFillByte   = 0x77;
 
     // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Test Case #1: RFC-5869 Appendix A.1
diff --git a/tests/unit/test_ip4_header.cpp b/tests/unit/test_ip4_header.cpp
index b7fcaae..4a74bfa 100644
--- a/tests/unit/test_ip4_header.cpp
+++ b/tests/unit/test_ip4_header.cpp
@@ -78,8 +78,9 @@
 
     header.Clear();
     header.InitVersionIhl();
+    header.SetTotalLength(header.GetHeaderLength());
     VerifyOrQuit(header.IsValid());
-    VerifyOrQuit(header.GetTotalLength() == 0);
+    VerifyOrQuit(header.GetTotalLength() == sizeof(Header));
     VerifyOrQuit(header.GetProtocol() == 0);
     VerifyOrQuit(header.GetTtl() == 0);
     VerifyOrQuit(header.GetSource().mFields.m32 == 0);
diff --git a/tests/unit/test_ip_address.cpp b/tests/unit/test_ip_address.cpp
index 2210026..3307498 100644
--- a/tests/unit/test_ip_address.cpp
+++ b/tests/unit/test_ip_address.cpp
@@ -406,7 +406,7 @@
         for (size_t prefixLength = 0; prefixLength <= sizeof(Ip6::Address) * kBitsPerByte; prefixLength++)
         {
             ip6Prefix.Clear();
-            ip6Prefix.Set(prefix, prefixLength);
+            ip6Prefix.InitFrom(prefix, prefixLength);
 
             address = allZeroAddress;
             address.SetPrefix(ip6Prefix);
@@ -442,7 +442,7 @@
     Ip6::Address address;
 
     SuccessOrQuit(address.FromString(aAddressString));
-    prefix.Set(address.GetBytes(), aPrefixLength);
+    prefix.InitFrom(address.GetBytes(), aPrefixLength);
 
     return prefix;
 }
@@ -466,7 +466,7 @@
 
         for (uint8_t prefixLength = 1; prefixLength <= Ip6::Prefix::kMaxLength; prefixLength++)
         {
-            prefix.Set(prefixBytes, prefixLength);
+            prefix.InitFrom(prefixBytes, prefixLength);
 
             printf("Prefix %s\n", prefix.ToString().AsCString());
 
@@ -484,7 +484,7 @@
             {
                 Ip6::Prefix subPrefix;
 
-                subPrefix.Set(prefixBytes, subPrefixLength);
+                subPrefix.InitFrom(prefixBytes, subPrefixLength);
 
                 VerifyOrQuit(prefix.ContainsPrefix(subPrefix));
 
@@ -740,7 +740,7 @@
             Ip6::Prefix prefix, answer;
 
             SuccessOrQuit(answer.FromString(test.prefixStringAfterTidy[i]));
-            prefix.Set(test.originalPrefix, i);
+            prefix.InitFrom(test.originalPrefix, i);
             prefix.Tidy();
 
             {
@@ -774,10 +774,10 @@
 
     printf("\nTestIp4MappedIp6Address()\n");
 
-    expectedIp4Address.SetBytes(kIp4Address);
+    expectedIp4Address.InitFrom(kIp4Address);
 
     SuccessOrQuit(expectedIp6Address.FromString("::ffff:192.0.2.33"));
-    ip6Address.SetToIp4Mapped(expectedIp4Address);
+    ip6Address.InitAsIp4Mapped(expectedIp4Address);
 
     printf("IPv4-mapped IPv6 address: %s\n", ip6Address.ToString().AsCString());
 
@@ -823,7 +823,7 @@
 
     printf("\nTestIp4Ip6Translation()\n");
 
-    ip4Address.SetBytes(kIp4Address);
+    ip4Address.InitFrom(kIp4Address);
 
     for (const TestCase &testCase : kTestCases)
     {
@@ -832,7 +832,7 @@
         Ip6::Address expectedAddress;
 
         SuccessOrQuit(address.FromString(testCase.mPrefix));
-        prefix.Set(address.GetBytes(), testCase.mLength);
+        prefix.InitFrom(address.GetBytes(), testCase.mLength);
 
         SuccessOrQuit(expectedAddress.FromString(testCase.mIp6Address));
 
diff --git a/tests/unit/test_link_metrics_manager.cpp b/tests/unit/test_link_metrics_manager.cpp
index 6c928b2..e28572e 100644
--- a/tests/unit/test_link_metrics_manager.cpp
+++ b/tests/unit/test_link_metrics_manager.cpp
@@ -161,7 +161,7 @@
     }
 
     // subject1 received a response with a success status code
-    linkLocalAddr.SetToLinkLocalAddress(AsCoreType(&mTestChildList[0].mExtAddress));
+    linkLocalAddr.InitAsLinkLocalAddress(AsCoreType(&mTestChildList[0].mExtAddress));
     linkMetricsMgr->HandleMgmtResponse(&linkLocalAddr, MapEnum(LinkMetrics::Status::kStatusSuccess));
     VerifyOrQuit(subject1->mState == LinkMetricsManager::SubjectState::kActive);
 
diff --git a/tests/unit/test_link_quality.cpp b/tests/unit/test_link_quality.cpp
index 71132d0..aecd5c1 100644
--- a/tests/unit/test_link_quality.cpp
+++ b/tests/unit/test_link_quality.cpp
@@ -38,20 +38,17 @@
 
 static Instance *sInstance;
 
-enum
-{
-    kMaxRssValue = 0,
-    kMinRssValue = -128,
+constexpr int8_t kMaxRssValue = 0;
+constexpr int8_t kMinRssValue = -128;
 
-    kStringBuffferSize = 80,
+constexpr uint16_t kStringBuffferSize = 80;
 
-    kRssAverageMaxDiff = 16,
-    kNumRssAdds        = 300,
+constexpr uint8_t  kRssAverageMaxDiff = 16;
+constexpr uint16_t kNumRssAdds        = 300;
 
-    kRawAverageBitShift = 3,
-    kRawAverageMultiple = (1 << kRawAverageBitShift),
-    kRawAverageBitMask  = (1 << kRawAverageBitShift) - 1,
-};
+constexpr uint8_t kRawAverageBitShift = 3;
+constexpr uint8_t kRawAverageMultiple = (1 << kRawAverageBitShift);
+constexpr uint8_t kRawAverageBitMask  = (1 << kRawAverageBitShift) - 1;
 
 #define MIN_RSS(_rss1, _rss2) (((_rss1) < (_rss2)) ? (_rss1) : (_rss2))
 #define MAX_RSS(_rss1, _rss2) (((_rss1) < (_rss2)) ? (_rss2) : (_rss1))
diff --git a/tests/unit/test_lowpan.cpp b/tests/unit/test_lowpan.cpp
index 9509b20..f1081c6 100644
--- a/tests/unit/test_lowpan.cpp
+++ b/tests/unit/test_lowpan.cpp
@@ -1856,12 +1856,9 @@
 
 void TestLowpanMeshHeader(void)
 {
-    enum
-    {
-        kMaxFrameSize = 127,
-        kSourceAddr   = 0x100,
-        kDestAddr     = 0x200,
-    };
+    static constexpr uint16_t kMaxFrameSize = 127;
+    static constexpr uint16_t kSourceAddr   = 0x100;
+    static constexpr uint16_t kDestAddr     = 0x200;
 
     const uint8_t kMeshHeader1[] = {0xb1, 0x01, 0x00, 0x02, 0x00};       // src:0x100, dest:0x200, hop:0x1
     const uint8_t kMeshHeader2[] = {0xbf, 0x20, 0x01, 0x00, 0x02, 0x00}; // src:0x100, dest:0x200, hop:0x20
diff --git a/tests/unit/test_lowpan.hpp b/tests/unit/test_lowpan.hpp
index f867490..ff66d28 100644
--- a/tests/unit/test_lowpan.hpp
+++ b/tests/unit/test_lowpan.hpp
@@ -237,11 +237,11 @@
     /**
      * This fields represent uncompressed IPv6 packet.
      */
-    Mac::Addresses   mMacAddrs;
-    Ip6::Header      mIpHeader;
-    Payload          mExtHeader;
-    Ip6::Header      mIpTunneledHeader;
-    Ip6::Udp::Header mUdpHeader;
+    Mac::Addresses mMacAddrs;
+    Ip6::Header    mIpHeader;
+    Payload        mExtHeader;
+    Ip6::Header    mIpTunneledHeader;
+    Ip6::UdpHeader mUdpHeader;
 
     /**
      * This fields represent compressed IPv6 packet.
diff --git a/tests/unit/test_mac_frame.cpp b/tests/unit/test_mac_frame.cpp
index d9bb83c..5756246 100644
--- a/tests/unit/test_mac_frame.cpp
+++ b/tests/unit/test_mac_frame.cpp
@@ -290,43 +290,43 @@
         uint8_t psdu[OT_RADIO_FRAME_MAX_SIZE];
         uint8_t offset;
 
-        Mac::TxFrame       frame;
-        Mac::TxFrame::Info frameInfo;
-        Mac::Address       address;
-        Mac::PanId         panId;
+        Mac::TxFrame            frame;
+        Mac::TxFrame::BuildInfo buildInfo;
+        Mac::Address            address;
+        Mac::PanId              panId;
 
         frame.mPsdu      = psdu;
         frame.mLength    = 0;
         frame.mRadioType = 0;
 
-        VerifyOrQuit(frameInfo.mAddrs.mSource.IsNone());
-        VerifyOrQuit(frameInfo.mAddrs.mDestination.IsNone());
-        VerifyOrQuit(!frameInfo.mPanIds.IsSourcePresent());
-        VerifyOrQuit(!frameInfo.mPanIds.IsDestinationPresent());
+        VerifyOrQuit(buildInfo.mAddrs.mSource.IsNone());
+        VerifyOrQuit(buildInfo.mAddrs.mDestination.IsNone());
+        VerifyOrQuit(!buildInfo.mPanIds.IsSourcePresent());
+        VerifyOrQuit(!buildInfo.mPanIds.IsDestinationPresent());
 
         switch (testCase.mSrcAddrType)
         {
         case kNoneAddr:
-            frameInfo.mAddrs.mSource.SetNone();
+            buildInfo.mAddrs.mSource.SetNone();
             break;
         case kShrtAddr:
-            frameInfo.mAddrs.mSource.SetShort(kShortAddr1);
+            buildInfo.mAddrs.mSource.SetShort(kShortAddr1);
             break;
         case kExtdAddr:
-            frameInfo.mAddrs.mSource.SetExtended(extAddr1);
+            buildInfo.mAddrs.mSource.SetExtended(extAddr1);
             break;
         }
 
         switch (testCase.mDstAddrType)
         {
         case kNoneAddr:
-            frameInfo.mAddrs.mDestination.SetNone();
+            buildInfo.mAddrs.mDestination.SetNone();
             break;
         case kShrtAddr:
-            frameInfo.mAddrs.mDestination.SetShort(kShortAddr2);
+            buildInfo.mAddrs.mDestination.SetShort(kShortAddr2);
             break;
         case kExtdAddr:
-            frameInfo.mAddrs.mDestination.SetExtended(extAddr2);
+            buildInfo.mAddrs.mDestination.SetExtended(extAddr2);
             break;
         }
 
@@ -335,10 +335,10 @@
         case kNoPanId:
             break;
         case kUsePanId1:
-            frameInfo.mPanIds.SetSource(kPanId1);
+            buildInfo.mPanIds.SetSource(kPanId1);
             break;
         case kUsePanId2:
-            frameInfo.mPanIds.SetSource(kPanId2);
+            buildInfo.mPanIds.SetSource(kPanId2);
             break;
         }
 
@@ -347,20 +347,20 @@
         case kNoPanId:
             break;
         case kUsePanId1:
-            frameInfo.mPanIds.SetDestination(kPanId1);
+            buildInfo.mPanIds.SetDestination(kPanId1);
             break;
         case kUsePanId2:
-            frameInfo.mPanIds.SetDestination(kPanId2);
+            buildInfo.mPanIds.SetDestination(kPanId2);
             break;
         }
 
-        frameInfo.mType             = Mac::Frame::kTypeData;
-        frameInfo.mVersion          = testCase.mVersion;
-        frameInfo.mSecurityLevel    = testCase.mSecurity;
-        frameInfo.mKeyIdMode        = testCase.mKeyIdMode;
-        frameInfo.mSuppressSequence = testCase.mSuppressSequence;
+        buildInfo.mType             = Mac::Frame::kTypeData;
+        buildInfo.mVersion          = testCase.mVersion;
+        buildInfo.mSecurityLevel    = testCase.mSecurity;
+        buildInfo.mKeyIdMode        = testCase.mKeyIdMode;
+        buildInfo.mSuppressSequence = testCase.mSuppressSequence;
 
-        frameInfo.PrepareHeadersIn(frame);
+        buildInfo.PrepareHeadersIn(frame);
 
         VerifyOrQuit(frame.GetHeaderLength() == testCase.mHeaderLength);
         VerifyOrQuit(frame.GetFooterLength() == testCase.mFooterLength);
@@ -376,25 +376,25 @@
 
         VerifyOrQuit(frame.IsSrcAddrPresent() == (testCase.mSrcAddrType != kNoneAddr));
         SuccessOrQuit(frame.GetSrcAddr(address));
-        VerifyOrQuit(CompareAddresses(address, frameInfo.mAddrs.mSource));
+        VerifyOrQuit(CompareAddresses(address, buildInfo.mAddrs.mSource));
         VerifyOrQuit(frame.IsDstAddrPresent() == (testCase.mDstAddrType != kNoneAddr));
         SuccessOrQuit(frame.GetDstAddr(address));
-        VerifyOrQuit(CompareAddresses(address, frameInfo.mAddrs.mDestination));
+        VerifyOrQuit(CompareAddresses(address, buildInfo.mAddrs.mDestination));
 
         VerifyOrQuit(frame.IsDstPanIdPresent() == (testCase.mDstPanIdMode != kNoPanId));
 
         if (frame.IsDstPanIdPresent())
         {
             SuccessOrQuit(frame.GetDstPanId(panId));
-            VerifyOrQuit(panId == frameInfo.mPanIds.GetDestination());
-            VerifyOrQuit(frameInfo.mPanIds.IsDestinationPresent());
+            VerifyOrQuit(panId == buildInfo.mPanIds.GetDestination());
+            VerifyOrQuit(buildInfo.mPanIds.IsDestinationPresent());
         }
 
         if (frame.IsSrcPanIdPresent())
         {
             SuccessOrQuit(frame.GetSrcPanId(panId));
-            VerifyOrQuit(panId == frameInfo.mPanIds.GetSource());
-            VerifyOrQuit(frameInfo.mPanIds.IsSourcePresent());
+            VerifyOrQuit(panId == buildInfo.mPanIds.GetSource());
+            VerifyOrQuit(buildInfo.mPanIds.IsSourcePresent());
         }
 
         if (frame.GetSecurityEnabled())
@@ -470,6 +470,88 @@
     VerifyOrQuit(aLength == aMask.GetNumberOfChannels());
 }
 
+void TestMacHeaderIeStartEnd(void)
+{
+    using HeaderIe = Mac::HeaderIe;
+
+    static const uint8_t kIeId1     = 0x0a;
+    static const uint8_t kIeId2     = 0x0b;
+    static const uint8_t kIeId3     = 0x0c;
+    static const uint8_t kContent[] = {0x11, 0x22, 0x33, 0x44, 0x55};
+
+    uint8_t            buffer[256];
+    FrameBuilder       builder;
+    uint16_t           offset;
+    HeaderIe::Bookmark bookmark;
+    HeaderIe          *ie;
+    uint8_t            largeContent[128];
+
+    printf("TestMacHeaderIeStartEnd\n");
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Normal use: Append an IE (5 bytes content)
+
+    builder.Init(buffer, sizeof(buffer));
+    SuccessOrQuit(HeaderIe::StartIe(builder, kIeId1, bookmark));
+    VerifyOrQuit(builder.GetLength() == sizeof(HeaderIe));
+
+    SuccessOrQuit(builder.AppendBytes(kContent, sizeof(kContent)));
+    SuccessOrQuit(HeaderIe::EndIe(builder, bookmark));
+
+    VerifyOrQuit(builder.GetLength() == sizeof(HeaderIe) + sizeof(kContent));
+
+    ie = reinterpret_cast<HeaderIe *>(buffer);
+    VerifyOrQuit(ie->GetId() == kIeId1);
+    VerifyOrQuit(ie->GetLength() == sizeof(kContent));
+    VerifyOrQuit(ie->GetSize() == sizeof(HeaderIe) + sizeof(kContent));
+    VerifyOrQuit(memcmp(ie->GetContent(), kContent, sizeof(kContent)) == 0);
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Normal use: Append a new IE with empty payload (0-length IE)
+
+    offset = builder.GetLength();
+    SuccessOrQuit(HeaderIe::StartIe(builder, kIeId2, bookmark));
+    SuccessOrQuit(HeaderIe::EndIe(builder, bookmark));
+
+    // First IE should remain untouched
+    ie = reinterpret_cast<HeaderIe *>(buffer);
+    VerifyOrQuit(ie->GetId() == kIeId1);
+    VerifyOrQuit(ie->GetLength() == sizeof(kContent));
+    VerifyOrQuit(ie->GetSize() == sizeof(HeaderIe) + sizeof(kContent));
+    VerifyOrQuit(memcmp(ie->GetContent(), kContent, sizeof(kContent)) == 0);
+
+    // Second IE with empty content
+    ie = builder.Read<HeaderIe>(offset);
+    VerifyOrQuit(ie->GetId() == kIeId2);
+    VerifyOrQuit(ie->GetLength() == 0);
+    VerifyOrQuit(ie->GetSize() == sizeof(HeaderIe));
+
+    VerifyOrQuit(builder.GetLength() == 2 * sizeof(HeaderIe) + sizeof(kContent));
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Maximum allowed IE length (127 bytes)
+
+    memset(largeContent, 0xaa, sizeof(largeContent));
+
+    builder.Init(buffer, sizeof(buffer));
+    SuccessOrQuit(HeaderIe::StartIe(builder, kIeId3, bookmark));
+    SuccessOrQuit(builder.AppendBytes(largeContent, HeaderIe::kMaxLength));
+    SuccessOrQuit(HeaderIe::EndIe(builder, bookmark));
+
+    ie = builder.Read<HeaderIe>(0);
+    VerifyOrQuit(ie->GetId() == kIeId3);
+    VerifyOrQuit(ie->GetLength() == HeaderIe::kMaxLength);
+    VerifyOrQuit(builder.GetLength() == ie->GetSize());
+
+    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+    // Exceeding maximum length (128 bytes > 127)
+
+    builder.Init(buffer, sizeof(buffer));
+    SuccessOrQuit(HeaderIe::StartIe(builder, kIeId3, bookmark));
+    SuccessOrQuit(builder.AppendBytes(largeContent, HeaderIe::kMaxLength + 1));
+    VerifyOrQuit(HeaderIe::EndIe(builder, bookmark) == kErrorInvalidArgs);
+}
+
 void TestMacChannelMask(void)
 {
     uint8_t allChannels[] = {11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26};
@@ -761,7 +843,7 @@
 
     SuccessOrQuit(ackFrame.GenerateEnhAck(receivedFrame, false, ie_data, sizeof(ie_data)));
 
-    csl = reinterpret_cast<Mac::CslIe *>(ackFrame.GetHeaderIe(Mac::CslIe::kHeaderIeId) + sizeof(Mac::HeaderIe));
+    csl = ackFrame.Find<Mac::CslIe>();
     VerifyOrQuit(ackFrame.mLength == 25);
     VerifyOrQuit(ackFrame.GetType() == Mac::Frame::kTypeAck);
     VerifyOrQuit(ackFrame.GetSecurityEnabled());
@@ -775,308 +857,23 @@
     VerifyOrQuit(csl->GetPeriod() == 3125 && csl->GetPhase() == 3105);
 
 #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE
-    ackFrame.SetCslIe(123, 456);
-    csl = reinterpret_cast<Mac::CslIe *>(ackFrame.GetHeaderIe(Mac::CslIe::kHeaderIeId) + sizeof(Mac::HeaderIe));
+    ackFrame.UpdateCslIe(123, 456);
+    csl = ackFrame.Find<Mac::CslIe>();
     VerifyOrQuit(csl->GetPeriod() == 123 && csl->GetPhase() == 456);
 #endif
 #endif // (OPENTHREAD_CONFIG_THREAD_VERSION >= OT_THREAD_VERSION_1_2)
 }
 
-#if OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE
-constexpr uint16_t kMpFcfLongFrame           = 1 << 3;
-constexpr uint16_t kMpFcfDstAddrShift        = 4;
-constexpr uint16_t kMpFcfDstAddrNone         = 0 << kMpFcfDstAddrShift;
-constexpr uint16_t kMpFcfDstAddrExt          = 3 << kMpFcfDstAddrShift;
-constexpr uint16_t kMpFcfSrcAddrShift        = 6;
-constexpr uint16_t kMpFcfSrcAddrShort        = 2 << kMpFcfSrcAddrShift;
-constexpr uint16_t kMpFcfSrcAddrExt          = 3 << kMpFcfSrcAddrShift;
-constexpr uint16_t kMpFcfPanidPresent        = 1 << 8;
-constexpr uint16_t kMpFcfSecurityEnabled     = 1 << 9;
-constexpr uint16_t kMpFcfSequenceSuppression = 1 << 10;
-constexpr uint16_t kMpFcfAckRequest          = 1 << 14;
-constexpr uint16_t kMpFcfIePresent           = 1 << 15;
-
-void TestMacWakeupFrameGeneration(void)
-{
-    constexpr static Mac::WakeupId kWakeupId     = 0x1020;
-    constexpr static uint8_t       kSrcExtaddr[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
-    constexpr static uint8_t       kDstExtaddr[] = {0xf0, 0xe1, 0xd2, 0xc3, 0xb4, 0xa5, 0x96, 0x87};
-    constexpr static uint8_t       kKeySource[]  = {0, 0, 0, 0x1c};
-
-    constexpr static uint8_t kWakeupPsdu[] = {
-        // Frame Control
-        Mac::Frame::kTypeMultipurpose | kMpFcfLongFrame | kMpFcfDstAddrExt | kMpFcfSrcAddrExt,
-        (kMpFcfPanidPresent | kMpFcfSecurityEnabled | kMpFcfSequenceSuppression | kMpFcfIePresent) >> 8,
-        // PAN ID
-        0xce, 0xfa,
-        // Destination Address
-        0x87, 0x96, 0xa5, 0xb4, 0xc3, 0xd2, 0xe1, 0xf0,
-        // Source Address
-        0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01,
-        // Security Header
-        Mac::Frame::kKeyIdMode2 | Mac::Frame::kSecurityEncMic32, 0xfc, 0xfc, 0xfc, 0xfc, 0x00, 0x00, 0x00, 0x1c, 0x1d,
-        // Rendezvous Time IE
-        0x82, 0x0e, 0xcd, 0xab,
-        // Connection IE
-        0x05, 0x00, 0x9b, 0xb8, 0xea, 0x01, 0x1c};
-
-    constexpr static uint8_t kWakeupPsdu2[] = {
-        // Frame Control
-        Mac::Frame::kTypeMultipurpose | kMpFcfLongFrame | kMpFcfDstAddrNone | kMpFcfSrcAddrExt,
-        (kMpFcfPanidPresent | kMpFcfSecurityEnabled | kMpFcfSequenceSuppression | kMpFcfIePresent) >> 8,
-        // PAN ID
-        0xce, 0xfa,
-        // No Destination Address
-        // Source Address
-        0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01,
-        // Security Header
-        Mac::Frame::kKeyIdMode2 | Mac::Frame::kSecurityEncMic32, 0xfc, 0xfc, 0xfc, 0xfc, 0x00, 0x00, 0x00, 0x1c, 0x1d,
-        // Rendezvous Time IE
-        0x82, 0x0e, 0xcd, 0xab,
-        // Connection IE
-        0x07, 0x00, 0x9b, 0xb8, 0xea, 0x01, 0x1c, 0x20, 0x10};
-
-    uint8_t            psdu[OT_RADIO_FRAME_MAX_SIZE];
-    Mac::Address       src;
-    Mac::Address       dst;
-    Mac::Address       addr;
-    Mac::WakeupId      wakeupId;
-    Mac::WakeupRequest wakeupRequest;
-    Mac::TxFrame       txFrame;
-    Mac::Frame         rxFrame;
-    Mac::ConnectionIe *connectionIe;
-
-    printf("TestMacWakeupFrameGeneration\n");
-
-    src.SetExtended(kSrcExtaddr);
-    dst.SetExtended(kDstExtaddr);
-    wakeupRequest.SetExtAddress(dst.GetExtended());
-    txFrame.mPsdu      = psdu;
-    txFrame.mLength    = 0;
-    txFrame.mRadioType = 0;
-
-    SuccessOrQuit(txFrame.GenerateWakeupFrame(0xface, wakeupRequest, src));
-
-    // Validate that the frame satisfies the wake-up frame definition
-    VerifyOrQuit(txFrame.GetType() == Mac::Frame::kTypeMultipurpose);
-    VerifyOrQuit(!txFrame.GetAckRequest());
-    VerifyOrQuit(txFrame.GetRendezvousTimeIe() != nullptr);
-    VerifyOrQuit(txFrame.GetConnectionIe() != nullptr);
-    VerifyOrQuit(txFrame.GetPayloadLength() == 0);
-    SuccessOrQuit(txFrame.GetSrcAddr(addr));
-    VerifyOrQuit(CompareAddresses(src, addr));
-    SuccessOrQuit(txFrame.GetDstAddr(addr));
-    VerifyOrQuit(CompareAddresses(dst, addr));
-
-    // Initialize remaining fields and check if the frame has the expected contents
-    txFrame.SetFrameCounter(0xfcfcfcfc);
-    txFrame.SetKeySource(kKeySource);
-    txFrame.SetKeyId(0x1d);
-    txFrame.GetRendezvousTimeIe()->SetRendezvousTime(0xabcd);
-    connectionIe = txFrame.GetConnectionIe();
-    connectionIe->SetRetryInterval(1);
-    connectionIe->SetRetryCount(12);
-    VerifyOrQuit(connectionIe->SetWakeupId(kWakeupId) == kErrorParse);
-
-    VerifyOrQuit(txFrame.GetRendezvousTimeIe()->GetRendezvousTime() == 0xabcd);
-    VerifyOrQuit(connectionIe->GetRetryInterval() == 1);
-    VerifyOrQuit(connectionIe->GetRetryCount() == 12);
-    VerifyOrQuit(connectionIe->GetWakeupId(wakeupId) == kErrorParse);
-    VerifyOrQuit(txFrame.GetLength() == sizeof(kWakeupPsdu) + txFrame.GetFooterLength());
-    VerifyOrQuit(memcmp(psdu, kWakeupPsdu, sizeof(kWakeupPsdu)) == 0);
-
-    // Initialize RX Frame with the same PSDU and check if it's recognized as wake-up frame
-    rxFrame.mPsdu      = psdu;
-    rxFrame.mLength    = txFrame.GetLength();
-    rxFrame.mRadioType = 0;
-
-    SuccessOrQuit(rxFrame.ValidatePsdu());
-    VerifyOrQuit(rxFrame.IsWakeupFrame());
-
-    // Validate the wake-up frame using the wake-up identifier.
-    src.SetExtended(kSrcExtaddr);
-    wakeupRequest.SetWakeupId(kWakeupId);
-    txFrame.mPsdu      = psdu;
-    txFrame.mLength    = 0;
-    txFrame.mRadioType = 0;
-
-    SuccessOrQuit(txFrame.GenerateWakeupFrame(0xface, wakeupRequest, src));
-
-    // Validate that the frame satisfies the wake-up frame definition
-    VerifyOrQuit(txFrame.GetType() == Mac::Frame::kTypeMultipurpose);
-    VerifyOrQuit(!txFrame.GetAckRequest());
-    VerifyOrQuit(txFrame.GetRendezvousTimeIe() != nullptr);
-    VerifyOrQuit(txFrame.GetConnectionIe() != nullptr);
-    VerifyOrQuit(txFrame.GetPayloadLength() == 0);
-    SuccessOrQuit(txFrame.GetSrcAddr(addr));
-    VerifyOrQuit(CompareAddresses(src, addr));
-    SuccessOrQuit(txFrame.GetDstAddr(addr));
-    VerifyOrQuit(addr.IsNone());
-
-    // Initialize remaining fields and check if the frame has the expected contents
-    txFrame.SetFrameCounter(0xfcfcfcfc);
-    txFrame.SetKeySource(kKeySource);
-    txFrame.SetKeyId(0x1d);
-    txFrame.GetRendezvousTimeIe()->SetRendezvousTime(0xabcd);
-    connectionIe = txFrame.GetConnectionIe();
-    connectionIe->SetRetryInterval(1);
-    connectionIe->SetRetryCount(12);
-    SuccessOrQuit(connectionIe->SetWakeupId(kWakeupId));
-
-    VerifyOrQuit(txFrame.GetRendezvousTimeIe()->GetRendezvousTime() == 0xabcd);
-    VerifyOrQuit(connectionIe->GetRetryInterval() == 1);
-    VerifyOrQuit(connectionIe->GetRetryCount() == 12);
-    SuccessOrQuit(connectionIe->GetWakeupId(wakeupId));
-    VerifyOrQuit(wakeupId == kWakeupId);
-    VerifyOrQuit(wakeupRequest.GetWakeupId() == kWakeupId);
-    VerifyOrQuit(txFrame.GetLength() == sizeof(kWakeupPsdu2) + txFrame.GetFooterLength());
-    VerifyOrQuit(memcmp(psdu, kWakeupPsdu2, sizeof(kWakeupPsdu2)) == 0);
-
-    // Initialize RX Frame with the same PSDU and check if it's recognized as wake-up frame
-    rxFrame.mPsdu      = psdu;
-    rxFrame.mLength    = txFrame.GetLength();
-    rxFrame.mRadioType = 0;
-
-    SuccessOrQuit(rxFrame.ValidatePsdu());
-    VerifyOrQuit(rxFrame.IsWakeupFrame());
-}
-
-void TestMacWakeupFrameDetectionNegative(void)
-{
-    struct TestCase
-    {
-        uint8_t *mPsdu;
-        uint8_t  mLength;
-    };
-
-    uint8_t ackRequestedPsdu[] = {
-        // Frame Control
-        Mac::Frame::kTypeMultipurpose | kMpFcfLongFrame | kMpFcfDstAddrExt | kMpFcfSrcAddrExt,
-        (kMpFcfPanidPresent | kMpFcfSecurityEnabled | kMpFcfSequenceSuppression | kMpFcfAckRequest | kMpFcfIePresent) >>
-            8,
-        // PAN ID
-        0xCE, 0xFA,
-        // Destination Address
-        0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD,
-        // Source Address
-        0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55,
-        // Security Header
-        Mac::Frame::kKeyIdMode2 | Mac::Frame::kSecurityEncMic32, 0xFC, 0xFC, 0xFC, 0xFC, 0x00, 0x00, 0x00, 0x1C, 0x1D,
-        // Rendezvous Time IE
-        0x82, 0x0E, 0xCD, 0xAB,
-        // Connection IE
-        0x05, 0x00, 0x9B, 0xB8, 0xEA, 0x01, 0x1C,
-        // Footer
-        0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
-
-    uint8_t shortAddressPsdu[] = {
-        // Frame Control
-        Mac::Frame::kTypeMultipurpose | kMpFcfLongFrame | kMpFcfDstAddrExt | kMpFcfSrcAddrShort,
-        (kMpFcfPanidPresent | kMpFcfSecurityEnabled | kMpFcfSequenceSuppression | kMpFcfIePresent) >> 8,
-        // PAN ID
-        0xCE, 0xFA,
-        // Destination Address
-        0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD,
-        // Source Address
-        0x55, 0x55,
-        // Security Header
-        Mac::Frame::kKeyIdMode2 | Mac::Frame::kSecurityEncMic32, 0xFC, 0xFC, 0xFC, 0xFC, 0x00, 0x00, 0x00, 0x1C, 0x1D,
-        // Rendezvous Time IE
-        0x82, 0x0E, 0xCD, 0xAB,
-        // Connection IE
-        0x05, 0x00, 0x9B, 0xB8, 0xEA, 0x01, 0x1C,
-        // Footer
-        0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
-
-    uint8_t noRendezvousIePsdu[] = {
-        // Frame Control
-        Mac::Frame::kTypeMultipurpose | kMpFcfLongFrame | kMpFcfDstAddrExt | kMpFcfSrcAddrExt,
-        (kMpFcfPanidPresent | kMpFcfSecurityEnabled | kMpFcfSequenceSuppression | kMpFcfIePresent) >> 8,
-        // PAN ID
-        0xCE, 0xFA,
-        // Destination Address
-        0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD,
-        // Source Address
-        0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55,
-        // Security Header
-        Mac::Frame::kKeyIdMode2 | Mac::Frame::kSecurityEncMic32, 0xFC, 0xFC, 0xFC, 0xFC, 0x00, 0x00, 0x00, 0x1C, 0x1D,
-        // Connection IE
-        0x05, 0x00, 0x9B, 0xB8, 0xEA, 0x01, 0x1C,
-        // Footer
-        0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
-
-    uint8_t noConnectionIePsdu[] = {
-        // Frame Control
-        Mac::Frame::kTypeMultipurpose | kMpFcfLongFrame | kMpFcfDstAddrExt | kMpFcfSrcAddrExt,
-        (kMpFcfPanidPresent | kMpFcfSecurityEnabled | kMpFcfSequenceSuppression | kMpFcfIePresent) >> 8,
-        // PAN ID
-        0xCE, 0xFA,
-        // Destination Address
-        0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD,
-        // Source Address
-        0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55,
-        // Security Header
-        Mac::Frame::kKeyIdMode2 | Mac::Frame::kSecurityEncMic32, 0xFC, 0xFC, 0xFC, 0xFC, 0x00, 0x00, 0x00, 0x1C, 0x1D,
-        // Rendezvous Time IE
-        0x82, 0x0E, 0xCD, 0xAB,
-        // Connection IE
-        0x05, 0x00, 0x9B, 0xB8, 0xEA, 0x02, 0x1C,
-        // Footer
-        0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
-
-    uint8_t keyIdMode1Psdu[] = {
-        // Frame Control
-        Mac::Frame::kTypeMultipurpose | kMpFcfLongFrame | kMpFcfDstAddrExt | kMpFcfSrcAddrExt,
-        (kMpFcfPanidPresent | kMpFcfSecurityEnabled | kMpFcfSequenceSuppression | kMpFcfIePresent) >> 8,
-        // PAN ID
-        0xCE, 0xFA,
-        // Destination Address
-        0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDD,
-        // Source Address
-        0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55,
-        // Security Header
-        Mac::Frame::kKeyIdMode1 | Mac::Frame::kSecurityEncMic32, 0xFC, 0xFC, 0xFC, 0xFC, 0x1D,
-        // Rendezvous Time IE
-        0x82, 0x0E, 0xCD, 0xAB,
-        // Connection IE
-        0x05, 0x00, 0x9B, 0xB8, 0xEA, 0x01, 0x1C,
-        // Footer
-        0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
-
-    const TestCase testCases[] = {
-        {ackRequestedPsdu, sizeof(ackRequestedPsdu)},     {shortAddressPsdu, sizeof(shortAddressPsdu)},
-        {noRendezvousIePsdu, sizeof(noRendezvousIePsdu)}, {noConnectionIePsdu, sizeof(noConnectionIePsdu)},
-        {keyIdMode1Psdu, sizeof(keyIdMode1Psdu)},
-    };
-
-    Mac::Frame rxFrame;
-
-    printf("TestMacWakeupFrameDetectionNegative\n");
-
-    for (const TestCase &testCase : testCases)
-    {
-        rxFrame.mPsdu      = testCase.mPsdu;
-        rxFrame.mLength    = testCase.mLength;
-        rxFrame.mRadioType = 0;
-
-        SuccessOrQuit(rxFrame.ValidatePsdu());
-        VerifyOrQuit(!rxFrame.IsWakeupFrame());
-    }
-}
-#endif
 } // namespace ot
 
 int main(void)
 {
     ot::TestMacAddress();
     ot::TestMacHeader();
+    ot::TestMacHeaderIeStartEnd();
     ot::TestMacChannelMask();
     ot::TestMacFrameApi();
     ot::TestMacFrameAckGeneration();
-#if OPENTHREAD_CONFIG_WAKEUP_COORDINATOR_ENABLE
-    ot::TestMacWakeupFrameGeneration();
-    ot::TestMacWakeupFrameDetectionNegative();
-#endif
     printf("All tests passed\n");
     return 0;
 }
diff --git a/tests/unit/test_mdns.cpp b/tests/unit/test_mdns.cpp
index 64f8618..45100f1 100644
--- a/tests/unit/test_mdns.cpp
+++ b/tests/unit/test_mdns.cpp
@@ -1331,6 +1331,53 @@
     otPlatMdnsHandleReceive(sInstance, message, /* aIsUnicast */ false, &senderAddrInfo);
 }
 
+static void SendPtrResponseWithEmptyInstanceLabel(const char *aName, uint32_t aTtl)
+{
+    // Sends a PTR response whose target name starts with a single
+    // NUL-byte (empty) label, i.e. the wire label `01 00` followed by
+    // `aName`. The RDLENGTH is computed from the appended target bytes.
+
+    static const uint8_t kEmptyLabel[] = {1, 0};
+
+    Message          *message;
+    Header            header;
+    PtrRecord         ptr;
+    Core::AddressInfo senderAddrInfo;
+    uint16_t          ptrOffset;
+    uint16_t          rdataOffset;
+
+    message = sInstance->Get<MessagePool>().Allocate(Message::kTypeOther);
+    VerifyOrQuit(message != nullptr);
+
+    header.Clear();
+    header.SetType(Header::kTypeResponse);
+    header.SetAnswerCount(1);
+
+    SuccessOrQuit(message->Append(header));
+    SuccessOrQuit(Name::AppendName(aName, *message));
+
+    ptr.Init();
+    ptr.SetTtl(aTtl);
+    ptr.SetLength(0);
+    ptrOffset = message->GetLength();
+    SuccessOrQuit(message->Append(ptr));
+
+    rdataOffset = message->GetLength();
+    SuccessOrQuit(message->AppendBytes(kEmptyLabel, sizeof(kEmptyLabel)));
+    SuccessOrQuit(Name::AppendName(aName, *message));
+
+    ptr.SetLength(message->GetLength() - rdataOffset);
+    message->WriteBytes(ptrOffset, &ptr, sizeof(ptr));
+
+    SuccessOrQuit(AsCoreType(&senderAddrInfo.mAddress).FromString(kDeviceIp6Address));
+    senderAddrInfo.mPort         = kMdnsPort;
+    senderAddrInfo.mInfraIfIndex = 0;
+
+    Log("Sending PTR response for %s with empty instance label, ttl:%lu", aName, ToUlong(aTtl));
+
+    otPlatMdnsHandleReceive(sInstance, message, /* aIsUnicast */ false, &senderAddrInfo);
+}
+
 static void SendSrvResponse(const char *aServiceName,
                             const char *aHostName,
                             uint16_t    aPort,
@@ -1677,26 +1724,26 @@
 
 extern "C" {
 
-#if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
-void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
-{
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-    OT_UNUSED_VARIABLE(aFormat);
-
 #if ENABLE_TEST_LOG
+
+#if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+void otPlatLogOutput(otInstance *, otLogLevel, const char *aLogLine) { printf("   %s\n", aLogLine); }
+#else
+void otPlatLog(otLogLevel, otLogRegion, const char *aFormat, ...)
+{
     va_list args;
 
     printf("   ");
     va_start(args, aFormat);
     vprintf(aFormat, args);
     va_end(args);
-
     printf("\n");
-#endif
 }
-
 #endif
+#endif
+
+#endif // ENABLE_TEST_LOG
 
 //----------------------------------------------------------------------------------------------------------------------
 // `otPlatAlarm`
@@ -2271,7 +2318,7 @@
 
     SuccessOrQuit(ip4Address.FromString("200.1.5.6"));
     SuccessOrQuit(localHost.mIp4Addrs.PushBack(ip4Address));
-    ip6Address.SetToIp4Mapped(ip4Address);
+    ip6Address.InitAsIp4Mapped(ip4Address);
     otPlatMdnsHandleHostAddressEvent(sInstance, &ip6Address, /* aAdded */ true, kInfraIfIndex);
 
     AdvanceTime(4);
@@ -2436,17 +2483,17 @@
 
     SuccessOrQuit(ip4Address.FromString("200.1.5.7"));
     SuccessOrQuit(localHost.mIp4Addrs.PushBack(ip4Address));
-    ip6Address.SetToIp4Mapped(ip4Address);
+    ip6Address.InitAsIp4Mapped(ip4Address);
     otPlatMdnsHandleHostAddressEvent(sInstance, &ip6Address, /* aAdded */ true, kInfraIfIndex);
 
     SuccessOrQuit(ip4Address.FromString("200.1.2.100"));
     SuccessOrQuit(localHost.mIp4Addrs.PushBack(ip4Address));
-    ip6Address.SetToIp4Mapped(ip4Address);
+    ip6Address.InitAsIp4Mapped(ip4Address);
     otPlatMdnsHandleHostAddressEvent(sInstance, &ip6Address, /* aAdded */ true, kInfraIfIndex);
 
     SuccessOrQuit(ip4Address.FromString("200.1.4.0"));
     SuccessOrQuit(localHost.mIp4Addrs.PushBack(ip4Address));
-    ip6Address.SetToIp4Mapped(ip4Address);
+    ip6Address.InitAsIp4Mapped(ip4Address);
     otPlatMdnsHandleHostAddressEvent(sInstance, &ip6Address, /* aAdded */ true, kInfraIfIndex);
 
     Log("Validate the announcements");
@@ -2481,7 +2528,7 @@
 
     for (Ip4::Address &ip4Addr : localHost.mIp4Addrs)
     {
-        ip6Address.SetToIp4Mapped(ip4Addr);
+        ip6Address.InitAsIp4Mapped(ip4Addr);
         otPlatMdnsHandleHostAddressEvent(sInstance, &ip6Address, /* aAdded */ true, kInfraIfIndex);
     }
 
@@ -2510,7 +2557,7 @@
 
     for (Ip4::Address &ip4Addr : localHost.mIp4Addrs)
     {
-        ip6Address.SetToIp4Mapped(ip4Addr);
+        ip6Address.InitAsIp4Mapped(ip4Addr);
         otPlatMdnsHandleHostAddressEvent(sInstance, &ip6Address, /* aAdded */ true, kInfraIfIndex);
     }
 
@@ -2562,7 +2609,7 @@
 
     for (Ip4::Address &ip4Addr : localHost.mIp4Addrs)
     {
-        ip6Address.SetToIp4Mapped(ip4Addr);
+        ip6Address.InitAsIp4Mapped(ip4Addr);
         otPlatMdnsHandleHostAddressEvent(sInstance, &ip6Address, /* aAdded */ true, kInfraIfIndex);
     }
 
@@ -2589,7 +2636,7 @@
 
     for (const Ip4::Address &ip4Addr : localHost.mIp4Addrs)
     {
-        ip6Address.SetToIp4Mapped(ip4Addr);
+        ip6Address.InitAsIp4Mapped(ip4Addr);
         otPlatMdnsHandleHostAddressEvent(sInstance, &ip6Address, /* aAdded */ false, kInfraIfIndex);
     }
 
@@ -6223,6 +6270,69 @@
     testFreeInstance(sInstance);
 }
 
+void TestBrowserMalformedPtrName(void)
+{
+    Core             *mdns = InitTest();
+    Core::Browser     browser;
+    const DnsMessage *dnsMsg;
+    uint16_t          heapAllocations;
+
+    Log("-------------------------------------------------------------------------------------------");
+    Log("TestBrowserMalformedPtrName");
+
+    AdvanceTime(1);
+
+    heapAllocations = sHeapAllocatedPtrs.GetLength();
+    SuccessOrQuit(mdns->SetEnabled(true, kInfraIfIndex));
+
+    ClearAllBytes(browser);
+    browser.mServiceType  = "_srv._udp";
+    browser.mSubTypeLabel = nullptr;
+    browser.mInfraIfIndex = kInfraIfIndex;
+    browser.mCallback     = HandleBrowseResult;
+
+    sDnsMessages.Clear();
+    sBrowseCallbacks.Clear();
+    SuccessOrQuit(mdns->StartBrowser(browser));
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -");
+    Log("Send a PTR response whose service-instance label is a single NUL byte");
+
+    SendPtrResponseWithEmptyInstanceLabel("_srv._udp.local.", 120);
+
+    AdvanceTime(1);
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -");
+    Log("Validate that the malformed record is dropped and no result is reported");
+
+    VerifyOrQuit(sBrowseCallbacks.IsEmpty());
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -");
+    Log("Validate that the browser keeps querying with no known-answer for the dropped record");
+
+    for (uint8_t queryCount = 0; queryCount < kNumInitialQueries; queryCount++)
+    {
+        sDnsMessages.Clear();
+
+        AdvanceTime(DetermineQueryWaitTime(queryCount));
+
+        VerifyOrQuit(!sDnsMessages.IsEmpty());
+        dnsMsg = sDnsMessages.GetHead();
+        dnsMsg->ValidateHeader(kMulticastQuery, /* Q */ 1, /* Ans */ 0, /* Auth */ 0, /* Addnl */ 0);
+        dnsMsg->ValidateAsQueryFor(browser);
+        VerifyOrQuit(dnsMsg->GetNext() == nullptr);
+    }
+
+    VerifyOrQuit(sBrowseCallbacks.IsEmpty());
+
+    SuccessOrQuit(mdns->SetEnabled(false, kInfraIfIndex));
+    VerifyOrQuit(sHeapAllocatedPtrs.GetLength() <= heapAllocations);
+
+    Log("End of test");
+
+    testFreeInstance(sInstance);
+}
+
 void TestSrvResolver(void)
 {
     Core              *mdns = InitTest();
@@ -8923,6 +9033,7 @@
     ot::Dns::Multicast::TestServiceConflict();
 
     ot::Dns::Multicast::TestBrowser();
+    ot::Dns::Multicast::TestBrowserMalformedPtrName();
     ot::Dns::Multicast::TestSrvResolver();
     ot::Dns::Multicast::TestTxtResolver();
     ot::Dns::Multicast::TestIp6AddrResolver();
diff --git a/tests/unit/test_message_queue.cpp b/tests/unit/test_message_queue.cpp
index 1612f13..54eb49e 100644
--- a/tests/unit/test_message_queue.cpp
+++ b/tests/unit/test_message_queue.cpp
@@ -286,6 +286,59 @@
     testFreeInstance(sInstance);
 }
 
+void TestMessageQueueEnqueueAllFrom(void)
+{
+    // Validates the behavior of `MessageQueue::EnqueueAllFrom()` method.
+
+    MessageQueue messageQueue1;
+    MessageQueue messageQueue2;
+    Message     *messages[kNumTestMessages];
+
+    sInstance = testInitInstance();
+    VerifyOrQuit(sInstance != nullptr);
+
+    sMessagePool = &sInstance->Get<MessagePool>();
+
+    for (Message *&msg : messages)
+    {
+        msg = sMessagePool->Allocate(Message::kTypeIp6);
+        VerifyOrQuit(msg != nullptr, "Message::Allocate() failed");
+    }
+
+    // Case 1: Both queues are empty
+    messageQueue1.EnqueueAllFrom(messageQueue2);
+    VerifyMessageQueueContent(messageQueue1, 0);
+    VerifyMessageQueueContent(messageQueue2, 0);
+
+    // Case 2: Target queue (Q1) is empty, input queue (Q2) is non-empty
+    messageQueue2.Enqueue(*messages[0]);
+    messageQueue2.Enqueue(*messages[1]);
+    VerifyMessageQueueContent(messageQueue2, 2, messages[0], messages[1]);
+    messageQueue1.EnqueueAllFrom(messageQueue2);
+    VerifyMessageQueueContent(messageQueue1, 2, messages[0], messages[1]);
+    VerifyMessageQueueContent(messageQueue2, 0);
+
+    // Case 3: Target queue (Q1) is non-empty, input queue (Q2) is empty
+    messageQueue1.EnqueueAllFrom(messageQueue2);
+    VerifyMessageQueueContent(messageQueue1, 2, messages[0], messages[1]);
+    VerifyMessageQueueContent(messageQueue2, 0);
+
+    // Case 4: Both queues are non-empty
+    messageQueue2.Enqueue(*messages[2]);
+    VerifyMessageQueueContent(messageQueue2, 1, messages[2]);
+    messageQueue1.EnqueueAllFrom(messageQueue2);
+    VerifyMessageQueueContent(messageQueue1, 3, messages[0], messages[1], messages[2]);
+    VerifyMessageQueueContent(messageQueue2, 0);
+
+    // Case 5: Using the same queue as both the target and input queues
+    messageQueue1.EnqueueAllFrom(messageQueue1);
+    VerifyMessageQueueContent(messageQueue1, 3, messages[0], messages[1], messages[2]);
+    messageQueue2.EnqueueAllFrom(messageQueue2);
+    VerifyMessageQueueContent(messageQueue2, 0);
+
+    testFreeInstance(sInstance);
+}
+
 // This function verifies the content of the message queue to match the passed in messages
 void VerifyMessageQueueContentUsingOtApi(otMessageQueue *aQueue, int aExpectedLength, ...)
 {
@@ -386,6 +439,7 @@
 int main(void)
 {
     ot::TestMessageQueue();
+    ot::TestMessageQueueEnqueueAllFrom();
     ot::TestMessageQueueOtApis();
     printf("All tests passed\n");
     return 0;
diff --git a/tests/unit/test_mle.cpp b/tests/unit/test_mle.cpp
index 8e5a34b..6bef15c 100644
--- a/tests/unit/test_mle.cpp
+++ b/tests/unit/test_mle.cpp
@@ -32,7 +32,11 @@
 #include "test_util.hpp"
 
 #include "common/num_utils.hpp"
+#include "thread/lowpan.hpp"
+#include "thread/mle.hpp"
+#include "thread/mle_tlvs.hpp"
 #include "thread/mle_types.hpp"
+#include "thread/network_data_leader.hpp"
 
 namespace ot {
 
@@ -188,6 +192,287 @@
     printf("TestDeviceMode passed\n");
 }
 
+namespace {
+
+constexpr uint16_t kOldParentRloc16 = 0x5400;
+constexpr uint16_t kOldChildRloc16  = 0x5401;
+constexpr uint16_t kNewParentRloc16 = 0x5c00;
+constexpr uint16_t kNewChildRloc16  = 0x5c01;
+
+constexpr uint8_t kOldDataVersion   = 4;
+constexpr uint8_t kOldStableVersion = 3;
+constexpr uint8_t kNewDataVersion   = 8;
+constexpr uint8_t kNewStableVersion = 7;
+constexpr uint8_t kMalformedDataLen = 31;
+
+const uint8_t kOldNetworkData[] = {
+    0x03, 0x0e, 0x00, 0x40, 0x20, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x07, 0x02, 0x11, 0x40,
+    0x03, 0x0e, 0x00, 0x40, 0x20, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x07, 0x02, 0x02, 0x40,
+};
+
+const uint8_t kNewNetworkData[] = {
+    0x03, 0x0e, 0x00, 0x40, 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x03, 0x07, 0x02, 0x13, 0x40,
+    0x03, 0x0e, 0x00, 0x40, 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x04, 0x07, 0x02, 0x04, 0x40,
+};
+
+Ip6::Prefix PrefixFromString(const char *aString, uint8_t aLength)
+{
+    Ip6::Prefix prefix;
+
+    SuccessOrQuit(AsCoreType(&prefix.mPrefix).FromString(aString));
+    prefix.mLength = aLength;
+
+    return prefix;
+}
+
+Mac::ExtAddress ExtAddressFromSeed(uint8_t aSeed)
+{
+    Mac::ExtAddress extAddress;
+
+    for (size_t index = 0; index < sizeof(extAddress.m8); index++)
+    {
+        extAddress.m8[index] = static_cast<uint8_t>(aSeed + index);
+    }
+
+    return extAddress;
+}
+
+void VerifyContext(Instance &aInstance, uint8_t aContextId, const Ip6::Prefix &aExpectedPrefix, bool aShouldBeValid)
+{
+    Lowpan::Context context;
+
+    aInstance.Get<NetworkData::Leader>().FindContextForId(aContextId, context);
+    VerifyOrQuit(context.IsValid() == aShouldBeValid);
+
+    if (aShouldBeValid)
+    {
+        VerifyOrQuit(context.GetContextId() == aContextId);
+        VerifyOrQuit(context.GetPrefix() == aExpectedPrefix);
+    }
+}
+
+} // namespace
+
+class UnitTester
+{
+public:
+    static void TestChildIdResponseNetworkDataHandling(void)
+    {
+        TestValidNetworkDataControl();
+        TestMissingNetworkDataControl();
+        TestMalformedNetworkDataControl();
+
+        printf("TestChildIdResponseNetworkDataHandling passed\n");
+    }
+
+private:
+    static void SetNetworkData(Instance      &aInstance,
+                               uint8_t        aDataVersion,
+                               uint8_t        aStableVersion,
+                               const uint8_t *aNetworkData,
+                               uint8_t        aNetworkDataLength)
+    {
+        Message    *message = aInstance.Get<MessagePool>().Allocate(Message::kTypeIp6);
+        OffsetRange offsetRange;
+
+        VerifyOrQuit(message != nullptr);
+
+        SuccessOrQuit(message->AppendBytes(aNetworkData, aNetworkDataLength));
+        offsetRange.Init(0, aNetworkDataLength);
+
+        SuccessOrQuit(aInstance.Get<NetworkData::Leader>().SetNetworkData(
+            aDataVersion, aStableVersion, NetworkData::kFullSet, *message, offsetRange));
+
+        message->Free();
+    }
+
+    static Message *NewChildIdResponseMessage(Instance              &aInstance,
+                                              uint16_t               aSourceAddress,
+                                              uint16_t               aChildAddress,
+                                              const Mle::LeaderData &aLeaderData,
+                                              const uint8_t         *aNetworkData,
+                                              uint8_t                aNetworkDataLength,
+                                              bool                   aIncludeNetworkData)
+    {
+        Message                      *message = aInstance.Get<MessagePool>().Allocate(Message::kTypeIp6);
+        const Mle::LeaderDataTlvValue leaderDataTlv(aLeaderData);
+
+        VerifyOrQuit(message != nullptr);
+        message->SetSubType(Message::kSubTypeMle);
+
+        SuccessOrQuit(Tlv::Append<Mle::SourceAddressTlv>(*message, aSourceAddress));
+        SuccessOrQuit(Tlv::Append<Mle::Address16Tlv>(*message, aChildAddress));
+        SuccessOrQuit(Tlv::Append<Mle::LeaderDataTlv>(*message, leaderDataTlv));
+
+        if (aIncludeNetworkData)
+        {
+            SuccessOrQuit(Tlv::Append<Mle::NetworkDataTlv>(*message, aNetworkData, aNetworkDataLength));
+        }
+
+        return message;
+    }
+
+    static void PrepareChildIdResponse(Mle::Mle &aMle, const Mac::ExtAddress &aParentExtAddress, uint16_t aParentRloc16)
+    {
+        Parent &parentCandidate = aMle.GetParentCandidate();
+
+        aMle.SetStateDetached();
+        aMle.mParent.SetState(Neighbor::kStateInvalid);
+        aMle.SetRloc16(Mle::kInvalidRloc16);
+        aMle.Get<ThreadNetif>().Up();
+        aMle.Get<ThreadNetif>().AddUnicastAddress(aMle.mMeshLocalEid);
+
+        parentCandidate.Clear();
+        parentCandidate.GetExtAddress() = aParentExtAddress;
+        parentCandidate.SetRloc16(aParentRloc16);
+        parentCandidate.SetVersion(kThreadVersion);
+        parentCandidate.SetDeviceMode(Mle::DeviceMode(Mle::DeviceMode::kModeFullThreadDevice |
+                                                      Mle::DeviceMode::kModeRxOnWhenIdle |
+                                                      Mle::DeviceMode::kModeFullNetworkData));
+        parentCandidate.SetState(Neighbor::kStateValid);
+        aMle.mAttacher.mState = Mle::Mle::Attacher::kStateChildIdRequest;
+    }
+
+    static void HandleChildIdResponse(Mle::Mle &aMle, Message &aMessage, const Mac::ExtAddress &aParentExtAddress)
+    {
+        Ip6::Address     peerAddress;
+        Ip6::MessageInfo messageInfo;
+        Mle::Mle::RxInfo rxInfo(aMessage, messageInfo);
+
+        peerAddress.InitAsLinkLocalAddress(aParentExtAddress);
+        messageInfo.SetPeerAddr(peerAddress);
+        messageInfo.SetSockAddr(aMle.GetLinkLocalAddress());
+
+        aMessage.SetOffset(0);
+        rxInfo.mNeighbor = &aMle.mAttacher.mParentCandidate;
+
+        aMle.mAttacher.HandleChildIdResponse(rxInfo);
+    }
+
+    static void VerifyDataVersions(Instance &aInstance, uint8_t aDataVersion, uint8_t aStableVersion)
+    {
+        VerifyOrQuit(aInstance.Get<NetworkData::Leader>().GetVersion(NetworkData::kFullSet) == aDataVersion);
+        VerifyOrQuit(aInstance.Get<NetworkData::Leader>().GetVersion(NetworkData::kStableSubset) == aStableVersion);
+    }
+
+    static Mle::LeaderData NewLeaderData(uint32_t aPartitionId,
+                                         uint8_t  aWeighting,
+                                         uint8_t  aLeaderRouterId,
+                                         uint8_t  aDataVersion,
+                                         uint8_t  aStableVersion)
+    {
+        Mle::LeaderData leaderData;
+
+        leaderData.SetPartitionId(aPartitionId);
+        leaderData.SetWeighting(aWeighting);
+        leaderData.SetLeaderRouterId(aLeaderRouterId);
+        leaderData.SetDataVersion(aDataVersion);
+        leaderData.SetStableDataVersion(aStableVersion);
+
+        return leaderData;
+    }
+
+    static void TestValidNetworkDataControl(void)
+    {
+        Instance             *instance         = static_cast<Instance *>(testInitInstance());
+        Mle::Mle             &mle              = instance->Get<Mle::Mle>();
+        const Mac::ExtAddress parentExtAddress = ExtAddressFromSeed(0x30);
+        const Ip6::Prefix     oldPrefix1       = PrefixFromString("2001:2:0:1::", 64);
+        const Ip6::Prefix     oldPrefix2       = PrefixFromString("2001:2:0:2::", 64);
+        const Ip6::Prefix     newPrefix1       = PrefixFromString("2001:db8:0:3::", 64);
+        const Ip6::Prefix     newPrefix2       = PrefixFromString("2001:db8:0:4::", 64);
+        Message              *message;
+        Mle::LeaderData       leaderData = NewLeaderData(0x11111111, 64, Mle::RouterIdFromRloc16(kNewParentRloc16),
+                                                         kNewDataVersion, kNewStableVersion);
+
+        printf("valid-network-data-control\n");
+
+        SetNetworkData(*instance, kOldDataVersion, kOldStableVersion, kOldNetworkData, sizeof(kOldNetworkData));
+        PrepareChildIdResponse(mle, parentExtAddress, kNewParentRloc16);
+
+        message = NewChildIdResponseMessage(*instance, kNewParentRloc16, kNewChildRloc16, leaderData, kNewNetworkData,
+                                            sizeof(kNewNetworkData), true);
+
+        HandleChildIdResponse(mle, *message, parentExtAddress);
+
+        VerifyOrQuit(mle.IsChild());
+        VerifyOrQuit(mle.GetRloc16() == kNewChildRloc16);
+        VerifyOrQuit(mle.GetParent().GetRloc16() == kNewParentRloc16);
+        VerifyDataVersions(*instance, kNewDataVersion, kNewStableVersion);
+        VerifyContext(*instance, 1, oldPrefix1, false);
+        VerifyContext(*instance, 2, oldPrefix2, false);
+        VerifyContext(*instance, 3, newPrefix1, true);
+        VerifyContext(*instance, 4, newPrefix2, true);
+
+        message->Free();
+        testFreeInstance(instance);
+    }
+
+    static void TestMissingNetworkDataControl(void)
+    {
+        Instance             *instance         = static_cast<Instance *>(testInitInstance());
+        Mle::Mle             &mle              = instance->Get<Mle::Mle>();
+        const Mac::ExtAddress parentExtAddress = ExtAddressFromSeed(0x40);
+        const Ip6::Prefix     oldPrefix1       = PrefixFromString("2001:2:0:1::", 64);
+        const Ip6::Prefix     oldPrefix2       = PrefixFromString("2001:2:0:2::", 64);
+        Message              *message;
+        Mle::LeaderData leaderData = NewLeaderData(0x22222222, 64, Mle::RouterIdFromRloc16(kOldParentRloc16), 9, 9);
+
+        printf("missing-network-data-control\n");
+
+        SetNetworkData(*instance, kOldDataVersion, kOldStableVersion, kOldNetworkData, sizeof(kOldNetworkData));
+        PrepareChildIdResponse(mle, parentExtAddress, kOldParentRloc16);
+
+        message =
+            NewChildIdResponseMessage(*instance, kOldParentRloc16, kOldChildRloc16, leaderData, nullptr, 0, false);
+
+        HandleChildIdResponse(mle, *message, parentExtAddress);
+
+        VerifyOrQuit(!mle.IsChild());
+        VerifyDataVersions(*instance, kOldDataVersion, kOldStableVersion);
+        VerifyContext(*instance, 1, oldPrefix1, true);
+        VerifyContext(*instance, 2, oldPrefix2, true);
+
+        message->Free();
+        testFreeInstance(instance);
+    }
+
+    static void TestMalformedNetworkDataControl(void)
+    {
+        Instance             *instance         = static_cast<Instance *>(testInitInstance());
+        Mle::Mle             &mle              = instance->Get<Mle::Mle>();
+        const Mac::ExtAddress parentExtAddress = ExtAddressFromSeed(0x50);
+        const Ip6::Prefix     oldPrefix1       = PrefixFromString("2001:2:0:1::", 64);
+        const Ip6::Prefix     oldPrefix2       = PrefixFromString("2001:2:0:2::", 64);
+        const Ip6::Prefix     newPrefix1       = PrefixFromString("2001:db8:0:3::", 64);
+        Message              *message;
+        Mle::LeaderData       leaderData = NewLeaderData(0x33333333, 64, Mle::RouterIdFromRloc16(kNewParentRloc16),
+                                                         kNewDataVersion, kNewStableVersion);
+
+        printf("malformed-network-data-control\n");
+
+        SetNetworkData(*instance, kOldDataVersion, kOldStableVersion, kOldNetworkData, sizeof(kOldNetworkData));
+        PrepareChildIdResponse(mle, parentExtAddress, kNewParentRloc16);
+
+        message = NewChildIdResponseMessage(*instance, kNewParentRloc16, kNewChildRloc16, leaderData, kNewNetworkData,
+                                            kMalformedDataLen, true);
+
+        HandleChildIdResponse(mle, *message, parentExtAddress);
+
+        VerifyOrQuit(mle.IsDetached());
+        VerifyOrQuit(mle.GetParent().IsStateInvalid());
+        VerifyOrQuit(mle.mAttacher.mState == Mle::Mle::Attacher::kStateStart);
+        VerifyOrQuit(mle.mAttacher.mTimer.IsRunning());
+        VerifyDataVersions(*instance, kOldDataVersion, kOldStableVersion);
+        VerifyContext(*instance, 1, oldPrefix1, true);
+        VerifyContext(*instance, 2, oldPrefix2, true);
+        VerifyContext(*instance, 3, newPrefix1, false);
+
+        message->Free();
+        testFreeInstance(instance);
+    }
+};
+
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_MLE_DEVICE_PROPERTY_LEADER_WEIGHT_ENABLE
 
 void TestDefaultDeviceProperties(void)
@@ -329,6 +614,7 @@
 int main(void)
 {
     ot::TestDeviceMode();
+    ot::UnitTester::TestChildIdResponseNetworkDataHandling();
 
 #if OPENTHREAD_FTD && OPENTHREAD_CONFIG_MLE_DEVICE_PROPERTY_LEADER_WEIGHT_ENABLE
     ot::TestDefaultDeviceProperties();
diff --git a/tests/unit/test_multicast_listeners_table.cpp b/tests/unit/test_multicast_listeners_table.cpp
index ba50711..bb1f1d2 100644
--- a/tests/unit/test_multicast_listeners_table.cpp
+++ b/tests/unit/test_multicast_listeners_table.cpp
@@ -42,150 +42,86 @@
 
 namespace ot {
 
-static Instance *sInstance;
-
 using namespace ot::BackboneRouter;
 
-static const otIp6Address MA201 = {
-    {{0xff, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}}};
-static const otIp6Address MA301 = {
-    {{0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}}};
-static const otIp6Address MA401 = {
-    {{0xff, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}}};
-static const otIp6Address MA501 = {
-    {{0xff, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}}};
-
-uint32_t sNow;
-
-extern "C" uint32_t otPlatAlarmMilliGetNow(void) { return sNow; }
-
-void testMulticastListenersTableAPIs(Instance *aInstance);
-
 void TestMulticastListenersTable(void)
 {
-    sInstance = testInitInstance();
-    VerifyOrQuit(sInstance != nullptr);
+    static constexpr uint16_t kMaxSize = OPENTHREAD_CONFIG_MAX_MULTICAST_LISTENERS;
 
-    MulticastListenersTable &table = sInstance->Get<MulticastListenersTable>();
+    MulticastListenersTable *table;
+    Instance                *instance;
+    Ip6::Address             kMa201;
+    Ip6::Address             kMa301;
+    Ip6::Address             kMa401;
+    Ip6::Address             kMa501;
+    TimeMilli                now(0);
 
-    for (MulticastListenersTable::Listener &listener : table.Iterate())
-    {
-        VerifyOrQuit(false, "MulticastListenersTable should be empty when created");
-    }
+    SuccessOrQuit(kMa201.FromString("ff02::01"));
+    SuccessOrQuit(kMa301.FromString("ff03::01"));
+    SuccessOrQuit(kMa401.FromString("ff04::01"));
+    SuccessOrQuit(kMa501.FromString("ff05::01"));
 
-    // Removing from empty table should be OK
-    table.Remove(static_cast<const Ip6::Address &>(MA401));
+    instance = testInitInstance();
+    VerifyOrQuit(instance != nullptr);
 
-    sNow = 1;
-    // Add valid MAs should succeed
-    SuccessOrQuit(table.Add(static_cast<const Ip6::Address &>(MA401), TimerMilli::GetNow()));
-    SuccessOrQuit(table.Add(static_cast<const Ip6::Address &>(MA501), TimerMilli::GetNow()));
-    VerifyOrQuit(table.Count() == 2, "Table count is wrong");
+    table = &instance->Get<MulticastListenersTable>();
+
+    VerifyOrQuit(table->Count() == 0);
+
+    table->Remove(kMa201);
+
+    VerifyOrQuit(table->Count() == 0);
+
+    SuccessOrQuit(table->Add(kMa401, now));
+    SuccessOrQuit(table->Add(kMa501, now));
+    VerifyOrQuit(table->Count() == 2);
 
     // Add invalid MAs should fail with kErrorInvalidArgs
-    VerifyOrQuit(table.Add(static_cast<const Ip6::Address &>(MA201), TimerMilli::GetNow()) == kErrorInvalidArgs,
-                 "failed to detect bad arg");
-    VerifyOrQuit(table.Add(static_cast<const Ip6::Address &>(MA301), TimerMilli::GetNow()) == kErrorInvalidArgs,
-                 "failed to detect bad arg");
+    VerifyOrQuit(table->Add(kMa201, now) == kErrorInvalidArgs);
+    VerifyOrQuit(table->Add(kMa301, now) == kErrorInvalidArgs);
+    VerifyOrQuit(table->Count() == 2);
 
-    // Expire should expire outdated Listeners
-    sNow = 2;
-    table.Expire();
-    VerifyOrQuit(table.Count() == 0, "Table count is wrong");
+    // Re-add an existing entry
+    SuccessOrQuit(table->Add(kMa501, now));
+    VerifyOrQuit(table->Count() == 2);
 
-    // Add different addresses until the table is full
-    sNow = 10;
-    for (uint16_t i = 0; i < OPENTHREAD_CONFIG_MAX_MULTICAST_LISTENERS; i++)
+    VerifyOrQuit(table->Has(kMa401));
+    VerifyOrQuit(table->Has(kMa501));
+    VerifyOrQuit(!table->Has(kMa201));
+    VerifyOrQuit(!table->Has(kMa301));
+
+    table->Clear();
+    VerifyOrQuit(table->Count() == 0);
+
+    for (uint16_t i = 0; i < kMaxSize; i++)
     {
         Ip6::Address address;
 
-        address                = static_cast<const Ip6::Address &>(MA401);
+        address                = kMa401;
         address.mFields.m16[7] = BigEndian::HostSwap16(i);
 
-        SuccessOrQuit(table.Add(address, TimerMilli::GetNow() + i));
-        VerifyOrQuit(table.Count() == i + 1, "Table count is wrong");
+        SuccessOrQuit(table->Add(address, now));
+
+        VerifyOrQuit(table->Count() == i + 1);
+        VerifyOrQuit(table->Has(address));
     }
 
-    // Now the table is full, we can't add more addresses
-    VerifyOrQuit(table.Add(static_cast<const Ip6::Address &>(MA501), TimerMilli::GetNow()) == kErrorNoBufs,
-                 "succeeded when table is full");
+    // Now the table is full, adding more entries should fail
+    VerifyOrQuit(table->Add(kMa501, now) == kErrorNoBufs);
 
-    // Expire one Listener at a time
-    for (uint16_t i = 0; i < OPENTHREAD_CONFIG_MAX_MULTICAST_LISTENERS; i++)
-    {
-        table.Expire();
-        VerifyOrQuit(table.Count() == OPENTHREAD_CONFIG_MAX_MULTICAST_LISTENERS - i - 1, "Table count is wrong");
+    VerifyOrQuit(table->Count() == kMaxSize);
 
-        sNow += 1;
-    }
-
-    // Now the table should be empty
-    VerifyOrQuit(table.Count() == 0, "Table count is wrong");
-
-    // Now test the APIs
-    testMulticastListenersTableAPIs(sInstance);
-
-    // Do some fuzzy test
-    for (uint16_t i = 0; i < 10000; i++)
+    for (uint16_t i = 0; i < kMaxSize; i++)
     {
         Ip6::Address address;
-        sNow += 10;
 
-        table.Expire();
+        address                = kMa401;
+        address.mFields.m16[7] = BigEndian::HostSwap16(i);
 
-        for (MulticastListenersTable::Listener &listener : table.Iterate())
-        {
-            OT_ASSERT(listener.GetAddress().IsMulticastLargerThanRealmLocal());
-            OT_ASSERT(listener.GetExpireTime() > TimerMilli::GetNow());
-        }
-
-        address = static_cast<const Ip6::Address &>(MA401);
-
-        address.mFields.m16[7] = Random::NonCrypto::GetUint16InRange(1, 1000);
-        IgnoreError(table.Add(address, TimerMilli::GetNow() + Random::NonCrypto::GetUint32InRange(1, 100)));
-
-        address.mFields.m16[7] = Random::NonCrypto::GetUint16InRange(1, 1000);
-        if (Random::NonCrypto::GetUint16InRange(0, 2) == 0)
-        {
-            table.Remove(address);
-        }
+        VerifyOrQuit(table->Has(address));
     }
 }
 
-void testMulticastListenersTableAPIs(Instance *aInstance)
-{
-#if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE
-    otBackboneRouterMulticastListenerIterator iter = OT_BACKBONE_ROUTER_MULTICAST_LISTENER_ITERATOR_INIT;
-    otBackboneRouterMulticastListenerInfo     info;
-    size_t                                    table_size = 0;
-
-    while (otBackboneRouterMulticastListenerGetNext(aInstance, &iter, &info) == kErrorNone)
-    {
-        VerifyOrQuit(false, "Table should be empty");
-    }
-
-    SuccessOrQuit(otBackboneRouterMulticastListenerAdd(aInstance, &MA401, 30));
-
-    table_size = 0, iter = OT_BACKBONE_ROUTER_MULTICAST_LISTENER_ITERATOR_INIT;
-    while (otBackboneRouterMulticastListenerGetNext(aInstance, &iter, &info) == kErrorNone)
-    {
-        table_size++;
-
-        VerifyOrQuit(memcmp(&info.mAddress, &MA401, sizeof(otIp6Address)) == 0, "bad address");
-        VerifyOrQuit(info.mTimeout == 30, "bad timeout");
-    }
-    VerifyOrQuit(table_size == 1, "Table size is wrong");
-
-    otBackboneRouterMulticastListenerClear(aInstance);
-
-    iter = OT_BACKBONE_ROUTER_MULTICAST_LISTENER_ITERATOR_INIT;
-    while (otBackboneRouterMulticastListenerGetNext(aInstance, &iter, &info) == kErrorNone)
-    {
-        VerifyOrQuit(false, "Table should be empty");
-    }
-#endif
-}
-
 } // namespace ot
 
 int main(void)
diff --git a/tests/unit/test_multipan_rcp_instances.cpp b/tests/unit/test_multipan_rcp_instances.cpp
index 3b2b839..87223f2 100644
--- a/tests/unit/test_multipan_rcp_instances.cpp
+++ b/tests/unit/test_multipan_rcp_instances.cpp
@@ -41,18 +41,9 @@
 using namespace ot;
 using namespace ot::Ncp;
 
-enum
-{
-    kTestBufferSize = 800
-};
+constexpr uint16_t kTestBufferSize = 800;
 
-enum
-{
-    kTestMacScanChannelMask = 0x01
-};
-
-OT_TOOL_PACKED_BEGIN
-struct RadioMessage
+OT_TOOL_PACKED_BEGIN struct RadioMessage
 {
     uint8_t mChannel;
     uint8_t mPsdu[OT_RADIO_FRAME_MAX_SIZE];
diff --git a/tests/unit/test_ndproxy_table.cpp b/tests/unit/test_ndproxy_table.cpp
deleted file mode 100644
index e350a15..0000000
--- a/tests/unit/test_ndproxy_table.cpp
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- *  Copyright (c) 2020, The OpenThread Authors.
- *  All rights reserved.
- *
- *  Redistribution and use in source and binary forms, with or without
- *  modification, are permitted provided that the following conditions are met:
- *  1. Redistributions of source code must retain the above copyright
- *     notice, this list of conditions and the following disclaimer.
- *  2. Redistributions in binary form must reproduce the above copyright
- *     notice, this list of conditions and the following disclaimer in the
- *     documentation and/or other materials provided with the distribution.
- *  3. Neither the name of the copyright holder nor the
- *     names of its contributors may be used to endorse or promote products
- *     derived from this software without specific prior written permission.
- *
- *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- *  POSSIBILITY OF SUCH DAMAGE.
- */
-
-#include "openthread-core-config.h"
-
-#if OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
-
-#include "test_platform.h"
-
-#include <openthread/config.h>
-#include <openthread/ip6.h>
-
-#include "test_util.h"
-#include "backbone_router/ndproxy_table.hpp"
-#include "common/code_utils.hpp"
-#include "instance/instance.hpp"
-
-namespace ot {
-
-static Instance *sInstance;
-
-using namespace ot::BackboneRouter;
-
-Ip6::InterfaceIdentifier generateRandomIid(uint16_t aIndex)
-{
-    Ip6::InterfaceIdentifier iid;
-
-    Random::NonCrypto::Fill(iid);
-    iid.mFields.m16[3] = aIndex;
-
-    return iid;
-}
-
-void TestNdProxyTable(void)
-{
-    Error error;
-
-    sInstance = testInitInstance();
-    VerifyOrQuit(sInstance != nullptr);
-
-    BackboneRouter::NdProxyTable &table = sInstance->Get<BackboneRouter::NdProxyTable>();
-
-    Ip6::InterfaceIdentifier existedAddressIid    = generateRandomIid(0);
-    Ip6::InterfaceIdentifier existedMeshLocalIid  = generateRandomIid(0);
-    Ip6::InterfaceIdentifier notExistAddressIid   = generateRandomIid(OPENTHREAD_CONFIG_NDPROXY_TABLE_ENTRY_NUM);
-    Ip6::InterfaceIdentifier notExistMeshLocalIid = generateRandomIid(OPENTHREAD_CONFIG_NDPROXY_TABLE_ENTRY_NUM);
-
-    // Reregister address IID when there are enough room should succeed.
-    SuccessOrQuit(table.Register(existedAddressIid, existedMeshLocalIid, 0, nullptr));
-    VerifyOrQuit(table.IsRegistered(existedAddressIid));
-    VerifyOrQuit(!table.IsRegistered(notExistAddressIid));
-
-    for (uint16_t i = 1; i < OPENTHREAD_CONFIG_NDPROXY_TABLE_ENTRY_NUM; i++)
-    {
-        Ip6::InterfaceIdentifier addressIid   = generateRandomIid(i);
-        Ip6::InterfaceIdentifier meshLocalIid = generateRandomIid(i);
-
-        // Reregister address IID when there are enough room should succeed.
-        SuccessOrQuit(table.Register(addressIid, meshLocalIid, i, nullptr));
-
-        VerifyOrQuit(table.IsRegistered(addressIid));
-
-        // Reregister the same address IID should always succeed.
-        SuccessOrQuit(table.Register(addressIid, meshLocalIid, i, nullptr));
-
-        // Register the same address IID with a different ML-IID should fail.
-        VerifyOrQuit(table.Register(addressIid, notExistMeshLocalIid, i, nullptr) == kErrorDuplicated);
-
-        VerifyOrQuit(table.IsRegistered(addressIid));
-    }
-
-    // Now the table is full, registering another IID should fail.
-    VerifyOrQuit(table.Register(notExistAddressIid, notExistMeshLocalIid, OPENTHREAD_CONFIG_NDPROXY_TABLE_ENTRY_NUM,
-                                nullptr) == kErrorNoBufs);
-    VerifyOrQuit(!table.IsRegistered(notExistAddressIid));
-}
-
-} // namespace ot
-
-int main(void)
-{
-    ot::TestNdProxyTable();
-
-    printf("\nAll tests passed.\n");
-    return 0;
-}
-
-#else
-int main(void) { return 0; }
-#endif // OPENTHREAD_FTD && OPENTHREAD_CONFIG_BACKBONE_ROUTER_DUA_NDPROXYING_ENABLE
diff --git a/tests/unit/test_plat_tcp.cpp b/tests/unit/test_plat_tcp.cpp
new file mode 100644
index 0000000..f844f2f
--- /dev/null
+++ b/tests/unit/test_plat_tcp.cpp
@@ -0,0 +1,733 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include <openthread/config.h>
+
+#include "test_platform.h"
+#include "test_util.hpp"
+
+#include "common/arg_macros.hpp"
+#include "common/as_core_type.hpp"
+#include "instance/instance.hpp"
+#include "net/plat_tcp.hpp"
+
+namespace ot {
+
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+// Logs a message
+#define Log(...) printf(OT_FIRST_ARG(__VA_ARGS__) "\n" OT_REST_ARGS(__VA_ARGS__))
+
+using SockAddr   = Ip6::PlatTcp::SockAddr;
+using Listener   = Ip6::PlatTcp::Listener;
+using Connection = Ip6::PlatTcp::Connection;
+
+struct ListenerInfo
+{
+    Listener *mListener;
+    SockAddr  mLocalSockAddr;
+    bool      mEnabled;
+};
+
+static constexpr uint16_t kTxBufferSize = 1000;
+
+struct ConnInfo
+{
+    Connection *mConnection;
+    bool        mConnected;
+    bool        mConnecting;
+    bool        mClosing;
+    bool        mAborted;
+    bool        mNotifyTxPending;
+    SockAddr    mLocalSockAddr;
+    SockAddr    mPeerSockAddr;
+    uint16_t    mTxBufferUsed;
+    uint8_t     mTxBuffer[kTxBufferSize];
+};
+
+static ListenerInfo sListenerInfo;
+static ConnInfo     sConnInfo;
+
+static bool              sAcceptHandlerCalled;
+static bool              sDeleteHandlerCalled;
+static bool              sEventHandlerCalled;
+static Connection::Event sLastEvent;
+
+static void ResetTestState(void)
+{
+    ClearAllBytes(sListenerInfo);
+    ClearAllBytes(sConnInfo);
+
+    sAcceptHandlerCalled = false;
+    sDeleteHandlerCalled = false;
+    sEventHandlerCalled  = false;
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// otPlatTcp
+
+extern "C" {
+
+otError otPlatTcpEnableListener(otPlatTcpListener *aListener, const otPlatTcpSockAddr *aLocalSockAddr)
+{
+    sListenerInfo.mListener      = AsCoreTypePtr(aListener);
+    sListenerInfo.mLocalSockAddr = AsCoreType(aLocalSockAddr);
+    sListenerInfo.mEnabled       = true;
+
+    Log(" - otPlatTcpEnableListener(%s)", sListenerInfo.mLocalSockAddr.ToString().AsCString());
+
+    return kErrorNone;
+}
+
+void otPlatTcpDisableListener(otPlatTcpListener *aListener)
+{
+    Log(" - otPlatTcpDisableListener()");
+
+    VerifyOrQuit(sListenerInfo.mListener == aListener);
+    sListenerInfo.mEnabled = false;
+}
+
+otError otPlatTcpConnect(otPlatTcpConnection     *aConn,
+                         const otPlatTcpSockAddr *aPeerSockAddr,
+                         const otPlatTcpSockAddr *aLocalSockAddr)
+{
+    sConnInfo.mConnection   = AsCoreTypePtr(aConn);
+    sConnInfo.mConnecting   = true;
+    sConnInfo.mPeerSockAddr = AsCoreType(aPeerSockAddr);
+
+    Log(" - otPlatTcpConnect(peerAddr:%s)", sConnInfo.mPeerSockAddr.ToString().AsCString());
+
+    if (aLocalSockAddr != nullptr)
+    {
+        sConnInfo.mLocalSockAddr = AsCoreType(aLocalSockAddr);
+        Log("   localAddr:%s", sConnInfo.mLocalSockAddr.ToString().AsCString());
+    }
+
+    return kErrorNone;
+}
+
+void otPlatTcpNotifyTxPending(otPlatTcpConnection *aConn)
+{
+    Log(" - otPlatTcpNotifyTxPending()");
+    VerifyOrQuit(sConnInfo.mConnection == aConn);
+    sConnInfo.mNotifyTxPending = true;
+}
+
+uint16_t otPlatTcpSend(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength)
+{
+    uint16_t copyLength;
+
+    VerifyOrQuit(sConnInfo.mConnection == aConn);
+
+    copyLength = Min<uint16_t>(aLength, kTxBufferSize - sConnInfo.mTxBufferUsed);
+
+    memcpy(&sConnInfo.mTxBuffer[sConnInfo.mTxBufferUsed], aBuffer, copyLength);
+    sConnInfo.mTxBufferUsed += copyLength;
+
+    Log(" - otPlatTcpSend(aLength:%u), copied:%u", aLength, copyLength);
+
+    return copyLength;
+}
+
+void otPlatTcpClose(otPlatTcpConnection *aConn)
+{
+    Log(" - otPlatTcpClose()");
+
+    VerifyOrQuit(sConnInfo.mConnection == aConn);
+    sConnInfo.mClosing = true;
+}
+
+void otPlatTcpAbort(otPlatTcpConnection *aConn)
+{
+    Log(" - otPlatTcpAbort()");
+
+    VerifyOrQuit(sConnInfo.mConnection == aConn);
+    sConnInfo.mAborted = true;
+}
+
+} // extern "C"
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// Callbacks
+
+static Connection *AcceptHandler(Listener &aListener, const SockAddr &aPeerSockAddr)
+{
+    Log(" - AcceptHandler(peerAddr:%s)", aPeerSockAddr.ToString().AsCString());
+
+    OT_UNUSED_VARIABLE(aListener);
+    sAcceptHandlerCalled = true;
+
+    return sConnInfo.mConnection;
+}
+
+static void ListenerDeleteHandler(Listener &aListener)
+{
+    Log(" - ListenerDeleteHandler()");
+
+    sDeleteHandlerCalled = true;
+    OT_UNUSED_VARIABLE(aListener);
+}
+
+static void ConnectionDeleteHandler(Connection &aConnection)
+{
+    Log(" - ConnectionDeleteHandler()");
+
+    sDeleteHandlerCalled = true;
+    OT_UNUSED_VARIABLE(aConnection);
+}
+
+static void EventHandler(Connection &aConnection, Connection::Event aEvent)
+{
+    Log(" - EventHandler(%s)", Connection::EventToString(aEvent));
+
+    sLastEvent          = aEvent;
+    sEventHandlerCalled = true;
+    OT_UNUSED_VARIABLE(aConnection);
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+void TestPlatTcpListener(void)
+{
+    Instance *instance = testInitInstance();
+    Listener  listener(*instance, AcceptHandler, ListenerDeleteHandler);
+    SockAddr  localAddr;
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - ");
+    Log("TestPlatTcpListener");
+
+    ResetTestState();
+
+    localAddr.SetPort(1234);
+
+    Log(" Enable listener");
+    SuccessOrQuit(listener.Enable(localAddr));
+    VerifyOrQuit(listener.GetState() == Listener::kStateEnabled);
+    VerifyOrQuit(sListenerInfo.mEnabled);
+    VerifyOrQuit(sListenerInfo.mLocalSockAddr == localAddr);
+
+    Log(" Check active listeners");
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetListeners().GetHead() == &listener);
+    VerifyOrQuit(listener.GetNext() == nullptr);
+
+    Log(" Disable listener");
+    sDeleteHandlerCalled = false;
+    listener.Disable();
+    VerifyOrQuit(listener.GetState() == Listener::kStateDisabled);
+    VerifyOrQuit(!sListenerInfo.mEnabled);
+
+    Log(" Verify delete handler is called");
+    VerifyOrQuit(!sDeleteHandlerCalled);
+    otTaskletsProcess(instance);
+    VerifyOrQuit(sDeleteHandlerCalled);
+    VerifyOrQuit(listener.GetState() == Listener::kStateUnused);
+
+    Log(" Check active listeners list is empty");
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetListeners().IsEmpty());
+
+    testFreeInstance(instance);
+
+    Log("End of TestPlatTcpListener");
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+void TestPlatTcpConnection(void)
+{
+    Instance  *instance = testInitInstance();
+    Connection connection(*instance, EventHandler, ConnectionDeleteHandler);
+    SockAddr   peerAddr;
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - ");
+    Log("TestPlatTcpConnection");
+
+    ResetTestState();
+
+    SuccessOrQuit(peerAddr.GetAddress().FromString("fd00:1234::cafe"));
+    peerAddr.SetPort(5678);
+    peerAddr.SetIfIndex(4);
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().IsEmpty());
+
+    Log(" Connect");
+    sEventHandlerCalled = false;
+    SuccessOrQuit(connection.Connect(peerAddr));
+    VerifyOrQuit(connection.GetState() == Connection::kStateConnecting);
+    VerifyOrQuit(sConnInfo.mConnecting);
+    VerifyOrQuit(!sEventHandlerCalled);
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().GetHead() == &connection);
+    VerifyOrQuit(connection.GetNext() == nullptr);
+
+    Log(" Signal connected `otPlatTcpHandleConnected`");
+    otPlatTcpHandleConnected(&connection);
+    VerifyOrQuit(sEventHandlerCalled);
+    VerifyOrQuit(sLastEvent == Connection::kEventConnected);
+    VerifyOrQuit(connection.GetState() == Connection::kStateConnected);
+
+    Log(" Disconnect from platform `otPlatTcpHandleDisconnected`");
+    sEventHandlerCalled = false;
+    otPlatTcpHandleDisconnected(&connection, OT_PLAT_TCP_DISCONNECT_REASON_CLOSED);
+    VerifyOrQuit(sEventHandlerCalled);
+    VerifyOrQuit(sLastEvent == Connection::kEventDisconnected);
+    VerifyOrQuit(connection.GetState() == Connection::kStateDisconnected);
+    VerifyOrQuit(connection.GetDisconnectReason() == Ip6::PlatTcp::kDisconnectReasonClosed);
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().GetHead() == &connection);
+    VerifyOrQuit(connection.GetNext() == nullptr);
+
+    Log(" Cleanup");
+    sDeleteHandlerCalled = false;
+    otTaskletsProcess(instance);
+    VerifyOrQuit(sDeleteHandlerCalled);
+    VerifyOrQuit(connection.GetState() == Connection::kStateUnused);
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().IsEmpty());
+
+    testFreeInstance(instance);
+
+    Log("End of TestPlatTcpConnection");
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+void TestPlatTcpSendRecv(void)
+{
+    static const uint8_t kData[]  = {1, 2, 3, 4, 5, 6, 7};
+    static const uint8_t kData2[] = {0xfe, 0xdc, 0xba};
+
+    Instance         *instance = testInitInstance();
+    Connection        connection(*instance, EventHandler, ConnectionDeleteHandler);
+    SockAddr          localAddr;
+    SockAddr          peerAddr;
+    OwnedPtr<Message> message;
+    const Message    *rxMessage;
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - ");
+    Log("TestPlatTcpSendRecv");
+
+    ResetTestState();
+    sConnInfo.mConnection = &connection;
+
+    SuccessOrQuit(localAddr.GetAddress().FromString("fd00:beef:cafe::2"));
+    localAddr.SetPort(4321);
+    localAddr.SetIfIndex(7);
+
+    SuccessOrQuit(peerAddr.GetAddress().FromString("fd00:beef:cafe::1"));
+    peerAddr.SetPort(5678);
+    peerAddr.SetIfIndex(7);
+
+    Log(" Connect");
+
+    SuccessOrQuit(connection.BindAndConnect(localAddr, peerAddr));
+    otPlatTcpHandleConnected(&connection);
+    VerifyOrQuit(connection.GetState() == Connection::kStateConnected);
+
+    VerifyOrQuit(sConnInfo.mConnection == &connection);
+    VerifyOrQuit(sConnInfo.mLocalSockAddr == localAddr);
+    VerifyOrQuit(sConnInfo.mPeerSockAddr == peerAddr);
+
+    Log(" Send data");
+
+    message.Reset(instance->Get<MessagePool>().Allocate(Message::kTypeOther));
+    SuccessOrQuit(message->AppendBytes(kData, sizeof(kData)));
+
+    sEventHandlerCalled = false;
+    SuccessOrQuit(connection.Send(message.PassOwnership()));
+    VerifyOrQuit(!sEventHandlerCalled);
+    VerifyOrQuit(sConnInfo.mNotifyTxPending);
+
+    Log(" Signal `otPlatTcpHandleTxReady`");
+    otPlatTcpHandleTxReady(&connection);
+    VerifyOrQuit(sConnInfo.mTxBufferUsed == sizeof(kData));
+    VerifyOrQuit(memcmp(sConnInfo.mTxBuffer, kData, sizeof(kData)) == 0);
+
+    VerifyOrQuit(sEventHandlerCalled);
+    VerifyOrQuit(sLastEvent == Connection::kEventSendDone);
+
+    Log(" Send more data");
+
+    message.Reset(instance->Get<MessagePool>().Allocate(Message::kTypeOther));
+    SuccessOrQuit(message->AppendBytes(kData2, sizeof(kData2)));
+
+    sEventHandlerCalled = false;
+    SuccessOrQuit(connection.Send(message.PassOwnership()));
+    VerifyOrQuit(!sEventHandlerCalled);
+    VerifyOrQuit(sConnInfo.mNotifyTxPending);
+
+    Log(" Signal `otPlatTcpHandleTxReady`");
+    otPlatTcpHandleTxReady(&connection);
+    VerifyOrQuit(sConnInfo.mTxBufferUsed == sizeof(kData) + sizeof(kData2));
+    VerifyOrQuit(memcmp(sConnInfo.mTxBuffer, kData, sizeof(kData)) == 0);
+    VerifyOrQuit(memcmp(&sConnInfo.mTxBuffer[sizeof(kData)], kData2, sizeof(kData2)) == 0);
+
+    VerifyOrQuit(sEventHandlerCalled);
+    VerifyOrQuit(sLastEvent == Connection::kEventSendDone);
+
+    Log(" Receive data `otPlatTcpHandleReceive`");
+    sEventHandlerCalled = false;
+    otPlatTcpHandleReceive(&connection, kData, sizeof(kData));
+    VerifyOrQuit(sEventHandlerCalled);
+    VerifyOrQuit(sLastEvent == Connection::kEventReceive);
+
+    rxMessage = connection.GetRxMessage();
+    VerifyOrQuit(rxMessage != nullptr);
+    VerifyOrQuit(rxMessage->GetLength() == sizeof(kData));
+    VerifyOrQuit(rxMessage->CompareBytes(/* aOffset */ 0, kData, sizeof(kData)));
+
+    Log(" Remove part of rx data");
+    connection.RemoveParsedLengthFromRxMessage(2);
+    rxMessage = connection.GetRxMessage();
+    VerifyOrQuit(rxMessage->GetLength() == sizeof(kData) - 2);
+
+    Log(" Receive more data `otPlatTcpHandleReceive`");
+    sEventHandlerCalled = false;
+    otPlatTcpHandleReceive(&connection, kData2, sizeof(kData2));
+    VerifyOrQuit(sEventHandlerCalled);
+    VerifyOrQuit(sLastEvent == Connection::kEventReceive);
+
+    rxMessage = connection.GetRxMessage();
+    VerifyOrQuit(rxMessage != nullptr);
+    VerifyOrQuit(rxMessage->GetLength() == sizeof(kData) + sizeof(kData2) - 2);
+    VerifyOrQuit(rxMessage->CompareBytes(/* aOffset */ sizeof(kData) - 2, kData2, sizeof(kData2)));
+    VerifyOrQuit(rxMessage->GetOffset() == sizeof(kData) - 2);
+
+    Log(" Free Rx message");
+    connection.FreeRxMessage();
+    VerifyOrQuit(connection.GetRxMessage() == nullptr);
+
+    Log(" Close");
+    sEventHandlerCalled = false;
+    connection.Close();
+    VerifyOrQuit(sConnInfo.mClosing);
+    VerifyOrQuit(!sEventHandlerCalled);
+
+    Log(" Signal `otPlatTcpHandleDisconnected`");
+    otPlatTcpHandleDisconnected(&connection, OT_PLAT_TCP_DISCONNECT_REASON_CLOSED);
+
+    VerifyOrQuit(sEventHandlerCalled);
+    VerifyOrQuit(sLastEvent == Connection::kEventDisconnected);
+    VerifyOrQuit(connection.GetState() == Connection::kStateDisconnected);
+
+    Log(" Cleanup");
+    sDeleteHandlerCalled = false;
+    otTaskletsProcess(instance);
+    VerifyOrQuit(sDeleteHandlerCalled);
+    VerifyOrQuit(connection.GetState() == Connection::kStateUnused);
+
+    testFreeInstance(instance);
+
+    Log("End of TestPlatTcpSendRecv");
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+void TestPlatTcpFlowControl(void)
+{
+    Instance         *instance = testInitInstance();
+    Connection        connection(*instance, EventHandler, ConnectionDeleteHandler);
+    SockAddr          peerAddr;
+    OwnedPtr<Message> message1, message2;
+    uint8_t           data[600];
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - ");
+    Log("TestPlatTcpFlowControl");
+
+    ResetTestState();
+
+    sConnInfo.mConnection = &connection;
+
+    SuccessOrQuit(peerAddr.GetAddress().FromString("fd00:3f12::aaaa"));
+    peerAddr.SetPort(9876);
+    peerAddr.SetIfIndex(12);
+
+    for (uint16_t i = 0; i < sizeof(data); i++)
+    {
+        data[i] = static_cast<uint8_t>(i & 0xff) ^ static_cast<uint8_t>(i >> 8);
+    }
+
+    Log(" Connect");
+    SuccessOrQuit(connection.Connect(peerAddr));
+    otPlatTcpHandleConnected(&connection);
+
+    Log(" Partial send (buffer full)");
+
+    message1.Reset(instance->Get<MessagePool>().Allocate(Message::kTypeIp6));
+    SuccessOrQuit(message1->AppendBytes(data, 600));
+    message2.Reset(instance->Get<MessagePool>().Allocate(Message::kTypeIp6));
+    SuccessOrQuit(message2->AppendBytes(data, 600));
+
+    SuccessOrQuit(connection.Send(message1.PassOwnership()));
+    SuccessOrQuit(connection.Send(message2.PassOwnership()));
+
+    Log(" Platform tx buffer size 1000. Validate partial write");
+
+    sEventHandlerCalled = false;
+
+    Log(" Signal `otPlatTcpHandleTxReady`");
+    otPlatTcpHandleTxReady(&connection);
+
+    VerifyOrQuit(sConnInfo.mTxBufferUsed == kTxBufferSize);
+    VerifyOrQuit(memcmp(sConnInfo.mTxBuffer, data, sizeof(data)) == 0);
+    VerifyOrQuit(memcmp(&sConnInfo.mTxBuffer[sizeof(data)], data, kTxBufferSize - sizeof(data)) == 0);
+
+    VerifyOrQuit(!sEventHandlerCalled, "SendDone should NOT be called yet");
+
+    Log(" Now clear platform tx buffer. Validate the rest of data is passed to platform");
+    sConnInfo.mTxBufferUsed = 0;
+
+    Log(" Signal `otPlatTcpHandleTxReady`");
+    otPlatTcpHandleTxReady(&connection);
+
+    VerifyOrQuit(sConnInfo.mTxBufferUsed == 2 * sizeof(data) - kTxBufferSize);
+    VerifyOrQuit(memcmp(sConnInfo.mTxBuffer, &data[sizeof(data) - sConnInfo.mTxBufferUsed], sConnInfo.mTxBufferUsed) ==
+                 0);
+
+    VerifyOrQuit(sEventHandlerCalled, "SendDone should be called now");
+    VerifyOrQuit(sLastEvent == Connection::kEventSendDone);
+
+    Log(" Abort");
+    connection.Abort();
+    VerifyOrQuit(connection.GetState() == Connection::kStateDisconnected);
+    VerifyOrQuit(sConnInfo.mAborted);
+
+    testFreeInstance(instance);
+    Log("End of TestPlatTcpFlowControl");
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+void TestPlatTcpAccept(void)
+{
+    Instance   *instance = testInitInstance();
+    Listener    listener(*instance, AcceptHandler, ListenerDeleteHandler);
+    Connection  connection(*instance, EventHandler, ConnectionDeleteHandler);
+    Connection *acceptedConn;
+    SockAddr    localAddr;
+    SockAddr    peerAddr;
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - ");
+    Log("TestPlatTcpAccept");
+
+    ResetTestState();
+
+    sConnInfo.mConnection = &connection;
+
+    localAddr.SetPort(1234);
+
+    SuccessOrQuit(peerAddr.GetAddress().FromString("fe80::1234"));
+    peerAddr.SetPort(5678);
+
+    Log(" Enable listener");
+    SuccessOrQuit(listener.Enable(localAddr));
+
+    Log(" Signal incoming connection request `otPlatTcpAccept`");
+    sAcceptHandlerCalled = false;
+    acceptedConn         = AsCoreTypePtr(otPlatTcpAccept(&listener, &peerAddr));
+
+    VerifyOrQuit(sAcceptHandlerCalled);
+    VerifyOrQuit(acceptedConn == &connection);
+    VerifyOrQuit(connection.GetState() == Connection::kStateConnecting);
+
+    Log(" Signal `otPlatTcpHandleConnected`");
+    otPlatTcpHandleConnected(&connection);
+    VerifyOrQuit(connection.GetState() == Connection::kStateConnected);
+
+    testFreeInstance(instance);
+
+    Log("End of TestPlatTcpAccept");
+}
+
+//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+void TestPlatTcpIteration(void)
+{
+    Instance   *instance = testInitInstance();
+    Listener    listener1(*instance, AcceptHandler, ListenerDeleteHandler);
+    Listener    listener2(*instance, AcceptHandler, ListenerDeleteHandler);
+    Connection  connection1(*instance, EventHandler, ConnectionDeleteHandler);
+    Connection  connection2(*instance, EventHandler, ConnectionDeleteHandler);
+    Listener   *listener;
+    Connection *connection;
+    SockAddr    sockAddr;
+
+    Log("- - - - - - - - - - - - - - - - - - - - - - - - - ");
+    Log("TestPlatTcpIteration");
+
+    ResetTestState();
+
+    Log(" Enable two listeners");
+
+    sockAddr.SetPort(1);
+    SuccessOrQuit(listener1.Enable(sockAddr));
+    sockAddr.SetPort(2);
+    SuccessOrQuit(listener2.Enable(sockAddr));
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetListeners().GetHead() == &listener2);
+    VerifyOrQuit(listener2.GetNext() == &listener1);
+    VerifyOrQuit(listener1.GetNext() == nullptr);
+
+    Log(" Validate IterateListeners");
+
+    listener = nullptr;
+    listener = instance->Get<Ip6::PlatTcp>().IterateListeners(listener);
+    VerifyOrQuit(listener == &listener2);
+    listener = instance->Get<Ip6::PlatTcp>().IterateListeners(listener);
+    VerifyOrQuit(listener == &listener1);
+    listener = instance->Get<Ip6::PlatTcp>().IterateListeners(listener);
+    VerifyOrQuit(listener == nullptr);
+
+    Log(" Disable first listener");
+
+    sListenerInfo.mListener = &listener1;
+    listener1.Disable();
+
+    Log(" Validate IterateListeners skips the disabled entry");
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetListeners().GetHead() == &listener2);
+    VerifyOrQuit(listener2.GetNext() == &listener1);
+    VerifyOrQuit(listener1.GetNext() == nullptr);
+
+    listener = nullptr;
+    listener = instance->Get<Ip6::PlatTcp>().IterateListeners(listener);
+    VerifyOrQuit(listener == &listener2);
+    listener = instance->Get<Ip6::PlatTcp>().IterateListeners(listener);
+    VerifyOrQuit(listener == nullptr);
+
+    Log(" otTaskletsProcess");
+    otTaskletsProcess(instance);
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetListeners().GetHead() == &listener2);
+    VerifyOrQuit(listener2.GetNext() == nullptr);
+
+    Log(" Disable second listener");
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetListeners().GetHead() == &listener2);
+    VerifyOrQuit(listener2.GetNext() == nullptr);
+
+    sListenerInfo.mListener = &listener2;
+    listener2.Disable();
+
+    listener = nullptr;
+    listener = instance->Get<Ip6::PlatTcp>().IterateListeners(listener);
+    VerifyOrQuit(listener == nullptr);
+
+    Log(" otTaskletsProcess");
+    otTaskletsProcess(instance);
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetListeners().IsEmpty());
+
+    listener = nullptr;
+    listener = instance->Get<Ip6::PlatTcp>().IterateListeners(listener);
+    VerifyOrQuit(listener == nullptr);
+
+    Log(" Start two connections");
+
+    sockAddr.SetPort(3);
+    SuccessOrQuit(connection1.Connect(sockAddr));
+    sockAddr.SetPort(4);
+    SuccessOrQuit(connection2.Connect(sockAddr));
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().GetHead() == &connection2);
+    VerifyOrQuit(connection2.GetNext() == &connection1);
+    VerifyOrQuit(connection1.GetNext() == nullptr);
+
+    connection = nullptr;
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == &connection2);
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == &connection1);
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == nullptr);
+
+    Log(" Abort second connection");
+
+    sConnInfo.mConnection = &connection2;
+    connection2.Abort();
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().GetHead() == &connection2);
+    VerifyOrQuit(connection2.GetNext() == &connection1);
+    VerifyOrQuit(connection1.GetNext() == nullptr);
+
+    connection = nullptr;
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == &connection1);
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == nullptr);
+
+    Log(" otTaskletsProcess");
+    otTaskletsProcess(instance);
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().GetHead() == &connection1);
+    VerifyOrQuit(connection1.GetNext() == nullptr);
+
+    connection = nullptr;
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == &connection1);
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == nullptr);
+
+    Log(" Abort first connection");
+
+    sConnInfo.mConnection = &connection1;
+    connection1.Abort();
+
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().GetHead() == &connection1);
+    VerifyOrQuit(connection1.GetNext() == nullptr);
+
+    connection = nullptr;
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == nullptr);
+
+    Log(" otTaskletsProcess");
+    otTaskletsProcess(instance);
+    VerifyOrQuit(instance->Get<Ip6::PlatTcp>().GetConnections().IsEmpty());
+
+    connection = nullptr;
+    connection = instance->Get<Ip6::PlatTcp>().IterateConnections(connection);
+    VerifyOrQuit(connection == nullptr);
+
+    testFreeInstance(instance);
+
+    Log("End of TestPlatTcpIteration");
+}
+
+#endif // OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+} // namespace ot
+
+int main(void)
+{
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+    ot::TestPlatTcpListener();
+    ot::TestPlatTcpConnection();
+    ot::TestPlatTcpSendRecv();
+    ot::TestPlatTcpFlowControl();
+    ot::TestPlatTcpAccept();
+    ot::TestPlatTcpIteration();
+#endif
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/tests/unit/test_platform.cpp b/tests/unit/test_platform.cpp
index 728a333..8a919dc 100644
--- a/tests/unit/test_platform.cpp
+++ b/tests/unit/test_platform.cpp
@@ -33,16 +33,15 @@
 
 #include <stdio.h>
 #include <sys/time.h>
-#ifdef OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
+#include <openthread/platform/flash.h>
+
+#if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
 #include <openthread/tcat.h>
 #include <openthread/platform/ble.h>
 #endif
 
-enum
-{
-    FLASH_SWAP_SIZE = 2048,
-    FLASH_SWAP_NUM  = 2,
-};
+constexpr uint16_t kFlashSwapSize = 2048;
+constexpr uint8_t  kFlashSwapNum  = 2;
 
 std::map<uint32_t, std::vector<std::vector<uint8_t>>> settings;
 
@@ -50,6 +49,12 @@
 {
     otInstance *instance = nullptr;
 
+    settings.clear();
+    for (uint8_t idx = 0; idx < kFlashSwapNum; idx++)
+    {
+        otPlatFlashErase(nullptr, idx);
+    }
+
 #if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
 #if OPENTHREAD_CONFIG_MULTIPLE_STATIC_INSTANCE_ENABLE
     instance = otInstanceInitMultiple(0);
@@ -75,6 +80,23 @@
     return static_cast<ot::Instance *>(instance);
 }
 
+ot::Instance *testResetInstance(ot::Instance *aInstance)
+{
+    otInstanceFinalize(aInstance);
+
+#if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE
+    {
+        size_t instanceBufferLength = sizeof(ot::Instance);
+
+        aInstance = static_cast<ot::Instance *>(otInstanceInit(aInstance, &instanceBufferLength));
+    }
+#else
+    aInstance = static_cast<ot::Instance *>(otInstanceInitSingle());
+#endif
+
+    return aInstance;
+}
+
 #if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_MULTIPLE_STATIC_INSTANCE_ENABLE
 ot::Instance *testInitAdditionalInstance(uint8_t id)
 {
@@ -280,6 +302,8 @@
 
 OT_TOOL_WEAK void otPlatWakeHost(void) {}
 
+OT_TOOL_WEAK void otPlatLogOutput(otInstance *, otLogLevel, const char *) {}
+
 OT_TOOL_WEAK void otPlatLog(otLogLevel, otLogRegion, const char *, ...) {}
 
 OT_TOOL_WEAK void otPlatSettingsInit(otInstance *, const uint16_t *, uint16_t) {}
@@ -361,7 +385,7 @@
 
 uint8_t *GetFlash(void)
 {
-    static uint8_t sFlash[FLASH_SWAP_SIZE * FLASH_SWAP_NUM];
+    static uint8_t sFlash[kFlashSwapSize * kFlashSwapNum];
     static bool    sInitialized;
 
     if (!sInitialized)
@@ -375,28 +399,28 @@
 
 OT_TOOL_WEAK void otPlatFlashInit(otInstance *) {}
 
-OT_TOOL_WEAK uint32_t otPlatFlashGetSwapSize(otInstance *) { return FLASH_SWAP_SIZE; }
+OT_TOOL_WEAK uint32_t otPlatFlashGetSwapSize(otInstance *) { return kFlashSwapSize; }
 
 OT_TOOL_WEAK void otPlatFlashErase(otInstance *, uint8_t aSwapIndex)
 {
     uint32_t address;
 
-    VerifyOrQuit(aSwapIndex < FLASH_SWAP_NUM, "aSwapIndex invalid");
+    VerifyOrQuit(aSwapIndex < kFlashSwapNum, "aSwapIndex invalid");
 
-    address = aSwapIndex ? FLASH_SWAP_SIZE : 0;
+    address = aSwapIndex ? kFlashSwapSize : 0;
 
-    memset(GetFlash() + address, 0xff, FLASH_SWAP_SIZE);
+    memset(GetFlash() + address, 0xff, kFlashSwapSize);
 }
 
 OT_TOOL_WEAK void otPlatFlashRead(otInstance *, uint8_t aSwapIndex, uint32_t aOffset, void *aData, uint32_t aSize)
 {
     uint32_t address;
 
-    VerifyOrQuit(aSwapIndex < FLASH_SWAP_NUM, "aSwapIndex invalid");
-    VerifyOrQuit(aSize <= FLASH_SWAP_SIZE, "aSize invalid");
-    VerifyOrQuit(aOffset <= (FLASH_SWAP_SIZE - aSize), "aOffset + aSize invalid");
+    VerifyOrQuit(aSwapIndex < kFlashSwapNum, "aSwapIndex invalid");
+    VerifyOrQuit(aSize <= kFlashSwapSize, "aSize invalid");
+    VerifyOrQuit(aOffset <= (kFlashSwapSize - aSize), "aOffset + aSize invalid");
 
-    address = aSwapIndex ? FLASH_SWAP_SIZE : 0;
+    address = aSwapIndex ? kFlashSwapSize : 0;
 
     memcpy(aData, GetFlash() + address + aOffset, aSize);
 }
@@ -409,11 +433,11 @@
 {
     uint32_t address;
 
-    VerifyOrQuit(aSwapIndex < FLASH_SWAP_NUM, "aSwapIndex invalid");
-    VerifyOrQuit(aSize <= FLASH_SWAP_SIZE, "aSize invalid");
-    VerifyOrQuit(aOffset <= (FLASH_SWAP_SIZE - aSize), "aOffset + aSize invalid");
+    VerifyOrQuit(aSwapIndex < kFlashSwapNum, "aSwapIndex invalid");
+    VerifyOrQuit(aSize <= kFlashSwapSize, "aSize invalid");
+    VerifyOrQuit(aOffset <= (kFlashSwapSize - aSize), "aOffset + aSize invalid");
 
-    address = aSwapIndex ? FLASH_SWAP_SIZE : 0;
+    address = aSwapIndex ? kFlashSwapSize : 0;
 
     for (uint32_t index = 0; index < aSize; index++)
     {
@@ -433,7 +457,7 @@
 
 OT_TOOL_WEAK otError otPlatRadioResetCsl(otInstance *) { return OT_ERROR_NONE; }
 
-OT_TOOL_WEAK void otPlatRadioUpdateCslSampleTime(otInstance *, uint32_t) {}
+OT_TOOL_WEAK void otPlatRadioUpdateCslSampleTime(otInstance *, otRadioTime32) {}
 
 OT_TOOL_WEAK uint8_t otPlatRadioGetCslAccuracy(otInstance *)
 {
@@ -634,6 +658,46 @@
 
 #endif // OPENTHREAD_CONFIG_MULTICAST_DNS_ENABLE
 
+#if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
+OT_TOOL_WEAK otError otPlatTcpEnableListener(otPlatTcpListener *aListener, const otPlatTcpSockAddr *aLocalSockAddr)
+{
+    OT_UNUSED_VARIABLE(aListener);
+    OT_UNUSED_VARIABLE(aLocalSockAddr);
+
+    return OT_ERROR_FAILED;
+}
+
+OT_TOOL_WEAK void otPlatTcpDisableListener(otPlatTcpListener *aListener) { OT_UNUSED_VARIABLE(aListener); }
+
+OT_TOOL_WEAK otError otPlatTcpConnect(otPlatTcpConnection     *aConn,
+                                      const otPlatTcpSockAddr *aPeerSockAddr,
+                                      const otPlatTcpSockAddr *aLocalSockAddr)
+{
+    OT_UNUSED_VARIABLE(aConn);
+    OT_UNUSED_VARIABLE(aPeerSockAddr);
+    OT_UNUSED_VARIABLE(aLocalSockAddr);
+
+    return OT_ERROR_FAILED;
+}
+
+OT_TOOL_WEAK void otPlatTcpNotifyTxPending(otPlatTcpConnection *aConn) { OT_UNUSED_VARIABLE(aConn); }
+
+OT_TOOL_WEAK uint16_t otPlatTcpSend(otPlatTcpConnection *aConn, const uint8_t *aBuffer, uint16_t aLength)
+{
+    OT_UNUSED_VARIABLE(aConn);
+    OT_UNUSED_VARIABLE(aBuffer);
+    OT_UNUSED_VARIABLE(aLength);
+
+    return 0;
+}
+
+OT_TOOL_WEAK void otPlatTcpClose(otPlatTcpConnection *aConn) { OT_UNUSED_VARIABLE(aConn); }
+
+OT_TOOL_WEAK void otPlatTcpAbort(otPlatTcpConnection *aConn) { OT_UNUSED_VARIABLE(aConn); }
+
+#endif // #if OPENTHREAD_CONFIG_PLATFORM_TCP_ENABLE
+
 #if OPENTHREAD_CONFIG_DNS_DSO_ENABLE
 
 OT_TOOL_WEAK void otPlatDsoEnableListening(otInstance *aInstance, bool aEnable)
@@ -776,7 +840,11 @@
 
 OT_TOOL_WEAK otError otPlatSetMcuPowerState(otInstance *aInstance, otPlatMcuPowerState aState) { return OT_ERROR_NONE; }
 #endif // OPENTHREAD_CONFIG_NCP_ENABLE_MCU_POWER_STATE_CONTROL
-#ifdef OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
+#if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
+
+uint8_t  sPlatBleLastAdvSetData[OT_TCAT_ADVERTISEMENT_MAX_LEN];
+uint16_t sPlatBleLastAdvSetDataLen = 0;
+
 otError otPlatBleEnable(otInstance *aInstance)
 {
     OT_UNUSED_VARIABLE(aInstance);
@@ -814,7 +882,9 @@
 
 otError otPlatBleGapDisconnect(otInstance *aInstance)
 {
-    OT_UNUSED_VARIABLE(aInstance);
+    // Honor the platform contract: report completion of the disconnection via the callback.
+    // Normally this call is done asynchronously, but for unit testing purposes we make the reentrant call.
+    otPlatBleGapOnDisconnected(aInstance, 0);
     return OT_ERROR_NONE;
 }
 
@@ -851,17 +921,19 @@
 otError otPlatBleGapAdvSetData(otInstance *aInstance, uint8_t *aAdvertisementData, uint16_t aAdvertisementLen)
 {
     OT_UNUSED_VARIABLE(aInstance);
-    OT_UNUSED_VARIABLE(aAdvertisementData);
-    OT_UNUSED_VARIABLE(aAdvertisementLen);
+    if (aAdvertisementData == nullptr || aAdvertisementLen > OT_TCAT_ADVERTISEMENT_MAX_LEN)
+    {
+        return OT_ERROR_INVALID_ARGS;
+    }
+    memcpy(sPlatBleLastAdvSetData, aAdvertisementData, aAdvertisementLen);
+    sPlatBleLastAdvSetDataLen = aAdvertisementLen;
+
     return OT_ERROR_NONE;
 }
 
 otError otPlatBleGapAdvUpdateData(otInstance *aInstance, uint8_t *aAdvertisementData, uint16_t aAdvertisementLen)
 {
-    OT_UNUSED_VARIABLE(aInstance);
-    OT_UNUSED_VARIABLE(aAdvertisementData);
-    OT_UNUSED_VARIABLE(aAdvertisementLen);
-    return OT_ERROR_NONE;
+    return otPlatBleGapAdvSetData(aInstance, aAdvertisementData, aAdvertisementLen);
 }
 
 #endif // OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
diff --git a/tests/unit/test_platform.h b/tests/unit/test_platform.h
index c72f747..8ae2756 100644
--- a/tests/unit/test_platform.h
+++ b/tests/unit/test_platform.h
@@ -43,6 +43,7 @@
 #include <openthread/platform/misc.h>
 #include <openthread/platform/multipan.h>
 #include <openthread/platform/radio.h>
+#include <openthread/platform/tcp.h>
 #include <openthread/platform/trel.h>
 
 #include "common/code_utils.hpp"
@@ -51,9 +52,22 @@
 #include "test_util.h"
 
 ot::Instance *testInitInstance(void);
+ot::Instance *testResetInstance(ot::Instance *aInstance);
 #if OPENTHREAD_CONFIG_MULTIPLE_INSTANCE_ENABLE && OPENTHREAD_CONFIG_MULTIPLE_STATIC_INSTANCE_ENABLE
 ot::Instance *testInitAdditionalInstance(uint8_t id);
 #endif
 void testFreeInstance(otInstance *aInstance);
 
+#if OPENTHREAD_CONFIG_BLE_TCAT_ENABLE
+#include <openthread/tcat.h>
+#ifdef __cplusplus
+extern "C" {
+#endif
+extern uint8_t  sPlatBleLastAdvSetData[OT_TCAT_ADVERTISEMENT_MAX_LEN];
+extern uint16_t sPlatBleLastAdvSetDataLen;
+#ifdef __cplusplus
+}
+#endif
+#endif
+
 #endif // OT_UNIT_TEST_PLATFORM_H_
diff --git a/tests/unit/test_pskc.cpp b/tests/unit/test_pskc.cpp
index b1a3877..c486b77 100644
--- a/tests/unit/test_pskc.cpp
+++ b/tests/unit/test_pskc.cpp
@@ -29,6 +29,7 @@
 
 #include "meshcop/commissioner.hpp"
 #include "meshcop/meshcop.hpp"
+#include "thread/key_manager.hpp"
 
 #include "test_platform.h"
 #include "test_util.h"
@@ -108,6 +109,22 @@
     testFreeInstance(instance);
 }
 
+void TestKeyManagerKek(void)
+{
+    Instance   *instance   = testInitInstance();
+    KeyManager &keyManager = instance->Get<KeyManager>();
+
+    VerifyOrQuit(!keyManager.IsKekSet());
+
+    Kek kek;
+    memset(kek.m8, 0xaa, sizeof(kek.m8));
+    keyManager.SetKek(kek);
+
+    VerifyOrQuit(keyManager.IsKekSet());
+
+    testFreeInstance(instance);
+}
+
 } // namespace MeshCoP
 } // namespace ot
 
@@ -119,6 +136,7 @@
     ot::MeshCoP::TestMinimumPassphrase();
     ot::MeshCoP::TestMaximumPassphrase();
     ot::MeshCoP::TestExampleInSpec();
+    ot::MeshCoP::TestKeyManagerKek();
     printf("All tests passed\n");
 #else
     printf("PSKc generation is not supported on non-ftd build\n");
diff --git a/tests/unit/test_random.cpp b/tests/unit/test_random.cpp
new file mode 100644
index 0000000..37e0089
--- /dev/null
+++ b/tests/unit/test_random.cpp
@@ -0,0 +1,225 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+c *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "test_platform.h"
+
+#include <openthread/config.h>
+
+#include "test_util.h"
+#include "common/code_utils.hpp"
+#include "common/num_utils.hpp"
+#include "common/numeric_limits.hpp"
+#include "common/random.hpp"
+
+namespace ot {
+
+template <typename UintType> void TestRandomNonCryto(const char *aName)
+{
+    static constexpr uint8_t kMaxIters = 6;
+
+    Instance *instance = static_cast<Instance *>(testInitInstance());
+    UintType  value;
+    UintType  max = NumericLimits<UintType>::kMax;
+
+    VerifyOrQuit(instance != nullptr);
+
+    printf("--------------------------------------------------------------------\n\r");
+    printf("TestRandomNonCryto<%s>()\r\n", aName);
+
+    //--------------------------------------------------------------------------
+    // GenerateUpToExcluding
+
+    value = Random::NonCrypto::GenerateUpToExcluding<UintType>(0);
+    VerifyOrQuit(value == 0);
+
+    printf("\r\nGenerateUpToExcluding(100): ");
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateUpToExcluding<UintType>(100);
+        printf("%lu, ", ToUlong(value));
+
+        VerifyOrQuit(value < 100);
+    }
+
+    printf("\r\nGenerateUpToExcluding(kMax): ");
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateUpToExcluding<UintType>(max);
+        printf("0x%lx, ", ToUlong(value));
+
+        VerifyOrQuit(value < max);
+    }
+
+    //--------------------------------------------------------------------------
+    // GenerateUpToExcluding
+
+    value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(12, 12);
+    VerifyOrQuit(value == 12);
+
+    value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(12, 11);
+    VerifyOrQuit(value == 12);
+
+    value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(12, 0);
+    VerifyOrQuit(value == 12);
+
+    value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(max, 0);
+    VerifyOrQuit(value == max);
+
+    value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(max, max);
+    VerifyOrQuit(value == max);
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(iter, iter + 1);
+        VerifyOrQuit(value == iter);
+    }
+
+    printf("\r\nGenerateFromMinUpToExcluding(100, 105): ");
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(100, 105);
+        printf("%lu, ", ToUlong(value));
+
+        VerifyOrQuit(value >= 100);
+        VerifyOrQuit(value < 105);
+    }
+
+    printf("\r\nGenerateFromMinUpToExcluding(max - 2, max): ");
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(max - 2, max);
+        printf("0x%lx, ", ToUlong(value));
+
+        VerifyOrQuit(value >= max - 2);
+        VerifyOrQuit(value < max);
+    }
+
+    printf("\r\nGenerateFromMinUpToExcluding(0, max): ");
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateFromMinUpToExcluding<UintType>(0, max);
+        printf("0x%lx, ", ToUlong(value));
+
+        VerifyOrQuit(value < max);
+    }
+
+    //--------------------------------------------------------------------------
+    // GenerateInClosedRange
+
+    value = Random::NonCrypto::GenerateInClosedRange<UintType>(101, 101);
+    VerifyOrQuit(value == 101);
+
+    value = Random::NonCrypto::GenerateInClosedRange<UintType>(101, 100);
+    VerifyOrQuit(value == 101);
+
+    value = Random::NonCrypto::GenerateInClosedRange<UintType>(101, 0);
+    VerifyOrQuit(value == 101);
+
+    value = Random::NonCrypto::GenerateInClosedRange<UintType>(max, max);
+    VerifyOrQuit(value == max);
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateInClosedRange<UintType>(iter, iter);
+        VerifyOrQuit(value == iter);
+    }
+
+    printf("\r\nGenerateInClosedRange(200, 201): ");
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateInClosedRange<UintType>(200, 201);
+        printf("%lu, ", ToUlong(value));
+
+        VerifyOrQuit(value >= 200);
+        VerifyOrQuit(value <= 201);
+    }
+
+    while (true)
+    {
+        // Make sure upper bound can be returned
+
+        value = Random::NonCrypto::GenerateInClosedRange<UintType>(100, 101);
+        VerifyOrQuit(value >= 100);
+        VerifyOrQuit(value <= 101);
+
+        if (value == 101)
+        {
+            break;
+        }
+    }
+
+    printf("\r\nGenerateInClosedRange(0, max): ");
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateInClosedRange<UintType>(0, max);
+        printf("0x%lx, ", ToUlong(value));
+    }
+
+    printf("\r\nGenerateInClosedRange(max-1, max): ");
+
+    for (uint8_t iter = 0; iter < kMaxIters; iter++)
+    {
+        value = Random::NonCrypto::GenerateInClosedRange<UintType>(max - 1, max);
+        printf("0x%lx, ", ToUlong(value));
+
+        VerifyOrQuit(value >= max - 1);
+    }
+
+    while (true)
+    {
+        // Make sure upper bound can be returned
+
+        value = Random::NonCrypto::GenerateInClosedRange<UintType>(max - 1, max);
+        VerifyOrQuit(value >= max - 1);
+
+        if (value == max)
+        {
+            break;
+        }
+    }
+
+    printf("\r\nTest passed\r\n");
+}
+
+} // namespace ot
+
+int main(void)
+{
+    ot::TestRandomNonCryto<uint8_t>("uint8_t");
+    ot::TestRandomNonCryto<uint16_t>("uint16_t");
+    ot::TestRandomNonCryto<uint32_t>("uint32_t");
+
+    printf("\nAll tests passed.\n");
+    return 0;
+}
diff --git a/tests/unit/test_routing_manager.cpp b/tests/unit/test_routing_manager.cpp
index e97a22f..5d03a96 100644
--- a/tests/unit/test_routing_manager.cpp
+++ b/tests/unit/test_routing_manager.cpp
@@ -242,11 +242,11 @@
 extern "C" {
 
 #if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
-void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+void otPlatLogOutput(otInstance *, otLogLevel, const char *aLogLine) { printf("   %s\n", aLogLine); }
+#else
+void otPlatLog(otLogLevel, otLogRegion, const char *aFormat, ...)
 {
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-
     va_list args;
 
     printf("   ");
@@ -256,6 +256,7 @@
     printf("\n");
 }
 #endif
+#endif
 
 //----------------------------------------------------------------------------------------------------------------------
 // `otPlatRadio
@@ -301,8 +302,8 @@
                                  const uint8_t      *aBuffer,
                                  uint16_t            aBufferLength)
 {
-    Icmp6Packet        packet;
-    Ip6::Icmp::Header *header;
+    Icmp6Packet       packet;
+    Ip6::Icmp6Header *header;
 
     Log("otPlatInfraIfSendIcmp6Nd(aDestAddr: %s, aBufferLength:%u)", AsCoreType(aDestAddress).ToString().AsCString(),
         aBufferLength);
@@ -312,19 +313,19 @@
 
     packet.Init(aBuffer, aBufferLength);
 
-    VerifyOrQuit(aBufferLength >= sizeof(Ip6::Icmp::Header));
+    VerifyOrQuit(aBufferLength >= sizeof(Ip6::Icmp6Header));
 
-    header = reinterpret_cast<Ip6::Icmp::Header *>(const_cast<uint8_t *>(aBuffer));
+    header = reinterpret_cast<Ip6::Icmp6Header *>(const_cast<uint8_t *>(aBuffer));
 
     switch (header->GetType())
     {
-    case Ip6::Icmp::Header::kTypeRouterSolicit:
+    case Ip6::Icmp6Header::kTypeRouterSolicit:
         Log("  Router Solicit message");
         sRsEmitted = true;
         otPlatInfraIfRecvIcmp6Nd(sInstance, kInfraIfIndex, &sInfraIfAddress, aBuffer, aBufferLength);
         break;
 
-    case Ip6::Icmp::Header::kTypeRouterAdvert:
+    case Ip6::Icmp6Header::kTypeRouterAdvert:
         Log("  Router Advertisement message");
         LogRouterAdvert(packet);
         ValidateRouterAdvert(packet);
@@ -334,7 +335,7 @@
         otPlatInfraIfRecvIcmp6Nd(sInstance, kInfraIfIndex, &sInfraIfAddress, aBuffer, aBufferLength);
         break;
 
-    case Ip6::Icmp::Header::kTypeNeighborSolicit:
+    case Ip6::Icmp6Header::kTypeNeighborSolicit:
     {
         const Ip6::Nd::NeighborSolicitHeader *nsMsg =
             reinterpret_cast<const Ip6::Nd::NeighborSolicitHeader *>(packet.GetBytes());
@@ -1385,19 +1386,23 @@
     VerifyOrQuit(favoredPrefix == aPrefix);
 }
 
-void InitTest(bool aEnablBorderRouting = false, bool aAfterReset = false)
+void InitTest(bool aEnablBorderRouting = false, bool aResetInstance = false)
 {
     uint32_t delay = 10000;
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Initialize OT instance.
 
-    sNow      = 0;
-    sAlarmOn  = false;
-    sInstance = static_cast<Instance *>(testInitInstance());
+    sNow     = 0;
+    sAlarmOn = false;
 
-    if (aAfterReset)
+    if (!aResetInstance)
     {
+        sInstance = testInitInstance();
+    }
+    else
+    {
+        sInstance = testResetInstance(sInstance);
         delay += 26000; // leader reset sync delay
     }
 
@@ -2749,164 +2754,6 @@
     FinalizeTest();
 }
 
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-void TestDomainPrefixAsOmr(void)
-{
-    Ip6::Prefix                     localOnLink;
-    Ip6::Prefix                     localOmr;
-    Ip6::Prefix                     domainPrefix = PrefixFromString("2000:0000:1111:4444::", 64);
-    NetworkData::OnMeshPrefixConfig prefixConfig;
-    uint16_t                        heapAllocations;
-
-    Log("--------------------------------------------------------------------------------------------");
-    Log("TestDomainPrefixAsOmr");
-
-    InitTest();
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Start Routing Manager. Check emitted RS and RA messages.
-
-    sRsEmitted   = false;
-    sRaValidated = false;
-    sExpectedPio = kPioAdvertisingLocalOnLink;
-    sExpectedRios.Clear();
-
-    heapAllocations = sHeapAllocatedPtrs.GetLength();
-    SuccessOrQuit(sInstance->Get<BorderRouter::RoutingManager>().SetEnabled(true));
-
-    SuccessOrQuit(sInstance->Get<BorderRouter::RoutingManager>().GetOnLinkPrefix(localOnLink));
-    SuccessOrQuit(sInstance->Get<BorderRouter::RoutingManager>().GetOmrPrefix(localOmr));
-
-    Log("Local on-link prefix is %s", localOnLink.ToString().AsCString());
-    Log("Local OMR prefix is %s", localOmr.ToString().AsCString());
-
-    sExpectedRios.Add(localOmr);
-
-    AdvanceTime(30000);
-
-    VerifyOrQuit(sRsEmitted);
-    VerifyOrQuit(sRaValidated);
-    VerifyOrQuit(sExpectedRios.SawAll());
-    Log("Received RA was validated");
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Check Network Data to include the local OMR and on-link prefix.
-
-    VerifyOmrPrefixInNetData(localOmr, /* aDefaultRoute */ false);
-    VerifyExternalRouteInNetData(kUlaRoute, kWithAdvPioFlagSet);
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Add a domain prefix directly into net data. The new prefix should
-    // be favored over the local OMR prefix.
-
-    otBackboneRouterSetEnabled(sInstance, true);
-
-    prefixConfig.Clear();
-    prefixConfig.mPrefix       = domainPrefix;
-    prefixConfig.mStable       = true;
-    prefixConfig.mSlaac        = true;
-    prefixConfig.mPreferred    = true;
-    prefixConfig.mOnMesh       = true;
-    prefixConfig.mDefaultRoute = false;
-    prefixConfig.mDp           = true;
-    prefixConfig.mPreference   = NetworkData::kRoutePreferenceMedium;
-
-    SuccessOrQuit(otBorderRouterAddOnMeshPrefix(sInstance, &prefixConfig));
-    SuccessOrQuit(otBorderRouterRegister(sInstance));
-
-    AdvanceTime(100);
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Make sure BR emits RA without domain prefix or previous local OMR.
-
-    sRaValidated = false;
-    sExpectedPio = kPioAdvertisingLocalOnLink;
-    sExpectedRios.Clear();
-    sExpectedRios.Add(domainPrefix);
-    sExpectedRios.Add(localOmr);
-
-    AdvanceTime(20000);
-
-    VerifyOrQuit(sRaValidated);
-
-    // We should see RIO removing the local OMR prefix with lifetime zero
-    // and should not see the domain prefix as RIO.
-
-    VerifyOrQuit(sExpectedRios[0].mPrefix == domainPrefix);
-    VerifyOrQuit(!sExpectedRios[0].mSawInRa);
-
-    VerifyOrQuit(sExpectedRios[1].mPrefix == localOmr);
-    VerifyOrQuit(sExpectedRios[1].mSawInRa);
-    VerifyOrQuit(sExpectedRios[1].mLifetime <= kRioDeprecatingLifetime);
-    VerifyOrQuit(sExpectedRios[1].mPreference == NetworkData::kRoutePreferenceLow);
-
-    // Wait long enough for deprecating RIO prefix to expire
-    AdvanceTime(3200000);
-
-    sRaValidated = false;
-    sExpectedPio = kPioAdvertisingLocalOnLink;
-    sExpectedRios.Clear();
-    sExpectedRios.Add(domainPrefix);
-    sExpectedRios.Add(localOmr);
-
-    // Wait for next RA (650 seconds).
-
-    AdvanceTime(650000);
-
-    VerifyOrQuit(sRaValidated);
-
-    // We should not see either domain prefix or local OMR
-    // as RIO.
-
-    VerifyOrQuit(!sExpectedRios[0].mSawInRa);
-    VerifyOrQuit(!sExpectedRios[1].mSawInRa);
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Check Network Data. We should now see that the local OMR prefix
-    // is removed.
-
-    VerifyOmrPrefixInNetData(domainPrefix, /* aDefaultRoute */ false);
-    VerifyExternalRouteInNetData(kUlaRoute, kWithAdvPioFlagSet);
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Remove the domain prefix from net data.
-
-    SuccessOrQuit(otBorderRouterRemoveOnMeshPrefix(sInstance, &domainPrefix));
-    SuccessOrQuit(otBorderRouterRegister(sInstance));
-
-    AdvanceTime(100);
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Make sure BR emits RA with local OMR prefix again.
-
-    sRaValidated = false;
-    sExpectedRios.Clear();
-    sExpectedRios.Add(localOmr);
-
-    AdvanceTime(20000);
-
-    VerifyOrQuit(sRaValidated);
-    VerifyOrQuit(sExpectedRios.SawAll());
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Check Network Data. We should see that the local OMR prefix is
-    // added again.
-
-    VerifyOmrPrefixInNetData(localOmr, /* aDefaultRoute */ false);
-    VerifyExternalRouteInNetData(kUlaRoute, kWithAdvPioFlagSet);
-
-    //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-    SuccessOrQuit(sInstance->Get<BorderRouter::RoutingManager>().SetEnabled(false));
-    AdvanceTime(3000);
-
-    VerifyOrQuit(heapAllocations == sHeapAllocatedPtrs.GetLength());
-
-    Log("End of TestDomainPrefixAsOmr");
-    FinalizeTest();
-}
-#endif // OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-
 void TestExtPanIdChange(void)
 {
     static constexpr uint32_t kMaxRaTxInterval = 196; // In seconds
@@ -4284,9 +4131,7 @@
 
     Log("Disabling and re-enabling OT Instance");
 
-    testFreeInstance(sInstance);
-
-    InitTest(/* aEnablBorderRouting */ true, /* aAfterReset */ true);
+    InitTest(/* aEnablBorderRouting */ true, /* aResetInstance */ true);
 
     sExpectedPio = kPioAdvertisingLocalOnLink;
 
@@ -4330,9 +4175,7 @@
 
     Log("Disabling and re-enabling OT Instance");
 
-    testFreeInstance(sInstance);
-
-    InitTest(/* aEnablBorderRouting */ false, /* aAfterReset */ true);
+    InitTest(/* aEnablBorderRouting */ false, /* aResetInstance */ true);
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
     // Start Routing Manager.
@@ -4380,8 +4223,7 @@
 
     Log("Disabling and re-enabling OT Instance again");
 
-    testFreeInstance(sInstance);
-    InitTest(/* aEnablBorderRouting */ false, /* aAfterReset */ true);
+    InitTest(/* aEnablBorderRouting */ false, /* aResetInstance */ true);
 
     SuccessOrQuit(sInstance->Get<BorderRouter::RoutingManager>().SetEnabled(true));
     AdvanceTime(100);
@@ -4997,7 +4839,7 @@
 
     prefix = PrefixFromString("2001:db8:cafe:0::", 64);
 
-    shortPrefix.Set(prefix.GetBytes(), 48);
+    shortPrefix.InitFrom(prefix.GetBytes(), 48);
     ReportPdPrefixesAsRa({Pio(shortPrefix, kValidLitime, kPreferredLifetime)});
 
     sExpectedRios.Add(prefix);
@@ -5185,7 +5027,7 @@
     // Now Advertise the PD prefix as on-link from a router first.
 
     Log("Router A advertises PD prefix as on-link before delegating the prefix");
-    SendRouterAdvert(routerAddressA, {Pio(pdPrefix, 200, 200)});
+    SendRouterAdvert(routerAddressA, {Pio(pdPrefix, 350, 350)});
 
     // Check that local OMR is used.
 
@@ -5207,7 +5049,7 @@
     sExpectedRios.Clear();
     sExpectedRios.Add(localOmr);
 
-    AdvanceTime(100 * 1000);
+    AdvanceTime(200 * 1000);
     VerifyOrQuit(sExpectedRios.SawAll());
 
     VerifyOmrPrefixInNetData(localOmr, /* aDefaultRoute */ true);
@@ -5265,7 +5107,7 @@
     sExpectedRios.Clear();
     sExpectedRios.Add(localOmr);
 
-    AdvanceTime(30 * 1000);
+    AdvanceTime(200 * 1000);
     VerifyOrQuit(sExpectedRios.SawAll());
 
     VerifyOmrPrefixInNetData(localOmr, /* aDefaultRoute */ false);
@@ -5594,9 +5436,6 @@
     ot::TestAdvNonUlaRoute();
     ot::TestFavoredOnLinkPrefix();
     ot::TestLocalOnLinkPrefixDeprecation();
-#if OPENTHREAD_CONFIG_BACKBONE_ROUTER_ENABLE
-    ot::TestDomainPrefixAsOmr();
-#endif
     ot::TestExtPanIdChange();
     ot::TestConflictingPrefix();
     ot::TestPrefixStaleTime();
diff --git a/tests/unit/test_spinel_buffer.cpp b/tests/unit/test_spinel_buffer.cpp
index 6ec7dd0..bc567a7 100644
--- a/tests/unit/test_spinel_buffer.cpp
+++ b/tests/unit/test_spinel_buffer.cpp
@@ -43,12 +43,10 @@
 // This module implements unit-test for Spinel::Buffer class.
 
 // Test related constants:
-enum
-{
-    kTestBufferSize       = 800,
-    kTestIterationAttemps = 10000,
-    kTagArraySize         = 1000,
-};
+
+constexpr uint16_t kTestBufferSize       = 800;
+constexpr uint16_t kTestIterationAttemps = 10000;
+constexpr uint16_t kTagArraySize         = 1000;
 
 //  Messages used for building frames...
 static const uint8_t sOpenThreadText[] = "OpenThread Rocks";
@@ -68,15 +66,13 @@
 
 CallbackContext sContext;
 
-enum
-{
-    kNumPrios = 2, // Number of priority levels.
+constexpr uint8_t kNumPrios = 2; // Number of priority levels.
 
-    kTestFrame1Size = sizeof(sMottoText) + sizeof(sMysteryText) + sizeof(sMottoText) + sizeof(sHelloText),
-    kTestFrame2Size = sizeof(sMysteryText) + sizeof(sHelloText) + sizeof(sOpenThreadText),
-    kTestFrame3Size = sizeof(sMysteryText),
-    kTestFrame4Size = sizeof(sOpenThreadText),
-};
+constexpr uint16_t kTestFrame1Size =
+    sizeof(sMottoText) + sizeof(sMysteryText) + sizeof(sMottoText) + sizeof(sHelloText);
+constexpr uint16_t kTestFrame2Size = sizeof(sMysteryText) + sizeof(sHelloText) + sizeof(sOpenThreadText);
+constexpr uint16_t kTestFrame3Size = sizeof(sMysteryText);
+constexpr uint16_t kTestFrame4Size = sizeof(sOpenThreadText);
 
 Spinel::Buffer::FrameTag sTagHistoryArray[kNumPrios][kTagArraySize];
 uint32_t                 sTagHistoryHead[kNumPrios] = {0};
@@ -870,16 +866,13 @@
  * Handle the cases where buffer gets full or empty.
  */
 
-enum
-{
-    kFuzTestBufferSize            = 2000,   // Size of the buffer used during fuzz testing
-    kFuzTestIterationAttempts     = 500000, // Number of iterations  to run
-    kLensArraySize                = 500,    // Size of "Lengths" array.
-    kMaxFrameLen                  = 400,    // Maximum frame length
-    kReadProbability              = 50,     // Probability (in percent) to randomly choose to read vs write frame
-    kHighPriorityProbability      = 20,     // Probability (in percent) to write a high priority frame
-    kUseTrueRandomNumberGenerator = 1,      // To use true random number generator or not.
-};
+constexpr uint16_t kFuzTestBufferSize            = 2000;   // Size of the buffer used during fuzz testing
+constexpr uint32_t kFuzTestIterationAttempts     = 500000; // Number of iterations to run
+constexpr uint16_t kLensArraySize                = 500;    // Size of "Lengths" array.
+constexpr uint16_t kMaxFrameLen                  = 400;    // Maximum frame length
+constexpr uint8_t  kReadProbability              = 50;     // Probability to randomly choose to read vs write frame
+constexpr uint8_t  kHighPriorityProbability      = 20;     // Probability to write a high priority frame
+constexpr bool     kUseTrueRandomNumberGenerator = true;   // To use true random number generator or not.
 
 uint8_t  sFrameBuffer[kNumPrios][kFuzTestBufferSize];
 uint32_t sFrameBufferTailIndex[kNumPrios] = {0};
@@ -894,7 +887,7 @@
     }
     else
     {
-        value = Random::NonCrypto::GetUint32();
+        value = Random::NonCrypto::Generate<uint32_t>();
     }
 
     return value % max;
diff --git a/tests/unit/test_spinel_decoder.cpp b/tests/unit/test_spinel_decoder.cpp
index e8ee858..ff17a57 100644
--- a/tests/unit/test_spinel_decoder.cpp
+++ b/tests/unit/test_spinel_decoder.cpp
@@ -35,10 +35,7 @@
 namespace ot {
 namespace Spinel {
 
-enum
-{
-    kTestBufferSize = 800,
-};
+constexpr uint16_t kTestBufferSize = 800;
 
 void TestDecoder(void)
 {
diff --git a/tests/unit/test_spinel_encoder.cpp b/tests/unit/test_spinel_encoder.cpp
index 09c8de9..214c255 100644
--- a/tests/unit/test_spinel_encoder.cpp
+++ b/tests/unit/test_spinel_encoder.cpp
@@ -35,10 +35,7 @@
 namespace ot {
 namespace Spinel {
 
-enum
-{
-    kTestBufferSize = 800,
-};
+constexpr uint16_t kTestBufferSize = 800;
 
 otError ReadFrame(Spinel::Buffer &aNcpBuffer, uint8_t *aFrame, uint16_t &aFrameLen)
 {
diff --git a/tests/unit/test_spinel_prop_codec.cpp b/tests/unit/test_spinel_prop_codec.cpp
index 458d3e7..becb956 100644
--- a/tests/unit/test_spinel_prop_codec.cpp
+++ b/tests/unit/test_spinel_prop_codec.cpp
@@ -72,13 +72,15 @@
     otPlatDnssdHost dnssdHostDecode;
     otIp6Address    dnssdHostAddrs[] = {
         {0xfd, 0x2a, 0xc3, 0x0c, 0x87, 0xd3, 0x00, 0x01, 0xed, 0x1c, 0x0c, 0x91, 0xcc, 0xb6, 0x57, 0x8b},
+        {0xfd, 0x2a, 0xc3, 0x0c, 0x87, 0xd3, 0x00, 0x01, 0xed, 0x1c, 0x0c, 0x91, 0xcc, 0xb6, 0x57, 0x8c},
+        {0xfd, 0x2a, 0xc3, 0x0c, 0x87, 0xd3, 0x00, 0x01, 0xed, 0x1c, 0x0c, 0x91, 0xcc, 0xb6, 0x57, 0x8d},
     };
     otPlatDnssdRequestId requestId;
     const uint8_t       *callbackData;
     uint16_t             callbackDataLen;
     dnssdHostEncode.mHostName        = "ot-host1";
     dnssdHostEncode.mAddresses       = dnssdHostAddrs;
-    dnssdHostEncode.mAddressesLength = 1;
+    dnssdHostEncode.mAddressesLength = sizeof(dnssdHostAddrs) / sizeof(dnssdHostAddrs[0]);
 
     SuccessOrQuit(error = encoder.BeginFrame(SPINEL_HEADER_FLAG, SPINEL_CMD_PROP_VALUE_INSERTED));
     SuccessOrQuit(error = EncodeDnssd(encoder, dnssdHostEncode, 1 /* aRequestId */, DnssdFakeCallback));
@@ -103,6 +105,67 @@
     VerifyOrQuit(callbackDataLen == sizeof(otPlatDnssdRegisterCallback));
     VerifyOrQuit(*reinterpret_cast<const otPlatDnssdRegisterCallback *>(callbackData) == DnssdFakeCallback);
 
+    ncpBuffer.Clear();
+    dnssdHostEncode.mHostName        = "ot-host-empty";
+    dnssdHostEncode.mAddresses       = nullptr;
+    dnssdHostEncode.mAddressesLength = 0;
+
+    SuccessOrQuit(error = encoder.BeginFrame(SPINEL_HEADER_FLAG, SPINEL_CMD_PROP_VALUE_INSERTED));
+    SuccessOrQuit(error = EncodeDnssd(encoder, dnssdHostEncode, 10 /* aRequestId */, DnssdFakeCallback));
+    SuccessOrQuit(error = encoder.EndFrame());
+    SuccessOrQuit(ncpBuffer.OutFrameBegin());
+    len = ncpBuffer.OutFrameGetLength();
+    VerifyOrQuit(ncpBuffer.OutFrameRead(len, buf) == len);
+
+    decoder.Init(buf, len);
+    SuccessOrQuit(error = decoder.ReadUint8(header));
+    VerifyOrQuit(header == SPINEL_HEADER_FLAG);
+    SuccessOrQuit(error = decoder.ReadUintPacked(command));
+    VerifyOrQuit(command == SPINEL_CMD_PROP_VALUE_INSERTED);
+    SuccessOrQuit(error = decoder.ReadUintPacked(propKey));
+    VerifyOrQuit(static_cast<spinel_prop_key_t>(propKey) == SPINEL_PROP_DNSSD_HOST);
+    SuccessOrQuit(error = DecodeDnssdHost(decoder, dnssdHostDecode, requestId, callbackData, callbackDataLen));
+    VerifyOrQuit(strcmp(dnssdHostDecode.mHostName, dnssdHostEncode.mHostName) == 0);
+    VerifyOrQuit(dnssdHostDecode.mAddressesLength == 0);
+    VerifyOrQuit(dnssdHostDecode.mAddresses == nullptr);
+    VerifyOrQuit(requestId == 10);
+    VerifyOrQuit(callbackDataLen == sizeof(otPlatDnssdRegisterCallback));
+    VerifyOrQuit(*reinterpret_cast<const otPlatDnssdRegisterCallback *>(callbackData) == DnssdFakeCallback);
+
+    ncpBuffer.Clear();
+    dnssdHostEncode.mHostName        = "ot-host-short";
+    dnssdHostEncode.mAddresses       = dnssdHostAddrs;
+    dnssdHostEncode.mAddressesLength = 1;
+
+    SuccessOrQuit(error = encoder.BeginFrame(SPINEL_HEADER_FLAG, SPINEL_CMD_PROP_VALUE_INSERTED));
+    SuccessOrQuit(error = EncodeDnssd(encoder, dnssdHostEncode, 11 /* aRequestId */, DnssdFakeCallback));
+    SuccessOrQuit(error = encoder.EndFrame());
+    SuccessOrQuit(ncpBuffer.OutFrameBegin());
+    len = ncpBuffer.OutFrameGetLength();
+    VerifyOrQuit(ncpBuffer.OutFrameRead(len, buf) == len);
+
+    decoder.Init(buf, len);
+    SuccessOrQuit(error = decoder.ReadUint8(header));
+    SuccessOrQuit(error = decoder.ReadUintPacked(command));
+    SuccessOrQuit(error = decoder.ReadUintPacked(propKey));
+    {
+        const char *hostName;
+
+        SuccessOrQuit(error = decoder.ReadUtf8(hostName));
+    }
+
+    buf[decoder.GetReadLength()]     = 2;
+    buf[decoder.GetReadLength() + 1] = 0;
+
+    decoder.Init(buf, len);
+    SuccessOrQuit(error = decoder.ReadUint8(header));
+    VerifyOrQuit(header == SPINEL_HEADER_FLAG);
+    SuccessOrQuit(error = decoder.ReadUintPacked(command));
+    VerifyOrQuit(command == SPINEL_CMD_PROP_VALUE_INSERTED);
+    SuccessOrQuit(error = decoder.ReadUintPacked(propKey));
+    VerifyOrQuit(static_cast<spinel_prop_key_t>(propKey) == SPINEL_PROP_DNSSD_HOST);
+    VerifyOrQuit(DecodeDnssdHost(decoder, dnssdHostDecode, requestId, callbackData, callbackDataLen) == OT_ERROR_PARSE);
+
     // Test DnssdService encoding and decoding
     otPlatDnssdService dnssdServiceEncode;
     otPlatDnssdService dnssdServiceDecode;
diff --git a/tests/unit/test_srp_adv_proxy.cpp b/tests/unit/test_srp_adv_proxy.cpp
index 1794a39..ecb9ce6 100644
--- a/tests/unit/test_srp_adv_proxy.cpp
+++ b/tests/unit/test_srp_adv_proxy.cpp
@@ -458,11 +458,11 @@
 #endif
 
 #if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
-void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+void otPlatLogOutput(otInstance *, otLogLevel, const char *aLogLine) { printf("   %s\n", aLogLine); }
+#else
+void otPlatLog(otLogLevel, otLogRegion, const char *aFormat, ...)
 {
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-
     va_list args;
 
     printf("   ");
@@ -472,6 +472,7 @@
     printf("\n");
 }
 #endif
+#endif
 
 } // extern "C"
 
diff --git a/tests/unit/test_srp_server.cpp b/tests/unit/test_srp_server.cpp
index a9a2e91..1d2675d 100644
--- a/tests/unit/test_srp_server.cpp
+++ b/tests/unit/test_srp_server.cpp
@@ -134,11 +134,11 @@
 #endif
 
 #if OPENTHREAD_CONFIG_LOG_OUTPUT == OPENTHREAD_CONFIG_LOG_OUTPUT_PLATFORM_DEFINED
-void otPlatLog(otLogLevel aLogLevel, otLogRegion aLogRegion, const char *aFormat, ...)
+#if OPENTHREAD_CONFIG_LOG_INSTANCE_AWARE_API_ENABLE
+void otPlatLogOutput(otInstance *, otLogLevel, const char *aLogLine) { printf("   %s\n", aLogLine); }
+#else
+void otPlatLog(otLogLevel, otLogRegion, const char *aFormat, ...)
 {
-    OT_UNUSED_VARIABLE(aLogLevel);
-    OT_UNUSED_VARIABLE(aLogRegion);
-
     va_list args;
 
     printf("   ");
@@ -148,6 +148,7 @@
     printf("\n");
 }
 #endif
+#endif
 
 } // extern "C"
 
@@ -966,11 +967,11 @@
     VerifyOrQuit(service1.GetState() == Srp::Client::kRemoved);
 
     //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-    // Register the service again, but this time change it to request
-    // a lease time that is larger than the `LeaseConfig.mMinLease` of
-    // 27 hours. This ensures that server needs to include the Lease
-    // Option in its response (since it need to grant a different
-    // lease interval).
+    // Register the service again, but this time request a lease time
+    // larger than the server's `LeaseConfig.mMaxLease` of 27 hours, so
+    // the server grants a different (clamped) interval. Validate that
+    // the client adopts the granted lease from the Update Lease
+    // Option in the response.
 
     service1.mLease    = 100u * 3600; // 100 hours >= 27 hours.
     service1.mKeyLease = 110u * 3600;
diff --git a/tests/unit/test_string.cpp b/tests/unit/test_string.cpp
index 716bd66..768826f 100644
--- a/tests/unit/test_string.cpp
+++ b/tests/unit/test_string.cpp
@@ -36,10 +36,7 @@
 
 namespace ot {
 
-enum
-{
-    kStringSize = 10,
-};
+constexpr uint16_t kStringSize = 10;
 
 template <uint16_t kSize> void PrintString(const char *aName, const String<kSize> aString)
 {
diff --git a/tests/unit/test_tasklet.cpp b/tests/unit/test_tasklet.cpp
index 06c1b71..b593ca4 100644
--- a/tests/unit/test_tasklet.cpp
+++ b/tests/unit/test_tasklet.cpp
@@ -38,9 +38,12 @@
 #define Log(aMessage) fprintf(stderr, aMessage "\n\r")
 
 static Instance *sInstance                = nullptr;
+static Tasklet  *sTask1                   = nullptr;
 static bool      sTask1Handled            = false;
 static bool      sTask2Handled            = false;
 static bool      sTask3Handled            = false;
+static bool      sTask4Handled            = false;
+static bool      sTask5Handled            = false;
 static bool      sSignalPendingCalled     = false;
 static bool      sShouldTask3RepostItself = false;
 
@@ -61,6 +64,8 @@
     sTask1Handled        = false;
     sTask2Handled        = false;
     sTask3Handled        = false;
+    sTask4Handled        = false;
+    sTask5Handled        = false;
     sSignalPendingCalled = false;
 }
 
@@ -99,6 +104,26 @@
     }
 }
 
+void HandleTask4(Tasklet &aTasklet)
+{
+    Log("   HandleTask4() - will post task1");
+    CheckTaskeltFromHandler(aTasklet);
+    VerifyOrQuit(!sTask4Handled);
+    sTask4Handled = true;
+
+    sTask1->Post();
+}
+
+void HandleTask5(Tasklet &aTasklet)
+{
+    Log("   HandleTask5() - will un-post task1");
+    CheckTaskeltFromHandler(aTasklet);
+    VerifyOrQuit(!sTask5Handled);
+    sTask5Handled = true;
+
+    sTask1->Unpost();
+}
+
 void TestTasklet(void)
 {
     Log("TestTasklet");
@@ -111,6 +136,10 @@
         Tasklet             task1(*sInstance, HandleTask1);
         Tasklet             task2(*sInstance, HandleTask2);
         Tasklet             task3(*sInstance, HandleTask3);
+        Tasklet             task4(*sInstance, HandleTask4);
+        Tasklet             task5(*sInstance, HandleTask5);
+
+        sTask1 = &task1;
 
         Log("Process all initially posted tasks after `Instance` initialization");
 
@@ -563,6 +592,214 @@
 
         VerifyOrQuit(!sSignalPendingCalled);
         VerifyOrQuit(!scheduler.AreTaskletsPending());
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        Log("Post task4 (which posts task1 from its handler) and then task1");
+        Log("We expect the posting of task1 (from task4 handler) to be ignored since it is already posted");
+
+        ResetTestFlags();
+
+        task4.Post();
+        task1.Post();
+
+        VerifyOrQuit(task4.IsPosted());
+        VerifyOrQuit(task1.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        sSignalPendingCalled = false;
+
+        scheduler.ProcessQueuedTasklets();
+
+        VerifyOrQuit(sTask4Handled);
+        VerifyOrQuit(sTask1Handled);
+
+        VerifyOrQuit(!task4.IsPosted());
+        VerifyOrQuit(!task1.IsPosted());
+
+        VerifyOrQuit(!sSignalPendingCalled);
+        VerifyOrQuit(!scheduler.AreTaskletsPending());
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        Log("Post task1 first and then task4 (which posts task1 from its handler) now");
+        Log("Since task1 is first, it should be handled before task4's handler and task4's handler should post task1");
+
+        ResetTestFlags();
+
+        task1.Post();
+        task4.Post();
+
+        VerifyOrQuit(task4.IsPosted());
+        VerifyOrQuit(task1.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        sSignalPendingCalled = false;
+
+        scheduler.ProcessQueuedTasklets();
+
+        VerifyOrQuit(sTask4Handled);
+        VerifyOrQuit(sTask1Handled);
+
+        VerifyOrQuit(!task4.IsPosted());
+        VerifyOrQuit(task1.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        VerifyOrQuit(scheduler.AreTaskletsPending());
+
+        ResetTestFlags();
+
+        scheduler.ProcessQueuedTasklets();
+        VerifyOrQuit(!sTask4Handled);
+        VerifyOrQuit(sTask1Handled);
+
+        VerifyOrQuit(!task4.IsPosted());
+        VerifyOrQuit(!task1.IsPosted());
+
+        VerifyOrQuit(!sSignalPendingCalled);
+        VerifyOrQuit(!scheduler.AreTaskletsPending());
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        Log("Post task5 (which un-posts task1 from its handler) and then task1");
+        Log("Since task5 is first, we expect task1 to be removed and its handler never called");
+
+        ResetTestFlags();
+
+        task5.Post();
+        task1.Post();
+
+        VerifyOrQuit(task5.IsPosted());
+        VerifyOrQuit(task1.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        sSignalPendingCalled = false;
+
+        scheduler.ProcessQueuedTasklets();
+
+        VerifyOrQuit(sTask5Handled);
+        VerifyOrQuit(!sTask1Handled);
+
+        VerifyOrQuit(!task5.IsPosted());
+        VerifyOrQuit(!task1.IsPosted());
+
+        VerifyOrQuit(!sSignalPendingCalled);
+        VerifyOrQuit(!scheduler.AreTaskletsPending());
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        Log("Post task1 first, then task5 (which un-posts task1 from its handler)");
+        Log("Both tasks should be handled");
+
+        ResetTestFlags();
+
+        task1.Post();
+        task5.Post();
+
+        VerifyOrQuit(task1.IsPosted());
+        VerifyOrQuit(task5.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        sSignalPendingCalled = false;
+
+        scheduler.ProcessQueuedTasklets();
+
+        VerifyOrQuit(sTask1Handled);
+        VerifyOrQuit(sTask5Handled);
+
+        VerifyOrQuit(!task5.IsPosted());
+        VerifyOrQuit(!task1.IsPosted());
+
+        VerifyOrQuit(!sSignalPendingCalled);
+        VerifyOrQuit(!scheduler.AreTaskletsPending());
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        Log("Post task5 on its own, which un-posts task1 from its handler - it should do nothing to task1");
+
+        ResetTestFlags();
+
+        task5.Post();
+
+        VerifyOrQuit(task5.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        sSignalPendingCalled = false;
+
+        scheduler.ProcessQueuedTasklets();
+
+        VerifyOrQuit(sTask5Handled);
+
+        VerifyOrQuit(!task5.IsPosted());
+        VerifyOrQuit(!task1.IsPosted());
+
+        VerifyOrQuit(!sSignalPendingCalled);
+        VerifyOrQuit(!scheduler.AreTaskletsPending());
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        Log("Post task4 (which posts task1 from its handler), then task5 (which un-posts task1 from its handler)");
+        Log("The two should cancel each other and at the end task1 should not be posted");
+
+        ResetTestFlags();
+
+        task4.Post();
+        task5.Post();
+
+        VerifyOrQuit(task4.IsPosted());
+        VerifyOrQuit(task5.IsPosted());
+        VerifyOrQuit(!task1.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        sSignalPendingCalled = false;
+
+        scheduler.ProcessQueuedTasklets();
+
+        VerifyOrQuit(sTask4Handled);
+        VerifyOrQuit(sTask5Handled);
+        VerifyOrQuit(!sTask1Handled);
+
+        VerifyOrQuit(!task5.IsPosted());
+        VerifyOrQuit(!task4.IsPosted());
+        VerifyOrQuit(!task1.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        VerifyOrQuit(!scheduler.AreTaskletsPending());
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        Log("Post task5 (which un-posts task1 from its handler), then task1, and then task4 (which posts task1)");
+        Log("The task1 should be removed while processing task5, but then posted again from task4");
+
+        ResetTestFlags();
+
+        task5.Post();
+        task1.Post();
+        task4.Post();
+
+        VerifyOrQuit(task4.IsPosted());
+        VerifyOrQuit(task5.IsPosted());
+        VerifyOrQuit(task1.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        sSignalPendingCalled = false;
+
+        scheduler.ProcessQueuedTasklets();
+
+        VerifyOrQuit(sTask5Handled);
+        VerifyOrQuit(sTask4Handled);
+        VerifyOrQuit(!sTask1Handled);
+
+        VerifyOrQuit(!task5.IsPosted());
+        VerifyOrQuit(!task4.IsPosted());
+        VerifyOrQuit(task1.IsPosted());
+
+        VerifyOrQuit(sSignalPendingCalled);
+        VerifyOrQuit(scheduler.AreTaskletsPending());
+
+        // Handle the posted task1
+        ResetTestFlags();
+
+        scheduler.ProcessQueuedTasklets();
+        VerifyOrQuit(sTask1Handled);
+
+        VerifyOrQuit(!task1.IsPosted());
+
+        VerifyOrQuit(!sSignalPendingCalled);
+        VerifyOrQuit(!scheduler.AreTaskletsPending());
     }
 }
 
diff --git a/tests/unit/test_tcat.cpp b/tests/unit/test_tcat.cpp
index bc73746..c9ab997 100644
--- a/tests/unit/test_tcat.cpp
+++ b/tests/unit/test_tcat.cpp
@@ -36,6 +36,7 @@
 #include <openthread/ble_secure.h>
 
 #include "cli/cli_dataset.hpp"
+#include "radio/ble_secure.hpp"
 
 #define OT_TCAT_X509_CERT                                                \
     "-----BEGIN CERTIFICATE-----\n"                                      \
@@ -193,6 +194,7 @@
     TestBleSecure(void)
         : mIsConnected(false)
         , mIsBleConnectionOpen(false)
+        , mConnectCallbackCount(0)
     {
     }
 
@@ -200,14 +202,18 @@
     {
         mIsConnected         = aConnected;
         mIsBleConnectionOpen = aBleConnectionOpen;
+        mConnectCallbackCount++;
     }
 
-    bool IsConnected(void) const { return mIsConnected; }
-    bool IsBleConnectionOpen(void) const { return mIsBleConnectionOpen; }
+    bool     IsConnected(void) const { return mIsConnected; }
+    bool     IsBleConnectionOpen(void) const { return mIsBleConnectionOpen; }
+    uint32_t GetConnectCallbackCount(void) const { return mConnectCallbackCount; }
+    void     ResetConnectCallbackCount(void) { mConnectCallbackCount = 0; }
 
 private:
-    bool mIsConnected;
-    bool mIsBleConnectionOpen;
+    bool     mIsConnected;
+    bool     mIsBleConnectionOpen;
+    uint32_t mConnectCallbackCount;
 };
 
 static void HandleBleSecureConnect(otInstance *aInstance, bool aConnected, bool aBleConnectionOpen, void *aContext)
@@ -267,6 +273,8 @@
     memcpy(&sCommExtPanId, &kExtPanId, sizeof(sCommExtPanId));
     memcpy(&sCommAuth, &kCommCert1AuthField, sizeof(sCommAuth));
     memcpy(&sDeviceAuth, &kDeviceCert1AuthField, sizeof(sDeviceAuth));
+    sPlatBleLastAdvSetDataLen = 0;
+    memset(sPlatBleLastAdvSetData, 0, OT_TCAT_ADVERTISEMENT_MAX_LEN);
 
     return instance;
 }
@@ -303,8 +311,11 @@
     // Validate connection callbacks when calling `otBleSecureDisconnect()`
     otPlatBleGapOnConnected(instance, kConnectionId);
     VerifyOrQuit(!ble.IsConnected() && ble.IsBleConnectionOpen());
+    ble.ResetConnectCallbackCount();
     otBleSecureDisconnect(instance);
     VerifyOrQuit(!ble.IsConnected() && !ble.IsBleConnectionOpen());
+    // Regression test: a locally-initiated disconnect must invoke the connect callback exactly once.
+    VerifyOrQuit(ble.GetConnectCallbackCount() == 1);
 
     // Validate TLS connection can be started (as client) only when peer is BLE-connected
     otPlatBleGapOnConnected(instance, kConnectionId);
@@ -336,6 +347,46 @@
     testFreeInstance(instance);
 }
 
+void TestTcatAdvertisementUpdates(void)
+{
+    TestBleSecure ble;
+    Instance     *instance = TestInitInstanceTcat();
+    uint8_t       advDataSnapshot[OT_TCAT_ADVERTISEMENT_MAX_LEN];
+    uint16_t      advDataSnapshotLen;
+
+    VerifyOrQuit(sPlatBleLastAdvSetDataLen == 0, "Adv data should be unset before BleSecure start");
+
+    SuccessOrQuit(otBleSecureStart(instance, HandleBleSecureConnect, nullptr, true, &ble));
+    SuccessOrQuit(otBleSecureTcatStart(instance, nullptr));
+
+    VerifyOrQuit(sPlatBleLastAdvSetDataLen > 0, "Adv data should be set after BleSecure start");
+    advDataSnapshotLen = sPlatBleLastAdvSetDataLen;
+    memcpy(advDataSnapshot, sPlatBleLastAdvSetData, advDataSnapshotLen);
+
+    otPlatBleGapOnConnected(instance, kConnectionId);
+    SuccessOrQuit(otBleSecureConnect(instance));
+
+    // BLE connect and initiating TLS does not change the adv data.
+    VerifyOrQuit(sPlatBleLastAdvSetDataLen == advDataSnapshotLen &&
+                     memcmp(sPlatBleLastAdvSetData, advDataSnapshot, advDataSnapshotLen) == 0,
+                 "Adv data changed unexpectedly after BLE connect");
+    advDataSnapshotLen = sPlatBleLastAdvSetDataLen;
+    memcpy(advDataSnapshot, sPlatBleLastAdvSetData, advDataSnapshotLen);
+
+    // Commissioner sets dataset, then disconnects
+    instance->Get<ActiveDatasetManager>().SaveLocal(sPartialDataset);
+    otBleSecureDisconnect(instance);
+
+    // Adv is changed due to the partial dataset now being advertised in the S flag.
+    VerifyOrQuit(sPlatBleLastAdvSetDataLen == advDataSnapshotLen, "Adv data length changed unexpectedly");
+    VerifyOrQuit(memcmp(sPlatBleLastAdvSetData, advDataSnapshot, advDataSnapshotLen) != 0,
+                 "Adv data did not change after disconnect, which it should due to S flag");
+
+    otBleSecureStop(instance);
+
+    testFreeInstance(instance);
+}
+
 class UnitTester
 {
 private:
@@ -345,15 +396,17 @@
                                           const TcatAgent::CertificateAuthorizationField aDeviceAuth,
                                           bool                                           aIsCommissionedAtStart)
     {
-        aAgent->mState                          = TcatAgent::kStateConnected;
+        // This mock function mimics the steps in TcatAgent::Connected() without requiring the actual TLS
+        // session object.
+        aAgent->ClearCommissionerState();
         aAgent->mCommissionerAuthorizationField = aCommAuth;
         aAgent->mDeviceAuthorizationField       = aDeviceAuth;
-        aAgent->mPskcVerified                   = false;
-        aAgent->mPskdVerified                   = false;
-        aAgent->mCommissionerHasExtendedPanId   = false;
-        aAgent->mCommissionerHasNetworkName     = false;
-        aAgent->mCommissionerHasDomainName      = false;
         aAgent->mIsCommissioned                 = aIsCommissionedAtStart;
+
+        aAgent->mNextState =
+            (aAgent->mState == TcatAgent::kStateActiveTemporary) ? TcatAgent::kStateStandby : TcatAgent::kStateActive;
+        aAgent->mState = TcatAgent::kStateConnected;
+        aAgent->NotifyStateChange();
     }
 
     // Mock condition: commissioner has or has not the given Extended Pan ID in its certificate.
@@ -545,6 +598,7 @@
         // Mock TCAT Commissioner 4 connects to the Device - verify it only has access to class General by default.
         // The Device is commissioned already at start of the TCAT Link.
         // =======================================================================================================
+        instance->Get<ActiveDatasetManager>().SaveLocal(sFullDataset);
         memcpy(&sCommAuth, &kCommCert4AuthField, sizeof(sCommAuth));
         MockCommissionerConnected(agent, sCommAuth, sDeviceAuth, true);
         VerifyOrQuit(CommandClassesAuthorized(agent, kClassGeneral));
@@ -740,23 +794,42 @@
         VerifyOrQuit(!SetActiveDatasetAuthorized(agent, sFullDataset));
         VerifyOrQuit(!SetActiveDatasetAuthorized(agent, sPartialDataset));
 
-        // Domain Name match
+        // Domain Name match - but Commissioning in general is still not authorized, due to missing Active Dataset.
+        // Hence the Network Name and XPAN ID checks cannot succeed in general. They will succeed now for the
+        // specific 'Set Active Dataset' command.
         MockDomainName(agent, true, &sCommDomainName);
-        VerifyOrQuit(CommandClassesAuthorized(agent, kClassGeneral | kClassCommissioning));
+        VerifyOrQuit(CommandClassesAuthorized(agent, kClassGeneral));
         VerifyOrQuit(SetActiveDatasetAuthorized(agent, sFullDataset));
+
+        // the partial dataset cannot be written, because it lacks the required Network Name and XPAN ID combo.
         VerifyOrQuit(!SetActiveDatasetAuthorized(agent, sPartialDataset));
 
         // PSKc proof
         agent->mPskcVerified = true;
-        VerifyOrQuit(CommandClassesAuthorized(agent, kClassGeneral | kClassCommissioning | kClassExtraction |
-                                                         kClassDecommissioning | kClassApplication));
+        VerifyOrQuit(CommandClassesAuthorized(agent, kClassGeneral));
         VerifyOrQuit(SetActiveDatasetAuthorized(agent, sFullDataset));
         VerifyOrQuit(!SetActiveDatasetAuthorized(agent, sPartialDataset));
 
-        // Try write a full dataset with differing XPAN ID
+        // Try write a full dataset with differing XPAN ID - this fails
         sFullDataset.mExtendedPanId.m8[2]++;
         VerifyOrQuit(!SetActiveDatasetAuthorized(agent, sFullDataset));
 
+        // Test an equivalent case to above where the device does have a full dataset stored already, and the
+        // Commissioner connects. Now it has full access to all classes due to matching Network Name / XPAN ID combo.
+        sFullDataset = AsCoreType(&kFullDataset);
+        instance->Get<ActiveDatasetManager>().SaveLocal(sFullDataset);
+        MockCommissionerConnected(agent, sCommAuth, sDeviceAuth, true);
+        agent->mPskdVerified = true;
+        agent->mPskcVerified = true;
+        MockNetworkName(agent, true, &sCommNetworkName);
+        MockExtPanId(agent, true, &sCommExtPanId);
+        MockDomainName(agent, true, &sCommDomainName);
+
+        VerifyOrQuit(CommandClassesAuthorized(agent, kClassGeneral | kClassCommissioning | kClassDecommissioning |
+                                                         kClassExtraction | kClassApplication));
+        VerifyOrQuit(!SetActiveDatasetAuthorized(agent, sFullDataset));
+        VerifyOrQuit(!SetActiveDatasetAuthorized(agent, sPartialDataset));
+
         testFreeInstance(instance);
     }
 
@@ -812,6 +885,7 @@
     ot::MeshCoP::UnitTester::TestTcatCommissioner1AuthWithDeviceRequirements();
     ot::MeshCoP::UnitTester::TestTcatCommissioner2AuthWithDeviceRequirements();
     ot::MeshCoP::UnitTester::TestTcatCommissioner4AuthWithExistingPartialDataset();
+    ot::MeshCoP::TestTcatAdvertisementUpdates();
     printf("All tests passed\n");
 #else
     printf("TCAT feature is not enabled\n");
diff --git a/tests/unit/test_timer.cpp b/tests/unit/test_timer.cpp
index 8666ba7..6dbeb06 100644
--- a/tests/unit/test_timer.cpp
+++ b/tests/unit/test_timer.cpp
@@ -37,14 +37,10 @@
 
 namespace ot {
 
-enum
-{
-    kCallCountIndexAlarmStop = 0,
-    kCallCountIndexAlarmStart,
-    kCallCountIndexTimerHandler,
-
-    kCallCountIndexMax
-};
+constexpr uint16_t kCallCountIndexAlarmStop    = 0;
+constexpr uint16_t kCallCountIndexAlarmStart   = 1;
+constexpr uint16_t kCallCountIndexTimerHandler = 2;
+constexpr uint16_t kCallCountIndexMax          = 3;
 
 uint32_t sNow;
 uint32_t sPlatT0;
diff --git a/tests/unit/test_tlv.cpp b/tests/unit/test_tlv.cpp
index 3d83fe5..3a8c549 100644
--- a/tests/unit/test_tlv.cpp
+++ b/tests/unit/test_tlv.cpp
@@ -46,6 +46,7 @@
     Instance     *instance = testInitInstance();
     Message      *message;
     Tlv           tlv;
+    Tlv::Info     tlvInfo;
     ExtendedTlv   extTlv;
     Tlv::Bookmark bookmark;
     uint16_t      offset;
@@ -64,7 +65,7 @@
     VerifyOrQuit(message->GetLength() == 0);
 
     VerifyOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 1, offsetRange) == kErrorNotFound);
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, 0, buffer, 1) == kErrorParse);
+    VerifyOrQuit(tlvInfo.ParseFrom(*message, 0) == kErrorParse);
 
     // Add an empty TLV with type 1 and check that we can find it
 
@@ -77,8 +78,11 @@
     SuccessOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 1, offsetRange));
     VerifyOrQuit(offsetRange.GetOffset() == sizeof(Tlv));
     VerifyOrQuit(offsetRange.GetLength() == 0);
-    SuccessOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 0));
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 1) == kErrorParse);
+    SuccessOrQuit(tlvInfo.ParseFrom(*message, offset));
+    VerifyOrQuit(tlvInfo.GetType() == 1);
+    VerifyOrQuit(tlvInfo.GetLength() == 0);
+    VerifyOrQuit(!tlvInfo.IsExtended());
+    VerifyOrQuit(tlvInfo.ReadValue(*message, buffer, 1) == kErrorParse);
 
     // Add an empty extended TLV (type 2), and check that we can find it.
 
@@ -91,8 +95,11 @@
     SuccessOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 2, offsetRange));
     VerifyOrQuit(offsetRange.GetOffset() == offset + sizeof(ExtendedTlv));
     VerifyOrQuit(offsetRange.GetLength() == 0);
-    SuccessOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 0));
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 1) == kErrorParse);
+    SuccessOrQuit(tlvInfo.ParseFrom(*message, offset));
+    VerifyOrQuit(tlvInfo.GetType() == 2);
+    VerifyOrQuit(tlvInfo.GetLength() == 0);
+    VerifyOrQuit(tlvInfo.IsExtended());
+    VerifyOrQuit(tlvInfo.ReadValue(*message, buffer, 1) == kErrorParse);
 
     // Add a TLV with type 3 with one byte value and check if we can find it.
 
@@ -106,9 +113,13 @@
     SuccessOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 3, offsetRange));
     VerifyOrQuit(offsetRange.GetOffset() == offset + sizeof(Tlv));
     VerifyOrQuit(offsetRange.GetLength() == 1);
-    SuccessOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 1));
+    SuccessOrQuit(tlvInfo.ParseFrom(*message, offset));
+    VerifyOrQuit(tlvInfo.GetType() == 3);
+    VerifyOrQuit(tlvInfo.GetLength() == 1);
+    VerifyOrQuit(!tlvInfo.IsExtended());
+    SuccessOrQuit(tlvInfo.ReadValue(*message, buffer, 1));
     VerifyOrQuit(buffer[0] == 0x0ff);
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 2) == kErrorParse);
+    VerifyOrQuit(tlvInfo.ReadValue(*message, buffer, 2) == kErrorParse);
 
     // Add an extended TLV with type 4 with two byte value and check if we can find it.
 
@@ -123,12 +134,16 @@
     SuccessOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 4, offsetRange));
     VerifyOrQuit(offsetRange.GetOffset() == offset + sizeof(ExtendedTlv));
     VerifyOrQuit(offsetRange.GetLength() == 2);
-    SuccessOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 1));
+    SuccessOrQuit(tlvInfo.ParseFrom(*message, offset));
+    VerifyOrQuit(tlvInfo.GetType() == 4);
+    VerifyOrQuit(tlvInfo.GetLength() == 2);
+    VerifyOrQuit(tlvInfo.IsExtended());
+    SuccessOrQuit(tlvInfo.ReadValue(*message, buffer, 1));
     VerifyOrQuit(buffer[0] == 0x12);
-    SuccessOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 2));
+    SuccessOrQuit(tlvInfo.ReadValue(*message, buffer, 2));
     VerifyOrQuit(buffer[0] == 0x12);
     VerifyOrQuit(buffer[1] == 0x34);
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 3) == kErrorParse);
+    VerifyOrQuit(tlvInfo.ReadValue(*message, buffer, 3) == kErrorParse);
 
     // Add a TLV with missing value.
 
@@ -139,7 +154,7 @@
     SuccessOrQuit(message->Append(tlv));
 
     VerifyOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 5, offsetRange) != kErrorNone);
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 0) == kErrorParse);
+    VerifyOrQuit(tlvInfo.ParseFrom(*message, offset) == kErrorParse);
 
     // Add the missing value.
     SuccessOrQuit(message->Append<uint8_t>(0xaa));
@@ -147,9 +162,13 @@
     SuccessOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 5, offsetRange));
     VerifyOrQuit(offsetRange.GetOffset() == offset + sizeof(Tlv));
     VerifyOrQuit(offsetRange.GetLength() == 1);
-    SuccessOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 1));
+    SuccessOrQuit(tlvInfo.ParseFrom(*message, offset));
+    VerifyOrQuit(tlvInfo.GetType() == 5);
+    VerifyOrQuit(tlvInfo.GetLength() == 1);
+    VerifyOrQuit(!tlvInfo.IsExtended());
+    SuccessOrQuit(tlvInfo.ReadValue(*message, buffer, 1));
     VerifyOrQuit(buffer[0] == 0xaa);
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 2) == kErrorParse);
+    VerifyOrQuit(tlvInfo.ReadValue(*message, buffer, 2) == kErrorParse);
 
     // Add an extended TLV with missing value.
 
@@ -161,17 +180,21 @@
     SuccessOrQuit(message->Append<uint8_t>(0xbb));
 
     VerifyOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 6, offsetRange) != kErrorNone);
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 1) == kErrorParse);
+    VerifyOrQuit(tlvInfo.ParseFrom(*message, offset) == kErrorParse);
 
     SuccessOrQuit(message->Append<uint8_t>(0xcc));
 
     SuccessOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 6, offsetRange) != kErrorNone);
     VerifyOrQuit(offsetRange.GetOffset() == offset + sizeof(ExtendedTlv));
     VerifyOrQuit(offsetRange.GetLength() == 2);
-    SuccessOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 2));
+    SuccessOrQuit(tlvInfo.ParseFrom(*message, offset));
+    VerifyOrQuit(tlvInfo.GetType() == 6);
+    VerifyOrQuit(tlvInfo.GetLength() == 2);
+    VerifyOrQuit(tlvInfo.IsExtended());
+    SuccessOrQuit(tlvInfo.ReadValue(*message, buffer, 2));
     VerifyOrQuit(buffer[0] == 0xbb);
     VerifyOrQuit(buffer[1] == 0xcc);
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 3) == kErrorParse);
+    VerifyOrQuit(tlvInfo.ReadValue(*message, buffer, 3) == kErrorParse);
 
     // Add an extended TLV with overflow length.
 
@@ -183,7 +206,7 @@
     SuccessOrQuit(message->Append<uint8_t>(0x11));
 
     VerifyOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 7, offsetRange) != kErrorNone);
-    VerifyOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 1) == kErrorParse);
+    VerifyOrQuit(tlvInfo.ParseFrom(*message, offset) == kErrorParse);
 
     //- - - - - - - - - - - - - - - - - - - - - -
     // Validate `StartTlv()`, `AdjustTlv()`, `EndTlv()`
@@ -202,7 +225,10 @@
     SuccessOrQuit(Tlv::FindTlvValueOffsetRange(*message, /* aType */ 1, offsetRange));
     VerifyOrQuit(offsetRange.GetOffset() == offset + sizeof(Tlv));
     VerifyOrQuit(offsetRange.GetLength() == 3);
-    SuccessOrQuit(Tlv::ReadTlvValue(*message, offset, buffer, 3));
+    SuccessOrQuit(tlvInfo.ParseFrom(*message, offset));
+    VerifyOrQuit(tlvInfo.GetLength() == 3);
+    VerifyOrQuit(!tlvInfo.IsExtended());
+    SuccessOrQuit(tlvInfo.ReadValue(*message, buffer, 3));
     VerifyOrQuit(buffer[0] == 0xab);
     VerifyOrQuit(buffer[1] == 0xcd);
     VerifyOrQuit(buffer[2] == 0xef);
diff --git a/tests/unit/test_trickle_timer.cpp b/tests/unit/test_trickle_timer.cpp
index 45a0bc7..7343be0 100644
--- a/tests/unit/test_trickle_timer.cpp
+++ b/tests/unit/test_trickle_timer.cpp
@@ -240,6 +240,7 @@
     TimeMilli          fireTime;
     uint32_t           interval;
     uint32_t           t;
+    uint32_t           newIntervalMax;
 
     sInstance = instance;
     TrickleTimerTester::RemoveAll(*instance);
@@ -457,9 +458,11 @@
     AdvanceTime(1999);
     timer.VerifyTimerDidFire();
 
-    timer.SetIntervalMax(t + 1);
+    newIntervalMax = Min(t + 1, interval - 1);
 
-    VerifyOrQuit(timer.GetInterval() == t + 1);
+    timer.SetIntervalMax(newIntervalMax);
+
+    VerifyOrQuit(timer.GetInterval() == newIntervalMax);
     fireTime = timer.GetFireTime();
 
     // Check that new interval is started immediately.
@@ -487,9 +490,11 @@
     AdvanceTime(t);
     timer.VerifyTimerDidFire();
 
-    timer.SetIntervalMax(t + 1);
+    newIntervalMax = Min(t + 1, interval - 1);
 
-    VerifyOrQuit(timer.GetInterval() == t + 1);
+    timer.SetIntervalMax(newIntervalMax);
+
+    VerifyOrQuit(timer.GetInterval() == newIntervalMax);
     fireTime = timer.GetFireTime();
 
     AdvanceTime(1);
diff --git a/tests/unit/test_url.cpp b/tests/unit/test_url.cpp
index 63c2e88..a0f57ad 100644
--- a/tests/unit/test_url.cpp
+++ b/tests/unit/test_url.cpp
@@ -36,7 +36,7 @@
 
 void TestSimple(void)
 {
-    char url[] = "spinel:///dev/ttyUSB0?baudrate=460800";
+    char url[100] = "spinel:///dev/ttyUSB0?baudrate=460800";
     Url  args;
 
     VerifyOrQuit(!args.Init(url));
@@ -53,7 +53,7 @@
 
 void TestSimpleNoQueryString(void)
 {
-    char url[] = "spinel:///dev/ttyUSB0";
+    char url[100] = "spinel:///dev/ttyUSB0";
     Url  args;
 
     VerifyOrQuit(!args.Init(url));
@@ -67,7 +67,7 @@
 
 void TestEmptyValue(void)
 {
-    char        url[] = "spinel:///dev/ttyUSB0?rtscts&baudrate=460800&verbose&verbose&verbose";
+    char        url[100] = "spinel:///dev/ttyUSB0?rtscts&baudrate=460800&verbose&verbose&verbose";
     Url         args;
     const char *arg = nullptr;
 
@@ -85,7 +85,7 @@
 
 void TestMultipleProtocols(void)
 {
-    char url[] = "spinel+spi:///dev/ttyUSB0?baudrate=460800";
+    char url[100] = "spinel+spi:///dev/ttyUSB0?baudrate=460800";
     Url  args;
 
     VerifyOrQuit(!args.Init(url));
@@ -97,7 +97,7 @@
 
 void TestMultipleProtocolsAndDuplicateParameters(void)
 {
-    char        url[] = "spinel+exec:///path/to/ot-rcp?arg=1&arg=arg2&arg=3";
+    char        url[100] = "spinel+exec:///path/to/ot-rcp?arg=1&arg=arg2&arg=3";
     Url         args;
     const char *arg = nullptr;
 
@@ -122,9 +122,9 @@
 
 void TestIntValue(void)
 {
-    char int8url[]  = "spinel:///dev/ttyUSB0?no-reset&val1=1&val2=0x02&val3=-0X03&val4=-4&val5=+5&val6=128&val7=-129";
-    char int16url[] = "spinel:///dev/ttyUSB0?val1=1&val2=0x02&val3=-0X03&val4=-400&val5=+500&val6=32768&val7=-32769";
-    char int32url[] =
+    char int8url[200] = "spinel:///dev/ttyUSB0?no-reset&val1=1&val2=0x02&val3=-0X03&val4=-4&val5=+5&val6=128&val7=-129";
+    char int16url[200] = "spinel:///dev/ttyUSB0?val1=1&val2=0x02&val3=-0X03&val4=-400&val5=+500&val6=32768&val7=-32769";
+    char int32url[200] =
         "spinel:///dev/ttyUSB0?val1=1&val2=0x02&val3=-0X03&val4=-40000&val5=+50000&val6=2147483648&val7=-2147483649";
     Url     args;
     int8_t  int8val;
@@ -195,9 +195,9 @@
 
 void TestUintValue(void)
 {
-    char uint8url[]  = "spinel:///dev/ttyUSB0?no-reset&val1=1&val2=0x02&val3=0X03&val4=-4&val5=+5&val6=256&val7=-1";
-    char uint16url[] = "spinel:///dev/ttyUSB0?val1=1&val2=0x02&val3=0X03&val4=-400&val5=+500&val6=65536&val7=-1";
-    char uint32url[] =
+    char uint8url[200]  = "spinel:///dev/ttyUSB0?no-reset&val1=1&val2=0x02&val3=0X03&val4=-4&val5=+5&val6=256&val7=-1";
+    char uint16url[200] = "spinel:///dev/ttyUSB0?val1=1&val2=0x02&val3=0X03&val4=-400&val5=+500&val6=65536&val7=-1";
+    char uint32url[200] =
         "spinel:///dev/ttyUSB0?val1=1&val2=0x02&val3=0X03&val4=-40000&val5=+70000&val6=4294967296&val7=-1";
     Url      args;
     uint8_t  uint8val;
@@ -264,6 +264,58 @@
     printf("PASS %s\r\n", __func__);
 }
 
+void TestValidate(void)
+{
+    char        url[100] = "spinel:///dev/ttyUSB0?val1=1&val2=0x02&val3=0X03";
+    Url         args;
+    const char *unusedParam = nullptr;
+
+    VerifyOrQuit(!args.Init(url));
+
+    // Test idempotency
+    VerifyOrQuit(args.Validate(&unusedParam) == OT_ERROR_INVALID_ARGS);
+    VerifyOrQuit(!strncmp(unusedParam, "val1=1", 6));
+    VerifyOrQuit(args.Validate(&unusedParam) == OT_ERROR_INVALID_ARGS);
+    VerifyOrQuit(!strncmp(unusedParam, "val1=1", 6));
+
+    VerifyOrQuit(!strcmp(args.GetValue("val1"), "1"));
+    VerifyOrQuit(args.Validate(&unusedParam) == OT_ERROR_INVALID_ARGS);
+    VerifyOrQuit(!strncmp(unusedParam, "val2=0x02", 9));
+
+    VerifyOrQuit(!strcmp(args.GetValue("val2"), "0x02"));
+    VerifyOrQuit(args.Validate(&unusedParam) == OT_ERROR_INVALID_ARGS);
+    VerifyOrQuit(!strncmp(unusedParam, "val3=0X03", 9));
+
+    VerifyOrQuit(!strcmp(args.GetValue("val3"), "0X03"));
+    VerifyOrQuit(args.Validate(&unusedParam) == OT_ERROR_NONE);
+
+    // Test with multiple same-named parameters
+    {
+        char url2[100] = "spinel:///dev/tty?arg=1&arg=2&val=3";
+        Url  args2;
+
+        VerifyOrQuit(!args2.Init(url2));
+        VerifyOrQuit(args2.Validate(&unusedParam) == OT_ERROR_INVALID_ARGS);
+        VerifyOrQuit(!strncmp(unusedParam, "arg=1", 5));
+
+        const char *val = args2.GetValue("arg");
+        VerifyOrQuit(!strcmp(val, "1"));
+        VerifyOrQuit(args2.Validate(&unusedParam) == OT_ERROR_INVALID_ARGS);
+        VerifyOrQuit(!strncmp(unusedParam, "arg=2", 5));
+
+        val = args2.GetValue("arg", val);
+        VerifyOrQuit(!strcmp(val, "2"));
+        VerifyOrQuit(args2.Validate(&unusedParam) == OT_ERROR_INVALID_ARGS);
+        VerifyOrQuit(!strncmp(unusedParam, "val=3", 5));
+
+        val = args2.GetValue("val");
+        VerifyOrQuit(!strcmp(val, "3"));
+        VerifyOrQuit(args2.Validate(&unusedParam) == OT_ERROR_NONE);
+    }
+
+    printf("PASS %s\r\n", __func__);
+}
+
 } // namespace Url
 } // namespace ot
 
@@ -276,6 +328,7 @@
     ot::Url::TestMultipleProtocolsAndDuplicateParameters();
     ot::Url::TestIntValue();
     ot::Url::TestUintValue();
+    ot::Url::TestValidate();
 
     return 0;
 }
diff --git a/tests/unit/test_util.cpp b/tests/unit/test_util.cpp
index cfa6400..051f5ee 100644
--- a/tests/unit/test_util.cpp
+++ b/tests/unit/test_util.cpp
@@ -32,10 +32,7 @@
 
 void DumpBuffer(const char *aTextMessage, const uint8_t *aBuffer, uint16_t aBufferLength)
 {
-    enum
-    {
-        kBytesPerLine = 16, // Number of bytes per line.
-    };
+    static constexpr uint16_t kBytesPerLine = 16; // Number of bytes per line.
 
     char     charBuff[kBytesPerLine + 1];
     uint16_t counter;
diff --git a/tests/unit/test_vendor_oui.cpp b/tests/unit/test_vendor_oui.cpp
new file mode 100644
index 0000000..b1e68f8
--- /dev/null
+++ b/tests/unit/test_vendor_oui.cpp
@@ -0,0 +1,207 @@
+/*
+ *  Copyright (c) 2026, The OpenThread Authors.
+ *  All rights reserved.
+ *
+ *  Redistribution and use in source and binary forms, with or without
+ *  modification, are permitted provided that the following conditions are met:
+ *  1. Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *  2. Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ *  3. Neither the name of the copyright holder nor the
+ *     names of its contributors may be used to endorse or promote products
+ *     derived from this software without specific prior written permission.
+ *
+ *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ *  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ *  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ *  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+ *  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ *  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ *  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ *  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ *  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ *  POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#include "test_platform.h"
+#include "test_util.h"
+
+#include <openthread/config.h>
+
+#include "thread/vendor_info.hpp"
+
+namespace ot {
+
+class UnitTester
+{
+public:
+    template <uint64_t kConfig>
+    static void TestParserCase(bool aIsSpecified, bool aIsValid, uint8_t aBitLength, const uint8_t aExpectedBytes[5])
+    {
+        using Parser = VendorInfo::OuiParser<kConfig>;
+
+        VerifyOrQuit(Parser::IsSpecified() == aIsSpecified, "IsSpecified() failed");
+        VerifyOrQuit(Parser::IsValid() == aIsValid, "IsValid() failed");
+
+        if (aIsValid && aIsSpecified)
+        {
+            VerifyOrQuit(Parser::GetBitLength() == aBitLength, "GetBitLength() failed");
+
+            VerifyOrQuit(Parser::GetByte0() == aExpectedBytes[0]);
+            VerifyOrQuit(Parser::GetByte1() == aExpectedBytes[1]);
+            VerifyOrQuit(Parser::GetByte2() == aExpectedBytes[2]);
+            VerifyOrQuit(Parser::GetByte3() == aExpectedBytes[3]);
+            VerifyOrQuit(Parser::GetByte4() == aExpectedBytes[4]);
+        }
+    }
+
+    static void TestCompileTimeOuiParser(void)
+    {
+        printf("TestCompileTimeOuiParser\n");
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        // 1. Unspecified cases
+        {
+            uint8_t expected[5] = {0};
+            TestParserCase<0xffffffffULL>(/* aIsSpecified */ false, /* aIsValid */ true, 0, expected);
+        }
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        // 2. Implicit 24-bit (legacy OUI format)
+        {
+            uint8_t expected[5] = {0x64, 0x16, 0x66, 0, 0};
+            TestParserCase<0x641666>(/* aIsSpecified */ true, /* aIsValid */ true, 24, expected);
+        }
+
+        {
+            uint8_t expected[5] = {0x00, 0x00, 0x01, 0, 0};
+            TestParserCase<0x000001>(/* aIsSpecified */ true, /* aIsValid */ true, 24, expected);
+        }
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        // 3. Explicit 24-bit OUI (length in bits 40-47, bytes in bits 0-39)
+        {
+            uint8_t expected[5] = {0x00, 0x1a, 0x2b, 0, 0};
+            TestParserCase<0x18001a2b0000ULL>(/* aIsSpecified */ true, /* aIsValid */ true, 24, expected);
+        }
+
+        // Invalid: non-zero in byte index 3
+        {
+            uint8_t expected[5] = {0};
+            TestParserCase<0x18001a2b0300ULL>(/* aIsSpecified */ true, /* aIsValid */ false, 24, expected);
+        }
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        // 4. Explicit 28-bit OUI
+        {
+            uint8_t expected[5] = {0x00, 0x1a, 0x2b, 0x30, 0};
+            TestParserCase<0x1c001a2b3000ULL>(/* aIsSpecified */ true, /* aIsValid */ true, 28, expected);
+        }
+
+        // Invalid: non-zero in byte index 4
+        {
+            uint8_t expected[5] = {0};
+            TestParserCase<0x1c001a2b3005ULL>(/* aIsSpecified */ true, /* aIsValid */ false, 28, expected);
+        }
+
+        // Invalid: non-zero in lower nibble of byte index 3
+        {
+            uint8_t expected[5] = {0};
+            TestParserCase<0x1c001a2b3500ULL>(/* aIsSpecified */ true, /* aIsValid */ false, 28, expected);
+        }
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        // 5. Explicit 36-bit OUI
+        {
+            uint8_t expected[5] = {0x00, 0x1a, 0x2b, 0x3c, 0x40};
+            TestParserCase<0x24001a2b3c40ULL>(/* aIsSpecified */ true, /* aIsValid */ true, 36, expected);
+        }
+        // Invalid: non-zero in lower nibble of byte index 4
+        {
+            uint8_t expected[5] = {0};
+            TestParserCase<0x24001a2b3c43ULL>(/* aIsSpecified */ true, /* aIsValid */ false, 36, expected);
+        }
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        // 6. Invalid Bit Length (e.g. 32 bits)
+        {
+            uint8_t expected[5] = {0};
+            TestParserCase<0x20001a2b3c40ULL>(/* aIsSpecified */ true, /* aIsValid */ false, 32, expected);
+        }
+
+        //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+        // 7. Value out of valid range (exceeds 6 bytes)
+        {
+            uint8_t expected[5] = {0};
+            TestParserCase<0x1c11001a2b3000ULL>(/* aIsSpecified */ true, /* aIsValid */ false, 28, expected);
+        }
+    }
+
+    static void TestOuiClass(void)
+    {
+        VendorInfo::Oui oui;
+        VendorInfo::Oui oui1;
+        VendorInfo::Oui oui2;
+        VendorInfo::Oui oui3;
+        uint8_t         bytes24[] = {0x64, 0x16, 0x66};
+        uint8_t         bytes28[] = {0x00, 0x1a, 0x2b, 0x35};
+        uint8_t         bytes36[] = {0x00, 0x1a, 0x2b, 0x3c, 0x42};
+
+        printf("TestOuiClass\n");
+
+        // Test ToString() on unspecified OUI
+        oui.Clear();
+        VerifyOrQuit(StringMatch(oui.ToString().AsCString(), "unspecified"));
+
+        // Test `SetFrom()` with bytes (24-bit)
+        SuccessOrQuit(oui.SetFrom(bytes24, sizeof(bytes24)));
+        VerifyOrQuit(oui.IsValid());
+        VerifyOrQuit(oui.GetBitLength() == 24);
+        VerifyOrQuit(oui.GetSize() == 3);
+        VerifyOrQuit(oui.GetAsOui24() == 0x641666);
+        VerifyOrQuit(StringMatch(oui.ToString().AsCString(), "64-16-66"));
+
+        // Test `SetFrom()` with non-tidy 28-bit
+        SuccessOrQuit(oui.SetFrom(bytes28, sizeof(bytes28)));
+        VerifyOrQuit(oui.IsValid());
+        VerifyOrQuit(oui.GetBitLength() == 28);
+        VerifyOrQuit(oui.GetSize() == 4);
+        VerifyOrQuit(oui.GetBytes()[3] == 0x30);
+        VerifyOrQuit(StringMatch(oui.ToString().AsCString(), "00-1A-2B-3"));
+
+        // Test `SetFrom()` with non-tidy 36-bit
+        SuccessOrQuit(oui.SetFrom(bytes36, sizeof(bytes36)));
+        VerifyOrQuit(oui.IsValid());
+        VerifyOrQuit(oui.GetBitLength() == 36);
+        VerifyOrQuit(oui.GetSize() == 5);
+        VerifyOrQuit(oui.GetBytes()[4] == 0x40);
+        VerifyOrQuit(StringMatch(oui.ToString().AsCString(), "00-1A-2B-3C-4"));
+
+        // Test `operator==` (with tidy check on right-hand side)
+        SuccessOrQuit(oui1.SetFrom(bytes28, sizeof(bytes28))); // will be tidied
+
+        oui2.Clear();
+        oui2.mBitLength = 28;
+        memcpy(oui2.mBytes, bytes28, sizeof(bytes28)); // manually write untidied bytes
+
+        VerifyOrQuit(oui1 == oui2);
+
+        // Test `SetFrom(const Oui &)` tidying
+        oui3.SetFrom(oui2);
+        VerifyOrQuit(oui3.GetBytes()[3] == 0x30, "SetFrom(const Oui&) failed to tidy");
+    }
+};
+
+} // namespace ot
+
+int main(void)
+{
+    ot::UnitTester::TestCompileTimeOuiParser();
+    ot::UnitTester::TestOuiClass();
+
+    printf("All tests passed\n");
+    return 0;
+}
diff --git a/third_party/tcplp/lib/cbuf.c b/third_party/tcplp/lib/cbuf.c
index 0900010..5bfaffd 100644
--- a/third_party/tcplp/lib/cbuf.c
+++ b/third_party/tcplp/lib/cbuf.c
@@ -172,7 +172,9 @@
     if (used_space < numbytes) {
         numbytes = used_space;
     }
-    chdr->r_index = (chdr->r_index + numbytes) % chdr->size;
+    if (chdr->size > 0) {
+        chdr->r_index = (chdr->r_index + numbytes) % chdr->size;
+    }
     chdr->used -= numbytes;
     return numbytes;
 }
@@ -288,7 +290,7 @@
     size_t free_space = cbuf_free_space(chdr);
     size_t start_index;
     size_t bytes_to_end;
-    if (offset > free_space) {
+    if (chdr->size == 0 || offset > free_space) {
         return 0;
     } else if (offset + numbytes > free_space) {
         numbytes = free_space - offset;
@@ -335,8 +337,14 @@
 }
 
 size_t cbuf_reass_count_set(struct cbufhead* chdr, size_t offset, uint8_t* bitmap, size_t limit) {
-    size_t bitmap_size = BITS_TO_BYTES(chdr->size);
+    size_t bitmap_size;
     size_t until_end;
+
+    if (chdr->size == 0) {
+        return 0;
+    }
+
+    bitmap_size = BITS_TO_BYTES(chdr->size);
     offset = (cbuf_get_w_index(chdr) + offset) % chdr->size;
     until_end = bmp_countset(bitmap, bitmap_size, offset, limit);
     if (until_end >= limit || until_end < (chdr->size - offset)) {
@@ -349,8 +357,15 @@
 }
 
 int cbuf_reass_within_offset(struct cbufhead* chdr, size_t offset, size_t index) {
-    size_t range_start = cbuf_get_w_index(chdr);
-    size_t range_end = (range_start + offset) % chdr->size;
+    size_t range_start;
+    size_t range_end;
+
+    if (chdr->size == 0) {
+        return 0;
+    }
+
+    range_start = cbuf_get_w_index(chdr);
+    range_end = (range_start + offset) % chdr->size;
     if (range_end >= range_start) {
         return index >= range_start && index < range_end;
     } else {
diff --git a/tools/harness-simulation/posix/config.yml b/tools/harness-simulation/posix/config.yml
index 11b29bc..57a80e0 100644
--- a/tools/harness-simulation/posix/config.yml
+++ b/tools/harness-simulation/posix/config.yml
@@ -20,7 +20,6 @@
     - "-DOPENTHREAD_CONFIG_IP6_MAX_EXT_MCAST_ADDRS=8"
     options:
     - "-DOT_REFERENCE_DEVICE=ON"
-    - "-DOT_DUA=ON"
     - "-DOT_MLR=ON"
     - "-DOT_COMMISSIONER=ON"
     - "-DOT_JOINER=ON"
@@ -35,7 +34,6 @@
     - "-DOPENTHREAD_CONFIG_IP6_MAX_EXT_MCAST_ADDRS=8"
     options:
     - "-DOT_REFERENCE_DEVICE=ON"
-    - "-DOT_DUA=ON"
     - "-DOT_MLR=ON"
     - "-DOT_COMMISSIONER=ON"
     - "-DOT_JOINER=ON"
@@ -56,8 +54,6 @@
     - REST_API=0
     - OT_COMMISSIONER=1
     options:
-    - "-DOTBR_DUA_ROUTING=ON"
-    - "-DOT_DUA=ON"
     - "-DOT_MLR=ON"
     rcp_subpath: build/ot12/simulation
     rcp_options:
@@ -75,8 +71,6 @@
     - REST_API=0
     - EXTERNAL_COMMISSIONER=1
     options:
-    - "-DOTBR_DUA_ROUTING=ON"
-    - "-DOT_DUA=ON"
     - "-DOT_MLR=ON"
     rcp_subpath: build/ot13/simulation
     rcp_options:
diff --git a/tools/harness-thci/OpenThread.py b/tools/harness-thci/OpenThread.py
index 476d099..3dc751e 100644
--- a/tools/harness-thci/OpenThread.py
+++ b/tools/harness-thci/OpenThread.py
@@ -632,9 +632,6 @@
             self.__configBbrDataset(SeqNum=self.bbrSeqNum,
                                     MlrTimeout=self.bbrMlrTimeout,
                                     ReRegDelay=self.bbrReRegDelay)
-            # Add default domain prefix is not configured otherwise
-            if self.__useDefaultDomainPrefix:
-                self.__addDefaultDomainPrefix()
 
         self.__executeCommand('ifconfig up')
         self.__executeCommand('thread start')
@@ -1473,8 +1470,6 @@
         # to default when joining network
         self.hasSetChannel = False
         self.IsBeingTestedAsCommercialBBR = False
-        # indicate whether the default domain prefix is used.
-        self.__useDefaultDomainPrefix = True
         self.__isUdpOpened = False
         self.IsHost = False
 
@@ -1646,16 +1641,9 @@
         """
         assert (ipaddress.IPv6Network(P_Prefix.decode()))
 
-        # turn off default domain prefix if configBorderRouter is called before joining network
-        if P_dp == 0 and not self.__isOpenThreadRunning():
-            self.__useDefaultDomainPrefix = False
-
         parameter = ''
         prf = ''
 
-        if P_dp:
-            P_slaac_preferred = 1
-
         if P_slaac_preferred == 1:
             parameter += 'p'
             parameter += 'a'
@@ -1672,10 +1660,6 @@
         if P_on_mesh == 1:
             parameter += 'o'
 
-        if P_dp == 1:
-            assert P_slaac_preferred and P_default and P_on_mesh and P_stable
-            parameter += 'D'
-
         if P_preference == 1:
             prf = 'high'
         elif P_preference == 0:
@@ -3086,37 +3070,6 @@
         cmd = 'childip max %d' % int(num)
         self.__executeCommand(cmd)
 
-    @API
-    def config_next_dua_status_rsp(self, mliid, status_code):
-        if status_code >= 400:
-            # map status_code to correct COAP response code
-            a, b = divmod(status_code, 100)
-            status_code = ((a & 0x7) << 5) + (b & 0x1f)
-
-        cmd = 'bbr mgmt dua %d' % status_code
-
-        if mliid is not None:
-            mliid = mliid.replace(':', '')
-            cmd += ' %s' % mliid
-
-        self.__executeCommand(cmd)
-
-    @API
-    def getDUA(self):
-        dua = self.getGUA('fd00:7d03')
-        return dua
-
-    def __addDefaultDomainPrefix(self):
-        self.configBorderRouter(P_dp=1, P_stable=1, P_on_mesh=1, P_default=1)
-
-    def __setDUA(self, sDua):
-        """specify the DUA before Thread Starts."""
-        if isinstance(sDua, str):
-            sDua = sDua.decode('utf8')
-        iid = ipaddress.IPv6Address(sDua).packed[-8:]
-        cmd = 'dua iid %s' % ''.join('%02x' % ord(b) for b in iid)
-        return self.__executeCommand(cmd)[-1] == 'Done'
-
     def __getMlIid(self):
         """get the Mesh Local IID."""
         # getULA64() would return the full string representation
@@ -3131,10 +3084,6 @@
         self.__executeCommand(cmd)
 
     @API
-    def registerDUA(self, sAddr=''):
-        self.__setDUA(sAddr)
-
-    @API
     def config_next_mlr_status_rsp(self, status_code):
         cmd = 'bbr mgmt mlr response %d' % status_code
         return self.__executeCommand(cmd)[-1] == 'Done'
@@ -3185,8 +3134,7 @@
     @API
     def migrateNetwork(self, channel=None, net_name=None):
         """migrate to another Thread Partition 'net_name' (could be None)
-            on specified 'channel'. Make sure same Mesh Local IID and DUA
-            after migration for DUA-TC-06/06b (DEV-1923)
+            on specified 'channel'.
         """
         if channel is None:
             raise Exception('channel None')
@@ -3197,7 +3145,6 @@
         print('new partition %s on channel %d' % (net_name, channel))
 
         mliid = self.__getMlIid()
-        dua = self.getDUA()
         self.reset()
         deviceRole = self.deviceRole
         self.setDefaultValues()
@@ -3205,7 +3152,6 @@
         if net_name is not None:
             self.setNetworkName(net_name)
         self.__setMlIid(mliid)
-        self.__setDUA(dua)
         return self.joinNetwork(deviceRole)
 
     @API
diff --git a/tools/harness-thci/OpenThread_BR.py b/tools/harness-thci/OpenThread_BR.py
index c22557a..b096509 100644
--- a/tools/harness-thci/OpenThread_BR.py
+++ b/tools/harness-thci/OpenThread_BR.py
@@ -357,14 +357,11 @@
         self.bash(cmd)
 
     @API
-    def setupHost(self, setDp=False, setDua=False):
+    def setupHost(self, setDp=False):
         self.IsHost = True
 
         self.bash('ip -6 addr add 910b::1 dev %s' % self.backboneNetif)
 
-        if setDua:
-            self.bash('ip -6 addr add fd00:7d03:7d03:7d03::1 dev %s' % self.backboneNetif)
-
         self.__startRadvdService(setDp)
 
     def _deviceEscapeEscapable(self, string):
diff --git a/tools/otci/otci/otci.py b/tools/otci/otci/otci.py
index 1a3b432..79ceee3 100644
--- a/tools/otci/otci/otci.py
+++ b/tools/otci/otci/otci.py
@@ -2436,14 +2436,6 @@
     #
 
     # TODO: bbr mgmt ...
-    def set_bbr_dua_response_status(self, status: int, mliid: Optional[str] = None):
-        """Set Backbone Router Data Unicast Address Response status/coap-code.
-
-        Only for testing/reference devices
-        """
-        _mliid = mliid if mliid is not None else ""
-        self.execute_command(f'bbr mgmt dua {status} {_mliid}')
-
     def set_bbr_mlr_response_status(self, status: int):
         """Set Backbone Router Multicast Listener Response status."""
         self.execute_command(f'bbr mgmt mlr response {status}')
@@ -2583,7 +2575,7 @@
         return listeners
 
     #
-    # Thread 1.2 and DUA/MLR utilities
+    # Thread 1.2 and MLR utilities
     #
 
     def get_domain_name(self) -> str:
@@ -2594,23 +2586,6 @@
         """Set the Thread Domain Name for Thread 1.2 device."""
         self.execute_command(f'domainname {self.__escape_escapable(name)}')
 
-    def get_dua_iid(self) -> str:
-        """Get the DUA IID for Thread 1.2 device."""
-        raw_iid = self.execute_command('dua iid')
-        if raw_iid:
-            return self.__parse_iid(raw_iid)
-        else:
-            return ''
-
-    def set_dua_iid(self, iid: str):
-        """Set the DUA IID for Thread 1.2 device."""
-        self.__validate_iid(iid)
-        self.execute_command(f'dua iid {iid}')
-
-    def clear_dua_iid(self):
-        """Clear the DUA IID for Thread 1.2 device."""
-        self.execute_command('dua iid clear')
-
     # TODO: mlr reg <ipaddr> ... [timeout]
 
     #
@@ -3292,7 +3267,7 @@
         self.execute_command('diag rawpowersetting disable')
 
     def is_command_supported(self, command: str) -> bool:
-        """Check whether the the given command is supported by the device."""
+        """Check whether the given command is supported by the device."""
         output = self.__otcmd.execute_command(command, timeout=10)
 
         if re.match(r"Error \d+: \w*", output[-1]):
diff --git a/tools/otci/tests/test_otci.py b/tools/otci/tests/test_otci.py
index 5bdbb9d..74504e1 100644
--- a/tools/otci/tests/test_otci.py
+++ b/tools/otci/tests/test_otci.py
@@ -135,10 +135,6 @@
         self.assertEqual('fd00:dba::/64', leader.get_mesh_local_prefix())
         leader.set_mesh_local_prefix(TEST_MESH_LOCAL_PREFIX + '/64')
         leader.set_ml_iid('b1a5ed57a71571c5')
-        leader.set_dua_iid('ad4a011dad4a011d')
-        self.assertEqual('ad4a011dad4a011d', leader.get_dua_iid())
-        leader.clear_dua_iid()
-        self.assertEqual('', leader.get_dua_iid())
 
         self.assertFalse(leader.get_ifconfig_state())
         # ifconfig up
diff --git a/tools/spi-hdlc-adapter/spi-hdlc-adapter.c b/tools/spi-hdlc-adapter/spi-hdlc-adapter.c
index 48c256c..5cb2430 100644
--- a/tools/spi-hdlc-adapter/spi-hdlc-adapter.c
+++ b/tools/spi-hdlc-adapter/spi-hdlc-adapter.c
@@ -92,6 +92,7 @@
 
 #define MAX_FRAME_SIZE 2048
 #define HEADER_LEN 5
+#define HDLC_MAX_FRAME_SIZE ((MAX_FRAME_SIZE - HEADER_LEN) * 2 + 5)
 #define SPI_HEADER_RESET_FLAG 0x80
 #define SPI_HEADER_CRC_FLAG 0x40
 #define SPI_HEADER_PATTERN_VALUE 0x02
@@ -531,8 +532,9 @@
     spi_header_set_accept_len(sSpiTxFrameBuffer, 0);
     spi_header_set_data_len(sSpiTxFrameBuffer, 0);
 
-    // Sanity check.
-    if (slave_data_len > MAX_FRAME_SIZE)
+    // Sanity check. The header `data_len` carries the payload length only
+    // (it excludes HEADER_LEN), so the largest valid value is the MTU.
+    if (slave_data_len > MAX_FRAME_SIZE - HEADER_LEN)
     {
         slave_data_len = 0;
     }
@@ -630,8 +632,8 @@
     slave_max_rx   = spi_header_get_accept_len(spiRxFrameBuffer);
     slave_data_len = spi_header_get_data_len(spiRxFrameBuffer);
 
-    if (((slave_header & SPI_HEADER_PATTERN_MASK) != SPI_HEADER_PATTERN_VALUE) || (slave_max_rx > MAX_FRAME_SIZE) ||
-        (slave_data_len > MAX_FRAME_SIZE))
+    if (((slave_header & SPI_HEADER_PATTERN_MASK) != SPI_HEADER_PATTERN_VALUE) ||
+        (slave_max_rx > MAX_FRAME_SIZE - HEADER_LEN) || (slave_data_len > MAX_FRAME_SIZE - HEADER_LEN))
     {
         sSpiGarbageFrameCount++;
         sSpiTxRefusedCount++;
@@ -802,7 +804,7 @@
 {
     int             ret              = 0;
     const uint8_t  *spiRxFrameBuffer = get_real_rx_frame_start();
-    static uint8_t  escaped_frame_buffer[MAX_FRAME_SIZE * 2];
+    static uint8_t  escaped_frame_buffer[HDLC_MAX_FRAME_SIZE];
     static uint16_t unescaped_frame_len;
     static uint16_t escaped_frame_len;
     static uint16_t escaped_frame_sent;
diff --git a/tools/tcat_ble_client/GENERATING_CERTIFICATES.md b/tools/tcat_ble_client/GENERATING_CERTIFICATES.md
index 973d17e..2ae6651 100644
--- a/tools/tcat_ble_client/GENERATING_CERTIFICATES.md
+++ b/tools/tcat_ble_client/GENERATING_CERTIFICATES.md
@@ -6,13 +6,19 @@
 
 ## Extensions
 
-Extensions were introduced in version 3 of the X.509 standard for certificates. They allow certificates to be customised to applications by supporting the addition of arbitrary fields in the certificate. Each extension, identified by its OID (Object Identifier), is marked as "Critical" or "Non-Critical", and includes the extension-specific data.
+Extensions were introduced in version 3 of the X.509 standard for certificates. They allow certificates to be customised to applications by supporting the addition of arbitrary fields in the certificate. Each extension, identified by its OID (Object Identifier), is marked as "Critical" or "Non-Critical", and includes the extension-specific data. See the Thread specification for more details on the defined values in the Thread-specific OID namespace `1.3.6.1.4.1.44970`.
+
+## Certificate Validity
+
+The TCAT Device certificates built into devices apply a validity policy of "forever valid", in this case encoded by using a year 2999. This mimics the validity policy for IDevID certificates that use the year 9999. The Commissioner certificates however are usually expected to be of a short lifetime, e.g. project-specific, and then would require interaction with the device vendor to renew. In this repo, 2 weeks validity is used as an example.
 
 ## Certificates generation (by script)
 
 The directory `auth-generate` contains example scripts and a Makefile to generate TCAT Commissioner certificates and TCAT Device certificates. The scripts can also handle multiple CAs, and provide the most detailed view on how to generate these certificates.
 
-To generate all certificates:
+NOTE: by default, the Makefile defines a CA called 'TcatCertCa' and expects this CA certificate and private key (for signing) to be present in the `auth-generate/ca` directory. Please replace this by the name of your own CA certificate and CA private key and add the corresponding files in the `ca` directory using the same naming scheme. The CA named 'ca' for which a private key is present in the directory, is just an example and not used for Thread certification. This example CA is not the same CA used for the TCAT Commissioner and Device identities in the `auth` and `auth-cert` directories! The 'TcatCertCa' is privately maintained by Thread Group and therefore a private key is not included for this CA.
+
+Then, to generate all certificates except for CA certificates:
 
 ```
 cd auth-generate
@@ -21,9 +27,7 @@
 
 This will create an `output` directory with subdirectories for each of the created identities. Each subdirectory can be used as a value for the BBTC Commissioner `--cert_path` argument, if needed.
 
-NOTE: the directory `auth-generate/ca` contains an example CA certificate and private key (for signing). Other CAs can be added in here. This CA is not the same CA used for the TCAT Commissioner and Device identities in the `auth` and `auth-cert` directories! The CA for the latter is privately maintained by Thread Group.
-
-## Certificates generation (manually)
+## Certificates generation (manually - not recommended)
 
 Thread TCAT uses Elliptic Curve Cryptography (ECC), so we use the `ecparam` `openssl` argument to generate the keys.
 
@@ -85,9 +89,9 @@
 openssl asn1parse -inform PEM -in commissioner_cert.pem
 ```
 
-## Configurations
+### Configurations
 
-file: `commissioner.cnf` (line `1.3.6.1.4.1.44970.3 = DER:21:01:01:01:01` specifies permissions (all)) See scripts in `auth-generate` directory for more details.
+In above examples, the file `commissioner.cnf` is used to specify details and X509 V3 extensions when generating the certificate. See below for an example of such file. Specifically, the line `1.3.6.1.4.1.44970.3 = DER:21:01:01:01:01` specifies TCAT permissions ("all allowed") for a TCAT Commissioner. See the scripts in `auth-generate` directory for more details and more realistic examples and see the Thread specification for more details on the defined values in the OID namespace `1.3.6.1.4.1.44970`.
 
 ```
 [ req ]
diff --git a/tools/tcat_ble_client/auth-cert/CommCert4/commissioner_cert.pem b/tools/tcat_ble_client/auth-cert/CommCert4/commissioner_cert.pem
index 7003eba..247d586 100644
--- a/tools/tcat_ble_client/auth-cert/CommCert4/commissioner_cert.pem
+++ b/tools/tcat_ble_client/auth-cert/CommCert4/commissioner_cert.pem
@@ -1,14 +1,14 @@
 -----BEGIN CERTIFICATE-----
-MIICIzCCAcigAwIBAgIDDhqGMAoGCCqGSM49BAMCMGYxHTAbBgNVBAMMFFRDQVQg
-RXhhbXBsZSBDQSAnY2EnMRQwEgYDVQQKDAtFeGFtcGxlIEluYzEVMBMGA1UEBwwM
-RXhhbXBsZSBDaXR5MQswCQYDVQQIDAJDQTELMAkGA1UEBhMCVVMwHhcNMjUxMTIz
-MjIwNDU4WhcNMjUxMjA3MjIwNDU4WjA6MR8wHQYDVQQDDBZUQ0FUIEV4YW1wbGUg
-Q29tbUNlcnQ0MRcwFQYDVQQFEw4zNTIzLTE1NDMtMDAwNDBZMBMGByqGSM49AgEG
-CCqGSM49AwEHA0IABICIKe55HN+sUzyr9Jckp0GAssP0gx4wyOYkQ7pBWBFQcEUN
-QiNtBw2d4qn/zMV2W91HC2eGmMpUhyXqvCOY1/OjgZAwgY0wHwYDVR0jBBgwFoAU
-4EynoSw9eDKZEVPkums2IWLAJCowGgYJKwYBBAGC3yoBBA0WC090aGVyRG9tYWlu
-MBQGCSsGAQQBgt8qAwQHBAUhIQUJETAfBgkrBgEEAYLfKgQEEgwQT3RoZXJUaHJl
-YWQtYzY0ZTAXBgkrBgEEAYLfKgUECgQI7xOYwv1QS2gwCgYIKoZIzj0EAwIDSQAw
-RgIhAJdvb/ETZqSrpZTYQ2GzTLJGVBNe018CT+dlwSdeHjQaAiEAqLgbc9r6PV+j
-91r1jtaSP+StXf2ZAkylcDg+tEGoojk=
+MIICLDCCAdOgAwIBAgIDDhqGMAoGCCqGSM49BAMCMHExJjAkBgNVBAMMHVRocmVh
+ZCBDZXJ0aWZpY2F0aW9uIERldmljZUNBMRkwFwYDVQQKDBBUaHJlYWQgR3JvdXAg
+SW5jMRIwEAYDVQQHDAlTYW4gUmFtb24xCzAJBgNVBAgMAkNBMQswCQYDVQQGEwJV
+UzAeFw0yNjA0MTAwOTMzNTlaFw0yNjA0MjQwOTMzNTlaMDoxHzAdBgNVBAMMFlRD
+QVQgRXhhbXBsZSBDb21tQ2VydDQxFzAVBgNVBAUTDjM1MjMtMTU0My0wMDA0MFkw
+EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgIgp7nkc36xTPKv0lySnQYCyw/SDHjDI
+5iRDukFYEVBwRQ1CI20HDZ3iqf/MxXZb3UcLZ4aYylSHJeq8I5jX86OBkDCBjTAf
+BgNVHSMEGDAWgBRfqxspaIih1LQxqIZh5+dmWe34GTAaBgkrBgEEAYLfKgEEDRYL
+T3RoZXJEb21haW4wFAYJKwYBBAGC3yoDBAcEBSEhBQkRMB8GCSsGAQQBgt8qBAQS
+DBBPdGhlclRocmVhZC1jNjRlMBcGCSsGAQQBgt8qBQQKBAjvE5jC/VBLaDAKBggq
+hkjOPQQDAgNHADBEAiBFwBKxMFLgZOH0Q73E30fdtAJUmpVloSYOZC2Kdew2vwIg
+QFMnNeS0ZBk9giW9Ovc72aS9Ttp0f8iWvj04b5xF7OY=
 -----END CERTIFICATE-----
diff --git a/tools/tcat_ble_client/auth-generate/Makefile b/tools/tcat_ble_client/auth-generate/Makefile
index 6760a98..80ae4b0 100644
--- a/tools/tcat_ble_client/auth-generate/Makefile
+++ b/tools/tcat_ble_client/auth-generate/Makefile
@@ -29,20 +29,62 @@
 # Makefile for creating TCAT example certificates (except CA).
 
 # Select here which named CAs from the 'ca' directory are used for signing.
-#ca = TcatCertCa # for example
-otherCa = ca
-ca = ca
+# NOTE: private CA, so its private key is not present in this repo.
+ca := TcatCertCa
+otherCa := ca
 
-all: DeviceCert1 DeviceCert2 CommCert1 CommCert2 CommCert3 CommCert4
+.PHONY: all check-ca-key check-other-ca-key test clean
 
-DeviceCert1 DeviceCert2: ext/DeviceCert1.ext ext/DeviceCert2.ext
+all: DeviceCert1 DeviceCert2 CommCert1 CommCert2 CommCert3 CommCert4 test
+
+check-ca-key:
+	@if [ ! -f "ca/$(strip $(ca))_key.pem" ]; then \
+	    echo "ERROR: CA private key 'ca/$(strip $(ca))_key.pem' not found."; \
+	    echo "  The default CA '$(strip $(ca))' is privately maintained by Thread Group"; \
+	    echo "  and its private key is intentionally not included in this repository."; \
+	    echo "  To generate certificates, update var 'ca' in the Makefile to your own CA name"; \
+	    echo "  and place the CA certificate (<name>_cert.pem) and private key (<name>_key.pem)"; \
+	    echo "  in the 'ca' directory. See ../GENERATING_CERTIFICATES.md for details."; \
+	    exit 1; \
+	fi
+
+check-other-ca-key:
+	@if [ ! -f "ca/$(strip $(otherCa))_key.pem" ]; then \
+	    echo "ERROR: CA private key 'ca/$(strip $(otherCa))_key.pem' not found."; \
+	    echo "  Update var 'otherCa' in the Makefile to a CA whose private key is present"; \
+	    echo "  in the 'ca' directory, or add the key file (<name>_key.pem) there."; \
+	    echo "  See ../GENERATING_CERTIFICATES.md for details."; \
+	    exit 1; \
+	fi
+
+DeviceCert1 DeviceCert2: check-ca-key ext/DeviceCert1.ext ext/DeviceCert2.ext
 	./create-cert-tcat-device.sh $@ $(ca)
 
-CommCert1 CommCert2 CommCert4: ext/CommCert1.ext ext/CommCert2.ext ext/CommCert4.ext
+CommCert1 CommCert2 CommCert4: check-ca-key ext/CommCert1.ext ext/CommCert2.ext ext/CommCert4.ext
 	./create-cert-tcat-commissioner.sh $@ $(ca)
 
-CommCert3: ext/CommCert3.ext
+CommCert3: check-other-ca-key ext/CommCert3.ext
 	./create-cert-tcat-commissioner.sh $@ $(otherCa)
 
+test:
+	@echo "Testing certificate chains..."
+	@for name in CommCert1 CommCert2 CommCert4 DeviceCert1 DeviceCert2; do \
+	    dir="output/$$name"; \
+	    if [ ! -d "$$dir" ]; then \
+	        echo "SKIP $$name: output directory not found (run 'make' first)"; \
+	        continue; \
+	    fi; \
+	    openssl verify -CAfile "ca/$(strip $(ca))_cert.pem" "$$dir/commissioner_cert.pem" || exit 1; \
+	    if [ -f "$$dir/device_cert.pem" ]; then \
+	        openssl verify -CAfile "ca/$(strip $(ca))_cert.pem" "$$dir/device_cert.pem" || exit 1; \
+	    fi; \
+	done
+	@if [ -d "output/CommCert3" ]; then \
+	    openssl verify -CAfile "ca/$(strip $(otherCa))_cert.pem" "output/CommCert3/commissioner_cert.pem" || exit 1; \
+	else \
+	    echo "SKIP CommCert3: output directory not found (run 'make' first)"; \
+	fi
+	@echo "All certificate chain tests passed."
+
 clean:
 	rm -rf ./output
diff --git a/tools/tcat_ble_client/auth-generate/ca/TcatCertCa_cert.pem b/tools/tcat_ble_client/auth-generate/ca/TcatCertCa_cert.pem
new file mode 100644
index 0000000..190687b
--- /dev/null
+++ b/tools/tcat_ble_client/auth-generate/ca/TcatCertCa_cert.pem
@@ -0,0 +1,14 @@
+-----BEGIN CERTIFICATE-----
+MIICOzCCAeGgAwIBAgIJAKOc2hehOGoBMAoGCCqGSM49BAMCMHExJjAkBgNVBAMM
+HVRocmVhZCBDZXJ0aWZpY2F0aW9uIERldmljZUNBMRkwFwYDVQQKDBBUaHJlYWQg
+R3JvdXAgSW5jMRIwEAYDVQQHDAlTYW4gUmFtb24xCzAJBgNVBAgMAkNBMQswCQYD
+VQQGEwJVUzAeFw0yNDA1MDMyMDAyMThaFw00NDA0MjgyMDAyMThaMHExJjAkBgNV
+BAMMHVRocmVhZCBDZXJ0aWZpY2F0aW9uIERldmljZUNBMRkwFwYDVQQKDBBUaHJl
+YWQgR3JvdXAgSW5jMRIwEAYDVQQHDAlTYW4gUmFtb24xCzAJBgNVBAgMAkNBMQsw
+CQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABGy850VBIPTkN3oL
+x++zIUsZk2k26w4fuieFz9oNvjdb5W14+Yf3mvGWsl4NHyLxqhmamVAR4h7zWRlZ
+0XyMVpKjYjBgMB4GA1UdEQQXMBWBE3RvbUB0aHJlYWRncm91cC5vcmcwDgYDVR0P
+AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFF+rGyloiKHUtDGo
+hmHn52ZZ7fgZMAoGCCqGSM49BAMCA0gAMEUCIQCTq1qjPZs9fAJB6ppTXs588Pnu
+eVFOwC8bd//D99KiHAIgU84kwFHIyDvFqu6y+u1hFqBGsiuTmKwZ2PHhVe/xK1k=
+-----END CERTIFICATE-----
diff --git a/tools/tcat_ble_client/cli/base_commands.py b/tools/tcat_ble_client/cli/base_commands.py
index 1954e2b..c466d7a 100644
--- a/tools/tcat_ble_client/cli/base_commands.py
+++ b/tools/tcat_ble_client/cli/base_commands.py
@@ -324,41 +324,6 @@
         return TLV(TcatTLVType.GET_NETWORK_NAME.value, bytes()).to_bytes()
 
 
-class GetPskdHash(BleCommand):
-
-    def __init__(self):
-        super().__init__()
-        self.digest = None
-
-    def get_log_string(self) -> str:
-        return 'Retrieving peer PSKd hash.'
-
-    def get_help_string(self) -> str:
-        return 'Get calculated PSKd hash.'
-
-    def prepare_data(self, args, context) -> bytes:
-        bless: BleStreamSecure = context['ble_sstream']
-        if bless.peer_public_key is None:
-            raise DataNotPrepared("Peer certificate not present.")
-
-        challenge = token_bytes(CHALLENGE_SIZE)
-        pskd = bytes(args[0], 'utf-8')
-
-        data = TLV(TcatTLVType.GET_PSKD_HASH.value, challenge).to_bytes()
-
-        hash = hmac.new(pskd, digestmod=sha256)
-        hash.update(challenge)
-        hash.update(bless.peer_public_key)
-        self.digest = hash.digest()
-        return data
-
-    def process_response(self, tlv_response, context) -> None:
-        if tlv_response.value == self.digest:
-            print('Requested hash is valid.')
-        else:
-            print('Requested hash is NOT valid.')
-
-
 class GetRandomNumberChallenge(BleCommand):
 
     def get_log_string(self) -> str:
diff --git a/tools/tcat_ble_client/cli/cli.py b/tools/tcat_ble_client/cli/cli.py
index 9de33de..25fc6a0 100644
--- a/tools/tcat_ble_client/cli/cli.py
+++ b/tools/tcat_ble_client/cli/cli.py
@@ -33,7 +33,7 @@
 from typing import Optional
 
 from cli.base_commands import (DisconnectCommand, HelpCommand, HelloCommand, CommissionCommand, DecommissionCommand,
-                               ExtractDatasetCommand, GetCommissionerCertificate, GetDeviceIdCommand, GetPskdHash,
+                               ExtractDatasetCommand, GetCommissionerCertificate, GetDeviceIdCommand,
                                GetExtPanIDCommand, GetNetworkNameCommand, GetProvisioningUrlCommand, PingCommand,
                                GetRandomNumberChallenge, ThreadStateCommand, ScanCommand, PresentHash,
                                DiagnosticTlvsCommand, GetApplicationLayersCommand, SendVendorData,
@@ -74,7 +74,6 @@
             'simulation': SimulationCommand(),
             'random_challenge': GetRandomNumberChallenge(),
             'present_hash': PresentHash(),
-            'peer_pskd_hash': GetPskdHash(),
             'tlv': TlvCommand(),
             'get_comm_cert': GetCommissionerCertificate(),
             'diagnostic_tlvs': DiagnosticTlvsCommand()
diff --git a/tools/tcat_ble_client/tlv/tcat_tlv.py b/tools/tcat_ble_client/tlv/tcat_tlv.py
index 8a3e2ae..6cde9b0 100644
--- a/tools/tcat_ble_client/tlv/tcat_tlv.py
+++ b/tools/tcat_ble_client/tlv/tcat_tlv.py
@@ -42,7 +42,6 @@
     PRESENT_PSKC_HASH = 0x11
     PRESENT_INSTALL_CODE_HASH = 0x12
     GET_RANDOM_NUMBER_CHALLENGE = 0x13
-    GET_PSKD_HASH = 0x14
     ACTIVE_DATASET = 0x20
     GET_COMMISSIONER_CERTIFICATE = 0x25
     GET_ACTIVE_DATASET = 0x40