From ccccba66381a6af541f3fb217f7c347ae5322a6e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:15:34 +0700 Subject: [PATCH 1/7] feat(sv-ptp): add bounded source-local wire/scheduler qualification gate --- .../time_sync/sv_tx_egress_evidence.hpp | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index f856ec4b7..fc1525e0a 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -180,4 +180,77 @@ class SvTxEgressEvidence final { SvTxEgressSummary summary_{}; }; +// Sparse MAC egress is a corroborating measurement, NOT sufficient by itself +// to declare synchronized samples. The independent software phase observer +// must ALSO verify smpCnt-zero PTP epoch continuity with a bounded clock-read +// uncertainty. Keep this gate independent of ESP-IDF/Qt for fault regression. +class SvLocalSyncEvidenceGate final { +public: + static constexpr std::int64_t kMaxEgressPhaseNs = 4000LL; + static constexpr std::uint32_t kMaxSoftwarePhaseBoundUs = 4U; + static constexpr std::uint32_t kEvidenceStride = 16U; + static constexpr std::uint32_t kMaxEvidenceAgeEpochs = 17U; + static constexpr std::uint32_t kRequiredMeasuredPairs = 2U; + + constexpr void reset() noexcept { + matched_pairs_ = 0U; + session_ = 0U; + fault_generation_ = 0U; + source_generation_ = 0U; + last_epoch_ = 0U; + } + + // Call only for the actual canonical SV descriptor timestamp. + // The lower-level tracker already verifies that consecutive timestamps + // belong to adjacent 16-second PTP epochs and the same source/session. + constexpr void observe(const SvTxEgressObservation& o, + const SvTxEvidenceResult result, + const std::int32_t phase_ns) noexcept { + const std::int64_t phase = static_cast(phase_ns); + if (result != SvTxEvidenceResult::measured || + !o.canonical_tx_success || !o.no_missed_slots || + !o.same_sample_epoch || + phase > kMaxEgressPhaseNs || phase < -kMaxEgressPhaseNs) { + reset(); + return; + } + if (matched_pairs_ != 0U && + (session_ != o.publisher_session_generation || + fault_generation_ != o.publisher_fault_generation || + source_generation_ != o.ptp_source_generation || + o.sample_epoch_generation - last_epoch_ != kEvidenceStride)) { + reset(); + } + session_ = o.publisher_session_generation; + fault_generation_ = o.publisher_fault_generation; + source_generation_ = o.ptp_source_generation; + last_epoch_ = o.sample_epoch_generation; + if (matched_pairs_ < kRequiredMeasuredPairs) ++matched_pairs_; + } + + [[nodiscard]] constexpr bool qualified( + const std::uint32_t session, + const std::uint32_t fault_generation, + const std::uint32_t source_generation, + const std::uint32_t current_epoch, + const bool software_tracking, + const std::uint32_t software_phase_bound_us) const noexcept { + return matched_pairs_ >= kRequiredMeasuredPairs && + software_tracking && + software_phase_bound_us <= kMaxSoftwarePhaseBoundUs && + session != 0U && source_generation != 0U && + session == session_ && + fault_generation == fault_generation_ && + source_generation == source_generation_ && + current_epoch >= last_epoch_ && + current_epoch - last_epoch_ <= kMaxEvidenceAgeEpochs; + } +private: + std::uint32_t matched_pairs_{}; + std::uint32_t session_{}; + std::uint32_t fault_generation_{}; + std::uint32_t source_generation_{}; + std::uint32_t last_epoch_{}; +}; + } // namespace ar::iec61850::time_sync From aafac51416d02e34e1eb7154c14ef88bdabaae58 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:15:59 +0700 Subject: [PATCH 2/7] feat(sv-ptp): resolve SOURCE AUTO as local only after measured qualified phase --- embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp index f35544d52..61eefbace 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp @@ -31,4 +31,10 @@ void smp_synch_lab_set_mode(SvSyncPolicyMode mode) noexcept; */ void smp_synch_lab_set_measured(std::optional value) noexcept; +/** SOURCE AUTO only: assert local sync after independent scheduled-phase AND + * canonical MAC TX evidence have been qualified. Must revoke on STOP, source + * change, missed slot, TX failure or stale phase; never claims global sync. */ +void smp_synch_set_source_local_qualified(bool qualified) noexcept; +[[nodiscard]] bool smp_synch_source_local_qualified() noexcept; + } // namespace ar::esp32p4::smv From f99911efdf8be3d94f039fd671295eb25ce036b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:16:02 +0700 Subject: [PATCH 3/7] feat(sv-ptp): resolve SOURCE AUTO as local only after measured qualified phase --- .../main/smp_synch_lab.cpp | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 94b170769..4f2779124 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -18,6 +18,7 @@ std::atomic g_mode{ std::atomic g_measured_value{-1}; // Fail-closed until an independent SV timeline/phase acceptance gate exists. std::atomic_bool g_sv_timeline_phase_qualified{false}; +std::atomic_bool g_source_local_qualified{false}; [[nodiscard]] std::optional measured_value() noexcept { const int raw = g_measured_value.load(std::memory_order_acquire); @@ -33,6 +34,17 @@ std::atomic_bool g_sv_timeline_phase_qualified{false}; } } +[[nodiscard]] std::optional auto_evidence() noexcept { + // SOURCE has no receiver discipline object. Its local-sync claim + // originates exclusively from the sample phase + canonical TX gate. + // Global sync is reserved for measured external time traceability. + if (g_source_local_qualified.load(std::memory_order_acquire)) + return SmpSynchValue::local_synchronized; + return ar::iec61850::time_sync::qualified_sv_sync_value( + measured_value(), g_sv_timeline_phase_qualified.load( + std::memory_order_acquire)); +} + [[nodiscard]] SvSyncPolicyMode current_mode() noexcept { return static_cast( g_mode.load(std::memory_order_acquire)); @@ -49,14 +61,11 @@ SvSyncPolicyMode smp_synch_lab_mode() noexcept { } SmpSynchDecision smp_synch_lab_decision() noexcept { - return resolve_sv_sync_policy(current_mode(), - ar::iec61850::time_sync::qualified_sv_sync_value(measured_value(), - g_sv_timeline_phase_qualified.load(std::memory_order_acquire))); + return resolve_sv_sync_policy(current_mode(), auto_evidence()); } SmpSynchLabStatus smp_synch_lab_status() noexcept { - const auto measured = ar::iec61850::time_sync::qualified_sv_sync_value( - measured_value(), g_sv_timeline_phase_qualified.load(std::memory_order_acquire)); + const auto measured = auto_evidence(); return { resolve_sv_sync_policy(current_mode(), measured), measured.has_value(), @@ -73,4 +82,12 @@ void smp_synch_lab_set_measured(const std::optional value) noexce std::memory_order_release); } +void smp_synch_set_source_local_qualified(const bool qualified) noexcept { + g_source_local_qualified.store(qualified, std::memory_order_release); +} + +bool smp_synch_source_local_qualified() noexcept { + return g_source_local_qualified.load(std::memory_order_acquire); +} + } // namespace ar::esp32p4::smv From 67b4a6bfae04ccd372b13443030703e9b7743a20 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:16:47 +0700 Subject: [PATCH 4/7] feat(esp32p4): connect source phase, MAC egress and AUTO local sync with loss revocation --- .../esp32p4_smv_injector/main/app_main.cpp | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 4cd4618dc..955d7ca75 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -71,6 +71,8 @@ using ar::esp32p4::smv::runtime_profile_initialize; using ar::esp32p4::smv::runtime_profile_snapshot; using ar::esp32p4::smv::runtime_profile_validate; using ar::esp32p4::smv::smp_synch_lab_decision; +using ar::esp32p4::smv::smp_synch_set_source_local_qualified; +using ar::esp32p4::smv::smp_synch_source_local_qualified; using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; @@ -97,6 +99,7 @@ using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; #endif #if AR_SV_TX_EGRESS_SUPPORTED using ar::iec61850::time_sync::SvTxEgressEvidence; +using ar::iec61850::time_sync::SvLocalSyncEvidenceGate; using ar::iec61850::time_sync::SvTxEgressObservation; using ar::iec61850::time_sync::SvTxEgressSummary; #endif @@ -139,6 +142,7 @@ std::atomic g_sample_tick_total{0U}; constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = 16U; portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED; SvTxEgressEvidence g_tx_egress_evidence{}; +SvLocalSyncEvidenceGate g_local_sync_evidence{}; // same critical section as egress tracker #endif #if AR_SV_PTP_PHASE_SUPPORTED SemaphoreHandle_t g_phase_timer_mutex = nullptr; @@ -651,6 +655,9 @@ gptimer_handle_t start_sample_clock( } void stop_sample_clock(gptimer_handle_t& timer) noexcept { + // Release the wire claim before teardown; old MAC evidence never + // survives STOP/START, even when the same SOURCE later restarts. + smp_synch_set_source_local_qualified(false); if (timer == nullptr) return; #if AR_SV_PTP_PHASE_SUPPORTED // Serialize teardown with the non-ISR phase observer, so a GPTimer @@ -762,6 +769,7 @@ void publisher_task(void* argument) { #if AR_SV_TX_EGRESS_SUPPORTED zero_epoch_frames_seen = 0U; portENTER_CRITICAL(&g_tx_egress_mux); + g_local_sync_evidence.reset(); g_tx_egress_evidence.reset(); portEXIT_CRITICAL(&g_tx_egress_mux); #endif @@ -848,6 +856,7 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); #endif missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( @@ -874,11 +883,24 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); #endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; #endif } else { +#if AR_SV_PTP_PHASE_SUPPORTED + // After qualification, consult the lightweight SOURCE freshness + // authority on each frame; a stopped/faulted PTP source must not + // continue advertising previously measured local synchronization. + if (smp_synch_source_local_qualified() && + (!g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire) || + ar_ptp_lab_source_generation() != + g_sample_clock_context.armed_ptp_clock.generation)) { + smp_synch_set_source_local_qualified(false); + } +#endif const auto sync_decision = smp_synch_lab_decision(); const auto sample_synchronization = static_cast(sync_decision.value); @@ -955,8 +977,12 @@ void publisher_task(void* argument) { g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire) == fault_generation_before; portENTER_CRITICAL(&g_tx_egress_mux); - static_cast(g_tx_egress_evidence.record(egress)); + const auto result = g_tx_egress_evidence.record(egress); + const auto phase_ns = g_tx_egress_evidence.summary().last_phase_ns; + g_local_sync_evidence.observe(egress, result, phase_ns); portEXIT_CRITICAL(&g_tx_egress_mux); + if (result != ar::iec61850::time_sync::SvTxEvidenceResult::measured) + smp_synch_set_source_local_qualified(false); } #else const esp_err_t canonical_result = @@ -975,6 +1001,7 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); #endif } @@ -1036,6 +1063,7 @@ void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept { false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); + smp_synch_set_source_local_qualified(false); } xSemaphoreGive(g_phase_timer_mutex); } @@ -1131,6 +1159,30 @@ void sv_ptp_phase_observer_task(void* argument) { } const auto decision = controller.observe(observation); + // Snapshot the independently stamped canonical SV evidence outside + // the GPTimer mutex. Source identity and fault/session generations + // must match the exact phase observation before any AUTO promotion. + bool source_local_qualified = false; +#if AR_SV_TX_EGRESS_SUPPORTED + if (lease.role == PtpAuthorityRole::source && + decision.accepted && decision.epoch_second_continuous) { + const std::int64_t abs_phase_us = decision.phase_error_us < 0 + ? -static_cast(decision.phase_error_us) + : static_cast(decision.phase_error_us); + const std::uint64_t worst_case_phase_us = + static_cast(abs_phase_us) + + decision.observation_uncertainty_us; + portENTER_CRITICAL(&g_tx_egress_mux); + source_local_qualified = g_local_sync_evidence.qualified( + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation, + decision.software_tracking, + worst_case_phase_us <= UINT32_MAX + ? static_cast(worst_case_phase_us) + : UINT32_MAX); + portEXIT_CRITICAL(&g_tx_egress_mux); + } +#endif // Handoff and GPTimer deletion share a control-plane mutex: // neither a stale sample nor a recycled timer can receive a command. bool committed = false; @@ -1161,12 +1213,15 @@ void sv_ptp_phase_observer_task(void* argument) { decision.epoch_second_continuous ? decision.slew_ticks_next_epoch : 0, std::memory_order_release); + smp_synch_set_source_local_qualified( + source_local_qualified && decision.epoch_second_continuous); committed = true; } else if (session_matches) { g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); + smp_synch_set_source_local_qualified(false); } xSemaphoreGive(g_phase_timer_mutex); if (!committed) { @@ -1177,13 +1232,14 @@ void sv_ptp_phase_observer_task(void* argument) { if ((accepted_epochs % 60U) == 0U) { ESP_LOGI(kTag, "SV/PTP phase SW evidence: phase=%ld us uncertainty=%lu us " - "slew=%ld ticks/epoch tracking=%u PTPepoch=%llu bound=%u; smpSynch AUTO remains 0", + "slew=%ld ticks/epoch tracking=%u PTPepoch=%llu bound=%u AUTOlocal=%u", static_cast(decision.phase_error_us), static_cast(decision.observation_uncertainty_us), static_cast(decision.slew_ticks_next_epoch), decision.software_tracking ? 1U : 0U, static_cast(decision.sv_epoch_ptp_second), - decision.epoch_second_continuous ? 1U : 0U); + decision.epoch_second_continuous ? 1U : 0U, + smp_synch_source_local_qualified() ? 1U : 0U); } } } From a8ef2dce1ed3bf46fde398f8a7a018ff96d9add7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:17:05 +0700 Subject: [PATCH 5/7] fix(ptp): revoke SV AUTO local claim synchronously on PTP role stop --- embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 80605e917..9637f748b 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -2,6 +2,7 @@ #include "ptp_lab_task.hpp" #include "ptp_receiver_task.hpp" +#include "smp_synch_lab.hpp" #include "ariec61850/time_sync/ptp_discipline.hpp" #include "ariec61850/time_sync/ptp_canonical_authority.hpp" @@ -122,6 +123,9 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { // Ownership is derived from live workers, never from a stale selected role. void stop_active_ptp_roles() noexcept { + // Operator STOP or role handoff revokes the transmitted AUTO local-sync + // claim immediately, independent of the slower phase observer cadence. + smp_synch_set_source_local_qualified(false); if (ar_ptp_source_runtime_active()) { ar_ptp_source_stop(); } From 5283c7c4ec75cfdc3db56b301a516412ae96fd92 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:17:36 +0700 Subject: [PATCH 6/7] test(sv-ptp): reject stale, mismatched, imprecise and faulted AUTO local evidence --- tests/test_sv_tx_egress_evidence.cpp | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index 71fbea6a6..0e6c72248 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -31,6 +31,46 @@ SvTxEgressObservation make_good( o.same_sample_epoch = true; return o; } +void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() { + SvLocalSyncEvidenceGate gate; + auto o = make_good(32U, 1700000016U, 2000U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::baseline_established, 2000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::measured, 2000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + o.sample_epoch_generation = 48U; + gate.observe(o, SvTxEvidenceResult::measured, -3000); + REQUIRE(gate.qualified(7U, 0U, 3U, 48U, true, 4U)); + REQUIRE(gate.qualified(7U, 0U, 3U, 65U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 66U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 48U, true, 5U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 48U, false, 0U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 48U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 4U, 48U, true, 0U)); + REQUIRE(!gate.qualified(8U, 0U, 3U, 48U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 47U, true, 0U)); + // A new source, bad SV timestamp, skipped frame or TX failure + // immediately revokes the old promotion and requires fresh pairs. + o.sample_epoch_generation = 64U; + gate.observe(o, SvTxEvidenceResult::measured, 5000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::measured, 1000); + o.sample_epoch_generation = 80U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + REQUIRE(gate.qualified(7U, 0U, 3U, 80U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::transmit_failed, 1000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 80U, true, 0U)); + o.sample_epoch_generation = 96U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + o.sample_epoch_generation = 112U; + o.publisher_fault_generation = 1U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + REQUIRE(!gate.qualified(7U, 1U, 3U, 112U, true, 0U)); + gate.reset(); + REQUIRE(!gate.qualified(7U, 1U, 3U, 112U, true, 0U)); +} + void check_baseline_and_contiguous_ptp() { SvTxEgressEvidence e{16U}; auto o = make_good(16U, 1700000000U, 125000U); @@ -146,6 +186,7 @@ void check_counter_wrap_and_signed_negative_phase() { int main() { try { + check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); check_baseline_and_contiguous_ptp(); check_clock_steps_recovery_and_session(); check_invalid_evidence_requires_rebaseline(); From 8e2fabac19f35f997a17a558cf91ca8b09564c01 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:18:57 +0700 Subject: [PATCH 7/7] docs(sv-ptp): describe measured source-local AUTO and relay interoperability --- docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md index e3ac4d8f8..fdc4ca65b 100644 --- a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -35,3 +35,38 @@ authorization to assert `smpSynch=1/2`. Portable regression includes six accurate-phase/uncertain-clock observations, four fresh narrowly bracketed reacquisition observations, and confidence demotion after one degraded bracket. Hardware bench acceptance is still absent. + +## M3C.5 — Vendor-neutral AUTO local-sync qualification (2026-10-17) + +No new operator prompts; source PTP On → SMV Start still operates directly. +The IEC 61850-9-2 local sync declaration (`smpSynch=1`) is not the same +as merely sending PTP frames. IEC/IEEE 61850-9-3 defines the applicable +power-utility PTP profile. The SIPROTEC 5 process-bus manual confirms +`Accepted SmpSynch=1` is an intentional local-source interoperability mode +when merging unit and relay share a time reference; ARStack contains no +Siemens-specific wire quirk. + +A **SOURCE-only, explicit hardware-evidence build** can now promote AUTO to +local `1` only when ALL gates pass: stable PTP SOURCE generation, coherent +second-bound GPTimer sample-zero observations, software tracking with the +measured absolute phase plus read uncertainty <=4us, two adjacent coherent +16-second comparisons of genuine canonical SV MAC TX descriptor timestamps +both within +/-4us, no publish faults/missed slots/descriptor stalls, and +current hardware evidence age <=17 sample epochs. These 4us values are a +conservative project admission policy, not a universal IEC profile claim. +When disabled or insufficient, AUTO stays `0`; global `2` is never implied. + +The gate revokes on STOP, role change, missed sample, failed transmission, +wrong generation, discontinuous PTP epoch, stale evidence or unqualified +software phase. No heap, blocking IO, or additional work in the GPTimer ISR. +The sparse actual-frame descriptor poll remains a possible publisher timing +disturbance; evaluate actual board and relay traces before release. + +SOURCE and RECEIVER remain distinct: this milestone qualifies SOURCE local +evidence only; receiver `LOCKED` without equivalent measured SV egress +does not promote AUTO. Successful firmware CI does not establish physical +phase accuracy or guarantee zero relay sync alarms. + +External specifications: +- https://standards.ieee.org/ieee/61850-9-3/6167/ +- https://cache.industry.siemens.com/dl/files/460/109742460/att_1135746/v1/SIP5_Processbus_V09.50_Manual_C054-D_en.pdf?download=true