From fc679dd3dedbead86b1c4f4d6b9e7a06e3419de4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 06:53:13 +0700 Subject: [PATCH 01/20] fix(sv): retain certified PTP sample-clock authority across SV-only STOP/START --- .../esp32p4_smv_injector/main/app_main.cpp | 87 +++++++++++++++++-- .../time_sync/sv_ptp_phase_authority.hpp | 31 +++++++ 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 8f0a948fc..4658b61ed 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -129,6 +129,13 @@ constexpr std::uint16_t kDiagnosticEtherType = 0x88B5U; constexpr std::uint32_t kStatsMergeEvery = 400U; constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz constexpr std::uint32_t kTimerResolutionHz = 1000000U; +#if AR_SV_PTP_PHASE_SUPPORTED +// Separate clock authority from SV TX session state. During SV STOP the +// exact 4-kHz grid/phase servo and PTP SOURCE continue uninterrupted; +// remember only a PREVIOUSLY MEASURED SOURCE certification. Never use this +// latch to qualify a cold start or a changed GM/clock/rate. +std::atomic g_parked_source_clock_certified{false}; +#endif // A single fail-closed operation for both AUTO roles. In particular, keep // receiver invalidation INSIDE the same conditional as SOURCE invalidation: @@ -136,6 +143,11 @@ constexpr std::uint32_t kTimerResolutionHz = 1000000U; // RECEIVER qualification impossible at every evidence epoch. inline void revoke_sv_auto_claims( const SvSyncLossReason reason = SvSyncLossReason::other) noexcept { +#if AR_SV_PTP_PHASE_SUPPORTED + // Every genuine timing/lease/transport fault revokes any parked + // certificate as well; only a verified SV-only STOP may preserve it. + g_parked_source_clock_certified.store(false, std::memory_order_release); +#endif // Only timestamp an actual lock transition; ordinary 4kHz frame // processing keeps its existing work budget. const bool was_qualified = smp_synch_source_local_qualified() || @@ -1151,17 +1163,34 @@ void publisher_task(void* argument) { // same dry-running sample grid may survive START. The 1-Hz // phase observer and per-frame link/fault/lease checks remain // authoritative. Fresh MAC descriptors must follow within 8 s. - const bool warm_resume = ar::iec61850::time_sync::SvWarmResumePolicy::may_begin( - source_unchanged, reuse_preflight, - smp_synch_source_local_qualified(), + const bool current_phase_healthy = g_sample_clock_context.phase_authority_valid.load( - std::memory_order_acquire) && + std::memory_order_acquire); + const bool phase_observer_fresh = ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh( g_sample_clock_context.last_phase_observed_epoch.load( std::memory_order_acquire), g_sample_clock_context.epoch_generation.load( - std::memory_order_acquire)), - now_lease.role == PtpAuthorityRole::source); + std::memory_order_acquire)); + // STOP can occur between two control-plane phase observations. + // Do not treat the SV session switch as loss of the time domain: + // prior measured clock authority was captured when we PARKED. + const bool resume_certified = + smp_synch_source_local_qualified() || + g_parked_source_clock_certified.load(std::memory_order_acquire); + const bool warm_resume = + ar::iec61850::time_sync::SvParkedClockAuthority::may_resume( + resume_certified, source_unchanged, + reuse_preflight && profile_unchanged, + current_phase_healthy, phase_observer_fresh, + now_lease.role == PtpAuthorityRole::source, auto_mode); + // WarmResumePolicy remains an independent admission guard: + // no new MAC exception on a cold start. + const bool warm_admitted = + ar::iec61850::time_sync::SvWarmResumePolicy::may_begin( + source_unchanged, reuse_preflight, warm_resume, + current_phase_healthy && phase_observer_fresh, + now_lease.role == PtpAuthorityRole::source); #else constexpr bool reuse_preflight = false; stop_sample_clock(sample_clock); @@ -1203,7 +1232,7 @@ void publisher_task(void* argument) { portEXIT_CRITICAL(&g_tx_egress_mux); #if AR_SV_PTP_PHASE_SUPPORTED g_warm_resume_first_epoch.store( - warm_resume + warm_admitted ? g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) : 0U, std::memory_order_release); @@ -1213,6 +1242,22 @@ void publisher_task(void* argument) { #endif #if AR_SV_PTP_PHASE_SUPPORTED previous_slew_total = 0; + // Restore only an ALREADY CERTIFIED clock on the same live PTP + // lease and phase grid. The descriptor tracker will acquire a + // fresh MAC baseline within its existing bounded warm window. + // This happens before the first resumed SV packet; never force + // smpSynch 2 or bypass the bench-global/source-readiness gate. + if (warm_admitted) { + smp_synch_set_source_local_qualified( + true, SvSyncLossReason::none); + } + g_parked_source_clock_certified.store( + false, std::memory_order_release); + ESP_LOGI(kTag, "SV CLOCK RESUME %s: same PTP lease=%u phase=%u fresh=%u", + warm_admitted ? "WARM CERTIFIED" : "COLD REQUALIFY", + source_unchanged ? 1U : 0U, + current_phase_healthy ? 1U : 0U, + phase_observer_fresh ? 1U : 0U); #endif ESP_LOGI(kTag, "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", @@ -1294,6 +1339,21 @@ void publisher_task(void* argument) { profile.sample_counter_modulus == active_profile.sample_counter_modulus, auto_clock_ready); if (park) { + const bool clock_was_certified = + ar::iec61850::time_sync::SvParkedClockAuthority::may_preserve( + smp_synch_source_local_qualified(), + lease.role == PtpAuthorityRole::source, same_lease, + true, + g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire), + ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh( + g_sample_clock_context.last_phase_observed_epoch.load( + std::memory_order_acquire), + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire)), + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto); + g_parked_source_clock_certified.store( + clock_was_certified, std::memory_order_release); // ISR can have one in-flight wake at this edge; the next // START explicitly drains it before enabling epoch launch. g_sample_clock_context.launch_on_next_epoch.store( @@ -1305,7 +1365,8 @@ void publisher_task(void* argument) { preflight_clock = true; preflight_rate_hz = profile.publisher_rate_hz; ESP_LOGI(kTag, - "M3A PARKED: SV TX OFF, PTP/SV GPTimer and phase observer continue"); + "M3E PARKED: SV TX OFF, PTP/4k clock continuous certified=%u", + clock_was_certified ? 1U : 0U); } else { stop_sample_clock(sample_clock); epoch_armed = false; @@ -1972,8 +2033,16 @@ void sv_ptp_phase_observer_task(void* argument) { healthy_dry_phase, notified_session, current_faults, lease.generation, observation.last_sv_epoch_generation); - if (!healthy_dry_phase) + if (!healthy_dry_phase) { revoke_sv_auto_claims(SvSyncLossReason::phase_or_mac); + } else if (lease.role == PtpAuthorityRole::source && + smp_synch_source_local_qualified()) { + // Keep the established SOURCE certificate across + // arbitrarily many healthy parked PTP seconds. + // Preflight cannot promote an unqualified cold clock. + g_parked_source_clock_certified.store( + true, std::memory_order_release); + } } else { local_acquisition.reset(); source_fast_acquisition.reset(); diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 4492c7b4d..8e325c9f9 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -332,6 +332,37 @@ struct SvAutoPreflightPolicy final { } }; +// IEC 61850-9-2 smpSynch describes the SAMPLING TIMEBASE, not an Ethernet +// publisher session. A STOP can park the grid while the phase servo and PTP +// SOURCE remain alive; a subsequent START must use the SAME certified clock. +// In particular, deleting a serial TX/MAC observation baseline must never +// by itself invalidate a still-measured sample-clock authority. This policy +// has no clock-quality or traceability promotion function. +struct SvParkedClockAuthority final { + [[nodiscard]] static constexpr bool may_preserve( + const bool previously_certified, const bool is_internal_source, + const bool same_ptp_lease, const bool parked_grid_unchanged, + const bool measured_phase_healthy, const bool phase_observer_fresh, + const bool auto_mode) noexcept { + return previously_certified && is_internal_source && same_ptp_lease && + parked_grid_unchanged && measured_phase_healthy && + phase_observer_fresh && auto_mode; + } + + // A prior certification may be reused across STOP/START, but only after + // checking the current 1-Hz observer, source generation, exact GPTimer + // and runtime profile. A cold start or a new GM NEVER inherits a claim. + [[nodiscard]] static constexpr bool may_resume( + const bool certified_before_stop, + const bool same_ptp_lease, const bool unchanged_grid_and_profile, + const bool healthy_phase, const bool fresh_observer, + const bool internal_source, const bool auto_mode) noexcept { + return may_preserve(certified_before_stop, internal_source, + same_ptp_lease, unchanged_grid_and_profile, + healthy_phase, fresh_observer, auto_mode); + } +}; + // A certified, continuously running sample grid keeps its time authority // while SV TX is parked. A subsequent START must NOT turn the missing first // MAC timestamp BASELINE into a lost PTP lock. The publisher nevertheless From f5a291e6c351f46fb470fb667550b5fb94375994 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 06:53:56 +0700 Subject: [PATCH 02/20] test(sv): 100 stop/start cycles preserve clock proof and reject false claims --- tests/test_sv_ptp_phase_authority.cpp | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 6efaef1a1..005239082 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -153,6 +153,36 @@ void check_m3a_stop_start_keeps_qualified_sample_grid() { true, true, true, 4000U, 4800U)); } +void check_m3e_parked_certified_ptp_authority_survives_100_restarts() { + // IEC 61850-9-2: SmpSynch describes the sampled TIMEBASE, not whether + // the publisher is presently transmitting SV frames. + using Park = SvParkedClockAuthority; + using Resume = SvWarmResumePolicy; + CHECK(!Park::may_preserve(false, true, true, true, true, true, true)); + CHECK(!Park::may_resume(false, true, true, true, true, true, true)); + for (std::uint32_t cycle = 0U; cycle < 100U; ++cycle) { + // The old certificate came from a measured MAC and qualified phase. + // The phase observer runs for sixty more seconds while SV TX is off. + bool certified = true; + CHECK(Park::may_preserve(certified, true, true, true, true, true, true)); + for (std::uint32_t second = 0U; second < 60U; ++second) + CHECK(Park::may_preserve(certified, true, true, true, true, true, true)); + CHECK(Park::may_resume(certified, true, true, true, true, true, true)); + CHECK(Resume::may_begin(true, true, certified, true, true)); + CHECK(Resume::retain(100U, 101U, true, true, true, true)); + CHECK(Resume::retain(100U, 105U, true, true, true, true)); + CHECK(!Resume::retain(100U, 109U, true, true, true, true)); + } + // A real PTP lease change, lost phase, invalid freshness, non-SOURCE + // mode, non-AUTO policy, or cold clock cannot inherit smpSynch. + for (int gate = 0; gate < 7; ++gate) { + std::array checks{true, true, true, true, true, true, true}; + checks[static_cast(gate)] = false; + CHECK(!Park::may_resume(checks[0], checks[1], checks[2], + checks[3], checks[4], checks[5], checks[6])); + } +} + void check_m3b_boot_ptp_is_safe_and_operator_owned() { using Boot = SvBootClockPolicy; static_assert(Boot::kMaxAttempts == 4U); @@ -701,6 +731,7 @@ int main() { try { check_auto_preflight_does_not_invent_sync_or_start(); check_m3a_stop_start_keeps_qualified_sample_grid(); + check_m3e_parked_certified_ptp_authority_survives_100_restarts(); check_m3b_boot_ptp_is_safe_and_operator_owned(); check_m3c_foreign_announce_and_exclusive_emac_handover(); check_m3d_advanced_ptp_policy_prevents_implicit_role_promotion(); From 29a3630825d17004703d8dd5fe3a744cfe34cd69 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 06:54:05 +0700 Subject: [PATCH 03/20] test(studio): guard clock certification against SV-only STOP regressions --- apps/arstack_studio/tests/check_operator_workspace.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a715d540d..927b286d8 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -747,6 +747,14 @@ def main() -> None: and "g_warm_resume_first_epoch" in firmware_source, "isolated simulated GM must boot/fallback and warm SV resume must requalify MAC") + # M3.E: IEC 61850 sample-clock sync must survive SV-only STOP/START. + require("g_parked_source_clock_certified" in firmware_source + and "SvParkedClockAuthority::may_preserve" in firmware_source + and "SvParkedClockAuthority::may_resume" in firmware_source + and "g_preflight_phase_clock_active" in firmware_source + and "g_warm_resume_first_epoch" in firmware_source, + "SV STOP/START must preserve measured PTP clock authority") + print("Studio operator workspace contract: PASS") From 3ee4e25a2156433d4abf3da5f066b7fbe48f2292 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 06:56:35 +0700 Subject: [PATCH 04/20] fix(ptp): source generation tracks actual EMAC timebase starts, not transient TX readiness --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 4dbca7446..090730827 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -826,6 +826,12 @@ void ptp_lab_task(void* argument) { g_bench_global_active.store( selected_ptp_config.bench_global_gm && ptp_tai_epoch_seeded, std::memory_order_release); + // Clock-generation identifies the actual EMAC IEEE1588 timebase + // lifetime. It changes when that hardware timebase is initialized, + // NEVER when a transient Announce/Sync/Pdelay TX readiness drops and + // recovers. The latter is transport health, not a PTP clock step. + // One exclusive SOURCE runtime owns the EMAC, so one bump per START. + g_ptp_source_generation.fetch_add(1U, std::memory_order_release); const auto options = context.runtime->options(); // Nonstandard bench cadences are explicitly opt-in, so expiry tracks the // larger validated interval. Default power-profile TTL = 3 seconds. @@ -960,7 +966,9 @@ void ptp_lab_task(void* argument) { if (!qualified_ready) { g_ptp_ready.store(false, std::memory_order_release); } else if (!was_ready) { - g_ptp_source_generation.fetch_add(1U, std::memory_order_release); + // A recovered TX stream uses the SAME still-running EMAC clock. + // Do not report a new GM generation and force the SV sample + // phase servo to throw away its continuous 4-kHz grid. g_source_start_reason.store( PtpSourceStartReason::ready, std::memory_order_release); g_ptp_ready.store(true, std::memory_order_release); From c917739a96ec5e4bb6e53a3c2cb7de746b5fb1ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 06:56:45 +0700 Subject: [PATCH 05/20] test(ptp): prohibit fake GM generation changes on readiness reacquisition --- apps/arstack_studio/tests/check_operator_workspace.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 927b286d8..94e5535ac 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -754,6 +754,13 @@ def main() -> None: and "g_preflight_phase_clock_active" in firmware_source and "g_warm_resume_first_epoch" in firmware_source, "SV STOP/START must preserve measured PTP clock authority") + # PTP SOURCE readiness recovery cannot fabricate a NEW timebase lease. + # A source generation bump belongs to hardware initialization only. + source_generation_bump = "g_ptp_source_generation.fetch_add(1U" + require(ptp_source_task.count(source_generation_bump) == 1 + and ptp_source_task.find(source_generation_bump) < + ptp_source_task.find("std::uint32_t consecutive_failures = 0U"), + "PTP source generation must never advance on TX readiness recovery") print("Studio operator workspace contract: PASS") From fab12b3a249612dfb89a72c96b4eba088dac42f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 07:58:22 +0700 Subject: [PATCH 06/20] fix(m3e): keep sample phase observer on owned GPTimer during asynchronous SV STOP/START --- .../esp32p4_smv_injector/main/app_main.cpp | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 4658b61ed..12b195a9b 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1792,9 +1792,11 @@ void sv_ptp_phase_observer_task(void* argument) { continue; } + // SV RUNNING and preflight are asynchronous publisher/session flags. + // STOP toggles RUNNING before the publisher can publish preflight. + // A still-owned GPTimer/EMAC PTP lease cannot lose clock alignment + // merely because both flags happen to be false during this handoff. if (events != 1U || - (!live_tx_running() && - !g_preflight_phase_clock_active.load(std::memory_order_acquire)) || (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { revoke_with_reason("epoch_notification_or_link"); @@ -1913,16 +1915,20 @@ void sv_ptp_phase_observer_task(void* argument) { g_sample_clock_context.session_generation.load( std::memory_order_acquire) == notified_session; const bool injecting = live_tx_running(); - const bool preflight_active = g_preflight_phase_clock_active.load( - std::memory_order_acquire); + const bool clock_timer_owned_and_enabled = + g_monitored_sample_clock != nullptr && + g_sample_clock_context.phase_enabled; + const bool auto_sync_mode = + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto; if (g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) == observation.last_sv_epoch_generation && - SvAutoPreflightPolicy::can_steer( + SvAutoPreflightPolicy::can_steer_owned_grid( session_matches, decision.accepted, same_sv_ptp_clock_lease( armed_lease, g_sample_clock_context.armed_ptp_clock) && same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease()), - no_new_publisher_fault, injecting, preflight_active)) { + no_new_publisher_fault, + clock_timer_owned_and_enabled, auto_sync_mode)) { // The PRE-START clock must truly steer while SV TX is OFF. // The old live_tx_running() gate invalidated its servo on every // 1Hz observation; capture verified 120s of smpSynch=0. @@ -2019,7 +2025,9 @@ void sv_ptp_phase_observer_task(void* argument) { stable_local && lease.role == PtpAuthorityRole::receiver, SvSyncLossReason::phase_or_mac, stamp_ms); } else { - if (preflight_active) { + // M3.E: the same owned timebase keeps steering throughout + // STOP->PARK->START, even before the publisher updates its + // optional preflight flag. No SV packet is emitted here. // M3.A: keep *clock* qualification across an SV-only STOP. // No SV frame exists while TX is off. Preserve a prior // verified claim only while this SAME timer, lease, @@ -2043,11 +2051,6 @@ void sv_ptp_phase_observer_task(void* argument) { g_parked_source_clock_certified.store( true, std::memory_order_release); } - } else { - local_acquisition.reset(); - source_fast_acquisition.reset(); - revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); - } } committed = true; } else if (session_matches) { From 2d27ea1a8fbab5821d4fa4b9769d6f5731cb892a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 07:58:30 +0700 Subject: [PATCH 07/20] feat(timebase): pure continuous GPTimer ownership policy independent of SV TX session --- .../ariec61850/time_sync/sv_ptp_phase_authority.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 8e325c9f9..7dc7833f7 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -305,6 +305,17 @@ struct SvAutoPreflightPolicy final { faults_unchanged && (injecting || preflight); } + // Servo authority is the owned GPTimer + PTP clock lease, NOT a + // transient publisher RUNNING/preflight flag. They can both be false + // during a legitimate STOP->PARK handoff with a continuous 1-Hz clock. + [[nodiscard]] static constexpr bool can_steer_owned_grid( + const bool session_valid, const bool phase_accepted, + const bool lease_valid, const bool faults_unchanged, + const bool owned_timer_phase_enabled, const bool auto_sync_mode) noexcept { + return session_valid && phase_accepted && lease_valid && + faults_unchanged && owned_timer_phase_enabled && auto_sync_mode; + } + [[nodiscard]] static constexpr bool can_advertise_local( const bool injecting, const bool descriptor_qualified) noexcept { return injecting && descriptor_qualified; From 26070b480873300f027901a6ae3bb41c66c1d4dd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 07:58:45 +0700 Subject: [PATCH 08/20] test(sv): 100 STOP/PARK/START flag races cannot invalidate continuous sample-clock servo --- tests/test_sv_ptp_phase_authority.cpp | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 005239082..e7d5e2484 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -153,6 +153,33 @@ void check_m3a_stop_start_keeps_qualified_sample_grid() { true, true, true, 4000U, 4800U)); } +void check_m3e_transition_flags_cannot_revoke_owned_sample_clock() { + using Policy = SvAutoPreflightPolicy; + // Old implementation required RUNNING || PREFLIGHT. Both may be false + // after a legitimate serial SV STOP and before publisher PARK. + CHECK(!Policy::can_steer(true, true, true, true, false, false)); + CHECK(Policy::can_steer_owned_grid(true, true, true, true, true, true)); + // 100 STOP/START cycles; all four flag orderings share one GPTimer. + for (std::uint32_t cycle = 0U; cycle < 100U; ++cycle) { + for (bool sv_running : {true, false}) { + for (bool preflight_flag : {true, false}) { + (void)sv_running; (void)preflight_flag; + CHECK(Policy::can_steer_owned_grid( + true, true, true, true, true, true)); + } + } + } + // No promotion for a missing timer, stale/changed PTP lease, skipped + // phase epoch, TX fault, invalid session or operator override. + for (int i = 0; i < 6; ++i) { + std::array valid{true, true, true, true, true, true}; + valid[static_cast(i)] = false; + CHECK(!Policy::can_steer_owned_grid( + valid[0], valid[1], valid[2], + valid[3], valid[4], valid[5])); + } +} + void check_m3e_parked_certified_ptp_authority_survives_100_restarts() { // IEC 61850-9-2: SmpSynch describes the sampled TIMEBASE, not whether // the publisher is presently transmitting SV frames. @@ -731,6 +758,7 @@ int main() { try { check_auto_preflight_does_not_invent_sync_or_start(); check_m3a_stop_start_keeps_qualified_sample_grid(); + check_m3e_transition_flags_cannot_revoke_owned_sample_clock(); check_m3e_parked_certified_ptp_authority_survives_100_restarts(); check_m3b_boot_ptp_is_safe_and_operator_owned(); check_m3c_foreign_announce_and_exclusive_emac_handover(); From c192b7b88fbb6892304735df3d30ec7d040fe0a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 07:58:57 +0700 Subject: [PATCH 09/20] test(studio): prohibit transient STOP/PARK flags revoking PTP phase clock --- apps/arstack_studio/tests/check_operator_workspace.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 94e5535ac..6d45cf5c6 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -754,6 +754,15 @@ def main() -> None: and "g_preflight_phase_clock_active" in firmware_source and "g_warm_resume_first_epoch" in firmware_source, "SV STOP/START must preserve measured PTP clock authority") + # The 1-Hz phase servo must follow actual GPTimer/PTP ownership. + # A serial STOP flips RUNNING before PARKED; both GUI/ISR flags may be + # false briefly while the real hardware timebase is still continuous. + require("SvAutoPreflightPolicy::can_steer_owned_grid" in firmware_source + and "clock_timer_owned_and_enabled" in firmware_source + and "if (preflight_active)" not in firmware_source + and "(!live_tx_running() &&" not in firmware_source, + "transition flags must not tear down an owned PTP sample clock") + # PTP SOURCE readiness recovery cannot fabricate a NEW timebase lease. # A source generation bump belongs to hardware initialization only. source_generation_bump = "g_ptp_source_generation.fetch_add(1U" From 2a2931d8e4b78c81ac3a56b865dc8d5e35f3831a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 07:59:56 +0700 Subject: [PATCH 10/20] feat(sv): qualify active transmission from physical ISR publication gate --- include/ariec61850/time_sync/sv_ptp_phase_authority.hpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 7dc7833f7..5691fe898 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -305,6 +305,14 @@ struct SvAutoPreflightPolicy final { faults_unchanged && (injecting || preflight); } + // The serial control state may say RUNNING *before* the ISR has opened + // the next PTP-second SV TX gate. Live MAC measurement may start only + // after that physical gate opened; until then the grid is still dry. + [[nodiscard]] static constexpr bool live_publisher_active( + const bool logical_running, const bool tx_notifications_enabled) noexcept { + return logical_running && tx_notifications_enabled; + } + // Servo authority is the owned GPTimer + PTP clock lease, NOT a // transient publisher RUNNING/preflight flag. They can both be false // during a legitimate STOP->PARK handoff with a continuous 1-Hz clock. From 3283b26f82fc0a3d0afab2a9a4eb731f77eb2216 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:00:07 +0700 Subject: [PATCH 11/20] fix(sv): do not requalify live MAC before PTP-second ISR actually enables SV TX --- embedded/esp32p4_smv_injector/main/app_main.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 12b195a9b..314ef2d74 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1914,7 +1914,14 @@ void sv_ptp_phase_observer_task(void* argument) { g_monitored_sample_clock != nullptr && g_sample_clock_context.session_generation.load( std::memory_order_acquire) == notified_session; - const bool injecting = live_tx_running(); + // Serial START becomes RUNNING before the next PTP-second gate + // physically enables publisher notifications. Until that ISR edge, + // continue observing a DRY synchronized clock, not a missing MAC + // stream. Also covers STOP arriving before the PARK control pass. + const bool injecting = SvAutoPreflightPolicy::live_publisher_active( + live_tx_running(), + g_sample_clock_context.publisher_notifications_enabled.load( + std::memory_order_acquire)); const bool clock_timer_owned_and_enabled = g_monitored_sample_clock != nullptr && g_sample_clock_context.phase_enabled; From bbdf30e4f5d5d8751f3c47eb934fd8c74afd6d71 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:00:16 +0700 Subject: [PATCH 12/20] test(sv): no premature MAC evidence loss before actual PTP boundary launch --- tests/test_sv_ptp_phase_authority.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index e7d5e2484..764657bfd 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -159,6 +159,12 @@ void check_m3e_transition_flags_cannot_revoke_owned_sample_clock() { // after a legitimate serial SV STOP and before publisher PARK. CHECK(!Policy::can_steer(true, true, true, true, false, false)); CHECK(Policy::can_steer_owned_grid(true, true, true, true, true, true)); + // Serial control can become RUNNING before the actual second-boundary + // ISR launch. A dry clock is not an SV publication fault. + CHECK(!Policy::live_publisher_active(true, false)); + CHECK(!Policy::live_publisher_active(false, true)); + CHECK(!Policy::live_publisher_active(false, false)); + CHECK(Policy::live_publisher_active(true, true)); // 100 STOP/START cycles; all four flag orderings share one GPTimer. for (std::uint32_t cycle = 0U; cycle < 100U; ++cycle) { for (bool sv_running : {true, false}) { From ffc2668b34ccc1728d72433fc368d1067b3a1232 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:00:24 +0700 Subject: [PATCH 13/20] test(studio): forbid premature live MAC admission before real ISR PTP epoch gate --- apps/arstack_studio/tests/check_operator_workspace.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 6d45cf5c6..4c129b20a 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -758,6 +758,8 @@ def main() -> None: # A serial STOP flips RUNNING before PARKED; both GUI/ISR flags may be # false briefly while the real hardware timebase is still continuous. require("SvAutoPreflightPolicy::can_steer_owned_grid" in firmware_source + and "SvAutoPreflightPolicy::live_publisher_active" in firmware_source + and "publisher_notifications_enabled.load(" in firmware_source and "clock_timer_owned_and_enabled" in firmware_source and "if (preflight_active)" not in firmware_source and "(!live_tx_running() &&" not in firmware_source, From c599be14e1ce0d09b1df08554cabfbb5f9e0b56e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:01:31 +0700 Subject: [PATCH 14/20] ci(fat): compile-time gate for simulated Global GM, PTP boot and measured SV phase evidence --- .github/workflows/esp32p4-smv-injector.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 72cf8ad26..f169e9446 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -158,12 +158,16 @@ jobs: # M3.B must really be linked into the exact shipped ESP32-P4 binary. # A disabled dependency would otherwise silently negate sdkconfig.defaults. grep -qx 'CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART=y' sdkconfig + # A LOCAL default can pass phase/MAC gates but the relay requires + # globally synchronized samples. Never ship a preview where the + # isolated lab Global GM boot option silently compiled out. + grep -qx 'CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM=y' sdkconfig grep -qx 'CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL=y' sdkconfig grep -qx 'CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK=y' sdkconfig grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig - echo 'SOURCE local AUTO preview: servo ON; canonical MAC TX evidence ON; mirror OFF' + echo 'SOURCE simulated Global GM preview: PTP autostart ON; servo ON; canonical MAC TX evidence ON; mirror OFF' - name: Verify P0 pre-v3 silicon policy run: | @@ -259,7 +263,9 @@ jobs: servo = 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' in cfg mac_evidence = 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' in cfg no_mirror = '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' in cfg - candidate = servo and mac_evidence and no_mirror + gm_boot = 'CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM=y' in cfg + source_boot = 'CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART=y' in cfg + candidate = servo and mac_evidence and no_mirror and gm_boot and source_boot if not re.fullmatch(r"[0-9a-f]{40}", source_commit): raise SystemExit("Firmware source SHA unavailable/invalid; refusing a zero-provenance package") manifest = { @@ -277,6 +283,9 @@ jobs: "sourceCommit": source_commit, "buildId": source_commit[:16], "phaseSyncPreview": candidate, + "bootSimulatedGlobalGmEnabled": gm_boot, + "bootSourceAutostartEnabled": source_boot, + "globalGmTraceabilityPhysicallyProven": False, "phaseServoEnabled": servo, "canonicalMacTxEvidenceEnabled": mac_evidence, "broadcastMirrorEnabled": not no_mirror, From 4412e19ebc57677a0505f580ae908faf2e1e5ed4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:01:40 +0700 Subject: [PATCH 15/20] ci(studio): refuse matched Windows installer unless ESP32 firmware boots simulated Global GM --- .github/workflows/arstack-studio-native-preview.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index cbc7acdfd..b1d04642d 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -111,10 +111,13 @@ jobs: } $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json if ($manifest.phaseSyncPreview -ne $true -or + $manifest.bootSimulatedGlobalGmEnabled -ne $true -or + $manifest.bootSourceAutostartEnabled -ne $true -or + $manifest.globalGmTraceabilityPhysicallyProven -ne $false -or $manifest.phaseServoEnabled -ne $true -or $manifest.canonicalMacTxEvidenceEnabled -ne $true -or $manifest.broadcastMirrorEnabled -ne $false) { - throw 'Studio preview refuses old/default SMV firmware without measured PTP-SV phase and MAC TX evidence' + throw 'FAT preview requires simulated Global GM BOOT + PTP-source autostart + measured SV phase/MAC evidence, never LOCAL-only firmware' } $expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SESSION-LEASE', 'PTP-P2', 'SMPSYNCH-AUTO') foreach ($capability in $expectedCapabilities) { From a14dea19b577f77f1d8353a3a93a7afeb5761049 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:06:37 +0700 Subject: [PATCH 16/20] fix(ptp): default to INTERNAL_GM_ONLY; compiled external GM capability is not runtime AUTO opt-in --- embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index d0914c4cd..f37b45716 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -48,13 +48,12 @@ std::atomic_bool g_receiver_stop_pending{false}; // Cancel only automatic boot attempts; operator PTP actions are authoritative. std::atomic_bool g_boot_source_autostart_allowed{true}; std::atomic g_ptp_policy_generation{1U}; -#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL -std::atomic g_advanced_ptp_mode{ - AR_PTP_POLICY_AUTO_PREFER_EXTERNAL}; -#else +// Compiling external-GM arbitration must NEVER silently opt the relay +// test-set into handover. SOURCE stays its own GM across every SV STOP/START. +// AUTO_PREFER_EXTERNAL is an explicit, operator-selected runtime policy; +// the Kconfig option is a capability gate, NOT the power-on policy. std::atomic g_advanced_ptp_mode{ AR_PTP_POLICY_INTERNAL_GM_ONLY}; -#endif ar::iec61850::time_sync::PtpControlCancellationGate g_role_control; void stop_active_ptp_roles() noexcept; From 01b49ce4c92b17ac50b7971f2422c3b5441759ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:06:48 +0700 Subject: [PATCH 17/20] test(ptp): block implicit AUTO external-GM handover in internal SOURCE lab mode --- apps/arstack_studio/tests/check_operator_workspace.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 4c129b20a..87a2d398e 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -773,6 +773,14 @@ def main() -> None: ptp_source_task.find("std::uint32_t consecutive_failures = 0U"), "PTP source generation must never advance on TX readiness recovery") + # Compiled external-GM support is optional capability, never an + # implicit AUTO handover while the lab ESP32 is supposed to remain GM. + facade = (project_root / "embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp").read_text(encoding="utf-8") + default_mode = facade.split("g_advanced_ptp_mode{", 1)[1].split("};", 1)[0] + require("AR_PTP_POLICY_INTERNAL_GM_ONLY" in default_mode + and "AR_PTP_POLICY_AUTO_PREFER_EXTERNAL" not in default_mode, + "PTP SOURCE must default to INTERNAL_GM_ONLY, AUTO only operator opt-in") + print("Studio operator workspace contract: PASS") From 455be598980aec0e66b008f5b0e7a413d01077b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:07:06 +0700 Subject: [PATCH 18/20] ci(fat): make implicit AUTO GM handover a release-blocker, record INTERNAL_GM_ONLY default --- .github/workflows/esp32p4-smv-injector.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index f169e9446..e664ca679 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -266,6 +266,12 @@ jobs: gm_boot = 'CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM=y' in cfg source_boot = 'CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART=y' in cfg candidate = servo and mac_evidence and no_mirror and gm_boot and source_boot + # External GM arbitration is compiled for Advanced operator use. + # It must NEVER silently become the power-on time source. + facade = Path('main/ptp_mode_facade.cpp').read_text(encoding='utf-8') + default_mode = facade.split('g_advanced_ptp_mode{', 1)[1].split('};', 1)[0] + if 'AR_PTP_POLICY_INTERNAL_GM_ONLY' not in default_mode or 'AR_PTP_POLICY_AUTO_PREFER_EXTERNAL' in default_mode: + raise SystemExit('Unsafe implicit AUTO GM handover: FAT SOURCE must default INTERNAL_GM_ONLY') if not re.fullmatch(r"[0-9a-f]{40}", source_commit): raise SystemExit("Firmware source SHA unavailable/invalid; refusing a zero-provenance package") manifest = { @@ -285,6 +291,7 @@ jobs: "phaseSyncPreview": candidate, "bootSimulatedGlobalGmEnabled": gm_boot, "bootSourceAutostartEnabled": source_boot, + "defaultPtpPolicy": "INTERNAL_GM_ONLY", "globalGmTraceabilityPhysicallyProven": False, "phaseServoEnabled": servo, "canonicalMacTxEvidenceEnabled": mac_evidence, From 52ee5d711e89472ea7a07978ebc0c2f125eef4e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:07:13 +0700 Subject: [PATCH 19/20] ci(windows): installer rejects implicit external-GM AUTO defaults; require internal GM SOURCE --- .github/workflows/arstack-studio-native-preview.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index b1d04642d..f18374cba 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -113,6 +113,7 @@ jobs: if ($manifest.phaseSyncPreview -ne $true -or $manifest.bootSimulatedGlobalGmEnabled -ne $true -or $manifest.bootSourceAutostartEnabled -ne $true -or + $manifest.defaultPtpPolicy -ne 'INTERNAL_GM_ONLY' -or $manifest.globalGmTraceabilityPhysicallyProven -ne $false -or $manifest.phaseServoEnabled -ne $true -or $manifest.canonicalMacTxEvidenceEnabled -ne $true -or From 34b26147ea19bd31153ced9eafba1f019151fb4f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:11:49 +0700 Subject: [PATCH 20/20] test(studio): update preflight guard to verified continuously owned GPTimer policy --- apps/arstack_studio/tests/check_operator_workspace.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 87a2d398e..a7a7259ab 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -82,7 +82,9 @@ def main() -> None: and "preflight_only = false" in firmware_source and "SV/PTP AUTO preflight sample clock armed (SV TX OFF)" in firmware_source and "check_auto_preflight_does_not_invent_sync_or_start()" in phase_tests - and "SvAutoPreflightPolicy::can_steer(" in firmware_source + # Timebase continues through the short STOP->PARK interval + # independently of transient RUNNING/PREFLIGHT booleans. + and "SvAutoPreflightPolicy::can_steer_owned_grid(" in firmware_source and "SvAutoPreflightPolicy::can_advertise_local(" in firmware_source and "if (injecting)" in firmware_source and "preflight_telemetry" in firmware_source