From c1ef5671a2a9fb4a58140c674982a247bfa2ce91 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 05:03:54 +0700 Subject: [PATCH 1/6] Define BRCB recovery state format v2 --- include/ariec61850/mms/static_brcb_state_codec.hpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/include/ariec61850/mms/static_brcb_state_codec.hpp b/include/ariec61850/mms/static_brcb_state_codec.hpp index 18f58da74..32c85ad1e 100644 --- a/include/ariec61850/mms/static_brcb_state_codec.hpp +++ b/include/ariec61850/mms/static_brcb_state_codec.hpp @@ -28,16 +28,23 @@ struct MmsStaticBrcbStateResult final { } }; -// Storage-agnostic bounded state image for the already-buffered BRCB queue. -// Pending BufTm coalescing is intentionally transient and is not restored. +// Storage-agnostic bounded BRCB recovery image. +// +// v1 persisted only the still-undelivered suffix. v2 persists the entire +// retained replay window plus the independent delivery cursor and replay-gap +// flag. Pending BufTm coalescing, live association ownership and RptEna remain +// deliberately transient and are never restored across reboot. class MmsStaticBrcbStateCodec final { public: - static constexpr std::uint16_t format_version = 1U; + static constexpr std::uint16_t legacy_format_version = 1U; + static constexpr std::uint16_t format_version = 2U; [[nodiscard]] static MmsStaticBrcbStateResult encode( const MmsStaticBrcbRuntime& runtime, std::span destination) noexcept; + // Accepts both v2 and legacy v1 images. Legacy v1 restores as an + // undelivered-only queue with delivery cursor at the oldest entry. [[nodiscard]] static MmsStaticBrcbStateResult restore( MmsStaticBrcbRuntime& runtime, std::span source) noexcept; From 83ee8969e09afb798e3b94324b19308529835ba3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 05:04:38 +0700 Subject: [PATCH 2/6] Persist BRCB retained history and replay cursor --- src/mms/static_brcb_state_codec.cpp | 103 ++++++++++++++++++---------- 1 file changed, 67 insertions(+), 36 deletions(-) diff --git a/src/mms/static_brcb_state_codec.cpp b/src/mms/static_brcb_state_codec.cpp index b30c39935..808b3f499 100644 --- a/src/mms/static_brcb_state_codec.cpp +++ b/src/mms/static_brcb_state_codec.cpp @@ -15,10 +15,14 @@ namespace ar::iec61850::mms { namespace { -constexpr std::array kMagic{ +constexpr std::array kMagicV1{ 'A','R','B','R','C','B','Q','1'}; -constexpr std::size_t kHeaderBytes = 48U; +constexpr std::array kMagicV2{ + 'A','R','B','R','C','B','Q','2'}; +constexpr std::size_t kHeaderBytesV1 = 48U; +constexpr std::size_t kHeaderBytesV2 = 56U; constexpr std::size_t kEntryHeaderBytes = 16U; +constexpr std::uint8_t kReplayGapFlag = 0x01U; constexpr std::uint64_t kFnvOffset = 14695981039346656037ULL; constexpr std::uint64_t kFnvPrime = 1099511628211ULL; @@ -145,6 +149,13 @@ void clear_slots(const std::span slots) noexcept { } } +[[nodiscard]] bool magic_matches( + const std::span source, + const std::span magic) noexcept { + return source.size() >= magic.size() && + std::equal(magic.begin(), magic.end(), source.begin()); +} + } // namespace MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::encode( @@ -153,26 +164,18 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::encode( MmsStaticBrcbStateResult result; if (!runtime.initialized_ || runtime.definition_ == nullptr || runtime.pending_ == nullptr || runtime.slots_.empty() || + runtime.head_ >= runtime.slots_.size() || runtime.count_ > runtime.slots_.size() || - runtime.delivery_offset_ > runtime.count_) { - result.status = MmsStaticBrcbStateStatus::invalid_runtime; - return result; - } - - // Checkpoint format v1 intentionally persists only reports that are still - // selected for delivery. Delivered retained history is a volatile R1 replay - // window; persisting its cursor/history requires an explicit future format - // revision so reboot behavior cannot silently change. - const auto persisted_count = runtime.queue_size(); - if (persisted_count > std::numeric_limits::max()) { + runtime.delivery_offset_ > runtime.count_ || + runtime.count_ > std::numeric_limits::max() || + runtime.delivery_offset_ > std::numeric_limits::max()) { result.status = MmsStaticBrcbStateStatus::invalid_runtime; return result; } - std::size_t required = kHeaderBytes; - for (std::size_t logical = 0U; logical < persisted_count; ++logical) { - const auto physical = - (runtime.head_ + runtime.delivery_offset_ + logical) % runtime.slots_.size(); + std::size_t required = kHeaderBytesV2; + for (std::size_t logical = 0U; logical < runtime.count_; ++logical) { + const auto physical = (runtime.head_ + logical) % runtime.slots_.size(); const auto& slot = runtime.slots_[physical]; if (!slot.occupied || slot.bytes == 0U || slot.bytes > slot.storage.size() || slot.bytes > std::numeric_limits::max() || @@ -190,19 +193,20 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::encode( auto output = destination.first(required); std::fill(output.begin(), output.end(), std::uint8_t{0U}); - std::copy(kMagic.begin(), kMagic.end(), output.begin()); + std::copy(kMagicV2.begin(), kMagicV2.end(), output.begin()); write_u16(output, 8U, format_version); - write_u16(output, 10U, static_cast(kHeaderBytes)); + write_u16(output, 10U, static_cast(kHeaderBytesV2)); write_u64(output, 12U, definition_fingerprint(*runtime.definition_)); - write_u32(output, 20U, static_cast(persisted_count)); + write_u32(output, 20U, static_cast(runtime.count_)); write_u64(output, 24U, runtime.next_entry_number_); write_u64(output, 32U, runtime.dropped_reports_); output[40U] = runtime.sequence_number_; + output[41U] = runtime.replay_gap_ ? kReplayGapFlag : 0U; + write_u32(output, 44U, static_cast(runtime.delivery_offset_)); - std::size_t offset = kHeaderBytes; - for (std::size_t logical = 0U; logical < persisted_count; ++logical) { - const auto physical = - (runtime.head_ + runtime.delivery_offset_ + logical) % runtime.slots_.size(); + std::size_t offset = kHeaderBytesV2; + for (std::size_t logical = 0U; logical < runtime.count_; ++logical) { + const auto physical = (runtime.head_ + logical) % runtime.slots_.size(); const auto& slot = runtime.slots_[physical]; write_u32(output, offset, static_cast(slot.bytes)); std::copy( @@ -234,19 +238,46 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( result.status = MmsStaticBrcbStateStatus::invalid_runtime; return result; } - if (source.size() < kHeaderBytes || - !std::equal(kMagic.begin(), kMagic.end(), source.begin()) || - read_u16(source, 8U) != format_version || - read_u16(source, 10U) != static_cast(kHeaderBytes)) { + + const bool is_v2 = source.size() >= kHeaderBytesV2 && + magic_matches(source, kMagicV2) && + read_u16(source, 8U) == format_version && + read_u16(source, 10U) == static_cast(kHeaderBytesV2); + const bool is_v1 = source.size() >= kHeaderBytesV1 && + magic_matches(source, kMagicV1) && + read_u16(source, 8U) == legacy_format_version && + read_u16(source, 10U) == static_cast(kHeaderBytesV1); + if (!is_v2 && !is_v1) { result.status = MmsStaticBrcbStateStatus::invalid_state; return result; } - for (std::size_t index = 41U; index < kHeaderBytes; ++index) { - if (source[index] != 0U) { + + const auto header_bytes = is_v2 ? kHeaderBytesV2 : kHeaderBytesV1; + std::size_t delivery_offset = 0U; + bool replay_gap = false; + if (is_v2) { + if ((source[41U] & static_cast(~kReplayGapFlag)) != 0U || + source[42U] != 0U || source[43U] != 0U) { result.status = MmsStaticBrcbStateStatus::invalid_state; return result; } + for (std::size_t index = 48U; index < kHeaderBytesV2; ++index) { + if (source[index] != 0U) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + } + replay_gap = (source[41U] & kReplayGapFlag) != 0U; + delivery_offset = static_cast(read_u32(source, 44U)); + } else { + for (std::size_t index = 41U; index < kHeaderBytesV1; ++index) { + if (source[index] != 0U) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + } } + if (read_u64(source, 12U) != definition_fingerprint(*runtime.definition_)) { result.status = MmsStaticBrcbStateStatus::definition_mismatch; return result; @@ -260,12 +291,12 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( result.status = MmsStaticBrcbStateStatus::capacity_mismatch; return result; } - if (restored_next_entry == 0U) { + if (delivery_offset > count || restored_next_entry == 0U) { result.status = MmsStaticBrcbStateStatus::invalid_state; return result; } - std::size_t offset = kHeaderBytes; + std::size_t offset = header_bytes; std::uint64_t previous_entry_number = 0U; std::uint8_t last_sequence = 0U; for (std::size_t logical = 0U; logical < count; ++logical) { @@ -309,7 +340,7 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( } clear_slots(runtime.slots_); - offset = kHeaderBytes; + offset = header_bytes; for (std::size_t logical = 0U; logical < count; ++logical) { auto& slot = runtime.slots_[logical]; const auto pdu_bytes = static_cast(read_u32(source, offset)); @@ -331,11 +362,11 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( runtime.head_ = 0U; runtime.count_ = count; - runtime.delivery_offset_ = 0U; + runtime.delivery_offset_ = delivery_offset; runtime.next_entry_number_ = restored_next_entry; runtime.dropped_reports_ = restored_dropped; runtime.sequence_number_ = restored_sequence; - runtime.replay_gap_ = false; + runtime.replay_gap_ = replay_gap; runtime.queue_revision_ = runtime.queue_revision_ == std::numeric_limits::max() ? 1U @@ -349,4 +380,4 @@ MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( return result; } -} // namespace ar::iec61850::mms \ No newline at end of file +} // namespace ar::iec61850::mms From 2f87bf372d21656dc32cd375c5f3eaec0038b0b8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 05:05:32 +0700 Subject: [PATCH 3/6] Add BRCB recovery v2 replay-history smoke --- ...ms_brcb_recovery_v2_hard_profile_smoke.cpp | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 embedded/mms_brcb_recovery_v2_hard_profile_smoke.cpp diff --git a/embedded/mms_brcb_recovery_v2_hard_profile_smoke.cpp b/embedded/mms_brcb_recovery_v2_hard_profile_smoke.cpp new file mode 100644 index 000000000..9a8123661 --- /dev/null +++ b/embedded/mms_brcb_recovery_v2_hard_profile_smoke.cpp @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_runtime.hpp" +#include "ariec61850/mms/static_brcb_state_codec.hpp" + +#include +#include +#include +#include +#include + +namespace { +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +bool g_value = true; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +const std::array kObjects{ + mms::MmsStaticObjectEntry{ + "LD0", "X1", kBooleanType, read_boolean, &g_value, false}}; +const mms::MmsStaticObjectTable kObjectTable{kObjects}; +const std::array kMembers{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; +const std::array kDataSets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", kMembers, false}}; +const mms::MmsStaticDataSetTable kDataSetTable{kDataSets}; + +const mms::MmsStaticBrcbDefinition kDefinition{ + "LD0", + "LLN0$BR$Events", + "LD0/LLN0$BR$Events", + "LD0", + "LLN0$Events", + 23U, + {0x7FU, 0x80U}, + 0U, + 0x70U}; + +struct RuntimeFixture final { + std::array slot0{}; + std::array slot1{}; + std::array slot2{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}, + mms::MmsStaticBrcbSlot{slot1}, + mms::MmsStaticBrcbSlot{slot2}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime runtime{ + kDefinition, pending, slots, kObjectTable, kDataSetTable}; +}; + +[[nodiscard]] bool capture_one( + mms::MmsStaticBrcbRuntime& runtime, + const std::uint64_t now_ms, + const std::span staging, + const std::span workspace) noexcept { + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, now_ms) != + mms::MmsStaticBrcbStatus::ok) { + return false; + } + mms::MmsStaticBrcbCapturePlan plan; + if (!runtime.next_due(now_ms, plan)) { + return false; + } + return runtime.capture(plan, kReportTime, staging, workspace).success(); +} + +[[nodiscard]] bool entry_is( + const mms::MmsStaticBrcbEntryView& entry, + const std::uint8_t low_byte) noexcept { + return entry.entry_id.size() == 8U && entry.entry_id[7U] == low_byte; +} + +[[nodiscard]] bool build_legacy_v1_from_v2( + const std::span v2, + const std::span legacy, + std::size_t& legacy_bytes) noexcept { + constexpr std::size_t v1_header = 48U; + constexpr std::size_t v2_header = 56U; + if (v2.size() < v2_header || legacy.size() < v2.size() - 8U || + v2[0] != 'A' || v2[1] != 'R' || v2[7] != '2') { + legacy_bytes = 0U; + return false; + } + + legacy_bytes = v2.size() - (v2_header - v1_header); + std::fill(legacy.begin(), legacy.begin() + static_cast(legacy_bytes), + std::uint8_t{0U}); + std::copy_n(v2.begin(), 41U, legacy.begin()); + legacy[7U] = static_cast('1'); + legacy[8U] = 0U; + legacy[9U] = 1U; + legacy[10U] = 0U; + legacy[11U] = static_cast(v1_header); + std::copy( + v2.begin() + static_cast(v2_header), + v2.end(), + legacy.begin() + static_cast(v1_header)); + return true; +} + +} // namespace + +int main() { + std::array state{}; + std::array legacy{}; + std::array staging{}; + std::array workspace{}; + + // v2 must preserve delivered retained history separately from the delivery + // cursor. Normal post-reboot delivery resumes at E2, while replay can still + // seek back to the already-delivered E1. + RuntimeFixture source; + if (!source.runtime.initialize() || + source.runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 1; + } + for (std::uint64_t now = 100U; now <= 300U; now += 100U) { + g_value = !g_value; + if (!capture_one(source.runtime, now, staging, workspace)) { + return 2; + } + } + mms::MmsStaticBrcbEntryView front; + std::array entry1{}; + if (source.runtime.retained_size() != 3U || source.runtime.queue_size() != 3U || + !source.runtime.front(front) || !entry_is(front, 1U)) { + return 3; + } + std::copy(front.entry_id.begin(), front.entry_id.end(), entry1.begin()); + if (source.runtime.commit_delivery(front.entry_id) != mms::MmsStaticBrcbStatus::ok || + source.runtime.retained_size() != 3U || source.runtime.queue_size() != 2U || + !source.runtime.front(front) || !entry_is(front, 2U)) { + return 4; + } + + const auto encoded = mms::MmsStaticBrcbStateCodec::encode(source.runtime, state); + if (!encoded.success() || encoded.bytes_written == 0U || + state[7U] != static_cast('2') || + state[9U] != mms::MmsStaticBrcbStateCodec::format_version) { + return 5; + } + + RuntimeFixture recovered; + if (!recovered.runtime.initialize()) { + return 6; + } + const auto restored = mms::MmsStaticBrcbStateCodec::restore( + recovered.runtime, + std::span{state}.first(encoded.bytes_written)); + if (!restored.success() || recovered.runtime.enabled() || + recovered.runtime.retained_size() != 3U || recovered.runtime.queue_size() != 2U || + recovered.runtime.replay_gap() || !recovered.runtime.front(front) || + !entry_is(front, 2U)) { + return 7; + } + if (recovered.runtime.rewind_to_oldest() != mms::MmsStaticBrcbStatus::ok || + recovered.runtime.queue_size() != 3U || !recovered.runtime.front(front) || + !entry_is(front, 1U) || + recovered.runtime.resume_after(entry1) != mms::MmsStaticBrcbStatus::ok || + recovered.runtime.queue_size() != 2U || !recovered.runtime.front(front) || + !entry_is(front, 2U)) { + return 8; + } + + // Backward restore: a v1 image has no delivered-history cursor, so it is + // intentionally interpreted as an all-undelivered queue. + RuntimeFixture legacy_source; + if (!legacy_source.runtime.initialize() || + legacy_source.runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok || + !capture_one(legacy_source.runtime, 100U, staging, workspace) || + !capture_one(legacy_source.runtime, 200U, staging, workspace)) { + return 9; + } + const auto legacy_v2 = mms::MmsStaticBrcbStateCodec::encode( + legacy_source.runtime, state); + std::size_t legacy_bytes{}; + if (!legacy_v2.success() || + !build_legacy_v1_from_v2( + std::span{state}.first(legacy_v2.bytes_written), + legacy, + legacy_bytes)) { + return 10; + } + RuntimeFixture from_v1; + if (!from_v1.runtime.initialize() || + !mms::MmsStaticBrcbStateCodec::restore( + from_v1.runtime, + std::span{legacy}.first(legacy_bytes)).success() || + from_v1.runtime.retained_size() != 2U || from_v1.runtime.queue_size() != 2U || + from_v1.runtime.replay_gap() || !from_v1.runtime.front(front) || + !entry_is(front, 1U)) { + return 11; + } + + // Overflow creates a replay gap. v2 must retain that fact across reboot so + // an MMS client is not given false confidence that older history exists. + RuntimeFixture overflow; + if (!overflow.runtime.initialize() || + overflow.runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 12; + } + for (std::uint64_t now = 100U; now <= 400U; now += 100U) { + g_value = !g_value; + if (!capture_one(overflow.runtime, now, staging, workspace)) { + return 13; + } + } + if (overflow.runtime.retained_size() != 3U || overflow.runtime.queue_size() != 3U || + overflow.runtime.dropped_reports() != 1U || !overflow.runtime.replay_gap() || + !overflow.runtime.front(front) || !entry_is(front, 2U)) { + return 14; + } + const auto overflow_state = mms::MmsStaticBrcbStateCodec::encode( + overflow.runtime, state); + RuntimeFixture overflow_recovered; + if (!overflow_state.success() || !overflow_recovered.runtime.initialize() || + !mms::MmsStaticBrcbStateCodec::restore( + overflow_recovered.runtime, + std::span{state}.first(overflow_state.bytes_written)).success() || + overflow_recovered.runtime.retained_size() != 3U || + overflow_recovered.runtime.queue_size() != 3U || + overflow_recovered.runtime.dropped_reports() != 1U || + !overflow_recovered.runtime.replay_gap() || + !overflow_recovered.runtime.front(front) || !entry_is(front, 2U)) { + return 15; + } + + // Purge is a durable recovery boundary. It clears retained history/gap but + // does not roll EntryID backward; after another reboot the next report is E5. + if (overflow_recovered.runtime.purge_buffer() != mms::MmsStaticBrcbStatus::ok || + overflow_recovered.runtime.retained_size() != 0U || + overflow_recovered.runtime.queue_size() != 0U || + overflow_recovered.runtime.replay_gap()) { + return 16; + } + const auto purged_state = mms::MmsStaticBrcbStateCodec::encode( + overflow_recovered.runtime, state); + RuntimeFixture purged_recovered; + if (!purged_state.success() || !purged_recovered.runtime.initialize() || + !mms::MmsStaticBrcbStateCodec::restore( + purged_recovered.runtime, + std::span{state}.first(purged_state.bytes_written)).success() || + purged_recovered.runtime.retained_size() != 0U || + purged_recovered.runtime.queue_size() != 0U || + purged_recovered.runtime.replay_gap() || + purged_recovered.runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok || + !capture_one(purged_recovered.runtime, 500U, staging, workspace) || + !purged_recovered.runtime.front(front) || !entry_is(front, 5U)) { + return 17; + } + + return 0; +} From d3adf430e30a6026e4846775dd6262fde82969cd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 05:06:20 +0700 Subject: [PATCH 4/6] Gate BRCB recovery v2 in hard profile --- embedded/brcb_hard_profile/CMakeLists.txt | 45 +++++++++++------------ 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/embedded/brcb_hard_profile/CMakeLists.txt b/embedded/brcb_hard_profile/CMakeLists.txt index f12c76b19..44a407ab3 100644 --- a/embedded/brcb_hard_profile/CMakeLists.txt +++ b/embedded/brcb_hard_profile/CMakeLists.txt @@ -19,14 +19,8 @@ function(ariec61850_configure_brcb_hard_target target) ARIEC61850_HOST_SERVICES=0 ARIEC61850_NO_EXCEPTIONS=1) target_compile_options(${target} PRIVATE - -Wall - -Wextra - -Wpedantic - -Wconversion - -Wsign-conversion - -Werror - -fno-exceptions - -fno-rtti) + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror + -fno-exceptions -fno-rtti) endfunction() add_executable(ariec61850_hard_mms_brcb_smoke @@ -117,6 +111,19 @@ add_executable(ariec61850_hard_mms_brcb_connection_smoke ${ARIEC61850_ROOT}/src/mms/static_brcb_connection.cpp) ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_connection_smoke) +add_executable(ariec61850_hard_mms_brcb_recovery_v2_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_recovery_v2_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/buffered_selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_state_codec.cpp) +ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_recovery_v2_smoke) + add_executable(ariec61850_hard_mms_brcb_persistence_smoke ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_persistence_hard_profile_smoke.cpp ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp @@ -133,26 +140,18 @@ ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_persistence_smoke enable_testing() add_test(NAME ariec61850_hard_mms_brcb_smoke COMMAND ariec61850_hard_mms_brcb_smoke) -add_test( - NAME ariec61850_hard_mms_brcb_control_smoke - COMMAND ariec61850_hard_mms_brcb_control_smoke) -add_test( - NAME ariec61850_hard_mms_brcb_objects_smoke - COMMAND ariec61850_hard_mms_brcb_objects_smoke) -add_test( - NAME ariec61850_hard_mms_brcb_lifecycle_smoke - COMMAND ariec61850_hard_mms_brcb_lifecycle_smoke) -add_test( - NAME ariec61850_hard_mms_brcb_connection_smoke - COMMAND ariec61850_hard_mms_brcb_connection_smoke) -add_test( - NAME ariec61850_hard_mms_brcb_persistence_smoke - COMMAND ariec61850_hard_mms_brcb_persistence_smoke) +add_test(NAME ariec61850_hard_mms_brcb_control_smoke COMMAND ariec61850_hard_mms_brcb_control_smoke) +add_test(NAME ariec61850_hard_mms_brcb_objects_smoke COMMAND ariec61850_hard_mms_brcb_objects_smoke) +add_test(NAME ariec61850_hard_mms_brcb_lifecycle_smoke COMMAND ariec61850_hard_mms_brcb_lifecycle_smoke) +add_test(NAME ariec61850_hard_mms_brcb_connection_smoke COMMAND ariec61850_hard_mms_brcb_connection_smoke) +add_test(NAME ariec61850_hard_mms_brcb_recovery_v2_smoke COMMAND ariec61850_hard_mms_brcb_recovery_v2_smoke) +add_test(NAME ariec61850_hard_mms_brcb_persistence_smoke COMMAND ariec61850_hard_mms_brcb_persistence_smoke) message(STATUS "arstack61850 BRCB hard profile: bounded RAM queue + EntryID + BufOvfl=ON") message(STATUS "arstack61850 BRCB hard profile: association ownership + ResvTms gate=ON") message(STATUS "arstack61850 BRCB hard profile: MMS RptEna/EntryID/PurgeBuf/ResvTms/Owner=ON") message(STATUS "arstack61850 BRCB hard profile: transport association lifecycle bridge=ON") +message(STATUS "arstack61850 BRCB hard profile: retained-history recovery state v2 + v1 restore=ON") message(STATUS "arstack61850 BRCB hard profile: retry-safe reconnect delivery=ON") message(STATUS "arstack61850 BRCB hard profile: dual-bank checkpoint + reboot recovery oracle=ON") message(STATUS "arstack61850 BRCB hard profile: platform non-volatile adapter=NOT_CLAIMED") From c59c262c87b76ee1c1c359b94b21977fde147fd6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 05:06:39 +0700 Subject: [PATCH 5/6] Include BRCB recovery v2 in CI evidence --- .github/workflows/brcb-hard-profile-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml index 75e4974d9..2c7bd3d8e 100644 --- a/.github/workflows/brcb-hard-profile-ci.yml +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -54,6 +54,7 @@ jobs: build-brcb-hard/ariec61850_hard_mms_brcb_objects_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_lifecycle_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_recovery_v2_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_persistence_smoke; do if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then echo "exception runtime symbol found in $binary" >&2 @@ -74,6 +75,7 @@ jobs: build-brcb-hard/ariec61850_hard_mms_brcb_objects_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_lifecycle_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_recovery_v2_smoke \ build-brcb-hard/ariec61850_hard_mms_brcb_persistence_smoke } | tee build-brcb-hard/brcb-footprint.txt From f53693029170e54f8d243b37eb832ab5a16b0f52 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 10 Aug 2026 05:08:16 +0700 Subject: [PATCH 6/6] Update port status through Phase 4D BRCB recovery --- PORT_STATUS.md | 125 +++++++++++++++++++++++++++++++------------------ 1 file changed, 80 insertions(+), 45 deletions(-) diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 6de67e1c4..331b05cf5 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,7 +2,9 @@ ## Current milestone -**Phase 4C.1 — built-in non-blocking TCP, bounded read-only discovery, C#-compatible live-model parity, and physical interoperability evidence tooling are implemented for review.** +**Phase 4D-R3 — bounded BRCB operational semantics, association ownership, MMS replay/purge controls, lifecycle handling, and replay-capable recovery state v2 are implemented for review.** + +The active Phase 4D work is intentionally carried as a stacked draft-PR series on top of the ESP32-P4/SV work. `main` does not yet represent this milestone. ## Delivered modules @@ -15,47 +17,80 @@ | Offline DataSet/RCB/report monitoring | Merged to `main` | | Transport-injected MMS association/runtime | Merged to `main` | | Persistent RCB subscription runtime | Merged to `main` | -| Built-in Windows/Linux TCP transport | Implemented in PR #14 | -| Live read-only MMS discovery | Implemented in PR #14 | -| `live-ied-model-v1` hierarchy/parity mapper | Implemented in PR #14 | -| Physical read-only evidence runner | Implemented in PR #14 | -| Physical IED acceptance evidence | Pending lab execution | - -## Phase 4C.1 behavior - -- Non-blocking Winsock/POSIX TCP honors deadlines and cancellation. -- Discovery is bounded and sends only GetNameList, GetVariableAccessAttributes, - GetNamedVariableListAttributes, and Read. -- MMS references are mapped to Logical Device, Logical Node, Data Object, and Data Attribute. -- Functional constraints, MMS/SCL type evidence, DataSets, and report controls are retained. -- Direct leaf and nested TypeSpecification evidence are resolved into the model. -- IED identity and CDC are inferred conservatively with explicit confidence. -- Canonical manifests and fingerprints make unchanged-model runs comparable. -- C# and C++ `live-ied-model-v1` JSON can be compared by the parity script. -- The lab runner performs repeated reconnect/discovery cycles and records acceptance evidence. - -## Validation completed without a physical IED - -- Phase 4B baseline GCC, Clang, MSVC, sanitizer, CTest, and fuzz evidence remains valid. -- Phase 4C TCP loopback, cancellation, strict GCC/Clang warning, and sanitizer checks passed locally. -- Live-model hierarchy, deterministic fingerprint, parity, and multi-translation-unit header - validation passed locally with GCC and Clang; ASan/UBSan smoke passed. -- Python parity and interoperability scripts pass syntax checks and a deterministic simulated - three-cycle acceptance run. -- The discovery regression decodes all emitted requests and rejects MMS Write. - -## Remaining acceptance gates - -- Run repository GCC/Clang/MSVC and Security/Evidence workflows through `workflow_dispatch`. -- Run the physical evidence runner against a reachable IED or vendor simulator. -- Record a primary-vendor ten-cycle run, timeout/reconnect evidence, and large-model pagination - where available. -- Export the C# oracle model and accept C#↔C++ parity against the same IED configuration. -- Review warning policy and packet-capture references before merging PR #14. - -## Safety boundary - -The live discovery surface is read-only and does not construct Write, control, GI, RCB -reservation/enable, dynamic DataSet mutation, or file-service requests. The generic TCP transport -can be used by other explicit runtime surfaces, but those surfaces retain their own authorization -and physical-laboratory gates. +| Built-in Windows/Linux TCP transport | Implemented in Phase 4C stack | +| Live read-only MMS discovery/model parity | Implemented in Phase 4C stack | +| ESP32-P4 BRCB raw-partition backend + geometry/latency probe firmware | Implemented; physical measurement pending | +| BRCB retained history + independent delivery cursor | Phase 4D-R1 / PR #16 | +| Association-aware BRCB reservation/Owner/ResvTms state machine | Phase 4D-R2a / PR #17 | +| Per-request association identity propagated through MMS writes | Phase 4D-R2b / PR #18 | +| MMS RptEna/EntryID/PurgeBuf/ResvTms/Owner operational object bank | Phase 4D-R2c / PR #20 | +| TCP/reset/COTP-DR association-loss lifecycle bridge | Phase 4D-R2d / PR #22 | +| Replay-capable BRCB recovery image v2 with v1 restore | Phase 4D-R3 / PR #23 | + +## Phase 4D behavior + +- BRCB delivery no longer destroys retained report history. +- The bounded ring has an independent delivery cursor and explicit retained-history size. +- `EntryID` can resume after a retained report; all-zero `EntryID` rewinds to the oldest retained report. +- `PurgeBuf` clears retained history and replay-gap state without rolling EntryID backward. +- Queue overflow remains bounded and records both dropped-report count and a replay-gap condition. +- Reservation ownership uses an opaque stable Owner identity plus a separate ephemeral MMS association ID; it is not coupled to IP addresses or socket handles. +- A second live association cannot steal an owned BRCB, including one presenting the same stable Owner identity. +- `ResvTms=0` releases on association loss; positive `ResvTms` retains the stable Owner until expiry and permits that Owner to reconnect with a new association ID. +- Association loss always disables `RptEna`. +- TCP EOF/socket/local close, runtime reset, and incoming COTP Disconnect Request use the same exactly-once lifecycle bridge. +- MMS static writes carry caller-owned association context without changing legacy non-contextual object callbacks. +- The operational MMS object bank exposes `RptID`, `RptEna`, `DatSet`, `ConfRev`, `PurgeBuf`, `EntryID`, `ResvTms`, and `Owner`. +- Recovery image v2 persists the full retained replay window, delivery cursor, replay-gap flag, EntryIDs, report PDUs, sequence state, next EntryID, and dropped-report counter. +- Recovery image v2 accepts legacy v1 images. A v1 image restores with its original undelivered-only semantics. +- Reboot recovery deliberately returns reporting disabled and does not restore live association identity or Owner connection state. + +## Validation completed without physical ESP32-P4 flash evidence + +The BRCB hard profile is compiled with GCC and Clang using `-fno-exceptions -fno-rtti`, and its evidence gate currently covers: + +- bounded BRCB capture, EntryID progression and BufOvfl behavior; +- retained replay history and independent delivery cursor; +- multi-client ownership, reservation, reconnect and expiry semantics; +- encoded MMS writes crossing BER decode -> dispatcher -> association-aware BRCB control; +- EntryID resume/rewind, invalid EntryID rejection and PurgeBuf; +- TCP/reset/COTP-DR association-loss lifecycle behavior; +- A/B checkpoint generation, torn-write fallback and corruption fallback; +- recovery state v2 preserving delivered history, cursor and replay gap; +- durable purge with monotonic EntryID after reboot; +- backward restore of a legacy v1 recovery image; +- absence of C++ exception runtime symbols in the hard-profile binaries. + +## Physical non-volatile storage gate + +The ESP32-P4 non-volatile adapter and probe firmware are implemented, but **physical acceptance is not yet claimed**. + +Current evidence status remains: + +`READY_NOT_MEASURED` + +The dedicated ESP32-P4 probe can read the actual partition erase geometry and measure erase/write/read latency using the real `brcb_state` raw partition. Hosted CI cannot replace this evidence. + +Before changing the status to hardware-measured/passed, capture and retain: + +1. actual `esp_partition_t::erase_size` and accepted A/B/probe geometry from the target board; +2. measured erase/write/read latency records from the destructive isolated probe erase unit; +3. the actual flash-device endurance/P-E rating used to convert checkpoint frequency into a wear budget; +4. an accepted checkpoint interval and worst-case lifetime policy based on that rating; +5. controlled physical power-loss evidence demonstrating committed-bank recovery and torn-write fallback on the real board. + +Recovery state v2 can be larger than v1 because delivered replay history is intentionally retained. The checkpoint operation must therefore fail closed if the measured bank geometry cannot hold the configured worst-case retained state; software must not silently discard replay history merely to fit flash. + +## Remaining Phase 4D acceptance gates + +- Complete the repository-wide CI matrix for the final stacked head and resolve any non-BRCB regression if reported. +- Run the ESP32-P4 flash probe on physical hardware and archive UART geometry/latency evidence. +- Bind the actual flash P/E endurance rating into an explicit checkpoint-frequency/wear policy. +- Run controlled physical power-cut/reboot tests against the A/B journal and recovery-v2 image. +- Review and merge the stacked Phase 4D PRs in dependency order once their parent branches are accepted. + +## Deliberately deferred + +Mutable BRCB definition/configuration lifecycle for fields such as `OptFlds`, `BufTm`, `TrgOps`, and `IntgPd` is not mixed into the ownership/recovery work. Those fields require their own configuration/versioning rules. + +IEC 61850 control-model parity (`ctlModel`, Direct/SBO, enhanced security, `Oper`, `SBO/SBOw`, `Cancel`, command termination, `LastApplError`/`AddCause`, interlock/synchrocheck, and no automatic command retry) is the next major functional phase after the Phase 4D hardware acceptance gate is closed.