diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index c1073d90678ed2..dc152c1a714f19 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -204,11 +204,13 @@ jobs:
               run: |
                   ./scripts/run_in_build_env.sh "./scripts/run_codegen_targets.sh out/sanitizers"
             - name: Clang-tidy validation
+              # NOTE: clang-tidy crashes on CodegenDataModel_Write due to Nullable/std::optional check.
+              #       See https://github.com/llvm/llvm-project/issues/97426
               run: |
                   ./scripts/run_in_build_env.sh \
                     "./scripts/run-clang-tidy-on-compile-commands.py \
                        --compile-database out/sanitizers/compile_commands.json \
-                       --file-exclude-regex '/(repo|zzz_generated|lwip/standalone)/|-ReadImpl|-InvokeSubscribeImpl' \
+                       --file-exclude-regex '/(repo|zzz_generated|lwip/standalone)/|-ReadImpl|-InvokeSubscribeImpl|CodegenDataModel_Write' \
                        check \
                     "
             - name: Clean output
@@ -422,10 +424,13 @@ jobs:
               run: |
                   ./scripts/run_in_build_env.sh "./scripts/run_codegen_targets.sh out/default"
             - name: Clang-tidy validation
+              # NOTE: clang-tidy crashes on CodegenDataModel_Write due to Nullable/std::optional check.
+              #       See https://github.com/llvm/llvm-project/issues/97426
               run: |
                   ./scripts/run_in_build_env.sh \
                     "./scripts/run-clang-tidy-on-compile-commands.py \
                        --compile-database out/default/compile_commands.json \
+                       --file-exclude-regex '/(repo|zzz_generated|lwip/standalone)/|CodegenDataModel_Write' \
                        check \
                     "
             - name: Uploading diagnostic logs
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 2b5bbeff0fe465..c76ddc512b1531 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -288,7 +288,17 @@ jobs:
                   type-safe setters
               if: always()
               run: |
-                  git grep -I -n 'emberAfWriteAttribute' -- './*' ':(exclude).github/workflows/lint.yml' ':(exclude)zzz_generated/app-common/app-common/zap-generated/attributes/Accessors.cpp' ':(exclude)src/app/zap-templates/templates/app/attributes/Accessors-src.zapt' ':(exclude)src/app/util/attribute-table.cpp' ':(exclude)examples/common/pigweed/rpc_services/Attributes.h' ':(exclude)src/app/util/attribute-table.h' ':(exclude)src/app/util/ember-compatibility-functions.cpp' && exit 1 || exit 0
+                  git grep -I -n 'emberAfWriteAttribute' -- './*'                                            \
+                      ':(exclude).github/workflows/lint.yml'                                                 \
+                      ':(exclude)examples/common/pigweed/rpc_services/Attributes.h'                          \
+                      ':(exclude)src/app/codegen-data-model/CodegenDataModel_Write.cpp'                      \
+                      ':(exclude)src/app/codegen-data-model/tests/EmberReadWriteOverride.cpp'                \
+                      ':(exclude)src/app/util/attribute-table.cpp'                                           \
+                      ':(exclude)src/app/util/attribute-table.h'                                             \
+                      ':(exclude)src/app/util/ember-compatibility-functions.cpp'                             \
+                      ':(exclude)src/app/zap-templates/templates/app/attributes/Accessors-src.zapt'          \
+                      ':(exclude)zzz_generated/app-common/app-common/zap-generated/attributes/Accessors.cpp' \
+                      && exit 1 || exit 0
 
             # Run ruff python linter
             - name: Check for errors using ruff Python linter
diff --git a/src/app/codegen-data-model/BUILD.gn b/src/app/codegen-data-model/BUILD.gn
index 5803f01a37778e..cc856539d001e7 100644
--- a/src/app/codegen-data-model/BUILD.gn
+++ b/src/app/codegen-data-model/BUILD.gn
@@ -20,8 +20,11 @@ import("//build_overrides/chip.gni")
 #
 # Use `model.gni` to get access to:
 #   CodegenDataModel.cpp
-#   CodegenDataModel_Read.cpp
 #   CodegenDataModel.h
+#   CodegenDataModel_Read.cpp
+#   CodegenDataModel_Write.cpp
+#   EmberMetadata.cpp
+#   EmberMetadata.h
 #
 # The above list of files exists to satisfy the "dependency linter"
 # since those files should technically be "visible to gn" even though we
diff --git a/src/app/codegen-data-model/CodegenDataModel.cpp b/src/app/codegen-data-model/CodegenDataModel.cpp
index ec7b13af357287..0cc9b42aaa64f7 100644
--- a/src/app/codegen-data-model/CodegenDataModel.cpp
+++ b/src/app/codegen-data-model/CodegenDataModel.cpp
@@ -231,13 +231,6 @@ bool CodegenDataModel::EmberCommandListIterator::Exists(const CommandId * list,
     return (*mCurrentHint == toCheck);
 }
 
-CHIP_ERROR CodegenDataModel::WriteAttribute(const InteractionModel::WriteAttributeRequest & request,
-                                            AttributeValueDecoder & decoder)
-{
-    // TODO: this needs an implementation
-    return CHIP_ERROR_NOT_IMPLEMENTED;
-}
-
 CHIP_ERROR CodegenDataModel::Invoke(const InteractionModel::InvokeRequest & request, TLV::TLVReader & input_arguments,
                                     InteractionModel::InvokeReply & reply)
 {
diff --git a/src/app/codegen-data-model/CodegenDataModel_Read.cpp b/src/app/codegen-data-model/CodegenDataModel_Read.cpp
index 04265d37f8623f..127e0e7ad3866a 100644
--- a/src/app/codegen-data-model/CodegenDataModel_Read.cpp
+++ b/src/app/codegen-data-model/CodegenDataModel_Read.cpp
@@ -14,7 +14,6 @@
  *    See the License for the specific language governing permissions and
  *    limitations under the License.
  */
-#include "lib/core/CHIPError.h"
 #include <app/codegen-data-model/CodegenDataModel.h>
 
 #include <optional>
@@ -29,6 +28,7 @@
 #include <app/AttributeValueEncoder.h>
 #include <app/GlobalAttributes.h>
 #include <app/RequiredPrivilege.h>
+#include <app/codegen-data-model/EmberMetadata.h>
 #include <app/data-model/FabricScoped.h>
 #include <app/util/af-types.h>
 #include <app/util/attribute-metadata.h>
@@ -49,56 +49,6 @@ namespace app {
 namespace {
 using namespace chip::app::Compatibility::Internal;
 
-// Fetch the source for the given attribute path: either a cluster (for global ones) or attribute
-// path.
-//
-// if returning a CHIP_ERROR, it will NEVER be CHIP_NO_ERROR.
-std::variant<const EmberAfCluster *,           // global attribute, data from a cluster
-             const EmberAfAttributeMetadata *, // a specific attribute stored by ember
-             CHIP_ERROR                        // error, this will NEVER be CHIP_NO_ERROR
-             >
-FindAttributeMetadata(const ConcreteAttributePath & aPath)
-{
-    for (auto & attr : GlobalAttributesNotInMetadata)
-    {
-
-        if (attr == aPath.mAttributeId)
-        {
-            const EmberAfCluster * cluster = emberAfFindServerCluster(aPath.mEndpointId, aPath.mClusterId);
-            if (cluster == nullptr)
-            {
-                return (emberAfFindEndpointType(aPath.mEndpointId) == nullptr) ? CHIP_IM_GLOBAL_STATUS(UnsupportedEndpoint)
-                                                                               : CHIP_IM_GLOBAL_STATUS(UnsupportedCluster);
-            }
-
-            return cluster;
-        }
-    }
-    const EmberAfAttributeMetadata * metadata =
-        emberAfLocateAttributeMetadata(aPath.mEndpointId, aPath.mClusterId, aPath.mAttributeId);
-
-    if (metadata == nullptr)
-    {
-        const EmberAfEndpointType * type = emberAfFindEndpointType(aPath.mEndpointId);
-        if (type == nullptr)
-        {
-            return CHIP_IM_GLOBAL_STATUS(UnsupportedEndpoint);
-        }
-
-        const EmberAfCluster * cluster = emberAfFindClusterInType(type, aPath.mClusterId, CLUSTER_MASK_SERVER);
-        if (cluster == nullptr)
-        {
-            return CHIP_IM_GLOBAL_STATUS(UnsupportedCluster);
-        }
-
-        // Since we know the attribute is unsupported and the endpoint/cluster are
-        // OK, this is the only option left.
-        return CHIP_IM_GLOBAL_STATUS(UnsupportedAttribute);
-    }
-
-    return metadata;
-}
-
 /// Attempts to read via an attribute access interface (AAI)
 ///
 /// If it returns a CHIP_ERROR, then this is a FINAL result (i.e. either failure or success).
@@ -138,6 +88,14 @@ struct ShortPascalString
 {
     using LengthType                        = uint8_t;
     static constexpr LengthType kNullLength = 0xFF;
+
+    static size_t GetLength(ByteSpan buffer)
+    {
+        VerifyOrDie(buffer.size() >= 1);
+        // NOTE: we do NOT use emberAfStringLength from ember-strings.h because that will result in 0
+        //       length for null sizes (i.e. 0xFF is translated to 0 and we do not want that here)
+        return buffer[0];
+    }
 };
 
 /// Metadata of what a ember/pascal LONG string means (prepended by a u16 length)
@@ -145,6 +103,15 @@ struct LongPascalString
 {
     using LengthType                        = uint16_t;
     static constexpr LengthType kNullLength = 0xFFFF;
+
+    static size_t GetLength(ByteSpan buffer)
+    {
+        // NOTE: we do NOT use emberAfLongStringLength from ember-strings.h because that will result in 0
+        //       length for null sizes (i.e. 0xFFFF is translated to 0 and we do not want that here)
+        VerifyOrDie(buffer.size() >= 2);
+        const uint8_t * data = buffer.data();
+        return Encoding::LittleEndian::Read16(data);
+    }
 };
 
 // ember assumptions ... should just work
@@ -157,20 +124,17 @@ static_assert(sizeof(LongPascalString::LengthType) == 2);
 template <class OUT, class ENCODING>
 std::optional<OUT> ExtractEmberString(ByteSpan data)
 {
-    typename ENCODING::LengthType len;
-
-    // Ember storage format for pascal-prefix data is specifically "native byte order",
-    // hence the use of memcpy.
-    VerifyOrDie(sizeof(len) <= data.size());
-    memcpy(&len, data.data(), sizeof(len));
+    constexpr size_t kLengthTypeSize = sizeof(typename ENCODING::LengthType);
+    VerifyOrDie(kLengthTypeSize <= data.size());
+    auto len = ENCODING::GetLength(data);
 
     if (len == ENCODING::kNullLength)
     {
         return std::nullopt;
     }
 
-    VerifyOrDie(static_cast<size_t>(len + sizeof(len)) <= data.size());
-    return std::make_optional<OUT>(reinterpret_cast<typename OUT::pointer>(data.data() + sizeof(len)), len);
+    VerifyOrDie(len + sizeof(len) <= data.size());
+    return std::make_optional<OUT>(reinterpret_cast<typename OUT::pointer>(data.data() + kLengthTypeSize), len);
 }
 
 /// Encode a value inside `encoder`
@@ -282,7 +246,7 @@ CHIP_ERROR EncodeEmberValue(ByteSpan data, const EmberAfAttributeMetadata * meta
         return EncodeStringLike<ByteSpan, LongPascalString>(data, isNullable, encoder);
     default:
         ChipLogError(DataManagement, "Attribute type 0x%x not handled", static_cast<int>(metadata->attributeType));
-        return CHIP_IM_GLOBAL_STATUS(UnsupportedRead);
+        return CHIP_IM_GLOBAL_STATUS(Failure);
     }
 }
 
@@ -311,21 +275,26 @@ CHIP_ERROR CodegenDataModel::ReadAttribute(const InteractionModel::ReadAttribute
                                                           RequiredPrivilege::ForReadAttribute(request.path));
         if (err != CHIP_NO_ERROR)
         {
+            ReturnErrorCodeIf(err != CHIP_ERROR_ACCESS_DENIED, err);
+
             // Implementation of 8.4.3.2 of the spec for path expansion
-            if (request.path.mExpanded && (err == CHIP_ERROR_ACCESS_DENIED))
+            if (request.path.mExpanded)
             {
                 return CHIP_NO_ERROR;
             }
-            return err;
+            // access denied has a specific code for IM
+            return CHIP_IM_GLOBAL_STATUS(UnsupportedAccess);
         }
     }
 
-    auto metadata = FindAttributeMetadata(request.path);
+    auto metadata = Ember::FindAttributeMetadata(request.path);
 
     // Explicit failure in finding a suitable metadata
     if (const CHIP_ERROR * err = std::get_if<CHIP_ERROR>(&metadata))
     {
-        VerifyOrDie(*err != CHIP_NO_ERROR);
+        VerifyOrDie((*err == CHIP_IM_GLOBAL_STATUS(UnsupportedEndpoint)) || //
+                    (*err == CHIP_IM_GLOBAL_STATUS(UnsupportedCluster)) ||  //
+                    (*err == CHIP_IM_GLOBAL_STATUS(UnsupportedAttribute)));
         return *err;
     }
 
diff --git a/src/app/codegen-data-model/CodegenDataModel_Write.cpp b/src/app/codegen-data-model/CodegenDataModel_Write.cpp
new file mode 100644
index 00000000000000..0805bfec6cbd18
--- /dev/null
+++ b/src/app/codegen-data-model/CodegenDataModel_Write.cpp
@@ -0,0 +1,405 @@
+/*
+ *    Copyright (c) 2024 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <app/codegen-data-model/CodegenDataModel.h>
+
+#include <access/AccessControl.h>
+#include <app-common/zap-generated/attribute-type.h>
+#include <app/AttributeAccessInterface.h>
+#include <app/AttributeAccessInterfaceRegistry.h>
+#include <app/RequiredPrivilege.h>
+#include <app/codegen-data-model/EmberMetadata.h>
+#include <app/data-model/FabricScoped.h>
+#include <app/reporting/reporting.h>
+#include <app/util/af-types.h>
+#include <app/util/attribute-metadata.h>
+#include <app/util/attribute-storage-detail.h>
+#include <app/util/attribute-storage-null-handling.h>
+#include <app/util/attribute-table-detail.h>
+#include <app/util/attribute-table.h>
+#include <app/util/ember-io-storage.h>
+#include <app/util/ember-strings.h>
+#include <app/util/odd-sized-integers.h>
+#include <lib/core/CHIPError.h>
+#include <lib/support/CodeUtils.h>
+
+#include <zap-generated/endpoint_config.h>
+
+namespace chip {
+namespace app {
+namespace {
+
+using namespace chip::app::Compatibility::Internal;
+
+/// Attempts to write via an attribute access interface (AAI)
+///
+/// If it returns a CHIP_ERROR, then this is a FINAL result (i.e. either failure or success)
+///
+/// If it returns std::nullopt, then there is no AAI to handle the given path
+/// and processing should figure out the value otherwise (generally from other ember data)
+std::optional<CHIP_ERROR> TryWriteViaAccessInterface(const ConcreteAttributePath & path, AttributeAccessInterface * aai,
+                                                     AttributeValueDecoder & decoder)
+{
+    // Processing can happen only if an attribute access interface actually exists..
+    if (aai == nullptr)
+    {
+        return std::nullopt;
+    }
+
+    CHIP_ERROR err = aai->Write(path, decoder);
+
+    if (err != CHIP_NO_ERROR)
+    {
+        return std::make_optional(err);
+    }
+
+    // If the decoder tried to decode, then a value should have been read for processing.
+    //   - if decoding was done, assume DONE (i.e. final CHIP_NO_ERROR)
+    //   -  otherwise, if no decoding done, return that processing must continue via nullopt
+    return decoder.TriedDecode() ? std::make_optional(CHIP_NO_ERROR) : std::nullopt;
+}
+
+/// Metadata of what a ember/pascal short string means (prepended by a u8 length)
+struct ShortPascalString
+{
+    using LengthType                        = uint8_t;
+    static constexpr LengthType kNullLength = 0xFF;
+
+    static void SetLength(uint8_t * buffer, LengthType value) { *buffer = value; }
+};
+
+/// Metadata of what a ember/pascal LONG string means (prepended by a u16 length)
+struct LongPascalString
+{
+    using LengthType                        = uint16_t;
+    static constexpr LengthType kNullLength = 0xFFFF;
+
+    // Encoding for ember string lengths is little-endian (see ember-strings.cpp)
+    static void SetLength(uint8_t * buffer, LengthType value) { Encoding::LittleEndian::Put16(buffer, value); }
+};
+
+// ember assumptions ... should just work
+static_assert(sizeof(ShortPascalString::LengthType) == 1);
+static_assert(sizeof(LongPascalString::LengthType) == 2);
+
+/// Convert the value stored in 'decoder' into an ember format span 'out'
+///
+/// The value converted will be of type T (e.g. CharSpan or ByteSpan) and it will be converted
+/// via the given ENCODING (i.e. ShortPascalString or LongPascalString)
+///
+/// isNullable defines if the value of NULL is allowed to be converted.
+template <typename T, class ENCODING>
+CHIP_ERROR DecodeStringLikeIntoEmberBuffer(AttributeValueDecoder decoder, bool isNullable, MutableByteSpan & out)
+{
+    T workingValue;
+
+    if (isNullable)
+    {
+        typename DataModel::Nullable<T> nullableWorkingValue;
+        ReturnErrorOnFailure(decoder.Decode(nullableWorkingValue));
+
+        if (nullableWorkingValue.IsNull())
+        {
+            VerifyOrReturnError(out.size() >= sizeof(typename ENCODING::LengthType), CHIP_ERROR_BUFFER_TOO_SMALL);
+            ENCODING::SetLength(out.data(), ENCODING::kNullLength);
+            out.reduce_size(sizeof(typename ENCODING::LengthType));
+            return CHIP_NO_ERROR;
+        }
+
+        // continue encoding non-null value
+        workingValue = nullableWorkingValue.Value();
+    }
+    else
+    {
+        ReturnErrorOnFailure(decoder.Decode(workingValue));
+    }
+
+    auto len = static_cast<typename ENCODING::LengthType>(workingValue.size());
+    VerifyOrReturnError(out.size() >= sizeof(len) + len, CHIP_ERROR_BUFFER_TOO_SMALL);
+
+    uint8_t * output_buffer = out.data();
+
+    ENCODING::SetLength(output_buffer, len);
+    output_buffer += sizeof(len);
+
+    memcpy(output_buffer, workingValue.data(), workingValue.size());
+    output_buffer += workingValue.size();
+
+    out.reduce_size(static_cast<size_t>(output_buffer - out.data()));
+    return CHIP_NO_ERROR;
+}
+
+/// Decodes a numeric data value of type T from the `decoder` into a ember-encoded buffer `out`
+///
+/// isNullable defines if the value of NULL is allowed to be decoded.
+template <typename T>
+CHIP_ERROR DecodeIntoEmberBuffer(AttributeValueDecoder & decoder, bool isNullable, MutableByteSpan & out)
+{
+    using Traits = NumericAttributeTraits<T>;
+    typename Traits::StorageType storageValue;
+
+    if (isNullable)
+    {
+        DataModel::Nullable<typename Traits::WorkingType> workingValue;
+        ReturnErrorOnFailure(decoder.Decode(workingValue));
+
+        if (workingValue.IsNull())
+        {
+            Traits::SetNull(storageValue);
+        }
+        else
+        {
+            // This guards against trying to decode something that overlaps nullable, for example
+            // Nullable<uint8_t>(0xFF) is not representable because 0xFF is the encoding of NULL in ember
+            // as well as odd-sized integers (e.g. full 32-bit value like 0x11223344 cannot be written
+            // to a 3-byte odd-sized integger).
+            VerifyOrReturnError(Traits::CanRepresentValue(isNullable, *workingValue), CHIP_ERROR_INVALID_ARGUMENT);
+            Traits::WorkingToStorage(*workingValue, storageValue);
+        }
+
+        VerifyOrReturnError(out.size() >= sizeof(storageValue), CHIP_ERROR_INVALID_ARGUMENT);
+    }
+    else
+    {
+        typename Traits::WorkingType workingValue;
+        ReturnErrorOnFailure(decoder.Decode(workingValue));
+
+        Traits::WorkingToStorage(workingValue, storageValue);
+
+        VerifyOrReturnError(out.size() >= sizeof(storageValue), CHIP_ERROR_INVALID_ARGUMENT);
+
+        // Even non-nullable values may be outside range: e.g. odd-sized integers have working values
+        // that are larger than the storage values (e.g. a uint32_t being stored as a 3-byte integer)
+        VerifyOrReturnError(Traits::CanRepresentValue(isNullable, workingValue), CHIP_ERROR_INVALID_ARGUMENT);
+    }
+
+    const uint8_t * data = Traits::ToAttributeStoreRepresentation(storageValue);
+
+    // The decoding + ToAttributeStoreRepresentation will result in data being
+    // stored in native format/byteorder, suitable to directly be stored in the data store
+    memcpy(out.data(), data, sizeof(storageValue));
+    out.reduce_size(sizeof(storageValue));
+
+    return CHIP_NO_ERROR;
+}
+
+/// Read the data from "decoder" into an ember-formatted buffer "out"
+///
+/// `out` is a in/out buffer:
+///    - its initial size determines the maximum size of the buffer
+///    - its output size reflects the actual data size
+///
+/// Uses the attribute `metadata` to determine how the data is to be encoded into out.
+CHIP_ERROR DecodeValueIntoEmberBuffer(AttributeValueDecoder & decoder, const EmberAfAttributeMetadata * metadata,
+                                      MutableByteSpan & out)
+{
+    VerifyOrReturnError(metadata != nullptr, CHIP_ERROR_INVALID_ARGUMENT);
+
+    const bool isNullable = metadata->IsNullable();
+
+    switch (AttributeBaseType(metadata->attributeType))
+    {
+    case ZCL_BOOLEAN_ATTRIBUTE_TYPE: // Boolean
+        return DecodeIntoEmberBuffer<bool>(decoder, isNullable, out);
+    case ZCL_INT8U_ATTRIBUTE_TYPE: // Unsigned 8-bit integer
+        return DecodeIntoEmberBuffer<uint8_t>(decoder, isNullable, out);
+    case ZCL_INT16U_ATTRIBUTE_TYPE: // Unsigned 16-bit integer
+        return DecodeIntoEmberBuffer<uint16_t>(decoder, isNullable, out);
+    case ZCL_INT24U_ATTRIBUTE_TYPE: // Unsigned 24-bit integer
+        return DecodeIntoEmberBuffer<OddSizedInteger<3, false>>(decoder, isNullable, out);
+    case ZCL_INT32U_ATTRIBUTE_TYPE: // Unsigned 32-bit integer
+        return DecodeIntoEmberBuffer<uint32_t>(decoder, isNullable, out);
+    case ZCL_INT40U_ATTRIBUTE_TYPE: // Unsigned 40-bit integer
+        return DecodeIntoEmberBuffer<OddSizedInteger<5, false>>(decoder, isNullable, out);
+    case ZCL_INT48U_ATTRIBUTE_TYPE: // Unsigned 48-bit integer
+        return DecodeIntoEmberBuffer<OddSizedInteger<6, false>>(decoder, isNullable, out);
+    case ZCL_INT56U_ATTRIBUTE_TYPE: // Unsigned 56-bit integer
+        return DecodeIntoEmberBuffer<OddSizedInteger<7, false>>(decoder, isNullable, out);
+    case ZCL_INT64U_ATTRIBUTE_TYPE: // Unsigned 64-bit integer
+        return DecodeIntoEmberBuffer<uint64_t>(decoder, isNullable, out);
+    case ZCL_INT8S_ATTRIBUTE_TYPE: // Signed 8-bit integer
+        return DecodeIntoEmberBuffer<int8_t>(decoder, isNullable, out);
+    case ZCL_INT16S_ATTRIBUTE_TYPE: // Signed 16-bit integer
+        return DecodeIntoEmberBuffer<int16_t>(decoder, isNullable, out);
+    case ZCL_INT24S_ATTRIBUTE_TYPE: // Signed 24-bit integer
+        return DecodeIntoEmberBuffer<OddSizedInteger<3, true>>(decoder, isNullable, out);
+    case ZCL_INT32S_ATTRIBUTE_TYPE: // Signed 32-bit integer
+        return DecodeIntoEmberBuffer<int32_t>(decoder, isNullable, out);
+    case ZCL_INT40S_ATTRIBUTE_TYPE: // Signed 40-bit integer
+        return DecodeIntoEmberBuffer<OddSizedInteger<5, true>>(decoder, isNullable, out);
+    case ZCL_INT48S_ATTRIBUTE_TYPE: // Signed 48-bit integer
+        return DecodeIntoEmberBuffer<OddSizedInteger<6, true>>(decoder, isNullable, out);
+    case ZCL_INT56S_ATTRIBUTE_TYPE: // Signed 56-bit integer
+        return DecodeIntoEmberBuffer<OddSizedInteger<7, true>>(decoder, isNullable, out);
+    case ZCL_INT64S_ATTRIBUTE_TYPE: // Signed 64-bit integer
+        return DecodeIntoEmberBuffer<int64_t>(decoder, isNullable, out);
+    case ZCL_SINGLE_ATTRIBUTE_TYPE: // 32-bit float
+        return DecodeIntoEmberBuffer<float>(decoder, isNullable, out);
+    case ZCL_DOUBLE_ATTRIBUTE_TYPE: // 64-bit float
+        return DecodeIntoEmberBuffer<double>(decoder, isNullable, out);
+    case ZCL_CHAR_STRING_ATTRIBUTE_TYPE: // Char string
+        return DecodeStringLikeIntoEmberBuffer<CharSpan, ShortPascalString>(decoder, isNullable, out);
+    case ZCL_LONG_CHAR_STRING_ATTRIBUTE_TYPE:
+        return DecodeStringLikeIntoEmberBuffer<CharSpan, LongPascalString>(decoder, isNullable, out);
+    case ZCL_OCTET_STRING_ATTRIBUTE_TYPE: // Octet string
+        return DecodeStringLikeIntoEmberBuffer<ByteSpan, ShortPascalString>(decoder, isNullable, out);
+    case ZCL_LONG_OCTET_STRING_ATTRIBUTE_TYPE:
+        return DecodeStringLikeIntoEmberBuffer<ByteSpan, LongPascalString>(decoder, isNullable, out);
+    default:
+        ChipLogError(DataManagement, "Attribute type 0x%x not handled", static_cast<int>(metadata->attributeType));
+        return CHIP_IM_GLOBAL_STATUS(Failure);
+    }
+}
+
+} // namespace
+
+CHIP_ERROR CodegenDataModel::WriteAttribute(const InteractionModel::WriteAttributeRequest & request,
+                                            AttributeValueDecoder & decoder)
+{
+    ChipLogDetail(DataManagement, "Writing attribute: Cluster=" ChipLogFormatMEI " Endpoint=0x%x AttributeId=" ChipLogFormatMEI,
+                  ChipLogValueMEI(request.path.mClusterId), request.path.mEndpointId, ChipLogValueMEI(request.path.mAttributeId));
+
+    // ACL check for non-internal requests
+    if (!request.operationFlags.Has(InteractionModel::OperationFlags::kInternal))
+    {
+        ReturnErrorCodeIf(!request.subjectDescriptor.has_value(), CHIP_IM_GLOBAL_STATUS(UnsupportedAccess));
+
+        Access::RequestPath requestPath{ .cluster = request.path.mClusterId, .endpoint = request.path.mEndpointId };
+        CHIP_ERROR err = Access::GetAccessControl().Check(*request.subjectDescriptor, requestPath,
+                                                          RequiredPrivilege::ForWriteAttribute(request.path));
+
+        if (err != CHIP_NO_ERROR)
+        {
+            ReturnErrorCodeIf(err != CHIP_ERROR_ACCESS_DENIED, err);
+
+            // TODO: when wildcard/group writes are supported, handle them to discard rather than fail with status
+            return CHIP_IM_GLOBAL_STATUS(UnsupportedAccess);
+        }
+    }
+
+    auto metadata = Ember::FindAttributeMetadata(request.path);
+
+    if (const CHIP_ERROR * err = std::get_if<CHIP_ERROR>(&metadata))
+    {
+        VerifyOrDie((*err == CHIP_IM_GLOBAL_STATUS(UnsupportedEndpoint)) || //
+                    (*err == CHIP_IM_GLOBAL_STATUS(UnsupportedCluster)) ||  //
+                    (*err == CHIP_IM_GLOBAL_STATUS(UnsupportedAttribute)));
+        return *err;
+    }
+
+    const EmberAfAttributeMetadata ** attributeMetadata = std::get_if<const EmberAfAttributeMetadata *>(&metadata);
+
+    // All the global attributes that we do not have metadata for are
+    // read-only. Specifically only the following list-based attributes match the
+    // "global attributes not in metadata" (see GlobalAttributes.h :: GlobalAttributesNotInMetadata):
+    //   - AttributeList
+    //   - EventList
+    //   - AcceptedCommands
+    //   - GeneratedCommands
+    //
+    // Given the above, UnsupportedWrite should be correct (attempt to write to a read-only list)
+    bool isReadOnly = (attributeMetadata == nullptr) || (*attributeMetadata)->IsReadOnly();
+
+    // Internal is allowed to bypass timed writes and read-only.
+    if (!request.operationFlags.Has(InteractionModel::OperationFlags::kInternal))
+    {
+        VerifyOrReturnError(!isReadOnly, CHIP_IM_GLOBAL_STATUS(UnsupportedWrite));
+
+        VerifyOrReturnError(!(*attributeMetadata)->MustUseTimedWrite() ||
+                                request.writeFlags.Has(InteractionModel::WriteFlags::kTimed),
+                            CHIP_IM_GLOBAL_STATUS(NeedsTimedInteraction));
+    }
+
+    // Extra check: internal requests can bypass the read only check, however global attributes
+    // have no underlying storage, so write still cannot be done
+    VerifyOrReturnError(attributeMetadata != nullptr, CHIP_IM_GLOBAL_STATUS(UnsupportedWrite));
+
+    if (request.path.mDataVersion.HasValue())
+    {
+        std::optional<InteractionModel::ClusterInfo> clusterInfo = GetClusterInfo(request.path);
+        if (!clusterInfo.has_value())
+        {
+            ChipLogError(DataManagement, "Unable to get cluster info for Endpoint 0x%x, Cluster " ChipLogFormatMEI,
+                         request.path.mEndpointId, ChipLogValueMEI(request.path.mClusterId));
+            return CHIP_IM_GLOBAL_STATUS(DataVersionMismatch);
+        }
+
+        if (request.path.mDataVersion.Value() != clusterInfo->dataVersion)
+        {
+            ChipLogError(DataManagement, "Write Version mismatch for Endpoint 0x%x, Cluster " ChipLogFormatMEI,
+                         request.path.mEndpointId, ChipLogValueMEI(request.path.mClusterId));
+            return CHIP_IM_GLOBAL_STATUS(DataVersionMismatch);
+        }
+    }
+
+    AttributeAccessInterface * aai       = GetAttributeAccessOverride(request.path.mEndpointId, request.path.mClusterId);
+    std::optional<CHIP_ERROR> aai_result = TryWriteViaAccessInterface(request.path, aai, decoder);
+    if (aai_result.has_value())
+    {
+        if (*aai_result == CHIP_NO_ERROR)
+        {
+            // TODO: change callbacks should likely be routed through the context `MarkDirty` only
+            //       however for now this is called directly because ember code does this call
+            //       inside emberAfWriteAttribute.
+            MatterReportingAttributeChangeCallback(request.path);
+            CurrentContext().dataModelChangeListener->MarkDirty(request.path);
+        }
+        return *aai_result;
+    }
+
+    MutableByteSpan dataBuffer = gEmberAttributeIOBufferSpan;
+    ReturnErrorOnFailure(DecodeValueIntoEmberBuffer(decoder, *attributeMetadata, dataBuffer));
+
+    Protocols::InteractionModel::Status status;
+
+    if (dataBuffer.size() > (*attributeMetadata)->size)
+    {
+        ChipLogDetail(Zcl, "Data to write exceeds the attribute size claimed.");
+        return CHIP_IM_GLOBAL_STATUS(InvalidValue);
+    }
+
+    if (request.operationFlags.Has(InteractionModel::OperationFlags::kInternal))
+    {
+        // Internal requests use the non-External interface that has less enforcement
+        // than the external version (e.g. does not check/enforce writable settings, does not
+        // validate attribute types) - see attribute-table.h documentation for details.
+        status = emberAfWriteAttribute(request.path.mEndpointId, request.path.mClusterId, request.path.mAttributeId,
+                                       dataBuffer.data(), (*attributeMetadata)->attributeType);
+    }
+    else
+    {
+        status = emAfWriteAttributeExternal(request.path.mEndpointId, request.path.mClusterId, request.path.mAttributeId,
+                                            dataBuffer.data(), (*attributeMetadata)->attributeType);
+    }
+
+    if (status != Protocols::InteractionModel::Status::Success)
+    {
+        return CHIP_ERROR_IM_GLOBAL_STATUS_VALUE(status);
+    }
+
+    // TODO: this WILL requre updates
+    //
+    // - Internal writes may need to be able to decide if to mark things dirty or not (see AAI as well)
+    // - Changes-ommited paths should not be marked dirty (ember is not aware of that flag)
+    // - This likely maps to `MatterReportingAttributeChangeCallback` HOWEVER current ember write functions
+    //   will selectively call that one depending on old attribute state (i.e. calling every time is a
+    //   change in behavior)
+    CurrentContext().dataModelChangeListener->MarkDirty(request.path);
+    return CHIP_NO_ERROR;
+}
+
+} // namespace app
+} // namespace chip
diff --git a/src/app/codegen-data-model/EmberMetadata.cpp b/src/app/codegen-data-model/EmberMetadata.cpp
new file mode 100644
index 00000000000000..9114196a377906
--- /dev/null
+++ b/src/app/codegen-data-model/EmberMetadata.cpp
@@ -0,0 +1,79 @@
+/*
+ *    Copyright (c) 2024 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#include <app/codegen-data-model/EmberMetadata.h>
+
+#include <app/GlobalAttributes.h>
+#include <app/util/attribute-storage.h>
+#include <app/util/endpoint-config-api.h>
+
+namespace chip {
+namespace app {
+namespace Ember {
+
+std::variant<const EmberAfCluster *,           // global attribute, data from a cluster
+             const EmberAfAttributeMetadata *, // a specific attribute stored by ember
+             CHIP_ERROR                        // error, this will NEVER be CHIP_NO_ERROR
+             >
+FindAttributeMetadata(const ConcreteAttributePath & aPath)
+{
+    if (IsGlobalAttribute(aPath.mAttributeId))
+    {
+        // Global list attribute check first: during path expansion a lot of attributes
+        // will actually be global attributes (so not too much of a performance hit)
+        for (auto & attr : GlobalAttributesNotInMetadata)
+        {
+            if (attr == aPath.mAttributeId)
+            {
+                const EmberAfCluster * cluster = emberAfFindServerCluster(aPath.mEndpointId, aPath.mClusterId);
+                if (cluster == nullptr)
+                {
+                    return (emberAfFindEndpointType(aPath.mEndpointId) == nullptr) ? CHIP_IM_GLOBAL_STATUS(UnsupportedEndpoint)
+                                                                                   : CHIP_IM_GLOBAL_STATUS(UnsupportedCluster);
+                }
+
+                return cluster;
+            }
+        }
+    }
+    const EmberAfAttributeMetadata * metadata =
+        emberAfLocateAttributeMetadata(aPath.mEndpointId, aPath.mClusterId, aPath.mAttributeId);
+
+    if (metadata == nullptr)
+    {
+        const EmberAfEndpointType * type = emberAfFindEndpointType(aPath.mEndpointId);
+        if (type == nullptr)
+        {
+            return CHIP_IM_GLOBAL_STATUS(UnsupportedEndpoint);
+        }
+
+        const EmberAfCluster * cluster = emberAfFindClusterInType(type, aPath.mClusterId, CLUSTER_MASK_SERVER);
+        if (cluster == nullptr)
+        {
+            return CHIP_IM_GLOBAL_STATUS(UnsupportedCluster);
+        }
+
+        // Since we know the attribute is unsupported and the endpoint/cluster are
+        // OK, this is the only option left.
+        return CHIP_IM_GLOBAL_STATUS(UnsupportedAttribute);
+    }
+
+    return metadata;
+}
+
+} // namespace Ember
+} // namespace app
+} // namespace chip
diff --git a/src/app/codegen-data-model/EmberMetadata.h b/src/app/codegen-data-model/EmberMetadata.h
new file mode 100644
index 00000000000000..f8f41312f1f7c9
--- /dev/null
+++ b/src/app/codegen-data-model/EmberMetadata.h
@@ -0,0 +1,46 @@
+/*
+ *    Copyright (c) 2024 Project CHIP Authors
+ *    All rights reserved.
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+#pragma once
+
+#include <app/util/af-types.h>
+#include <lib/core/CHIPError.h>
+
+#include <variant>
+
+namespace chip {
+namespace app {
+namespace Ember {
+
+/// Fetch the source for the given attribute path: either a cluster (for global ones) or attribute
+/// path.
+///
+/// Possible return values:
+///    - EmberAfCluster (NEVER null)           - Only for GlobalAttributesNotInMetaData
+///    - EmberAfAttributeMetadata (NEVER null) - if the attribute is known to ember datastore
+///    - CHIP_ERROR, only specifically for unknown attributes, may only be one of:
+///        - CHIP_IM_GLOBAL_STATUS(UnsupportedEndpoint);
+///        - CHIP_IM_GLOBAL_STATUS(UnsupportedCluster);
+///        - CHIP_IM_GLOBAL_STATUS(UnsupportedAttribute);
+std::variant<const EmberAfCluster *,           // global attribute, data from a cluster
+             const EmberAfAttributeMetadata *, // a specific attribute stored by ember
+             CHIP_ERROR                        // error, (CHIP_IM_GLOBAL_STATUS(Unsupported*))
+             >
+FindAttributeMetadata(const ConcreteAttributePath & aPath);
+
+} // namespace Ember
+} // namespace app
+} // namespace chip
diff --git a/src/app/codegen-data-model/model.gni b/src/app/codegen-data-model/model.gni
index 3be7b2d2610513..a2abf6377c07a4 100644
--- a/src/app/codegen-data-model/model.gni
+++ b/src/app/codegen-data-model/model.gni
@@ -28,6 +28,9 @@ codegen_data_model_SOURCES = [
   "${chip_root}/src/app/codegen-data-model/CodegenDataModel.h",
   "${chip_root}/src/app/codegen-data-model/CodegenDataModel.cpp",
   "${chip_root}/src/app/codegen-data-model/CodegenDataModel_Read.cpp",
+  "${chip_root}/src/app/codegen-data-model/CodegenDataModel_Write.cpp",
+  "${chip_root}/src/app/codegen-data-model/EmberMetadata.cpp",
+  "${chip_root}/src/app/codegen-data-model/EmberMetadata.h",
 ]
 
 codegen_data_model_PUBLIC_DEPS = [
diff --git a/src/app/codegen-data-model/tests/EmberReadWriteOverride.cpp b/src/app/codegen-data-model/tests/EmberReadWriteOverride.cpp
index 8c65ee29b05556..d3c3b9975f8176 100644
--- a/src/app/codegen-data-model/tests/EmberReadWriteOverride.cpp
+++ b/src/app/codegen-data-model/tests/EmberReadWriteOverride.cpp
@@ -17,6 +17,8 @@
 #include "EmberReadWriteOverride.h"
 
 #include <app/util/attribute-storage.h>
+#include <app/util/ember-io-storage.h>
+#include <lib/support/Span.h>
 
 using chip::Protocols::InteractionModel::Status;
 
@@ -63,6 +65,11 @@ void SetEmberReadOutput(std::variant<chip::ByteSpan, Status> what)
     gEmberStatusCode = Status::InvalidAction;
 }
 
+ByteSpan GetEmberBuffer()
+{
+    return ByteSpan(gEmberIoBuffer, gEmberIoBufferFill);
+}
+
 } // namespace Test
 } // namespace chip
 
@@ -76,12 +83,47 @@ Status emAfReadOrWriteAttribute(const EmberAfAttributeSearchRecord * attRecord,
         return gEmberStatusCode;
     }
 
-    if (gEmberIoBufferFill > readLength)
+    if (write)
+    {
+        // copy over as much data as possible
+        // NOTE: we do NOT use (*metadata)->size since it is unclear if our mocks set that correctly
+        size_t len = std::min<size_t>(sizeof(gEmberIoBuffer), readLength);
+        memcpy(gEmberIoBuffer, buffer, len);
+        gEmberIoBufferFill = len;
+    }
+    else
     {
-        ChipLogError(Test, "Internal TEST error: insufficient output buffer space.");
-        return Status::ResourceExhausted;
+        VerifyOrDie(gEmberIoBufferFill <= readLength);
+        memcpy(buffer, gEmberIoBuffer, gEmberIoBufferFill);
     }
 
-    memcpy(buffer, gEmberIoBuffer, gEmberIoBufferFill);
     return Status::Success;
 }
+
+Status emAfWriteAttributeExternal(chip::EndpointId endpoint, chip::ClusterId cluster, chip::AttributeId attributeID,
+                                  uint8_t * dataPtr, EmberAfAttributeType dataType)
+{
+    if (gEmberStatusCode != Status::Success)
+    {
+        return gEmberStatusCode;
+    }
+
+    // ember here deduces the size of dataPtr. For testing however, we KNOW we read
+    // out of the ember IO buffer, so we try to use that
+    VerifyOrDie(dataPtr == chip::app::Compatibility::Internal::gEmberAttributeIOBufferSpan.data());
+
+    // In theory this should do type validation and sizes. This is NOT done for testing.
+    // copy over as much data as possible
+    // NOTE: we do NOT use (*metadata)->size since it is unclear if our mocks set that correctly
+    size_t len = std::min<size_t>(sizeof(gEmberIoBuffer), chip::app::Compatibility::Internal::gEmberAttributeIOBufferSpan.size());
+    memcpy(gEmberIoBuffer, dataPtr, len);
+    gEmberIoBufferFill = len;
+
+    return Status::Success;
+}
+
+Status emberAfWriteAttribute(chip::EndpointId endpoint, chip::ClusterId cluster, chip::AttributeId attributeID, uint8_t * dataPtr,
+                             EmberAfAttributeType dataType)
+{
+    return emAfWriteAttributeExternal(endpoint, cluster, attributeID, dataPtr, dataType);
+}
diff --git a/src/app/codegen-data-model/tests/EmberReadWriteOverride.h b/src/app/codegen-data-model/tests/EmberReadWriteOverride.h
index 527a6cfd0d18c7..5aeaeadc254086 100644
--- a/src/app/codegen-data-model/tests/EmberReadWriteOverride.h
+++ b/src/app/codegen-data-model/tests/EmberReadWriteOverride.h
@@ -29,5 +29,8 @@ namespace Test {
 /// It may return a value with success or some error. The byte span WILL BE COPIED.
 void SetEmberReadOutput(std::variant<chip::ByteSpan, chip::Protocols::InteractionModel::Status> what);
 
+/// Grab the data currently in the buffer
+chip::ByteSpan GetEmberBuffer();
+
 } // namespace Test
 } // namespace chip
diff --git a/src/app/codegen-data-model/tests/InteractionModelTemporaryOverrides.cpp b/src/app/codegen-data-model/tests/InteractionModelTemporaryOverrides.cpp
index 868a26880d3ce7..a595acc81f3b5c 100644
--- a/src/app/codegen-data-model/tests/InteractionModelTemporaryOverrides.cpp
+++ b/src/app/codegen-data-model/tests/InteractionModelTemporaryOverrides.cpp
@@ -82,3 +82,8 @@ void DispatchSingleClusterCommand(const ConcreteCommandPath & aRequestCommandPat
 
 } // namespace app
 } // namespace chip
+
+void MatterReportingAttributeChangeCallback(const chip::app::ConcreteAttributePath & aPath)
+{
+    // TODO: should we add logic to track these calls for test purposes?
+}
diff --git a/src/app/codegen-data-model/tests/TestCodegenModelViaMocks.cpp b/src/app/codegen-data-model/tests/TestCodegenModelViaMocks.cpp
index df759e78666659..2bf88a02bae6ef 100644
--- a/src/app/codegen-data-model/tests/TestCodegenModelViaMocks.cpp
+++ b/src/app/codegen-data-model/tests/TestCodegenModelViaMocks.cpp
@@ -21,6 +21,7 @@
 
 #include <access/AccessControl.h>
 #include <access/SubjectDescriptor.h>
+#include <app-common/zap-generated/attribute-type.h>
 #include <app-common/zap-generated/cluster-objects.h>
 #include <app/AttributeAccessInterface.h>
 #include <app/AttributeAccessInterfaceRegistry.h>
@@ -29,19 +30,27 @@
 #include <app/ConcreteAttributePath.h>
 #include <app/GlobalAttributes.h>
 #include <app/MessageDef/ReportDataMessage.h>
+#include <app/data-model-interface/OperationTypes.h>
 #include <app/data-model/Decode.h>
 #include <app/data-model/Encode.h>
+#include <app/data-model/Nullable.h>
+#include <app/util/attribute-metadata.h>
 #include <app/util/attribute-storage-null-handling.h>
+#include <app/util/ember-io-storage.h>
 #include <app/util/mock/Constants.h>
 #include <app/util/mock/Functions.h>
 #include <app/util/mock/MockNodeConfig.h>
 #include <app/util/odd-sized-integers.h>
 #include <lib/core/CHIPError.h>
 #include <lib/core/DataModelTypes.h>
+#include <lib/core/Optional.h>
 #include <lib/core/StringBuilderAdapters.h>
 #include <lib/core/TLVDebug.h>
 #include <lib/core/TLVReader.h>
+#include <lib/core/TLVTags.h>
+#include <lib/core/TLVTypes.h>
 #include <lib/core/TLVWriter.h>
+#include <lib/support/Span.h>
 
 #include <gtest/gtest.h>
 #include <vector>
@@ -57,6 +66,9 @@ namespace {
 constexpr FabricIndex kTestFabrixIndex = kMinValidFabricIndex;
 constexpr NodeId kTestNodeId           = 0xFFFF'1234'ABCD'4321;
 
+constexpr AttributeId kAttributeIdReadOnly   = 0x3001;
+constexpr AttributeId kAttributeIdTimedWrite = 0x3002;
+
 constexpr EndpointId kEndpointIdThatIsMissing = kMockEndpointMin - 1;
 
 constexpr AttributeId kReadOnlyAttributeId = 0x5001;
@@ -107,6 +119,57 @@ bool operator==(const Access::SubjectDescriptor & a, const Access::SubjectDescri
     return true;
 }
 
+class TestDataModelChangeListener : public DataModelChangeListener
+{
+public:
+    void MarkDirty(const ConcreteAttributePath & path) override { mDirtyList.push_back(path); }
+
+    std::vector<ConcreteAttributePath> & DirtyList() { return mDirtyList; }
+    const std::vector<ConcreteAttributePath> & DirtyList() const { return mDirtyList; }
+
+private:
+    std::vector<ConcreteAttributePath> mDirtyList;
+};
+
+class TestEventGenerator : public EventsGenerator
+{
+    CHIP_ERROR GenerateEvent(EventLoggingDelegate * eventPayloadWriter, const EventOptions & options,
+                             EventNumber & generatedEventNumber) override
+    {
+        return CHIP_ERROR_NOT_IMPLEMENTED;
+    }
+};
+
+class TestActionContext : public ActionContext
+{
+public:
+    Messaging::ExchangeContext * CurrentExchange() override { return nullptr; }
+};
+
+class CodegenDataModelWithContext : public CodegenDataModel
+{
+public:
+    CodegenDataModelWithContext()
+    {
+        InteractionModelContext context{
+            .eventsGenerator         = &mEventGenerator,
+            .dataModelChangeListener = &mChangeListener,
+            .actionContext           = &mActionContext,
+        };
+
+        Startup(context);
+    }
+    ~CodegenDataModelWithContext() { Shutdown(); }
+
+    TestDataModelChangeListener & ChangeListener() { return mChangeListener; }
+    const TestDataModelChangeListener & ChangeListener() const { return mChangeListener; }
+
+private:
+    TestEventGenerator mEventGenerator;
+    TestDataModelChangeListener mChangeListener;
+    TestActionContext mActionContext;
+};
+
 class MockAccessControl : public Access::AccessControl::Delegate, public Access::AccessControl::DeviceTypeResolver
 {
 public:
@@ -348,6 +411,10 @@ const MockNodeConfig gTestNodeConfig({
             MOCK_ATTRIBUTE_CONFIG_NULLABLE(ZCL_IPV6ADR_ATTRIBUTE_TYPE),
             MOCK_ATTRIBUTE_CONFIG_NULLABLE(ZCL_IPV6PRE_ATTRIBUTE_TYPE),
             MOCK_ATTRIBUTE_CONFIG_NULLABLE(ZCL_HWADR_ATTRIBUTE_TYPE),
+
+            // Special case handling
+            MockAttributeConfig(kAttributeIdReadOnly, ZCL_INT32S_ATTRIBUTE_TYPE, 0),
+            MockAttributeConfig(kAttributeIdTimedWrite, ZCL_INT32S_ATTRIBUTE_TYPE, ATTRIBUTE_MASK_WRITABLE | ATTRIBUTE_MASK_MUST_USE_TIMED_WRITE),
         }),
     }),
 });
@@ -422,14 +489,58 @@ class StructAttributeAccessInterface : public AttributeAccessInterface
         return encoder.Encode(mData);
     }
 
+    CHIP_ERROR Write(const ConcreteDataAttributePath & path, AttributeValueDecoder & decoder) override
+    {
+        if (static_cast<const ConcreteAttributePath &>(path) != mPath)
+        {
+            // returning without trying to handle means "I do not handle this"
+            return CHIP_NO_ERROR;
+        }
+
+        return decoder.Decode(mData);
+    }
+
     void SetReturnedData(const Clusters::UnitTesting::Structs::SimpleStruct::Type & data) { mData = data; }
-    Clusters::UnitTesting::Structs::SimpleStruct::Type simpleStruct;
+    const Clusters::UnitTesting::Structs::SimpleStruct::Type & GetData() const { return mData; }
 
 private:
     ConcreteAttributePath mPath;
     Clusters::UnitTesting::Structs::SimpleStruct::Type mData;
 };
 
+class ErrorAccessInterface : public AttributeAccessInterface
+{
+public:
+    ErrorAccessInterface(ConcreteAttributePath path, CHIP_ERROR err) :
+        AttributeAccessInterface(MakeOptional(path.mEndpointId), path.mClusterId), mPath(path), mError(err)
+    {}
+    ~ErrorAccessInterface() = default;
+
+    CHIP_ERROR Read(const ConcreteReadAttributePath & path, AttributeValueEncoder & encoder) override
+    {
+        if (static_cast<const ConcreteAttributePath &>(path) != mPath)
+        {
+            // returning without trying to handle means "I do not handle this"
+            return CHIP_NO_ERROR;
+        }
+        return mError;
+    }
+
+    CHIP_ERROR Write(const ConcreteDataAttributePath & path, AttributeValueDecoder & decoder) override
+    {
+        if (static_cast<const ConcreteAttributePath &>(path) != mPath)
+        {
+            // returning without trying to handle means "I do not handle this"
+            return CHIP_NO_ERROR;
+        }
+        return mError;
+    }
+
+private:
+    ConcreteAttributePath mPath;
+    CHIP_ERROR mError;
+};
+
 class ListAttributeAcessInterface : public AttributeAccessInterface
 {
 public:
@@ -458,7 +569,6 @@ class ListAttributeAcessInterface : public AttributeAccessInterface
 
     void SetReturnedData(const Clusters::UnitTesting::Structs::SimpleStruct::Type & data) { mData = data; }
     void SetReturnedDataCount(unsigned count) { mCount = count; }
-    Clusters::UnitTesting::Structs::SimpleStruct::Type simpleStruct;
 
 private:
     ConcreteAttributePath mPath;
@@ -508,7 +618,7 @@ struct TestReadRequest
         request.path              = path;
     }
 
-    std::unique_ptr<AttributeValueEncoder> StartEncoding(chip::app::InteractionModel::DataModel * model,
+    std::unique_ptr<AttributeValueEncoder> StartEncoding(InteractionModel::DataModel * model,
                                                          AttributeEncodeState state = AttributeEncodeState())
     {
         std::optional<ClusterInfo> info = model->GetClusterInfo(request.path);
@@ -538,11 +648,60 @@ struct TestReadRequest
     CHIP_ERROR FinishEncoding() { return encodedIBs.FinishEncoding(reportBuilder); }
 };
 
+// Sets up data for writing
+struct TestWriteRequest
+{
+    InteractionModel::WriteAttributeRequest request;
+    uint8_t tlvBuffer[128] = { 0 };
+    TLV::TLVReader
+        tlvReader; /// tlv reader used for the returned AttributeValueDecoder (since attributeValueDecoder uses references)
+
+    TestWriteRequest(const Access::SubjectDescriptor & subject, const ConcreteDataAttributePath & path)
+    {
+        request.subjectDescriptor = subject;
+        request.path              = path;
+    }
+
+    template <typename T>
+    TLV::TLVReader ReadEncodedValue(const T & value)
+    {
+        TLV::TLVWriter writer;
+        writer.Init(tlvBuffer);
+
+        // Encoding is within a structure:
+        //   - BEGIN_STRUCT
+        //     - 1: .....
+        //   - END_STRUCT
+        TLV::TLVType outerContainerType;
+        VerifyOrDie(writer.StartContainer(TLV::AnonymousTag(), TLV::kTLVType_Structure, outerContainerType) == CHIP_NO_ERROR);
+        VerifyOrDie(chip::app::DataModel::Encode(writer, TLV::ContextTag(1), value) == CHIP_NO_ERROR);
+        VerifyOrDie(writer.EndContainer(outerContainerType) == CHIP_NO_ERROR);
+        VerifyOrDie(writer.Finalize() == CHIP_NO_ERROR);
+
+        TLV::TLVReader reader;
+        reader.Init(tlvBuffer);
+
+        // position the reader inside the buffer, on the encoded value
+        VerifyOrDie(reader.Next() == CHIP_NO_ERROR);
+        VerifyOrDie(reader.EnterContainer(outerContainerType) == CHIP_NO_ERROR);
+        VerifyOrDie(reader.Next() == CHIP_NO_ERROR);
+
+        return reader;
+    }
+
+    template <class T>
+    AttributeValueDecoder DecoderFor(const T & value)
+    {
+        tlvReader = ReadEncodedValue(value);
+        return AttributeValueDecoder(tlvReader, request.subjectDescriptor.value_or(kDenySubjectDescriptor));
+    }
+};
+
 template <typename T, EmberAfAttributeType ZclType>
 void TestEmberScalarTypeRead(typename NumericAttributeTraits<T>::WorkingType value)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(
@@ -577,7 +736,7 @@ template <typename T, EmberAfAttributeType ZclType>
 void TestEmberScalarNullRead()
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(
@@ -606,12 +765,128 @@ void TestEmberScalarNullRead()
     ASSERT_TRUE(actual.IsNull());
 }
 
+template <typename T, EmberAfAttributeType ZclType>
+void TestEmberScalarTypeWrite(const typename NumericAttributeTraits<T>::WorkingType value)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    // non-nullable test
+    {
+        TestWriteRequest test(
+            kAdminSubjectDescriptor,
+            ConcreteAttributePath(kMockEndpoint3, MockClusterId(4), MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZclType)));
+        AttributeValueDecoder decoder = test.DecoderFor(value);
+
+        // write should succeed
+        ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+
+        // Validate data after write
+        chip::ByteSpan writtenData = Test::GetEmberBuffer();
+
+        typename NumericAttributeTraits<T>::StorageType storage;
+        ASSERT_GE(writtenData.size(), sizeof(storage));
+        memcpy(&storage, writtenData.data(), sizeof(storage));
+        typename NumericAttributeTraits<T>::WorkingType actual = NumericAttributeTraits<T>::StorageToWorking(storage);
+
+        EXPECT_EQ(actual, value);
+        ASSERT_EQ(model.ChangeListener().DirtyList().size(), 1u);
+        EXPECT_EQ(model.ChangeListener().DirtyList()[0], test.request.path);
+
+        // reset for the next test
+        model.ChangeListener().DirtyList().clear();
+    }
+
+    // nullable test
+    {
+        TestWriteRequest test(
+            kAdminSubjectDescriptor,
+            ConcreteAttributePath(kMockEndpoint3, MockClusterId(4), MOCK_ATTRIBUTE_ID_FOR_NULLABLE_TYPE(ZclType)));
+        AttributeValueDecoder decoder = test.DecoderFor(value);
+
+        // write should succeed
+        ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+
+        // Validate data after write
+        chip::ByteSpan writtenData = Test::GetEmberBuffer();
+
+        typename NumericAttributeTraits<T>::StorageType storage;
+        ASSERT_GE(writtenData.size(), sizeof(storage));
+        memcpy(&storage, writtenData.data(), sizeof(storage));
+        typename NumericAttributeTraits<T>::WorkingType actual = NumericAttributeTraits<T>::StorageToWorking(storage);
+
+        ASSERT_EQ(actual, value);
+        ASSERT_EQ(model.ChangeListener().DirtyList().size(), 1u);
+        EXPECT_EQ(model.ChangeListener().DirtyList()[0], test.request.path);
+    }
+}
+
+template <typename T, EmberAfAttributeType ZclType>
+void TestEmberScalarNullWrite()
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4), MOCK_ATTRIBUTE_ID_FOR_NULLABLE_TYPE(ZclType)));
+
+    using NumericType             = NumericAttributeTraits<T>;
+    using NullableType            = chip::app::DataModel::Nullable<typename NumericType::WorkingType>;
+    AttributeValueDecoder decoder = test.DecoderFor<NullableType>(NullableType());
+
+    // write should succeed
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+
+    // Validate data after write
+    chip::ByteSpan writtenData = Test::GetEmberBuffer();
+
+    using Traits = NumericAttributeTraits<T>;
+
+    typename Traits::StorageType storage;
+    ASSERT_GE(writtenData.size(), sizeof(storage));
+    memcpy(&storage, writtenData.data(), sizeof(storage));
+    ASSERT_TRUE(Traits::IsNullValue(storage));
+}
+
+template <typename T, EmberAfAttributeType ZclType>
+void TestEmberScalarTypeWriteNullValueToNullable()
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(
+        kAdminSubjectDescriptor,
+        ConcreteAttributePath(kMockEndpoint3, MockClusterId(4), MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZclType)));
+
+    using NumericType             = NumericAttributeTraits<T>;
+    using NullableType            = chip::app::DataModel::Nullable<typename NumericType::WorkingType>;
+    AttributeValueDecoder decoder = test.DecoderFor<NullableType>(NullableType());
+
+    // write should fail: we are trying to write null
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_ERROR_WRONG_TLV_TYPE);
+}
+
+uint16_t ReadLe16(const void * buffer)
+{
+    const uint8_t * p = reinterpret_cast<const uint8_t *>(buffer);
+    return chip::Encoding::LittleEndian::Read16(p);
+}
+
+void WriteLe16(void * buffer, uint16_t value)
+{
+    uint8_t * p = reinterpret_cast<uint8_t *>(buffer);
+    chip::Encoding::LittleEndian::Write16(p, value);
+}
+
 } // namespace
 
 TEST(TestCodegenModelViaMocks, IterateOverEndpoints)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     // This iteration relies on the hard-coding that occurs when mock_ember is used
     EXPECT_EQ(model.FirstEndpoint(), kMockEndpoint1);
@@ -639,7 +914,7 @@ TEST(TestCodegenModelViaMocks, IterateOverEndpoints)
 TEST(TestCodegenModelViaMocks, IterateOverClusters)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     chip::Test::ResetVersion();
 
@@ -702,7 +977,7 @@ TEST(TestCodegenModelViaMocks, GetClusterInfo)
 {
 
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     chip::Test::ResetVersion();
 
@@ -727,7 +1002,7 @@ TEST(TestCodegenModelViaMocks, GetClusterInfo)
 TEST(TestCodegenModelViaMocks, IterateOverAttributes)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     // invalid paths should return in "no more data"
     ASSERT_FALSE(model.FirstAttribute(ConcreteClusterPath(kEndpointIdThatIsMissing, MockClusterId(1))).path.HasValidIds());
@@ -798,7 +1073,7 @@ TEST(TestCodegenModelViaMocks, IterateOverAttributes)
 TEST(TestCodegenModelViaMocks, GetAttributeInfo)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     // various non-existent or invalid paths should return no info data
     ASSERT_FALSE(
@@ -838,7 +1113,7 @@ TEST(TestCodegenModelViaMocks, GetAttributeInfo)
 TEST(TestCodegenModelViaMocks, GlobalAttributeInfo)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     std::optional<AttributeInfo> info = model.GetAttributeInfo(
         ConcreteAttributePath(kMockEndpoint1, MockClusterId(1), Clusters::Globals::Attributes::GeneratedCommandList::Id));
@@ -853,7 +1128,7 @@ TEST(TestCodegenModelViaMocks, GlobalAttributeInfo)
 TEST(TestCodegenModelViaMocks, IterateOverAcceptedCommands)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     // invalid paths should return in "no more data"
     ASSERT_FALSE(model.FirstAcceptedCommand(ConcreteClusterPath(kEndpointIdThatIsMissing, MockClusterId(1))).path.HasValidIds());
@@ -918,7 +1193,7 @@ TEST(TestCodegenModelViaMocks, IterateOverAcceptedCommands)
 TEST(TestCodegenModelViaMocks, AcceptedCommandInfo)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     // invalid paths should return in "no more data"
     ASSERT_FALSE(model.GetAcceptedCommandInfo(ConcreteCommandPath(kEndpointIdThatIsMissing, MockClusterId(1), 1)).has_value());
@@ -950,7 +1225,7 @@ TEST(TestCodegenModelViaMocks, AcceptedCommandInfo)
 TEST(TestCodegenModelViaMocks, IterateOverGeneratedCommands)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
 
     // invalid paths should return in "no more data"
     ASSERT_FALSE(model.FirstGeneratedCommand(ConcreteClusterPath(kEndpointIdThatIsMissing, MockClusterId(1))).HasValidIds());
@@ -1009,20 +1284,20 @@ TEST(TestCodegenModelViaMocks, IterateOverGeneratedCommands)
 TEST(TestCodegenModelViaMocks, EmberAttributeReadAclDeny)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(kDenySubjectDescriptor,
                                 ConcreteAttributePath(kMockEndpoint1, MockClusterId(1), MockAttributeId(10)));
     std::unique_ptr<AttributeValueEncoder> encoder = testRequest.StartEncoding(&model);
 
-    ASSERT_EQ(model.ReadAttribute(testRequest.request, *encoder), CHIP_ERROR_ACCESS_DENIED);
+    ASSERT_EQ(model.ReadAttribute(testRequest.request, *encoder), CHIP_IM_GLOBAL_STATUS(UnsupportedAccess));
 }
 
 TEST(TestCodegenModelViaMocks, ReadForInvalidGlobalAttributePath)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     {
@@ -1043,7 +1318,7 @@ TEST(TestCodegenModelViaMocks, ReadForInvalidGlobalAttributePath)
 TEST(TestCodegenModelViaMocks, EmberAttributeInvalidRead)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     // Invalid attribute
@@ -1077,7 +1352,7 @@ TEST(TestCodegenModelViaMocks, EmberAttributeInvalidRead)
 TEST(TestCodegenModelViaMocks, EmberAttributePathExpansionAccessDeniedRead)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(kDenySubjectDescriptor,
@@ -1095,7 +1370,7 @@ TEST(TestCodegenModelViaMocks, EmberAttributePathExpansionAccessDeniedRead)
 TEST(TestCodegenModelViaMocks, AccessInterfaceUnsupportedRead)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     const ConcreteAttributePath kTestPath(kMockEndpoint3, MockClusterId(4),
@@ -1199,7 +1474,7 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadNulls)
 TEST(TestCodegenModelViaMocks, EmberAttributeReadErrorReading)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     {
@@ -1227,12 +1502,15 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadErrorReading)
         std::unique_ptr<AttributeValueEncoder> encoder = testRequest.StartEncoding(&model);
         ASSERT_EQ(model.ReadAttribute(testRequest.request, *encoder), CHIP_IM_GLOBAL_STATUS(Busy));
     }
+
+    // reset things to success to not affect other tests
+    chip::Test::SetEmberReadOutput(ByteSpan());
 }
 
 TEST(TestCodegenModelViaMocks, EmberAttributeReadNullOctetString)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(kAdminSubjectDescriptor,
@@ -1267,7 +1545,7 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadNullOctetString)
 TEST(TestCodegenModelViaMocks, EmberAttributeReadOctetString)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(
@@ -1277,9 +1555,8 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadOctetString)
 
     // NOTE: This is a pascal string, so actual data is "test"
     //       the longer encoding is to make it clear we do not encode the overflow
-    char data[]  = "\0\0testing here with overflow";
-    uint16_t len = 4;
-    memcpy(data, &len, sizeof(uint16_t));
+    char data[] = "\0\0testing here with overflow";
+    WriteLe16(data, 4);
     chip::Test::SetEmberReadOutput(ByteSpan(reinterpret_cast<const uint8_t *>(data), sizeof(data)));
 
     // Actual read via an encoder
@@ -1307,7 +1584,7 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadOctetString)
 TEST(TestCodegenModelViaMocks, EmberAttributeReadLongOctetString)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(kAdminSubjectDescriptor,
@@ -1344,7 +1621,7 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadLongOctetString)
 TEST(TestCodegenModelViaMocks, EmberAttributeReadShortString)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(kAdminSubjectDescriptor,
@@ -1353,9 +1630,8 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadShortString)
 
     // NOTE: This is a pascal string, so actual data is "abcde"
     //       the longer encoding is to make it clear we do not encode the overflow
-    char data[]  = "\0abcdef...this is the alphabet";
-    uint16_t len = 5;
-    memcpy(data, &len, sizeof(uint8_t));
+    char data[] = "\0abcdef...this is the alphabet";
+    *data       = 5;
     chip::Test::SetEmberReadOutput(ByteSpan(reinterpret_cast<const uint8_t *>(data), sizeof(data)));
 
     // Actual read via an encoder
@@ -1381,7 +1657,7 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadShortString)
 TEST(TestCodegenModelViaMocks, EmberAttributeReadLongString)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(
@@ -1391,9 +1667,8 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadLongString)
 
     // NOTE: This is a pascal string, so actual data is "abcde"
     //       the longer encoding is to make it clear we do not encode the overflow
-    char data[]  = "\0\0abcdef...this is the alphabet";
-    uint16_t len = 5;
-    memcpy(data, &len, sizeof(uint16_t));
+    char data[] = "\0\0abcdef...this is the alphabet";
+    WriteLe16(data, 5);
     chip::Test::SetEmberReadOutput(ByteSpan(reinterpret_cast<const uint8_t *>(data), sizeof(data)));
 
     // Actual read via an encoder
@@ -1419,7 +1694,7 @@ TEST(TestCodegenModelViaMocks, EmberAttributeReadLongString)
 TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceStructRead)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     const ConcreteAttributePath kStructPath(kMockEndpoint3, MockClusterId(4),
@@ -1459,10 +1734,25 @@ TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceStructRead)
     ASSERT_TRUE(actual.e.data_equal("foo"_span));
 }
 
+TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceReadError)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    const ConcreteAttributePath kStructPath(kMockEndpoint3, MockClusterId(4),
+                                            MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_STRUCT_ATTRIBUTE_TYPE));
+
+    TestReadRequest testRequest(kAdminSubjectDescriptor, kStructPath);
+    RegisteredAttributeAccessInterface<ErrorAccessInterface> aai(kStructPath, CHIP_ERROR_KEY_NOT_FOUND);
+    std::unique_ptr<AttributeValueEncoder> encoder = testRequest.StartEncoding(&model);
+    ASSERT_EQ(model.ReadAttribute(testRequest.request, *encoder), CHIP_ERROR_KEY_NOT_FOUND);
+}
+
 TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceListRead)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     const ConcreteAttributePath kStructPath(kMockEndpoint3, MockClusterId(4),
@@ -1514,7 +1804,7 @@ TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceListRead)
 TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceListOverflowRead)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     const ConcreteAttributePath kStructPath(kMockEndpoint3, MockClusterId(4),
@@ -1573,7 +1863,7 @@ TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceListOverflowRead)
 TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceListIncrementalRead)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     const ConcreteAttributePath kStructPath(kMockEndpoint3, MockClusterId(4),
@@ -1635,7 +1925,7 @@ TEST(TestCodegenModelViaMocks, AttributeAccessInterfaceListIncrementalRead)
 TEST(TestCodegenModelViaMocks, ReadGlobalAttributeAttributeList)
 {
     UseMockNodeConfig config(gTestNodeConfig);
-    chip::app::CodegenDataModel model;
+    CodegenDataModelWithContext model;
     ScopedMockAccessControl accessControl;
 
     TestReadRequest testRequest(kAdminSubjectDescriptor,
@@ -1687,3 +1977,505 @@ TEST(TestCodegenModelViaMocks, ReadGlobalAttributeAttributeList)
         EXPECT_EQ(items[i], expected[i]);
     }
 }
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteAclDeny)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kDenySubjectDescriptor, ConcreteDataAttributePath(kMockEndpoint1, MockClusterId(1), MockAttributeId(10)));
+    AttributeValueDecoder decoder = test.DecoderFor<uint32_t>(1234);
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(UnsupportedAccess));
+    ASSERT_TRUE(model.ChangeListener().DirtyList().empty());
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteBasicTypes)
+{
+    TestEmberScalarTypeWrite<uint8_t, ZCL_INT8U_ATTRIBUTE_TYPE>(0x12);
+    TestEmberScalarTypeWrite<uint16_t, ZCL_ENUM16_ATTRIBUTE_TYPE>(0x1234);
+    TestEmberScalarTypeWrite<OddSizedInteger<3, false>, ZCL_INT24U_ATTRIBUTE_TYPE>(0x112233);
+    TestEmberScalarTypeWrite<uint32_t, ZCL_INT32U_ATTRIBUTE_TYPE>(0x11223344);
+    TestEmberScalarTypeWrite<OddSizedInteger<5, false>, ZCL_INT40U_ATTRIBUTE_TYPE>(0x1122334455ULL);
+    TestEmberScalarTypeWrite<OddSizedInteger<6, false>, ZCL_INT48U_ATTRIBUTE_TYPE>(0x112233445566ULL);
+    TestEmberScalarTypeWrite<OddSizedInteger<7, false>, ZCL_INT56U_ATTRIBUTE_TYPE>(0x11223344556677ULL);
+    TestEmberScalarTypeWrite<uint64_t, ZCL_INT64U_ATTRIBUTE_TYPE>(0x1122334455667788ULL);
+
+    TestEmberScalarTypeWrite<int8_t, ZCL_INT8S_ATTRIBUTE_TYPE>(-10);
+    TestEmberScalarTypeWrite<int16_t, ZCL_INT16S_ATTRIBUTE_TYPE>(-123);
+    TestEmberScalarTypeWrite<OddSizedInteger<3, true>, ZCL_INT24S_ATTRIBUTE_TYPE>(-1234);
+    TestEmberScalarTypeWrite<int32_t, ZCL_INT32S_ATTRIBUTE_TYPE>(-12345);
+    TestEmberScalarTypeWrite<OddSizedInteger<5, true>, ZCL_INT40S_ATTRIBUTE_TYPE>(-123456);
+    TestEmberScalarTypeWrite<OddSizedInteger<6, true>, ZCL_INT48S_ATTRIBUTE_TYPE>(-1234567);
+    TestEmberScalarTypeWrite<OddSizedInteger<7, true>, ZCL_INT56S_ATTRIBUTE_TYPE>(-12345678);
+    TestEmberScalarTypeWrite<int64_t, ZCL_INT64S_ATTRIBUTE_TYPE>(-123456789);
+
+    TestEmberScalarTypeWrite<bool, ZCL_BOOLEAN_ATTRIBUTE_TYPE>(true);
+    TestEmberScalarTypeWrite<bool, ZCL_BOOLEAN_ATTRIBUTE_TYPE>(false);
+    TestEmberScalarTypeWrite<float, ZCL_SINGLE_ATTRIBUTE_TYPE>(0.625);
+    TestEmberScalarTypeWrite<double, ZCL_DOUBLE_ATTRIBUTE_TYPE>(0.625);
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteInvalidValueToNullable)
+{
+    TestEmberScalarTypeWriteNullValueToNullable<uint8_t, ZCL_INT8U_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<uint16_t, ZCL_ENUM16_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<OddSizedInteger<3, false>, ZCL_INT24U_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<uint32_t, ZCL_INT32U_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<OddSizedInteger<5, false>, ZCL_INT40U_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<OddSizedInteger<6, false>, ZCL_INT48U_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<OddSizedInteger<7, false>, ZCL_INT56U_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<uint64_t, ZCL_INT64U_ATTRIBUTE_TYPE>();
+
+    TestEmberScalarTypeWriteNullValueToNullable<int8_t, ZCL_INT8S_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<int16_t, ZCL_INT16S_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<OddSizedInteger<3, true>, ZCL_INT24S_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<int32_t, ZCL_INT32S_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<OddSizedInteger<5, true>, ZCL_INT40S_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<OddSizedInteger<6, true>, ZCL_INT48S_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<OddSizedInteger<7, true>, ZCL_INT56S_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<int64_t, ZCL_INT64S_ATTRIBUTE_TYPE>();
+
+    TestEmberScalarTypeWriteNullValueToNullable<bool, ZCL_BOOLEAN_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<bool, ZCL_BOOLEAN_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<float, ZCL_SINGLE_ATTRIBUTE_TYPE>();
+    TestEmberScalarTypeWriteNullValueToNullable<double, ZCL_DOUBLE_ATTRIBUTE_TYPE>();
+}
+
+TEST(TestCodegenModelViaMocks, EmberTestWriteReservedNullPlaceholderToNullable)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(
+        kAdminSubjectDescriptor,
+        ConcreteAttributePath(kMockEndpoint3, MockClusterId(4), MOCK_ATTRIBUTE_ID_FOR_NULLABLE_TYPE(ZCL_INT32U_ATTRIBUTE_TYPE)));
+
+    using NumericType             = NumericAttributeTraits<uint32_t>;
+    using NullableType            = chip::app::DataModel::Nullable<typename NumericType::WorkingType>;
+    AttributeValueDecoder decoder = test.DecoderFor<NullableType>(0xFFFFFFFF);
+
+    // write should fail: we are trying to write null which is out of range
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(ConstraintError));
+}
+
+TEST(TestCodegenModelViaMocks, EmberTestWriteOutOfRepresentableRangeOddIntegerNonNullable)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_INT24U_ATTRIBUTE_TYPE)));
+
+    using NumericType             = NumericAttributeTraits<uint32_t>;
+    using NullableType            = chip::app::DataModel::Nullable<typename NumericType::WorkingType>;
+    AttributeValueDecoder decoder = test.DecoderFor<NullableType>(0x1223344);
+
+    // write should fail: written value is not in range
+    // NOTE: this matches legacy behaviour, however realistically maybe ConstraintError would be more correct
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_ERROR_INVALID_ARGUMENT);
+}
+
+TEST(TestCodegenModelViaMocks, EmberTestWriteOutOfRepresentableRangeOddIntegerNullable)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(
+        kAdminSubjectDescriptor,
+        ConcreteAttributePath(kMockEndpoint3, MockClusterId(4), MOCK_ATTRIBUTE_ID_FOR_NULLABLE_TYPE(ZCL_INT24U_ATTRIBUTE_TYPE)));
+
+    using NumericType             = NumericAttributeTraits<uint32_t>;
+    using NullableType            = chip::app::DataModel::Nullable<typename NumericType::WorkingType>;
+    AttributeValueDecoder decoder = test.DecoderFor<NullableType>(0x1223344);
+
+    // write should fail: written value is not in range
+    // NOTE: this matches legacy behaviour, however realistically maybe ConstraintError would be more correct
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_ERROR_INVALID_ARGUMENT);
+}
+
+TEST(TestCodegenModelViaMoceNullValueToNullables, EmberAttributeWriteBasicTypesLowestValue)
+{
+    TestEmberScalarTypeWrite<int8_t, ZCL_INT8S_ATTRIBUTE_TYPE>(-127);
+    TestEmberScalarTypeWrite<int16_t, ZCL_INT16S_ATTRIBUTE_TYPE>(-32767);
+    TestEmberScalarTypeWrite<OddSizedInteger<3, true>, ZCL_INT24S_ATTRIBUTE_TYPE>(-8388607);
+    TestEmberScalarTypeWrite<int32_t, ZCL_INT32S_ATTRIBUTE_TYPE>(-2147483647);
+    TestEmberScalarTypeWrite<OddSizedInteger<5, true>, ZCL_INT40S_ATTRIBUTE_TYPE>(-549755813887);
+    TestEmberScalarTypeWrite<OddSizedInteger<6, true>, ZCL_INT48S_ATTRIBUTE_TYPE>(-140737488355327);
+    TestEmberScalarTypeWrite<OddSizedInteger<7, true>, ZCL_INT56S_ATTRIBUTE_TYPE>(-36028797018963967);
+    TestEmberScalarTypeWrite<int64_t, ZCL_INT64S_ATTRIBUTE_TYPE>(-9223372036854775807);
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteNulls)
+{
+    TestEmberScalarNullWrite<uint8_t, ZCL_INT8U_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<uint16_t, ZCL_ENUM16_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<OddSizedInteger<3, false>, ZCL_INT24U_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<uint32_t, ZCL_INT32U_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<OddSizedInteger<5, false>, ZCL_INT40U_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<OddSizedInteger<6, false>, ZCL_INT48U_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<OddSizedInteger<7, false>, ZCL_INT56U_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<uint64_t, ZCL_INT64U_ATTRIBUTE_TYPE>();
+
+    TestEmberScalarNullWrite<int8_t, ZCL_INT8S_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<int16_t, ZCL_INT16S_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<OddSizedInteger<3, true>, ZCL_INT24S_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<int32_t, ZCL_INT32S_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<OddSizedInteger<5, true>, ZCL_INT40S_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<OddSizedInteger<6, true>, ZCL_INT48S_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<OddSizedInteger<7, true>, ZCL_INT56S_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<int64_t, ZCL_INT64S_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<bool, ZCL_BOOLEAN_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<float, ZCL_SINGLE_ATTRIBUTE_TYPE>();
+    TestEmberScalarNullWrite<double, ZCL_DOUBLE_ATTRIBUTE_TYPE>();
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteShortString)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_CHAR_STRING_ATTRIBUTE_TYPE)));
+    AttributeValueDecoder decoder = test.DecoderFor<CharSpan>("hello world"_span);
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+    chip::ByteSpan writtenData = GetEmberBuffer();
+    chip::CharSpan asCharSpan(reinterpret_cast<const char *>(writtenData.data()), writtenData[0] + 1);
+    ASSERT_TRUE(asCharSpan.data_equal("\x0Bhello world"_span));
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteLongStringOutOfBounds)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_LONG_CHAR_STRING_ATTRIBUTE_TYPE)));
+
+    // Mocks allow for 16 bytes only by default for string attributes
+    AttributeValueDecoder decoder = test.DecoderFor<CharSpan>(
+        "this is a very long string that will be longer than the default attribute size for our mocks"_span);
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(InvalidValue));
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteLongString)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_LONG_CHAR_STRING_ATTRIBUTE_TYPE)));
+    AttributeValueDecoder decoder = test.DecoderFor<CharSpan>("text"_span);
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+    chip::ByteSpan writtenData = GetEmberBuffer();
+
+    uint16_t len = ReadLe16(writtenData.data());
+    EXPECT_EQ(len, 4);
+    chip::CharSpan asCharSpan(reinterpret_cast<const char *>(writtenData.data() + 2), 4);
+
+    ASSERT_TRUE(asCharSpan.data_equal("text"_span));
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteNullableLongStringValue)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NULLABLE_TYPE(ZCL_LONG_CHAR_STRING_ATTRIBUTE_TYPE)));
+    AttributeValueDecoder decoder =
+        test.DecoderFor<chip::app::DataModel::Nullable<CharSpan>>(chip::app::DataModel::MakeNullable("text"_span));
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+    chip::ByteSpan writtenData = GetEmberBuffer();
+
+    uint16_t len = ReadLe16(writtenData.data());
+    EXPECT_EQ(len, 4);
+    chip::CharSpan asCharSpan(reinterpret_cast<const char *>(writtenData.data() + 2), 4);
+
+    ASSERT_TRUE(asCharSpan.data_equal("text"_span));
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteLongNullableStringNull)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NULLABLE_TYPE(ZCL_LONG_CHAR_STRING_ATTRIBUTE_TYPE)));
+    AttributeValueDecoder decoder =
+        test.DecoderFor<chip::app::DataModel::Nullable<CharSpan>>(chip::app::DataModel::Nullable<CharSpan>());
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+    chip::ByteSpan writtenData = GetEmberBuffer();
+    ASSERT_EQ(writtenData[0], 0xFF);
+    ASSERT_EQ(writtenData[1], 0xFF);
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteShortBytes)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_OCTET_STRING_ATTRIBUTE_TYPE)));
+    uint8_t buffer[] = { 11, 12, 13 };
+
+    AttributeValueDecoder decoder = test.DecoderFor<ByteSpan>(ByteSpan(buffer));
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+    chip::ByteSpan writtenData = GetEmberBuffer();
+
+    EXPECT_EQ(writtenData[0], 3u);
+    EXPECT_EQ(writtenData[1], 11u);
+    EXPECT_EQ(writtenData[2], 12u);
+    EXPECT_EQ(writtenData[3], 13u);
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteLongBytes)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_LONG_OCTET_STRING_ATTRIBUTE_TYPE)));
+    uint8_t buffer[] = { 11, 12, 13 };
+
+    AttributeValueDecoder decoder = test.DecoderFor<ByteSpan>(ByteSpan(buffer));
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+    chip::ByteSpan writtenData = GetEmberBuffer();
+
+    uint16_t len = ReadLe16(writtenData.data());
+    EXPECT_EQ(len, 3);
+
+    EXPECT_EQ(writtenData[2], 11u);
+    EXPECT_EQ(writtenData[3], 12u);
+    EXPECT_EQ(writtenData[4], 13u);
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteTimedWrite)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor, ConcreteAttributePath(kMockEndpoint3, MockClusterId(4), kAttributeIdTimedWrite));
+    AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(NeedsTimedInteraction));
+
+    // writing as timed should be fine
+    test.request.writeFlags.Set(WriteFlags::kTimed);
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteReadOnlyAttribute)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor, ConcreteAttributePath(kMockEndpoint3, MockClusterId(4), kAttributeIdReadOnly));
+    AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(UnsupportedWrite));
+
+    // Internal writes bypass the read only requirement
+    test.request.operationFlags.Set(OperationFlags::kInternal);
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+}
+
+TEST(TestCodegenModelViaMocks, EmberAttributeWriteDataVersion)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_INT32S_ATTRIBUTE_TYPE)));
+
+    // Initialize to some version
+    ResetVersion();
+    BumpVersion();
+    test.request.path.mDataVersion = MakeOptional(GetVersion());
+
+    // Make version invalid
+    BumpVersion();
+
+    AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(DataVersionMismatch));
+
+    // Write passes if we set the right version for the data
+    test.request.path.mDataVersion = MakeOptional(GetVersion());
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+}
+
+TEST(TestCodegenModelViaMocks, WriteToInvalidPath)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    {
+        TestWriteRequest test(kAdminSubjectDescriptor, ConcreteAttributePath(kInvalidEndpointId, MockClusterId(1234), 1234));
+        AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+        ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(UnsupportedEndpoint));
+    }
+    {
+        TestWriteRequest test(kAdminSubjectDescriptor, ConcreteAttributePath(kMockEndpoint1, MockClusterId(1234), 1234));
+        AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+        ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(UnsupportedCluster));
+    }
+
+    {
+        TestWriteRequest test(kAdminSubjectDescriptor, ConcreteAttributePath(kMockEndpoint1, MockClusterId(1), 1234));
+        AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+        ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(UnsupportedAttribute));
+    }
+}
+
+TEST(TestCodegenModelViaMocks, WriteToGlobalAttribute)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor, ConcreteAttributePath(kMockEndpoint1, MockClusterId(1), AttributeList::Id));
+    AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(UnsupportedWrite));
+}
+
+TEST(TestCodegenModelViaMocks, EmberWriteFailure)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    TestWriteRequest test(kAdminSubjectDescriptor,
+                          ConcreteAttributePath(kMockEndpoint3, MockClusterId(4),
+                                                MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_INT32S_ATTRIBUTE_TYPE)));
+
+    {
+        AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+        chip::Test::SetEmberReadOutput(Protocols::InteractionModel::Status::Failure);
+        ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(Failure));
+    }
+    {
+        AttributeValueDecoder decoder = test.DecoderFor<int32_t>(1234);
+        chip::Test::SetEmberReadOutput(Protocols::InteractionModel::Status::Busy);
+        ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(Busy));
+    }
+    // reset things to success to not affect other tests
+    chip::Test::SetEmberReadOutput(ByteSpan());
+}
+
+TEST(TestCodegenModelViaMocks, EmberWriteAttributeAccessInterfaceTest)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    const ConcreteAttributePath kStructPath(kMockEndpoint3, MockClusterId(4),
+                                            MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_STRUCT_ATTRIBUTE_TYPE));
+    RegisteredAttributeAccessInterface<StructAttributeAccessInterface> aai(kStructPath);
+
+    TestWriteRequest test(kAdminSubjectDescriptor, kStructPath);
+    Clusters::UnitTesting::Structs::SimpleStruct::Type testValue{
+        .a = 112,
+        .b = true,
+        .e = "aai_write_test"_span,
+        .g = 0.5,
+        .h = 0.125,
+    };
+
+    AttributeValueDecoder decoder = test.DecoderFor(testValue);
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_NO_ERROR);
+
+    EXPECT_EQ(aai->GetData().a, 112);
+    EXPECT_TRUE(aai->GetData().e.data_equal("aai_write_test"_span));
+
+    // AAI marks dirty paths
+    ASSERT_EQ(model.ChangeListener().DirtyList().size(), 1u);
+    EXPECT_EQ(model.ChangeListener().DirtyList()[0], kStructPath);
+
+    // AAI does not prevent read/write of regular attributes
+    // validate that once AAI is added, we still can go through writing regular bits (i.e.
+    // AAI returning "unknown" has fallback to ember)
+    TestEmberScalarTypeWrite<uint32_t, ZCL_INT32U_ATTRIBUTE_TYPE>(1234);
+    TestEmberScalarNullWrite<int64_t, ZCL_INT64S_ATTRIBUTE_TYPE>();
+}
+
+TEST(TestCodegenModelViaMocks, EmberWriteAttributeAccessInterfaceReturningError)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    const ConcreteAttributePath kStructPath(kMockEndpoint3, MockClusterId(4),
+                                            MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_STRUCT_ATTRIBUTE_TYPE));
+    RegisteredAttributeAccessInterface<ErrorAccessInterface> aai(kStructPath, CHIP_ERROR_KEY_NOT_FOUND);
+
+    TestWriteRequest test(kAdminSubjectDescriptor, kStructPath);
+    Clusters::UnitTesting::Structs::SimpleStruct::Type testValue{
+        .a = 112,
+        .b = true,
+        .e = "aai_write_test"_span,
+        .g = 0.5,
+        .h = 0.125,
+    };
+
+    AttributeValueDecoder decoder = test.DecoderFor(testValue);
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_ERROR_KEY_NOT_FOUND);
+    ASSERT_TRUE(model.ChangeListener().DirtyList().empty());
+}
+
+TEST(TestCodegenModelViaMocks, EmberWriteInvalidDataType)
+{
+    UseMockNodeConfig config(gTestNodeConfig);
+    CodegenDataModelWithContext model;
+    ScopedMockAccessControl accessControl;
+
+    const ConcreteAttributePath kStructPath(kMockEndpoint3, MockClusterId(4),
+                                            MOCK_ATTRIBUTE_ID_FOR_NON_NULLABLE_TYPE(ZCL_STRUCT_ATTRIBUTE_TYPE));
+
+    TestWriteRequest test(kAdminSubjectDescriptor, kStructPath);
+    Clusters::UnitTesting::Structs::SimpleStruct::Type testValue{
+        .a = 112,
+        .b = true,
+        .e = "aai_write_test"_span,
+        .g = 0.5,
+        .h = 0.125,
+    };
+
+    AttributeValueDecoder decoder = test.DecoderFor(testValue);
+
+    // Embed specifically DOES NOT support structures.
+    // Without AAI, we expect a data type error (translated to failure)
+    ASSERT_EQ(model.WriteAttribute(test.request, decoder), CHIP_IM_GLOBAL_STATUS(Failure));
+    ASSERT_TRUE(model.ChangeListener().DirtyList().empty());
+}
diff --git a/src/app/data-model-interface/DataModel.h b/src/app/data-model-interface/DataModel.h
index 04911fd75cccc3..d673b79aac72a9 100644
--- a/src/app/data-model-interface/DataModel.h
+++ b/src/app/data-model-interface/DataModel.h
@@ -58,7 +58,7 @@ class DataModel : public DataModelMetadataTree
     /// TEMPORARY/TRANSITIONAL requirement for transitioning from ember-specific code
     ///   ReadAttribute is REQUIRED to perform:
     ///     - ACL validation (see notes on OperationFlags::kInternal)
-    ///     - Validation of readability/writability
+    ///     - Validation of readability/writability (also controlled by OperationFlags::kInternal)
     ///     - use request.path.mExpanded to skip encoding replies for data according
     ///       to 8.4.3.2 of the spec:
     ///         > If the path indicates attribute data that is not readable, then the path SHALL
@@ -84,8 +84,11 @@ class DataModel : public DataModelMetadataTree
     /// When this is invoked, caller is expected to have already done some validations:
     ///    - cluster `data version` has been checked for the incoming request if applicable
     ///
-    /// When `request.writeFlags.Has(WriteFlags::kForceInternal)` the request is from an internal app update
-    /// and SHOULD bypass some internal checks (like timed enforcement, potentially read-only restrictions)
+    /// TEMPORARY/TRANSITIONAL requirement for transitioning from ember-specific code
+    ///   WriteAttribute is REQUIRED to perform:
+    ///     - ACL validation (see notes on OperationFlags::kInternal)
+    ///     - Validation of readability/writability (also controlled by OperationFlags::kInternal)
+    ///     - Validation of timed interaction required (also controlled by OperationFlags::kInternal)
     ///
     /// Return codes
     ///   CHIP_IM_GLOBAL_STATUS(code):
diff --git a/src/app/data-model-interface/DataModelChangeListener.h b/src/app/data-model-interface/DataModelChangeListener.h
index 37c278c76b92c8..a5ba12684baffe 100644
--- a/src/app/data-model-interface/DataModelChangeListener.h
+++ b/src/app/data-model-interface/DataModelChangeListener.h
@@ -16,7 +16,7 @@
  */
 #pragma once
 
-#include <app/AttributePathParams.h>
+#include <app/ConcreteAttributePath.h>
 
 namespace chip {
 namespace app {
@@ -34,12 +34,12 @@ namespace InteractionModel {
 class DataModelChangeListener
 {
 public:
-    virtual ~DataModelChangeListener() = 0;
+    virtual ~DataModelChangeListener() = default;
 
     /// Mark all attributes matching the given path (which may be a wildcard) dirty.
     ///
     /// Wildcards are supported.
-    virtual void MarkDirty(const AttributePathParams & path) = 0;
+    virtual void MarkDirty(const ConcreteAttributePath & path) = 0;
 };
 
 } // namespace InteractionModel
diff --git a/src/app/util/ember-compatibility-functions.cpp b/src/app/util/ember-compatibility-functions.cpp
index cc3185f78a5df6..676197be9ab2f2 100644
--- a/src/app/util/ember-compatibility-functions.cpp
+++ b/src/app/util/ember-compatibility-functions.cpp
@@ -570,7 +570,7 @@ CHIP_ERROR ReadSingleClusterData(const SubjectDescriptor & aSubjectDescriptor, b
         }
         default:
             ChipLogError(DataManagement, "Attribute type 0x%x not handled", static_cast<int>(attributeType));
-            status = Status::UnsupportedRead;
+            status = Status::Failure;
         }
     }
 
diff --git a/src/app/util/mock/MockNodeConfig.cpp b/src/app/util/mock/MockNodeConfig.cpp
index 5670966e2ebb29..b48c846e65072d 100644
--- a/src/app/util/mock/MockNodeConfig.cpp
+++ b/src/app/util/mock/MockNodeConfig.cpp
@@ -26,6 +26,63 @@
 
 namespace chip {
 namespace Test {
+namespace internal {
+uint16_t SizeForType(EmberAfAttributeType type)
+{
+    switch (type)
+    {
+    case ZCL_BOOLEAN_ATTRIBUTE_TYPE:
+    case ZCL_INT8S_ATTRIBUTE_TYPE:
+    case ZCL_INT8U_ATTRIBUTE_TYPE:
+    case ZCL_BITMAP8_ATTRIBUTE_TYPE:
+    case ZCL_ENUM8_ATTRIBUTE_TYPE:
+        return 1;
+    case ZCL_INT16S_ATTRIBUTE_TYPE:
+    case ZCL_INT16U_ATTRIBUTE_TYPE:
+    case ZCL_BITMAP16_ATTRIBUTE_TYPE:
+    case ZCL_ENUM16_ATTRIBUTE_TYPE:
+        return 2;
+    case ZCL_INT24S_ATTRIBUTE_TYPE:
+    case ZCL_INT24U_ATTRIBUTE_TYPE:
+        return 3;
+    case ZCL_SINGLE_ATTRIBUTE_TYPE:
+    case ZCL_INT32S_ATTRIBUTE_TYPE:
+    case ZCL_INT32U_ATTRIBUTE_TYPE:
+    case ZCL_BITMAP32_ATTRIBUTE_TYPE:
+        return 4;
+    case ZCL_INT40S_ATTRIBUTE_TYPE:
+    case ZCL_INT40U_ATTRIBUTE_TYPE:
+        return 5;
+    case ZCL_INT48S_ATTRIBUTE_TYPE:
+    case ZCL_INT48U_ATTRIBUTE_TYPE:
+        return 6;
+    case ZCL_INT56S_ATTRIBUTE_TYPE:
+    case ZCL_INT56U_ATTRIBUTE_TYPE:
+        return 7;
+    case ZCL_DOUBLE_ATTRIBUTE_TYPE:
+    case ZCL_INT64S_ATTRIBUTE_TYPE:
+    case ZCL_INT64U_ATTRIBUTE_TYPE:
+    case ZCL_BITMAP64_ATTRIBUTE_TYPE:
+        return 8;
+    case ZCL_CHAR_STRING_ATTRIBUTE_TYPE:
+    case ZCL_LONG_CHAR_STRING_ATTRIBUTE_TYPE:
+    case ZCL_OCTET_STRING_ATTRIBUTE_TYPE:
+    case ZCL_LONG_OCTET_STRING_ATTRIBUTE_TYPE:
+        return kDefaultStringSize;
+    case ZCL_ARRAY_ATTRIBUTE_TYPE:
+    case ZCL_STRUCT_ATTRIBUTE_TYPE:
+        // These items are using AAI and are acceptable for tests
+        return 0;
+    default:
+        // this type of attribute is not supported for tests, we cannot guess its size
+        //
+        // see attribute-type.h for a list of attributes
+        ChipLogError(Test, "Warning: size for attribute type 0x%x is not set", type);
+        return 0;
+    }
+}
+
+} // namespace internal
 
 namespace {
 
diff --git a/src/app/util/mock/MockNodeConfig.h b/src/app/util/mock/MockNodeConfig.h
index 55649e00935c83..10110604678c20 100644
--- a/src/app/util/mock/MockNodeConfig.h
+++ b/src/app/util/mock/MockNodeConfig.h
@@ -31,6 +31,12 @@ namespace Test {
 
 namespace internal {
 
+constexpr uint16_t kDefaultStringSize = 16; // note: this is INCLUDING the length byte(s)
+
+// Determine an appropriate size for the given type.
+// NOTE: this is for test only, not all types are included
+uint16_t SizeForType(EmberAfAttributeType type);
+
 constexpr EmberAfAttributeMetadata DefaultAttributeMetadata(chip::AttributeId id)
 {
     return EmberAfAttributeMetadata{
@@ -54,6 +60,7 @@ struct MockAttributeConfig
     {
         attributeMetaData.attributeType = type;
         attributeMetaData.mask          = mask;
+        attributeMetaData.size          = internal::SizeForType(type);
     }
 
     const AttributeId id;