Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fc679dd
fix(sv): retain certified PTP sample-clock authority across SV-only S…
masarray Oct 23, 2026
f5a291e
test(sv): 100 stop/start cycles preserve clock proof and reject false…
masarray Oct 23, 2026
29a3630
test(studio): guard clock certification against SV-only STOP regressions
masarray Oct 23, 2026
3ee4e25
fix(ptp): source generation tracks actual EMAC timebase starts, not t…
masarray Oct 23, 2026
c917739
test(ptp): prohibit fake GM generation changes on readiness reacquisi…
masarray Oct 23, 2026
fab12b3
fix(m3e): keep sample phase observer on owned GPTimer during asynchro…
masarray Oct 24, 2026
2d27ea1
feat(timebase): pure continuous GPTimer ownership policy independent …
masarray Oct 24, 2026
26070b4
test(sv): 100 STOP/PARK/START flag races cannot invalidate continuous…
masarray Oct 24, 2026
c192b7b
test(studio): prohibit transient STOP/PARK flags revoking PTP phase c…
masarray Oct 24, 2026
2a2931d
feat(sv): qualify active transmission from physical ISR publication gate
masarray Oct 24, 2026
3283b26
fix(sv): do not requalify live MAC before PTP-second ISR actually ena…
masarray Oct 24, 2026
bbdf30e
test(sv): no premature MAC evidence loss before actual PTP boundary l…
masarray Oct 24, 2026
ffc2668
test(studio): forbid premature live MAC admission before real ISR PTP…
masarray Oct 24, 2026
c599be1
ci(fat): compile-time gate for simulated Global GM, PTP boot and meas…
masarray Oct 24, 2026
4412e19
ci(studio): refuse matched Windows installer unless ESP32 firmware bo…
masarray Oct 24, 2026
a14dea1
fix(ptp): default to INTERNAL_GM_ONLY; compiled external GM capabilit…
masarray Oct 24, 2026
01b49ce
test(ptp): block implicit AUTO external-GM handover in internal SOURC…
masarray Oct 24, 2026
455be59
ci(fat): make implicit AUTO GM handover a release-blocker, record INT…
masarray Oct 24, 2026
52ee5d7
ci(windows): installer rejects implicit external-GM AUTO defaults; re…
masarray Oct 24, 2026
34b2614
test(studio): update preflight guard to verified continuously owned G…
masarray Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/arstack-studio-native-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,14 @@ 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.defaultPtpPolicy -ne 'INTERNAL_GM_ONLY' -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) {
Expand Down
20 changes: 18 additions & 2 deletions .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -259,7 +263,15 @@ 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
# 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 = {
Expand All @@ -277,6 +289,10 @@ jobs:
"sourceCommit": source_commit,
"buildId": source_commit[:16],
"phaseSyncPreview": candidate,
"bootSimulatedGlobalGmEnabled": gm_boot,
"bootSourceAutostartEnabled": source_boot,
"defaultPtpPolicy": "INTERNAL_GM_ONLY",
"globalGmTraceabilityPhysicallyProven": False,
"phaseServoEnabled": servo,
"canonicalMacTxEvidenceEnabled": mac_evidence,
"broadcastMirrorEnabled": not no_mirror,
Expand Down
38 changes: 37 additions & 1 deletion apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -747,6 +749,40 @@ 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")
# 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 "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,
"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"
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")

# 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")


Expand Down
123 changes: 101 additions & 22 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,25 @@ 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<bool> 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:
// clearing it unconditionally after a valid sparse MAC TX stamp would make
// 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() ||
Expand Down Expand Up @@ -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);
Comment on lines +1178 to +1180

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate parked certification on sync-policy changes

If a certified clock is parked and PROFILE SMPSYNCH 1 followed by PROFILE SMPSYNCH AUTO arrives before the publisher's next 200-ms idle check, smp_synch_lab_set_mode() clears the existing SOURCE qualification but never clears this new latch. The timer and PTP lease remain unchanged, so START accepts the cached certificate and restores qualification without fresh MAC evidence, defeating the existing fail-closed mode-change behavior tested in test_smp_synch_policy.cpp. Checking only the current AUTO mode cannot detect this intervening invalidation. Tie parked certification to the shared qualification invalidation path or a policy generation so a revoked certificate cannot be resurrected.

AGENTS.md reference: AGENTS.md:L30-L30

Useful? React with 👍 / 👎.

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);
Expand Down Expand Up @@ -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);
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down Expand Up @@ -1731,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");
Expand Down Expand Up @@ -1851,17 +1914,28 @@ 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();
const bool preflight_active = g_preflight_phase_clock_active.load(
std::memory_order_acquire);
// 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;
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.
Expand Down Expand Up @@ -1958,7 +2032,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,
Expand All @@ -1972,13 +2048,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 {
local_acquisition.reset();
source_fast_acquisition.reset();
revoke_sv_auto_claims(SvSyncLossReason::publisher_stop);
}
} 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);
}
}
committed = true;
} else if (session_matches) {
Expand Down
Loading
Loading