From 6887ea12cdf80b15be0fd84481582346dd92cab2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:17:38 +0700 Subject: [PATCH 001/243] test(discovery): add bounded smart capture path --- ...iveIec61850Client.SmartDiscoveryCapture.cs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 Services/NativeIec61850Client.SmartDiscoveryCapture.cs diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs new file mode 100644 index 000000000..1eb07eb7b --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -0,0 +1,125 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + // This path is intentionally isolated to the PR #134 Wireshark comparison build. + // It keeps live MMS evidence authoritative while removing ARSAS's historical + // supplemental GetNameList/read/probe passes from the discovery critical path. + private static bool SmartDiscoveryCaptureModeEnabled => true; + + private async Task> DiscoverSignalsSmartForCaptureAsync( + CancellationToken cancellationToken, + IProgress? progress) + { + LastDiscoverySummary = string.Empty; + cancellationToken.ThrowIfCancellationRequested(); + + if (!_session.IsMmsInitiated) + { + LastErrorMessage = $"ARIEC61850 smart discovery requires ACSE/MMS association. Current state: {_session.State}. {_session.LastAssociationAttemptSummary}"; + return Array.Empty(); + } + + try + { + var smartOptions = new ArMms.MmsSmartDiscoveryOptions + { + MaxConcurrentChains = 8, + UnknownPeerMaxConcurrentChains = 4, + MaxDomains = 256, + MaxVariableNamesPerDomain = 20000, + MaxVariableListNamesPerDomain = 4096, + MaxNameListPages = 64, + ProbeReportAttributes = false, + ReadDataSetDirectories = false + }; + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.DiscoveringDirectory, + "Smart MMS discovery: bounded parallel directory scan…", + 28d, 4, 10)); + + var discovery = await _session + .DiscoverSmartAsync(smartOptions, cancellationToken) + .ConfigureAwait(false); + _lastDiscovery = discovery; + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.ProbingLogicalNodes, + "Smart MMS type discovery: Logical Node hierarchy probes…", + 52d, 5, 10)); + + var variableTypes = await LiveIedVariableTypeProbeExecutor + .ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, cancellationToken) + .ConfigureAwait(false); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.BuildingLiveModel, + "Building canonical IEC 61850 model from smart discovery evidence…", + 68d, 6, 10)); + + _liveModel = LiveIedModelDiscoveryBuilder.Build( + discovery, + new LiveIedModelDiscoveryBuildOptions + { + Host = _host, + Port = _port, + IncludeLowConfidenceTemplates = true + }, + variableTypeAttributes: variableTypes); + + var snapshot = ToNativeSnapshot(discovery.Snapshot); + LastReportInventory = ToNativeInventory(discovery.ReportInventory); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.MappingSignals, + "Mapping smart structural model to the ARSAS signal workspace…", + 82d, 7, 10)); + + var signals = BuildSignalsFromArIecModel(_liveModel, snapshot).ToList(); + AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts( + signals, + discovery, + snapshot, + LastReportInventory, + DateTime.Now); + signals = FinalizeDiscoveredSignals(signals).ToList(); + + // Report hints derived from structural NamedVariable/NamedVariableList evidence + // remain available. Attribute reads and DataSet-directory reads are deferred. + NativeReportDiscoveryMapper.ApplyReportHints(signals, LastReportInventory); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.ResolvingIdentity, + "Resolving IED identity from the canonical live model…", + 94d, 8, 10)); + + DetectedIdentity = Iec61850DeviceIdentityResolver.Resolve(discovery, _liveModel, signals); + + var logicalNodes = signals + .Select(signal => signal.LogicalNode) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + var rawVariables = snapshot.DomainVariables.Values.Sum(values => values.Count); + var successfulTypeRoots = variableTypes.Count(result => result.IsSuccess); + + LastDiscoverySummary = + $"SMART-CAPTURE PR134; IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + + $"{discovery.Summary} {_liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + + $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}. " + + "Deferred in this capture build: supplemental GetNameList, eager report attributes, DataSet directories, adaptive sibling probes, primary-equipment proof probes, per-signal operational-reference probes, and engineering-unit reads."; + LastErrorMessage = LastDiscoverySummary; + return signals; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LastErrorMessage = $"ARIEC61850 smart capture discovery failed: {ex.GetType().Name}: {ex.Message}. Last discovery: {_session.LastDiscoveryAttemptSummary}. Last request: {_session.LastDiscoveryRequestHex}"; + return Array.Empty(); + } + } +} From c7da2e9239227a089a56f8c45f54491b9914b0ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:17:53 +0700 Subject: [PATCH 002/243] test(discovery): route capture build to smart path --- scripts/enable-smart-discovery-capture.ps1 | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 scripts/enable-smart-discovery-capture.ps1 diff --git a/scripts/enable-smart-discovery-capture.ps1 b/scripts/enable-smart-discovery-capture.ps1 new file mode 100644 index 000000000..63ba58ebb --- /dev/null +++ b/scripts/enable-smart-discovery-capture.ps1 @@ -0,0 +1,30 @@ +$ErrorActionPreference = 'Stop' + +$sourcePath = Join-Path $PSScriptRoot '..\Services\NativeIec61850Client.cs' +$sourcePath = [System.IO.Path]::GetFullPath($sourcePath) +$text = [System.IO.File]::ReadAllText($sourcePath) +$marker = 'DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress)' + +if ($text.Contains($marker, [System.StringComparison]::Ordinal)) { + Write-Host 'Smart discovery capture route already installed.' + exit 0 +} + +$pattern = '(public async Task> DiscoverSignalsAsync\(CancellationToken cancellationToken, IProgress\? progress = null\)\s*\{)' +$match = [regex]::Match($text, $pattern) +if (-not $match.Success) { + throw 'Could not locate NativeIec61850Client.DiscoverSignalsAsync entrypoint.' +} +if ([regex]::Matches($text, $pattern).Count -ne 1) { + throw 'DiscoverSignalsAsync entrypoint is not unique; refusing ambiguous build-time patch.' +} + +$injection = @' + + if (SmartDiscoveryCaptureModeEnabled) + return await DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress).ConfigureAwait(false); +'@ + +$patched = $text.Insert($match.Index + $match.Length, $injection) +[System.IO.File]::WriteAllText($sourcePath, $patched, [System.Text.UTF8Encoding]::new($false)) +Write-Host 'Installed PR #134 smart discovery capture route into NativeIec61850Client.DiscoverSignalsAsync.' From f131501d8d1f4d7faa94323bd0aabf270c21d442 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:18:00 +0700 Subject: [PATCH 003/243] test(discovery): enable smart capture route before compile --- Directory.Build.targets | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 Directory.Build.targets diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 000000000..5c9c8aa5f --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,7 @@ + + + + + From 6fa3b7b25971ff58f60d3cafea7e5c444ab81cb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:18:31 +0700 Subject: [PATCH 004/243] test(discovery): pin ARIEC61850 smart discovery PR --- engines/ARIEC61850.lock.json | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index e4e2a9548..a77c60f2c 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,17 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "3afc924c97627fe86adbe784c905e2f35dff0b1a", - "sourcePullRequest": 133, - "purpose": "Pins merged ARIEC61850 PR #133 on top of the exact PR #132 golden-wire tree. ARIEC .NET CI #605 passed source/provenance verification, restore, build, and tests. This adds only an opt-in source-backed Legacy SAS export boundary: ARSAS may display concrete live runtime RCB slots such as Buffer01/Buffer02 while CID/IID export retains the logical source ReportControl identity and RptEnabled indexing metadata. The option is default-off, so existing exact-runtime live-model export remains unchanged. All trusted-SCL acquisition, reporting and control contracts from PR #132 remain unchanged: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. SCL RptEnabled@max remains declarative design metadata and is never authority to synthesize concrete runtime RCB names.", - "previousTrialPin": { - "commit": "0023ef9a4373855497464ed3979e359c4041c95d", - "sourcePullRequest": 132, - "purpose": "Previous ARSAS 1.6.36 combined golden-wire convergence pin retained for explicit ancestry." - }, - "fieldProvenBaseline": { - "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", - "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." - } + "commit": "040718027b92681b89f2e04ce048a53fe225a1c7", + "sourcePullRequest": 134, + "purpose": "Test-only capture build pin for ARIEC61850 PR #134. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, and smart FC-root read support. ARIEC61850 CI run #626 passed source verification, Release build with zero warnings/errors, all 882 tests, and artifact packaging. This ARSAS branch intentionally defers historical supplemental discovery passes so field capture can measure the new bounded structural path directly." } From 9743459b015dc7b63b84edb6820afaeb8bd52a4e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:23:07 +0700 Subject: [PATCH 005/243] ci(test): build smart discovery field-capture executable --- .../smart-discovery-capture-build.yml | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/smart-discovery-capture-build.yml diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml new file mode 100644 index 000000000..771d545e5 --- /dev/null +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -0,0 +1,132 @@ +name: Smart Discovery Field Capture Build + +on: + pull_request: + workflow_dispatch: + +jobs: + build-smart-capture: + name: Build smart-discovery portable field capture + runs-on: windows-latest + steps: + - name: Checkout ARSAS test branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Resolve and validate engine pin + shell: powershell + run: | + $lock = Get-Content .\ArIED61850Tester\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid ARIEC61850 field-capture pin.' + } + if ($lock.commit -ne '040718027b92681b89f2e04ce048a53fe225a1c7') { + throw "Unexpected engine commit: $($lock.commit)" + } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + $version = (Get-Content .\ArIED61850Tester\VERSION -Raw).Trim() + "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Verify smart capture sources + shell: powershell + run: | + $helper = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $patcher = Get-Content .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -Raw + $targets = Get-Content .\ArIED61850Tester\Directory.Build.targets -Raw + if ($helper -notmatch 'DiscoverSmartAsync' -or + $helper -notmatch 'LiveIedVariableTypeProbeExecutor' -or + $helper -notmatch 'variableTypeAttributes' -or + $patcher -notmatch 'DiscoverSignalsSmartForCaptureAsync' -or + $targets -notmatch 'EnableSmartDiscoveryCaptureRoute') { + throw 'Smart discovery capture routing is incomplete.' + } + + - name: Checkout immutable ARIEC61850 PR 134 engine + shell: powershell + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT + git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { throw "Engine SHA mismatch: $actual" } + $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw + $hierarchy = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedVariableTypeHierarchy.cs -Raw + if ($smart -notmatch 'DiscoverSmartAsync' -or $hierarchy -notmatch 'ProbeSmartAsync') { + throw 'Pinned engine does not expose the required smart discovery APIs.' + } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln + + - name: Build Release + run: dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore + + - name: Verify smart route was installed + shell: powershell + run: | + $native = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.cs -Raw + if ($native -notmatch 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'Build-time smart discovery route was not installed.' + } + + - name: Run ARSAS regression tests + run: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-smart-capture-tests.trx" --results-directory .\ArIED61850Tester\TestResults + + - name: Publish portable single EXE x64 + shell: powershell + run: | + .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` + -Version $env:ARSAS_VERSION ` + -Runtime win-x64 ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + + - name: Smoke test portable executable + shell: powershell + run: | + $exe = ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" + if (-not (Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing: $exe" } + $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-smart-capture-bundle-cache' + $process = Start-Process -FilePath $exe -ArgumentList @('--portable-smoke-test') -PassThru + if (-not $process.WaitForExit(30000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw 'Portable EXE smoke test timed out.' + } + if ($process.ExitCode -ne 0) { throw "Portable EXE smoke test failed: $($process.ExitCode)" } + @( + "ARSAS smart discovery field-capture build", + "ARSAS commit: $env:GITHUB_SHA", + "ARIEC61850 commit: $env:ARIEC61850_COMMIT", + "Engine PR: 134", + "Mode: bounded smart structural discovery + hierarchy-first smart type probes", + "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" + ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 + + - name: Upload smart field-capture build + uses: actions/upload-artifact@v4 + with: + name: ARSAS-smart-discovery-pr134-win-x64 + path: | + ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt + if-no-files-found: error + retention-days: 14 + + - name: Upload regression evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-smart-discovery-pr134-test-evidence + path: ArIED61850Tester\TestResults\*.trx + if-no-files-found: warn + retention-days: 14 From f543dedb5f20a604cb2e6dd5da93ae24d313c424 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:25:00 +0700 Subject: [PATCH 006/243] fix(test): support Windows PowerShell smart route patch --- scripts/enable-smart-discovery-capture.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/enable-smart-discovery-capture.ps1 b/scripts/enable-smart-discovery-capture.ps1 index 63ba58ebb..f5ff45208 100644 --- a/scripts/enable-smart-discovery-capture.ps1 +++ b/scripts/enable-smart-discovery-capture.ps1 @@ -5,7 +5,7 @@ $sourcePath = [System.IO.Path]::GetFullPath($sourcePath) $text = [System.IO.File]::ReadAllText($sourcePath) $marker = 'DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress)' -if ($text.Contains($marker, [System.StringComparison]::Ordinal)) { +if ($text.IndexOf($marker, [System.StringComparison]::Ordinal) -ge 0) { Write-Host 'Smart discovery capture route already installed.' exit 0 } @@ -26,5 +26,5 @@ $injection = @' '@ $patched = $text.Insert($match.Index + $match.Length, $injection) -[System.IO.File]::WriteAllText($sourcePath, $patched, [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($sourcePath, $patched, (New-Object System.Text.UTF8Encoding($false))) Write-Host 'Installed PR #134 smart discovery capture route into NativeIec61850Client.DiscoverSignalsAsync.' From c3dc8c3f74aa31e42fa6e921b9c68d4ad2647042 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:28:22 +0700 Subject: [PATCH 007/243] fix(test): preserve reviewed engine ancestry in capture pin --- engines/ARIEC61850.lock.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index a77c60f2c..6d0f8b848 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -4,5 +4,15 @@ "ref": "main", "commit": "040718027b92681b89f2e04ce048a53fe225a1c7", "sourcePullRequest": 134, - "purpose": "Test-only capture build pin for ARIEC61850 PR #134. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, and smart FC-root read support. ARIEC61850 CI run #626 passed source verification, Release build with zero warnings/errors, all 882 tests, and artifact packaging. This ARSAS branch intentionally defers historical supplemental discovery passes so field capture can measure the new bounded structural path directly." + "purpose": "Test-only capture build pin for ARIEC61850 PR #134. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, and smart FC-root read support. ARIEC61850 CI run #626 passed source verification, Release build with zero warnings/errors, all 882 tests, and artifact packaging. This ARSAS branch intentionally defers historical supplemental discovery passes so field capture can measure the new bounded structural path directly. The reviewed production ancestry below remains preserved unchanged for regression authority.", + "previousTrialPin": { + "commit": "0023ef9a4373855497464ed3979e359c4041c95d", + "sourcePullRequest": 132, + "purpose": "Previous ARSAS 1.6.36 combined golden-wire convergence pin retained for explicit ancestry." + }, + "fieldProvenBaseline": { + "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", + "sourcePullRequest": 111, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." + } } From 6f0688117acd87f7d5d5022cc8ce4df06a8d7913 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 21:29:53 +0700 Subject: [PATCH 008/243] perf(discovery): index smart projection and guard reentry --- ...c61850Client.SmartDiscoveryOptimization.cs | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 Services/NativeIec61850Client.SmartDiscoveryOptimization.cs diff --git a/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs new file mode 100644 index 000000000..bce350697 --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs @@ -0,0 +1,222 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + // One caller owns the smart structural discovery for the active association. A + // concurrent UI/runtime re-entry waits for that work and then reuses the exact + // authoritative result instead of starting another GetNameList/GVA pass. + private readonly SemaphoreSlim _smartDiscoveryCaptureGate = new(1, 1); + private ArMms.MmsDiscoveryResult? _smartDiscoveryAuthority; + private LiveIedModelDiscoveryDocument? _smartDiscoveryModelAuthority; + private int _smartDiscoveryTypeProbeCount; + private int _smartDiscoverySuccessfulTypeProbeCount; + + private bool TryGetSmartDiscoveryAuthority( + out ArMms.MmsDiscoveryResult discovery, + out LiveIedModelDiscoveryDocument model) + { + discovery = null!; + model = null!; + + if (_smartDiscoveryAuthority is null || + _smartDiscoveryModelAuthority is null || + !ReferenceEquals(_lastDiscovery, _smartDiscoveryAuthority) || + !ReferenceEquals(_liveModel, _smartDiscoveryModelAuthority)) + { + return false; + } + + discovery = _smartDiscoveryAuthority; + model = _smartDiscoveryModelAuthority; + return true; + } + + private void PublishSmartDiscoveryAuthority( + ArMms.MmsDiscoveryResult discovery, + LiveIedModelDiscoveryDocument model, + int typeProbeCount, + int successfulTypeProbeCount) + { + _smartDiscoveryAuthority = discovery; + _smartDiscoveryModelAuthority = model; + _smartDiscoveryTypeProbeCount = typeProbeCount; + _smartDiscoverySuccessfulTypeProbeCount = successfulTypeProbeCount; + } + + private readonly record struct SmartProjectionStats( + int LogicalNodeHints, + int AddedFallbackSignals); + + /// + /// Projects the already-finalized canonical model and then adds only cheap indexed + /// fallback evidence. The legacy compatibility fallback walks the discovery graph + /// reflectively (up to 50k objects twice) and repeatedly scans the complete signal + /// list; that work is intentionally excluded from the normal PR134 critical path. + /// + private static List BuildSmartCaptureSignalProjection( + LiveIedModelDiscoveryDocument model, + NativeMmsDiscoverySnapshot snapshot, + NativeReportInventory inventory, + out SmartProjectionStats stats) + { + var signals = BuildSignalsFromArIecModel(model, snapshot).ToList(); + stats = AddSmartIndexedLogicalNodeFallbacks( + signals, + snapshot, + inventory, + DateTime.Now); + + // BuildSignalsFromArIecModel has already finalized the canonical list once. + // Only newly-added fallback statuses can need a control candidate. Do not run + // the full FinalizeDiscoveredSignals grouping/sorting pipeline a second time. + if (stats.AddedFallbackSignals > 0) + AddValidatedControlCandidatesFromStatus(signals); + + return signals; + } + + private static SmartProjectionStats AddSmartIndexedLogicalNodeFallbacks( + ICollection signals, + NativeMmsDiscoverySnapshot snapshot, + NativeReportInventory inventory, + DateTime now) + { + var hints = new Dictionary(StringComparer.OrdinalIgnoreCase); + var signalsByLogicalNode = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var logicalNodesWithCoreSignals = new HashSet(StringComparer.OrdinalIgnoreCase); + var references = new HashSet(StringComparer.OrdinalIgnoreCase); + + static string LogicalNodeKey(string domain, string logicalNode) + => string.Concat(domain, "\u001F", logicalNode); + + void IndexSignal(SignalDefinition signal) + { + var normalizedReference = NormalizeReference(signal.ObjectReference); + if (!string.IsNullOrWhiteSpace(normalizedReference)) + references.Add(normalizedReference); + + var domain = ExtractDomain(signal.ObjectReference); + var logicalNode = signal.LogicalNode?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(logicalNode)) + return; + + var key = LogicalNodeKey(domain, logicalNode); + if (!signalsByLogicalNode.TryGetValue(key, out var bucket)) + { + bucket = new List(); + signalsByLogicalNode[key] = bucket; + } + bucket.Add(signal); + if (signal.IsScadaCoreSignal) + logicalNodesWithCoreSignals.Add(key); + } + + void AddHint(string domain, string logicalNode, string source) + { + domain = (domain ?? string.Empty).Trim().Replace('$', '.'); + logicalNode = (logicalNode ?? string.Empty).Trim().Replace('$', '.'); + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(logicalNode)) + return; + + var logicalNodeClass = SignalDefinition.DetectLogicalNodeClass(logicalNode).ToUpperInvariant(); + if (!IsScadaLogicalNodeClassForFallback(logicalNodeClass)) + return; + + var key = LogicalNodeKey(domain, logicalNode); + if (!hints.ContainsKey(key)) + hints[key] = new LogicalNodeHint(domain, logicalNode, logicalNodeClass, source); + } + + foreach (var signal in signals) + { + IndexSignal(signal); + var domain = ExtractDomain(signal.ObjectReference); + if (!string.IsNullOrWhiteSpace(domain) && !string.IsNullOrWhiteSpace(signal.LogicalNode)) + AddHint(domain, signal.LogicalNode, "canonical signal inventory"); + } + + foreach (var domainPair in snapshot.DomainVariables) + { + var domain = domainPair.Key ?? string.Empty; + foreach (var rawName in domainPair.Value) + { + foreach (var hint in ExtractLogicalNodeHintsFromText(rawName, domain, "MMS NamedVariable")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + } + } + + foreach (var domainPair in snapshot.DomainVariableLists) + { + var domain = domainPair.Key ?? string.Empty; + foreach (var rawName in domainPair.Value) + { + foreach (var hint in ExtractLogicalNodeHintsFromText(rawName, domain, "MMS NamedVariableList/DataSet")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + } + } + + foreach (var dataSet in inventory.DataSets) + { + AddHint(dataSet.Domain, dataSet.LogicalNode, "Report inventory DataSet"); + foreach (var hint in ExtractLogicalNodeHintsFromText(dataSet.Reference, dataSet.Domain, "Report inventory DataSet reference")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + foreach (var hint in ExtractLogicalNodeHintsFromText(dataSet.RawMmsName, dataSet.Domain, "Report inventory DataSet raw name")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + } + + foreach (var reportControl in inventory.ReportControls) + { + AddHint(reportControl.Domain, reportControl.LogicalNode, "Report inventory RCB"); + foreach (var hint in ExtractLogicalNodeHintsFromText(reportControl.Reference, reportControl.Domain, "Report inventory RCB reference")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + foreach (var hint in ExtractLogicalNodeHintsFromText(reportControl.DataSetReference, reportControl.Domain, "Report inventory RCB DataSet reference")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + } + + var added = 0; + foreach (var hint in hints.Values + .OrderBy(item => item.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.LogicalNode, StringComparer.OrdinalIgnoreCase)) + { + var key = LogicalNodeKey(hint.Domain, hint.LogicalNode); + if (logicalNodesWithCoreSignals.Contains(key)) + continue; + + var existingForLogicalNode = signalsByLogicalNode.TryGetValue(key, out var bucket) + ? bucket.ToArray() + : Array.Empty(); + + foreach (var point in BuildArIecLogicalNodeFallbackPoints(hint.LogicalNodeClass)) + { + if (hint.LogicalNodeClass is "MMXU" or "MMXN" && + existingForLogicalNode.Length > 0 && + !existingForLogicalNode.Any(signal => HasDataObjectPath(signal.ObjectReference, point.DataObject))) + { + continue; + } + + var reference = $"{hint.Domain}/{hint.LogicalNode}.{point.Path}"; + if (!references.Add(NormalizeReference(reference))) + continue; + + var signal = CreateArIecSignal( + reference, + point.FunctionalConstraint, + point.Category, + hint.LogicalNodeClass, + point.DataObject, + string.Empty, + now, + $"Indexed smart discovery fallback ({hint.Source})"); + signals.Add(signal); + added++; + } + } + + return new SmartProjectionStats(hints.Count, added); + } +} From 97d4f2394fd7ae68a4e29bd13550b1dad9c47505 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 21:30:31 +0700 Subject: [PATCH 009/243] perf(discovery): reuse smart authority and expose phase timings --- ...iveIec61850Client.SmartDiscoveryCapture.cs | 122 ++++++++++++++++-- 1 file changed, 111 insertions(+), 11 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index 1eb07eb7b..a4ef7a7f9 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArMms = AR.Iec61850.Mms; @@ -14,6 +15,24 @@ public sealed partial class NativeIec61850Client private async Task> DiscoverSignalsSmartForCaptureAsync( CancellationToken cancellationToken, IProgress? progress) + { + // Protect one physical MMS association from accidental concurrent discovery + // (double-click, overlapping runtime requests, or future background consumers). + // Waiting callers reuse the completed association-scoped authority below. + await _smartDiscoveryCaptureGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await DiscoverSignalsSmartForCaptureCoreAsync(cancellationToken, progress).ConfigureAwait(false); + } + finally + { + _smartDiscoveryCaptureGate.Release(); + } + } + + private async Task> DiscoverSignalsSmartForCaptureCoreAsync( + CancellationToken cancellationToken, + IProgress? progress) { LastDiscoverySummary = string.Empty; cancellationToken.ThrowIfCancellationRequested(); @@ -24,8 +43,63 @@ private async Task> DiscoverSignalsSmartForCaptu return Array.Empty(); } + var totalWatch = Stopwatch.StartNew(); try { + // A second discovery request on the same association must be wire-free. The + // authority marker is reference-bound to _lastDiscovery/_liveModel, both of + // which are reset by the normal connection lifecycle before a new IED/session. + if (TryGetSmartDiscoveryAuthority(out var cachedDiscovery, out var cachedModel)) + { + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.MappingSignals, + "Reusing the authoritative smart discovery for this MMS association…", + 82d, 7, 10)); + + var projectionWatch = Stopwatch.StartNew(); + var cachedSnapshot = ToNativeSnapshot(cachedDiscovery.Snapshot); + LastReportInventory = ToNativeInventory(cachedDiscovery.ReportInventory); + var cachedSignals = BuildSmartCaptureSignalProjection( + cachedModel, + cachedSnapshot, + LastReportInventory, + out var cachedProjectionStats); + projectionWatch.Stop(); + + var reportWatch = Stopwatch.StartNew(); + NativeReportDiscoveryMapper.ApplyReportHints(cachedSignals, LastReportInventory); + reportWatch.Stop(); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.ResolvingIdentity, + "Resolving IED identity from the cached canonical live model…", + 94d, 8, 10)); + + var identityWatch = Stopwatch.StartNew(); + DetectedIdentity = Iec61850DeviceIdentityResolver.Resolve(cachedDiscovery, cachedModel, cachedSignals); + identityWatch.Stop(); + totalWatch.Stop(); + + var cachedLogicalNodes = cachedSignals + .Select(signal => signal.LogicalNode) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + var cachedRawVariables = cachedSnapshot.DomainVariables.Values.Sum(values => values.Count); + + LastDiscoverySummary = + $"SMART-CAPTURE PR134 R2; association authority=reused; wire discovery=skipped; " + + $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + + $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + + $"indexed LN hints={cachedProjectionStats.LogicalNodeHints}, indexed fallback signals={cachedProjectionStats.AddedFallbackSignals}. " + + $"TimingMs directory=0.0, types=0.0, model=0.0, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + + "Deferred: supplemental GetNameList, eager report attributes, DataSet directories, reflection fallback, adaptive sibling/equipment/reference/unit probes."; + LastErrorMessage = LastDiscoverySummary; + return cachedSignals; + } + var smartOptions = new ArMms.MmsSmartDiscoveryOptions { MaxConcurrentChains = 8, @@ -43,9 +117,11 @@ private async Task> DiscoverSignalsSmartForCaptu "Smart MMS discovery: bounded parallel directory scan…", 28d, 4, 10)); + var directoryWatch = Stopwatch.StartNew(); var discovery = await _session .DiscoverSmartAsync(smartOptions, cancellationToken) .ConfigureAwait(false); + directoryWatch.Stop(); _lastDiscovery = discovery; progress?.Report(new IedDiscoveryProgress( @@ -53,15 +129,18 @@ private async Task> DiscoverSignalsSmartForCaptu "Smart MMS type discovery: Logical Node hierarchy probes…", 52d, 5, 10)); + var typeWatch = Stopwatch.StartNew(); var variableTypes = await LiveIedVariableTypeProbeExecutor .ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, cancellationToken) .ConfigureAwait(false); + typeWatch.Stop(); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.BuildingLiveModel, "Building canonical IEC 61850 model from smart discovery evidence…", 68d, 6, 10)); + var modelWatch = Stopwatch.StartNew(); _liveModel = LiveIedModelDiscoveryBuilder.Build( discovery, new LiveIedModelDiscoveryBuildOptions @@ -71,34 +150,38 @@ private async Task> DiscoverSignalsSmartForCaptu IncludeLowConfidenceTemplates = true }, variableTypeAttributes: variableTypes); + modelWatch.Stop(); var snapshot = ToNativeSnapshot(discovery.Snapshot); LastReportInventory = ToNativeInventory(discovery.ReportInventory); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.MappingSignals, - "Mapping smart structural model to the ARSAS signal workspace…", + "Mapping canonical smart evidence with indexed fallbacks…", 82d, 7, 10)); - var signals = BuildSignalsFromArIecModel(_liveModel, snapshot).ToList(); - AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts( - signals, - discovery, + var projectionWatch = Stopwatch.StartNew(); + var signals = BuildSmartCaptureSignalProjection( + _liveModel, snapshot, LastReportInventory, - DateTime.Now); - signals = FinalizeDiscoveredSignals(signals).ToList(); + out var projectionStats); + projectionWatch.Stop(); // Report hints derived from structural NamedVariable/NamedVariableList evidence // remain available. Attribute reads and DataSet-directory reads are deferred. + var reportWatch = Stopwatch.StartNew(); NativeReportDiscoveryMapper.ApplyReportHints(signals, LastReportInventory); + reportWatch.Stop(); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.ResolvingIdentity, "Resolving IED identity from the canonical live model…", 94d, 8, 10)); + var identityWatch = Stopwatch.StartNew(); DetectedIdentity = Iec61850DeviceIdentityResolver.Resolve(discovery, _liveModel, signals); + identityWatch.Stop(); var logicalNodes = signals .Select(signal => signal.LogicalNode) @@ -108,17 +191,34 @@ private async Task> DiscoverSignalsSmartForCaptu var rawVariables = snapshot.DomainVariables.Values.Sum(values => values.Count); var successfulTypeRoots = variableTypes.Count(result => result.IsSuccess); + // Publish only after the complete projection succeeds. If mapping fails, a + // retry is allowed to repeat wire discovery rather than reusing partial state. + PublishSmartDiscoveryAuthority( + discovery, + _liveModel, + variableTypes.Count, + successfulTypeRoots); + + totalWatch.Stop(); LastDiscoverySummary = - $"SMART-CAPTURE PR134; IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + + $"SMART-CAPTURE PR134 R2; association authority=new; " + + $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + $"{discovery.Summary} {_liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + - $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}. " + - "Deferred in this capture build: supplemental GetNameList, eager report attributes, DataSet directories, adaptive sibling probes, primary-equipment proof probes, per-signal operational-reference probes, and engineering-unit reads."; + $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}, " + + $"indexed LN hints={projectionStats.LogicalNodeHints}, indexed fallback signals={projectionStats.AddedFallbackSignals}. " + + $"TimingMs directory={directoryWatch.Elapsed.TotalMilliseconds:F1}, types={typeWatch.Elapsed.TotalMilliseconds:F1}, " + + $"model={modelWatch.Elapsed.TotalMilliseconds:F1}, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + + "Deferred: supplemental GetNameList, eager report attributes, DataSet directories, reflection fallback, adaptive sibling/equipment/reference/unit probes."; LastErrorMessage = LastDiscoverySummary; return signals; } catch (Exception ex) when (ex is not OperationCanceledException) { - LastErrorMessage = $"ARIEC61850 smart capture discovery failed: {ex.GetType().Name}: {ex.Message}. Last discovery: {_session.LastDiscoveryAttemptSummary}. Last request: {_session.LastDiscoveryRequestHex}"; + totalWatch.Stop(); + LastErrorMessage = + $"ARIEC61850 smart capture discovery failed after {totalWatch.Elapsed.TotalMilliseconds:F1} ms: " + + $"{ex.GetType().Name}: {ex.Message}. Last discovery: {_session.LastDiscoveryAttemptSummary}. Last request: {_session.LastDiscoveryRequestHex}"; return Array.Empty(); } } From 688f9306135cba02b770953909cf5a44508fd1eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 21:34:28 +0700 Subject: [PATCH 010/243] fix(discovery): disambiguate cached phase timers --- ...NativeIec61850Client.SmartDiscoveryCapture.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index a4ef7a7f9..e06ff46b3 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -56,7 +56,7 @@ private async Task> DiscoverSignalsSmartForCaptu "Reusing the authoritative smart discovery for this MMS association…", 82d, 7, 10)); - var projectionWatch = Stopwatch.StartNew(); + var cachedProjectionWatch = Stopwatch.StartNew(); var cachedSnapshot = ToNativeSnapshot(cachedDiscovery.Snapshot); LastReportInventory = ToNativeInventory(cachedDiscovery.ReportInventory); var cachedSignals = BuildSmartCaptureSignalProjection( @@ -64,20 +64,20 @@ private async Task> DiscoverSignalsSmartForCaptu cachedSnapshot, LastReportInventory, out var cachedProjectionStats); - projectionWatch.Stop(); + cachedProjectionWatch.Stop(); - var reportWatch = Stopwatch.StartNew(); + var cachedReportWatch = Stopwatch.StartNew(); NativeReportDiscoveryMapper.ApplyReportHints(cachedSignals, LastReportInventory); - reportWatch.Stop(); + cachedReportWatch.Stop(); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.ResolvingIdentity, "Resolving IED identity from the cached canonical live model…", 94d, 8, 10)); - var identityWatch = Stopwatch.StartNew(); + var cachedIdentityWatch = Stopwatch.StartNew(); DetectedIdentity = Iec61850DeviceIdentityResolver.Resolve(cachedDiscovery, cachedModel, cachedSignals); - identityWatch.Stop(); + cachedIdentityWatch.Stop(); totalWatch.Stop(); var cachedLogicalNodes = cachedSignals @@ -93,8 +93,8 @@ private async Task> DiscoverSignalsSmartForCaptu $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + $"indexed LN hints={cachedProjectionStats.LogicalNodeHints}, indexed fallback signals={cachedProjectionStats.AddedFallbackSignals}. " + - $"TimingMs directory=0.0, types=0.0, model=0.0, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + - $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + + $"TimingMs directory=0.0, types=0.0, model=0.0, projection={cachedProjectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"reportHints={cachedReportWatch.Elapsed.TotalMilliseconds:F1}, identity={cachedIdentityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + "Deferred: supplemental GetNameList, eager report attributes, DataSet directories, reflection fallback, adaptive sibling/equipment/reference/unit probes."; LastErrorMessage = LastDiscoverySummary; return cachedSignals; From 42d640136abb9b5c30b4a3cdb54085ccba318089 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 21:42:48 +0700 Subject: [PATCH 011/243] ci(discovery): harden R2 provenance and critical-path invariants --- .../smart-discovery-capture-build.yml | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 771d545e5..7a86b93c5 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -25,23 +25,39 @@ jobs: if ($lock.commit -ne '040718027b92681b89f2e04ce048a53fe225a1c7') { throw "Unexpected engine commit: $($lock.commit)" } + $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() + if ($arsasCommit -notmatch '^[0-9a-f]{40}$') { + throw "Invalid cloned ARSAS commit: $arsasCommit" + } "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append $version = (Get-Content .\ArIED61850Tester\VERSION -Raw).Trim() "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "ARSAS field source: $arsasCommit" - name: Verify smart capture sources shell: powershell run: | $helper = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $optimization = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryOptimization.cs -Raw $patcher = Get-Content .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -Raw $targets = Get-Content .\ArIED61850Tester\Directory.Build.targets -Raw if ($helper -notmatch 'DiscoverSmartAsync' -or $helper -notmatch 'LiveIedVariableTypeProbeExecutor' -or $helper -notmatch 'variableTypeAttributes' -or + $helper -notmatch 'SMART-CAPTURE PR134 R2' -or + $optimization -notmatch 'TryGetSmartDiscoveryAuthority' -or + $optimization -notmatch 'BuildSmartCaptureSignalProjection' -or + $optimization -notmatch 'AddSmartIndexedLogicalNodeFallbacks' -or + $helper -notmatch '_smartDiscoveryCaptureGate' -or $patcher -notmatch 'DiscoverSignalsSmartForCaptureAsync' -or $targets -notmatch 'EnableSmartDiscoveryCaptureRoute') { - throw 'Smart discovery capture routing is incomplete.' + throw 'Smart discovery R2 capture routing or optimization authority is incomplete.' + } + if ($helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or + $helper -match 'FinalizeDiscoveredSignals') { + throw 'Smart discovery R2 critical path regressed to reflection fallback or second full finalization.' } - name: Checkout immutable ARIEC61850 PR 134 engine @@ -105,10 +121,11 @@ jobs: if ($process.ExitCode -ne 0) { throw "Portable EXE smoke test failed: $($process.ExitCode)" } @( "ARSAS smart discovery field-capture build", - "ARSAS commit: $env:GITHUB_SHA", + "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine PR: 134", - "Mode: bounded smart structural discovery + hierarchy-first smart type probes", + "Mode: PR134 R2 bounded smart discovery + hierarchy-first types + indexed CPU projection + association reuse", + "CI invariant: no reflection fallback or second full signal finalization in the smart critical path", "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 @@ -129,4 +146,4 @@ jobs: name: ARSAS-smart-discovery-pr134-test-evidence path: ArIED61850Tester\TestResults\*.trx if-no-files-found: warn - retention-days: 14 + retention-days: 14 \ No newline at end of file From 38fa85068b02ceeb855b4650a9eb34e8aea143b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 22:18:45 +0700 Subject: [PATCH 012/243] perf(discovery): harden association-scoped authority lifecycle --- ...eIec61850Client.SmartDiscoveryLifecycle.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs diff --git a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs new file mode 100644 index 000000000..38f5307ce --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs @@ -0,0 +1,34 @@ +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private string _smartDiscoveryAuthorityHost = string.Empty; + private int _smartDiscoveryAuthorityPort; + + /// + /// Explicitly invalidates every association-scoped smart-discovery authority. + /// This is called before a new ConnectAsync lifecycle begins so stale model/type + /// evidence can never be reused across reconnects, even if later refactors change + /// how _lastDiscovery/_liveModel are reset. + /// + private void ResetSmartDiscoveryAuthority() + { + _smartDiscoveryAuthority = null; + _smartDiscoveryModelAuthority = null; + _smartDiscoveryTypeProbeCount = 0; + _smartDiscoverySuccessfulTypeProbeCount = 0; + _smartDiscoveryAuthorityHost = string.Empty; + _smartDiscoveryAuthorityPort = 0; + } + + private bool IsSmartDiscoveryAuthorityBoundToCurrentAssociation() + => _session.IsMmsInitiated && + string.Equals(_smartDiscoveryAuthorityHost, _host, StringComparison.OrdinalIgnoreCase) && + _smartDiscoveryAuthorityPort == _port; + + private void BindSmartDiscoveryAuthorityToCurrentAssociation() + { + _smartDiscoveryAuthorityHost = _host; + _smartDiscoveryAuthorityPort = _port; + } +} From 33526f622deac8e4509269eb6c9dcd8ecda01a27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 22:19:15 +0700 Subject: [PATCH 013/243] perf(discovery): bind smart authority to active MMS association --- Services/NativeIec61850Client.SmartDiscoveryOptimization.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs index bce350697..7248490a8 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs @@ -22,7 +22,8 @@ private bool TryGetSmartDiscoveryAuthority( discovery = null!; model = null!; - if (_smartDiscoveryAuthority is null || + if (!IsSmartDiscoveryAuthorityBoundToCurrentAssociation() || + _smartDiscoveryAuthority is null || _smartDiscoveryModelAuthority is null || !ReferenceEquals(_lastDiscovery, _smartDiscoveryAuthority) || !ReferenceEquals(_liveModel, _smartDiscoveryModelAuthority)) @@ -45,6 +46,7 @@ private void PublishSmartDiscoveryAuthority( _smartDiscoveryModelAuthority = model; _smartDiscoveryTypeProbeCount = typeProbeCount; _smartDiscoverySuccessfulTypeProbeCount = successfulTypeProbeCount; + BindSmartDiscoveryAuthorityToCurrentAssociation(); } private readonly record struct SmartProjectionStats( From fddcd10a660732e6f15042d197c2f29e26e28d54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 22:19:33 +0700 Subject: [PATCH 014/243] build(discovery): install explicit smart authority reset on reconnect --- scripts/enable-smart-discovery-capture.ps1 | 68 ++++++++++++++++------ 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/scripts/enable-smart-discovery-capture.ps1 b/scripts/enable-smart-discovery-capture.ps1 index f5ff45208..2b242deb7 100644 --- a/scripts/enable-smart-discovery-capture.ps1 +++ b/scripts/enable-smart-discovery-capture.ps1 @@ -3,28 +3,60 @@ $ErrorActionPreference = 'Stop' $sourcePath = Join-Path $PSScriptRoot '..\Services\NativeIec61850Client.cs' $sourcePath = [System.IO.Path]::GetFullPath($sourcePath) $text = [System.IO.File]::ReadAllText($sourcePath) -$marker = 'DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress)' +$changed = $false -if ($text.IndexOf($marker, [System.StringComparison]::Ordinal) -ge 0) { - Write-Host 'Smart discovery capture route already installed.' - exit 0 -} +$routeMarker = 'DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress)' +if ($text.IndexOf($routeMarker, [System.StringComparison]::Ordinal) -lt 0) { + $pattern = '(public async Task> DiscoverSignalsAsync\(CancellationToken cancellationToken, IProgress\? progress = null\)\s*\{)' + $match = [regex]::Match($text, $pattern) + if (-not $match.Success) { + throw 'Could not locate NativeIec61850Client.DiscoverSignalsAsync entrypoint.' + } + if ([regex]::Matches($text, $pattern).Count -ne 1) { + throw 'DiscoverSignalsAsync entrypoint is not unique; refusing ambiguous build-time patch.' + } -$pattern = '(public async Task> DiscoverSignalsAsync\(CancellationToken cancellationToken, IProgress\? progress = null\)\s*\{)' -$match = [regex]::Match($text, $pattern) -if (-not $match.Success) { - throw 'Could not locate NativeIec61850Client.DiscoverSignalsAsync entrypoint.' -} -if ([regex]::Matches($text, $pattern).Count -ne 1) { - throw 'DiscoverSignalsAsync entrypoint is not unique; refusing ambiguous build-time patch.' -} - -$injection = @' + $injection = @' if (SmartDiscoveryCaptureModeEnabled) return await DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress).ConfigureAwait(false); '@ -$patched = $text.Insert($match.Index + $match.Length, $injection) -[System.IO.File]::WriteAllText($sourcePath, $patched, (New-Object System.Text.UTF8Encoding($false))) -Write-Host 'Installed PR #134 smart discovery capture route into NativeIec61850Client.DiscoverSignalsAsync.' + $text = $text.Insert($match.Index + $match.Length, $injection) + $changed = $true + Write-Host 'Installed PR #134 smart discovery capture route into NativeIec61850Client.DiscoverSignalsAsync.' +} +else { + Write-Host 'Smart discovery capture route already installed.' +} + +$resetMarker = 'ResetSmartDiscoveryAuthority();' +if ($text.IndexOf($resetMarker, [System.StringComparison]::Ordinal) -lt 0) { + $resetAnchor = " _lastDiscovery = null;`r`n _liveModel = null;" + $anchorIndex = $text.IndexOf($resetAnchor, [System.StringComparison]::Ordinal) + if ($anchorIndex -lt 0) { + $resetAnchor = " _lastDiscovery = null;`n _liveModel = null;" + $anchorIndex = $text.IndexOf($resetAnchor, [System.StringComparison]::Ordinal) + } + if ($anchorIndex -lt 0) { + throw 'Could not locate ConnectAsync discovery reset anchor for smart authority invalidation.' + } + if ($text.IndexOf($resetAnchor, $anchorIndex + $resetAnchor.Length, [System.StringComparison]::Ordinal) -ge 0) { + throw 'ConnectAsync discovery reset anchor is not unique; refusing ambiguous smart authority patch.' + } + + $resetInjection = $resetAnchor + "`r`n ResetSmartDiscoveryAuthority();" + if ($resetAnchor.Contains("`n") -and -not $resetAnchor.Contains("`r`n")) { + $resetInjection = $resetAnchor + "`n ResetSmartDiscoveryAuthority();" + } + $text = $text.Remove($anchorIndex, $resetAnchor.Length).Insert($anchorIndex, $resetInjection) + $changed = $true + Write-Host 'Installed explicit smart discovery authority reset into ConnectAsync.' +} +else { + Write-Host 'Smart discovery authority reset already installed.' +} + +if ($changed) { + [System.IO.File]::WriteAllText($sourcePath, $text, (New-Object System.Text.UTF8Encoding($false))) +} From ca388a5be80155630e8c59ec8bbbd4cdf58e19a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 22:20:15 +0700 Subject: [PATCH 015/243] ci(discovery): enforce authority lifecycle invalidation in R2 build --- .../smart-discovery-capture-build.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 7a86b93c5..98d721946 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -41,6 +41,7 @@ jobs: run: | $helper = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw $optimization = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryOptimization.cs -Raw + $lifecycle = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw $patcher = Get-Content .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -Raw $targets = Get-Content .\ArIED61850Tester\Directory.Build.targets -Raw if ($helper -notmatch 'DiscoverSmartAsync' -or @@ -50,10 +51,15 @@ jobs: $optimization -notmatch 'TryGetSmartDiscoveryAuthority' -or $optimization -notmatch 'BuildSmartCaptureSignalProjection' -or $optimization -notmatch 'AddSmartIndexedLogicalNodeFallbacks' -or + $optimization -notmatch 'IsSmartDiscoveryAuthorityBoundToCurrentAssociation' -or + $lifecycle -notmatch 'ResetSmartDiscoveryAuthority' -or + $lifecycle -notmatch '_smartDiscoveryAuthorityHost' -or + $lifecycle -notmatch '_smartDiscoveryAuthorityPort' -or $helper -notmatch '_smartDiscoveryCaptureGate' -or $patcher -notmatch 'DiscoverSignalsSmartForCaptureAsync' -or + $patcher -notmatch 'ResetSmartDiscoveryAuthority' -or $targets -notmatch 'EnableSmartDiscoveryCaptureRoute') { - throw 'Smart discovery R2 capture routing or optimization authority is incomplete.' + throw 'Smart discovery R2 capture routing, optimization authority, or association lifecycle invalidation is incomplete.' } if ($helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or $helper -match 'FinalizeDiscoveredSignals') { @@ -85,13 +91,16 @@ jobs: - name: Build Release run: dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore - - name: Verify smart route was installed + - name: Verify smart route and lifecycle reset were installed shell: powershell run: | $native = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.cs -Raw if ($native -notmatch 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { throw 'Build-time smart discovery route was not installed.' } + if ($native -notmatch '_lastDiscovery = null;\s*_liveModel = null;\s*ResetSmartDiscoveryAuthority\(\);') { + throw 'Build-time smart discovery authority reset was not installed into ConnectAsync.' + } - name: Run ARSAS regression tests run: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-smart-capture-tests.trx" --results-directory .\ArIED61850Tester\TestResults @@ -124,8 +133,8 @@ jobs: "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine PR: 134", - "Mode: PR134 R2 bounded smart discovery + hierarchy-first types + indexed CPU projection + association reuse", - "CI invariant: no reflection fallback or second full signal finalization in the smart critical path", + "Mode: PR134 R2 bounded smart discovery + hierarchy-first types + indexed CPU projection + association-bound reuse", + "CI invariant: no reflection fallback, no second full signal finalization, explicit reconnect invalidation in smart critical path", "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 @@ -146,4 +155,4 @@ jobs: name: ARSAS-smart-discovery-pr134-test-evidence path: ArIED61850Tester\TestResults\*.trx if-no-files-found: warn - retention-days: 14 \ No newline at end of file + retention-days: 14 From 445d3c9cd59141cfb44599a0e0c775f16ba3c427 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:44:53 +0700 Subject: [PATCH 016/243] perf(discovery): serialize R3 smart capture on MMS operation gate --- ...iveIec61850Client.SmartDiscoveryCapture.cs | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index e06ff46b3..2d9d4fc47 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -18,11 +18,20 @@ private async Task> DiscoverSignalsSmartForCaptu { // Protect one physical MMS association from accidental concurrent discovery // (double-click, overlapping runtime requests, or future background consumers). - // Waiting callers reuse the completed association-scoped authority below. + // The MMS operation gate additionally prevents report/read workflows from + // entering a legacy discovery path before the smart authority is published. await _smartDiscoveryCaptureGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - return await DiscoverSignalsSmartForCaptureCoreAsync(cancellationToken, progress).ConfigureAwait(false); + await _mmsIoGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await DiscoverSignalsSmartForCaptureCoreAsync(cancellationToken, progress).ConfigureAwait(false); + } + finally + { + _mmsIoGate.Release(); + } } finally { @@ -47,8 +56,8 @@ private async Task> DiscoverSignalsSmartForCaptu try { // A second discovery request on the same association must be wire-free. The - // authority marker is reference-bound to _lastDiscovery/_liveModel, both of - // which are reset by the normal connection lifecycle before a new IED/session. + // authority marker is reference-bound to _lastDiscovery/_liveModel and also + // explicitly bound to the current host/port association lifecycle. if (TryGetSmartDiscoveryAuthority(out var cachedDiscovery, out var cachedModel)) { progress?.Report(new IedDiscoveryProgress( @@ -88,7 +97,7 @@ private async Task> DiscoverSignalsSmartForCaptu var cachedRawVariables = cachedSnapshot.DomainVariables.Values.Sum(values => values.Count); LastDiscoverySummary = - $"SMART-CAPTURE PR134 R2; association authority=reused; wire discovery=skipped; " + + $"SMART-CAPTURE PR134 R3; association authority=reused; engine single-flight=reused; wire discovery=skipped; " + $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + @@ -114,12 +123,12 @@ private async Task> DiscoverSignalsSmartForCaptu progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.DiscoveringDirectory, - "Smart MMS discovery: bounded parallel directory scan…", + "Smart MMS discovery: association single-flight bounded directory scan…", 28d, 4, 10)); var directoryWatch = Stopwatch.StartNew(); var discovery = await _session - .DiscoverSmartAsync(smartOptions, cancellationToken) + .DiscoverSmartSingleFlightAsync(smartOptions, cancellationToken) .ConfigureAwait(false); directoryWatch.Stop(); _lastDiscovery = discovery; @@ -201,7 +210,7 @@ private async Task> DiscoverSignalsSmartForCaptu totalWatch.Stop(); LastDiscoverySummary = - $"SMART-CAPTURE PR134 R2; association authority=new; " + + $"SMART-CAPTURE PR134 R3; association authority=new; engine single-flight=new; app MMS gate=exclusive; " + $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + $"{discovery.Summary} {_liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}, " + From 739981150a92756fe1dfd5c3d0e9046c6c66c1d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:45:31 +0700 Subject: [PATCH 017/243] test(discovery): pin PR134 R3 single-flight engine --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 6d0f8b848..9de9322e0 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "040718027b92681b89f2e04ce048a53fe225a1c7", + "commit": "1b7cbbe8af3dfbc2a15086cb2c3e5eff02e006bb", "sourcePullRequest": 134, - "purpose": "Test-only capture build pin for ARIEC61850 PR #134. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, and smart FC-root read support. ARIEC61850 CI run #626 passed source verification, Release build with zero warnings/errors, all 882 tests, and artifact packaging. This ARSAS branch intentionally defers historical supplemental discovery passes so field capture can measure the new bounded structural path directly. The reviewed production ancestry below remains preserved unchanged for regression authority.", + "purpose": "Test-only capture build pin for ARIEC61850 PR #134 R3. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, smart FC-root reads, and association-scoped smart discovery single-flight. ARSAS additionally serializes the field-capture critical path on its MMS operation gate so report/read workflows cannot enter legacy discovery before the authoritative smart model is published. The reviewed production ancestry below remains preserved unchanged for regression authority.", "previousTrialPin": { "commit": "0023ef9a4373855497464ed3979e359c4041c95d", "sourcePullRequest": 132, From 1b375f5e42dd8e2304c0b1d6df5d627bca7ba08d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:46:01 +0700 Subject: [PATCH 018/243] ci(discovery): validate R3 single-flight field build --- .../smart-discovery-capture-build.yml | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 98d721946..020ba6b5e 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -22,7 +22,7 @@ jobs: if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { throw 'Invalid ARIEC61850 field-capture pin.' } - if ($lock.commit -ne '040718027b92681b89f2e04ce048a53fe225a1c7') { + if ($lock.commit -ne '1b7cbbe8af3dfbc2a15086cb2c3e5eff02e006bb') { throw "Unexpected engine commit: $($lock.commit)" } $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() @@ -44,10 +44,11 @@ jobs: $lifecycle = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw $patcher = Get-Content .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -Raw $targets = Get-Content .\ArIED61850Tester\Directory.Build.targets -Raw - if ($helper -notmatch 'DiscoverSmartAsync' -or + if ($helper -notmatch 'DiscoverSmartSingleFlightAsync' -or $helper -notmatch 'LiveIedVariableTypeProbeExecutor' -or $helper -notmatch 'variableTypeAttributes' -or - $helper -notmatch 'SMART-CAPTURE PR134 R2' -or + $helper -notmatch 'SMART-CAPTURE PR134 R3' -or + $helper -notmatch '_mmsIoGate.WaitAsync' -or $optimization -notmatch 'TryGetSmartDiscoveryAuthority' -or $optimization -notmatch 'BuildSmartCaptureSignalProjection' -or $optimization -notmatch 'AddSmartIndexedLogicalNodeFallbacks' -or @@ -59,11 +60,11 @@ jobs: $patcher -notmatch 'DiscoverSignalsSmartForCaptureAsync' -or $patcher -notmatch 'ResetSmartDiscoveryAuthority' -or $targets -notmatch 'EnableSmartDiscoveryCaptureRoute') { - throw 'Smart discovery R2 capture routing, optimization authority, or association lifecycle invalidation is incomplete.' + throw 'Smart discovery R3 capture routing, single-flight, optimization authority, or association lifecycle invalidation is incomplete.' } if ($helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or $helper -match 'FinalizeDiscoveredSignals') { - throw 'Smart discovery R2 critical path regressed to reflection fallback or second full finalization.' + throw 'Smart discovery R3 critical path regressed to reflection fallback or second full finalization.' } - name: Checkout immutable ARIEC61850 PR 134 engine @@ -75,9 +76,14 @@ jobs: $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() if ($actual -ne $env:ARIEC61850_COMMIT) { throw "Engine SHA mismatch: $actual" } $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw + $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw $hierarchy = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedVariableTypeHierarchy.cs -Raw - if ($smart -notmatch 'DiscoverSmartAsync' -or $hierarchy -notmatch 'ProbeSmartAsync') { - throw 'Pinned engine does not expose the required smart discovery APIs.' + if ($smart -notmatch 'DiscoverSmartAsync' -or + $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $singleFlight -notmatch 'WaitAsync\(cancellationToken\)' -or + $singleFlight -notmatch 'incompleteChains=0' -or + $hierarchy -notmatch 'ProbeSmartAsync') { + throw 'Pinned engine does not expose the required R3 smart discovery single-flight APIs/invariants.' } - name: Setup .NET 8 @@ -133,8 +139,8 @@ jobs: "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine PR: 134", - "Mode: PR134 R2 bounded smart discovery + hierarchy-first types + indexed CPU projection + association-bound reuse", - "CI invariant: no reflection fallback, no second full signal finalization, explicit reconnect invalidation in smart critical path", + "Mode: PR134 R3 association single-flight + hierarchy-first types + indexed CPU projection + association-bound reuse", + "CI invariant: app MMS gate excludes legacy workflows during smart publication; waiter cancellation does not cancel shared engine discovery; partial discovery is not cached", "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 From c82cdbd02baaa5888de4ab04fc9f80a180297f46 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 23:48:19 +0700 Subject: [PATCH 019/243] fix(discovery): keep MMS gate held through shared directory flight --- Services/NativeIec61850Client.SmartDiscoveryCapture.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index 2d9d4fc47..8b83cb3c5 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -127,12 +127,17 @@ private async Task> DiscoverSignalsSmartForCaptu 28d, 4, 10)); var directoryWatch = Stopwatch.StartNew(); + // Once this caller owns the application MMS gate, keep that gate until the + // shared directory flight itself completes. A UI/waiter cancellation must + // not release the gate while the engine continues the association-scoped + // discovery in the background. var discovery = await _session - .DiscoverSmartSingleFlightAsync(smartOptions, cancellationToken) + .DiscoverSmartSingleFlightAsync(smartOptions, CancellationToken.None) .ConfigureAwait(false); directoryWatch.Stop(); _lastDiscovery = discovery; + cancellationToken.ThrowIfCancellationRequested(); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.ProbingLogicalNodes, "Smart MMS type discovery: Logical Node hierarchy probes…", From 54cd4c34409f7ffbb815ab86bc341d37dcb4ecb1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:30:16 +0700 Subject: [PATCH 020/243] test(discovery): mark R4 authoritative control inventory --- Services/NativeIec61850Client.SmartDiscoveryCapture.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index 8b83cb3c5..b69532405 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -97,7 +97,7 @@ private async Task> DiscoverSignalsSmartForCaptu var cachedRawVariables = cachedSnapshot.DomainVariables.Values.Sum(values => values.Count); LastDiscoverySummary = - $"SMART-CAPTURE PR134 R3; association authority=reused; engine single-flight=reused; wire discovery=skipped; " + + $"SMART-CAPTURE PR134 R4; association authority=reused; engine single-flight=reused; control inventory=authoritative; wire discovery=skipped; " + $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + @@ -215,7 +215,7 @@ private async Task> DiscoverSignalsSmartForCaptu totalWatch.Stop(); LastDiscoverySummary = - $"SMART-CAPTURE PR134 R3; association authority=new; engine single-flight=new; app MMS gate=exclusive; " + + $"SMART-CAPTURE PR134 R4; association authority=new; engine single-flight=new; app MMS gate=exclusive; control inventory=authoritative; " + $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + $"{discovery.Summary} {_liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}, " + From 439da9551d542b784ed400fbecf8f4fe6b6e4c7d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:30:36 +0700 Subject: [PATCH 021/243] test(discovery): pin R4 authoritative control inventory engine --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 9de9322e0..bc6650b4d 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "1b7cbbe8af3dfbc2a15086cb2c3e5eff02e006bb", + "commit": "7a86b5903df3ffaa694e2416f191c155fbf3cbd4", "sourcePullRequest": 134, - "purpose": "Test-only capture build pin for ARIEC61850 PR #134 R3. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, smart FC-root reads, and association-scoped smart discovery single-flight. ARSAS additionally serializes the field-capture critical path on its MMS operation gate so report/read workflows cannot enter legacy discovery before the authoritative smart model is published. The reviewed production ancestry below remains preserved unchanged for regression authority.", + "purpose": "Test-only capture build pin for ARIEC61850 PR #134 R4. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, smart FC-root reads, association-scoped smart discovery single-flight, and authoritative domain-variable inventory reuse by the Control service. ARSAS additionally serializes the field-capture critical path on its MMS operation gate so report/read workflows cannot enter legacy discovery before the authoritative smart model is published. The reviewed production ancestry below remains preserved unchanged for regression authority.", "previousTrialPin": { "commit": "0023ef9a4373855497464ed3979e359c4041c95d", "sourcePullRequest": 132, From b75d0facef195aa470024274da008271885f73cd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:31:01 +0700 Subject: [PATCH 022/243] ci(discovery): verify R4 control inventory reuse --- .../smart-discovery-capture-build.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 020ba6b5e..9c994fc24 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -22,7 +22,7 @@ jobs: if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { throw 'Invalid ARIEC61850 field-capture pin.' } - if ($lock.commit -ne '1b7cbbe8af3dfbc2a15086cb2c3e5eff02e006bb') { + if ($lock.commit -ne '7a86b5903df3ffaa694e2416f191c155fbf3cbd4') { throw "Unexpected engine commit: $($lock.commit)" } $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() @@ -47,7 +47,8 @@ jobs: if ($helper -notmatch 'DiscoverSmartSingleFlightAsync' -or $helper -notmatch 'LiveIedVariableTypeProbeExecutor' -or $helper -notmatch 'variableTypeAttributes' -or - $helper -notmatch 'SMART-CAPTURE PR134 R3' -or + $helper -notmatch 'SMART-CAPTURE PR134 R4' -or + $helper -notmatch 'control inventory=authoritative' -or $helper -notmatch '_mmsIoGate.WaitAsync' -or $optimization -notmatch 'TryGetSmartDiscoveryAuthority' -or $optimization -notmatch 'BuildSmartCaptureSignalProjection' -or @@ -60,11 +61,11 @@ jobs: $patcher -notmatch 'DiscoverSignalsSmartForCaptureAsync' -or $patcher -notmatch 'ResetSmartDiscoveryAuthority' -or $targets -notmatch 'EnableSmartDiscoveryCaptureRoute') { - throw 'Smart discovery R3 capture routing, single-flight, optimization authority, or association lifecycle invalidation is incomplete.' + throw 'Smart discovery R4 capture routing, authoritative inventory, single-flight, optimization authority, or association lifecycle invalidation is incomplete.' } if ($helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or $helper -match 'FinalizeDiscoveredSignals') { - throw 'Smart discovery R3 critical path regressed to reflection fallback or second full finalization.' + throw 'Smart discovery R4 critical path regressed to reflection fallback or second full finalization.' } - name: Checkout immutable ARIEC61850 PR 134 engine @@ -77,13 +78,17 @@ jobs: if ($actual -ne $env:ARIEC61850_COMMIT) { throw "Engine SHA mismatch: $actual" } $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw + $controlTransport = Get-Content .\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlTransport.cs -Raw $hierarchy = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedVariableTypeHierarchy.cs -Raw if ($smart -notmatch 'DiscoverSmartAsync' -or $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $singleFlight -notmatch 'GetAuthoritativeDomainVariableNamesAsync' -or $singleFlight -notmatch 'WaitAsync\(cancellationToken\)' -or $singleFlight -notmatch 'incompleteChains=0' -or + $controlTransport -notmatch 'GetAuthoritativeDomainVariableNamesAsync' -or + $controlTransport -match '=> _session\.DiscoverDomainVariableNamesAsync\(cancellationToken\)' -or $hierarchy -notmatch 'ProbeSmartAsync') { - throw 'Pinned engine does not expose the required R3 smart discovery single-flight APIs/invariants.' + throw 'Pinned engine does not expose the required R4 smart discovery/control inventory invariants.' } - name: Setup .NET 8 @@ -139,8 +144,8 @@ jobs: "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine PR: 134", - "Mode: PR134 R3 association single-flight + hierarchy-first types + indexed CPU projection + association-bound reuse", - "CI invariant: app MMS gate excludes legacy workflows during smart publication; waiter cancellation does not cancel shared engine discovery; partial discovery is not cached", + "Mode: PR134 R4 association single-flight + authoritative Control inventory reuse + hierarchy-first types + indexed CPU projection", + "CI invariant: Control cannot repeat legacy domain-variable discovery when a complete smart inventory exists; app MMS gate excludes legacy workflows during smart publication", "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 From b1e5c93bb640e7d69e0f89d10643c6d01c62211a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:48:52 +0700 Subject: [PATCH 023/243] perf(discovery): route control to smart inventory authority --- scripts/enable-smart-discovery-capture.ps1 | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/enable-smart-discovery-capture.ps1 b/scripts/enable-smart-discovery-capture.ps1 index 2b242deb7..ead45ed44 100644 --- a/scripts/enable-smart-discovery-capture.ps1 +++ b/scripts/enable-smart-discovery-capture.ps1 @@ -57,6 +57,27 @@ else { Write-Host 'Smart discovery authority reset already installed.' } +$controlAuthorityMarker = '_lastDiscovery.Snapshot.DomainVariables' +if ($text.IndexOf($controlAuthorityMarker, [System.StringComparison]::Ordinal) -lt 0) { + $controlPattern = '\(\) => service\.OpenAsync\(_session, signal\.ObjectReference, cancellationToken\)' + $controlMatches = [regex]::Matches($text, $controlPattern) + if ($controlMatches.Count -ne 1) { + throw "Expected exactly one control OpenAsync discovery call, found $($controlMatches.Count); refusing ambiguous authority patch." + } + + $controlReplacement = @' +() => _lastDiscovery != null + ? service.OpenAsync(_session, signal.ObjectReference, _lastDiscovery.Snapshot.DomainVariables, cancellationToken) + : service.OpenAsync(_session, signal.ObjectReference, cancellationToken) +'@ + $text = [regex]::Replace($text, $controlPattern, $controlReplacement, 1) + $changed = $true + Write-Host 'Installed authoritative smart domain inventory reuse into control inspection.' +} +else { + Write-Host 'Control inspection already reuses authoritative smart domain inventory.' +} + if ($changed) { [System.IO.File]::WriteAllText($sourcePath, $text, (New-Object System.Text.UTF8Encoding($false))) } From aa694ef510a750f421fef4f04c06868d626ce034 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:50:18 +0700 Subject: [PATCH 024/243] ci(discovery): verify explicit control inventory reuse --- .../workflows/smart-discovery-capture-build.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 9c994fc24..24d273496 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -22,7 +22,7 @@ jobs: if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { throw 'Invalid ARIEC61850 field-capture pin.' } - if ($lock.commit -ne '7a86b5903df3ffaa694e2416f191c155fbf3cbd4') { + if ($lock.commit -ne '57c8311c0dcf9e51da4d7dc31c757fc3f0912586') { throw "Unexpected engine commit: $($lock.commit)" } $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() @@ -60,8 +60,9 @@ jobs: $helper -notmatch '_smartDiscoveryCaptureGate' -or $patcher -notmatch 'DiscoverSignalsSmartForCaptureAsync' -or $patcher -notmatch 'ResetSmartDiscoveryAuthority' -or + $patcher -notmatch '_lastDiscovery\.Snapshot\.DomainVariables' -or $targets -notmatch 'EnableSmartDiscoveryCaptureRoute') { - throw 'Smart discovery R4 capture routing, authoritative inventory, single-flight, optimization authority, or association lifecycle invalidation is incomplete.' + throw 'Smart discovery R4 capture routing, explicit Control inventory reuse, single-flight, optimization authority, or association lifecycle invalidation is incomplete.' } if ($helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or $helper -match 'FinalizeDiscoveredSignals') { @@ -79,6 +80,8 @@ jobs: $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw $controlTransport = Get-Content .\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlTransport.cs -Raw + $controlService = Get-Content .\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlService.cs -Raw + $controlTest = Get-Content .\ARIEC61850\tests\AR.Iec61850.Tests\Control\AuthoritativeControlInventoryTests.cs -Raw $hierarchy = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedVariableTypeHierarchy.cs -Raw if ($smart -notmatch 'DiscoverSmartAsync' -or $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or @@ -87,6 +90,9 @@ jobs: $singleFlight -notmatch 'incompleteChains=0' -or $controlTransport -notmatch 'GetAuthoritativeDomainVariableNamesAsync' -or $controlTransport -match '=> _session\.DiscoverDomainVariableNamesAsync\(cancellationToken\)' -or + $controlService -notmatch 'authoritativeDomainVariables' -or + $controlService -notmatch 'domainInventory=authoritative-reuse' -or + $controlTest -notmatch 'DoesNotBrowseDomainVariablesAgain' -or $hierarchy -notmatch 'ProbeSmartAsync') { throw 'Pinned engine does not expose the required R4 smart discovery/control inventory invariants.' } @@ -112,6 +118,9 @@ jobs: if ($native -notmatch '_lastDiscovery = null;\s*_liveModel = null;\s*ResetSmartDiscoveryAuthority\(\);') { throw 'Build-time smart discovery authority reset was not installed into ConnectAsync.' } + if ($native -notmatch 'service\.OpenAsync\(_session, signal\.ObjectReference, _lastDiscovery\.Snapshot\.DomainVariables, cancellationToken\)') { + throw 'Build-time Control path does not consume the authoritative smart domain inventory.' + } - name: Run ARSAS regression tests run: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-smart-capture-tests.trx" --results-directory .\ArIED61850Tester\TestResults @@ -144,7 +153,7 @@ jobs: "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine PR: 134", - "Mode: PR134 R4 association single-flight + authoritative Control inventory reuse + hierarchy-first types + indexed CPU projection", + "Mode: PR134 R4 association single-flight + explicit authoritative Control inventory injection + hierarchy-first types + indexed CPU projection", "CI invariant: Control cannot repeat legacy domain-variable discovery when a complete smart inventory exists; app MMS gate excludes legacy workflows during smart publication", "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 From 58b18301f3c9b7e60f454651ad6c23b157ee90b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 07:50:36 +0700 Subject: [PATCH 025/243] test(discovery): pin explicit control inventory reuse engine --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index bc6650b4d..5ad9c8067 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "7a86b5903df3ffaa694e2416f191c155fbf3cbd4", + "commit": "57c8311c0dcf9e51da4d7dc31c757fc3f0912586", "sourcePullRequest": 134, - "purpose": "Test-only capture build pin for ARIEC61850 PR #134 R4. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, smart FC-root reads, association-scoped smart discovery single-flight, and authoritative domain-variable inventory reuse by the Control service. ARSAS additionally serializes the field-capture critical path on its MMS operation gate so report/read workflows cannot enter legacy discovery before the authoritative smart model is published. The reviewed production ancestry below remains preserved unchanged for regression authority.", + "purpose": "Test-only capture build pin for ARIEC61850 PR #134 R4. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, smart FC-root reads, association-scoped smart discovery single-flight, authoritative domain-variable inventory reuse by the Control transport, and explicit authoritative inventory injection into Control object inspection. ARSAS additionally serializes the field-capture critical path on its MMS operation gate so report/read workflows cannot enter legacy discovery before the authoritative smart model is published. The reviewed production ancestry below remains preserved unchanged for regression authority.", "previousTrialPin": { "commit": "0023ef9a4373855497464ed3979e359c4041c95d", "sourcePullRequest": 132, From 5c22f118aa6ccd0f62a17b19d2b9bee882f6372b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:38:08 +0700 Subject: [PATCH 026/243] P0-5c bind smart discovery flight to association generation --- ...eIec61850Client.SmartDiscoveryLifecycle.cs | 154 ++++++++++++++++-- 1 file changed, 144 insertions(+), 10 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs index 38f5307ce..3c0c262f1 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs @@ -1,24 +1,158 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + namespace ArIED61850Tester.Services; public sealed partial class NativeIec61850Client { + private readonly object _smartDiscoveryFlightSync = new(); + private Task>? _smartDiscoveryAssociationFlight; + private long _smartDiscoveryAssociationGeneration; + private long _smartDiscoveryFlightGeneration = -1; private string _smartDiscoveryAuthorityHost = string.Empty; private int _smartDiscoveryAuthorityPort; /// - /// Explicitly invalidates every association-scoped smart-discovery authority. - /// This is called before a new ConnectAsync lifecycle begins so stale model/type - /// evidence can never be reused across reconnects, even if later refactors change - /// how _lastDiscovery/_liveModel are reset. + /// Explicitly invalidates every association-scoped smart-discovery authority and + /// advances the generation token. An already-running owner is intentionally not + /// force-cancelled mid-PDU; it observes the generation change at the next safe + /// boundary and is forbidden from publishing into the replacement association. /// private void ResetSmartDiscoveryAuthority() { - _smartDiscoveryAuthority = null; - _smartDiscoveryModelAuthority = null; - _smartDiscoveryTypeProbeCount = 0; - _smartDiscoverySuccessfulTypeProbeCount = 0; - _smartDiscoveryAuthorityHost = string.Empty; - _smartDiscoveryAuthorityPort = 0; + lock (_smartDiscoveryFlightSync) + { + unchecked + { + _smartDiscoveryAssociationGeneration++; + } + + _smartDiscoveryAssociationFlight = null; + _smartDiscoveryFlightGeneration = -1; + _smartDiscoveryAuthority = null; + _smartDiscoveryModelAuthority = null; + _smartDiscoveryTypeProbeCount = 0; + _smartDiscoverySuccessfulTypeProbeCount = 0; + _smartDiscoveryAuthorityHost = string.Empty; + _smartDiscoveryAuthorityPort = 0; + } + } + + private long GetSmartDiscoveryAssociationGeneration() + { + lock (_smartDiscoveryFlightSync) + return _smartDiscoveryAssociationGeneration; + } + + private bool IsCurrentSmartDiscoveryAssociationGeneration(long generation) + { + lock (_smartDiscoveryFlightSync) + return generation == _smartDiscoveryAssociationGeneration; + } + + private bool TryGetSmartDiscoveryFlight( + long generation, + out Task> flight) + { + lock (_smartDiscoveryFlightSync) + { + if (_smartDiscoveryAssociationFlight is not null && + _smartDiscoveryFlightGeneration == generation) + { + flight = _smartDiscoveryAssociationFlight; + return true; + } + } + + flight = null!; + return false; + } + + private void PublishSmartDiscoveryFlight( + long generation, + Task> flight) + { + lock (_smartDiscoveryFlightSync) + { + if (generation != _smartDiscoveryAssociationGeneration) + return; + + _smartDiscoveryAssociationFlight = flight; + _smartDiscoveryFlightGeneration = generation; + } + } + + private void ClearSmartDiscoveryFlight( + long generation, + Task> flight) + { + // Read the exception here as well so a detached owner whose only waiter was + // cancelled cannot leave an unobserved fault behind. + _ = flight.Exception; + + lock (_smartDiscoveryFlightSync) + { + if (generation == _smartDiscoveryAssociationGeneration && + _smartDiscoveryFlightGeneration == generation && + ReferenceEquals(_smartDiscoveryAssociationFlight, flight)) + { + _smartDiscoveryAssociationFlight = null; + _smartDiscoveryFlightGeneration = -1; + } + } + } + + private bool TryPublishSmartDiscoveryAuthority( + long generation, + ArMms.MmsDiscoveryResult discovery, + LiveIedModelDiscoveryDocument model, + NativeReportInventory reportInventory, + Iec61850DeviceIdentity identity, + int typeProbeCount, + int successfulTypeProbeCount, + string summary) + { + lock (_smartDiscoveryFlightSync) + { + if (generation != _smartDiscoveryAssociationGeneration || !_session.IsMmsInitiated) + return false; + + _lastDiscovery = discovery; + _liveModel = model; + LastReportInventory = reportInventory; + DetectedIdentity = identity; + PublishSmartDiscoveryAuthority( + discovery, + model, + typeProbeCount, + successfulTypeProbeCount); + LastDiscoverySummary = summary; + LastErrorMessage = summary; + return true; + } + } + + private bool TryPublishSmartDiscoveryPresentation( + long generation, + NativeReportInventory reportInventory, + Iec61850DeviceIdentity identity, + string summary) + { + lock (_smartDiscoveryFlightSync) + { + if (generation != _smartDiscoveryAssociationGeneration || + !IsSmartDiscoveryAuthorityBoundToCurrentAssociation()) + { + return false; + } + + LastReportInventory = reportInventory; + DetectedIdentity = identity; + LastDiscoverySummary = summary; + LastErrorMessage = summary; + return true; + } } private bool IsSmartDiscoveryAuthorityBoundToCurrentAssociation() From 03e01fbe08ca6c9f4e6f7593b7355169f9fb1f9d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:38:40 +0700 Subject: [PATCH 027/243] P0-5c make association flight creation atomic --- ...eIec61850Client.SmartDiscoveryLifecycle.cs | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs index 3c0c262f1..6cbac46be 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs @@ -39,47 +39,35 @@ private void ResetSmartDiscoveryAuthority() } } - private long GetSmartDiscoveryAssociationGeneration() - { - lock (_smartDiscoveryFlightSync) - return _smartDiscoveryAssociationGeneration; - } - private bool IsCurrentSmartDiscoveryAssociationGeneration(long generation) { lock (_smartDiscoveryFlightSync) return generation == _smartDiscoveryAssociationGeneration; } - private bool TryGetSmartDiscoveryFlight( - long generation, - out Task> flight) + private Task> GetOrCreateSmartDiscoveryAssociationFlight( + Func>> ownerFactory) { + ArgumentNullException.ThrowIfNull(ownerFactory); + lock (_smartDiscoveryFlightSync) { + var generation = _smartDiscoveryAssociationGeneration; if (_smartDiscoveryAssociationFlight is not null && _smartDiscoveryFlightGeneration == generation) { - flight = _smartDiscoveryAssociationFlight; - return true; + return _smartDiscoveryAssociationFlight; } - } - - flight = null!; - return false; - } - - private void PublishSmartDiscoveryFlight( - long generation, - Task> flight) - { - lock (_smartDiscoveryFlightSync) - { - if (generation != _smartDiscoveryAssociationGeneration) - return; + var flight = ownerFactory(generation); _smartDiscoveryAssociationFlight = flight; _smartDiscoveryFlightGeneration = generation; + _ = flight.ContinueWith( + completed => ClearSmartDiscoveryFlight(generation, completed), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return flight; } } @@ -87,8 +75,7 @@ private void ClearSmartDiscoveryFlight( long generation, Task> flight) { - // Read the exception here as well so a detached owner whose only waiter was - // cancelled cannot leave an unobserved fault behind. + // Observe a detached owner's fault if every waiter cancelled independently. _ = flight.Exception; lock (_smartDiscoveryFlightSync) From d8631e5bf16f1a7d1e2bac8e75de792c340aae3c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:39:25 +0700 Subject: [PATCH 028/243] P0-5c coalesce full enrichment into one association flight --- ...iveIec61850Client.SmartDiscoveryCapture.cs | 176 ++++++++++++------ 1 file changed, 114 insertions(+), 62 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index b69532405..0a640f16e 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -16,48 +16,65 @@ private async Task> DiscoverSignalsSmartForCaptu CancellationToken cancellationToken, IProgress? progress) { - // Protect one physical MMS association from accidental concurrent discovery - // (double-click, overlapping runtime requests, or future background consumers). - // The MMS operation gate additionally prevents report/read workflows from - // entering a legacy discovery path before the smart authority is published. - await _smartDiscoveryCaptureGate.WaitAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // P0-5c: one owner performs the complete enrichment chain for one association + // generation. Every concurrent caller receives the same in-flight task. Caller + // cancellation only stops that caller waiting; it never cancels the shared MMS + // owner and therefore cannot cause a second GVA ladder on the same association. + var flight = GetOrCreateSmartDiscoveryAssociationFlight( + generation => RunSmartDiscoveryAssociationFlightAsync(generation, progress)); + + return await flight.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task> RunSmartDiscoveryAssociationFlightAsync( + long associationGeneration, + IProgress? progress) + { + // The complete directory -> GVA -> canonical model -> projection -> publish + // sequence owns the application MMS gate. Waiter cancellation is deliberately + // absent here: only association generation invalidation can make this owner stale. + await _mmsIoGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); try { - await _mmsIoGate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - return await DiscoverSignalsSmartForCaptureCoreAsync(cancellationToken, progress).ConfigureAwait(false); - } - finally - { - _mmsIoGate.Release(); - } + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + + return await DiscoverSignalsSmartForCaptureCoreAsync( + associationGeneration, + progress) + .ConfigureAwait(false); } finally { - _smartDiscoveryCaptureGate.Release(); + _mmsIoGate.Release(); } } private async Task> DiscoverSignalsSmartForCaptureCoreAsync( - CancellationToken cancellationToken, + long associationGeneration, IProgress? progress) { - LastDiscoverySummary = string.Empty; - cancellationToken.ThrowIfCancellationRequested(); + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + LastDiscoverySummary = string.Empty; if (!_session.IsMmsInitiated) { - LastErrorMessage = $"ARIEC61850 smart discovery requires ACSE/MMS association. Current state: {_session.State}. {_session.LastAssociationAttemptSummary}"; + if (IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + LastErrorMessage = $"ARIEC61850 smart discovery requires ACSE/MMS association. Current state: {_session.State}. {_session.LastAssociationAttemptSummary}"; + } return Array.Empty(); } var totalWatch = Stopwatch.StartNew(); try { - // A second discovery request on the same association must be wire-free. The - // authority marker is reference-bound to _lastDiscovery/_liveModel and also - // explicitly bound to the current host/port association lifecycle. + // A completed discovery on this exact association generation is wire-free. + // Concurrent callers do not reach this branch independently because they + // already share the same association flight above. if (TryGetSmartDiscoveryAuthority(out var cachedDiscovery, out var cachedModel)) { progress?.Report(new IedDiscoveryProgress( @@ -67,16 +84,16 @@ private async Task> DiscoverSignalsSmartForCaptu var cachedProjectionWatch = Stopwatch.StartNew(); var cachedSnapshot = ToNativeSnapshot(cachedDiscovery.Snapshot); - LastReportInventory = ToNativeInventory(cachedDiscovery.ReportInventory); + var cachedInventory = ToNativeInventory(cachedDiscovery.ReportInventory); var cachedSignals = BuildSmartCaptureSignalProjection( cachedModel, cachedSnapshot, - LastReportInventory, + cachedInventory, out var cachedProjectionStats); cachedProjectionWatch.Stop(); var cachedReportWatch = Stopwatch.StartNew(); - NativeReportDiscoveryMapper.ApplyReportHints(cachedSignals, LastReportInventory); + NativeReportDiscoveryMapper.ApplyReportHints(cachedSignals, cachedInventory); cachedReportWatch.Stop(); progress?.Report(new IedDiscoveryProgress( @@ -85,27 +102,43 @@ private async Task> DiscoverSignalsSmartForCaptu 94d, 8, 10)); var cachedIdentityWatch = Stopwatch.StartNew(); - DetectedIdentity = Iec61850DeviceIdentityResolver.Resolve(cachedDiscovery, cachedModel, cachedSignals); + var cachedIdentity = Iec61850DeviceIdentityResolver.Resolve( + cachedDiscovery, + cachedModel, + cachedSignals); cachedIdentityWatch.Stop(); totalWatch.Stop(); + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + var cachedLogicalNodes = cachedSignals .Select(signal => signal.LogicalNode) .Where(value => !string.IsNullOrWhiteSpace(value)) .Distinct(StringComparer.OrdinalIgnoreCase) .Count(); var cachedRawVariables = cachedSnapshot.DomainVariables.Values.Sum(values => values.Count); - - LastDiscoverySummary = - $"SMART-CAPTURE PR134 R4; association authority=reused; engine single-flight=reused; control inventory=authoritative; wire discovery=skipped; " + - $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + + var cachedBudget = _session.LastSmartTypeProbeBudget?.Summary ?? "Smart type budget unavailable."; + var cachedSummary = + $"SMART-CAPTURE PR134 P0-5c; association authority=reused; association flight=new-wire-free; control inventory=authoritative; wire discovery=skipped; " + + $"IEDName={(string.IsNullOrWhiteSpace(cachedIdentity.IedName) ? "unresolved" : cachedIdentity.IedName)} ({cachedIdentity.Source}); " + $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + $"indexed LN hints={cachedProjectionStats.LogicalNodeHints}, indexed fallback signals={cachedProjectionStats.AddedFallbackSignals}. " + + $"{cachedBudget} " + $"TimingMs directory=0.0, types=0.0, model=0.0, projection={cachedProjectionWatch.Elapsed.TotalMilliseconds:F1}, " + $"reportHints={cachedReportWatch.Elapsed.TotalMilliseconds:F1}, identity={cachedIdentityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + "Deferred: supplemental GetNameList, eager report attributes, DataSet directories, reflection fallback, adaptive sibling/equipment/reference/unit probes."; - LastErrorMessage = LastDiscoverySummary; + + if (!TryPublishSmartDiscoveryPresentation( + associationGeneration, + cachedInventory, + cachedIdentity, + cachedSummary)) + { + return Array.Empty(); + } + return cachedSignals; } @@ -123,39 +156,43 @@ private async Task> DiscoverSignalsSmartForCaptu progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.DiscoveringDirectory, - "Smart MMS discovery: association single-flight bounded directory scan…", + "Smart MMS discovery: association-generation single-flight bounded directory scan…", 28d, 4, 10)); var directoryWatch = Stopwatch.StartNew(); - // Once this caller owns the application MMS gate, keep that gate until the - // shared directory flight itself completes. A UI/waiter cancellation must - // not release the gate while the engine continues the association-scoped - // discovery in the background. var discovery = await _session .DiscoverSmartSingleFlightAsync(smartOptions, CancellationToken.None) .ConfigureAwait(false); directoryWatch.Stop(); - _lastDiscovery = discovery; - cancellationToken.ThrowIfCancellationRequested(); + // Reconnect/dispose may invalidate the generation while the current PDU is + // in flight. Stop at the boundary before issuing any GVA request. + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.ProbingLogicalNodes, - "Smart MMS type discovery: Logical Node hierarchy probes…", + "Smart MMS type discovery: coverage-aware Logical Node hierarchy probes…", 52d, 5, 10)); var typeWatch = Stopwatch.StartNew(); var variableTypes = await LiveIedVariableTypeProbeExecutor - .ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, cancellationToken) + .ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, CancellationToken.None) .ConfigureAwait(false); typeWatch.Stop(); + // A stale owner may finish an already-issued GVA batch, but it cannot build + // or publish state into the replacement association generation. + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.BuildingLiveModel, "Building canonical IEC 61850 model from smart discovery evidence…", 68d, 6, 10)); var modelWatch = Stopwatch.StartNew(); - _liveModel = LiveIedModelDiscoveryBuilder.Build( + var liveModel = LiveIedModelDiscoveryBuilder.Build( discovery, new LiveIedModelDiscoveryBuildOptions { @@ -167,7 +204,7 @@ private async Task> DiscoverSignalsSmartForCaptu modelWatch.Stop(); var snapshot = ToNativeSnapshot(discovery.Snapshot); - LastReportInventory = ToNativeInventory(discovery.ReportInventory); + var reportInventory = ToNativeInventory(discovery.ReportInventory); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.MappingSignals, @@ -176,16 +213,16 @@ private async Task> DiscoverSignalsSmartForCaptu var projectionWatch = Stopwatch.StartNew(); var signals = BuildSmartCaptureSignalProjection( - _liveModel, + liveModel, snapshot, - LastReportInventory, + reportInventory, out var projectionStats); projectionWatch.Stop(); // Report hints derived from structural NamedVariable/NamedVariableList evidence // remain available. Attribute reads and DataSet-directory reads are deferred. var reportWatch = Stopwatch.StartNew(); - NativeReportDiscoveryMapper.ApplyReportHints(signals, LastReportInventory); + NativeReportDiscoveryMapper.ApplyReportHints(signals, reportInventory); reportWatch.Stop(); progress?.Report(new IedDiscoveryProgress( @@ -194,9 +231,12 @@ private async Task> DiscoverSignalsSmartForCaptu 94d, 8, 10)); var identityWatch = Stopwatch.StartNew(); - DetectedIdentity = Iec61850DeviceIdentityResolver.Resolve(discovery, _liveModel, signals); + var identity = Iec61850DeviceIdentityResolver.Resolve(discovery, liveModel, signals); identityWatch.Stop(); + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + var logicalNodes = signals .Select(signal => signal.LogicalNode) .Where(value => !string.IsNullOrWhiteSpace(value)) @@ -204,35 +244,47 @@ private async Task> DiscoverSignalsSmartForCaptu .Count(); var rawVariables = snapshot.DomainVariables.Values.Sum(values => values.Count); var successfulTypeRoots = variableTypes.Count(result => result.IsSuccess); - - // Publish only after the complete projection succeeds. If mapping fails, a - // retry is allowed to repeat wire discovery rather than reusing partial state. - PublishSmartDiscoveryAuthority( - discovery, - _liveModel, - variableTypes.Count, - successfulTypeRoots); + var typeBudget = _session.LastSmartTypeProbeBudget?.Summary ?? "Smart type budget unavailable."; totalWatch.Stop(); - LastDiscoverySummary = - $"SMART-CAPTURE PR134 R4; association authority=new; engine single-flight=new; app MMS gate=exclusive; control inventory=authoritative; " + - $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); " + - $"{discovery.Summary} {_liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + + var summary = + $"SMART-CAPTURE PR134 P0-5c; association authority=new; association flight=single-owner; app MMS gate=exclusive; control inventory=authoritative; " + + $"IEDName={(string.IsNullOrWhiteSpace(identity.IedName) ? "unresolved" : identity.IedName)} ({identity.Source}); " + + $"{discovery.Summary} {liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}, " + $"indexed LN hints={projectionStats.LogicalNodeHints}, indexed fallback signals={projectionStats.AddedFallbackSignals}. " + + $"{typeBudget} " + $"TimingMs directory={directoryWatch.Elapsed.TotalMilliseconds:F1}, types={typeWatch.Elapsed.TotalMilliseconds:F1}, " + $"model={modelWatch.Elapsed.TotalMilliseconds:F1}, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + "Deferred: supplemental GetNameList, eager report attributes, DataSet directories, reflection fallback, adaptive sibling/equipment/reference/unit probes."; - LastErrorMessage = LastDiscoverySummary; + + // The generation check and state publication are atomic with Reset. A stale + // owner can never write _lastDiscovery/_liveModel/identity into a new session. + if (!TryPublishSmartDiscoveryAuthority( + associationGeneration, + discovery, + liveModel, + reportInventory, + identity, + variableTypes.Count, + successfulTypeRoots, + summary)) + { + return Array.Empty(); + } + return signals; } catch (Exception ex) when (ex is not OperationCanceledException) { totalWatch.Stop(); - LastErrorMessage = - $"ARIEC61850 smart capture discovery failed after {totalWatch.Elapsed.TotalMilliseconds:F1} ms: " + - $"{ex.GetType().Name}: {ex.Message}. Last discovery: {_session.LastDiscoveryAttemptSummary}. Last request: {_session.LastDiscoveryRequestHex}"; + if (IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + LastErrorMessage = + $"ARIEC61850 smart capture discovery failed after {totalWatch.Elapsed.TotalMilliseconds:F1} ms: " + + $"{ex.GetType().Name}: {ex.Message}. Last discovery: {_session.LastDiscoveryAttemptSummary}. Last request: {_session.LastDiscoveryRequestHex}"; + } return Array.Empty(); } } From a2924863e7dd348804394c045307df9defe8eb9c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:39:44 +0700 Subject: [PATCH 029/243] P0-5c invalidate association flight on connect and dispose --- scripts/enable-smart-discovery-capture.ps1 | 34 +++++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/scripts/enable-smart-discovery-capture.ps1 b/scripts/enable-smart-discovery-capture.ps1 index ead45ed44..e25090bf4 100644 --- a/scripts/enable-smart-discovery-capture.ps1 +++ b/scripts/enable-smart-discovery-capture.ps1 @@ -30,8 +30,8 @@ else { Write-Host 'Smart discovery capture route already installed.' } -$resetMarker = 'ResetSmartDiscoveryAuthority();' -if ($text.IndexOf($resetMarker, [System.StringComparison]::Ordinal) -lt 0) { +$resetMarker = '_liveModel = null;__P0_5C_CONNECT_RESET__' +if ($text.IndexOf('__P0_5C_CONNECT_RESET__', [System.StringComparison]::Ordinal) -lt 0) { $resetAnchor = " _lastDiscovery = null;`r`n _liveModel = null;" $anchorIndex = $text.IndexOf($resetAnchor, [System.StringComparison]::Ordinal) if ($anchorIndex -lt 0) { @@ -45,16 +45,34 @@ if ($text.IndexOf($resetMarker, [System.StringComparison]::Ordinal) -lt 0) { throw 'ConnectAsync discovery reset anchor is not unique; refusing ambiguous smart authority patch.' } - $resetInjection = $resetAnchor + "`r`n ResetSmartDiscoveryAuthority();" - if ($resetAnchor.Contains("`n") -and -not $resetAnchor.Contains("`r`n")) { - $resetInjection = $resetAnchor + "`n ResetSmartDiscoveryAuthority();" - } + $lineBreak = if ($resetAnchor.Contains("`r`n")) { "`r`n" } else { "`n" } + $resetInjection = $resetAnchor + $lineBreak + ' ResetSmartDiscoveryAuthority(); // __P0_5C_CONNECT_RESET__' $text = $text.Remove($anchorIndex, $resetAnchor.Length).Insert($anchorIndex, $resetInjection) $changed = $true - Write-Host 'Installed explicit smart discovery authority reset into ConnectAsync.' + Write-Host 'Installed P0-5c association-generation reset into ConnectAsync.' +} +else { + Write-Host 'P0-5c ConnectAsync association reset already installed.' +} + +$disposeMarker = '__P0_5C_DISPOSE_RESET__' +if ($text.IndexOf($disposeMarker, [System.StringComparison]::Ordinal) -lt 0) { + $disposePattern = '(public async ValueTask DisposeAsync\(\)\s*\{)' + $disposeMatch = [regex]::Match($text, $disposePattern) + if (-not $disposeMatch.Success -or [regex]::Matches($text, $disposePattern).Count -ne 1) { + throw 'Could not locate a unique NativeIec61850Client.DisposeAsync entrypoint for association invalidation.' + } + + $disposeInjection = @' + + ResetSmartDiscoveryAuthority(); // __P0_5C_DISPOSE_RESET__ +'@ + $text = $text.Insert($disposeMatch.Index + $disposeMatch.Length, $disposeInjection) + $changed = $true + Write-Host 'Installed P0-5c association-generation reset into DisposeAsync.' } else { - Write-Host 'Smart discovery authority reset already installed.' + Write-Host 'P0-5c DisposeAsync association reset already installed.' } $controlAuthorityMarker = '_lastDiscovery.Snapshot.DomainVariables' From 7dcc16c39d61c8cc4a1bea6ac4e9d96314b7b8f3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:40:09 +0700 Subject: [PATCH 030/243] P0-5c lock association single-flight regression contracts --- ...yAssociationSingleFlightRegressionTests.cs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs diff --git a/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs new file mode 100644 index 000000000..c358e93f3 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs @@ -0,0 +1,73 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryAssociationSingleFlightRegressionTests +{ + private const string P05bEngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + [Fact] + public void P05c_CompleteEnrichmentChain_IsAssociationScopedSingleFlight() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + + Assert.Contains("GetOrCreateSmartDiscoveryAssociationFlight", capture, StringComparison.Ordinal); + Assert.Contains("RunSmartDiscoveryAssociationFlightAsync", capture, StringComparison.Ordinal); + Assert.Contains("flight.WaitAsync(cancellationToken)", capture, StringComparison.Ordinal); + Assert.Contains("_mmsIoGate.WaitAsync(CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.Contains("DiscoverSmartSingleFlightAsync(smartOptions, CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.Contains("ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.DoesNotContain("ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, cancellationToken)", capture, StringComparison.Ordinal); + + Assert.Contains("_smartDiscoveryAssociationFlight", lifecycle, StringComparison.Ordinal); + Assert.Contains("_smartDiscoveryFlightGeneration", lifecycle, StringComparison.Ordinal); + Assert.Contains("ownerFactory(generation)", lifecycle, StringComparison.Ordinal); + Assert.Contains("ReferenceEquals(_smartDiscoveryAssociationFlight, flight)", lifecycle, StringComparison.Ordinal); + } + + [Fact] + public void P05c_ReconnectAndDispose_InvalidateGenerationAndBlockStalePublish() + { + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + var patcher = File.ReadAllText(FindRepoFile("scripts/enable-smart-discovery-capture.ps1")); + + Assert.Contains("_smartDiscoveryAssociationGeneration++", lifecycle, StringComparison.Ordinal); + Assert.Contains("generation != _smartDiscoveryAssociationGeneration", lifecycle, StringComparison.Ordinal); + Assert.Contains("TryPublishSmartDiscoveryAuthority", lifecycle, StringComparison.Ordinal); + Assert.Contains("IsCurrentSmartDiscoveryAssociationGeneration", capture, StringComparison.Ordinal); + Assert.Contains("__P0_5C_CONNECT_RESET__", patcher, StringComparison.Ordinal); + Assert.Contains("__P0_5C_DISPOSE_RESET__", patcher, StringComparison.Ordinal); + Assert.Contains("ResetSmartDiscoveryAuthority(); // __P0_5C_DISPOSE_RESET__", patcher, StringComparison.Ordinal); + } + + [Fact] + public void P05c_EnginePin_IsExactP05bBudgetConvergenceCommit() + { + var lockPath = FindRepoFile("engines/ARIEC61850.lock.json"); + using var document = JsonDocument.Parse(File.ReadAllText(lockPath)); + var commit = document.RootElement.GetProperty("commit").GetString(); + var workflow = File.ReadAllText(FindRepoFile(".github/workflows/smart-discovery-capture-build.yml")); + + Assert.Equal(P05bEngineCommit, commit); + Assert.Contains(P05bEngineCommit, workflow, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LastSmartTypeProbeBudget", workflow, StringComparison.Ordinal); + Assert.Contains("SuppressedExactRepeatRequests", workflow, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 63b2c839f4e957fd0f4122af9bbe7f687a87e08a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:40:37 +0700 Subject: [PATCH 031/243] P0-5c pin ARSAS to P0-5b engine head --- engines/ARIEC61850.lock.json | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5ad9c8067..0d9ee5284 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,17 +2,22 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "57c8311c0dcf9e51da4d7dc31c757fc3f0912586", + "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, - "purpose": "Test-only capture build pin for ARIEC61850 PR #134 R4. Uses bounded pipelined smart MMS directory discovery, hierarchy-first variable type probing, single-writer TPKT framing, partial-evidence preservation, smart FC-root reads, association-scoped smart discovery single-flight, authoritative domain-variable inventory reuse by the Control transport, and explicit authoritative inventory injection into Control object inspection. ARSAS additionally serializes the field-capture critical path on its MMS operation gate so report/read workflows cannot enter legacy discovery before the authoritative smart model is published. The reviewed production ancestry below remains preserved unchanged for regression authority.", + "purpose": "P0-5c ARSAS smart-discovery capture pin. Compiles against the exact P0-5b hierarchy-coverage/GVA-budget convergence engine commit, including live-LN suppression, LN -> unresolved DO -> distinct exact-leaf fallback, exact-repeat suppression, LastSmartTypeProbeBudget diagnostics, association-scoped engine directory single-flight, authoritative domain-variable reuse, and Control inventory injection. ARSAS P0-5c adds one application-level association-generation flight around directory discovery, GVA enrichment, canonical model build, projection, and publication so caller cancellation cannot restart GVA work on the same association and stale reconnect/dispose flights cannot publish into a replacement association.", "previousTrialPin": { + "commit": "57c8311c0dcf9e51da4d7dc31c757fc3f0912586", + "sourcePullRequest": 134, + "purpose": "Previous PR #134 R4 smart-discovery capture pin before P0-5b hierarchy request-budget convergence and P0-5c ARSAS association-generation flight integration." + }, + "priorGoldenWirePin": { "commit": "0023ef9a4373855497464ed3979e359c4041c95d", "sourcePullRequest": 132, - "purpose": "Previous ARSAS 1.6.36 combined golden-wire convergence pin retained for explicit ancestry." + "purpose": "Earlier ARSAS 1.6.36 combined golden-wire convergence trial retained for explicit ancestry." }, "fieldProvenBaseline": { "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." + "purpose": "Reviewed field-proven reporting/control baseline retained as regression ancestry. The PR #134 smart-discovery lane is capture/test-only and must preserve these reporting, semantic projection, commissioning, and Smart Control guarantees while optimizing discovery traffic." } } From 5d5dbf33e818a52a0803be4d096be51b2e0abee8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:41:08 +0700 Subject: [PATCH 032/243] P0-5c verify association single-flight against P0-5b engine --- .../smart-discovery-capture-build.yml | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 24d273496..aab770aa5 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -22,7 +22,7 @@ jobs: if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { throw 'Invalid ARIEC61850 field-capture pin.' } - if ($lock.commit -ne '57c8311c0dcf9e51da4d7dc31c757fc3f0912586') { + if ($lock.commit -ne '4467124775d8d9d76f3db194f9fbfd97144767a8') { throw "Unexpected engine commit: $($lock.commit)" } $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() @@ -36,7 +36,7 @@ jobs: "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "ARSAS field source: $arsasCommit" - - name: Verify smart capture sources + - name: Verify P0-5c smart capture sources shell: powershell run: | $helper = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw @@ -47,26 +47,30 @@ jobs: if ($helper -notmatch 'DiscoverSmartSingleFlightAsync' -or $helper -notmatch 'LiveIedVariableTypeProbeExecutor' -or $helper -notmatch 'variableTypeAttributes' -or - $helper -notmatch 'SMART-CAPTURE PR134 R4' -or + $helper -notmatch 'SMART-CAPTURE PR134 P0-5c' -or $helper -notmatch 'control inventory=authoritative' -or - $helper -notmatch '_mmsIoGate.WaitAsync' -or + $helper -notmatch 'GetOrCreateSmartDiscoveryAssociationFlight' -or + $helper -notmatch 'flight\.WaitAsync\(cancellationToken\)' -or + $helper -notmatch '_mmsIoGate\.WaitAsync\(CancellationToken\.None\)' -or + $helper -notmatch 'ProbeSmartAsync\(_session, discovery\.IedDirectory, smartOptions, CancellationToken\.None\)' -or $optimization -notmatch 'TryGetSmartDiscoveryAuthority' -or $optimization -notmatch 'BuildSmartCaptureSignalProjection' -or $optimization -notmatch 'AddSmartIndexedLogicalNodeFallbacks' -or $optimization -notmatch 'IsSmartDiscoveryAuthorityBoundToCurrentAssociation' -or - $lifecycle -notmatch 'ResetSmartDiscoveryAuthority' -or - $lifecycle -notmatch '_smartDiscoveryAuthorityHost' -or - $lifecycle -notmatch '_smartDiscoveryAuthorityPort' -or - $helper -notmatch '_smartDiscoveryCaptureGate' -or - $patcher -notmatch 'DiscoverSignalsSmartForCaptureAsync' -or - $patcher -notmatch 'ResetSmartDiscoveryAuthority' -or + $lifecycle -notmatch '_smartDiscoveryAssociationGeneration' -or + $lifecycle -notmatch '_smartDiscoveryAssociationFlight' -or + $lifecycle -notmatch 'TryPublishSmartDiscoveryAuthority' -or + $lifecycle -notmatch 'generation != _smartDiscoveryAssociationGeneration' -or + $patcher -notmatch '__P0_5C_CONNECT_RESET__' -or + $patcher -notmatch '__P0_5C_DISPOSE_RESET__' -or $patcher -notmatch '_lastDiscovery\.Snapshot\.DomainVariables' -or $targets -notmatch 'EnableSmartDiscoveryCaptureRoute') { - throw 'Smart discovery R4 capture routing, explicit Control inventory reuse, single-flight, optimization authority, or association lifecycle invalidation is incomplete.' + throw 'P0-5c association-scoped enrichment single-flight, authority publication, or lifecycle invalidation is incomplete.' } - if ($helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or + if ($helper -match 'ProbeSmartAsync\(_session, discovery\.IedDirectory, smartOptions, cancellationToken\)' -or + $helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or $helper -match 'FinalizeDiscoveredSignals') { - throw 'Smart discovery R4 critical path regressed to reflection fallback or second full finalization.' + throw 'P0-5c critical path regressed to caller-cancellable GVA, reflection fallback, or second full finalization.' } - name: Checkout immutable ARIEC61850 PR 134 engine @@ -79,6 +83,7 @@ jobs: if ($actual -ne $env:ARIEC61850_COMMIT) { throw "Engine SHA mismatch: $actual" } $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw + $smartTypes = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartVariableAccessAttributes.cs -Raw $controlTransport = Get-Content .\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlTransport.cs -Raw $controlService = Get-Content .\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlService.cs -Raw $controlTest = Get-Content .\ARIEC61850\tests\AR.Iec61850.Tests\Control\AuthoritativeControlInventoryTests.cs -Raw @@ -88,13 +93,17 @@ jobs: $singleFlight -notmatch 'GetAuthoritativeDomainVariableNamesAsync' -or $singleFlight -notmatch 'WaitAsync\(cancellationToken\)' -or $singleFlight -notmatch 'incompleteChains=0' -or + $smartTypes -notmatch 'LastSmartTypeProbeBudget' -or + $smartTypes -notmatch 'SuppressedNonLiveLogicalNodeCandidates' -or + $smartTypes -notmatch 'SuppressedExactRepeatRequests' -or + $smartTypes -notmatch 'BuildUnprobedExactFallbacks' -or $controlTransport -notmatch 'GetAuthoritativeDomainVariableNamesAsync' -or $controlTransport -match '=> _session\.DiscoverDomainVariableNamesAsync\(cancellationToken\)' -or $controlService -notmatch 'authoritativeDomainVariables' -or $controlService -notmatch 'domainInventory=authoritative-reuse' -or $controlTest -notmatch 'DoesNotBrowseDomainVariablesAgain' -or $hierarchy -notmatch 'ProbeSmartAsync') { - throw 'Pinned engine does not expose the required R4 smart discovery/control inventory invariants.' + throw 'Pinned P0-5b engine does not expose the required smart discovery, hierarchy-budget, and authoritative-Control invariants.' } - name: Setup .NET 8 @@ -108,15 +117,18 @@ jobs: - name: Build Release run: dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore - - name: Verify smart route and lifecycle reset were installed + - name: Verify smart route and association lifecycle resets were installed shell: powershell run: | $native = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.cs -Raw if ($native -notmatch 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { throw 'Build-time smart discovery route was not installed.' } - if ($native -notmatch '_lastDiscovery = null;\s*_liveModel = null;\s*ResetSmartDiscoveryAuthority\(\);') { - throw 'Build-time smart discovery authority reset was not installed into ConnectAsync.' + if ($native -notmatch '_lastDiscovery = null;\s*_liveModel = null;\s*ResetSmartDiscoveryAuthority\(\);\s*// __P0_5C_CONNECT_RESET__') { + throw 'P0-5c ConnectAsync association-generation reset was not installed.' + } + if ($native -notmatch 'public async ValueTask DisposeAsync\(\)\s*\{\s*ResetSmartDiscoveryAuthority\(\);\s*// __P0_5C_DISPOSE_RESET__') { + throw 'P0-5c DisposeAsync association-generation reset was not installed.' } if ($native -notmatch 'service\.OpenAsync\(_session, signal\.ObjectReference, _lastDiscovery\.Snapshot\.DomainVariables, cancellationToken\)') { throw 'Build-time Control path does not consume the authoritative smart domain inventory.' @@ -153,8 +165,9 @@ jobs: "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine PR: 134", - "Mode: PR134 R4 association single-flight + explicit authoritative Control inventory injection + hierarchy-first types + indexed CPU projection", - "CI invariant: Control cannot repeat legacy domain-variable discovery when a complete smart inventory exists; app MMS gate excludes legacy workflows during smart publication", + "Mode: P0-5c ARSAS association-generation enrichment single-flight + P0-5b hierarchy GVA budget convergence", + "CI invariant: caller cancellation only releases its waiter; directory/GVA/model/projection/publish remain one owner flight per association generation", + "CI invariant: reconnect/dispose invalidates the generation; stale owners cannot publish authority into a replacement association", "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 From 42c8f54177dd8f1c7570277e44cb95393427197b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 09:50:41 +0700 Subject: [PATCH 033/243] P0-5c preserve field-proven ancestry in smart discovery pin --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 0d9ee5284..e85247a75 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -4,7 +4,7 @@ "ref": "main", "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, - "purpose": "P0-5c ARSAS smart-discovery capture pin. Compiles against the exact P0-5b hierarchy-coverage/GVA-budget convergence engine commit, including live-LN suppression, LN -> unresolved DO -> distinct exact-leaf fallback, exact-repeat suppression, LastSmartTypeProbeBudget diagnostics, association-scoped engine directory single-flight, authoritative domain-variable reuse, and Control inventory injection. ARSAS P0-5c adds one application-level association-generation flight around directory discovery, GVA enrichment, canonical model build, projection, and publication so caller cancellation cannot restart GVA work on the same association and stale reconnect/dispose flights cannot publish into a replacement association.", + "purpose": "P0-5c ARSAS smart-discovery capture pin. Compiles against the exact P0-5b hierarchy-coverage/GVA-budget convergence engine commit, including live-LN suppression, LN -> unresolved DO -> distinct exact-leaf fallback, exact-repeat suppression, LastSmartTypeProbeBudget diagnostics, association-scoped engine directory single-flight, authoritative domain-variable reuse, and Control inventory injection. ARSAS P0-5c adds one application-level association-generation flight around directory discovery, GVA enrichment, canonical model build, projection, and publication so caller cancellation cannot restart GVA work on the same association and stale reconnect/dispose flights cannot publish into a replacement association. This trial pin changes discovery orchestration only; it preserves the complete field-proven reporting/control ancestry recorded in fieldProvenBaseline below.", "previousTrialPin": { "commit": "57c8311c0dcf9e51da4d7dc31c757fc3f0912586", "sourcePullRequest": 134, @@ -18,6 +18,6 @@ "fieldProvenBaseline": { "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", "sourcePullRequest": 111, - "purpose": "Reviewed field-proven reporting/control baseline retained as regression ancestry. The PR #134 smart-discovery lane is capture/test-only and must preserve these reporting, semantic projection, commissioning, and Smart Control guarantees while optimizing discovery traffic." + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." } } From 72617d9e61cc063f1732f98582a90261d9720ebb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:02:34 +0700 Subject: [PATCH 034/243] test(discovery): add P0-5d physical PCAP proof verifier --- scripts/verify-smart-discovery-pcap.ps1 | 417 ++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 scripts/verify-smart-discovery-pcap.ps1 diff --git a/scripts/verify-smart-discovery-pcap.ps1 b/scripts/verify-smart-discovery-pcap.ps1 new file mode 100644 index 000000000..5dba35239 --- /dev/null +++ b/scripts/verify-smart-discovery-pcap.ps1 @@ -0,0 +1,417 @@ +param( + [Parameter(Mandatory = $true)] + [string]$PcapPath, + + [string]$ReferencePcapPath, + [string]$TsharkPath = "tshark", + [string]$ClientIp, + [string]$ServerIp, + [int]$MaxConfirmedRequests = 0, + [int]$MaxGvaRequests = 0, + [switch]$RequireNoMoreRequestsThanReference, + [string]$OutputJson, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-CapturePath([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { + throw "$Label is not a file: $Path" + } + return $resolved.Path +} + +function Get-TsharkFieldSet { + param([string]$Executable) + + $lines = & $Executable -G fields 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "TShark field discovery failed with exit code $LASTEXITCODE. Output: $($lines -join [Environment]::NewLine)" + } + + $set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($line in $lines) { + $parts = [string]$line -split "`t" + if ($parts.Length -ge 3 -and $parts[0] -eq "F" -and -not [string]::IsNullOrWhiteSpace($parts[2])) { + [void]$set.Add($parts[2]) + } + } + return $set +} + +function Normalize-Cell([object]$Value) { + if ($null -eq $Value) { return "" } + return ([string]$Value).Trim() +} + +function Get-RowValue($Row, [string]$Name) { + if ($null -eq $Row) { return "" } + $property = $Row.PSObject.Properties[$Name] + if ($null -eq $property) { return "" } + return Normalize-Cell $property.Value +} + +function Get-EndpointSource($Row) { + $ipv4 = Get-RowValue $Row "ip.src" + if ($ipv4) { return $ipv4 } + return Get-RowValue $Row "ipv6.src" +} + +function Get-EndpointDestination($Row) { + $ipv4 = Get-RowValue $Row "ip.dst" + if ($ipv4) { return $ipv4 } + return Get-RowValue $Row "ipv6.dst" +} + +function Test-Present($Row, [string]$Field) { + return -not [string]::IsNullOrWhiteSpace((Get-RowValue $Row $Field)) +} + +function Get-ServiceName($Row) { + if (Test-Present $Row "mms.getNameList_element") { return "GetNameList" } + if (Test-Present $Row "mms.getVariableAccessAttributes_element") { return "GetVariableAccessAttributes" } + if (Test-Present $Row "mms.getNamedVariableListAttributes_element") { return "GetNamedVariableListAttributes" } + if (Test-Present $Row "mms.read_element") { return "Read" } + if (Test-Present $Row "mms.identify_element") { return "Identify" } + if (Test-Present $Row "mms.write_element") { return "Write" } + + $service = Get-RowValue $Row "mms.confirmedServiceRequest" + if ($service) { return "ConfirmedService:$service" } + return "UnknownConfirmedService" +} + +function Get-RequestFingerprint($Row) { + $service = Get-ServiceName $Row + $parts = [ordered]@{ + service = $service + objectClass = Get-RowValue $Row "mms.objectClass" + objectScope = Get-RowValue $Row "mms.objectScope" + domainId = Get-RowValue $Row "mms.domainId" + itemId = Get-RowValue $Row "mms.itemId" + objectItemId = Get-RowValue $Row "mms.objectName_domain_specific_itemId" + domainSpecific = Get-RowValue $Row "mms.domainSpecific" + vmdSpecific = Get-RowValue $Row "mms.vmd_specific" + variableListName = Get-RowValue $Row "mms.variableListName" + continueAfter = Get-RowValue $Row "mms.continueAfter" + getNameListContinueAfter = Get-RowValue $Row "mms.getNameList-Request_continueAfter" + nameToStartAfter = Get-RowValue $Row "mms.nameToStartAfter" + } + + return (($parts.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "|") +} + +function Decode-MmsRows { + param( + [string]$Capture, + [string]$Executable, + [System.Collections.Generic.HashSet[string]]$AvailableFields + ) + + $candidateFields = @( + "frame.number", + "frame.time_epoch", + "ip.src", + "ip.dst", + "ipv6.src", + "ipv6.dst", + "tcp.stream", + "mms.invokeID", + "mms.confirmed_requestPDU", + "mms.confirmed_responsePDU", + "mms.confirmed_errorPDU", + "mms.confirmedServiceRequest", + "mms.getNameList_element", + "mms.getVariableAccessAttributes_element", + "mms.getNamedVariableListAttributes_element", + "mms.read_element", + "mms.identify_element", + "mms.write_element", + "mms.objectClass", + "mms.objectScope", + "mms.domainId", + "mms.itemId", + "mms.objectName_domain_specific_itemId", + "mms.domainSpecific", + "mms.vmd_specific", + "mms.variableListName", + "mms.continueAfter", + "mms.getNameList-Request_continueAfter", + "mms.nameToStartAfter", + "mms.negociatedMaxServOutstandingCalling", + "mms.negociatedMaxServOutstandingCalled" + ) + + $fields = @($candidateFields | Where-Object { $AvailableFields.Contains($_) }) + foreach ($required in @("frame.number", "frame.time_epoch", "tcp.stream", "mms.invokeID", "mms.confirmed_requestPDU", "mms.confirmed_responsePDU", "mms.confirmed_errorPDU")) { + if ($fields -notcontains $required) { + throw "Installed TShark does not expose required field '$required'." + } + } + if (($fields -notcontains "ip.src") -and ($fields -notcontains "ipv6.src")) { + throw "Installed TShark exposes neither IPv4 nor IPv6 source fields." + } + + $args = @( + "-r", $Capture, + "-Y", "mms", + "-T", "fields", + "-E", "header=y", + "-E", "quote=d", + "-E", "occurrence=a", + "-E", "aggregator=," + ) + foreach ($field in $fields) { + $args += @("-e", $field) + } + + $csvLines = & $Executable @args 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "TShark failed to decode '$Capture' with exit code $LASTEXITCODE. Output: $($csvLines -join [Environment]::NewLine)" + } + if (-not $csvLines -or $csvLines.Count -lt 2) { + throw "No MMS rows were decoded from '$Capture'. Capture must include the full MMS association and discovery interval." + } + + return @($csvLines | ConvertFrom-Csv -Delimiter "`t") +} + +function Analyze-Capture { + param( + [string]$Capture, + [string]$Executable, + [System.Collections.Generic.HashSet[string]]$AvailableFields, + [string]$RequestedClientIp, + [string]$RequestedServerIp + ) + + $rows = Decode-MmsRows -Capture $Capture -Executable $Executable -AvailableFields $AvailableFields + $requestRowsAll = @($rows | Where-Object { Test-Present $_ "mms.confirmed_requestPDU" }) + if ($requestRowsAll.Count -eq 0) { + throw "No MMS confirmed-request PDU was found in '$Capture'." + } + + $client = $RequestedClientIp + $server = $RequestedServerIp + if ([string]::IsNullOrWhiteSpace($client)) { $client = Get-EndpointSource $requestRowsAll[0] } + if ([string]::IsNullOrWhiteSpace($server)) { $server = Get-EndpointDestination $requestRowsAll[0] } + if ([string]::IsNullOrWhiteSpace($client) -or [string]::IsNullOrWhiteSpace($server)) { + throw "Could not infer client/server IP endpoints from the first confirmed MMS request. Supply -ClientIp and -ServerIp explicitly." + } + + $directionRows = @($rows | Where-Object { + $src = Get-EndpointSource $_ + $dst = Get-EndpointDestination $_ + (($src -eq $client -and $dst -eq $server) -or ($src -eq $server -and $dst -eq $client)) + }) + + $requests = @($directionRows | Where-Object { + (Get-EndpointSource $_) -eq $client -and + (Get-EndpointDestination $_) -eq $server -and + (Test-Present $_ "mms.confirmed_requestPDU") + }) + $responses = @($directionRows | Where-Object { + (Get-EndpointSource $_) -eq $server -and + (Get-EndpointDestination $_) -eq $client -and + ((Test-Present $_ "mms.confirmed_responsePDU") -or (Test-Present $_ "mms.confirmed_errorPDU")) + }) + + $requestRecords = foreach ($row in $requests) { + [pscustomobject]@{ + Frame = [int](Get-RowValue $row "frame.number") + Time = [double](Get-RowValue $row "frame.time_epoch") + TcpStream = Get-RowValue $row "tcp.stream" + InvokeId = Get-RowValue $row "mms.invokeID" + Service = Get-ServiceName $row + Fingerprint = Get-RequestFingerprint $row + } + } + + $duplicateGroups = @($requestRecords | + Group-Object Fingerprint | + Where-Object Count -gt 1 | + Sort-Object Count -Descending, Name) + $duplicateRequests = [int](($duplicateGroups | ForEach-Object { $_.Count - 1 } | Measure-Object -Sum).Sum) + + $duplicateDetails = @($duplicateGroups | ForEach-Object { + $records = @($_.Group | Sort-Object Frame) + [pscustomobject]@{ + Service = $records[0].Service + DuplicateAttempts = $_.Count - 1 + Frames = @($records.Frame) + Fingerprint = $_.Name + } + }) + + $serviceCounts = [ordered]@{} + foreach ($group in ($requestRecords | Group-Object Service | Sort-Object Name)) { + $serviceCounts[$group.Name] = $group.Count + } + + $events = @() + foreach ($row in $requests) { + $events += [pscustomobject]@{ + Frame = [int](Get-RowValue $row "frame.number") + Kind = "request" + InvokeId = Get-RowValue $row "mms.invokeID" + } + } + foreach ($row in $responses) { + $events += [pscustomobject]@{ + Frame = [int](Get-RowValue $row "frame.number") + Kind = "response" + InvokeId = Get-RowValue $row "mms.invokeID" + } + } + + $outstanding = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $peakOutstanding = 0 + $invokeReuseWhileOutstanding = 0 + $orphanResponses = 0 + foreach ($event in ($events | Sort-Object Frame)) { + if ([string]::IsNullOrWhiteSpace($event.InvokeId)) { continue } + if ($event.Kind -eq "request") { + if (-not $outstanding.Add($event.InvokeId)) { + $invokeReuseWhileOutstanding++ + } + $peakOutstanding = [Math]::Max($peakOutstanding, $outstanding.Count) + } else { + if (-not $outstanding.Remove($event.InvokeId)) { + $orphanResponses++ + } + } + } + + $negotiatedCandidates = @($directionRows | ForEach-Object { + Get-RowValue $_ "mms.negociatedMaxServOutstandingCalling" + } | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }) + $negotiatedCalling = if ($negotiatedCandidates.Count -gt 0) { $negotiatedCandidates[0] } else { $null } + + $requestStreams = @($requestRecords.TcpStream | Where-Object { $_ } | Sort-Object -Unique) + $getNameListDuplicates = @($duplicateDetails | Where-Object Service -eq "GetNameList") + $gvaDuplicates = @($duplicateDetails | Where-Object Service -eq "GetVariableAccessAttributes") + + return [pscustomobject]@{ + Capture = $Capture + ClientIp = $client + ServerIp = $server + RequestTcpStreams = $requestStreams + ConfirmedRequests = $requestRecords.Count + ConfirmedResponsesOrErrors = $responses.Count + ServiceCounts = [pscustomobject]$serviceCounts + DuplicateSemanticRequests = $duplicateRequests + DuplicateGetNameListRequests = [int](($getNameListDuplicates | ForEach-Object DuplicateAttempts | Measure-Object -Sum).Sum) + DuplicateGvaRequests = [int](($gvaDuplicates | ForEach-Object DuplicateAttempts | Measure-Object -Sum).Sum) + DuplicateDetails = $duplicateDetails + SecondGetNameListSweepDetected = $getNameListDuplicates.Count -gt 0 + PeakOutstandingRequests = $peakOutstanding + NegotiatedMaxOutstandingCalling = $negotiatedCalling + InvokeIdReuseWhileOutstanding = $invokeReuseWhileOutstanding + OrphanResponses = $orphanResponses + UnansweredRequestsAtCaptureEnd = $outstanding.Count + } +} + +$pcap = Resolve-CapturePath $PcapPath "P0-5d capture" +$reference = Resolve-CapturePath $ReferencePcapPath "Reference capture" + +try { + $tsharkCommand = Get-Command $TsharkPath -ErrorAction Stop +} catch { + throw "TShark was not found. Install Wireshark/TShark or supply -TsharkPath. $($_.Exception.Message)" +} + +$fieldSet = Get-TsharkFieldSet -Executable $tsharkCommand.Source +$actual = Analyze-Capture -Capture $pcap -Executable $tsharkCommand.Source -AvailableFields $fieldSet -RequestedClientIp $ClientIp -RequestedServerIp $ServerIp +$referenceAnalysis = $null +if ($reference) { + $referenceAnalysis = Analyze-Capture -Capture $reference -Executable $tsharkCommand.Source -AvailableFields $fieldSet -RequestedClientIp "" -RequestedServerIp "" +} + +$failures = [System.Collections.Generic.List[string]]::new() +if ($actual.RequestTcpStreams.Count -ne 1) { $failures.Add("Expected exactly one MMS request TCP stream; observed $($actual.RequestTcpStreams.Count).") } +if ($actual.DuplicateSemanticRequests -ne 0) { $failures.Add("Duplicate semantic confirmed requests detected: $($actual.DuplicateSemanticRequests).") } +if ($actual.SecondGetNameListSweepDetected) { $failures.Add("A duplicate GetNameList semantic request was observed; this is evidence of a second/repeated naming sweep.") } +if ($actual.DuplicateGvaRequests -ne 0) { $failures.Add("Duplicate GetVariableAccessAttributes semantic requests detected: $($actual.DuplicateGvaRequests).") } +if ($actual.InvokeIdReuseWhileOutstanding -ne 0) { $failures.Add("Invoke-ID reuse while the previous request was still outstanding: $($actual.InvokeIdReuseWhileOutstanding).") } +if ($actual.OrphanResponses -ne 0) { $failures.Add("Responses/errors without an observed matching request: $($actual.OrphanResponses). Capture may be incomplete.") } +if ($actual.UnansweredRequestsAtCaptureEnd -ne 0) { $failures.Add("Confirmed requests still outstanding at capture end: $($actual.UnansweredRequestsAtCaptureEnd). Capture may have ended too early.") } +if ($null -ne $actual.NegotiatedMaxOutstandingCalling -and $actual.PeakOutstandingRequests -gt $actual.NegotiatedMaxOutstandingCalling) { + $failures.Add("Peak outstanding $($actual.PeakOutstandingRequests) exceeded negotiated maxOutstandingCalling $($actual.NegotiatedMaxOutstandingCalling).") +} +if ($MaxConfirmedRequests -gt 0 -and $actual.ConfirmedRequests -gt $MaxConfirmedRequests) { + $failures.Add("Confirmed request budget exceeded: $($actual.ConfirmedRequests) > $MaxConfirmedRequests.") +} +$gvaCount = 0 +if ($actual.ServiceCounts.PSObject.Properties["GetVariableAccessAttributes"]) { + $gvaCount = [int]$actual.ServiceCounts.GetVariableAccessAttributes +} +if ($MaxGvaRequests -gt 0 -and $gvaCount -gt $MaxGvaRequests) { + $failures.Add("GVA request budget exceeded: $gvaCount > $MaxGvaRequests.") +} +if ($RequireNoMoreRequestsThanReference -and $referenceAnalysis -and $actual.ConfirmedRequests -gt $referenceAnalysis.ConfirmedRequests) { + $failures.Add("ARSAS confirmed-request count $($actual.ConfirmedRequests) exceeds reference count $($referenceAnalysis.ConfirmedRequests).") +} + +$comparison = $null +if ($referenceAnalysis) { + $comparison = [pscustomobject]@{ + ReferenceCapture = $referenceAnalysis.Capture + ArsasConfirmedRequests = $actual.ConfirmedRequests + ReferenceConfirmedRequests = $referenceAnalysis.ConfirmedRequests + ConfirmedRequestDelta = $actual.ConfirmedRequests - $referenceAnalysis.ConfirmedRequests + ConfirmedRequestRatio = if ($referenceAnalysis.ConfirmedRequests -gt 0) { [Math]::Round($actual.ConfirmedRequests / $referenceAnalysis.ConfirmedRequests, 4) } else { $null } + ArsasPeakOutstanding = $actual.PeakOutstandingRequests + ReferencePeakOutstanding = $referenceAnalysis.PeakOutstandingRequests + ArsasDuplicateSemanticRequests = $actual.DuplicateSemanticRequests + ReferenceDuplicateSemanticRequests = $referenceAnalysis.DuplicateSemanticRequests + ArsasServiceCounts = $actual.ServiceCounts + ReferenceServiceCounts = $referenceAnalysis.ServiceCounts + } +} + +$result = [pscustomobject]@{ + SchemaVersion = 1 + Phase = "P0-5d" + Verdict = if ($failures.Count -eq 0) { "PASS" } else { "FAIL" } + AcceptanceFailures = @($failures) + ArsasCapture = $actual + ReferenceComparison = $comparison + ProofContract = [pscustomobject]@{ + ExactlyOneMmsRequestStream = $true + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + InvokeIdReuseWhileOutstanding = 0 + OrphanResponses = 0 + UnansweredRequestsAtCaptureEnd = 0 + PeakOutstandingMustNotExceedNegotiatedCallingLimit = $true + MaxConfirmedRequests = if ($MaxConfirmedRequests -gt 0) { $MaxConfirmedRequests } else { $null } + MaxGvaRequests = if ($MaxGvaRequests -gt 0) { $MaxGvaRequests } else { $null } + RequireNoMoreRequestsThanReference = [bool]$RequireNoMoreRequestsThanReference + } +} + +if ([string]::IsNullOrWhiteSpace($OutputJson)) { + $base = [IO.Path]::GetFileNameWithoutExtension($pcap) + $OutputJson = Join-Path ([IO.Path]::GetDirectoryName($pcap)) "P0-5D-$base-proof.json" +} +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 + +Write-Host "P0-5d physical capture proof: $($result.Verdict)" +Write-Host " association: $($actual.ClientIp) -> $($actual.ServerIp); TCP stream(s): $($actual.RequestTcpStreams -join ', ')" +Write-Host " confirmed requests: $($actual.ConfirmedRequests); peak outstanding: $($actual.PeakOutstandingRequests); negotiated calling: $($actual.NegotiatedMaxOutstandingCalling)" +Write-Host " duplicates: semantic=$($actual.DuplicateSemanticRequests), GetNameList=$($actual.DuplicateGetNameListRequests), GVA=$($actual.DuplicateGvaRequests)" +Write-Host " service budget: $($actual.ServiceCounts | ConvertTo-Json -Compress)" +if ($referenceAnalysis) { + Write-Host " reference requests: $($referenceAnalysis.ConfirmedRequests); delta=$($comparison.ConfirmedRequestDelta); ratio=$($comparison.ConfirmedRequestRatio)" +} +Write-Host " proof JSON: $OutputJson" + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + if (-not $NoFailExit) { exit 1 } +} From 9ba2fa603537315105b40986eeede70c8c895898 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:02:56 +0700 Subject: [PATCH 035/243] docs(discovery): define P0-5d physical capture proof contract --- docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md | 94 ++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md diff --git a/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md b/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md new file mode 100644 index 000000000..459d90e7d --- /dev/null +++ b/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md @@ -0,0 +1,94 @@ +# P0-5d — Physical Capture Request-Budget & Duplicate-Wire Proof + +Status: field-capture evidence lane for ARSAS PR #324. This phase does not change MMS discovery semantics. It proves, from a fresh PCAP, that the P0-5b engine request-budget work and P0-5c ARSAS association single-flight are actually visible on the wire. + +## Immutable test baseline + +- ARSAS branch: `test/smart-ied-discovery-pr134` +- ARIEC61850 PR: #134 +- Engine commit: `4467124775d8d9d76f3db194f9fbfd97144767a8` +- Engine `.NET CI`: #652 passed +- ARSAS P0-5c Smart Discovery Field Capture Build: #29 passed at ARSAS commit `42c8f54177dd8f1c7570277e44cb95393427197b` + +The P0-5d verifier is additive test tooling. It does not send MMS traffic. + +## What must be captured + +Capture the complete interval from before TCP/ACSE/MMS association establishment until the first smart discovery has completed. For the clean discovery proof, do not start reporting, polling, control inspection, or command execution during the capture. + +Recommended Wireshark capture filter when the IED address is known: + +```text +host and tcp port 102 +``` + +Save the result as `.pcapng` without trimming the beginning or end of the association. + +## Wire proof contract + +`scripts/verify-smart-discovery-pcap.ps1` decodes MMS with TShark and evaluates the client-to-server confirmed-request stream. A P0-5d PASS requires: + +1. exactly one TCP stream carrying client MMS confirmed requests; +2. zero repeated semantic confirmed requests after invoke-ID is excluded from the fingerprint; +3. zero repeated GetNameList semantic requests — the proxy for a second naming sweep; +4. zero repeated GetVariableAccessAttributes semantic requests; +5. no invoke-ID reuse while the previous request is still outstanding; +6. no orphan response/error and no request left outstanding when capture ends; +7. measured peak outstanding requests does not exceed the MMS `negociatedMaxServOutstandingCalling` value when Wireshark exposes it; +8. optional explicit total-request and GVA budgets are respected; +9. optional IEDScout reference comparison is emitted from the same verifier. + +The semantic request fingerprint includes service, object class/scope, domain, item/object item identity and continuation markers. It deliberately excludes `mms.invokeID`, so the same logical request sent twice with different invoke IDs is still detected as duplicate traffic. + +## Run the proof + +From the ARSAS repository or from the field-capture artifact bundle: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.ps1 ` + -PcapPath .\ARSAS_P0-5d.pcapng +``` + +To compare the same IED against an IEDScout capture: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.ps1 ` + -PcapPath .\ARSAS_P0-5d.pcapng ` + -ReferencePcapPath .\IEDScout_DiscoveryIED.pcapng +``` + +Optional hard budgets can be imposed after the first clean same-IED run establishes the expected envelope: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.ps1 ` + -PcapPath .\ARSAS_P0-5d.pcapng ` + -MaxConfirmedRequests ` + -MaxGvaRequests +``` + +Do not use `-RequireNoMoreRequestsThanReference` as a universal correctness rule. Different valid discovery strategies can use different service mixes. It is available only for a deliberately chosen same-IED acceptance contract. + +## Output + +The verifier writes `P0-5D--proof.json` beside the ARSAS capture. The JSON contains: + +- inferred client/server endpoints and request TCP stream(s); +- confirmed request/response counts; +- counts by MMS service; +- semantic duplicate details with frame numbers; +- duplicate GetNameList and GVA counts; +- second-sweep detection; +- peak outstanding requests; +- negotiated calling limit when present; +- invoke-ID lifecycle anomalies; +- unanswered/orphan counts; +- optional ARSAS-vs-reference request-count, peak-outstanding and service-budget deltas; +- final PASS/FAIL plus every failed acceptance gate. + +Keep the raw PCAP and generated proof JSON together. The JSON is derived evidence; the PCAP remains authoritative. + +## Field acceptance for the golden relay + +For the AA1E1F06R4 comparison, P0-5d is not considered physically proven until a fresh capture made with the exact P0-5d artifact passes the wire contract and the discovered model is separately checked against the canonical semantic target used throughout PR #134. Do not transfer an older R1/R2 capture result to a newer ARSAS or engine SHA. + +The first clean P0-5d result should be used to establish an evidence-backed same-IED hard request budget. That number should then be locked in a later regression/fixture rather than guessed in protocol code. From c794ad91fa90d8724da6cedf6eadf48577b271ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:03:30 +0700 Subject: [PATCH 036/243] test(discovery): lock P0-5d wire-proof contract --- ...veryPhysicalCaptureProofRegressionTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/ARSAS.Tests/SmartDiscoveryPhysicalCaptureProofRegressionTests.cs diff --git a/tests/ARSAS.Tests/SmartDiscoveryPhysicalCaptureProofRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryPhysicalCaptureProofRegressionTests.cs new file mode 100644 index 000000000..d00be15b4 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryPhysicalCaptureProofRegressionTests.cs @@ -0,0 +1,79 @@ +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryPhysicalCaptureProofRegressionTests +{ + [Fact] + public void P05d_Verifier_FingerprintsSemanticRequestsAndRejectsDuplicateDiscoveryTraffic() + { + var verifier = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-pcap.ps1")); + + Assert.Contains("mms.confirmed_requestPDU", verifier, StringComparison.Ordinal); + Assert.Contains("mms.confirmed_responsePDU", verifier, StringComparison.Ordinal); + Assert.Contains("mms.confirmed_errorPDU", verifier, StringComparison.Ordinal); + Assert.Contains("mms.getNameList_element", verifier, StringComparison.Ordinal); + Assert.Contains("mms.getVariableAccessAttributes_element", verifier, StringComparison.Ordinal); + Assert.Contains("mms.getNamedVariableListAttributes_element", verifier, StringComparison.Ordinal); + Assert.Contains("mms.read_element", verifier, StringComparison.Ordinal); + Assert.Contains("Get-RequestFingerprint", verifier, StringComparison.Ordinal); + Assert.Contains("DuplicateSemanticRequests", verifier, StringComparison.Ordinal); + Assert.Contains("DuplicateGetNameListRequests", verifier, StringComparison.Ordinal); + Assert.Contains("DuplicateGvaRequests", verifier, StringComparison.Ordinal); + Assert.Contains("SecondGetNameListSweepDetected", verifier, StringComparison.Ordinal); + + var fingerprintStart = verifier.IndexOf("function Get-RequestFingerprint", StringComparison.Ordinal); + var fingerprintEnd = verifier.IndexOf("function Decode-MmsRows", fingerprintStart, StringComparison.Ordinal); + Assert.True(fingerprintStart >= 0 && fingerprintEnd > fingerprintStart); + var fingerprint = verifier[fingerprintStart..fingerprintEnd]; + Assert.DoesNotContain("mms.invokeID", fingerprint, StringComparison.Ordinal); + Assert.Contains("mms.domainId", fingerprint, StringComparison.Ordinal); + Assert.Contains("mms.objectClass", fingerprint, StringComparison.Ordinal); + Assert.Contains("mms.getNameList-Request_continueAfter", fingerprint, StringComparison.Ordinal); + } + + [Fact] + public void P05d_Verifier_ProvesOutstandingWindowAndCompleteAssociationCapture() + { + var verifier = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-pcap.ps1")); + + Assert.Contains("PeakOutstandingRequests", verifier, StringComparison.Ordinal); + Assert.Contains("mms.negociatedMaxServOutstandingCalling", verifier, StringComparison.Ordinal); + Assert.Contains("InvokeIdReuseWhileOutstanding", verifier, StringComparison.Ordinal); + Assert.Contains("OrphanResponses", verifier, StringComparison.Ordinal); + Assert.Contains("UnansweredRequestsAtCaptureEnd", verifier, StringComparison.Ordinal); + Assert.Contains("Expected exactly one MMS request TCP stream", verifier, StringComparison.Ordinal); + Assert.Contains("Peak outstanding", verifier, StringComparison.Ordinal); + Assert.Contains("exceeded negotiated maxOutstandingCalling", verifier, StringComparison.Ordinal); + } + + [Fact] + public void P05d_Verifier_SupportsSameIedReferenceAndExplicitBudgetGates() + { + var verifier = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-pcap.ps1")); + var contract = File.ReadAllText(FindRepoFile("docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md")); + + Assert.Contains("ReferencePcapPath", verifier, StringComparison.Ordinal); + Assert.Contains("MaxConfirmedRequests", verifier, StringComparison.Ordinal); + Assert.Contains("MaxGvaRequests", verifier, StringComparison.Ordinal); + Assert.Contains("RequireNoMoreRequestsThanReference", verifier, StringComparison.Ordinal); + Assert.Contains("ConfirmedRequestDelta", verifier, StringComparison.Ordinal); + Assert.Contains("ConfirmedRequestRatio", verifier, StringComparison.Ordinal); + Assert.Contains("P0-5D-", verifier, StringComparison.Ordinal); + Assert.Contains("The JSON is derived evidence; the PCAP remains authoritative", contract, StringComparison.Ordinal); + Assert.Contains("Do not transfer an older R1/R2 capture result", contract, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From ee06c4931073839469685a925623a17a83d041cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:03:57 +0700 Subject: [PATCH 037/243] refactor(discovery): replace P0-5d verifier with testable proof path --- scripts/verify-smart-discovery-pcap.ps1 | 417 ------------------------ 1 file changed, 417 deletions(-) delete mode 100644 scripts/verify-smart-discovery-pcap.ps1 diff --git a/scripts/verify-smart-discovery-pcap.ps1 b/scripts/verify-smart-discovery-pcap.ps1 deleted file mode 100644 index 5dba35239..000000000 --- a/scripts/verify-smart-discovery-pcap.ps1 +++ /dev/null @@ -1,417 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$PcapPath, - - [string]$ReferencePcapPath, - [string]$TsharkPath = "tshark", - [string]$ClientIp, - [string]$ServerIp, - [int]$MaxConfirmedRequests = 0, - [int]$MaxGvaRequests = 0, - [switch]$RequireNoMoreRequestsThanReference, - [string]$OutputJson, - [switch]$NoFailExit -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -function Resolve-CapturePath([string]$Path, [string]$Label) { - if ([string]::IsNullOrWhiteSpace($Path)) { return $null } - $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop - if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { - throw "$Label is not a file: $Path" - } - return $resolved.Path -} - -function Get-TsharkFieldSet { - param([string]$Executable) - - $lines = & $Executable -G fields 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "TShark field discovery failed with exit code $LASTEXITCODE. Output: $($lines -join [Environment]::NewLine)" - } - - $set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) - foreach ($line in $lines) { - $parts = [string]$line -split "`t" - if ($parts.Length -ge 3 -and $parts[0] -eq "F" -and -not [string]::IsNullOrWhiteSpace($parts[2])) { - [void]$set.Add($parts[2]) - } - } - return $set -} - -function Normalize-Cell([object]$Value) { - if ($null -eq $Value) { return "" } - return ([string]$Value).Trim() -} - -function Get-RowValue($Row, [string]$Name) { - if ($null -eq $Row) { return "" } - $property = $Row.PSObject.Properties[$Name] - if ($null -eq $property) { return "" } - return Normalize-Cell $property.Value -} - -function Get-EndpointSource($Row) { - $ipv4 = Get-RowValue $Row "ip.src" - if ($ipv4) { return $ipv4 } - return Get-RowValue $Row "ipv6.src" -} - -function Get-EndpointDestination($Row) { - $ipv4 = Get-RowValue $Row "ip.dst" - if ($ipv4) { return $ipv4 } - return Get-RowValue $Row "ipv6.dst" -} - -function Test-Present($Row, [string]$Field) { - return -not [string]::IsNullOrWhiteSpace((Get-RowValue $Row $Field)) -} - -function Get-ServiceName($Row) { - if (Test-Present $Row "mms.getNameList_element") { return "GetNameList" } - if (Test-Present $Row "mms.getVariableAccessAttributes_element") { return "GetVariableAccessAttributes" } - if (Test-Present $Row "mms.getNamedVariableListAttributes_element") { return "GetNamedVariableListAttributes" } - if (Test-Present $Row "mms.read_element") { return "Read" } - if (Test-Present $Row "mms.identify_element") { return "Identify" } - if (Test-Present $Row "mms.write_element") { return "Write" } - - $service = Get-RowValue $Row "mms.confirmedServiceRequest" - if ($service) { return "ConfirmedService:$service" } - return "UnknownConfirmedService" -} - -function Get-RequestFingerprint($Row) { - $service = Get-ServiceName $Row - $parts = [ordered]@{ - service = $service - objectClass = Get-RowValue $Row "mms.objectClass" - objectScope = Get-RowValue $Row "mms.objectScope" - domainId = Get-RowValue $Row "mms.domainId" - itemId = Get-RowValue $Row "mms.itemId" - objectItemId = Get-RowValue $Row "mms.objectName_domain_specific_itemId" - domainSpecific = Get-RowValue $Row "mms.domainSpecific" - vmdSpecific = Get-RowValue $Row "mms.vmd_specific" - variableListName = Get-RowValue $Row "mms.variableListName" - continueAfter = Get-RowValue $Row "mms.continueAfter" - getNameListContinueAfter = Get-RowValue $Row "mms.getNameList-Request_continueAfter" - nameToStartAfter = Get-RowValue $Row "mms.nameToStartAfter" - } - - return (($parts.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "|") -} - -function Decode-MmsRows { - param( - [string]$Capture, - [string]$Executable, - [System.Collections.Generic.HashSet[string]]$AvailableFields - ) - - $candidateFields = @( - "frame.number", - "frame.time_epoch", - "ip.src", - "ip.dst", - "ipv6.src", - "ipv6.dst", - "tcp.stream", - "mms.invokeID", - "mms.confirmed_requestPDU", - "mms.confirmed_responsePDU", - "mms.confirmed_errorPDU", - "mms.confirmedServiceRequest", - "mms.getNameList_element", - "mms.getVariableAccessAttributes_element", - "mms.getNamedVariableListAttributes_element", - "mms.read_element", - "mms.identify_element", - "mms.write_element", - "mms.objectClass", - "mms.objectScope", - "mms.domainId", - "mms.itemId", - "mms.objectName_domain_specific_itemId", - "mms.domainSpecific", - "mms.vmd_specific", - "mms.variableListName", - "mms.continueAfter", - "mms.getNameList-Request_continueAfter", - "mms.nameToStartAfter", - "mms.negociatedMaxServOutstandingCalling", - "mms.negociatedMaxServOutstandingCalled" - ) - - $fields = @($candidateFields | Where-Object { $AvailableFields.Contains($_) }) - foreach ($required in @("frame.number", "frame.time_epoch", "tcp.stream", "mms.invokeID", "mms.confirmed_requestPDU", "mms.confirmed_responsePDU", "mms.confirmed_errorPDU")) { - if ($fields -notcontains $required) { - throw "Installed TShark does not expose required field '$required'." - } - } - if (($fields -notcontains "ip.src") -and ($fields -notcontains "ipv6.src")) { - throw "Installed TShark exposes neither IPv4 nor IPv6 source fields." - } - - $args = @( - "-r", $Capture, - "-Y", "mms", - "-T", "fields", - "-E", "header=y", - "-E", "quote=d", - "-E", "occurrence=a", - "-E", "aggregator=," - ) - foreach ($field in $fields) { - $args += @("-e", $field) - } - - $csvLines = & $Executable @args 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "TShark failed to decode '$Capture' with exit code $LASTEXITCODE. Output: $($csvLines -join [Environment]::NewLine)" - } - if (-not $csvLines -or $csvLines.Count -lt 2) { - throw "No MMS rows were decoded from '$Capture'. Capture must include the full MMS association and discovery interval." - } - - return @($csvLines | ConvertFrom-Csv -Delimiter "`t") -} - -function Analyze-Capture { - param( - [string]$Capture, - [string]$Executable, - [System.Collections.Generic.HashSet[string]]$AvailableFields, - [string]$RequestedClientIp, - [string]$RequestedServerIp - ) - - $rows = Decode-MmsRows -Capture $Capture -Executable $Executable -AvailableFields $AvailableFields - $requestRowsAll = @($rows | Where-Object { Test-Present $_ "mms.confirmed_requestPDU" }) - if ($requestRowsAll.Count -eq 0) { - throw "No MMS confirmed-request PDU was found in '$Capture'." - } - - $client = $RequestedClientIp - $server = $RequestedServerIp - if ([string]::IsNullOrWhiteSpace($client)) { $client = Get-EndpointSource $requestRowsAll[0] } - if ([string]::IsNullOrWhiteSpace($server)) { $server = Get-EndpointDestination $requestRowsAll[0] } - if ([string]::IsNullOrWhiteSpace($client) -or [string]::IsNullOrWhiteSpace($server)) { - throw "Could not infer client/server IP endpoints from the first confirmed MMS request. Supply -ClientIp and -ServerIp explicitly." - } - - $directionRows = @($rows | Where-Object { - $src = Get-EndpointSource $_ - $dst = Get-EndpointDestination $_ - (($src -eq $client -and $dst -eq $server) -or ($src -eq $server -and $dst -eq $client)) - }) - - $requests = @($directionRows | Where-Object { - (Get-EndpointSource $_) -eq $client -and - (Get-EndpointDestination $_) -eq $server -and - (Test-Present $_ "mms.confirmed_requestPDU") - }) - $responses = @($directionRows | Where-Object { - (Get-EndpointSource $_) -eq $server -and - (Get-EndpointDestination $_) -eq $client -and - ((Test-Present $_ "mms.confirmed_responsePDU") -or (Test-Present $_ "mms.confirmed_errorPDU")) - }) - - $requestRecords = foreach ($row in $requests) { - [pscustomobject]@{ - Frame = [int](Get-RowValue $row "frame.number") - Time = [double](Get-RowValue $row "frame.time_epoch") - TcpStream = Get-RowValue $row "tcp.stream" - InvokeId = Get-RowValue $row "mms.invokeID" - Service = Get-ServiceName $row - Fingerprint = Get-RequestFingerprint $row - } - } - - $duplicateGroups = @($requestRecords | - Group-Object Fingerprint | - Where-Object Count -gt 1 | - Sort-Object Count -Descending, Name) - $duplicateRequests = [int](($duplicateGroups | ForEach-Object { $_.Count - 1 } | Measure-Object -Sum).Sum) - - $duplicateDetails = @($duplicateGroups | ForEach-Object { - $records = @($_.Group | Sort-Object Frame) - [pscustomobject]@{ - Service = $records[0].Service - DuplicateAttempts = $_.Count - 1 - Frames = @($records.Frame) - Fingerprint = $_.Name - } - }) - - $serviceCounts = [ordered]@{} - foreach ($group in ($requestRecords | Group-Object Service | Sort-Object Name)) { - $serviceCounts[$group.Name] = $group.Count - } - - $events = @() - foreach ($row in $requests) { - $events += [pscustomobject]@{ - Frame = [int](Get-RowValue $row "frame.number") - Kind = "request" - InvokeId = Get-RowValue $row "mms.invokeID" - } - } - foreach ($row in $responses) { - $events += [pscustomobject]@{ - Frame = [int](Get-RowValue $row "frame.number") - Kind = "response" - InvokeId = Get-RowValue $row "mms.invokeID" - } - } - - $outstanding = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) - $peakOutstanding = 0 - $invokeReuseWhileOutstanding = 0 - $orphanResponses = 0 - foreach ($event in ($events | Sort-Object Frame)) { - if ([string]::IsNullOrWhiteSpace($event.InvokeId)) { continue } - if ($event.Kind -eq "request") { - if (-not $outstanding.Add($event.InvokeId)) { - $invokeReuseWhileOutstanding++ - } - $peakOutstanding = [Math]::Max($peakOutstanding, $outstanding.Count) - } else { - if (-not $outstanding.Remove($event.InvokeId)) { - $orphanResponses++ - } - } - } - - $negotiatedCandidates = @($directionRows | ForEach-Object { - Get-RowValue $_ "mms.negociatedMaxServOutstandingCalling" - } | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }) - $negotiatedCalling = if ($negotiatedCandidates.Count -gt 0) { $negotiatedCandidates[0] } else { $null } - - $requestStreams = @($requestRecords.TcpStream | Where-Object { $_ } | Sort-Object -Unique) - $getNameListDuplicates = @($duplicateDetails | Where-Object Service -eq "GetNameList") - $gvaDuplicates = @($duplicateDetails | Where-Object Service -eq "GetVariableAccessAttributes") - - return [pscustomobject]@{ - Capture = $Capture - ClientIp = $client - ServerIp = $server - RequestTcpStreams = $requestStreams - ConfirmedRequests = $requestRecords.Count - ConfirmedResponsesOrErrors = $responses.Count - ServiceCounts = [pscustomobject]$serviceCounts - DuplicateSemanticRequests = $duplicateRequests - DuplicateGetNameListRequests = [int](($getNameListDuplicates | ForEach-Object DuplicateAttempts | Measure-Object -Sum).Sum) - DuplicateGvaRequests = [int](($gvaDuplicates | ForEach-Object DuplicateAttempts | Measure-Object -Sum).Sum) - DuplicateDetails = $duplicateDetails - SecondGetNameListSweepDetected = $getNameListDuplicates.Count -gt 0 - PeakOutstandingRequests = $peakOutstanding - NegotiatedMaxOutstandingCalling = $negotiatedCalling - InvokeIdReuseWhileOutstanding = $invokeReuseWhileOutstanding - OrphanResponses = $orphanResponses - UnansweredRequestsAtCaptureEnd = $outstanding.Count - } -} - -$pcap = Resolve-CapturePath $PcapPath "P0-5d capture" -$reference = Resolve-CapturePath $ReferencePcapPath "Reference capture" - -try { - $tsharkCommand = Get-Command $TsharkPath -ErrorAction Stop -} catch { - throw "TShark was not found. Install Wireshark/TShark or supply -TsharkPath. $($_.Exception.Message)" -} - -$fieldSet = Get-TsharkFieldSet -Executable $tsharkCommand.Source -$actual = Analyze-Capture -Capture $pcap -Executable $tsharkCommand.Source -AvailableFields $fieldSet -RequestedClientIp $ClientIp -RequestedServerIp $ServerIp -$referenceAnalysis = $null -if ($reference) { - $referenceAnalysis = Analyze-Capture -Capture $reference -Executable $tsharkCommand.Source -AvailableFields $fieldSet -RequestedClientIp "" -RequestedServerIp "" -} - -$failures = [System.Collections.Generic.List[string]]::new() -if ($actual.RequestTcpStreams.Count -ne 1) { $failures.Add("Expected exactly one MMS request TCP stream; observed $($actual.RequestTcpStreams.Count).") } -if ($actual.DuplicateSemanticRequests -ne 0) { $failures.Add("Duplicate semantic confirmed requests detected: $($actual.DuplicateSemanticRequests).") } -if ($actual.SecondGetNameListSweepDetected) { $failures.Add("A duplicate GetNameList semantic request was observed; this is evidence of a second/repeated naming sweep.") } -if ($actual.DuplicateGvaRequests -ne 0) { $failures.Add("Duplicate GetVariableAccessAttributes semantic requests detected: $($actual.DuplicateGvaRequests).") } -if ($actual.InvokeIdReuseWhileOutstanding -ne 0) { $failures.Add("Invoke-ID reuse while the previous request was still outstanding: $($actual.InvokeIdReuseWhileOutstanding).") } -if ($actual.OrphanResponses -ne 0) { $failures.Add("Responses/errors without an observed matching request: $($actual.OrphanResponses). Capture may be incomplete.") } -if ($actual.UnansweredRequestsAtCaptureEnd -ne 0) { $failures.Add("Confirmed requests still outstanding at capture end: $($actual.UnansweredRequestsAtCaptureEnd). Capture may have ended too early.") } -if ($null -ne $actual.NegotiatedMaxOutstandingCalling -and $actual.PeakOutstandingRequests -gt $actual.NegotiatedMaxOutstandingCalling) { - $failures.Add("Peak outstanding $($actual.PeakOutstandingRequests) exceeded negotiated maxOutstandingCalling $($actual.NegotiatedMaxOutstandingCalling).") -} -if ($MaxConfirmedRequests -gt 0 -and $actual.ConfirmedRequests -gt $MaxConfirmedRequests) { - $failures.Add("Confirmed request budget exceeded: $($actual.ConfirmedRequests) > $MaxConfirmedRequests.") -} -$gvaCount = 0 -if ($actual.ServiceCounts.PSObject.Properties["GetVariableAccessAttributes"]) { - $gvaCount = [int]$actual.ServiceCounts.GetVariableAccessAttributes -} -if ($MaxGvaRequests -gt 0 -and $gvaCount -gt $MaxGvaRequests) { - $failures.Add("GVA request budget exceeded: $gvaCount > $MaxGvaRequests.") -} -if ($RequireNoMoreRequestsThanReference -and $referenceAnalysis -and $actual.ConfirmedRequests -gt $referenceAnalysis.ConfirmedRequests) { - $failures.Add("ARSAS confirmed-request count $($actual.ConfirmedRequests) exceeds reference count $($referenceAnalysis.ConfirmedRequests).") -} - -$comparison = $null -if ($referenceAnalysis) { - $comparison = [pscustomobject]@{ - ReferenceCapture = $referenceAnalysis.Capture - ArsasConfirmedRequests = $actual.ConfirmedRequests - ReferenceConfirmedRequests = $referenceAnalysis.ConfirmedRequests - ConfirmedRequestDelta = $actual.ConfirmedRequests - $referenceAnalysis.ConfirmedRequests - ConfirmedRequestRatio = if ($referenceAnalysis.ConfirmedRequests -gt 0) { [Math]::Round($actual.ConfirmedRequests / $referenceAnalysis.ConfirmedRequests, 4) } else { $null } - ArsasPeakOutstanding = $actual.PeakOutstandingRequests - ReferencePeakOutstanding = $referenceAnalysis.PeakOutstandingRequests - ArsasDuplicateSemanticRequests = $actual.DuplicateSemanticRequests - ReferenceDuplicateSemanticRequests = $referenceAnalysis.DuplicateSemanticRequests - ArsasServiceCounts = $actual.ServiceCounts - ReferenceServiceCounts = $referenceAnalysis.ServiceCounts - } -} - -$result = [pscustomobject]@{ - SchemaVersion = 1 - Phase = "P0-5d" - Verdict = if ($failures.Count -eq 0) { "PASS" } else { "FAIL" } - AcceptanceFailures = @($failures) - ArsasCapture = $actual - ReferenceComparison = $comparison - ProofContract = [pscustomobject]@{ - ExactlyOneMmsRequestStream = $true - DuplicateSemanticRequests = 0 - DuplicateGetNameListRequests = 0 - DuplicateGvaRequests = 0 - InvokeIdReuseWhileOutstanding = 0 - OrphanResponses = 0 - UnansweredRequestsAtCaptureEnd = 0 - PeakOutstandingMustNotExceedNegotiatedCallingLimit = $true - MaxConfirmedRequests = if ($MaxConfirmedRequests -gt 0) { $MaxConfirmedRequests } else { $null } - MaxGvaRequests = if ($MaxGvaRequests -gt 0) { $MaxGvaRequests } else { $null } - RequireNoMoreRequestsThanReference = [bool]$RequireNoMoreRequestsThanReference - } -} - -if ([string]::IsNullOrWhiteSpace($OutputJson)) { - $base = [IO.Path]::GetFileNameWithoutExtension($pcap) - $OutputJson = Join-Path ([IO.Path]::GetDirectoryName($pcap)) "P0-5D-$base-proof.json" -} -$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 - -Write-Host "P0-5d physical capture proof: $($result.Verdict)" -Write-Host " association: $($actual.ClientIp) -> $($actual.ServerIp); TCP stream(s): $($actual.RequestTcpStreams -join ', ')" -Write-Host " confirmed requests: $($actual.ConfirmedRequests); peak outstanding: $($actual.PeakOutstandingRequests); negotiated calling: $($actual.NegotiatedMaxOutstandingCalling)" -Write-Host " duplicates: semantic=$($actual.DuplicateSemanticRequests), GetNameList=$($actual.DuplicateGetNameListRequests), GVA=$($actual.DuplicateGvaRequests)" -Write-Host " service budget: $($actual.ServiceCounts | ConvertTo-Json -Compress)" -if ($referenceAnalysis) { - Write-Host " reference requests: $($referenceAnalysis.ConfirmedRequests); delta=$($comparison.ConfirmedRequestDelta); ratio=$($comparison.ConfirmedRequestRatio)" -} -Write-Host " proof JSON: $OutputJson" - -if ($failures.Count -gt 0) { - foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } - if (-not $NoFailExit) { exit 1 } -} From bc20bd6f6aa2e815344df1a4e2a55120209538c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:04:40 +0700 Subject: [PATCH 038/243] test(discovery): add testable P0-5d physical PCAP verifier --- scripts/verify-smart-discovery-pcap.ps1 | 337 ++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 scripts/verify-smart-discovery-pcap.ps1 diff --git a/scripts/verify-smart-discovery-pcap.ps1 b/scripts/verify-smart-discovery-pcap.ps1 new file mode 100644 index 000000000..cf94552ec --- /dev/null +++ b/scripts/verify-smart-discovery-pcap.ps1 @@ -0,0 +1,337 @@ +param( + [string]$PcapPath, + [string]$DecodedRowsPath, + [string]$ReferencePcapPath, + [string]$TsharkPath = "tshark", + [string]$ClientIp, + [string]$ServerIp, + [int]$MaxConfirmedRequests = 0, + [int]$MaxGvaRequests = 0, + [switch]$RequireNoMoreRequestsThanReference, + [string]$OutputJson, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-OptionalFile([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { + throw "$Label is not a file: $Path" + } + return $resolved.Path +} + +function Normalize-Cell([object]$Value) { + if ($null -eq $Value) { return "" } + return ([string]$Value).Trim() +} + +function Get-RowValue($Row, [string]$Name) { + if ($null -eq $Row) { return "" } + $property = $Row.PSObject.Properties[$Name] + if ($null -eq $property) { return "" } + return Normalize-Cell $property.Value +} + +function Get-EndpointSource($Row) { + $ipv4 = Get-RowValue $Row "ip.src" + if ($ipv4) { return $ipv4 } + return Get-RowValue $Row "ipv6.src" +} + +function Get-EndpointDestination($Row) { + $ipv4 = Get-RowValue $Row "ip.dst" + if ($ipv4) { return $ipv4 } + return Get-RowValue $Row "ipv6.dst" +} + +function Test-Present($Row, [string]$Field) { + return -not [string]::IsNullOrWhiteSpace((Get-RowValue $Row $Field)) +} + +function Get-ServiceName($Row) { + if (Test-Present $Row "mms.getNameList_element") { return "GetNameList" } + if (Test-Present $Row "mms.getVariableAccessAttributes_element") { return "GetVariableAccessAttributes" } + if (Test-Present $Row "mms.getNamedVariableListAttributes_element") { return "GetNamedVariableListAttributes" } + if (Test-Present $Row "mms.read_element") { return "Read" } + if (Test-Present $Row "mms.identify_element") { return "Identify" } + if (Test-Present $Row "mms.write_element") { return "Write" } + + $service = Get-RowValue $Row "mms.confirmedServiceRequest" + if ($service) { return "ConfirmedService:$service" } + return "UnknownConfirmedService" +} + +function Get-RequestFingerprint($Row) { + # Invoke-ID is deliberately excluded. Reissuing the same logical request with a + # different invoke-ID must still be detected as duplicate wire work. + $parts = [ordered]@{ + service = Get-ServiceName $Row + objectClass = Get-RowValue $Row "mms.objectClass" + objectScope = Get-RowValue $Row "mms.objectScope" + domainId = Get-RowValue $Row "mms.domainId" + itemId = Get-RowValue $Row "mms.itemId" + objectItemId = Get-RowValue $Row "mms.objectName_domain_specific_itemId" + domainSpecific = Get-RowValue $Row "mms.domainSpecific" + vmdSpecific = Get-RowValue $Row "mms.vmd_specific" + variableListName = Get-RowValue $Row "mms.variableListName" + continueAfter = Get-RowValue $Row "mms.continueAfter" + getNameListContinueAfter = Get-RowValue $Row "mms.getNameList-Request_continueAfter" + nameToStartAfter = Get-RowValue $Row "mms.nameToStartAfter" + } + return (($parts.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "|") +} + +function Get-TsharkFieldSet([string]$Executable) { + $lines = & $Executable -G fields 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "TShark field discovery failed with exit code $LASTEXITCODE." + } + + $set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($line in $lines) { + $parts = [string]$line -split "`t" + if ($parts.Length -ge 3 -and $parts[0] -eq "F" -and $parts[2]) { + [void]$set.Add($parts[2]) + } + } + return $set +} + +function Decode-MmsRows([string]$Capture, [string]$Executable, [System.Collections.Generic.HashSet[string]]$AvailableFields) { + $candidateFields = @( + "frame.number", "frame.time_epoch", "ip.src", "ip.dst", "ipv6.src", "ipv6.dst", "tcp.stream", + "mms.invokeID", "mms.confirmed_requestPDU", "mms.confirmed_responsePDU", "mms.confirmed_errorPDU", + "mms.confirmedServiceRequest", "mms.getNameList_element", "mms.getVariableAccessAttributes_element", + "mms.getNamedVariableListAttributes_element", "mms.read_element", "mms.identify_element", "mms.write_element", + "mms.objectClass", "mms.objectScope", "mms.domainId", "mms.itemId", "mms.objectName_domain_specific_itemId", + "mms.domainSpecific", "mms.vmd_specific", "mms.variableListName", "mms.continueAfter", + "mms.getNameList-Request_continueAfter", "mms.nameToStartAfter", + "mms.negociatedMaxServOutstandingCalling", "mms.negociatedMaxServOutstandingCalled" + ) + + $fields = @($candidateFields | Where-Object { $AvailableFields.Contains($_) }) + foreach ($required in @("frame.number", "frame.time_epoch", "tcp.stream", "mms.invokeID", "mms.confirmed_requestPDU", "mms.confirmed_responsePDU", "mms.confirmed_errorPDU")) { + if ($fields -notcontains $required) { throw "Installed TShark does not expose required field '$required'." } + } + + $args = @("-r", $Capture, "-Y", "mms", "-T", "fields", "-E", "header=y", "-E", "quote=d", "-E", "occurrence=a", "-E", "aggregator=,") + foreach ($field in $fields) { $args += @("-e", $field) } + + $lines = & $Executable @args 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "TShark failed to decode '$Capture' with exit code $LASTEXITCODE. Output: $($lines -join [Environment]::NewLine)" + } + if (-not $lines -or $lines.Count -lt 2) { + throw "No MMS rows were decoded from '$Capture'." + } + return @($lines | ConvertFrom-Csv -Delimiter "`t") +} + +function Import-DecodedRows([string]$Path) { + $rows = @(Import-Csv -LiteralPath $Path -Delimiter "`t") + if ($rows.Count -eq 0) { throw "Decoded-row fixture '$Path' is empty." } + return $rows +} + +function Analyze-Rows($Rows, [string]$Label, [string]$RequestedClientIp, [string]$RequestedServerIp) { + $requestRowsAll = @($Rows | Where-Object { Test-Present $_ "mms.confirmed_requestPDU" }) + if ($requestRowsAll.Count -eq 0) { throw "No MMS confirmed-request PDU was found in '$Label'." } + + $client = $RequestedClientIp + $server = $RequestedServerIp + if ([string]::IsNullOrWhiteSpace($client)) { $client = Get-EndpointSource $requestRowsAll[0] } + if ([string]::IsNullOrWhiteSpace($server)) { $server = Get-EndpointDestination $requestRowsAll[0] } + if (-not $client -or -not $server) { throw "Could not infer client/server endpoints for '$Label'." } + + $directionRows = @($Rows | Where-Object { + $src = Get-EndpointSource $_ + $dst = Get-EndpointDestination $_ + (($src -eq $client -and $dst -eq $server) -or ($src -eq $server -and $dst -eq $client)) + }) + $requests = @($directionRows | Where-Object { + (Get-EndpointSource $_) -eq $client -and (Get-EndpointDestination $_) -eq $server -and (Test-Present $_ "mms.confirmed_requestPDU") + }) + $responses = @($directionRows | Where-Object { + (Get-EndpointSource $_) -eq $server -and (Get-EndpointDestination $_) -eq $client -and + ((Test-Present $_ "mms.confirmed_responsePDU") -or (Test-Present $_ "mms.confirmed_errorPDU")) + }) + + $requestRecords = @($requests | ForEach-Object { + [pscustomobject]@{ + Frame = [int](Get-RowValue $_ "frame.number") + TcpStream = Get-RowValue $_ "tcp.stream" + InvokeId = Get-RowValue $_ "mms.invokeID" + Service = Get-ServiceName $_ + Fingerprint = Get-RequestFingerprint $_ + } + }) + + $duplicateGroups = @($requestRecords | Group-Object Fingerprint | Where-Object Count -gt 1 | + Sort-Object -Property @{ Expression = "Count"; Descending = $true }, @{ Expression = "Name"; Descending = $false }) + $duplicateDetails = @($duplicateGroups | ForEach-Object { + $records = @($_.Group | Sort-Object Frame) + [pscustomobject]@{ + Service = $records[0].Service + DuplicateAttempts = $_.Count - 1 + Frames = @($records.Frame) + Fingerprint = $_.Name + } + }) + $duplicateRequests = [int](($duplicateDetails | Measure-Object DuplicateAttempts -Sum).Sum) + + $serviceCounts = [ordered]@{} + foreach ($group in ($requestRecords | Group-Object Service | Sort-Object Name)) { $serviceCounts[$group.Name] = $group.Count } + + $events = @() + foreach ($row in $requests) { + $events += [pscustomobject]@{ Frame = [int](Get-RowValue $row "frame.number"); Kind = "request"; InvokeId = Get-RowValue $row "mms.invokeID" } + } + foreach ($row in $responses) { + $events += [pscustomobject]@{ Frame = [int](Get-RowValue $row "frame.number"); Kind = "response"; InvokeId = Get-RowValue $row "mms.invokeID" } + } + + $outstanding = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $peakOutstanding = 0 + $invokeReuseWhileOutstanding = 0 + $orphanResponses = 0 + foreach ($event in ($events | Sort-Object Frame)) { + if (-not $event.InvokeId) { continue } + if ($event.Kind -eq "request") { + if (-not $outstanding.Add($event.InvokeId)) { $invokeReuseWhileOutstanding++ } + $peakOutstanding = [Math]::Max($peakOutstanding, $outstanding.Count) + } elseif (-not $outstanding.Remove($event.InvokeId)) { + $orphanResponses++ + } + } + + $negotiated = @($directionRows | ForEach-Object { Get-RowValue $_ "mms.negociatedMaxServOutstandingCalling" } | + Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }) + $negotiatedCalling = if ($negotiated.Count -gt 0) { $negotiated[0] } else { $null } + $requestStreams = @($requestRecords.TcpStream | Where-Object { $_ } | Sort-Object -Unique) + $gnlDuplicates = @($duplicateDetails | Where-Object Service -eq "GetNameList") + $gvaDuplicates = @($duplicateDetails | Where-Object Service -eq "GetVariableAccessAttributes") + + return [pscustomobject]@{ + Capture = $Label + ClientIp = $client + ServerIp = $server + RequestTcpStreams = $requestStreams + ConfirmedRequests = $requestRecords.Count + ConfirmedResponsesOrErrors = $responses.Count + ServiceCounts = [pscustomobject]$serviceCounts + DuplicateSemanticRequests = $duplicateRequests + DuplicateGetNameListRequests = [int](($gnlDuplicates | Measure-Object DuplicateAttempts -Sum).Sum) + DuplicateGvaRequests = [int](($gvaDuplicates | Measure-Object DuplicateAttempts -Sum).Sum) + DuplicateDetails = $duplicateDetails + SecondGetNameListSweepDetected = $gnlDuplicates.Count -gt 0 + PeakOutstandingRequests = $peakOutstanding + NegotiatedMaxOutstandingCalling = $negotiatedCalling + InvokeIdReuseWhileOutstanding = $invokeReuseWhileOutstanding + OrphanResponses = $orphanResponses + UnansweredRequestsAtCaptureEnd = $outstanding.Count + } +} + +function Analyze-Pcap([string]$Capture, [string]$RequestedClientIp, [string]$RequestedServerIp) { + $command = Get-Command $TsharkPath -ErrorAction Stop + $fields = Get-TsharkFieldSet $command.Source + $rows = Decode-MmsRows $Capture $command.Source $fields + return Analyze-Rows $rows $Capture $RequestedClientIp $RequestedServerIp +} + +$pcap = Resolve-OptionalFile $PcapPath "P0-5d capture" +$decoded = Resolve-OptionalFile $DecodedRowsPath "P0-5d decoded rows" +$reference = Resolve-OptionalFile $ReferencePcapPath "Reference capture" +if (($null -eq $pcap) -eq ($null -eq $decoded)) { + throw "Supply exactly one of -PcapPath or -DecodedRowsPath." +} + +$actual = if ($decoded) { + Analyze-Rows (Import-DecodedRows $decoded) $decoded $ClientIp $ServerIp +} else { + Analyze-Pcap $pcap $ClientIp $ServerIp +} +$referenceAnalysis = if ($reference) { Analyze-Pcap $reference "" "" } else { $null } + +$failures = [System.Collections.Generic.List[string]]::new() +if ($actual.RequestTcpStreams.Count -ne 1) { $failures.Add("Expected exactly one MMS request TCP stream; observed $($actual.RequestTcpStreams.Count).") } +if ($actual.DuplicateSemanticRequests -ne 0) { $failures.Add("Duplicate semantic confirmed requests detected: $($actual.DuplicateSemanticRequests).") } +if ($actual.SecondGetNameListSweepDetected) { $failures.Add("A duplicate GetNameList semantic request was observed; this is evidence of a second/repeated naming sweep.") } +if ($actual.DuplicateGvaRequests -ne 0) { $failures.Add("Duplicate GetVariableAccessAttributes semantic requests detected: $($actual.DuplicateGvaRequests).") } +if ($actual.InvokeIdReuseWhileOutstanding -ne 0) { $failures.Add("Invoke-ID reuse while the previous request was still outstanding: $($actual.InvokeIdReuseWhileOutstanding).") } +if ($actual.OrphanResponses -ne 0) { $failures.Add("Responses/errors without an observed matching request: $($actual.OrphanResponses). Capture may be incomplete.") } +if ($actual.UnansweredRequestsAtCaptureEnd -ne 0) { $failures.Add("Confirmed requests still outstanding at capture end: $($actual.UnansweredRequestsAtCaptureEnd). Capture may have ended too early.") } +if ($null -ne $actual.NegotiatedMaxOutstandingCalling -and $actual.PeakOutstandingRequests -gt $actual.NegotiatedMaxOutstandingCalling) { + $failures.Add("Peak outstanding $($actual.PeakOutstandingRequests) exceeded negotiated maxOutstandingCalling $($actual.NegotiatedMaxOutstandingCalling).") +} +if ($MaxConfirmedRequests -gt 0 -and $actual.ConfirmedRequests -gt $MaxConfirmedRequests) { + $failures.Add("Confirmed request budget exceeded: $($actual.ConfirmedRequests) > $MaxConfirmedRequests.") +} +$gvaCount = 0 +if ($actual.ServiceCounts.PSObject.Properties["GetVariableAccessAttributes"]) { $gvaCount = [int]$actual.ServiceCounts.GetVariableAccessAttributes } +if ($MaxGvaRequests -gt 0 -and $gvaCount -gt $MaxGvaRequests) { $failures.Add("GVA request budget exceeded: $gvaCount > $MaxGvaRequests.") } +if ($RequireNoMoreRequestsThanReference -and $referenceAnalysis -and $actual.ConfirmedRequests -gt $referenceAnalysis.ConfirmedRequests) { + $failures.Add("ARSAS confirmed-request count $($actual.ConfirmedRequests) exceeds reference count $($referenceAnalysis.ConfirmedRequests).") +} + +$comparison = if ($referenceAnalysis) { + [pscustomobject]@{ + ReferenceCapture = $referenceAnalysis.Capture + ArsasConfirmedRequests = $actual.ConfirmedRequests + ReferenceConfirmedRequests = $referenceAnalysis.ConfirmedRequests + ConfirmedRequestDelta = $actual.ConfirmedRequests - $referenceAnalysis.ConfirmedRequests + ConfirmedRequestRatio = if ($referenceAnalysis.ConfirmedRequests -gt 0) { [Math]::Round($actual.ConfirmedRequests / $referenceAnalysis.ConfirmedRequests, 4) } else { $null } + ArsasPeakOutstanding = $actual.PeakOutstandingRequests + ReferencePeakOutstanding = $referenceAnalysis.PeakOutstandingRequests + ArsasDuplicateSemanticRequests = $actual.DuplicateSemanticRequests + ReferenceDuplicateSemanticRequests = $referenceAnalysis.DuplicateSemanticRequests + ArsasServiceCounts = $actual.ServiceCounts + ReferenceServiceCounts = $referenceAnalysis.ServiceCounts + } +} else { $null } + +$result = [pscustomobject]@{ + SchemaVersion = 1 + Phase = "P0-5d" + Verdict = if ($failures.Count -eq 0) { "PASS" } else { "FAIL" } + AcceptanceFailures = @($failures) + ArsasCapture = $actual + ReferenceComparison = $comparison + ProofContract = [pscustomobject]@{ + ExactlyOneMmsRequestStream = $true + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + InvokeIdReuseWhileOutstanding = 0 + OrphanResponses = 0 + UnansweredRequestsAtCaptureEnd = 0 + PeakOutstandingMustNotExceedNegotiatedCallingLimit = $true + MaxConfirmedRequests = if ($MaxConfirmedRequests -gt 0) { $MaxConfirmedRequests } else { $null } + MaxGvaRequests = if ($MaxGvaRequests -gt 0) { $MaxGvaRequests } else { $null } + RequireNoMoreRequestsThanReference = [bool]$RequireNoMoreRequestsThanReference + } +} + +if ([string]::IsNullOrWhiteSpace($OutputJson)) { + $sourcePath = if ($pcap) { $pcap } else { $decoded } + $base = [IO.Path]::GetFileNameWithoutExtension($sourcePath) + $OutputJson = Join-Path ([IO.Path]::GetDirectoryName($sourcePath)) "P0-5D-$base-proof.json" +} +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 + +Write-Host "P0-5d physical capture proof: $($result.Verdict)" +Write-Host " association: $($actual.ClientIp) -> $($actual.ServerIp); TCP stream(s): $($actual.RequestTcpStreams -join ', ')" +Write-Host " confirmed requests: $($actual.ConfirmedRequests); peak outstanding: $($actual.PeakOutstandingRequests); negotiated calling: $($actual.NegotiatedMaxOutstandingCalling)" +Write-Host " duplicates: semantic=$($actual.DuplicateSemanticRequests), GetNameList=$($actual.DuplicateGetNameListRequests), GVA=$($actual.DuplicateGvaRequests)" +Write-Host " service budget: $($actual.ServiceCounts | ConvertTo-Json -Compress)" +if ($comparison) { Write-Host " reference requests: $($comparison.ReferenceConfirmedRequests); delta=$($comparison.ConfirmedRequestDelta); ratio=$($comparison.ConfirmedRequestRatio)" } +Write-Host " proof JSON: $OutputJson" + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + if (-not $NoFailExit) { exit 1 } +} From a103f2f9bcd9fab5e06e66043abc0ae55690360e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:05:28 +0700 Subject: [PATCH 039/243] docs(discovery): keep P0-5d proof contract clean-room safe --- docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md b/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md index 459d90e7d..b3573d47e 100644 --- a/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md +++ b/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md @@ -16,7 +16,7 @@ The P0-5d verifier is additive test tooling. It does not send MMS traffic. Capture the complete interval from before TCP/ACSE/MMS association establishment until the first smart discovery has completed. For the clean discovery proof, do not start reporting, polling, control inspection, or command execution during the capture. -Recommended Wireshark capture filter when the IED address is known: +Recommended packet-capture filter when the IED address is known: ```text host and tcp port 102 @@ -34,9 +34,9 @@ Save the result as `.pcapng` without trimming the beginning or end of the associ 4. zero repeated GetVariableAccessAttributes semantic requests; 5. no invoke-ID reuse while the previous request is still outstanding; 6. no orphan response/error and no request left outstanding when capture ends; -7. measured peak outstanding requests does not exceed the MMS `negociatedMaxServOutstandingCalling` value when Wireshark exposes it; +7. measured peak outstanding requests does not exceed the MMS `negociatedMaxServOutstandingCalling` value when the decoder exposes it; 8. optional explicit total-request and GVA budgets are respected; -9. optional IEDScout reference comparison is emitted from the same verifier. +9. optional same-IED reference-capture comparison is emitted from the same verifier. The semantic request fingerprint includes service, object class/scope, domain, item/object item identity and continuation markers. It deliberately excludes `mms.invokeID`, so the same logical request sent twice with different invoke IDs is still detected as duplicate traffic. @@ -49,12 +49,12 @@ powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.p -PcapPath .\ARSAS_P0-5d.pcapng ``` -To compare the same IED against an IEDScout capture: +To compare the same IED against a trusted reference-tool capture: ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.ps1 ` -PcapPath .\ARSAS_P0-5d.pcapng ` - -ReferencePcapPath .\IEDScout_DiscoveryIED.pcapng + -ReferencePcapPath .\REFERENCE_DiscoveryIED.pcapng ``` Optional hard budgets can be imposed after the first clean same-IED run establishes the expected envelope: @@ -87,6 +87,10 @@ The verifier writes `P0-5D--proof.json` beside the ARSAS capture. Keep the raw PCAP and generated proof JSON together. The JSON is derived evidence; the PCAP remains authoritative. +## Regression mode + +CI may use `-DecodedRowsPath` with a synthetic tab-separated decoder fixture. This bypasses TShark only to execute the duplicate/outstanding proof algorithm deterministically. It is not accepted as physical relay evidence. + ## Field acceptance for the golden relay For the AA1E1F06R4 comparison, P0-5d is not considered physically proven until a fresh capture made with the exact P0-5d artifact passes the wire contract and the discovered model is separately checked against the canonical semantic target used throughout PR #134. Do not transfer an older R1/R2 capture result to a newer ARSAS or engine SHA. From 218be6bffdb679c5ae5cc6d99461c1d7cd4b7320 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:06:19 +0700 Subject: [PATCH 040/243] ci(discovery): execute P0-5d wire-proof regressions --- .../smart-discovery-capture-build.yml | 100 +++++++++++++++++- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index aab770aa5..732bbef2e 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -73,6 +73,94 @@ jobs: throw 'P0-5c critical path regressed to caller-cancellable GVA, reflection fallback, or second full finalization.' } + - name: Execute P0-5d wire-proof regressions + shell: powershell + run: | + $verifier = ".\ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1" + $contract = ".\ArIED61850Tester\docs\P0-5D_PHYSICAL_CAPTURE_PROOF.md" + $regression = ".\ArIED61850Tester\tests\ARSAS.Tests\SmartDiscoveryPhysicalCaptureProofRegressionTests.cs" + foreach ($required in @($verifier, $contract, $regression)) { + if (-not (Test-Path $required -PathType Leaf)) { throw "P0-5d source missing: $required" } + } + + $tokens = $null + $parseErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile($verifier, [ref]$tokens, [ref]$parseErrors) | Out-Null + if ($parseErrors.Count -ne 0) { + throw "P0-5d verifier has PowerShell parse errors: $($parseErrors | ForEach-Object Message -join '; ')" + } + + $source = Get-Content $verifier -Raw + foreach ($token in @( + 'Get-RequestFingerprint', + 'DuplicateSemanticRequests', + 'DuplicateGetNameListRequests', + 'DuplicateGvaRequests', + 'SecondGetNameListSweepDetected', + 'PeakOutstandingRequests', + 'negociatedMaxServOutstandingCalling', + 'ReferencePcapPath', + 'DecodedRowsPath')) { + if ($source -notmatch [regex]::Escape($token)) { throw "P0-5d verifier contract missing: $token" } + } + + New-Item -ItemType Directory -Force .\ArIED61850Tester\TestResults | Out-Null + function New-Row($frame, $src, $dst, $invoke, $request, $response, $gnl, $gva, $domain, $item, $negotiated) { + [pscustomobject][ordered]@{ + 'frame.number' = $frame + 'frame.time_epoch' = "1.$frame" + 'ip.src' = $src + 'ip.dst' = $dst + 'tcp.stream' = '0' + 'mms.invokeID' = $invoke + 'mms.confirmed_requestPDU' = $request + 'mms.confirmed_responsePDU' = $response + 'mms.confirmed_errorPDU' = '' + 'mms.confirmedServiceRequest' = '' + 'mms.getNameList_element' = $gnl + 'mms.getVariableAccessAttributes_element' = $gva + 'mms.getNamedVariableListAttributes_element' = '' + 'mms.read_element' = '' + 'mms.objectClass' = if ($gnl) { '9' } else { '' } + 'mms.objectScope' = if ($gnl) { '1' } else { '' } + 'mms.domainId' = $domain + 'mms.objectName_domain_specific_itemId' = $item + 'mms.getNameList-Request_continueAfter' = '' + 'mms.negociatedMaxServOutstandingCalling' = $negotiated + } + } + + $client = '192.0.2.10' + $server = '192.0.2.20' + $passRows = @( + (New-Row 1 $server $client '' '' '' '' '' '' '' '10'), + (New-Row 2 $client $server '1' '1' '' '1' '' 'LD0' '' ''), + (New-Row 3 $client $server '2' '1' '' '' '1' 'LD0' 'LLN0$ST$Mod' ''), + (New-Row 4 $server $client '2' '' '1' '' '' '' '' ''), + (New-Row 5 $server $client '1' '' '1' '' '' '' '' '') + ) + $passFixture = '.\ArIED61850Tester\TestResults\p0-5d-pass.tsv' + $passJson = '.\ArIED61850Tester\TestResults\P0-5D-fixture-pass.json' + $passRows | Export-Csv -Delimiter "`t" -NoTypeInformation -Encoding utf8 $passFixture + & $verifier -DecodedRowsPath $passFixture -OutputJson $passJson + if ($LASTEXITCODE -ne 0) { throw 'P0-5d PASS fixture was rejected.' } + $pass = Get-Content $passJson -Raw | ConvertFrom-Json + if ($pass.Verdict -ne 'PASS' -or $pass.ArsasCapture.PeakOutstandingRequests -ne 2 -or $pass.ArsasCapture.DuplicateSemanticRequests -ne 0) { + throw 'P0-5d PASS fixture produced incorrect proof metrics.' + } + + $failRows = @($passRows) + $failRows += (New-Row 6 $client $server '3' '1' '' '1' '' 'LD0' '' '') + $failRows += (New-Row 7 $server $client '3' '' '1' '' '' '' '' '') + $failFixture = '.\ArIED61850Tester\TestResults\p0-5d-duplicate.tsv' + $failJson = '.\ArIED61850Tester\TestResults\P0-5D-fixture-duplicate.json' + $failRows | Export-Csv -Delimiter "`t" -NoTypeInformation -Encoding utf8 $failFixture + & $verifier -DecodedRowsPath $failFixture -OutputJson $failJson -NoFailExit + $fail = Get-Content $failJson -Raw | ConvertFrom-Json + if ($fail.Verdict -ne 'FAIL' -or $fail.ArsasCapture.DuplicateGetNameListRequests -lt 1 -or -not $fail.ArsasCapture.SecondGetNameListSweepDetected) { + throw 'P0-5d duplicate fixture was not rejected by the semantic wire proof.' + } + - name: Checkout immutable ARIEC61850 PR 134 engine shell: powershell run: | @@ -165,9 +253,9 @@ jobs: "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine PR: 134", - "Mode: P0-5c ARSAS association-generation enrichment single-flight + P0-5b hierarchy GVA budget convergence", - "CI invariant: caller cancellation only releases its waiter; directory/GVA/model/projection/publish remain one owner flight per association generation", - "CI invariant: reconnect/dispose invalidates the generation; stale owners cannot publish authority into a replacement association", + "Mode: P0-5d physical wire proof over P0-5c association single-flight and P0-5b hierarchy request-budget convergence", + "CI invariant: duplicate semantic request, duplicate GetNameList/GVA, second naming sweep, invoke-ID reuse and outstanding-window proof logic execute against deterministic fixtures", + "Field invariant: one association capture, zero duplicate semantic confirmed requests, peak outstanding never above negotiated calling limit", "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 @@ -178,6 +266,8 @@ jobs: path: | ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt + ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1 + ArIED61850Tester\docs\P0-5D_PHYSICAL_CAPTURE_PROOF.md if-no-files-found: error retention-days: 14 @@ -186,6 +276,8 @@ jobs: uses: actions/upload-artifact@v4 with: name: ARSAS-smart-discovery-pr134-test-evidence - path: ArIED61850Tester\TestResults\*.trx + path: | + ArIED61850Tester\TestResults\*.trx + ArIED61850Tester\TestResults\P0-5D-*.json if-no-files-found: warn retention-days: 14 From 8d0e6bbf914e3760ee53b53bfd9631003ad71ed0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:23:21 +0700 Subject: [PATCH 041/243] fix(p0-5d): make duplicate sums strict-mode safe --- scripts/verify-smart-discovery-pcap.ps1 | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/verify-smart-discovery-pcap.ps1 b/scripts/verify-smart-discovery-pcap.ps1 index cf94552ec..fba5eb523 100644 --- a/scripts/verify-smart-discovery-pcap.ps1 +++ b/scripts/verify-smart-discovery-pcap.ps1 @@ -52,6 +52,18 @@ function Test-Present($Row, [string]$Field) { return -not [string]::IsNullOrWhiteSpace((Get-RowValue $Row $Field)) } +function Sum-IntProperty($Items, [string]$PropertyName) { + $sum = 0 + foreach ($item in @($Items)) { + if ($null -eq $item) { continue } + $property = $item.PSObject.Properties[$PropertyName] + if ($null -ne $property -and $null -ne $property.Value) { + $sum += [int]$property.Value + } + } + return $sum +} + function Get-ServiceName($Row) { if (Test-Present $Row "mms.getNameList_element") { return "GetNameList" } if (Test-Present $Row "mms.getVariableAccessAttributes_element") { return "GetVariableAccessAttributes" } @@ -181,7 +193,7 @@ function Analyze-Rows($Rows, [string]$Label, [string]$RequestedClientIp, [string Fingerprint = $_.Name } }) - $duplicateRequests = [int](($duplicateDetails | Measure-Object DuplicateAttempts -Sum).Sum) + $duplicateRequests = Sum-IntProperty $duplicateDetails "DuplicateAttempts" $serviceCounts = [ordered]@{} foreach ($group in ($requestRecords | Group-Object Service | Sort-Object Name)) { $serviceCounts[$group.Name] = $group.Count } @@ -224,8 +236,8 @@ function Analyze-Rows($Rows, [string]$Label, [string]$RequestedClientIp, [string ConfirmedResponsesOrErrors = $responses.Count ServiceCounts = [pscustomobject]$serviceCounts DuplicateSemanticRequests = $duplicateRequests - DuplicateGetNameListRequests = [int](($gnlDuplicates | Measure-Object DuplicateAttempts -Sum).Sum) - DuplicateGvaRequests = [int](($gvaDuplicates | Measure-Object DuplicateAttempts -Sum).Sum) + DuplicateGetNameListRequests = Sum-IntProperty $gnlDuplicates "DuplicateAttempts" + DuplicateGvaRequests = Sum-IntProperty $gvaDuplicates "DuplicateAttempts" DuplicateDetails = $duplicateDetails SecondGetNameListSweepDetected = $gnlDuplicates.Count -gt 0 PeakOutstandingRequests = $peakOutstanding From 748b3a60ff9705d7e5f385bed7ecff057bff3232 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:25:43 +0700 Subject: [PATCH 042/243] feat(p0-5e): derive golden request budget from physical proof --- scripts/new-smart-discovery-golden-lock.ps1 | 133 ++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 scripts/new-smart-discovery-golden-lock.ps1 diff --git a/scripts/new-smart-discovery-golden-lock.ps1 b/scripts/new-smart-discovery-golden-lock.ps1 new file mode 100644 index 000000000..a41c2f446 --- /dev/null +++ b/scripts/new-smart-discovery-golden-lock.ps1 @@ -0,0 +1,133 @@ +param( + [Parameter(Mandatory=$true)][string]$ProofJson, + [Parameter(Mandatory=$true)][string]$CapturePath, + [Parameter(Mandatory=$true)][string]$DeviceIdentity, + [Parameter(Mandatory=$true)][string]$ArsasCommit, + [Parameter(Mandatory=$true)][string]$EngineCommit, + [Parameter(Mandatory=$true)][string]$OutputPath, + [string]$TargetPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { + throw "$Label is not a file: $Path" + } + return $resolved.Path +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Get-IntProperty($Object, [string]$Name) { + if ($null -eq $Object) { return 0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return 0 } + return [int]$property.Value +} + +$proofPath = Resolve-File $ProofJson "P0-5d proof JSON" +$capture = Resolve-File $CapturePath "physical capture" +Assert-Commit $ArsasCommit "ARSAS commit" +Assert-Commit $EngineCommit "Engine commit" +if ([string]::IsNullOrWhiteSpace($DeviceIdentity)) { throw "DeviceIdentity must be non-empty." } + +$proof = Get-Content -LiteralPath $proofPath -Raw | ConvertFrom-Json +if ($proof.Phase -ne 'P0-5d' -or $proof.Verdict -ne 'PASS') { + throw "Golden budget can only be created from a P0-5d PASS proof." +} +$actual = $proof.ArsasCapture +if ($null -eq $actual) { throw "P0-5d proof does not contain ArsasCapture evidence." } +if (@($actual.RequestTcpStreams).Count -ne 1) { throw "Golden capture must contain exactly one MMS request TCP stream." } +if ((Get-IntProperty $actual 'DuplicateSemanticRequests') -ne 0 -or + (Get-IntProperty $actual 'DuplicateGetNameListRequests') -ne 0 -or + (Get-IntProperty $actual 'DuplicateGvaRequests') -ne 0 -or + [bool]$actual.SecondGetNameListSweepDetected -or + (Get-IntProperty $actual 'InvokeIdReuseWhileOutstanding') -ne 0 -or + (Get-IntProperty $actual 'OrphanResponses') -ne 0 -or + (Get-IntProperty $actual 'UnansweredRequestsAtCaptureEnd') -ne 0) { + throw "Golden capture contains duplicate, second-sweep, invoke-ID, orphan, or incomplete-capture evidence." +} + +$confirmedRequests = Get-IntProperty $actual 'ConfirmedRequests' +if ($confirmedRequests -le 0) { throw "Golden capture must contain at least one confirmed MMS request." } +$peakOutstanding = Get-IntProperty $actual 'PeakOutstandingRequests' +$negotiated = $null +if ($null -ne $actual.PSObject.Properties['NegotiatedMaxOutstandingCalling'] -and + $null -ne $actual.NegotiatedMaxOutstandingCalling -and + [string]$actual.NegotiatedMaxOutstandingCalling -match '^\d+$') { + $negotiated = [int]$actual.NegotiatedMaxOutstandingCalling + if ($peakOutstanding -gt $negotiated) { throw "Golden peak outstanding exceeds the negotiated calling limit." } +} + +$serviceBudget = [ordered]@{} +if ($null -ne $actual.ServiceCounts) { + foreach ($property in @($actual.ServiceCounts.PSObject.Properties | Sort-Object Name)) { + $count = [int]$property.Value + if ($count -lt 0) { throw "Invalid negative service count for '$($property.Name)'." } + $serviceBudget[$property.Name] = $count + } +} +if ($serviceBudget.Count -eq 0) { throw "Golden proof contains no MMS service budget." } + +$target = $null +if (-not [string]::IsNullOrWhiteSpace($TargetPath)) { + $resolvedTarget = Resolve-File $TargetPath "same-IED target" + $target = Get-Content -LiteralPath $resolvedTarget -Raw | ConvertFrom-Json + if ($target.DeviceIdentity -ne $DeviceIdentity) { + throw "DeviceIdentity '$DeviceIdentity' does not match target '$($target.DeviceIdentity)'." + } + if ($target.EngineCommit -and $target.EngineCommit -ne $EngineCommit) { + throw "Engine commit does not match the target baseline." + } +} + +$captureHash = (Get-FileHash -LiteralPath $capture -Algorithm SHA256).Hash.ToLowerInvariant() +$proofHash = (Get-FileHash -LiteralPath $proofPath -Algorithm SHA256).Hash.ToLowerInvariant() +$maxOutstanding = if ($null -ne $negotiated) { $negotiated } else { $peakOutstanding } + +$lock = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5e' + Status = 'locked' + DeviceIdentity = $DeviceIdentity + GoldenSource = [ordered]@{ + ArsasCommit = $ArsasCommit.ToLowerInvariant() + EngineCommit = $EngineCommit.ToLowerInvariant() + CaptureFileName = [IO.Path]::GetFileName($capture) + CaptureSha256 = $captureHash + ProofFileName = [IO.Path]::GetFileName($proofPath) + ProofSha256 = $proofHash + ClientIp = $actual.ClientIp + ServerIp = $actual.ServerIp + RequestTcpStream = @($actual.RequestTcpStreams)[0] + ObservedPeakOutstandingRequests = $peakOutstanding + NegotiatedMaxOutstandingCalling = $negotiated + } + HardRequestBudget = [ordered]@{ + MaxConfirmedRequests = $confirmedRequests + MaxServiceRequests = $serviceBudget + MaxDuplicateSemanticRequests = 0 + MaxDuplicateGetNameListRequests = 0 + MaxDuplicateGvaRequests = 0 + MaxInvokeIdReuseWhileOutstanding = 0 + MaxOrphanResponses = 0 + MaxUnansweredRequestsAtCaptureEnd = 0 + MaxPeakOutstandingRequests = $maxOutstanding + ForbidSecondGetNameListSweep = $true + ForbidUnexpectedServices = $true + } + SemanticTarget = if ($null -ne $target) { $target.SemanticTarget } else { $null } +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$lock | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host "P0-5e golden request-budget lock written: $OutputPath" +Write-Host " capture SHA256: $captureHash" +Write-Host " confirmed request hard max: $confirmedRequests" +Write-Host " service hard max: $($serviceBudget | ConvertTo-Json -Compress)" From 035d749534111e429e5d6dbaf6089e2703869025 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:26:04 +0700 Subject: [PATCH 043/243] feat(p0-5e): enforce same-IED hard request budget --- .../verify-smart-discovery-golden-lock.ps1 | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 scripts/verify-smart-discovery-golden-lock.ps1 diff --git a/scripts/verify-smart-discovery-golden-lock.ps1 b/scripts/verify-smart-discovery-golden-lock.ps1 new file mode 100644 index 000000000..7cf323712 --- /dev/null +++ b/scripts/verify-smart-discovery-golden-lock.ps1 @@ -0,0 +1,117 @@ +param( + [Parameter(Mandatory=$true)][string]$LockPath, + [Parameter(Mandatory=$true)][string]$ProofJson, + [Parameter(Mandatory=$true)][string]$DeviceIdentity, + [string]$CandidateEngineCommit, + [switch]$AllowDifferentEngineCommit, + [string]$OutputJson, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Get-IntProperty($Object, [string]$Name) { + if ($null -eq $Object) { return 0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return 0 } + return [int]$property.Value +} + +$lockFile = Resolve-File $LockPath "P0-5e golden lock" +$proofFile = Resolve-File $ProofJson "P0-5d proof JSON" +$lock = Get-Content -LiteralPath $lockFile -Raw | ConvertFrom-Json +$proof = Get-Content -LiteralPath $proofFile -Raw | ConvertFrom-Json +$failures = [System.Collections.Generic.List[string]]::new() + +if ($lock.Phase -ne 'P0-5e' -or $lock.Status -ne 'locked') { $failures.Add('Golden lock is not an active P0-5e locked contract.') } +if ($proof.Phase -ne 'P0-5d' -or $proof.Verdict -ne 'PASS') { $failures.Add('Candidate evidence must be a P0-5d PASS proof.') } +if ($lock.DeviceIdentity -ne $DeviceIdentity) { $failures.Add("Device identity mismatch: '$DeviceIdentity' != '$($lock.DeviceIdentity)'.") } +if (-not [string]::IsNullOrWhiteSpace($CandidateEngineCommit)) { + if ($CandidateEngineCommit -notmatch '^[0-9a-fA-F]{40}$') { $failures.Add('Candidate engine commit is not a full 40-character SHA.') } + elseif (-not $AllowDifferentEngineCommit -and $CandidateEngineCommit.ToLowerInvariant() -ne [string]$lock.GoldenSource.EngineCommit) { + $failures.Add('Candidate engine commit differs from the golden engine baseline. Use -AllowDifferentEngineCommit only for an intentional regression comparison.') + } +} + +$actual = $proof.ArsasCapture +$budget = $lock.HardRequestBudget +if ($null -eq $actual -or $null -eq $budget) { + $failures.Add('Candidate proof or hard request budget is missing.') +} else { + if ((Get-IntProperty $actual 'ConfirmedRequests') -gt [int]$budget.MaxConfirmedRequests) { + $failures.Add("Confirmed request budget exceeded: $($actual.ConfirmedRequests) > $($budget.MaxConfirmedRequests).") + } + if ((Get-IntProperty $actual 'DuplicateSemanticRequests') -gt [int]$budget.MaxDuplicateSemanticRequests) { $failures.Add('Semantic duplicate budget exceeded.') } + if ((Get-IntProperty $actual 'DuplicateGetNameListRequests') -gt [int]$budget.MaxDuplicateGetNameListRequests) { $failures.Add('GetNameList duplicate budget exceeded.') } + if ((Get-IntProperty $actual 'DuplicateGvaRequests') -gt [int]$budget.MaxDuplicateGvaRequests) { $failures.Add('GVA duplicate budget exceeded.') } + if ((Get-IntProperty $actual 'InvokeIdReuseWhileOutstanding') -gt [int]$budget.MaxInvokeIdReuseWhileOutstanding) { $failures.Add('Invoke-ID reuse budget exceeded.') } + if ((Get-IntProperty $actual 'OrphanResponses') -gt [int]$budget.MaxOrphanResponses) { $failures.Add('Orphan response budget exceeded.') } + if ((Get-IntProperty $actual 'UnansweredRequestsAtCaptureEnd') -gt [int]$budget.MaxUnansweredRequestsAtCaptureEnd) { $failures.Add('Unanswered request budget exceeded.') } + if ((Get-IntProperty $actual 'PeakOutstandingRequests') -gt [int]$budget.MaxPeakOutstandingRequests) { + $failures.Add("Peak outstanding budget exceeded: $($actual.PeakOutstandingRequests) > $($budget.MaxPeakOutstandingRequests).") + } + if ([bool]$budget.ForbidSecondGetNameListSweep -and [bool]$actual.SecondGetNameListSweepDetected) { $failures.Add('Second GetNameList sweep is forbidden by the golden lock.') } + + $allowed = $budget.MaxServiceRequests + if ($null -eq $allowed) { $failures.Add('Golden lock has no service budget.') } + else { + $candidateProperties = if ($null -ne $actual.ServiceCounts) { @($actual.ServiceCounts.PSObject.Properties) } else { @() } + foreach ($property in $candidateProperties) { + $allowedProperty = $allowed.PSObject.Properties[$property.Name] + if ($null -eq $allowedProperty) { + if ([bool]$budget.ForbidUnexpectedServices -and [int]$property.Value -gt 0) { + $failures.Add("Unexpected MMS service '$($property.Name)' is not present in the golden budget.") + } + continue + } + if ([int]$property.Value -gt [int]$allowedProperty.Value) { + $failures.Add("Service budget exceeded for '$($property.Name)': $($property.Value) > $($allowedProperty.Value).") + } + } + } +} + +$result = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5e' + Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } + DeviceIdentity = $DeviceIdentity + GoldenLock = [ordered]@{ + Path = $lockFile + CaptureSha256 = $lock.GoldenSource.CaptureSha256 + ProofSha256 = $lock.GoldenSource.ProofSha256 + EngineCommit = $lock.GoldenSource.EngineCommit + MaxConfirmedRequests = $lock.HardRequestBudget.MaxConfirmedRequests + MaxServiceRequests = $lock.HardRequestBudget.MaxServiceRequests + } + Candidate = [ordered]@{ + ProofPath = $proofFile + EngineCommit = $CandidateEngineCommit + ConfirmedRequests = if ($null -ne $actual) { $actual.ConfirmedRequests } else { $null } + ServiceCounts = if ($null -ne $actual) { $actual.ServiceCounts } else { $null } + PeakOutstandingRequests = if ($null -ne $actual) { $actual.PeakOutstandingRequests } else { $null } + } + AcceptanceFailures = @($failures) +} + +if ([string]::IsNullOrWhiteSpace($OutputJson)) { + $base = [IO.Path]::GetFileNameWithoutExtension($proofFile) + $OutputJson = Join-Path ([IO.Path]::GetDirectoryName($proofFile)) "P0-5E-$base-acceptance.json" +} +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 + +Write-Host "P0-5e golden capture acceptance: $($result.Verdict)" +Write-Host " device: $DeviceIdentity" +Write-Host " candidate requests: $($result.Candidate.ConfirmedRequests); hard max: $($result.GoldenLock.MaxConfirmedRequests)" +Write-Host " acceptance JSON: $OutputJson" +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + if (-not $NoFailExit) { exit 1 } +} From ea557d069403d3a351bb55ca000ab046ee09df60 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:26:14 +0700 Subject: [PATCH 044/243] test(p0-5e): define same-IED golden semantic target --- evidence/smart-discovery-golden-target.json | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 evidence/smart-discovery-golden-target.json diff --git a/evidence/smart-discovery-golden-target.json b/evidence/smart-discovery-golden-target.json new file mode 100644 index 000000000..0e3e5a2ab --- /dev/null +++ b/evidence/smart-discovery-golden-target.json @@ -0,0 +1,26 @@ +{ + "SchemaVersion": 1, + "Phase": "P0-5e", + "Status": "awaiting-fresh-physical-budget-lock", + "DeviceIdentity": "AA1E1F06R4", + "EngineCommit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "SemanticTarget": { + "LogicalDevices": 32, + "LogicalNodes": 119, + "SemanticLeaves": 4925, + "DataSets": 2, + "OrderedFcdaMembers": 58, + "LogicalReportControls": 32, + "BufferedRuntimeRcbInstances": 2, + "UnbufferedRuntimeRcbInstances": 2, + "SyntheticReportControlInstancesAllowed": 0 + }, + "BudgetAuthority": { + "RequiredProofPhase": "P0-5d", + "RequiredProofVerdict": "PASS", + "RequireRawCaptureSha256": true, + "RequireExactArsasCommit": true, + "RequireExactEngineCommit": true, + "BudgetValues": null + } +} From 442e172644ebb7f758320e211de0a9a55271c836 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:26:31 +0700 Subject: [PATCH 045/243] fix(p0-5e): clarify indexed RCB family target --- evidence/smart-discovery-golden-target.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/evidence/smart-discovery-golden-target.json b/evidence/smart-discovery-golden-target.json index 0e3e5a2ab..1bdfb9870 100644 --- a/evidence/smart-discovery-golden-target.json +++ b/evidence/smart-discovery-golden-target.json @@ -11,8 +11,9 @@ "DataSets": 2, "OrderedFcdaMembers": 58, "LogicalReportControls": 32, - "BufferedRuntimeRcbInstances": 2, - "UnbufferedRuntimeRcbInstances": 2, + "RuntimeReportControlInstances": 34, + "IndexedBufferedFamilyMax": 2, + "IndexedUnbufferedFamilyMax": 2, "SyntheticReportControlInstancesAllowed": 0 }, "BudgetAuthority": { From 3ff7d2377987af9c38f7a94ffac353f831e2eda4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:26:54 +0700 Subject: [PATCH 046/243] test(p0-5e): lock golden capture budget contract --- ...iscoveryGoldenBudgetLockRegressionTests.cs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/ARSAS.Tests/SmartDiscoveryGoldenBudgetLockRegressionTests.cs diff --git a/tests/ARSAS.Tests/SmartDiscoveryGoldenBudgetLockRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryGoldenBudgetLockRegressionTests.cs new file mode 100644 index 000000000..28ffe4a75 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryGoldenBudgetLockRegressionTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryGoldenBudgetLockRegressionTests +{ + private const string P05bEngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + [Fact] + public void P05e_TargetLocksSameIedSemanticAuthorityWithoutInventingWireBudget() + { + var path = FindRepoFile("evidence/smart-discovery-golden-target.json"); + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + + Assert.Equal("P0-5e", root.GetProperty("Phase").GetString()); + Assert.Equal("awaiting-fresh-physical-budget-lock", root.GetProperty("Status").GetString()); + Assert.Equal("AA1E1F06R4", root.GetProperty("DeviceIdentity").GetString()); + Assert.Equal(P05bEngineCommit, root.GetProperty("EngineCommit").GetString()); + + var target = root.GetProperty("SemanticTarget"); + Assert.Equal(32, target.GetProperty("LogicalDevices").GetInt32()); + Assert.Equal(119, target.GetProperty("LogicalNodes").GetInt32()); + Assert.Equal(4925, target.GetProperty("SemanticLeaves").GetInt32()); + Assert.Equal(2, target.GetProperty("DataSets").GetInt32()); + Assert.Equal(58, target.GetProperty("OrderedFcdaMembers").GetInt32()); + Assert.Equal(32, target.GetProperty("LogicalReportControls").GetInt32()); + Assert.Equal(34, target.GetProperty("RuntimeReportControlInstances").GetInt32()); + Assert.Equal(2, target.GetProperty("IndexedBufferedFamilyMax").GetInt32()); + Assert.Equal(2, target.GetProperty("IndexedUnbufferedFamilyMax").GetInt32()); + Assert.Equal(0, target.GetProperty("SyntheticReportControlInstancesAllowed").GetInt32()); + + var budgetAuthority = root.GetProperty("BudgetAuthority"); + Assert.Equal(JsonValueKind.Null, budgetAuthority.GetProperty("BudgetValues").ValueKind); + Assert.Equal("P0-5d", budgetAuthority.GetProperty("RequiredProofPhase").GetString()); + Assert.Equal("PASS", budgetAuthority.GetProperty("RequiredProofVerdict").GetString()); + Assert.True(budgetAuthority.GetProperty("RequireRawCaptureSha256").GetBoolean()); + } + + [Fact] + public void P05e_LockWriterDerivesBudgetFromPassProofAndHashesRawCapture() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-golden-lock.ps1")); + + Assert.Contains("P0-5d", source, StringComparison.Ordinal); + Assert.Contains("$proof.Verdict -ne 'PASS'", source, StringComparison.Ordinal); + Assert.Contains("Get-FileHash -LiteralPath $capture -Algorithm SHA256", source, StringComparison.Ordinal); + Assert.Contains("Get-FileHash -LiteralPath $proofPath -Algorithm SHA256", source, StringComparison.Ordinal); + Assert.Contains("MaxConfirmedRequests = $confirmedRequests", source, StringComparison.Ordinal); + Assert.Contains("MaxServiceRequests = $serviceBudget", source, StringComparison.Ordinal); + Assert.Contains("ForbidUnexpectedServices = $true", source, StringComparison.Ordinal); + Assert.Contains("ForbidSecondGetNameListSweep = $true", source, StringComparison.Ordinal); + Assert.DoesNotContain("MaxConfirmedRequests = 100", source, StringComparison.Ordinal); + } + + [Fact] + public void P05e_AcceptanceRejectsBudgetGrowthUnexpectedServicesAndIdentityMismatch() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-golden-lock.ps1")); + + Assert.Contains("Confirmed request budget exceeded", source, StringComparison.Ordinal); + Assert.Contains("Service budget exceeded", source, StringComparison.Ordinal); + Assert.Contains("Unexpected MMS service", source, StringComparison.Ordinal); + Assert.Contains("Second GetNameList sweep is forbidden", source, StringComparison.Ordinal); + Assert.Contains("Device identity mismatch", source, StringComparison.Ordinal); + Assert.Contains("AllowDifferentEngineCommit", source, StringComparison.Ordinal); + Assert.Contains("Candidate evidence must be a P0-5d PASS proof", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 2358c5bb61f283ec2be1e854282fde4b4292d974 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:27:14 +0700 Subject: [PATCH 047/243] docs(p0-5e): define golden capture budget lock workflow --- docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md | 81 ++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md diff --git a/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md b/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md new file mode 100644 index 000000000..40c0ff068 --- /dev/null +++ b/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md @@ -0,0 +1,81 @@ +# P0-5e — Same-IED Golden Capture Acceptance & Hard Request-Budget Lock + +P0-5e turns one fresh, physically captured P0-5d PASS into an immutable request-budget authority for the same IED. The hard budget is evidence-derived: no total request count, GVA count, service count, or capture hash is guessed in source code. + +## Same-IED semantic authority + +The tracked target is `evidence/smart-discovery-golden-target.json`. + +For `AA1E1F06R4` the canonical semantic target remains: + +- 32 Logical Devices; +- 119 Logical Nodes; +- 4,925 semantic leaves; +- 2 DataSets; +- 58 ordered FCDA members; +- 32 logical ReportControls; +- 34 runtime RCB instances before semantic indexed-family collapse; +- one indexed buffered family with `max=2` and one indexed unbuffered family with `max=2`; +- zero synthetic ReportControl instances. + +The target intentionally contains `BudgetValues: null` until a fresh physical P0-5d PASS exists. Fixture numbers are never promoted into the production golden budget. + +## Create the hard lock from physical evidence + +Required inputs: + +1. raw, complete same-IED PCAP/PCAPNG generated by the exact field artifact; +2. P0-5d proof JSON for that raw capture with `Verdict=PASS`; +3. full ARSAS commit SHA used to produce the capture; +4. full ARIEC61850 engine commit SHA; +5. the tracked same-IED semantic target. + +Run: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\new-smart-discovery-golden-lock.ps1 ` + -ProofJson .\P0-5D-physical-proof.json ` + -CapturePath .\physical-discovery.pcapng ` + -DeviceIdentity AA1E1F06R4 ` + -ArsasCommit <40-char-arsas-sha> ` + -EngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 ` + -TargetPath .\evidence\smart-discovery-golden-target.json ` + -OutputPath .\evidence\smart-discovery-golden-budget.lock.json +``` + +The lock writer refuses a non-PASS proof, duplicate semantic request evidence, duplicate GetNameList/GVA, second naming sweep, invoke-ID anomaly, orphan response, unanswered request, invalid commit SHA, or target/IED mismatch. + +The generated lock records SHA-256 for both the raw capture and the P0-5d proof. The request hard maximum is exactly the confirmed-request count observed in that physical PASS. Each observed MMS service count is also locked as its own maximum. A service absent from the golden capture has an implicit maximum of zero. + +## Verify later captures against the golden budget + +First run P0-5d on the new candidate capture. Then run: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-golden-lock.ps1 ` + -LockPath .\evidence\smart-discovery-golden-budget.lock.json ` + -ProofJson .\P0-5D-candidate-proof.json ` + -DeviceIdentity AA1E1F06R4 ` + -CandidateEngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 +``` + +A PASS requires: + +- candidate P0-5d proof is itself PASS; +- exact same IED identity; +- confirmed-request total does not exceed the locked golden maximum; +- no service count exceeds its locked maximum; +- no new/unexpected MMS service appears; +- duplicate semantic request/GetNameList/GVA remain zero; +- second GetNameList sweep remains forbidden; +- invoke-ID/orphan/unanswered counts remain zero; +- peak outstanding remains within the locked maximum; +- engine commit matches the golden engine by default. + +For an intentional future engine experiment, `-AllowDifferentEngineCommit` allows comparison against the old golden budget without silently changing the lock. If the new engine legitimately changes the budget contract, a new physical PASS and explicit lock replacement are required. + +## Lock replacement policy + +`smart-discovery-golden-budget.lock.json` must never be hand-edited to make a failing candidate pass. Replace it only by re-running the lock writer against a newly reviewed physical P0-5d PASS. Preserve the old capture/proof hashes in review history. + +The raw physical capture remains the primary evidence. The P0-5d proof is derived wire evidence, and the P0-5e lock is the regression contract derived from that evidence. From a17e64e4f7ed8e47b44c1efc73e6d1fb5b363287 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:27:59 +0700 Subject: [PATCH 048/243] ci(p0-5e): prove golden hard-budget lock behavior --- .../smart-discovery-golden-budget-lock.yml | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 .github/workflows/smart-discovery-golden-budget-lock.yml diff --git a/.github/workflows/smart-discovery-golden-budget-lock.yml b/.github/workflows/smart-discovery-golden-budget-lock.yml new file mode 100644 index 000000000..3766893d2 --- /dev/null +++ b/.github/workflows/smart-discovery-golden-budget-lock.yml @@ -0,0 +1,169 @@ +name: Smart Discovery Golden Budget Lock + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-golden-lock: + name: Verify P0-5e evidence-derived hard budget + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Validate P0-5e production target remains evidence-gated + shell: powershell + run: | + $targetPath = '.\ArIED61850Tester\evidence\smart-discovery-golden-target.json' + $target = Get-Content $targetPath -Raw | ConvertFrom-Json + if ($target.Phase -ne 'P0-5e' -or $target.Status -ne 'awaiting-fresh-physical-budget-lock') { + throw 'P0-5e target state is invalid.' + } + if ($null -ne $target.BudgetAuthority.BudgetValues) { + throw 'Production hard budget must not be populated by CI fixture values.' + } + if ($target.DeviceIdentity -ne 'AA1E1F06R4' -or + $target.SemanticTarget.LogicalDevices -ne 32 -or + $target.SemanticTarget.LogicalNodes -ne 119 -or + $target.SemanticTarget.SemanticLeaves -ne 4925 -or + $target.SemanticTarget.DataSets -ne 2 -or + $target.SemanticTarget.OrderedFcdaMembers -ne 58 -or + $target.SemanticTarget.LogicalReportControls -ne 32 -or + $target.SemanticTarget.RuntimeReportControlInstances -ne 34) { + throw 'P0-5e same-IED semantic target changed unexpectedly.' + } + + - name: Execute P0-5e golden budget fixtures + shell: powershell + run: | + $writer = '.\ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1' + $verifier = '.\ArIED61850Tester\scripts\verify-smart-discovery-golden-lock.ps1' + $target = '.\ArIED61850Tester\evidence\smart-discovery-golden-target.json' + foreach ($required in @($writer, $verifier, $target)) { + if (-not (Test-Path $required -PathType Leaf)) { throw "P0-5e source missing: $required" } + } + + foreach ($script in @($writer, $verifier)) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { throw "PowerShell parse failure in $script" } + } + + $results = '.\ArIED61850Tester\TestResults' + New-Item -ItemType Directory -Force $results | Out-Null + $capture = Join-Path $results 'p0-5e-fixture.pcapng' + [IO.File]::WriteAllBytes($capture, [Text.Encoding]::UTF8.GetBytes('CI fixture only - not physical evidence')) + + $proofPath = Join-Path $results 'P0-5D-fixture-golden-source.json' + $proof = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5d' + Verdict = 'PASS' + AcceptanceFailures = @() + ArsasCapture = [ordered]@{ + Capture = 'fixture' + ClientIp = '192.0.2.10' + ServerIp = '192.0.2.20' + RequestTcpStreams = @('0') + ConfirmedRequests = 4 + ConfirmedResponsesOrErrors = 4 + ServiceCounts = [ordered]@{ + GetNameList = 2 + GetVariableAccessAttributes = 2 + } + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + DuplicateDetails = @() + SecondGetNameListSweepDetected = $false + PeakOutstandingRequests = 3 + NegotiatedMaxOutstandingCalling = 10 + InvokeIdReuseWhileOutstanding = 0 + OrphanResponses = 0 + UnansweredRequestsAtCaptureEnd = 0 + } + } + $proof | ConvertTo-Json -Depth 12 | Set-Content $proofPath -Encoding utf8 + + $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() + $engineCommit = '4467124775d8d9d76f3db194f9fbfd97144767a8' + $lockPath = Join-Path $results 'P0-5E-fixture-golden.lock.json' + & $writer ` + -ProofJson $proofPath ` + -CapturePath $capture ` + -DeviceIdentity 'AA1E1F06R4' ` + -ArsasCommit $arsasCommit ` + -EngineCommit $engineCommit ` + -TargetPath $target ` + -OutputPath $lockPath + + $lock = Get-Content $lockPath -Raw | ConvertFrom-Json + if ($lock.Status -ne 'locked' -or + $lock.HardRequestBudget.MaxConfirmedRequests -ne 4 -or + $lock.HardRequestBudget.MaxServiceRequests.GetNameList -ne 2 -or + $lock.HardRequestBudget.MaxServiceRequests.GetVariableAccessAttributes -ne 2 -or + $lock.GoldenSource.CaptureSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.ProofSha256 -notmatch '^[0-9a-f]{64}$') { + throw 'P0-5e lock writer did not derive the expected fixture budget/provenance.' + } + + $acceptPath = Join-Path $results 'P0-5E-fixture-accept-pass.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $proofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateEngineCommit $engineCommit ` + -OutputJson $acceptPath + $accept = Get-Content $acceptPath -Raw | ConvertFrom-Json + if ($accept.Verdict -ne 'PASS') { throw 'P0-5e golden fixture should pass its own lock.' } + + $growthProofPath = Join-Path $results 'P0-5D-fixture-growth.json' + $growth = Get-Content $proofPath -Raw | ConvertFrom-Json + $growth.ArsasCapture.ConfirmedRequests = 5 + $growth.ArsasCapture.ServiceCounts.GetVariableAccessAttributes = 3 + $growth | ConvertTo-Json -Depth 12 | Set-Content $growthProofPath -Encoding utf8 + $growthAcceptPath = Join-Path $results 'P0-5E-fixture-growth-rejected.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $growthProofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateEngineCommit $engineCommit ` + -OutputJson $growthAcceptPath ` + -NoFailExit + $growthAcceptance = Get-Content $growthAcceptPath -Raw | ConvertFrom-Json + if ($growthAcceptance.Verdict -ne 'FAIL' -or + -not (@($growthAcceptance.AcceptanceFailures) -match 'Confirmed request budget exceeded')) { + throw 'P0-5e failed to reject confirmed-request growth.' + } + + $serviceProofPath = Join-Path $results 'P0-5D-fixture-unexpected-service.json' + $serviceGrowth = Get-Content $proofPath -Raw | ConvertFrom-Json + $serviceGrowth.ArsasCapture.ServiceCounts | Add-Member -NotePropertyName Read -NotePropertyValue 1 + $serviceGrowth | ConvertTo-Json -Depth 12 | Set-Content $serviceProofPath -Encoding utf8 + $serviceAcceptPath = Join-Path $results 'P0-5E-fixture-unexpected-service-rejected.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $serviceProofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateEngineCommit $engineCommit ` + -OutputJson $serviceAcceptPath ` + -NoFailExit + $serviceAcceptance = Get-Content $serviceAcceptPath -Raw | ConvertFrom-Json + if ($serviceAcceptance.Verdict -ne 'FAIL' -or + -not (@($serviceAcceptance.AcceptanceFailures) -match "Unexpected MMS service 'Read'")) { + throw 'P0-5e failed to reject an unexpected MMS service.' + } + + - name: Upload P0-5e regression evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5e-golden-budget-fixtures + path: ArIED61850Tester\TestResults\P0-5E-*.json + if-no-files-found: warn + retention-days: 14 From b72d023376425163a1994dce2a19d98d470fccb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:28:49 +0700 Subject: [PATCH 049/243] ci(p0-5e): package golden lock tools and fix P0-5d harness --- .github/workflows/smart-discovery-capture-build.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 732bbef2e..13b6ed527 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -143,7 +143,6 @@ jobs: $passJson = '.\ArIED61850Tester\TestResults\P0-5D-fixture-pass.json' $passRows | Export-Csv -Delimiter "`t" -NoTypeInformation -Encoding utf8 $passFixture & $verifier -DecodedRowsPath $passFixture -OutputJson $passJson - if ($LASTEXITCODE -ne 0) { throw 'P0-5d PASS fixture was rejected.' } $pass = Get-Content $passJson -Raw | ConvertFrom-Json if ($pass.Verdict -ne 'PASS' -or $pass.ArsasCapture.PeakOutstandingRequests -ne 2 -or $pass.ArsasCapture.DuplicateSemanticRequests -ne 0) { throw 'P0-5d PASS fixture produced incorrect proof metrics.' @@ -253,8 +252,9 @@ jobs: "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine PR: 134", - "Mode: P0-5d physical wire proof over P0-5c association single-flight and P0-5b hierarchy request-budget convergence", + "Mode: P0-5e golden capture acceptance over P0-5d physical wire proof, P0-5c association single-flight and P0-5b hierarchy request-budget convergence", "CI invariant: duplicate semantic request, duplicate GetNameList/GVA, second naming sweep, invoke-ID reuse and outstanding-window proof logic execute against deterministic fixtures", + "Golden invariant: production hard budget is derived only from a fresh physical P0-5d PASS plus raw capture/proof SHA-256 provenance", "Field invariant: one association capture, zero duplicate semantic confirmed requests, peak outstanding never above negotiated calling limit", "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 @@ -267,7 +267,11 @@ jobs: ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1 + ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1 + ArIED61850Tester\scripts\verify-smart-discovery-golden-lock.ps1 ArIED61850Tester\docs\P0-5D_PHYSICAL_CAPTURE_PROOF.md + ArIED61850Tester\docs\P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md + ArIED61850Tester\evidence\smart-discovery-golden-target.json if-no-files-found: error retention-days: 14 @@ -279,5 +283,6 @@ jobs: path: | ArIED61850Tester\TestResults\*.trx ArIED61850Tester\TestResults\P0-5D-*.json + ArIED61850Tester\TestResults\P0-5E-*.json if-no-files-found: warn retention-days: 14 From ac243079b22addc124e9408b3a5798d454b1e118 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:42:19 +0700 Subject: [PATCH 050/243] test(discovery): bind golden lock to raw capture and build manifest --- scripts/new-smart-discovery-golden-lock.ps1 | 155 +++++++++++++++----- 1 file changed, 121 insertions(+), 34 deletions(-) diff --git a/scripts/new-smart-discovery-golden-lock.ps1 b/scripts/new-smart-discovery-golden-lock.ps1 index a41c2f446..36c7b2e7c 100644 --- a/scripts/new-smart-discovery-golden-lock.ps1 +++ b/scripts/new-smart-discovery-golden-lock.ps1 @@ -5,7 +5,9 @@ param( [Parameter(Mandatory=$true)][string]$ArsasCommit, [Parameter(Mandatory=$true)][string]$EngineCommit, [Parameter(Mandatory=$true)][string]$OutputPath, - [string]$TargetPath + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$BuildManifestPath, + [switch]$AllowFixtureEvidence ) Set-StrictMode -Version Latest @@ -30,19 +32,62 @@ function Get-IntProperty($Object, [string]$Name) { return [int]$property.Value } -$proofPath = Resolve-File $ProofJson "P0-5d proof JSON" -$capture = Resolve-File $CapturePath "physical capture" -Assert-Commit $ArsasCommit "ARSAS commit" -Assert-Commit $EngineCommit "Engine commit" -if ([string]::IsNullOrWhiteSpace($DeviceIdentity)) { throw "DeviceIdentity must be non-empty." } +function Get-ServiceMap($Object) { + $map = [ordered]@{} + if ($null -eq $Object) { return $map } + foreach ($property in @($Object.PSObject.Properties | Sort-Object Name)) { + $map[$property.Name] = [int]$property.Value + } + return $map +} + +function Assert-EquivalentWireProof($Expected, $Observed) { + foreach ($name in @( + 'ConfirmedRequests', + 'DuplicateSemanticRequests', + 'DuplicateGetNameListRequests', + 'DuplicateGvaRequests', + 'PeakOutstandingRequests', + 'InvokeIdReuseWhileOutstanding', + 'OrphanResponses', + 'UnansweredRequestsAtCaptureEnd')) { + $a = Get-IntProperty $Expected $name + $b = Get-IntProperty $Observed $name + if ($a -ne $b) { throw "Proof/capture mismatch for $name: supplied=$a reverified=$b." } + } + + if ([bool]$Expected.SecondGetNameListSweepDetected -ne [bool]$Observed.SecondGetNameListSweepDetected) { + throw 'Proof/capture mismatch for second GetNameList sweep evidence.' + } + if ([string]$Expected.ClientIp -ne [string]$Observed.ClientIp -or [string]$Expected.ServerIp -ne [string]$Observed.ServerIp) { + throw 'Proof/capture endpoint identity mismatch.' + } + + $expectedServices = Get-ServiceMap $Expected.ServiceCounts + $observedServices = Get-ServiceMap $Observed.ServiceCounts + $allNames = @($expectedServices.Keys + $observedServices.Keys | Sort-Object -Unique) + foreach ($name in $allNames) { + $a = if ($expectedServices.Contains($name)) { [int]$expectedServices[$name] } else { 0 } + $b = if ($observedServices.Contains($name)) { [int]$observedServices[$name] } else { 0 } + if ($a -ne $b) { throw "Proof/capture service mismatch for '$name': supplied=$a reverified=$b." } + } +} + +$proofPath = Resolve-File $ProofJson 'P0-5d proof JSON' +$capture = Resolve-File $CapturePath 'physical capture' +$targetFile = Resolve-File $TargetPath 'same-IED target' +$manifestFile = Resolve-File $BuildManifestPath 'field build manifest' +Assert-Commit $ArsasCommit 'ARSAS commit' +Assert-Commit $EngineCommit 'Engine commit' +if ([string]::IsNullOrWhiteSpace($DeviceIdentity)) { throw 'DeviceIdentity must be non-empty.' } $proof = Get-Content -LiteralPath $proofPath -Raw | ConvertFrom-Json if ($proof.Phase -ne 'P0-5d' -or $proof.Verdict -ne 'PASS') { - throw "Golden budget can only be created from a P0-5d PASS proof." + throw 'Golden budget can only be created from a P0-5d PASS proof.' } $actual = $proof.ArsasCapture -if ($null -eq $actual) { throw "P0-5d proof does not contain ArsasCapture evidence." } -if (@($actual.RequestTcpStreams).Count -ne 1) { throw "Golden capture must contain exactly one MMS request TCP stream." } +if ($null -eq $actual) { throw 'P0-5d proof does not contain ArsasCapture evidence.' } +if (@($actual.RequestTcpStreams).Count -ne 1) { throw 'Golden capture must contain exactly one MMS request TCP stream.' } if ((Get-IntProperty $actual 'DuplicateSemanticRequests') -ne 0 -or (Get-IntProperty $actual 'DuplicateGetNameListRequests') -ne 0 -or (Get-IntProperty $actual 'DuplicateGvaRequests') -ne 0 -or @@ -50,48 +95,83 @@ if ((Get-IntProperty $actual 'DuplicateSemanticRequests') -ne 0 -or (Get-IntProperty $actual 'InvokeIdReuseWhileOutstanding') -ne 0 -or (Get-IntProperty $actual 'OrphanResponses') -ne 0 -or (Get-IntProperty $actual 'UnansweredRequestsAtCaptureEnd') -ne 0) { - throw "Golden capture contains duplicate, second-sweep, invoke-ID, orphan, or incomplete-capture evidence." + throw 'Golden capture contains duplicate, second-sweep, invoke-ID, orphan, or incomplete-capture evidence.' +} + +$extension = [IO.Path]::GetExtension($capture).ToLowerInvariant() +if (-not $AllowFixtureEvidence -and $extension -notin @('.pcap', '.pcapng')) { + throw 'Production golden lock requires a raw .pcap or .pcapng capture.' +} + +# Production default: independently decode the raw capture again before locking it. +# This prevents a PASS proof from capture A being paired with unrelated capture B. +if (-not $AllowFixtureEvidence) { + $wireVerifier = Join-Path $PSScriptRoot 'verify-smart-discovery-pcap.ps1' + if (-not (Test-Path -LiteralPath $wireVerifier -PathType Leaf)) { + throw 'P0-5d verifier is missing beside the P0-5e lock writer.' + } + $temporaryProof = Join-Path ([IO.Path]::GetTempPath()) ("p0-5e-reverify-{0}.json" -f [Guid]::NewGuid().ToString('N')) + try { + & $wireVerifier -PcapPath $capture -OutputJson $temporaryProof -NoFailExit + $reverified = Get-Content -LiteralPath $temporaryProof -Raw | ConvertFrom-Json + if ($reverified.Phase -ne 'P0-5d' -or $reverified.Verdict -ne 'PASS') { + throw 'Raw capture does not independently reproduce a P0-5d PASS.' + } + Assert-EquivalentWireProof $actual $reverified.ArsasCapture + } + finally { + Remove-Item -LiteralPath $temporaryProof -Force -ErrorAction SilentlyContinue + } +} + +$manifestText = Get-Content -LiteralPath $manifestFile -Raw +$arsasMatch = [regex]::Match($manifestText, '(?im)^ARSAS commit:\s*([0-9a-f]{40})\s*$') +$engineMatch = [regex]::Match($manifestText, '(?im)^ARIEC61850 commit:\s*([0-9a-f]{40})\s*$') +if (-not $arsasMatch.Success -or -not $engineMatch.Success) { + throw 'Build manifest does not contain exact ARSAS and ARIEC61850 commit identities.' +} +if ($arsasMatch.Groups[1].Value -ne $ArsasCommit.ToLowerInvariant()) { + throw 'ARSAS commit argument does not match the field build manifest.' +} +if ($engineMatch.Groups[1].Value -ne $EngineCommit.ToLowerInvariant()) { + throw 'Engine commit argument does not match the field build manifest.' } +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +if ($target.Phase -ne 'P0-5e') { throw 'Same-IED target is not a P0-5e target.' } +if ($target.DeviceIdentity -ne $DeviceIdentity) { + throw "DeviceIdentity '$DeviceIdentity' does not match target '$($target.DeviceIdentity)'." +} +if ($target.EngineCommit -and $target.EngineCommit -ne $EngineCommit.ToLowerInvariant()) { + throw 'Engine commit does not match the target baseline.' +} +if ($null -eq $target.SemanticTarget) { throw 'Same-IED target does not contain SemanticTarget authority.' } + $confirmedRequests = Get-IntProperty $actual 'ConfirmedRequests' -if ($confirmedRequests -le 0) { throw "Golden capture must contain at least one confirmed MMS request." } +if ($confirmedRequests -le 0) { throw 'Golden capture must contain at least one confirmed MMS request.' } $peakOutstanding = Get-IntProperty $actual 'PeakOutstandingRequests' $negotiated = $null if ($null -ne $actual.PSObject.Properties['NegotiatedMaxOutstandingCalling'] -and $null -ne $actual.NegotiatedMaxOutstandingCalling -and [string]$actual.NegotiatedMaxOutstandingCalling -match '^\d+$') { $negotiated = [int]$actual.NegotiatedMaxOutstandingCalling - if ($peakOutstanding -gt $negotiated) { throw "Golden peak outstanding exceeds the negotiated calling limit." } + if ($peakOutstanding -gt $negotiated) { throw 'Golden peak outstanding exceeds the negotiated calling limit.' } } -$serviceBudget = [ordered]@{} -if ($null -ne $actual.ServiceCounts) { - foreach ($property in @($actual.ServiceCounts.PSObject.Properties | Sort-Object Name)) { - $count = [int]$property.Value - if ($count -lt 0) { throw "Invalid negative service count for '$($property.Name)'." } - $serviceBudget[$property.Name] = $count - } -} -if ($serviceBudget.Count -eq 0) { throw "Golden proof contains no MMS service budget." } - -$target = $null -if (-not [string]::IsNullOrWhiteSpace($TargetPath)) { - $resolvedTarget = Resolve-File $TargetPath "same-IED target" - $target = Get-Content -LiteralPath $resolvedTarget -Raw | ConvertFrom-Json - if ($target.DeviceIdentity -ne $DeviceIdentity) { - throw "DeviceIdentity '$DeviceIdentity' does not match target '$($target.DeviceIdentity)'." - } - if ($target.EngineCommit -and $target.EngineCommit -ne $EngineCommit) { - throw "Engine commit does not match the target baseline." - } +$serviceBudget = Get-ServiceMap $actual.ServiceCounts +if ($serviceBudget.Count -eq 0) { throw 'Golden proof contains no MMS service budget.' } +foreach ($entry in $serviceBudget.GetEnumerator()) { + if ([int]$entry.Value -lt 0) { throw "Invalid negative service count for '$($entry.Key)'." } } $captureHash = (Get-FileHash -LiteralPath $capture -Algorithm SHA256).Hash.ToLowerInvariant() $proofHash = (Get-FileHash -LiteralPath $proofPath -Algorithm SHA256).Hash.ToLowerInvariant() +$manifestHash = (Get-FileHash -LiteralPath $manifestFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() $maxOutstanding = if ($null -ne $negotiated) { $negotiated } else { $peakOutstanding } $lock = [ordered]@{ - SchemaVersion = 1 + SchemaVersion = 2 Phase = 'P0-5e' Status = 'locked' DeviceIdentity = $DeviceIdentity @@ -102,11 +182,16 @@ $lock = [ordered]@{ CaptureSha256 = $captureHash ProofFileName = [IO.Path]::GetFileName($proofPath) ProofSha256 = $proofHash + BuildManifestFileName = [IO.Path]::GetFileName($manifestFile) + BuildManifestSha256 = $manifestHash + SemanticTargetFileName = [IO.Path]::GetFileName($targetFile) + SemanticTargetSha256 = $targetHash ClientIp = $actual.ClientIp ServerIp = $actual.ServerIp RequestTcpStream = @($actual.RequestTcpStreams)[0] ObservedPeakOutstandingRequests = $peakOutstanding NegotiatedMaxOutstandingCalling = $negotiated + RawCaptureReverified = -not [bool]$AllowFixtureEvidence } HardRequestBudget = [ordered]@{ MaxConfirmedRequests = $confirmedRequests @@ -121,7 +206,7 @@ $lock = [ordered]@{ ForbidSecondGetNameListSweep = $true ForbidUnexpectedServices = $true } - SemanticTarget = if ($null -ne $target) { $target.SemanticTarget } else { $null } + SemanticTarget = $target.SemanticTarget } $outputDirectory = Split-Path -Parent $OutputPath @@ -129,5 +214,7 @@ if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | O $lock | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 Write-Host "P0-5e golden request-budget lock written: $OutputPath" Write-Host " capture SHA256: $captureHash" +Write-Host " build manifest SHA256: $manifestHash" +Write-Host " semantic target SHA256: $targetHash" Write-Host " confirmed request hard max: $confirmedRequests" Write-Host " service hard max: $($serviceBudget | ConvertTo-Json -Compress)" From a2dcb72fc09c4901177a904d37151b0e2859d7d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:42:45 +0700 Subject: [PATCH 051/243] test(discovery): enforce build and semantic provenance in golden acceptance --- .../verify-smart-discovery-golden-lock.ps1 | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/scripts/verify-smart-discovery-golden-lock.ps1 b/scripts/verify-smart-discovery-golden-lock.ps1 index 7cf323712..e58f6340b 100644 --- a/scripts/verify-smart-discovery-golden-lock.ps1 +++ b/scripts/verify-smart-discovery-golden-lock.ps1 @@ -2,7 +2,10 @@ param( [Parameter(Mandatory=$true)][string]$LockPath, [Parameter(Mandatory=$true)][string]$ProofJson, [Parameter(Mandatory=$true)][string]$DeviceIdentity, - [string]$CandidateEngineCommit, + [Parameter(Mandatory=$true)][string]$CandidateArsasCommit, + [Parameter(Mandatory=$true)][string]$CandidateEngineCommit, + [Parameter(Mandatory=$true)][string]$TargetPath, + [switch]$AllowDifferentArsasCommit, [switch]$AllowDifferentEngineCommit, [string]$OutputJson, [switch]$NoFailExit @@ -24,20 +27,46 @@ function Get-IntProperty($Object, [string]$Name) { return [int]$property.Value } -$lockFile = Resolve-File $LockPath "P0-5e golden lock" -$proofFile = Resolve-File $ProofJson "P0-5d proof JSON" +$lockFile = Resolve-File $LockPath 'P0-5e golden lock' +$proofFile = Resolve-File $ProofJson 'P0-5d proof JSON' +$targetFile = Resolve-File $TargetPath 'same-IED semantic target' $lock = Get-Content -LiteralPath $lockFile -Raw | ConvertFrom-Json $proof = Get-Content -LiteralPath $proofFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json $failures = [System.Collections.Generic.List[string]]::new() if ($lock.Phase -ne 'P0-5e' -or $lock.Status -ne 'locked') { $failures.Add('Golden lock is not an active P0-5e locked contract.') } if ($proof.Phase -ne 'P0-5d' -or $proof.Verdict -ne 'PASS') { $failures.Add('Candidate evidence must be a P0-5d PASS proof.') } if ($lock.DeviceIdentity -ne $DeviceIdentity) { $failures.Add("Device identity mismatch: '$DeviceIdentity' != '$($lock.DeviceIdentity)'.") } -if (-not [string]::IsNullOrWhiteSpace($CandidateEngineCommit)) { - if ($CandidateEngineCommit -notmatch '^[0-9a-fA-F]{40}$') { $failures.Add('Candidate engine commit is not a full 40-character SHA.') } - elseif (-not $AllowDifferentEngineCommit -and $CandidateEngineCommit.ToLowerInvariant() -ne [string]$lock.GoldenSource.EngineCommit) { - $failures.Add('Candidate engine commit differs from the golden engine baseline. Use -AllowDifferentEngineCommit only for an intentional regression comparison.') - } +if ($target.Phase -ne 'P0-5e' -or $target.DeviceIdentity -ne $DeviceIdentity) { $failures.Add('Candidate semantic target identity does not match the golden device.') } + +if ($CandidateArsasCommit -notmatch '^[0-9a-fA-F]{40}$') { + $failures.Add('Candidate ARSAS commit is not a full 40-character SHA.') +} elseif (-not $AllowDifferentArsasCommit -and $CandidateArsasCommit.ToLowerInvariant() -ne [string]$lock.GoldenSource.ArsasCommit) { + $failures.Add('Candidate ARSAS commit differs from the golden build baseline. Use -AllowDifferentArsasCommit only for an intentional regression comparison.') +} + +if ($CandidateEngineCommit -notmatch '^[0-9a-fA-F]{40}$') { + $failures.Add('Candidate engine commit is not a full 40-character SHA.') +} elseif (-not $AllowDifferentEngineCommit -and $CandidateEngineCommit.ToLowerInvariant() -ne [string]$lock.GoldenSource.EngineCommit) { + $failures.Add('Candidate engine commit differs from the golden engine baseline. Use -AllowDifferentEngineCommit only for an intentional regression comparison.') +} + +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +if ($null -eq $lock.GoldenSource.PSObject.Properties['SemanticTargetSha256']) { + $failures.Add('Golden lock does not carry semantic-target provenance hash.') +} elseif ($targetHash -ne [string]$lock.GoldenSource.SemanticTargetSha256) { + $failures.Add('Semantic target hash differs from the golden lock authority.') +} +if ($null -eq $lock.GoldenSource.PSObject.Properties['BuildManifestSha256']) { + $failures.Add('Golden lock does not carry exact build-manifest provenance.') +} +if ($null -eq $lock.SemanticTarget) { + $failures.Add('Golden lock does not carry same-IED semantic authority.') +} else { + $lockedSemantic = $lock.SemanticTarget | ConvertTo-Json -Compress -Depth 8 + $targetSemantic = $target.SemanticTarget | ConvertTo-Json -Compress -Depth 8 + if ($lockedSemantic -ne $targetSemantic) { $failures.Add('Semantic target content differs from the golden lock.') } } $actual = $proof.ArsasCapture @@ -79,7 +108,7 @@ if ($null -eq $actual -or $null -eq $budget) { } $result = [ordered]@{ - SchemaVersion = 1 + SchemaVersion = 2 Phase = 'P0-5e' Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } DeviceIdentity = $DeviceIdentity @@ -87,13 +116,18 @@ $result = [ordered]@{ Path = $lockFile CaptureSha256 = $lock.GoldenSource.CaptureSha256 ProofSha256 = $lock.GoldenSource.ProofSha256 + BuildManifestSha256 = $lock.GoldenSource.BuildManifestSha256 + SemanticTargetSha256 = $lock.GoldenSource.SemanticTargetSha256 + ArsasCommit = $lock.GoldenSource.ArsasCommit EngineCommit = $lock.GoldenSource.EngineCommit MaxConfirmedRequests = $lock.HardRequestBudget.MaxConfirmedRequests MaxServiceRequests = $lock.HardRequestBudget.MaxServiceRequests } Candidate = [ordered]@{ ProofPath = $proofFile + ArsasCommit = $CandidateArsasCommit EngineCommit = $CandidateEngineCommit + SemanticTargetSha256 = $targetHash ConfirmedRequests = if ($null -ne $actual) { $actual.ConfirmedRequests } else { $null } ServiceCounts = if ($null -ne $actual) { $actual.ServiceCounts } else { $null } PeakOutstandingRequests = if ($null -ne $actual) { $actual.PeakOutstandingRequests } else { $null } @@ -109,6 +143,8 @@ $result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encod Write-Host "P0-5e golden capture acceptance: $($result.Verdict)" Write-Host " device: $DeviceIdentity" +Write-Host " candidate ARSAS: $CandidateArsasCommit" +Write-Host " candidate engine: $CandidateEngineCommit" Write-Host " candidate requests: $($result.Candidate.ConfirmedRequests); hard max: $($result.GoldenLock.MaxConfirmedRequests)" Write-Host " acceptance JSON: $OutputJson" if ($failures.Count -gt 0) { From e9d3148feaa10c2a58540f8b0c639be938aae64e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:42:56 +0700 Subject: [PATCH 052/243] test(discovery): lock P0-5e capture and build provenance --- ...iscoveryGoldenProvenanceRegressionTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/ARSAS.Tests/SmartDiscoveryGoldenProvenanceRegressionTests.cs diff --git a/tests/ARSAS.Tests/SmartDiscoveryGoldenProvenanceRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryGoldenProvenanceRegressionTests.cs new file mode 100644 index 000000000..376e5c9c4 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryGoldenProvenanceRegressionTests.cs @@ -0,0 +1,51 @@ +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryGoldenProvenanceRegressionTests +{ + [Fact] + public void P05e_WriterReverifiesRawCaptureAndBindsExactBuildAndTarget() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-golden-lock.ps1")); + + Assert.Contains("verify-smart-discovery-pcap.ps1", source, StringComparison.Ordinal); + Assert.Contains("Assert-EquivalentWireProof", source, StringComparison.Ordinal); + Assert.Contains("Production golden lock requires a raw .pcap or .pcapng capture", source, StringComparison.Ordinal); + Assert.Contains("BuildManifestPath", source, StringComparison.Ordinal); + Assert.Contains("ARSAS commit:", source, StringComparison.Ordinal); + Assert.Contains("ARIEC61850 commit:", source, StringComparison.Ordinal); + Assert.Contains("BuildManifestSha256", source, StringComparison.Ordinal); + Assert.Contains("SemanticTargetSha256", source, StringComparison.Ordinal); + Assert.Contains("RawCaptureReverified", source, StringComparison.Ordinal); + Assert.Contains("AllowFixtureEvidence", source, StringComparison.Ordinal); + } + + [Fact] + public void P05e_VerifierRequiresExactArsasEngineAndSemanticTargetByDefault() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-golden-lock.ps1")); + + Assert.Contains("CandidateArsasCommit", source, StringComparison.Ordinal); + Assert.Contains("CandidateEngineCommit", source, StringComparison.Ordinal); + Assert.Contains("Candidate ARSAS commit differs from the golden build baseline", source, StringComparison.Ordinal); + Assert.Contains("Candidate engine commit differs from the golden engine baseline", source, StringComparison.Ordinal); + Assert.Contains("Semantic target hash differs from the golden lock authority", source, StringComparison.Ordinal); + Assert.Contains("BuildManifestSha256", source, StringComparison.Ordinal); + Assert.Contains("AllowDifferentArsasCommit", source, StringComparison.Ordinal); + Assert.Contains("AllowDifferentEngineCommit", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 2c692edefcc0cd8448cdc7c803ec5e23291eea31 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:43:19 +0700 Subject: [PATCH 053/243] ci(discovery): verify P0-5e golden provenance binding --- .../smart-discovery-golden-provenance.yml | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 .github/workflows/smart-discovery-golden-provenance.yml diff --git a/.github/workflows/smart-discovery-golden-provenance.yml b/.github/workflows/smart-discovery-golden-provenance.yml new file mode 100644 index 000000000..7e2535557 --- /dev/null +++ b/.github/workflows/smart-discovery-golden-provenance.yml @@ -0,0 +1,157 @@ +name: Smart Discovery Golden Provenance + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-provenance: + name: Verify P0-5e capture build and target provenance + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Execute P0-5e provenance fixtures + shell: powershell + run: | + $writer = '.\ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1' + $verifier = '.\ArIED61850Tester\scripts\verify-smart-discovery-golden-lock.ps1' + $target = '.\ArIED61850Tester\evidence\smart-discovery-golden-target.json' + foreach ($required in @($writer, $verifier, $target)) { + if (-not (Test-Path $required -PathType Leaf)) { throw "Missing P0-5e provenance source: $required" } + } + + foreach ($script in @($writer, $verifier)) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { throw "PowerShell parse failure in $script" } + } + + $results = '.\ArIED61850Tester\TestResults' + New-Item -ItemType Directory -Force $results | Out-Null + $capture = Join-Path $results 'p0-5e-provenance-fixture.pcapng' + [IO.File]::WriteAllBytes($capture, [Text.Encoding]::UTF8.GetBytes('CI fixture only - never physical authority')) + + $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + $engineCommit = '4467124775d8d9d76f3db194f9fbfd97144767a8' + $manifest = Join-Path $results 'SMART-CAPTURE-BUILD.txt' + @( + 'ARSAS smart discovery field-capture build', + "ARSAS commit: $arsasCommit", + "ARIEC61850 commit: $engineCommit", + 'Engine PR: 134' + ) | Set-Content $manifest -Encoding utf8 + + $proofPath = Join-Path $results 'P0-5D-provenance-source.json' + $proof = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5d' + Verdict = 'PASS' + AcceptanceFailures = @() + ArsasCapture = [ordered]@{ + Capture = 'fixture' + ClientIp = '192.0.2.10' + ServerIp = '192.0.2.20' + RequestTcpStreams = @('0') + ConfirmedRequests = 4 + ConfirmedResponsesOrErrors = 4 + ServiceCounts = [ordered]@{ + GetNameList = 2 + GetVariableAccessAttributes = 2 + } + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + DuplicateDetails = @() + SecondGetNameListSweepDetected = $false + PeakOutstandingRequests = 3 + NegotiatedMaxOutstandingCalling = 10 + InvokeIdReuseWhileOutstanding = 0 + OrphanResponses = 0 + UnansweredRequestsAtCaptureEnd = 0 + } + } + $proof | ConvertTo-Json -Depth 12 | Set-Content $proofPath -Encoding utf8 + + $lockPath = Join-Path $results 'P0-5E-provenance.lock.json' + & $writer ` + -ProofJson $proofPath ` + -CapturePath $capture ` + -DeviceIdentity 'AA1E1F06R4' ` + -ArsasCommit $arsasCommit ` + -EngineCommit $engineCommit ` + -TargetPath $target ` + -BuildManifestPath $manifest ` + -OutputPath $lockPath ` + -AllowFixtureEvidence + + $lock = Get-Content $lockPath -Raw | ConvertFrom-Json + if ($lock.SchemaVersion -ne 2 -or + $lock.GoldenSource.BuildManifestSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.SemanticTargetSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.RawCaptureReverified) { + throw 'Fixture lock did not carry expected P0-5e provenance metadata.' + } + + $acceptPath = Join-Path $results 'P0-5E-provenance-pass.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $proofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` + -OutputJson $acceptPath + $accept = Get-Content $acceptPath -Raw | ConvertFrom-Json + if ($accept.Verdict -ne 'PASS') { throw 'Exact build/target fixture should pass the golden provenance lock.' } + + $wrongArsas = '1111111111111111111111111111111111111111' + $mismatchPath = Join-Path $results 'P0-5E-provenance-arsas-mismatch.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $proofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $wrongArsas ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` + -OutputJson $mismatchPath ` + -NoFailExit + $mismatch = Get-Content $mismatchPath -Raw | ConvertFrom-Json + if ($mismatch.Verdict -ne 'FAIL' -or + -not (@($mismatch.AcceptanceFailures) -match 'Candidate ARSAS commit differs')) { + throw 'P0-5e did not reject ARSAS build identity drift.' + } + + $alteredTarget = Join-Path $results 'altered-target.json' + $changed = Get-Content $target -Raw | ConvertFrom-Json + $changed.SemanticTarget.LogicalNodes = 120 + $changed | ConvertTo-Json -Depth 12 | Set-Content $alteredTarget -Encoding utf8 + $targetMismatchPath = Join-Path $results 'P0-5E-provenance-target-mismatch.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $proofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $alteredTarget ` + -OutputJson $targetMismatchPath ` + -NoFailExit + $targetMismatch = Get-Content $targetMismatchPath -Raw | ConvertFrom-Json + if ($targetMismatch.Verdict -ne 'FAIL' -or + -not (@($targetMismatch.AcceptanceFailures) -match 'Semantic target hash differs')) { + throw 'P0-5e did not reject semantic-target authority drift.' + } + + - name: Upload P0-5e provenance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5e-golden-provenance-fixtures + path: ArIED61850Tester\TestResults\P0-5E-*.json + if-no-files-found: warn + retention-days: 14 From e93c089ede3b0fd0653c5975921c426c92ebaaee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:43:44 +0700 Subject: [PATCH 054/243] ci(discovery): migrate P0-5e budget fixtures to provenance contract --- .../smart-discovery-golden-budget-lock.yml | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/smart-discovery-golden-budget-lock.yml b/.github/workflows/smart-discovery-golden-budget-lock.yml index 3766893d2..0459d702f 100644 --- a/.github/workflows/smart-discovery-golden-budget-lock.yml +++ b/.github/workflows/smart-discovery-golden-budget-lock.yml @@ -90,8 +90,16 @@ jobs: } $proof | ConvertTo-Json -Depth 12 | Set-Content $proofPath -Encoding utf8 - $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() + $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() $engineCommit = '4467124775d8d9d76f3db194f9fbfd97144767a8' + $manifest = Join-Path $results 'SMART-CAPTURE-BUILD.txt' + @( + 'ARSAS smart discovery field-capture build', + "ARSAS commit: $arsasCommit", + "ARIEC61850 commit: $engineCommit", + 'Engine PR: 134' + ) | Set-Content $manifest -Encoding utf8 + $lockPath = Join-Path $results 'P0-5E-fixture-golden.lock.json' & $writer ` -ProofJson $proofPath ` @@ -100,15 +108,20 @@ jobs: -ArsasCommit $arsasCommit ` -EngineCommit $engineCommit ` -TargetPath $target ` - -OutputPath $lockPath + -BuildManifestPath $manifest ` + -OutputPath $lockPath ` + -AllowFixtureEvidence $lock = Get-Content $lockPath -Raw | ConvertFrom-Json if ($lock.Status -ne 'locked' -or + $lock.SchemaVersion -ne 2 -or $lock.HardRequestBudget.MaxConfirmedRequests -ne 4 -or $lock.HardRequestBudget.MaxServiceRequests.GetNameList -ne 2 -or $lock.HardRequestBudget.MaxServiceRequests.GetVariableAccessAttributes -ne 2 -or $lock.GoldenSource.CaptureSha256 -notmatch '^[0-9a-f]{64}$' -or - $lock.GoldenSource.ProofSha256 -notmatch '^[0-9a-f]{64}$') { + $lock.GoldenSource.ProofSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.BuildManifestSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.SemanticTargetSha256 -notmatch '^[0-9a-f]{64}$') { throw 'P0-5e lock writer did not derive the expected fixture budget/provenance.' } @@ -117,7 +130,9 @@ jobs: -LockPath $lockPath ` -ProofJson $proofPath ` -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` -OutputJson $acceptPath $accept = Get-Content $acceptPath -Raw | ConvertFrom-Json if ($accept.Verdict -ne 'PASS') { throw 'P0-5e golden fixture should pass its own lock.' } @@ -132,7 +147,9 @@ jobs: -LockPath $lockPath ` -ProofJson $growthProofPath ` -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` -OutputJson $growthAcceptPath ` -NoFailExit $growthAcceptance = Get-Content $growthAcceptPath -Raw | ConvertFrom-Json @@ -150,7 +167,9 @@ jobs: -LockPath $lockPath ` -ProofJson $serviceProofPath ` -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` -OutputJson $serviceAcceptPath ` -NoFailExit $serviceAcceptance = Get-Content $serviceAcceptPath -Raw | ConvertFrom-Json From 971d4c547d5706e545b34a68acca6072fb6c73af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:45:01 +0700 Subject: [PATCH 055/243] docs(discovery): document P0-5e provenance hardening --- docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md | 65 ++++++++++++++++-------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md b/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md index 40c0ff068..2156c155c 100644 --- a/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md +++ b/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md @@ -20,32 +20,53 @@ For `AA1E1F06R4` the canonical semantic target remains: The target intentionally contains `BudgetValues: null` until a fresh physical P0-5d PASS exists. Fixture numbers are never promoted into the production golden budget. -## Create the hard lock from physical evidence +## Production evidence inputs + +A production lock requires all of the following: -Required inputs: +1. the raw, complete same-IED `.pcap` or `.pcapng` generated by the exact field artifact; +2. a P0-5d proof JSON with `Verdict=PASS` for that capture; +3. `SMART-CAPTURE-BUILD.txt` from the exact same field artifact; +4. the full ARSAS commit SHA recorded by that manifest; +5. the full ARIEC61850 engine commit SHA recorded by that manifest; +6. the tracked same-IED semantic target. -1. raw, complete same-IED PCAP/PCAPNG generated by the exact field artifact; -2. P0-5d proof JSON for that raw capture with `Verdict=PASS`; -3. full ARSAS commit SHA used to produce the capture; -4. full ARIEC61850 engine commit SHA; -5. the tracked same-IED semantic target. +The lock writer independently re-runs the P0-5d verifier against the raw capture and compares the resulting wire metrics/service counts with the supplied proof before any lock is written. A PASS proof from capture A therefore cannot be paired with capture B. -Run: +`-AllowFixtureEvidence` exists only for deterministic CI fixtures. Do not use it for physical acceptance. + +## Create the hard lock from physical evidence + +Run from the extracted field artifact directory: ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\new-smart-discovery-golden-lock.ps1 ` -ProofJson .\P0-5D-physical-proof.json ` -CapturePath .\physical-discovery.pcapng ` -DeviceIdentity AA1E1F06R4 ` - -ArsasCommit <40-char-arsas-sha> ` + -ArsasCommit <40-char-arsas-sha-from-manifest> ` -EngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 ` -TargetPath .\evidence\smart-discovery-golden-target.json ` + -BuildManifestPath .\SMART-CAPTURE-BUILD.txt ` -OutputPath .\evidence\smart-discovery-golden-budget.lock.json ``` -The lock writer refuses a non-PASS proof, duplicate semantic request evidence, duplicate GetNameList/GVA, second naming sweep, invoke-ID anomaly, orphan response, unanswered request, invalid commit SHA, or target/IED mismatch. +The writer refuses: + +- non-PASS P0-5d evidence; +- non-PCAP production evidence; +- duplicate semantic requests; +- duplicate GetNameList/GVA; +- a second naming sweep; +- invoke-ID reuse while outstanding; +- orphan/unanswered requests; +- a raw capture that does not independently reproduce the supplied proof; +- ARSAS or engine SHA that does not match `SMART-CAPTURE-BUILD.txt`; +- device/engine mismatch against the tracked same-IED target. + +The generated lock records SHA-256 for the raw capture, P0-5d proof, build manifest, and semantic target. It also embeds the reviewed semantic target and exact ARSAS/ARIEC61850 commits. -The generated lock records SHA-256 for both the raw capture and the P0-5d proof. The request hard maximum is exactly the confirmed-request count observed in that physical PASS. Each observed MMS service count is also locked as its own maximum. A service absent from the golden capture has an implicit maximum of zero. +The hard confirmed-request maximum is exactly the count observed in the physical PASS. Each MMS service count is separately locked as its own maximum. A service absent from the golden capture has an implicit maximum of zero. ## Verify later captures against the golden budget @@ -56,26 +77,30 @@ powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-golden -LockPath .\evidence\smart-discovery-golden-budget.lock.json ` -ProofJson .\P0-5D-candidate-proof.json ` -DeviceIdentity AA1E1F06R4 ` - -CandidateEngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 + -CandidateArsasCommit ` + -CandidateEngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 ` + -TargetPath .\evidence\smart-discovery-golden-target.json ``` -A PASS requires: +A default PASS requires: -- candidate P0-5d proof is itself PASS; +- candidate P0-5d proof is PASS; - exact same IED identity; -- confirmed-request total does not exceed the locked golden maximum; +- candidate ARSAS commit matches the golden artifact; +- candidate engine commit matches the golden engine; +- semantic-target file hash/content matches the golden lock; +- confirmed-request total does not exceed the locked maximum; - no service count exceeds its locked maximum; - no new/unexpected MMS service appears; - duplicate semantic request/GetNameList/GVA remain zero; - second GetNameList sweep remains forbidden; - invoke-ID/orphan/unanswered counts remain zero; -- peak outstanding remains within the locked maximum; -- engine commit matches the golden engine by default. +- peak outstanding remains within the locked maximum. -For an intentional future engine experiment, `-AllowDifferentEngineCommit` allows comparison against the old golden budget without silently changing the lock. If the new engine legitimately changes the budget contract, a new physical PASS and explicit lock replacement are required. +For an intentional future comparison, `-AllowDifferentArsasCommit` and/or `-AllowDifferentEngineCommit` can compare a new build against the old golden request envelope. These switches do not modify the lock and must never be used to silently redefine production authority. ## Lock replacement policy -`smart-discovery-golden-budget.lock.json` must never be hand-edited to make a failing candidate pass. Replace it only by re-running the lock writer against a newly reviewed physical P0-5d PASS. Preserve the old capture/proof hashes in review history. +`smart-discovery-golden-budget.lock.json` must never be hand-edited to make a failing candidate pass. Replace it only by re-running the writer against a newly reviewed physical P0-5d PASS produced by the exact candidate field artifact. Preserve the old capture/proof/build/target hashes in review history. -The raw physical capture remains the primary evidence. The P0-5d proof is derived wire evidence, and the P0-5e lock is the regression contract derived from that evidence. +The raw physical capture remains primary wire evidence. The P0-5d proof is derived wire evidence. The field build manifest proves application/engine identity. The P0-5e semantic target is reviewed model authority. The P0-5e golden lock is the regression contract derived from all four. From c92061c9a4242e4b6550b255604948090f0218cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 10:46:03 +0700 Subject: [PATCH 056/243] fix(discovery): make P0-5e writer parse on Windows PowerShell --- scripts/new-smart-discovery-golden-lock.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/new-smart-discovery-golden-lock.ps1 b/scripts/new-smart-discovery-golden-lock.ps1 index 36c7b2e7c..11d7970a9 100644 --- a/scripts/new-smart-discovery-golden-lock.ps1 +++ b/scripts/new-smart-discovery-golden-lock.ps1 @@ -53,7 +53,7 @@ function Assert-EquivalentWireProof($Expected, $Observed) { 'UnansweredRequestsAtCaptureEnd')) { $a = Get-IntProperty $Expected $name $b = Get-IntProperty $Observed $name - if ($a -ne $b) { throw "Proof/capture mismatch for $name: supplied=$a reverified=$b." } + if ($a -ne $b) { throw "Proof/capture mismatch for ${name}: supplied=$a reverified=$b." } } if ([bool]$Expected.SecondGetNameListSweepDetected -ne [bool]$Observed.SecondGetNameListSweepDetected) { From 59b14e1cd00b636dd4fd002d8533fde104f3049b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:24:14 +0700 Subject: [PATCH 057/243] P0-5f emit zero-traffic repeat-run discovery evidence --- ...0Client.SmartDiscoveryRepeatRunEvidence.cs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs diff --git a/Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs b/Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs new file mode 100644 index 000000000..aa454a281 --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs @@ -0,0 +1,183 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private const string P05fEngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + public string LastSmartDiscoveryRepeatRunEvidencePath { get; private set; } = string.Empty; + + private string TryWriteSmartDiscoveryRepeatRunEvidence( + long associationGeneration, + ArMms.MmsDiscoveryResult discovery, + IReadOnlyList signals, + Iec61850DeviceIdentity identity) + { + try + { + // Local bookkeeping only. This refresh sends no MMS traffic and makes the + // engine KPI signature reflect hierarchy-materialized live directory counts. + _session.RefreshSmartDiscoveryModelKpi(discovery.IedDirectory); + + var kpi = _session.LastSmartDiscoveryKpi; + var typeBudget = _session.LastSmartTypeProbeBudget; + if (kpi is null) + return string.Empty; + + var directorySignature = ComputeDirectoryModelSignature(discovery.IedDirectory); + var projectionSignature = ComputeSignalProjectionSignature(signals); + var deviceIdentity = string.IsNullOrWhiteSpace(identity.IedName) + ? DetectedIedName + : identity.IedName; + + var payload = new + { + SchemaVersion = 1, + Phase = "P0-5f-run", + CapturedAtUtc = DateTimeOffset.UtcNow, + DeviceIdentity = deviceIdentity ?? string.Empty, + Host = _host, + Port = _port, + AssociationGeneration = associationGeneration, + EngineCommit = P05fEngineCommit, + Model = new + { + LogicalDevices = discovery.IedDirectory.LogicalDeviceCount, + LogicalNodes = discovery.IedDirectory.LogicalNodeCount, + SemanticPoints = discovery.IedDirectory.PointCount, + ProjectedSignals = signals.Count, + DataSets = discovery.ReportInventory.DataSets.Count, + ReportControls = discovery.ReportInventory.ReportControls.Count, + BufferedReportControls = discovery.ReportInventory.BufferedCount, + UnbufferedReportControls = discovery.ReportInventory.UnbufferedCount, + DirectoryModelSignature = directorySignature, + ProjectionSignature = projectionSignature + }, + SmartDiscoveryKpi = new + { + kpi.Generation, + kpi.TotalRequests, + kpi.SuccessfulRequests, + kpi.FailedRequests, + kpi.DuplicateRequests, + kpi.PeakOutstandingRequests, + kpi.WireAccountingComplete, + kpi.AccountingNotes, + kpi.LogicalDeviceCount, + kpi.LogicalNodeCount, + kpi.RawVariableCount, + kpi.FcPointCount, + kpi.DataSetCount, + kpi.DataSetDirectoryCount, + kpi.DataSetMemberCount, + kpi.ReportControlCount, + kpi.BufferedReportControlCount, + kpi.UnbufferedReportControlCount, + kpi.DeterministicSignature + }, + TypeProbeBudget = typeBudget is null + ? null + : new + { + typeBudget.DirectoryPoints, + typeBudget.SuppliedLogicalNodeCandidates, + typeBudget.SuppressedNonLiveLogicalNodeCandidates, + typeBudget.LogicalNodeRequests, + typeBudget.PointsCoveredByLogicalNode, + typeBudget.DataObjectRequests, + typeBudget.PointsCoveredByDataObject, + typeBudget.ExactLeafRequests, + typeBudget.SuppressedExactRepeatRequests, + typeBudget.PointsCoveredByExactLeaf, + typeBudget.RemainingUnresolvedPoints, + typeBudget.TotalPlannedRequests + } + }; + + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "SmartDiscoveryEvidence"); + Directory.CreateDirectory(root); + + var safeIdentity = SanitizeEvidenceFileToken( + string.IsNullOrWhiteSpace(deviceIdentity) ? "unknown-ied" : deviceIdentity); + var stamp = DateTimeOffset.UtcNow.ToString("yyyyMMddTHHmmssfffZ"); + var path = Path.Combine(root, $"P0-5F-{safeIdentity}-{stamp}-g{associationGeneration}.json"); + var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + LastSmartDiscoveryRepeatRunEvidencePath = path; + return path; + } + catch + { + // P0-5f evidence is observability only. A local filesystem problem must not + // turn a successful IEC 61850 discovery into a protocol failure. + LastSmartDiscoveryRepeatRunEvidencePath = string.Empty; + return string.Empty; + } + } + + private static string ComputeDirectoryModelSignature(ArMms.MmsIedModelDirectory directory) + { + var canonical = directory.Points + .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.DataObjectPath, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase) + .Select(point => string.Join('|', + NormalizeSignaturePart(point.Domain), + NormalizeSignaturePart(point.LogicalNode), + NormalizeSignaturePart(point.FunctionalConstraint), + NormalizeSignaturePart(point.DataObjectPath), + NormalizeSignaturePart(point.MmsItemName))) + .ToArray(); + + return Sha256Lines(canonical); + } + + private static string ComputeSignalProjectionSignature(IReadOnlyList signals) + { + var canonical = signals + .OrderBy(signal => signal.ObjectReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.DataType, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase) + .Select(signal => string.Join('|', + NormalizeSignaturePart(signal.ObjectReference), + NormalizeSignaturePart(signal.FunctionalConstraint), + NormalizeSignaturePart(signal.DataType), + NormalizeSignaturePart(signal.Name), + NormalizeSignaturePart(signal.Category), + NormalizeSignaturePart(signal.DataSetReference), + NormalizeSignaturePart(signal.ReportControlReference), + NormalizeSignaturePart(signal.QualityReference), + NormalizeSignaturePart(signal.TimestampReference), + NormalizeSignaturePart(signal.Source))) + .ToArray(); + + return Sha256Lines(canonical); + } + + private static string Sha256Lines(IEnumerable lines) + { + var text = string.Join('\n', lines); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); + } + + private static string NormalizeSignaturePart(string? value) + => (value ?? string.Empty).Trim().Replace('\r', ' ').Replace('\n', ' ').ToLowerInvariant(); + + private static string SanitizeEvidenceFileToken(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + var chars = value.Trim().Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray(); + return new string(chars); + } +} From 97e3efd48a1edca0555aaa18ca53ffcba82d8b78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:25:02 +0700 Subject: [PATCH 058/243] P0-5f bind fresh association discovery to repeat-run evidence --- ...iveIec61850Client.SmartDiscoveryCapture.cs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index 0a640f16e..c4d96f3e4 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -74,7 +74,9 @@ private async Task> DiscoverSignalsSmartForCaptu { // A completed discovery on this exact association generation is wire-free. // Concurrent callers do not reach this branch independently because they - // already share the same association flight above. + // already share the same association flight above. P0-5f deliberately does + // not emit a new repeat-run evidence file from this cached branch: a repeat + // physical run requires a new association generation and fresh wire traffic. if (TryGetSmartDiscoveryAuthority(out var cachedDiscovery, out var cachedModel)) { progress?.Report(new IedDiscoveryProgress( @@ -274,6 +276,27 @@ private async Task> DiscoverSignalsSmartForCaptu return Array.Empty(); } + // P0-5f: emit one local, zero-traffic evidence snapshot only after a fresh + // association owner has successfully published authority. Cached reuse above + // deliberately never reaches this call and therefore cannot masquerade as an + // independent physical repeat run. + if (IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + var repeatEvidencePath = TryWriteSmartDiscoveryRepeatRunEvidence( + associationGeneration, + discovery, + signals, + identity); + var repeatKpi = _session.LastSmartDiscoveryKpi; + if (!string.IsNullOrWhiteSpace(repeatEvidencePath) && + IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + LastDiscoverySummary += + $" P0-5f repeatEvidence={repeatEvidencePath}; " + + $"kpiSignature={repeatKpi?.DeterministicSignature ?? "unavailable"}."; + } + } + return signals; } catch (Exception ex) when (ex is not OperationCanceledException) From 62d237b2b02bec59a55a11fcb5bd98d5be882710 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:25:29 +0700 Subject: [PATCH 059/243] P0-5f define physical repeat-run stability authority --- .../smart-discovery-repeat-run-target.json | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 evidence/smart-discovery-repeat-run-target.json diff --git a/evidence/smart-discovery-repeat-run-target.json b/evidence/smart-discovery-repeat-run-target.json new file mode 100644 index 000000000..6bf095cc0 --- /dev/null +++ b/evidence/smart-discovery-repeat-run-target.json @@ -0,0 +1,40 @@ +{ + "SchemaVersion": 1, + "Phase": "P0-5f", + "Status": "awaiting-physical-golden-lock-and-three-independent-runs", + "DeviceIdentity": "AA1E1F06R4", + "EngineCommit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "MinimumIndependentAssociations": 3, + "SemanticTarget": { + "LogicalDevices": 32, + "LogicalNodes": 119, + "SemanticLeaves": 4925, + "DataSets": 2, + "OrderedFcdaMembers": 58, + "LogicalReportControls": 32, + "RuntimeReportControlInstances": 34, + "IndexedBufferedFamilyMax": 2, + "IndexedUnbufferedFamilyMax": 2, + "SyntheticReportControlInstancesAllowed": 0 + }, + "RepeatRunContract": { + "RequireProductionGoldenLock": true, + "RequireFreshAssociationGenerationPerRun": true, + "RequireExactArsasCommit": true, + "RequireExactEngineCommit": true, + "RequireExactBuildManifestHash": true, + "RequireExactSemanticTargetHash": true, + "RequireWireAccountingComplete": true, + "RequireZeroDuplicateSemanticRequests": true, + "RequireZeroEngineDuplicateRequests": true, + "RequireExactConfirmedRequestCountAcrossRuns": true, + "RequireExactServiceMixAcrossRuns": true, + "RequireExactEngineKpiSignatureAcrossRuns": true, + "RequireExactDirectoryModelSignatureAcrossRuns": true, + "RequireExactProjectionSignatureAcrossRuns": true, + "RequireExactTypeProbeBudgetAcrossRuns": true, + "RequireExactModelCountsAcrossRuns": true, + "PeakOutstandingRule": "within-golden-lock-max-record-range" + }, + "FinalizationAuthority": null +} From eabb9b24e52a044c3aa8ebe6ebe43195d43a365c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:26:20 +0700 Subject: [PATCH 060/243] P0-5f bind each physical repeat run into immutable evidence bundle --- .../new-smart-discovery-repeat-run-bundle.ps1 | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 scripts/new-smart-discovery-repeat-run-bundle.ps1 diff --git a/scripts/new-smart-discovery-repeat-run-bundle.ps1 b/scripts/new-smart-discovery-repeat-run-bundle.ps1 new file mode 100644 index 000000000..6513f805c --- /dev/null +++ b/scripts/new-smart-discovery-repeat-run-bundle.ps1 @@ -0,0 +1,222 @@ +param( + [Parameter(Mandatory=$true)][string]$GoldenLockPath, + [Parameter(Mandatory=$true)][string]$ProofJson, + [Parameter(Mandatory=$true)][string]$CapturePath, + [Parameter(Mandatory=$true)][string]$RuntimeEvidenceJson, + [Parameter(Mandatory=$true)][string]$BuildManifestPath, + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$DeviceIdentity, + [Parameter(Mandatory=$true)][string]$ArsasCommit, + [Parameter(Mandatory=$true)][string]$EngineCommit, + [Parameter(Mandatory=$true)][string]$OutputPath, + [switch]$AllowFixtureEvidence +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Get-Int($Object, [string]$Name) { + if ($null -eq $Object) { return 0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return 0 } + return [int]$property.Value +} + +function Get-ServiceMap($Object) { + $map = [ordered]@{} + if ($null -eq $Object) { return $map } + foreach ($property in @($Object.PSObject.Properties | Sort-Object Name)) { + $map[$property.Name] = [int]$property.Value + } + return $map +} + +function Assert-SameServiceMap($Expected, $Observed, [string]$Label) { + $a = Get-ServiceMap $Expected + $b = Get-ServiceMap $Observed + $names = @($a.Keys + $b.Keys | Sort-Object -Unique) + foreach ($name in $names) { + $av = if ($a.Contains($name)) { [int]$a[$name] } else { 0 } + $bv = if ($b.Contains($name)) { [int]$b[$name] } else { 0 } + if ($av -ne $bv) { throw "${Label}: service '$name' differs: supplied=$av observed=$bv." } + } +} + +$goldenLockFile = Resolve-File $GoldenLockPath 'P0-5e golden lock' +$proofFile = Resolve-File $ProofJson 'P0-5d proof' +$captureFile = Resolve-File $CapturePath 'repeat raw capture' +$runtimeFile = Resolve-File $RuntimeEvidenceJson 'P0-5f runtime evidence' +$manifestFile = Resolve-File $BuildManifestPath 'field build manifest' +$targetFile = Resolve-File $TargetPath 'same-IED semantic target' +Assert-Commit $ArsasCommit 'ARSAS commit' +Assert-Commit $EngineCommit 'Engine commit' + +$arsasCommitNormalized = $ArsasCommit.ToLowerInvariant() +$engineCommitNormalized = $EngineCommit.ToLowerInvariant() +$lock = Get-Content -LiteralPath $goldenLockFile -Raw | ConvertFrom-Json +$proof = Get-Content -LiteralPath $proofFile -Raw | ConvertFrom-Json +$runtime = Get-Content -LiteralPath $runtimeFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json + +if ($lock.Phase -ne 'P0-5e' -or $lock.Status -ne 'locked') { throw 'P0-5f requires an active P0-5e golden lock.' } +if (-not $AllowFixtureEvidence -and -not [bool]$lock.GoldenSource.RawCaptureReverified) { + throw 'Production P0-5f requires a P0-5e lock created from independently reverified physical capture evidence.' +} +if ($lock.DeviceIdentity -ne $DeviceIdentity -or $target.DeviceIdentity -ne $DeviceIdentity) { + throw 'Repeat-run device identity does not match golden/target authority.' +} +if ([string]$lock.GoldenSource.ArsasCommit -ne $arsasCommitNormalized) { throw 'Repeat-run ARSAS commit differs from the golden lock.' } +if ([string]$lock.GoldenSource.EngineCommit -ne $engineCommitNormalized) { throw 'Repeat-run engine commit differs from the golden lock.' } +if ([string]$target.EngineCommit -ne $engineCommitNormalized) { throw 'Repeat-run engine commit differs from the same-IED target.' } + +$manifestHash = (Get-FileHash -LiteralPath $manifestFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +if ([string]$lock.GoldenSource.BuildManifestSha256 -ne $manifestHash) { throw 'Field build manifest hash differs from the golden lock.' } +if ([string]$lock.GoldenSource.SemanticTargetSha256 -ne $targetHash) { throw 'Semantic target hash differs from the golden lock.' } + +$manifestText = Get-Content -LiteralPath $manifestFile -Raw +$manifestArsas = [regex]::Match($manifestText, '(?im)^ARSAS commit:\s*([0-9a-f]{40})\s*$') +$manifestEngine = [regex]::Match($manifestText, '(?im)^ARIEC61850 commit:\s*([0-9a-f]{40})\s*$') +if (-not $manifestArsas.Success -or -not $manifestEngine.Success) { throw 'Build manifest is missing exact ARSAS/engine commit identity.' } +if ($manifestArsas.Groups[1].Value -ne $arsasCommitNormalized -or $manifestEngine.Groups[1].Value -ne $engineCommitNormalized) { + throw 'Build manifest commit identity does not match repeat-run inputs.' +} + +if ($proof.Phase -ne 'P0-5d' -or $proof.Verdict -ne 'PASS') { throw 'Repeat run requires a P0-5d PASS proof.' } +if ($runtime.Phase -ne 'P0-5f-run' -or $runtime.SchemaVersion -ne 1) { throw 'Runtime evidence is not a supported P0-5f-run snapshot.' } +if ([string]$runtime.DeviceIdentity -ne $DeviceIdentity) { throw 'Runtime evidence device identity mismatch.' } +if ([string]$runtime.EngineCommit -ne $engineCommitNormalized) { throw 'Runtime evidence engine commit mismatch.' } +if ($null -eq $runtime.SmartDiscoveryKpi -or -not [bool]$runtime.SmartDiscoveryKpi.WireAccountingComplete) { + throw 'Runtime evidence does not have complete smart-discovery wire accounting.' +} +if ((Get-Int $runtime.SmartDiscoveryKpi 'DuplicateRequests') -ne 0) { throw 'Runtime engine KPI reports duplicate smart-discovery requests.' } +if ([string]::IsNullOrWhiteSpace([string]$runtime.SmartDiscoveryKpi.DeterministicSignature)) { throw 'Runtime engine KPI deterministic signature is missing.' } +if ([string]::IsNullOrWhiteSpace([string]$runtime.Model.DirectoryModelSignature) -or + [string]::IsNullOrWhiteSpace([string]$runtime.Model.ProjectionSignature)) { + throw 'Runtime model/projection signature is missing.' +} +if ($null -eq $runtime.TypeProbeBudget) { throw 'Runtime hierarchy type-probe budget is missing.' } + +# Re-validate the P0-5d proof against the locked request budget and exact build identity. +$goldenVerifier = Join-Path $PSScriptRoot 'verify-smart-discovery-golden-lock.ps1' +if (-not (Test-Path -LiteralPath $goldenVerifier -PathType Leaf)) { throw 'P0-5e golden verifier is missing.' } +$goldenAcceptance = Join-Path ([IO.Path]::GetTempPath()) ("p0-5f-golden-{0}.json" -f [Guid]::NewGuid().ToString('N')) +try { + & $goldenVerifier ` + -LockPath $goldenLockFile ` + -ProofJson $proofFile ` + -DeviceIdentity $DeviceIdentity ` + -CandidateArsasCommit $arsasCommitNormalized ` + -CandidateEngineCommit $engineCommitNormalized ` + -TargetPath $targetFile ` + -OutputJson $goldenAcceptance ` + -NoFailExit + $accepted = Get-Content -LiteralPath $goldenAcceptance -Raw | ConvertFrom-Json + if ($accepted.Verdict -ne 'PASS') { + throw "Repeat run exceeds the P0-5e golden lock: $(@($accepted.AcceptanceFailures) -join '; ')" + } +} +finally { + Remove-Item -LiteralPath $goldenAcceptance -Force -ErrorAction SilentlyContinue +} + +# Production default independently decodes the supplied raw capture again. This binds +# the run bundle to real wire evidence rather than trusting a detached proof JSON. +$extension = [IO.Path]::GetExtension($captureFile).ToLowerInvariant() +if (-not $AllowFixtureEvidence -and $extension -notin @('.pcap', '.pcapng')) { throw 'Production repeat evidence requires raw .pcap/.pcapng input.' } +if (-not $AllowFixtureEvidence) { + $wireVerifier = Join-Path $PSScriptRoot 'verify-smart-discovery-pcap.ps1' + $reproofPath = Join-Path ([IO.Path]::GetTempPath()) ("p0-5f-reproof-{0}.json" -f [Guid]::NewGuid().ToString('N')) + try { + & $wireVerifier -PcapPath $captureFile -OutputJson $reproofPath -NoFailExit + $reproof = Get-Content -LiteralPath $reproofPath -Raw | ConvertFrom-Json + if ($reproof.Verdict -ne 'PASS') { throw 'Raw repeat capture does not independently reproduce a P0-5d PASS.' } + foreach ($name in @('ConfirmedRequests','DuplicateSemanticRequests','DuplicateGetNameListRequests','DuplicateGvaRequests','PeakOutstandingRequests','InvokeIdReuseWhileOutstanding','OrphanResponses','UnansweredRequestsAtCaptureEnd')) { + $a = Get-Int $proof.ArsasCapture $name + $b = Get-Int $reproof.ArsasCapture $name + if ($a -ne $b) { throw "Proof/raw-capture mismatch for ${name}: proof=$a redecoded=$b." } + } + Assert-SameServiceMap $proof.ArsasCapture.ServiceCounts $reproof.ArsasCapture.ServiceCounts 'Proof/raw-capture mismatch' + } + finally { + Remove-Item -LiteralPath $reproofPath -Force -ErrorAction SilentlyContinue + } +} + +$wireRequests = Get-Int $proof.ArsasCapture 'ConfirmedRequests' +$engineRequests = Get-Int $runtime.SmartDiscoveryKpi 'TotalRequests' +if ($wireRequests -ne $engineRequests) { + throw "Wire/engine request accounting mismatch: PCAP=$wireRequests engine=$engineRequests." +} +if ((Get-Int $proof.ArsasCapture 'DuplicateSemanticRequests') -ne 0 -or + (Get-Int $proof.ArsasCapture 'DuplicateGetNameListRequests') -ne 0 -or + (Get-Int $proof.ArsasCapture 'DuplicateGvaRequests') -ne 0 -or + [bool]$proof.ArsasCapture.SecondGetNameListSweepDetected) { + throw 'Repeat wire proof contains duplicate or second-sweep traffic.' +} + +$captureHash = (Get-FileHash -LiteralPath $captureFile -Algorithm SHA256).Hash.ToLowerInvariant() +$proofHash = (Get-FileHash -LiteralPath $proofFile -Algorithm SHA256).Hash.ToLowerInvariant() +$runtimeHash = (Get-FileHash -LiteralPath $runtimeFile -Algorithm SHA256).Hash.ToLowerInvariant() +$goldenHash = (Get-FileHash -LiteralPath $goldenLockFile -Algorithm SHA256).Hash.ToLowerInvariant() + +$bundle = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5f-run-bundle' + Verdict = 'PASS' + DeviceIdentity = $DeviceIdentity + ArsasCommit = $arsasCommitNormalized + EngineCommit = $engineCommitNormalized + GoldenLockSha256 = $goldenHash + BuildManifestSha256 = $manifestHash + SemanticTargetSha256 = $targetHash + FixtureEvidence = [bool]$AllowFixtureEvidence + Provenance = [ordered]@{ + CaptureFileName = [IO.Path]::GetFileName($captureFile) + CaptureSha256 = $captureHash + ProofFileName = [IO.Path]::GetFileName($proofFile) + ProofSha256 = $proofHash + RuntimeEvidenceFileName = [IO.Path]::GetFileName($runtimeFile) + RuntimeEvidenceSha256 = $runtimeHash + } + Wire = [ordered]@{ + ClientIp = $proof.ArsasCapture.ClientIp + ServerIp = $proof.ArsasCapture.ServerIp + ConfirmedRequests = $wireRequests + ServiceCounts = $proof.ArsasCapture.ServiceCounts + PeakOutstandingRequests = Get-Int $proof.ArsasCapture 'PeakOutstandingRequests' + NegotiatedMaxOutstandingCalling = $proof.ArsasCapture.NegotiatedMaxOutstandingCalling + DuplicateSemanticRequests = Get-Int $proof.ArsasCapture 'DuplicateSemanticRequests' + DuplicateGetNameListRequests = Get-Int $proof.ArsasCapture 'DuplicateGetNameListRequests' + DuplicateGvaRequests = Get-Int $proof.ArsasCapture 'DuplicateGvaRequests' + SecondGetNameListSweepDetected = [bool]$proof.ArsasCapture.SecondGetNameListSweepDetected + } + Runtime = [ordered]@{ + AssociationGeneration = [long]$runtime.AssociationGeneration + DirectoryModelSignature = [string]$runtime.Model.DirectoryModelSignature + ProjectionSignature = [string]$runtime.Model.ProjectionSignature + Model = $runtime.Model + SmartDiscoveryKpi = $runtime.SmartDiscoveryKpi + TypeProbeBudget = $runtime.TypeProbeBudget + } +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$bundle | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host "P0-5f repeat-run bundle: PASS" +Write-Host " output: $OutputPath" +Write-Host " requests: $wireRequests" +Write-Host " KPI signature: $($runtime.SmartDiscoveryKpi.DeterministicSignature)" +Write-Host " directory signature: $($runtime.Model.DirectoryModelSignature)" +Write-Host " projection signature: $($runtime.Model.ProjectionSignature)" From 3cdc5b008d5a8b096a9b22e3017371195c6f3bea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:26:30 +0700 Subject: [PATCH 061/243] fix(scl): acquire live FCDA evidence during RCB export --- MainWindow.RcbExport.cs | 132 ++++++++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 20 deletions(-) diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index 7d7c89f64..9f211d661 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -396,36 +396,104 @@ private async Task ExportLegacySasRcbAsync( var liveModel = device.LiveDiscoveryModel ?? throw new InvalidOperationException("A source SCL file or complete live discovery model is required for legacy SAS export."); - var selectedDataSet = string.IsNullOrWhiteSpace(row.DataSetReference) - ? null - : liveModel.DataSets.FirstOrDefault(dataSet => - NormalizeRcbReference(dataSet.Reference) - .Equals(NormalizeRcbReference(row.DataSetReference), StringComparison.OrdinalIgnoreCase)); + var effectiveAvailability = availability; var exportModel = liveModel; - // An RCB with no DataSet is still a real RCB and must remain exportable. - // Only request FCDA evidence when the RCB actually declares a DataSet. - if (!string.IsNullOrWhiteSpace(row.DataSetReference) && + // Reuse any availability evidence that the operator already acquired. Merge the live + // DatSet binding before resolving the DataSet so a runtime binding can override stale + // discovery evidence without forcing an unnecessary second MMS association. + if (effectiveAvailability != null) + { + exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedReportControlEvidence( + exportModel, + row.Reference, + effectiveAvailability); + } + + var effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + var selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference); + + // An RCB with no DataSet is still a real RCB and remains exportable. When an RCB does + // declare a DataSet, however, Save/Export owns the evidence acquisition: first reuse + // an existing availability directory, then perform one bounded read-only audit only + // when FCDA evidence is still missing. This keeps R4 discovery fast and makes Save SCL + // self-contained instead of requiring a manual Check Availability + retry cycle. + if (!string.IsNullOrWhiteSpace(effectiveDataSetReference) && (selectedDataSet is null || selectedDataSet.Members.Count == 0)) { - if (availability is null) + if (effectiveAvailability != null) { - throw new InvalidOperationException( - "The selected RCB declares a DataSet, but live discovery has no FCDA directory evidence yet. Click Check Availability, wait for the read-only audit to finish, then export again."); + exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( + exportModel, + row.Reference, + effectiveAvailability); + effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference); } - exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( - liveModel, - row.Reference, - availability); + if (!string.IsNullOrWhiteSpace(effectiveDataSetReference) && + (selectedDataSet is null || selectedDataSet.Members.Count == 0)) + { + if (!device.IsConnected) + { + throw new InvalidOperationException( + $"The selected RCB declares DataSet '{effectiveDataSetReference}', but no FCDA directory evidence is available and the IED is disconnected."); + } + + AddLog("INFO", "RCB Export", + $"{device.Name}: FCDA evidence missing for {row.Reference}; acquiring one read-only MMS availability snapshot before export."); + + using var evidenceTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + evidenceTimeout.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + effectiveAvailability = await _rcbAvailabilityProbe + .CheckAsync(device, evidenceTimeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Timed out while acquiring FCDA directory evidence for RCB '{row.Reference}'. The CID was not written."); + } + + exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedReportControlEvidence( + exportModel, + row.Reference, + effectiveAvailability); + effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + + if (!string.IsNullOrWhiteSpace(effectiveDataSetReference)) + { + exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( + exportModel, + row.Reference, + effectiveAvailability); + effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference); + } + else + { + selectedDataSet = null; + } + } + + if (!string.IsNullOrWhiteSpace(effectiveDataSetReference) && + (selectedDataSet is null || selectedDataSet.Members.Count == 0)) + { + throw new InvalidOperationException( + $"RCB '{row.Reference}' resolves to DataSet '{effectiveDataSetReference}', but the authoritative read-only MMS audit did not return any FCDA members. The CID was not written."); + } } - if (availability != null) + // If the probe proved a dynamic RCB is currently unbound, keep that authoritative + // result and do not resurrect the stale discovery binding during serialization. + if (effectiveAvailability != null) { exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedReportControlEvidence( exportModel, row.Reference, - availability); + effectiveAvailability); } var filteredModel = SclReportControlFilter.FilterLiveModel(exportModel, row.Reference); @@ -444,7 +512,7 @@ private async Task ExportLegacySasRcbAsync( var removedCount = Math.Max(0, liveModel.ReportControls.Count - 1); AddLog("INFO", "RCB Export", - $"{device.Name}: live-model legacy SAS CID saved; schema={liveResult.SclSchema}; RCB={row.Reference}; DataSet={row.DataSetName}; members={row.MemberCount}; removed RCB={removedCount}; output={liveResult.SclPath}"); + $"{device.Name}: live-model legacy SAS CID saved; schema={liveResult.SclSchema}; RCB={row.Reference}; DataSet={effectiveDataSetReference}; members={selectedDataSet?.Members.Count ?? 0}; removed RCB={removedCount}; output={liveResult.SclPath}"); SetStatus($"{device.Name}: legacy SAS CID exported with one RCB — {row.Name}."); return new RcbExportCompletion { @@ -453,13 +521,37 @@ private async Task ExportLegacySasRcbAsync( SummaryPath = liveResult.SummaryPath, SchemaDisplayName = liveResult.SclSchema, RetainedReportControl = row.Reference, - DataSetName = row.DataSetName, - DataSetMemberCount = row.MemberCount, + DataSetName = string.IsNullOrWhiteSpace(effectiveDataSetReference) ? row.DataSetName : LastReferenceSegment(effectiveDataSetReference), + DataSetMemberCount = selectedDataSet?.Members.Count ?? 0, RemovedReportControlCount = removedCount, Message = $"Export complete: {row.Reference} is the only RCB in the generated CID." }; } + private static string ResolveExportDataSetReference(LiveIedModelDiscoveryDocument model, RcbExportRow row) + { + var selectedReportControl = model.ReportControls.FirstOrDefault(reportControl => + NormalizeRcbReference(reportControl.Reference) + .Equals(NormalizeRcbReference(row.Reference), StringComparison.OrdinalIgnoreCase)); + + return !string.IsNullOrWhiteSpace(selectedReportControl?.DataSetReference) + ? selectedReportControl.DataSetReference.Trim() + : (row.DataSetReference ?? string.Empty).Trim(); + } + + private static LiveIedModelDataSet? FindExportDataSet( + LiveIedModelDiscoveryDocument model, + string? dataSetReference) + { + if (string.IsNullOrWhiteSpace(dataSetReference)) + return null; + + var normalizedReference = NormalizeRcbReference(dataSetReference); + return model.DataSets.FirstOrDefault(dataSet => + NormalizeRcbReference(dataSet.Reference) + .Equals(normalizedReference, StringComparison.OrdinalIgnoreCase)); + } + private static string EffectiveSclIedName(Iec61850MonitorDevice device) => string.IsNullOrWhiteSpace(device.SclIedName) ? device.Name : device.SclIedName; From b13a863e7f389ef026bcacbbe18ff6149f86669f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:27:04 +0700 Subject: [PATCH 062/243] P0-5f finalize golden stability from three independent repeat bundles --- ...e-smart-discovery-repeat-run-stability.ps1 | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 scripts/finalize-smart-discovery-repeat-run-stability.ps1 diff --git a/scripts/finalize-smart-discovery-repeat-run-stability.ps1 b/scripts/finalize-smart-discovery-repeat-run-stability.ps1 new file mode 100644 index 000000000..4cbd86197 --- /dev/null +++ b/scripts/finalize-smart-discovery-repeat-run-stability.ps1 @@ -0,0 +1,191 @@ +param( + [Parameter(Mandatory=$true)][string]$GoldenLockPath, + [Parameter(Mandatory=$true)][string]$RepeatTargetPath, + [Parameter(Mandatory=$true)][string[]]$RunBundlePaths, + [Parameter(Mandatory=$true)][string]$OutputPath, + [switch]$AllowFixtureEvidence, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Get-Int($Object, [string]$Name) { + if ($null -eq $Object) { return 0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return 0 } + return [int]$property.Value +} + +function Get-CanonicalServiceMap($Object) { + $map = [ordered]@{} + if ($null -ne $Object) { + foreach ($property in @($Object.PSObject.Properties | Sort-Object Name)) { $map[$property.Name] = [int]$property.Value } + } + return ($map | ConvertTo-Json -Compress) +} + +function Get-CanonicalObject($Object, [string[]]$Names) { + $map = [ordered]@{} + foreach ($name in $Names) { + $property = $Object.PSObject.Properties[$name] + $map[$name] = if ($null -eq $property) { $null } else { $property.Value } + } + return ($map | ConvertTo-Json -Compress -Depth 6) +} + +$goldenLockFile = Resolve-File $GoldenLockPath 'P0-5e golden lock' +$repeatTargetFile = Resolve-File $RepeatTargetPath 'P0-5f repeat target' +$lock = Get-Content -LiteralPath $goldenLockFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $repeatTargetFile -Raw | ConvertFrom-Json +$failures = [System.Collections.Generic.List[string]]::new() + +if ($lock.Phase -ne 'P0-5e' -or $lock.Status -ne 'locked') { $failures.Add('P0-5f requires an active P0-5e golden lock.') } +if (-not $AllowFixtureEvidence -and -not [bool]$lock.GoldenSource.RawCaptureReverified) { $failures.Add('Production finalization rejects a fixture/non-reverified P0-5e lock.') } +if ($target.Phase -ne 'P0-5f') { $failures.Add('Repeat-run target is not P0-5f authority.') } +if ($target.DeviceIdentity -ne $lock.DeviceIdentity) { $failures.Add('Repeat target device differs from the golden lock.') } +if ($target.EngineCommit -ne $lock.GoldenSource.EngineCommit) { $failures.Add('Repeat target engine differs from the golden lock.') } + +$minimumRuns = [int]$target.MinimumIndependentAssociations +if ($minimumRuns -lt 3) { $failures.Add('P0-5f target must require at least three independent associations.') } +if ($RunBundlePaths.Count -lt $minimumRuns) { $failures.Add("Insufficient independent repeat runs: $($RunBundlePaths.Count) < $minimumRuns.") } + +$goldenHash = (Get-FileHash -LiteralPath $goldenLockFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = [string]$lock.GoldenSource.SemanticTargetSha256 +$manifestHash = [string]$lock.GoldenSource.BuildManifestSha256 +$expectedArsas = [string]$lock.GoldenSource.ArsasCommit +$expectedEngine = [string]$lock.GoldenSource.EngineCommit +$maxPeak = [int]$lock.HardRequestBudget.MaxPeakOutstandingRequests +$bundles = @() + +foreach ($path in $RunBundlePaths) { + $file = Resolve-File $path 'P0-5f run bundle' + $bundle = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json + $bundles += [pscustomobject]@{ Path = $file; Hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant(); Evidence = $bundle } + + if ($bundle.Phase -ne 'P0-5f-run-bundle' -or $bundle.Verdict -ne 'PASS') { $failures.Add("Run bundle '$file' is not PASS P0-5f evidence.") } + if (-not $AllowFixtureEvidence -and [bool]$bundle.FixtureEvidence) { $failures.Add("Production finalization rejects fixture run bundle '$file'.") } + if ($bundle.DeviceIdentity -ne $lock.DeviceIdentity) { $failures.Add("Run bundle '$file' device identity mismatch.") } + if ($bundle.ArsasCommit -ne $expectedArsas) { $failures.Add("Run bundle '$file' ARSAS commit drift.") } + if ($bundle.EngineCommit -ne $expectedEngine) { $failures.Add("Run bundle '$file' engine commit drift.") } + if ($bundle.GoldenLockSha256 -ne $goldenHash) { $failures.Add("Run bundle '$file' is bound to a different golden lock.") } + if ($bundle.BuildManifestSha256 -ne $manifestHash) { $failures.Add("Run bundle '$file' build manifest drift.") } + if ($bundle.SemanticTargetSha256 -ne $targetHash) { $failures.Add("Run bundle '$file' semantic target drift.") } + if ((Get-Int $bundle.Wire 'DuplicateSemanticRequests') -ne 0 -or + (Get-Int $bundle.Wire 'DuplicateGetNameListRequests') -ne 0 -or + (Get-Int $bundle.Wire 'DuplicateGvaRequests') -ne 0 -or + [bool]$bundle.Wire.SecondGetNameListSweepDetected) { + $failures.Add("Run bundle '$file' contains duplicate/second-sweep wire work.") + } + if ((Get-Int $bundle.Runtime.SmartDiscoveryKpi 'DuplicateRequests') -ne 0 -or -not [bool]$bundle.Runtime.SmartDiscoveryKpi.WireAccountingComplete) { + $failures.Add("Run bundle '$file' engine KPI duplicate/accounting invariant failed.") + } + if ((Get-Int $bundle.Wire 'PeakOutstandingRequests') -gt $maxPeak) { $failures.Add("Run bundle '$file' exceeded golden peak outstanding max $maxPeak.") } +} + +if ($bundles.Count -gt 0) { + $first = $bundles[0].Evidence + $expectedRequests = Get-Int $first.Wire 'ConfirmedRequests' + $expectedServices = Get-CanonicalServiceMap $first.Wire.ServiceCounts + $expectedKpiSignature = [string]$first.Runtime.SmartDiscoveryKpi.DeterministicSignature + $expectedDirectorySignature = [string]$first.Runtime.DirectoryModelSignature + $expectedProjectionSignature = [string]$first.Runtime.ProjectionSignature + $typeFields = @( + 'DirectoryPoints','SuppliedLogicalNodeCandidates','SuppressedNonLiveLogicalNodeCandidates', + 'LogicalNodeRequests','PointsCoveredByLogicalNode','DataObjectRequests','PointsCoveredByDataObject', + 'ExactLeafRequests','SuppressedExactRepeatRequests','PointsCoveredByExactLeaf', + 'RemainingUnresolvedPoints','TotalPlannedRequests') + $modelFields = @('LogicalDevices','LogicalNodes','SemanticPoints','ProjectedSignals','DataSets','ReportControls','BufferedReportControls','UnbufferedReportControls') + $expectedTypeBudget = Get-CanonicalObject $first.Runtime.TypeProbeBudget $typeFields + $expectedModel = Get-CanonicalObject $first.Runtime.Model $modelFields + + foreach ($entry in $bundles) { + $bundle = $entry.Evidence + if ((Get-Int $bundle.Wire 'ConfirmedRequests') -ne $expectedRequests) { $failures.Add("Confirmed-request drift in '$($entry.Path)'.") } + if ((Get-CanonicalServiceMap $bundle.Wire.ServiceCounts) -ne $expectedServices) { $failures.Add("MMS service-mix drift in '$($entry.Path)'.") } + if ([string]$bundle.Runtime.SmartDiscoveryKpi.DeterministicSignature -ne $expectedKpiSignature) { $failures.Add("Engine KPI deterministic-signature drift in '$($entry.Path)'.") } + if ([string]$bundle.Runtime.DirectoryModelSignature -ne $expectedDirectorySignature) { $failures.Add("Directory model signature drift in '$($entry.Path)'.") } + if ([string]$bundle.Runtime.ProjectionSignature -ne $expectedProjectionSignature) { $failures.Add("ARSAS signal projection signature drift in '$($entry.Path)'.") } + if ((Get-CanonicalObject $bundle.Runtime.TypeProbeBudget $typeFields) -ne $expectedTypeBudget) { $failures.Add("Hierarchy type-probe budget drift in '$($entry.Path)'.") } + if ((Get-CanonicalObject $bundle.Runtime.Model $modelFields) -ne $expectedModel) { $failures.Add("Discovered model-count drift in '$($entry.Path)'.") } + } + + # The repeat signature must also land on the reviewed same-IED semantic authority. + $semantic = $target.SemanticTarget + if ((Get-Int $first.Runtime.Model 'LogicalDevices') -ne [int]$semantic.LogicalDevices) { $failures.Add('Repeat model LogicalDevice count differs from same-IED semantic target.') } + if ((Get-Int $first.Runtime.Model 'LogicalNodes') -ne [int]$semantic.LogicalNodes) { $failures.Add('Repeat model LogicalNode count differs from same-IED semantic target.') } + if ((Get-Int $first.Runtime.Model 'SemanticPoints') -ne [int]$semantic.SemanticLeaves) { $failures.Add('Repeat model semantic point count differs from same-IED semantic target.') } + if ((Get-Int $first.Runtime.Model 'DataSets') -ne [int]$semantic.DataSets) { $failures.Add('Repeat model DataSet count differs from same-IED semantic target.') } + if ((Get-Int $first.Runtime.Model 'ReportControls') -ne [int]$semantic.RuntimeReportControlInstances) { $failures.Add('Repeat runtime ReportControl count differs from same-IED semantic target.') } +} + +$distinctCaptureHashes = @($bundles | ForEach-Object { [string]$_.Evidence.Provenance.CaptureSha256 } | Sort-Object -Unique) +$distinctRuntimeHashes = @($bundles | ForEach-Object { [string]$_.Evidence.Provenance.RuntimeEvidenceSha256 } | Sort-Object -Unique) +if ($bundles.Count -gt 0 -and $distinctCaptureHashes.Count -ne $bundles.Count) { $failures.Add('Repeat set contains reused raw capture evidence; runs are not independent.') } +if ($bundles.Count -gt 0 -and $distinctRuntimeHashes.Count -ne $bundles.Count) { $failures.Add('Repeat set contains reused runtime evidence; runs are not independent.') } + +$peaks = @($bundles | ForEach-Object { Get-Int $_.Evidence.Wire 'PeakOutstandingRequests' }) +$result = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5f' + Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } + DeviceIdentity = $lock.DeviceIdentity + RequiredIndependentAssociations = $minimumRuns + ObservedRunBundles = $bundles.Count + GoldenLockSha256 = $goldenHash + ArsasCommit = $expectedArsas + EngineCommit = $expectedEngine + BuildManifestSha256 = $manifestHash + SemanticTargetSha256 = $targetHash + Consensus = if ($bundles.Count -gt 0) { + [ordered]@{ + ConfirmedRequests = Get-Int $bundles[0].Evidence.Wire 'ConfirmedRequests' + ServiceCounts = $bundles[0].Evidence.Wire.ServiceCounts + EngineKpiDeterministicSignature = [string]$bundles[0].Evidence.Runtime.SmartDiscoveryKpi.DeterministicSignature + DirectoryModelSignature = [string]$bundles[0].Evidence.Runtime.DirectoryModelSignature + ProjectionSignature = [string]$bundles[0].Evidence.Runtime.ProjectionSignature + TypeProbeBudget = $bundles[0].Evidence.Runtime.TypeProbeBudget + Model = $bundles[0].Evidence.Runtime.Model + PeakOutstandingMin = if ($peaks.Count -gt 0) { ($peaks | Measure-Object -Minimum).Minimum } else { $null } + PeakOutstandingMax = if ($peaks.Count -gt 0) { ($peaks | Measure-Object -Maximum).Maximum } else { $null } + GoldenPeakOutstandingMax = $maxPeak + } + } else { $null } + Runs = @($bundles | ForEach-Object { + [ordered]@{ + BundlePath = $_.Path + BundleSha256 = $_.Hash + CaptureSha256 = $_.Evidence.Provenance.CaptureSha256 + ProofSha256 = $_.Evidence.Provenance.ProofSha256 + RuntimeEvidenceSha256 = $_.Evidence.Provenance.RuntimeEvidenceSha256 + AssociationGeneration = $_.Evidence.Runtime.AssociationGeneration + PeakOutstandingRequests = $_.Evidence.Wire.PeakOutstandingRequests + } + }) + AcceptanceFailures = @($failures) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$result | ConvertTo-Json -Depth 18 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host "P0-5f physical golden repeat-run finalization: $($result.Verdict)" +Write-Host " runs: $($bundles.Count) / required $minimumRuns" +if ($result.Consensus) { + Write-Host " requests: $($result.Consensus.ConfirmedRequests)" + Write-Host " KPI signature: $($result.Consensus.EngineKpiDeterministicSignature)" + Write-Host " directory signature: $($result.Consensus.DirectoryModelSignature)" + Write-Host " projection signature: $($result.Consensus.ProjectionSignature)" + Write-Host " peak outstanding range: $($result.Consensus.PeakOutstandingMin)-$($result.Consensus.PeakOutstandingMax) / max $maxPeak" +} +Write-Host " finalization JSON: $OutputPath" + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + if (-not $NoFailExit) { exit 1 } +} From 96253e443ff7a370558e91561b6e12de59d404a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:29:22 +0700 Subject: [PATCH 063/243] fix(scl): use engine dataset model contract --- MainWindow.RcbExport.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index 9f211d661..24ffaffc7 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -539,7 +539,7 @@ private static string ResolveExportDataSetReference(LiveIedModelDiscoveryDocumen : (row.DataSetReference ?? string.Empty).Trim(); } - private static LiveIedModelDataSet? FindExportDataSet( + private static LiveIedDataSetModel? FindExportDataSet( LiveIedModelDiscoveryDocument model, string? dataSetReference) { From d66b3d16a95e9302ed781b244ec113fbe4e225e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:29:44 +0700 Subject: [PATCH 064/243] test(scl): guard live FCDA evidence acquisition before export --- ...xportEvidenceAcquisitionRegressionTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs diff --git a/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs b/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs new file mode 100644 index 000000000..5adf558cc --- /dev/null +++ b/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs @@ -0,0 +1,87 @@ +namespace ARSAS.Tests; + +public sealed class RcbExportEvidenceAcquisitionRegressionTests +{ + [Fact] + public void LiveExport_SelfAcquiresFcdaEvidence_WhenBoundDataSetIsIncomplete() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + Assert.Contains("FCDA evidence missing", source, StringComparison.Ordinal); + Assert.Contains("_rcbAvailabilityProbe", source, StringComparison.Ordinal); + Assert.Contains(".CheckAsync(device, evidenceTimeout.Token)", source, StringComparison.Ordinal); + Assert.Contains("evidenceTimeout.CancelAfter(TimeSpan.FromSeconds(30))", source, StringComparison.Ordinal); + Assert.Contains("MergeSelectedDataSetDirectory", source, StringComparison.Ordinal); + + Assert.DoesNotContain( + "Run Check Availability to browse DataSet directories, then export again.", + source, + StringComparison.Ordinal); + } + + [Fact] + public void LiveExport_ReusesExistingAvailability_BeforeOpeningFallbackAudit() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + var reuseIndex = source.IndexOf( + "if (effectiveAvailability != null)", + StringComparison.Ordinal); + var fallbackProbeIndex = source.IndexOf( + "effectiveAvailability = await _rcbAvailabilityProbe", + StringComparison.Ordinal); + + Assert.True(reuseIndex >= 0, "Existing availability evidence must be considered first."); + Assert.True(fallbackProbeIndex > reuseIndex, + "Fallback MMS audit must only occur after existing availability evidence has been considered."); + } + + [Fact] + public void LiveExport_RefusesSerialization_WhenAuthoritativeDirectoryStillHasNoFcdaMembers() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + var rejectionIndex = source.IndexOf( + "authoritative read-only MMS audit did not return any FCDA members. The CID was not written.", + StringComparison.Ordinal); + var serializationIndex = source.IndexOf( + "AuthoritativeLiveIedSclExporter.WriteFiles", + StringComparison.Ordinal); + + Assert.True(rejectionIndex >= 0, "Incomplete FCDA evidence must have an explicit export rejection gate."); + Assert.True(serializationIndex > rejectionIndex, + "The incomplete-evidence rejection gate must execute before SCL serialization."); + } + + [Fact] + public void LiveExport_ReResolvesDataSetReference_AfterLiveRcbEvidenceMerge() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + var mergeIndex = source.IndexOf( + "MergeSelectedReportControlEvidence", + StringComparison.Ordinal); + var resolveIndex = source.IndexOf( + "ResolveExportDataSetReference(exportModel, row)", + StringComparison.Ordinal); + + Assert.True(mergeIndex >= 0, "Live RCB evidence must be merged into the export model."); + Assert.True(resolveIndex > mergeIndex, + "The effective DataSet reference must be resolved from the merged live model, not stale UI evidence."); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 0b4c668bc83b440a9493cd6f67fe70475f3f7127 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:36:41 +0700 Subject: [PATCH 065/243] fix(scl): preserve authoritative empty live DatSet binding --- MainWindow.RcbExport.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index 24ffaffc7..fbe25ad7d 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -494,6 +494,8 @@ private async Task ExportLegacySasRcbAsync( exportModel, row.Reference, effectiveAvailability); + effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference); } var filteredModel = SclReportControlFilter.FilterLiveModel(exportModel, row.Reference); @@ -534,8 +536,8 @@ private static string ResolveExportDataSetReference(LiveIedModelDiscoveryDocumen NormalizeRcbReference(reportControl.Reference) .Equals(NormalizeRcbReference(row.Reference), StringComparison.OrdinalIgnoreCase)); - return !string.IsNullOrWhiteSpace(selectedReportControl?.DataSetReference) - ? selectedReportControl.DataSetReference.Trim() + return selectedReportControl is not null + ? (selectedReportControl.DataSetReference ?? string.Empty).Trim() : (row.DataSetReference ?? string.Empty).Trim(); } From 1d12d8a87e4659c63efcb3eda39694c466cf1b3a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:37:04 +0700 Subject: [PATCH 066/243] test(scl): guard authoritative empty live DatSet binding --- ...xportEvidenceAcquisitionRegressionTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs b/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs index 5adf558cc..af1a6147f 100644 --- a/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs +++ b/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs @@ -70,6 +70,50 @@ public void LiveExport_ReResolvesDataSetReference_AfterLiveRcbEvidenceMerge() "The effective DataSet reference must be resolved from the merged live model, not stale UI evidence."); } + [Fact] + public void LiveExport_DoesNotResurrectStaleRowBinding_WhenLiveDatSetIsEmpty() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + Assert.Contains( + "return selectedReportControl is not null", + source, + StringComparison.Ordinal); + Assert.Contains( + "? (selectedReportControl.DataSetReference ?? string.Empty).Trim()", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "return !string.IsNullOrWhiteSpace(selectedReportControl?.DataSetReference)", + source, + StringComparison.Ordinal); + } + + [Fact] + public void LiveExport_RefreshesResolvedDataSet_AfterFinalAuthoritativeRcbMerge() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + const string finalMergeComment = "If the probe proved a dynamic RCB is currently unbound"; + + var finalMergeIndex = source.IndexOf(finalMergeComment, StringComparison.Ordinal); + var resolveIndex = source.IndexOf( + "effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row);", + finalMergeIndex, + StringComparison.Ordinal); + var findIndex = source.IndexOf( + "selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference);", + resolveIndex, + StringComparison.Ordinal); + var serializationIndex = source.IndexOf( + "AuthoritativeLiveIedSclExporter.WriteFiles", + StringComparison.Ordinal); + + Assert.True(finalMergeIndex >= 0, "Final authoritative RCB merge guard must remain present."); + Assert.True(resolveIndex > finalMergeIndex, "DataSet reference must be recalculated after the final live merge."); + Assert.True(findIndex > resolveIndex, "DataSet membership must be recalculated from the final reference."); + Assert.True(serializationIndex > findIndex, "Final binding refresh must happen before serialization."); + } + private static string FindRepoFile(string relativePath) { DirectoryInfo? directory = new(AppContext.BaseDirectory); From f7c6b25f05d65be0c79d5b90e0fdeb1f9a954ebf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:50:16 +0700 Subject: [PATCH 067/243] P0-5f enforce independent association stability --- ...e-smart-discovery-repeat-run-stability.ps1 | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/finalize-smart-discovery-repeat-run-stability.ps1 b/scripts/finalize-smart-discovery-repeat-run-stability.ps1 index 4cbd86197..0d11b22a0 100644 --- a/scripts/finalize-smart-discovery-repeat-run-stability.ps1 +++ b/scripts/finalize-smart-discovery-repeat-run-stability.ps1 @@ -23,6 +23,13 @@ function Get-Int($Object, [string]$Name) { return [int]$property.Value } +function Get-Long($Object, [string]$Name) { + if ($null -eq $Object) { return [long]0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return [long]0 } + return [long]$property.Value +} + function Get-CanonicalServiceMap($Object) { $map = [ordered]@{} if ($null -ne $Object) { @@ -57,6 +64,7 @@ if ($minimumRuns -lt 3) { $failures.Add('P0-5f target must require at least thre if ($RunBundlePaths.Count -lt $minimumRuns) { $failures.Add("Insufficient independent repeat runs: $($RunBundlePaths.Count) < $minimumRuns.") } $goldenHash = (Get-FileHash -LiteralPath $goldenLockFile -Algorithm SHA256).Hash.ToLowerInvariant() +$repeatTargetHash = (Get-FileHash -LiteralPath $repeatTargetFile -Algorithm SHA256).Hash.ToLowerInvariant() $targetHash = [string]$lock.GoldenSource.SemanticTargetSha256 $manifestHash = [string]$lock.GoldenSource.BuildManifestSha256 $expectedArsas = [string]$lock.GoldenSource.ArsasCommit @@ -86,6 +94,12 @@ foreach ($path in $RunBundlePaths) { if ((Get-Int $bundle.Runtime.SmartDiscoveryKpi 'DuplicateRequests') -ne 0 -or -not [bool]$bundle.Runtime.SmartDiscoveryKpi.WireAccountingComplete) { $failures.Add("Run bundle '$file' engine KPI duplicate/accounting invariant failed.") } + if ((Get-Int $bundle.Wire 'ConfirmedRequests') -ne (Get-Int $bundle.Runtime.SmartDiscoveryKpi 'TotalRequests')) { + $failures.Add("Run bundle '$file' wire/engine request accounting mismatch.") + } + if ((Get-Long $bundle.Runtime 'AssociationGeneration') -le 0) { + $failures.Add("Run bundle '$file' has an invalid association generation.") + } if ((Get-Int $bundle.Wire 'PeakOutstandingRequests') -gt $maxPeak) { $failures.Add("Run bundle '$file' exceeded golden peak outstanding max $maxPeak.") } } @@ -116,7 +130,6 @@ if ($bundles.Count -gt 0) { if ((Get-CanonicalObject $bundle.Runtime.Model $modelFields) -ne $expectedModel) { $failures.Add("Discovered model-count drift in '$($entry.Path)'.") } } - # The repeat signature must also land on the reviewed same-IED semantic authority. $semantic = $target.SemanticTarget if ((Get-Int $first.Runtime.Model 'LogicalDevices') -ne [int]$semantic.LogicalDevices) { $failures.Add('Repeat model LogicalDevice count differs from same-IED semantic target.') } if ((Get-Int $first.Runtime.Model 'LogicalNodes') -ne [int]$semantic.LogicalNodes) { $failures.Add('Repeat model LogicalNode count differs from same-IED semantic target.') } @@ -127,18 +140,22 @@ if ($bundles.Count -gt 0) { $distinctCaptureHashes = @($bundles | ForEach-Object { [string]$_.Evidence.Provenance.CaptureSha256 } | Sort-Object -Unique) $distinctRuntimeHashes = @($bundles | ForEach-Object { [string]$_.Evidence.Provenance.RuntimeEvidenceSha256 } | Sort-Object -Unique) +$associationGenerations = @($bundles | ForEach-Object { Get-Long $_.Evidence.Runtime 'AssociationGeneration' }) +$distinctAssociationGenerations = @($associationGenerations | Sort-Object -Unique) if ($bundles.Count -gt 0 -and $distinctCaptureHashes.Count -ne $bundles.Count) { $failures.Add('Repeat set contains reused raw capture evidence; runs are not independent.') } if ($bundles.Count -gt 0 -and $distinctRuntimeHashes.Count -ne $bundles.Count) { $failures.Add('Repeat set contains reused runtime evidence; runs are not independent.') } +if ($bundles.Count -gt 0 -and $distinctAssociationGenerations.Count -ne $bundles.Count) { $failures.Add('Repeat set reuses an association generation; every run must reconnect and use a fresh association generation.') } $peaks = @($bundles | ForEach-Object { Get-Int $_.Evidence.Wire 'PeakOutstandingRequests' }) $result = [ordered]@{ - SchemaVersion = 1 + SchemaVersion = 2 Phase = 'P0-5f' Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } DeviceIdentity = $lock.DeviceIdentity RequiredIndependentAssociations = $minimumRuns ObservedRunBundles = $bundles.Count GoldenLockSha256 = $goldenHash + RepeatTargetSha256 = $repeatTargetHash ArsasCommit = $expectedArsas EngineCommit = $expectedEngine BuildManifestSha256 = $manifestHash @@ -152,6 +169,7 @@ $result = [ordered]@{ ProjectionSignature = [string]$bundles[0].Evidence.Runtime.ProjectionSignature TypeProbeBudget = $bundles[0].Evidence.Runtime.TypeProbeBudget Model = $bundles[0].Evidence.Runtime.Model + AssociationGenerations = @($associationGenerations) PeakOutstandingMin = if ($peaks.Count -gt 0) { ($peaks | Measure-Object -Minimum).Minimum } else { $null } PeakOutstandingMax = if ($peaks.Count -gt 0) { ($peaks | Measure-Object -Maximum).Maximum } else { $null } GoldenPeakOutstandingMax = $maxPeak @@ -181,6 +199,7 @@ if ($result.Consensus) { Write-Host " KPI signature: $($result.Consensus.EngineKpiDeterministicSignature)" Write-Host " directory signature: $($result.Consensus.DirectoryModelSignature)" Write-Host " projection signature: $($result.Consensus.ProjectionSignature)" + Write-Host " association generations: $($result.Consensus.AssociationGenerations -join ', ')" Write-Host " peak outstanding range: $($result.Consensus.PeakOutstandingMin)-$($result.Consensus.PeakOutstandingMax) / max $maxPeak" } Write-Host " finalization JSON: $OutputPath" From 8e1a6709c509f0b344312de610259cb4adbf9f14 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:50:37 +0700 Subject: [PATCH 068/243] P0-5f add repeat-run stability regression contract --- ...coveryRepeatRunStabilityRegressionTests.cs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs diff --git a/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs new file mode 100644 index 000000000..7b226c055 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs @@ -0,0 +1,93 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryRepeatRunStabilityRegressionTests +{ + private const string EngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + [Fact] + public void P05f_TargetRequiresThreeFreshIndependentAssociations() + { + using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-repeat-run-target.json"))); + var root = document.RootElement; + + Assert.Equal("P0-5f", root.GetProperty("Phase").GetString()); + Assert.Equal("AA1E1F06R4", root.GetProperty("DeviceIdentity").GetString()); + Assert.Equal(EngineCommit, root.GetProperty("EngineCommit").GetString()); + Assert.True(root.GetProperty("MinimumIndependentAssociations").GetInt32() >= 3); + + var contract = root.GetProperty("RepeatRunContract"); + Assert.True(contract.GetProperty("RequireFreshAssociationGenerationPerRun").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactConfirmedRequestCountAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactServiceMixAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactEngineKpiSignatureAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactDirectoryModelSignatureAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactProjectionSignatureAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactTypeProbeBudgetAcrossRuns").GetBoolean()); + } + + [Fact] + public void P05f_RuntimeEvidenceIsFreshAssociationOnlyAndZeroTraffic() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + var evidence = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs")); + + Assert.Contains("cached branch", capture, StringComparison.OrdinalIgnoreCase); + Assert.Contains("TryWriteSmartDiscoveryRepeatRunEvidence", capture, StringComparison.Ordinal); + Assert.Contains("after a fresh", capture, StringComparison.OrdinalIgnoreCase); + Assert.Contains("RefreshSmartDiscoveryModelKpi", evidence, StringComparison.Ordinal); + Assert.Contains("DirectoryModelSignature", evidence, StringComparison.Ordinal); + Assert.Contains("ProjectionSignature", evidence, StringComparison.Ordinal); + Assert.Contains("DeterministicSignature", evidence, StringComparison.Ordinal); + Assert.Contains("AssociationGeneration", evidence, StringComparison.Ordinal); + Assert.DoesNotContain("GetVariableAccessAttributesAsync", evidence, StringComparison.Ordinal); + Assert.DoesNotContain("GetNameList", evidence, StringComparison.Ordinal); + } + + [Fact] + public void P05f_FinalizerRejectsAssociationReuseAndCrossRunDrift() + { + var source = File.ReadAllText(FindRepoFile("scripts/finalize-smart-discovery-repeat-run-stability.ps1")); + + Assert.Contains("Repeat set reuses an association generation", source, StringComparison.Ordinal); + Assert.Contains("wire/engine request accounting mismatch", source, StringComparison.Ordinal); + Assert.Contains("Confirmed-request drift", source, StringComparison.Ordinal); + Assert.Contains("MMS service-mix drift", source, StringComparison.Ordinal); + Assert.Contains("Engine KPI deterministic-signature drift", source, StringComparison.Ordinal); + Assert.Contains("Directory model signature drift", source, StringComparison.Ordinal); + Assert.Contains("ARSAS signal projection signature drift", source, StringComparison.Ordinal); + Assert.Contains("Hierarchy type-probe budget drift", source, StringComparison.Ordinal); + Assert.Contains("Discovered model-count drift", source, StringComparison.Ordinal); + Assert.Contains("RepeatTargetSha256", source, StringComparison.Ordinal); + Assert.Contains("SchemaVersion = 2", source, StringComparison.Ordinal); + } + + [Fact] + public void P05f_RunBundleReverifiesGoldenBudgetAndRawCaptureByDefault() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-repeat-run-bundle.ps1")); + + Assert.Contains("verify-smart-discovery-golden-lock.ps1", source, StringComparison.Ordinal); + Assert.Contains("verify-smart-discovery-pcap.ps1", source, StringComparison.Ordinal); + Assert.Contains("Production repeat evidence requires raw .pcap/.pcapng input", source, StringComparison.Ordinal); + Assert.Contains("Wire/engine request accounting mismatch", source, StringComparison.Ordinal); + Assert.Contains("GoldenLockSha256", source, StringComparison.Ordinal); + Assert.Contains("RuntimeEvidenceSha256", source, StringComparison.Ordinal); + Assert.Contains("FixtureEvidence", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} From 7e084a13ce57ab2a3419889c102fc9b7ae02cc41 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:51:03 +0700 Subject: [PATCH 069/243] P0-5f add repeat-run stability CI gate --- .../smart-discovery-repeat-run-stability.yml | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 .github/workflows/smart-discovery-repeat-run-stability.yml diff --git a/.github/workflows/smart-discovery-repeat-run-stability.yml b/.github/workflows/smart-discovery-repeat-run-stability.yml new file mode 100644 index 000000000..a71153541 --- /dev/null +++ b/.github/workflows/smart-discovery-repeat-run-stability.yml @@ -0,0 +1,199 @@ +name: Smart Discovery Repeat-Run Stability + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-repeat-run-stability: + name: Verify P0-5f repeat-run stability contract + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Validate P0-5f sources + shell: powershell + run: | + $finalizer = '.\ArIED61850Tester\scripts\finalize-smart-discovery-repeat-run-stability.ps1' + $bundleWriter = '.\ArIED61850Tester\scripts\new-smart-discovery-repeat-run-bundle.ps1' + $target = '.\ArIED61850Tester\evidence\smart-discovery-repeat-run-target.json' + $runtime = '.\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs' + $test = '.\ArIED61850Tester\tests\ARSAS.Tests\SmartDiscoveryRepeatRunStabilityRegressionTests.cs' + foreach ($required in @($finalizer, $bundleWriter, $target, $runtime, $test)) { + if (-not (Test-Path $required -PathType Leaf)) { throw "P0-5f source missing: $required" } + } + foreach ($script in @($finalizer, $bundleWriter)) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { + $messages = @($errors | ForEach-Object { $_.Message }) -join '; ' + throw "PowerShell parse failure in $script: $messages" + } + } + $targetJson = Get-Content $target -Raw | ConvertFrom-Json + if ($targetJson.Phase -ne 'P0-5f' -or $targetJson.MinimumIndependentAssociations -lt 3) { + throw 'P0-5f target does not require at least three independent associations.' + } + + - name: Execute P0-5f finalization fixtures + shell: powershell + run: | + $root = '.\ArIED61850Tester' + $results = Join-Path $root 'TestResults' + New-Item -ItemType Directory -Force $results | Out-Null + $finalizer = Join-Path $root 'scripts\finalize-smart-discovery-repeat-run-stability.ps1' + $repeatTarget = Join-Path $root 'evidence\smart-discovery-repeat-run-target.json' + $semanticTarget = Join-Path $root 'evidence\smart-discovery-golden-target.json' + $arsasCommit = (git -C $root rev-parse HEAD).Trim().ToLowerInvariant() + $engineCommit = '4467124775d8d9d76f3db194f9fbfd97144767a8' + + $manifest = Join-Path $results 'SMART-CAPTURE-BUILD.txt' + @( + 'ARSAS smart discovery field-capture build', + "ARSAS commit: $arsasCommit", + "ARIEC61850 commit: $engineCommit", + 'Engine PR: 134' + ) | Set-Content $manifest -Encoding utf8 + $manifestHash = (Get-FileHash $manifest -Algorithm SHA256).Hash.ToLowerInvariant() + $semanticHash = (Get-FileHash $semanticTarget -Algorithm SHA256).Hash.ToLowerInvariant() + + $lockPath = Join-Path $results 'P0-5E-fixture.lock.json' + $lock = [ordered]@{ + SchemaVersion = 2 + Phase = 'P0-5e' + Status = 'locked' + DeviceIdentity = 'AA1E1F06R4' + GoldenSource = [ordered]@{ + ArsasCommit = $arsasCommit + EngineCommit = $engineCommit + BuildManifestSha256 = $manifestHash + SemanticTargetSha256 = $semanticHash + RawCaptureReverified = $false + } + HardRequestBudget = [ordered]@{ + MaxConfirmedRequests = 4 + MaxPeakOutstandingRequests = 4 + } + } + $lock | ConvertTo-Json -Depth 12 | Set-Content $lockPath -Encoding utf8 + $goldenHash = (Get-FileHash $lockPath -Algorithm SHA256).Hash.ToLowerInvariant() + + function New-Bundle([int]$index, [long]$generation, [int]$requests = 4, [string]$kpi = 'kpi-a', [string]$directory = 'dir-a', [string]$projection = 'proj-a') { + $captureHash = ('{0:x64}' -f (1000 + $index)) + $proofHash = ('{0:x64}' -f (2000 + $index)) + $runtimeHash = ('{0:x64}' -f (3000 + $index)) + [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5f-run-bundle' + Verdict = 'PASS' + DeviceIdentity = 'AA1E1F06R4' + ArsasCommit = $arsasCommit + EngineCommit = $engineCommit + GoldenLockSha256 = $goldenHash + BuildManifestSha256 = $manifestHash + SemanticTargetSha256 = $semanticHash + FixtureEvidence = $true + Provenance = [ordered]@{ + CaptureSha256 = $captureHash + ProofSha256 = $proofHash + RuntimeEvidenceSha256 = $runtimeHash + } + Wire = [ordered]@{ + ConfirmedRequests = $requests + ServiceCounts = [ordered]@{ GetNameList = 2; GetVariableAccessAttributes = 2 } + PeakOutstandingRequests = 3 + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + SecondGetNameListSweepDetected = $false + } + Runtime = [ordered]@{ + AssociationGeneration = $generation + DirectoryModelSignature = $directory + ProjectionSignature = $projection + SmartDiscoveryKpi = [ordered]@{ + TotalRequests = $requests + DuplicateRequests = 0 + WireAccountingComplete = $true + DeterministicSignature = $kpi + } + TypeProbeBudget = [ordered]@{ + DirectoryPoints = 4925 + SuppliedLogicalNodeCandidates = 119 + SuppressedNonLiveLogicalNodeCandidates = 0 + LogicalNodeRequests = 119 + PointsCoveredByLogicalNode = 4925 + DataObjectRequests = 0 + PointsCoveredByDataObject = 0 + ExactLeafRequests = 0 + SuppressedExactRepeatRequests = 0 + PointsCoveredByExactLeaf = 0 + RemainingUnresolvedPoints = 0 + TotalPlannedRequests = 119 + } + Model = [ordered]@{ + LogicalDevices = 32 + LogicalNodes = 119 + SemanticPoints = 4925 + ProjectedSignals = 4925 + DataSets = 2 + ReportControls = 34 + BufferedReportControls = 2 + UnbufferedReportControls = 32 + } + } + } + } + + $bundlePaths = @() + foreach ($i in 1..3) { + $path = Join-Path $results "P0-5F-run-$i.json" + (New-Bundle $i (100 + $i)) | ConvertTo-Json -Depth 18 | Set-Content $path -Encoding utf8 + $bundlePaths += $path + } + + $passPath = Join-Path $results 'P0-5F-finalization-pass.json' + & $finalizer -GoldenLockPath $lockPath -RepeatTargetPath $repeatTarget -RunBundlePaths $bundlePaths -OutputPath $passPath -AllowFixtureEvidence + $pass = Get-Content $passPath -Raw | ConvertFrom-Json + if ($pass.Verdict -ne 'PASS' -or $pass.SchemaVersion -ne 2 -or $pass.ObservedRunBundles -ne 3) { + throw 'P0-5f stable three-run fixture did not PASS.' + } + if (@($pass.Consensus.AssociationGenerations | Sort-Object -Unique).Count -ne 3) { + throw 'P0-5f PASS did not preserve three unique association generations.' + } + + $reused = New-Bundle 3 102 + $reusedPath = Join-Path $results 'P0-5F-run-reused-generation.json' + $reused | ConvertTo-Json -Depth 18 | Set-Content $reusedPath -Encoding utf8 + $reuseFailPath = Join-Path $results 'P0-5F-finalization-reused-generation-fail.json' + & $finalizer -GoldenLockPath $lockPath -RepeatTargetPath $repeatTarget -RunBundlePaths @($bundlePaths[0], $bundlePaths[1], $reusedPath) -OutputPath $reuseFailPath -AllowFixtureEvidence -NoFailExit + $reuseFail = Get-Content $reuseFailPath -Raw | ConvertFrom-Json + if ($reuseFail.Verdict -ne 'FAIL' -or -not (@($reuseFail.AcceptanceFailures) -match 'reuses an association generation')) { + throw 'P0-5f failed to reject reused association generation.' + } + + $drift = New-Bundle 4 104 5 'kpi-b' 'dir-b' 'proj-b' + $driftPath = Join-Path $results 'P0-5F-run-drift.json' + $drift | ConvertTo-Json -Depth 18 | Set-Content $driftPath -Encoding utf8 + $driftFailPath = Join-Path $results 'P0-5F-finalization-drift-fail.json' + & $finalizer -GoldenLockPath $lockPath -RepeatTargetPath $repeatTarget -RunBundlePaths @($bundlePaths[0], $bundlePaths[1], $driftPath) -OutputPath $driftFailPath -AllowFixtureEvidence -NoFailExit + $driftFail = Get-Content $driftFailPath -Raw | ConvertFrom-Json + if ($driftFail.Verdict -ne 'FAIL' -or + -not (@($driftFail.AcceptanceFailures) -match 'Confirmed-request drift') -or + -not (@($driftFail.AcceptanceFailures) -match 'deterministic-signature drift')) { + throw 'P0-5f failed to reject repeat-run request/signature drift.' + } + + - name: Upload P0-5f regression evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5f-repeat-run-fixtures + path: ArIED61850Tester\TestResults\P0-5F-*.json + if-no-files-found: warn + retention-days: 14 From 674a8a486cbb8f227dd5e86d067c6f21c673c876 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:51:27 +0700 Subject: [PATCH 070/243] P0-5f document physical repeat-run finalization --- docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md | 94 ++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md diff --git a/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md b/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md new file mode 100644 index 000000000..d4e1b3d8e --- /dev/null +++ b/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md @@ -0,0 +1,94 @@ +# P0-5f — Physical Golden Lock Finalization & Repeat-Run Stability Proof + +P0-5f proves that the accepted same-IED discovery is not a one-off lucky run. It requires a production P0-5e golden lock plus at least three fresh, independent MMS associations made by the exact same ARSAS/engine artifact. + +## Production prerequisites + +The production path requires: + +1. a P0-5e golden lock created from an independently reverified physical PCAP (`RawCaptureReverified=true`); +2. the exact field artifact and its `SMART-CAPTURE-BUILD.txt` manifest; +3. the tracked `smart-discovery-golden-target.json` and `smart-discovery-repeat-run-target.json`; +4. at least three fresh discovery runs, each starting from a new MMS association generation; +5. for every run: raw PCAP/PCAPNG, P0-5d PASS proof, and the local `P0-5F-*.json` runtime evidence emitted by ARSAS after the fresh association owner publishes authority. + +Cached rediscovery on the same association is not a repeat run and intentionally does not emit new P0-5f runtime evidence. + +## Per-run procedure + +For each run, disconnect/reconnect so ARSAS creates a fresh association generation. Start capture before TCP/ACSE/MMS association establishment, perform one smart discovery, then stop capture only after discovery has completed. + +Create the P0-5d wire proof: + +```powershell +powershell -ExecutionPolicy Bypass -File .\verify-smart-discovery-pcap.ps1 ` + -PcapPath .\run-01.pcapng ` + -OutputJson .\P0-5D-run-01-proof.json +``` + +Locate the matching ARSAS runtime evidence under: + +```text +%LOCALAPPDATA%\ARSAS\SmartDiscoveryEvidence\P0-5F-*.json +``` + +Then create a P0-5f run bundle: + +```powershell +powershell -ExecutionPolicy Bypass -File .\new-smart-discovery-repeat-run-bundle.ps1 ` + -GoldenLockPath .\smart-discovery-golden-budget.lock.json ` + -ProofJson .\P0-5D-run-01-proof.json ` + -CapturePath .\run-01.pcapng ` + -RuntimeEvidenceJson .\P0-5F-run-01-runtime.json ` + -BuildManifestPath .\SMART-CAPTURE-BUILD.txt ` + -TargetPath .\smart-discovery-golden-target.json ` + -DeviceIdentity AA1E1F06R4 ` + -ArsasCommit ` + -EngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 ` + -OutputPath .\P0-5F-run-01-bundle.json +``` + +Production bundle creation independently re-decodes the raw PCAP, re-applies the P0-5e golden request budget, verifies exact build/target hashes, and requires engine KPI `TotalRequests` to equal the PCAP confirmed-request count. + +Repeat this for at least three independently established associations. + +## Finalize repeat-run stability + +```powershell +powershell -ExecutionPolicy Bypass -File .\finalize-smart-discovery-repeat-run-stability.ps1 ` + -GoldenLockPath .\smart-discovery-golden-budget.lock.json ` + -RepeatTargetPath .\smart-discovery-repeat-run-target.json ` + -RunBundlePaths .\P0-5F-run-01-bundle.json,.\P0-5F-run-02-bundle.json,.\P0-5F-run-03-bundle.json ` + -OutputPath .\P0-5F-physical-finalization.json +``` + +A production PASS requires all runs to be bound to the same golden lock, device, ARSAS commit, engine commit, build manifest hash, and semantic-target hash. Every raw capture hash, runtime-evidence hash, and association generation must be unique. + +Across all runs the following must be exactly stable: + +- confirmed MMS request count; +- MMS service mix; +- engine smart-discovery deterministic signature; +- canonical directory model signature; +- ARSAS signal-projection signature; +- hierarchy type-probe budget; +- discovered model counts. + +Every run must also preserve: + +- zero semantic duplicate requests; +- zero duplicate GetNameList/GVA; +- no second naming sweep; +- engine `DuplicateRequests=0`; +- complete engine wire accounting; +- PCAP request count equal to engine KPI request count; +- peak outstanding no higher than the P0-5e golden maximum; +- same-IED semantic counts: 32 LD, 119 LN, 4,925 semantic points, 2 DataSets, and 34 runtime RCB instances before semantic family collapse. + +The finalization output records the consensus request/service budget, all deterministic signatures, association generations, peak-outstanding range, bundle hashes, capture hashes, runtime-evidence hashes, golden-lock hash, and repeat-target hash. + +## Evidence authority + +`-AllowFixtureEvidence` exists only for CI regression fixtures. Never use it for physical acceptance. + +P0-5f is physically complete only when the production finalization JSON reports `Verdict=PASS` from at least three fresh physical associations. Until then, `smart-discovery-repeat-run-target.json` remains in `awaiting-physical-golden-lock-and-three-independent-runs` state and `FinalizationAuthority` remains null. From 8bfbfeb2d09c9937aed44a06a1806c18fd5ea35b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:53:10 +0700 Subject: [PATCH 071/243] P0-5f add physical finalization authority gate --- ...w-smart-discovery-repeat-run-authority.ps1 | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 scripts/new-smart-discovery-repeat-run-authority.ps1 diff --git a/scripts/new-smart-discovery-repeat-run-authority.ps1 b/scripts/new-smart-discovery-repeat-run-authority.ps1 new file mode 100644 index 000000000..a20ec372b --- /dev/null +++ b/scripts/new-smart-discovery-repeat-run-authority.ps1 @@ -0,0 +1,98 @@ +param( + [Parameter(Mandatory=$true)][string]$GoldenLockPath, + [Parameter(Mandatory=$true)][string]$RepeatTargetPath, + [Parameter(Mandatory=$true)][string]$FinalizationJson, + [Parameter(Mandatory=$true)][string[]]$RunBundlePaths, + [Parameter(Mandatory=$true)][string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +$goldenFile = Resolve-File $GoldenLockPath 'P0-5e golden lock' +$targetFile = Resolve-File $RepeatTargetPath 'P0-5f repeat target' +$finalFile = Resolve-File $FinalizationJson 'P0-5f finalization JSON' +$golden = Get-Content -LiteralPath $goldenFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +$final = Get-Content -LiteralPath $finalFile -Raw | ConvertFrom-Json + +if ($golden.Phase -ne 'P0-5e' -or $golden.Status -ne 'locked') { throw 'Physical authority requires an active P0-5e golden lock.' } +if (-not [bool]$golden.GoldenSource.RawCaptureReverified) { throw 'Physical authority rejects a fixture/non-reverified P0-5e golden lock.' } +if ($target.Phase -ne 'P0-5f') { throw 'Repeat target is not P0-5f authority.' } +if ($final.Phase -ne 'P0-5f' -or $final.Verdict -ne 'PASS') { throw 'Physical authority requires a P0-5f PASS finalization.' } +if ([int]$final.SchemaVersion -lt 2) { throw 'Physical authority requires P0-5f finalization schema v2 or newer.' } + +$goldenHash = (Get-FileHash -LiteralPath $goldenFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$finalHash = (Get-FileHash -LiteralPath $finalFile -Algorithm SHA256).Hash.ToLowerInvariant() +if ([string]$final.GoldenLockSha256 -ne $goldenHash) { throw 'Finalization is bound to a different P0-5e golden lock.' } +if ([string]$final.RepeatTargetSha256 -ne $targetHash) { throw 'Finalization is bound to a different P0-5f repeat target.' } +if ([string]$final.DeviceIdentity -ne [string]$target.DeviceIdentity -or [string]$final.DeviceIdentity -ne [string]$golden.DeviceIdentity) { + throw 'Device identity differs across golden lock, repeat target, and finalization.' +} +if ([string]$final.ArsasCommit -ne [string]$golden.GoldenSource.ArsasCommit) { throw 'Finalization ARSAS commit differs from golden authority.' } +if ([string]$final.EngineCommit -ne [string]$golden.GoldenSource.EngineCommit) { throw 'Finalization engine commit differs from golden authority.' } + +$minimumRuns = [int]$target.MinimumIndependentAssociations +if ([int]$final.ObservedRunBundles -lt $minimumRuns -or $RunBundlePaths.Count -lt $minimumRuns) { + throw "Physical authority requires at least $minimumRuns independent run bundles." +} +if ([int]$final.ObservedRunBundles -ne $RunBundlePaths.Count) { + throw 'Supplied run-bundle count differs from the reviewed finalization.' +} + +$expectedBundleHashes = @($final.Runs | ForEach-Object { [string]$_.BundleSha256 } | Sort-Object) +$observedBundleHashes = @() +$associationGenerations = @() +$captureHashes = @() +$runtimeHashes = @() +foreach ($path in $RunBundlePaths) { + $bundleFile = Resolve-File $path 'P0-5f run bundle' + $bundle = Get-Content -LiteralPath $bundleFile -Raw | ConvertFrom-Json + if ($bundle.Phase -ne 'P0-5f-run-bundle' -or $bundle.Verdict -ne 'PASS') { throw "Run bundle '$bundleFile' is not PASS evidence." } + if ([bool]$bundle.FixtureEvidence) { throw "Physical authority rejects fixture run bundle '$bundleFile'." } + if ([string]$bundle.GoldenLockSha256 -ne $goldenHash) { throw "Run bundle '$bundleFile' is bound to a different golden lock." } + if ([string]$bundle.DeviceIdentity -ne [string]$golden.DeviceIdentity) { throw "Run bundle '$bundleFile' device identity mismatch." } + $observedBundleHashes += (Get-FileHash -LiteralPath $bundleFile -Algorithm SHA256).Hash.ToLowerInvariant() + $associationGenerations += [long]$bundle.Runtime.AssociationGeneration + $captureHashes += [string]$bundle.Provenance.CaptureSha256 + $runtimeHashes += [string]$bundle.Provenance.RuntimeEvidenceSha256 +} +$observedBundleHashes = @($observedBundleHashes | Sort-Object) +if (($expectedBundleHashes -join '|') -ne ($observedBundleHashes -join '|')) { throw 'Supplied run bundles do not exactly match the reviewed finalization bundle hashes.' } +if (@($associationGenerations | Sort-Object -Unique).Count -ne $RunBundlePaths.Count) { throw 'Physical authority rejects reused association generations.' } +if (@($captureHashes | Sort-Object -Unique).Count -ne $RunBundlePaths.Count) { throw 'Physical authority rejects reused raw capture evidence.' } +if (@($runtimeHashes | Sort-Object -Unique).Count -ne $RunBundlePaths.Count) { throw 'Physical authority rejects reused runtime evidence.' } + +$authority = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5f-authority' + Status = 'physical-finalized' + DeviceIdentity = [string]$golden.DeviceIdentity + ArsasCommit = [string]$golden.GoldenSource.ArsasCommit + EngineCommit = [string]$golden.GoldenSource.EngineCommit + GoldenLockSha256 = $goldenHash + RepeatTargetSha256 = $targetHash + FinalizationFileName = [IO.Path]::GetFileName($finalFile) + FinalizationSha256 = $finalHash + IndependentAssociations = $RunBundlePaths.Count + AssociationGenerations = @($associationGenerations) + Consensus = $final.Consensus + RunBundleSha256 = @($observedBundleHashes) + CaptureSha256 = @($captureHashes) + RuntimeEvidenceSha256 = @($runtimeHashes) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$authority | ConvertTo-Json -Depth 18 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host 'P0-5f physical repeat-run authority: FINALIZED' +Write-Host " independent associations: $($RunBundlePaths.Count)" +Write-Host " finalization SHA256: $finalHash" +Write-Host " authority file: $OutputPath" From f7451e7898eb61ad9e02a21b2cc34c9fe188a2a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:53:52 +0700 Subject: [PATCH 072/243] P0-5f lock physical finalization authority contract --- ...tDiscoveryRepeatRunStabilityRegressionTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs index 7b226c055..c74609f5e 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs @@ -16,6 +16,7 @@ public void P05f_TargetRequiresThreeFreshIndependentAssociations() Assert.Equal("AA1E1F06R4", root.GetProperty("DeviceIdentity").GetString()); Assert.Equal(EngineCommit, root.GetProperty("EngineCommit").GetString()); Assert.True(root.GetProperty("MinimumIndependentAssociations").GetInt32() >= 3); + Assert.Equal(JsonValueKind.Null, root.GetProperty("FinalizationAuthority").ValueKind); var contract = root.GetProperty("RepeatRunContract"); Assert.True(contract.GetProperty("RequireFreshAssociationGenerationPerRun").GetBoolean()); @@ -77,6 +78,20 @@ public void P05f_RunBundleReverifiesGoldenBudgetAndRawCaptureByDefault() Assert.Contains("FixtureEvidence", source, StringComparison.Ordinal); } + [Fact] + public void P05f_PhysicalAuthorityCannotPromoteFixtures() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-repeat-run-authority.ps1")); + + Assert.Contains("RawCaptureReverified", source, StringComparison.Ordinal); + Assert.Contains("Physical authority rejects fixture run bundle", source, StringComparison.Ordinal); + Assert.Contains("RunBundlePaths", source, StringComparison.Ordinal); + Assert.Contains("BundleSha256", source, StringComparison.Ordinal); + Assert.Contains("reused association generations", source, StringComparison.Ordinal); + Assert.Contains("physical-finalized", source, StringComparison.Ordinal); + Assert.Contains("FinalizationSha256", source, StringComparison.Ordinal); + } + private static string FindRepoFile(string relativePath) { DirectoryInfo? directory = new(AppContext.BaseDirectory); From e0a07365e31b3a076ac0221f7a0abf9aeeeceace Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:54:30 +0700 Subject: [PATCH 073/243] P0-5f document physical authority promotion --- docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md b/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md index d4e1b3d8e..33d62b08a 100644 --- a/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md +++ b/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md @@ -87,8 +87,25 @@ Every run must also preserve: The finalization output records the consensus request/service budget, all deterministic signatures, association generations, peak-outstanding range, bundle hashes, capture hashes, runtime-evidence hashes, golden-lock hash, and repeat-target hash. +## Promote reviewed physical finalization into authority + +After the finalization JSON is reviewed and reports `Verdict=PASS`, create the immutable physical authority file: + +```powershell +powershell -ExecutionPolicy Bypass -File .\new-smart-discovery-repeat-run-authority.ps1 ` + -GoldenLockPath .\smart-discovery-golden-budget.lock.json ` + -RepeatTargetPath .\smart-discovery-repeat-run-target.json ` + -FinalizationJson .\P0-5F-physical-finalization.json ` + -RunBundlePaths .\P0-5F-run-01-bundle.json,.\P0-5F-run-02-bundle.json,.\P0-5F-run-03-bundle.json ` + -OutputPath .\P0-5F-physical-authority.lock.json +``` + +The authority gate has no fixture override. It requires a P0-5e golden lock with `RawCaptureReverified=true`, a schema-v2 P0-5f PASS, and the exact run-bundle hashes referenced by the reviewed finalization. Every supplied run bundle must have `FixtureEvidence=false`; capture hashes, runtime-evidence hashes, and association generations must all be unique. + +The resulting authority file records the exact ARSAS/engine commits, golden-lock hash, repeat-target hash, finalization hash, consensus signatures/budgets, run-bundle hashes, raw-capture hashes, runtime-evidence hashes, and association generations. + ## Evidence authority -`-AllowFixtureEvidence` exists only for CI regression fixtures. Never use it for physical acceptance. +`-AllowFixtureEvidence` exists only for CI regression fixtures in bundle/finalization testing. Never use it for physical acceptance. The final authority writer intentionally exposes no fixture bypass. -P0-5f is physically complete only when the production finalization JSON reports `Verdict=PASS` from at least three fresh physical associations. Until then, `smart-discovery-repeat-run-target.json` remains in `awaiting-physical-golden-lock-and-three-independent-runs` state and `FinalizationAuthority` remains null. +P0-5f is physically complete only when both the production finalization JSON reports `Verdict=PASS` from at least three fresh physical associations and `P0-5F-physical-authority.lock.json` is generated successfully. Until then, `smart-discovery-repeat-run-target.json` remains in `awaiting-physical-golden-lock-and-three-independent-runs` state and `FinalizationAuthority` remains null. From 66decfc3a1935466d8007a737c027e7086d72118 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:55:16 +0700 Subject: [PATCH 074/243] P0-5f prove fixture promotion is rejected --- .../smart-discovery-repeat-run-stability.yml | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/.github/workflows/smart-discovery-repeat-run-stability.yml b/.github/workflows/smart-discovery-repeat-run-stability.yml index a71153541..f8428d63d 100644 --- a/.github/workflows/smart-discovery-repeat-run-stability.yml +++ b/.github/workflows/smart-discovery-repeat-run-stability.yml @@ -20,13 +20,15 @@ jobs: run: | $finalizer = '.\ArIED61850Tester\scripts\finalize-smart-discovery-repeat-run-stability.ps1' $bundleWriter = '.\ArIED61850Tester\scripts\new-smart-discovery-repeat-run-bundle.ps1' + $authorityWriter = '.\ArIED61850Tester\scripts\new-smart-discovery-repeat-run-authority.ps1' $target = '.\ArIED61850Tester\evidence\smart-discovery-repeat-run-target.json' $runtime = '.\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs' $test = '.\ArIED61850Tester\tests\ARSAS.Tests\SmartDiscoveryRepeatRunStabilityRegressionTests.cs' - foreach ($required in @($finalizer, $bundleWriter, $target, $runtime, $test)) { + $doc = '.\ArIED61850Tester\docs\P0-5F_REPEAT_RUN_STABILITY_PROOF.md' + foreach ($required in @($finalizer, $bundleWriter, $authorityWriter, $target, $runtime, $test, $doc)) { if (-not (Test-Path $required -PathType Leaf)) { throw "P0-5f source missing: $required" } } - foreach ($script in @($finalizer, $bundleWriter)) { + foreach ($script in @($finalizer, $bundleWriter, $authorityWriter)) { $tokens = $null $errors = $null [System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) | Out-Null @@ -39,6 +41,9 @@ jobs: if ($targetJson.Phase -ne 'P0-5f' -or $targetJson.MinimumIndependentAssociations -lt 3) { throw 'P0-5f target does not require at least three independent associations.' } + if ($null -ne $targetJson.FinalizationAuthority) { + throw 'CI source must not pre-populate physical FinalizationAuthority.' + } - name: Execute P0-5f finalization fixtures shell: powershell @@ -47,6 +52,7 @@ jobs: $results = Join-Path $root 'TestResults' New-Item -ItemType Directory -Force $results | Out-Null $finalizer = Join-Path $root 'scripts\finalize-smart-discovery-repeat-run-stability.ps1' + $authorityWriter = Join-Path $root 'scripts\new-smart-discovery-repeat-run-authority.ps1' $repeatTarget = Join-Path $root 'evidence\smart-discovery-repeat-run-target.json' $semanticTarget = Join-Path $root 'evidence\smart-discovery-golden-target.json' $arsasCommit = (git -C $root rev-parse HEAD).Trim().ToLowerInvariant() @@ -167,6 +173,22 @@ jobs: throw 'P0-5f PASS did not preserve three unique association generations.' } + $fixturePromotionRejected = $false + try { + & $authorityWriter ` + -GoldenLockPath $lockPath ` + -RepeatTargetPath $repeatTarget ` + -FinalizationJson $passPath ` + -RunBundlePaths $bundlePaths ` + -OutputPath (Join-Path $results 'P0-5F-FORBIDDEN-fixture-authority.json') + } + catch { + $fixturePromotionRejected = $_.Exception.Message -match 'fixture/non-reverified P0-5e golden lock' + } + if (-not $fixturePromotionRejected) { + throw 'P0-5f fixture evidence was incorrectly promotable to physical authority.' + } + $reused = New-Bundle 3 102 $reusedPath = Join-Path $results 'P0-5F-run-reused-generation.json' $reused | ConvertTo-Json -Depth 18 | Set-Content $reusedPath -Encoding utf8 @@ -189,11 +211,17 @@ jobs: throw 'P0-5f failed to reject repeat-run request/signature drift.' } - - name: Upload P0-5f regression evidence + - name: Upload P0-5f regression evidence and field toolkit if: always() uses: actions/upload-artifact@v4 with: - name: ARSAS-p0-5f-repeat-run-fixtures - path: ArIED61850Tester\TestResults\P0-5F-*.json + name: ARSAS-p0-5f-repeat-run-toolkit + path: | + ArIED61850Tester\TestResults\P0-5F-*.json + ArIED61850Tester\scripts\new-smart-discovery-repeat-run-bundle.ps1 + ArIED61850Tester\scripts\finalize-smart-discovery-repeat-run-stability.ps1 + ArIED61850Tester\scripts\new-smart-discovery-repeat-run-authority.ps1 + ArIED61850Tester\docs\P0-5F_REPEAT_RUN_STABILITY_PROOF.md + ArIED61850Tester\evidence\smart-discovery-repeat-run-target.json if-no-files-found: warn retention-days: 14 From 109695027ac3eed3fc0dae8bc384b5ac39920773 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 11:56:32 +0700 Subject: [PATCH 075/243] P0-5f fix Windows PowerShell workflow interpolation --- .github/workflows/smart-discovery-repeat-run-stability.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smart-discovery-repeat-run-stability.yml b/.github/workflows/smart-discovery-repeat-run-stability.yml index f8428d63d..123c12a80 100644 --- a/.github/workflows/smart-discovery-repeat-run-stability.yml +++ b/.github/workflows/smart-discovery-repeat-run-stability.yml @@ -34,7 +34,7 @@ jobs: [System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) | Out-Null if ($errors.Count -ne 0) { $messages = @($errors | ForEach-Object { $_.Message }) -join '; ' - throw "PowerShell parse failure in $script: $messages" + throw "PowerShell parse failure in ${script}: $messages" } } $targetJson = Get-Content $target -Raw | ConvertFrom-Json From 04a2bf0a5f992310885ea96c9f777f2437268ffb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:11:14 +0700 Subject: [PATCH 076/243] P0-5g add fail-closed production promotion switch --- evidence/SmartDiscoveryPromotion.props | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 evidence/SmartDiscoveryPromotion.props diff --git a/evidence/SmartDiscoveryPromotion.props b/evidence/SmartDiscoveryPromotion.props new file mode 100644 index 000000000..58eaafd72 --- /dev/null +++ b/evidence/SmartDiscoveryPromotion.props @@ -0,0 +1,9 @@ + + + + false + 4467124775d8d9d76f3db194f9fbfd97144767a8 + P0-5g + + From 80c223a11192b6e2f70ae14f8a4f392ddb89ea9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:11:23 +0700 Subject: [PATCH 077/243] P0-5g gate smart route behind explicit promotion --- Directory.Build.targets | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index 5c9c8aa5f..2148450ce 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,7 +1,22 @@ + + + + + true + + + true + false + + + Condition="'$(MSBuildProjectName)' == 'ArIED61850Tester' and '$(EnableSmartDiscoveryCaptureRoute)' == 'true'"> + From b25ba82943925a95665ef5fdbabb7773d3288de6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:11:35 +0700 Subject: [PATCH 078/243] P0-5g add production promotion target contract --- ...discovery-production-promotion-target.json | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 evidence/smart-discovery-production-promotion-target.json diff --git a/evidence/smart-discovery-production-promotion-target.json b/evidence/smart-discovery-production-promotion-target.json new file mode 100644 index 000000000..b87636d8e --- /dev/null +++ b/evidence/smart-discovery-production-promotion-target.json @@ -0,0 +1,37 @@ +{ + "SchemaVersion": 1, + "Phase": "P0-5g", + "Status": "awaiting-physical-p0-5f-authority-and-green-mainline-gates", + "ArsasPullRequest": 324, + "EngineRepository": "masarray/ARIEC61850", + "EnginePullRequest": 134, + "EvidenceEngineBaselineCommit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "DiscoveryCriticalEnginePaths": [ + "src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs", + "src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs", + "src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs", + "src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs", + "src/AR.Iec61850/Transport/TpktClient.cs" + ], + "AllowedPostPhysicalAuthorityPaths": [ + "evidence/smart-discovery-repeat-run.authority.json", + "evidence/smart-discovery-production-promotion-authority.json", + "evidence/SmartDiscoveryPromotion.props", + "evidence/smart-discovery-production-promotion-target.json" + ], + "ProductionPromotionContract": { + "RequireP05fPhysicalAuthority": true, + "RequirePhysicalAuthorityProductionEvidenceOnly": true, + "RequireEngineHeadCiSuccess": true, + "AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged": true, + "RequireFieldRouteExplicitOptInBeforePromotion": true, + "RequireProductionSwitchFalseUntilAuthority": true, + "RequirePurposeDiscoveryCiSuccess": true, + "RequireGenericBuildSuccessBeforeReadyForReview": true, + "RequireNoUnresolvedReviewThreadsBeforeReadyForReview": true, + "RequirePrRemainDraftUntilAllReadyGatesPass": true + }, + "PromotionAuthority": null +} From 0511d595a50fa7386fbab708228c93541e3ed1b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:12:27 +0700 Subject: [PATCH 079/243] P0-5g add production readiness verifier --- ...y-smart-discovery-production-readiness.ps1 | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 scripts/verify-smart-discovery-production-readiness.ps1 diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1 new file mode 100644 index 000000000..e3014240f --- /dev/null +++ b/scripts/verify-smart-discovery-production-readiness.ps1 @@ -0,0 +1,203 @@ +param( + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$EngineLockPath, + [Parameter(Mandatory=$true)][string]$PromotionPropsPath, + [Parameter(Mandatory=$true)][string]$ArsasRepositoryPath, + [Parameter(Mandatory=$true)][string]$EngineRepositoryPath, + [Parameter(Mandatory=$true)][string]$ArsasHeadCommit, + [Parameter(Mandatory=$true)][string]$EngineHeadCommit, + [Parameter(Mandatory=$true)][string]$EngineHeadCiConclusion, + [string]$PhysicalAuthorityPath, + [string]$PromotionAuthorityPath, + [string]$OutputJson, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Resolve-Directory([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { throw "$Label is not a directory: $Path" } + return $resolved.Path +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Get-GitHead([string]$RepositoryPath) { + $value = (& git -C $RepositoryPath rev-parse HEAD 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $value) { throw "Could not resolve Git HEAD for '$RepositoryPath'." } + return ([string]$value).Trim().ToLowerInvariant() +} + +function Test-GitAncestor([string]$RepositoryPath, [string]$Ancestor, [string]$Descendant) { + & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + +function Get-GitChangedPaths([string]$RepositoryPath, [string]$BaseCommit, [string]$HeadCommit, [string[]]$PathFilters) { + $args = @('-C', $RepositoryPath, 'diff', '--name-only', "$BaseCommit..$HeadCommit", '--') + @($PathFilters) + $lines = @(& git @args 2>$null) + if ($LASTEXITCODE -ne 0) { throw "git diff failed for $BaseCommit..$HeadCommit." } + return @($lines | ForEach-Object { ([string]$_).Trim().Replace('\\','/') } | Where-Object { $_ } | Sort-Object -Unique) +} + +function Get-PromotionSwitch([string]$PropsFile) { + [xml]$xml = Get-Content -LiteralPath $PropsFile -Raw + $node = $xml.Project.PropertyGroup.SmartDiscoveryProductionPromoted + if ($null -eq $node) { throw 'Promotion props does not define SmartDiscoveryProductionPromoted.' } + return ([string]$node).Trim().ToLowerInvariant() -eq 'true' +} + +$targetFile = Resolve-File $TargetPath 'P0-5g promotion target' +$engineLockFile = Resolve-File $EngineLockPath 'ARIEC61850 engine lock' +$propsFile = Resolve-File $PromotionPropsPath 'P0-5g promotion props' +$arsasRepo = Resolve-Directory $ArsasRepositoryPath 'ARSAS repository' +$engineRepo = Resolve-Directory $EngineRepositoryPath 'ARIEC61850 repository' +Assert-Commit $ArsasHeadCommit 'ARSAS head commit' +Assert-Commit $EngineHeadCommit 'Engine head commit' +$arsasHead = $ArsasHeadCommit.ToLowerInvariant() +$engineHead = $EngineHeadCommit.ToLowerInvariant() + +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +$engineLock = Get-Content -LiteralPath $engineLockFile -Raw | ConvertFrom-Json +$blockers = [System.Collections.Generic.List[string]]::new() +$warnings = [System.Collections.Generic.List[string]]::new() + +if ($target.Phase -ne 'P0-5g') { $blockers.Add('Promotion target is not P0-5g authority.') } +if ([string]$target.EngineRepository -ne 'masarray/ARIEC61850' -or [int]$target.EnginePullRequest -ne 134) { + $blockers.Add('Promotion target engine repository/PR authority changed unexpectedly.') +} +$baseline = ([string]$target.EvidenceEngineBaselineCommit).ToLowerInvariant() +Assert-Commit $baseline 'Evidence engine baseline commit' + +if (Get-GitHead $arsasRepo -ne $arsasHead) { $blockers.Add('ARSAS repository HEAD differs from the supplied readiness head.') } +if (Get-GitHead $engineRepo -ne $engineHead) { $blockers.Add('Engine repository HEAD differs from the supplied PR head.') } +if ([string]$engineLock.repository -ne [string]$target.EngineRepository) { $blockers.Add('ARSAS engine lock repository differs from the promotion target.') } +if (([string]$engineLock.commit).ToLowerInvariant() -ne $baseline) { + $blockers.Add('ARSAS engine lock no longer points at the physical-evidence engine baseline.') +} + +$engineIsDescendant = Test-GitAncestor $engineRepo $baseline $engineHead +if (-not $engineIsDescendant) { + $blockers.Add('Engine PR head is not a descendant of the physical-evidence engine baseline.') +} + +$criticalPaths = @($target.DiscoveryCriticalEnginePaths | ForEach-Object { [string]$_ }) +$criticalChanges = @() +if ($engineIsDescendant) { + $criticalChanges = Get-GitChangedPaths $engineRepo $baseline $engineHead $criticalPaths + if ($criticalChanges.Count -gt 0) { + $blockers.Add("Engine head changed discovery-critical evidence paths after the physical baseline: $($criticalChanges -join ', ').") + } +} + +if ($EngineHeadCiConclusion.Trim().ToLowerInvariant() -ne 'success') { + $blockers.Add("Engine PR head CI is not green: '$EngineHeadCiConclusion'.") +} + +$productionSwitch = Get-PromotionSwitch $propsFile +$physicalAuthority = $null +$physicalAuthorityFile = $null +if ([string]::IsNullOrWhiteSpace($PhysicalAuthorityPath) -or -not (Test-Path -LiteralPath $PhysicalAuthorityPath -PathType Leaf)) { + $blockers.Add('P0-5f physical-finalized authority is missing.') +} else { + $physicalAuthorityFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' + $physicalAuthority = Get-Content -LiteralPath $physicalAuthorityFile -Raw | ConvertFrom-Json + if ($physicalAuthority.Phase -ne 'P0-5f-authority' -or $physicalAuthority.Status -ne 'physical-finalized') { + $blockers.Add('P0-5f authority is not physical-finalized production evidence.') + } + if (([string]$physicalAuthority.EngineCommit).ToLowerInvariant() -ne $baseline) { + $blockers.Add('P0-5f authority engine commit differs from the evidence baseline.') + } + + $authorityArsas = ([string]$physicalAuthority.ArsasCommit).ToLowerInvariant() + if ($authorityArsas -notmatch '^[0-9a-f]{40}$') { + $blockers.Add('P0-5f authority does not contain a valid ARSAS commit.') + } elseif (-not (Test-GitAncestor $arsasRepo $authorityArsas $arsasHead)) { + $blockers.Add('Current ARSAS head is not a descendant of the physically validated ARSAS commit.') + } else { + $allChanged = Get-GitChangedPaths $arsasRepo $authorityArsas $arsasHead @('.') + $allowed = @($target.AllowedPostPhysicalAuthorityPaths | ForEach-Object { ([string]$_).Replace('\\','/') }) + $notAllowed = @($allChanged | Where-Object { $allowed -notcontains $_ }) + if ($notAllowed.Count -gt 0) { + $blockers.Add("Runtime/source changed after physical authority outside the promotion-only allowlist: $($notAllowed -join ', ').") + } + } +} + +$promotionAuthority = $null +$promotionAuthorityFile = $null +if (-not [string]::IsNullOrWhiteSpace($PromotionAuthorityPath) -and (Test-Path -LiteralPath $PromotionAuthorityPath -PathType Leaf)) { + $promotionAuthorityFile = Resolve-File $PromotionAuthorityPath 'P0-5g production authority' + $promotionAuthority = Get-Content -LiteralPath $promotionAuthorityFile -Raw | ConvertFrom-Json + if ($promotionAuthority.Phase -ne 'P0-5g-authority' -or $promotionAuthority.Status -ne 'production-promoted') { + $blockers.Add('P0-5g promotion authority is not production-promoted.') + } + if ($null -eq $physicalAuthorityFile) { + $blockers.Add('P0-5g promotion authority exists without P0-5f physical authority.') + } else { + $physicalHash = (Get-FileHash -LiteralPath $physicalAuthorityFile -Algorithm SHA256).Hash.ToLowerInvariant() + if (([string]$promotionAuthority.PhysicalAuthoritySha256).ToLowerInvariant() -ne $physicalHash) { + $blockers.Add('P0-5g promotion authority is bound to a different P0-5f physical authority.') + } + } + if (([string]$promotionAuthority.EngineHeadCommit).ToLowerInvariant() -ne $engineHead) { + $blockers.Add('P0-5g promotion authority is bound to a different engine PR head.') + } + if (-not $productionSwitch) { $blockers.Add('P0-5g authority exists but the tracked production switch is still false.') } +} elseif ($productionSwitch) { + $blockers.Add('Production switch is true without a tracked P0-5g promotion authority.') +} + +$status = 'BLOCKED' +if ($blockers.Count -eq 0) { + $status = if ($null -ne $promotionAuthority) { 'READY_FOR_REVIEW' } else { 'READY_TO_PROMOTE' } +} +if ($status -eq 'READY_TO_PROMOTE' -and $productionSwitch) { + $blockers.Add('Production switch must remain false until promotion authority is generated.') + $status = 'BLOCKED' +} + +$result = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5g' + Verdict = $status + ArsasHeadCommit = $arsasHead + EngineEvidenceBaselineCommit = $baseline + EngineHeadCommit = $engineHead + EngineHeadCiConclusion = $EngineHeadCiConclusion + EngineHeadIsEvidenceCompatibleDescendant = $engineIsDescendant -and $criticalChanges.Count -eq 0 + DiscoveryCriticalChanges = @($criticalChanges) + ProductionSwitchEnabled = $productionSwitch + PhysicalAuthorityPath = $physicalAuthorityFile + PromotionAuthorityPath = $promotionAuthorityFile + Blockers = @($blockers) + Warnings = @($warnings) +} + +if ([string]::IsNullOrWhiteSpace($OutputJson)) { + $OutputJson = Join-Path $arsasRepo 'P0-5G-production-readiness.json' +} +$outputDirectory = Split-Path -Parent $OutputJson +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 + +Write-Host "P0-5g production readiness: $($result.Verdict)" +Write-Host " ARSAS head: $arsasHead" +Write-Host " engine baseline: $baseline" +Write-Host " engine head: $engineHead" +Write-Host " critical engine changes: $($criticalChanges.Count)" +Write-Host " production switch: $productionSwitch" +foreach ($blocker in $blockers) { Write-Host " BLOCKER: $blocker" } +Write-Host " readiness JSON: $OutputJson" + +if ($result.Verdict -eq 'BLOCKED' -and -not $NoFailExit) { exit 1 } From 5ff9886dce5d80bdd766f01981ddccee695f28a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:12:52 +0700 Subject: [PATCH 080/243] P0-5g add physical-authority promotion writer --- ...scovery-production-promotion-authority.ps1 | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 scripts/new-smart-discovery-production-promotion-authority.ps1 diff --git a/scripts/new-smart-discovery-production-promotion-authority.ps1 b/scripts/new-smart-discovery-production-promotion-authority.ps1 new file mode 100644 index 000000000..1e0073058 --- /dev/null +++ b/scripts/new-smart-discovery-production-promotion-authority.ps1 @@ -0,0 +1,113 @@ +param( + [Parameter(Mandatory=$true)][string]$ReadinessJson, + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$PhysicalAuthorityPath, + [Parameter(Mandatory=$true)][string]$EngineLockPath, + [Parameter(Mandatory=$true)][string]$OutputAuthorityPath, + [Parameter(Mandatory=$true)][string]$OutputPropsPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +$readinessFile = Resolve-File $ReadinessJson 'P0-5g readiness JSON' +$targetFile = Resolve-File $TargetPath 'P0-5g promotion target' +$physicalFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' +$engineLockFile = Resolve-File $EngineLockPath 'ARIEC61850 engine lock' + +$readiness = Get-Content -LiteralPath $readinessFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +$physical = Get-Content -LiteralPath $physicalFile -Raw | ConvertFrom-Json +$engineLock = Get-Content -LiteralPath $engineLockFile -Raw | ConvertFrom-Json + +if ($readiness.Phase -ne 'P0-5g' -or $readiness.Verdict -ne 'READY_TO_PROMOTE') { + throw 'Production promotion requires a P0-5g READY_TO_PROMOTE readiness proof.' +} +if ([bool]$readiness.ProductionSwitchEnabled) { + throw 'Production switch was already enabled before promotion authority creation.' +} +if (@($readiness.Blockers).Count -ne 0) { + throw 'Production promotion refuses a readiness proof with blockers.' +} +if (-not [bool]$readiness.EngineHeadIsEvidenceCompatibleDescendant) { + throw 'Engine PR head is not evidence-compatible with the physical discovery baseline.' +} +if ([string]$readiness.EngineHeadCiConclusion -ne 'success') { + throw 'Engine PR head CI is not green.' +} +if ($physical.Phase -ne 'P0-5f-authority' -or $physical.Status -ne 'physical-finalized') { + throw 'Production promotion requires physical-finalized P0-5f authority.' +} +if ($target.Phase -ne 'P0-5g') { throw 'Promotion target is not P0-5g authority.' } + +$baseline = ([string]$target.EvidenceEngineBaselineCommit).ToLowerInvariant() +if (([string]$physical.EngineCommit).ToLowerInvariant() -ne $baseline) { + throw 'Physical P0-5f authority engine commit differs from the P0-5g evidence baseline.' +} +if (([string]$engineLock.commit).ToLowerInvariant() -ne $baseline) { + throw 'ARSAS engine lock differs from the P0-5g evidence baseline.' +} +if (([string]$readiness.ArsasHeadCommit).ToLowerInvariant() -notmatch '^[0-9a-f]{40}$' -or + ([string]$readiness.EngineHeadCommit).ToLowerInvariant() -notmatch '^[0-9a-f]{40}$') { + throw 'Readiness proof does not contain valid exact ARSAS/engine head commits.' +} + +$readinessHash = (Get-FileHash -LiteralPath $readinessFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$physicalHash = (Get-FileHash -LiteralPath $physicalFile -Algorithm SHA256).Hash.ToLowerInvariant() +$engineLockHash = (Get-FileHash -LiteralPath $engineLockFile -Algorithm SHA256).Hash.ToLowerInvariant() + +$authority = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5g-authority' + Status = 'production-promoted' + DeviceIdentity = [string]$physical.DeviceIdentity + PhysicalAuthorityFileName = [IO.Path]::GetFileName($physicalFile) + PhysicalAuthoritySha256 = $physicalHash + ReadinessFileName = [IO.Path]::GetFileName($readinessFile) + ReadinessSha256 = $readinessHash + PromotionTargetSha256 = $targetHash + EngineLockSha256 = $engineLockHash + EvidenceEngineBaselineCommit = $baseline + EngineHeadCommit = ([string]$readiness.EngineHeadCommit).ToLowerInvariant() + EngineHeadCiConclusion = 'success' + ArsasValidatedHeadCommit = ([string]$readiness.ArsasHeadCommit).ToLowerInvariant() + EngineHeadEvidenceCompatible = $true + DiscoveryCriticalChanges = @($readiness.DiscoveryCriticalChanges) + IndependentPhysicalAssociations = [int]$physical.IndependentAssociations + GoldenConsensus = $physical.Consensus +} + +$authorityDirectory = Split-Path -Parent $OutputAuthorityPath +if ($authorityDirectory) { New-Item -ItemType Directory -Force $authorityDirectory | Out-Null } +$authority | ConvertTo-Json -Depth 18 | Set-Content -LiteralPath $OutputAuthorityPath -Encoding utf8 +$authorityHash = (Get-FileHash -LiteralPath $OutputAuthorityPath -Algorithm SHA256).Hash.ToLowerInvariant() + +$propsDirectory = Split-Path -Parent $OutputPropsPath +if ($propsDirectory) { New-Item -ItemType Directory -Force $propsDirectory | Out-Null } +$props = @" + + + true + $baseline + P0-5g + $authorityHash + $(([string]$readiness.EngineHeadCommit).ToLowerInvariant()) + + +"@ +[IO.File]::WriteAllText($OutputPropsPath, $props, (New-Object Text.UTF8Encoding($false))) + +Write-Host 'P0-5g golden discovery production promotion: AUTHORIZED' +Write-Host " physical authority SHA256: $physicalHash" +Write-Host " evidence engine baseline: $baseline" +Write-Host " validated engine PR head: $($readiness.EngineHeadCommit)" +Write-Host " promotion authority SHA256: $authorityHash" +Write-Host " authority file: $OutputAuthorityPath" +Write-Host " production props: $OutputPropsPath" From ae51568cc715764d4159942fa4d76155d7a81cee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:13:20 +0700 Subject: [PATCH 081/243] P0-5g add production promotion regression contract --- ...overyProductionPromotionRegressionTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs new file mode 100644 index 000000000..7a87f186e --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using System.Xml.Linq; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryProductionPromotionRegressionTests +{ + private const string EvidenceEngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + [Fact] + public void P05g_TargetKeepsProductionPromotionFailClosed() + { + using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-production-promotion-target.json"))); + var root = document.RootElement; + + Assert.Equal("P0-5g", root.GetProperty("Phase").GetString()); + Assert.Equal(EvidenceEngineCommit, root.GetProperty("EvidenceEngineBaselineCommit").GetString()); + Assert.Equal(134, root.GetProperty("EnginePullRequest").GetInt32()); + Assert.Equal(324, root.GetProperty("ArsasPullRequest").GetInt32()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("PromotionAuthority").ValueKind); + + var contract = root.GetProperty("ProductionPromotionContract"); + Assert.True(contract.GetProperty("RequireP05fPhysicalAuthority").GetBoolean()); + Assert.True(contract.GetProperty("RequireEngineHeadCiSuccess").GetBoolean()); + Assert.True(contract.GetProperty("AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged").GetBoolean()); + Assert.True(contract.GetProperty("RequireProductionSwitchFalseUntilAuthority").GetBoolean()); + Assert.True(contract.GetProperty("RequireGenericBuildSuccessBeforeReadyForReview").GetBoolean()); + } + + [Fact] + public void P05g_DefaultBuildDoesNotPromoteFieldRoute() + { + var props = XDocument.Load(FindRepoFile("evidence/SmartDiscoveryPromotion.props")); + var promoted = props.Descendants("SmartDiscoveryProductionPromoted").Single().Value.Trim(); + Assert.Equal("false", promoted, ignoreCase: true); + + var targets = File.ReadAllText(FindRepoFile("Directory.Build.targets")); + Assert.Contains("GITHUB_WORKFLOW", targets, StringComparison.Ordinal); + Assert.Contains("Smart Discovery Field Capture Build", targets, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryProductionPromoted", targets, StringComparison.Ordinal); + Assert.Contains("EnableSmartDiscoveryCaptureRoute", targets, StringComparison.Ordinal); + Assert.Contains(">false", targets, StringComparison.Ordinal); + } + + [Fact] + public void P05g_ReadinessBindsPhysicalAuthorityAndEvidenceCompatibleEngineHead() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-production-readiness.ps1")); + + Assert.Contains("P0-5f physical-finalized authority is missing", source, StringComparison.Ordinal); + Assert.Contains("merge-base --is-ancestor", source, StringComparison.Ordinal); + Assert.Contains("DiscoveryCriticalEnginePaths", source, StringComparison.Ordinal); + Assert.Contains("Engine PR head CI is not green", source, StringComparison.Ordinal); + Assert.Contains("AllowedPostPhysicalAuthorityPaths", source, StringComparison.Ordinal); + Assert.Contains("READY_TO_PROMOTE", source, StringComparison.Ordinal); + Assert.Contains("READY_FOR_REVIEW", source, StringComparison.Ordinal); + Assert.Contains("production-promoted", source, StringComparison.Ordinal); + } + + [Fact] + public void P05g_PromotionWriterHasNoFixtureBypassAndRequiresReadyProof() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-production-promotion-authority.ps1")); + + Assert.Contains("READY_TO_PROMOTE", source, StringComparison.Ordinal); + Assert.Contains("physical-finalized P0-5f authority", source, StringComparison.Ordinal); + Assert.Contains("EngineHeadIsEvidenceCompatibleDescendant", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryProductionPromoted>true", source, StringComparison.Ordinal); + Assert.Contains("P0-5g-authority", source, StringComparison.Ordinal); + Assert.DoesNotContain("AllowFixtureEvidence", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} From 91d9c2c5888b7d61853111352eb6e06e0bb72100 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:13:43 +0700 Subject: [PATCH 082/243] P0-5g document production promotion and merge gates --- ...PRODUCTION_PROMOTION_MAINLINE_READINESS.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md diff --git a/docs/P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md b/docs/P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md new file mode 100644 index 000000000..3e816bea7 --- /dev/null +++ b/docs/P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md @@ -0,0 +1,122 @@ +# P0-5g — Golden Discovery Production Promotion & Mainline Merge Readiness + +P0-5g converts the field-only smart discovery lane into a controlled production promotion. It is deliberately fail-closed: a normal build does not activate the smart route until a reviewed physical P0-5f authority has been accepted and a P0-5g promotion authority has been generated. + +## State machine + +P0-5g has three observable readiness states: + +1. `BLOCKED` — one or more physical/provenance/engine gates are missing or invalid; +2. `READY_TO_PROMOTE` — physical P0-5f authority exists, the engine PR head is green and evidence-compatible, no disallowed post-authority ARSAS source changes exist, and the tracked production switch is still false; +3. `READY_FOR_REVIEW` — a P0-5g promotion authority is present, bound to the physical authority and validated engine head, and the tracked production switch is true. + +CI fixtures may test the state machine but can never create production authority. + +## Fail-closed build routing + +`evidence/SmartDiscoveryPromotion.props` owns the tracked production switch. + +Before production authority: + +```xml +false +``` + +`Directory.Build.targets` activates the smart route only when either: + +- the build is the dedicated `Smart Discovery Field Capture Build` evidence workflow; or +- `SmartDiscoveryProductionPromoted=true` has been written by the P0-5g promotion authority writer. + +This prevents merging the PR from silently converting every ordinary build to the field route before physical evidence is complete. + +## Engine evidence-compatible ancestry + +The physical discovery evidence baseline remains: + +```text +4467124775d8d9d76f3db194f9fbfd97144767a8 +``` + +A newer ARIEC61850 PR #134 head may be accepted for merge-readiness only when: + +- it is a descendant of that baseline; +- its exact head CI is green; +- none of the tracked discovery-critical files listed in `smart-discovery-production-promotion-target.json` changed between the physical baseline and the new head. + +Changes outside those paths, such as independent SCL export/test work, do not automatically invalidate the physical discovery request-budget evidence. Any change to a discovery-critical path requires a new physical evidence cycle rather than an exception. + +## Physical P0-5f authority + +The production readiness verifier expects a physical authority created by: + +```text +scripts/new-smart-discovery-repeat-run-authority.ps1 +``` + +That authority must have: + +- `Phase=P0-5f-authority`; +- `Status=physical-finalized`; +- production evidence only; +- the same device identity and evidence engine baseline; +- the reviewed independent repeat-run set. + +After the physical authority commit, only the narrow promotion-only allowlist in the P0-5g target may change before promotion. Runtime discovery source changes invalidate readiness. + +## Verify readiness + +The verifier requires local checkouts of ARSAS and the exact engine PR head: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-production-readiness.ps1 ` + -TargetPath .\evidence\smart-discovery-production-promotion-target.json ` + -EngineLockPath .\engines\ARIEC61850.lock.json ` + -PromotionPropsPath .\evidence\SmartDiscoveryPromotion.props ` + -ArsasRepositoryPath . ` + -EngineRepositoryPath ..\ARIEC61850 ` + -ArsasHeadCommit ` + -EngineHeadCommit ` + -EngineHeadCiConclusion success ` + -PhysicalAuthorityPath .\evidence\smart-discovery-repeat-run.authority.json ` + -PromotionAuthorityPath .\evidence\smart-discovery-production-promotion-authority.json ` + -OutputJson .\P0-5G-production-readiness.json +``` + +Before physical evidence exists the expected result is `BLOCKED`; that is a safety result, not a reason to bypass the verifier. + +## Create production promotion authority + +Only a `READY_TO_PROMOTE` proof may be promoted: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\new-smart-discovery-production-promotion-authority.ps1 ` + -ReadinessJson .\P0-5G-production-readiness.json ` + -TargetPath .\evidence\smart-discovery-production-promotion-target.json ` + -PhysicalAuthorityPath .\evidence\smart-discovery-repeat-run.authority.json ` + -EngineLockPath .\engines\ARIEC61850.lock.json ` + -OutputAuthorityPath .\evidence\smart-discovery-production-promotion-authority.json ` + -OutputPropsPath .\evidence\SmartDiscoveryPromotion.props +``` + +The writer has no fixture or force bypass. It writes `SmartDiscoveryProductionPromoted=true` only together with a cryptographically bound P0-5g authority. + +## Mainline ready-for-review gate + +`READY_FOR_REVIEW` from the local promotion verifier is necessary but not sufficient to mark PR #324 ready. Before leaving Draft, also verify on the exact ARSAS head: + +- Smart Discovery Field Capture Build = success; +- Smart Discovery Golden Budget Lock = success; +- Smart Discovery Golden Provenance = success; +- Smart Discovery Repeat-Run Stability = success; +- P0-5g Production Promotion Guard = success; +- generic ARSAS build/test workflow = success; +- relevant installer/evidence validation workflows = success; +- ARIEC61850 PR #134 exact head `.NET CI` = success; +- no unresolved blocking review threads; +- PR remains mergeable against current `main`. + +Do not mark the PR ready, enable auto-merge, or merge either repository while any of these conditions are unresolved. + +## Production source cleanup + +The current promotion mechanism keeps the historically proven build-time route patcher but places it behind the fail-closed promotion switch. Removing the patcher and moving the equivalent calls directly into `NativeIec61850Client.cs` is a separate source-cleanup operation and must preserve binary/runtime behavior. Do not combine that cleanup with the physical-evidence promotion commit because it would invalidate the exact ARSAS commit lineage being promoted. From 27027e9664b9c287c58587e9cf394ce7aa8060f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:14:19 +0700 Subject: [PATCH 083/243] P0-5g add production promotion guard workflow --- .../smart-discovery-production-promotion.yml | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .github/workflows/smart-discovery-production-promotion.yml diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml new file mode 100644 index 000000000..279cfaa10 --- /dev/null +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -0,0 +1,156 @@ +name: Smart Discovery Production Promotion Guard + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-production-promotion: + name: Verify P0-5g production promotion and merge-readiness contract + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 0 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + $arsasHead = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + "ARSAS_HEAD=$arsasHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5g ARSAS head: $arsasHead" + + - name: Checkout ARIEC61850 PR 134 head + shell: powershell + run: | + git clone --quiet --depth 0 --branch perf/smart-ied-discovery https://github.com/masarray/ARIEC61850.git ARIEC61850 + $engineHead = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + "ENGINE_HEAD=$engineHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5g engine PR head: $engineHead" + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Validate P0-5g source contract + shell: powershell + run: | + $root = '.\ArIED61850Tester' + $required = @( + "$root\evidence\SmartDiscoveryPromotion.props", + "$root\evidence\smart-discovery-production-promotion-target.json", + "$root\scripts\verify-smart-discovery-production-readiness.ps1", + "$root\scripts\new-smart-discovery-production-promotion-authority.ps1", + "$root\docs\P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md", + "$root\tests\ARSAS.Tests\SmartDiscoveryProductionPromotionRegressionTests.cs" + ) + foreach ($path in $required) { + if (-not (Test-Path $path -PathType Leaf)) { throw "P0-5g source missing: $path" } + } + foreach ($script in @( + "$root\scripts\verify-smart-discovery-production-readiness.ps1", + "$root\scripts\new-smart-discovery-production-promotion-authority.ps1")) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { + $messages = @($errors | ForEach-Object { $_.Message }) -join '; ' + throw "PowerShell parse failure in ${script}: $messages" + } + } + [xml]$props = Get-Content "$root\evidence\SmartDiscoveryPromotion.props" -Raw + $promoted = ([string]$props.Project.PropertyGroup.SmartDiscoveryProductionPromoted).Trim().ToLowerInvariant() + if ($promoted -notin @('true','false')) { throw 'P0-5g production switch is not a boolean.' } + + - name: Build and test exact engine PR head + shell: powershell + run: | + .\ARIEC61850\scripts\verify-source-clean.ps1 + dotnet restore .\ARIEC61850\ARIEC61850.sln + dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore + dotnet test .\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --no-build --no-restore + + - name: Verify P0-5g readiness state + shell: powershell + run: | + $root = '.\ArIED61850Tester' + $verifier = "$root\scripts\verify-smart-discovery-production-readiness.ps1" + $physical = "$root\evidence\smart-discovery-repeat-run.authority.json" + $promotion = "$root\evidence\smart-discovery-production-promotion-authority.json" + $physicalArg = if (Test-Path $physical -PathType Leaf) { $physical } else { '' } + $promotionArg = if (Test-Path $promotion -PathType Leaf) { $promotion } else { '' } + $output = "$root\TestResults\P0-5G-production-readiness.json" + New-Item -ItemType Directory -Force "$root\TestResults" | Out-Null + + & $verifier ` + -TargetPath "$root\evidence\smart-discovery-production-promotion-target.json" ` + -EngineLockPath "$root\engines\ARIEC61850.lock.json" ` + -PromotionPropsPath "$root\evidence\SmartDiscoveryPromotion.props" ` + -ArsasRepositoryPath $root ` + -EngineRepositoryPath '.\ARIEC61850' ` + -ArsasHeadCommit $env:ARSAS_HEAD ` + -EngineHeadCommit $env:ENGINE_HEAD ` + -EngineHeadCiConclusion success ` + -PhysicalAuthorityPath $physicalArg ` + -PromotionAuthorityPath $promotionArg ` + -OutputJson $output ` + -NoFailExit + + $result = Get-Content $output -Raw | ConvertFrom-Json + if (-not (Test-Path $physical -PathType Leaf)) { + if ($result.Verdict -ne 'BLOCKED' -or + -not (@($result.Blockers) -match 'P0-5f physical-finalized authority is missing')) { + throw 'P0-5g must remain fail-closed until physical P0-5f authority is present.' + } + if ([bool]$result.ProductionSwitchEnabled) { + throw 'P0-5g production switch became enabled without physical authority.' + } + } elseif (-not (Test-Path $promotion -PathType Leaf)) { + if ($result.Verdict -ne 'READY_TO_PROMOTE') { + throw "Physical authority exists but P0-5g is not READY_TO_PROMOTE: $(@($result.Blockers) -join '; ')" + } + } elseif ($result.Verdict -ne 'READY_FOR_REVIEW') { + throw "Promotion authority exists but P0-5g is not READY_FOR_REVIEW: $(@($result.Blockers) -join '; ')" + } + + - name: Build and test default fail-closed ARSAS path + shell: powershell + run: | + $native = '.\ArIED61850Tester\Services\NativeIec61850Client.cs' + $before = Get-Content $native -Raw + if ($before -match 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'Tracked NativeIec61850Client.cs already contains the field-route patch before default build.' + } + dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln + dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + $after = Get-Content $native -Raw + [xml]$props = Get-Content '.\ArIED61850Tester\evidence\SmartDiscoveryPromotion.props' -Raw + $promoted = ([string]$props.Project.PropertyGroup.SmartDiscoveryProductionPromoted).Trim().ToLowerInvariant() -eq 'true' + if (-not $promoted -and $after -match 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'Default pre-promotion build unexpectedly installed the smart discovery route.' + } + + - name: Build and test explicit smart-route candidate + shell: powershell + run: | + dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore -p:EnableSmartDiscoveryCaptureRoute=true + $native = Get-Content '.\ArIED61850Tester\Services\NativeIec61850Client.cs' -Raw + if ($native -notmatch 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'Explicit P0-5g smart-route build did not install the discovery route.' + } + if ($native -notmatch '__P0_5C_CONNECT_RESET__' -or $native -notmatch '__P0_5C_DISPOSE_RESET__') { + throw 'Explicit P0-5g smart-route build did not install association lifecycle resets.' + } + if ($native -notmatch '_lastDiscovery\.Snapshot\.DomainVariables') { + throw 'Explicit P0-5g smart-route build did not install authoritative Control inventory reuse.' + } + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + + - name: Upload P0-5g readiness evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5g-production-readiness + path: ArIED61850Tester\TestResults\P0-5G-*.json + if-no-files-found: warn + retention-days: 14 From 48e02f0603295c7a4b4adb771887b87ad3461918 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 12:15:13 +0700 Subject: [PATCH 084/243] P0-5g harden engine discovery-critical compatibility set --- ...smart-discovery-production-promotion-target.json | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/evidence/smart-discovery-production-promotion-target.json b/evidence/smart-discovery-production-promotion-target.json index b87636d8e..4f74831fe 100644 --- a/evidence/smart-discovery-production-promotion-target.json +++ b/evidence/smart-discovery-production-promotion-target.json @@ -7,13 +7,20 @@ "EnginePullRequest": 134, "EvidenceEngineBaselineCommit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "DiscoveryCriticalEnginePaths": [ + "src/AR.Iec61850/Control/Iec61850ControlService.cs", + "src/AR.Iec61850/Control/Iec61850ControlTransport.cs", + "src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs", "src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs", "src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs", "src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs", - "src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs", + "src/AR.Iec61850/Mms/MmsDataSetDirectory.cs", + "src/AR.Iec61850/Mms/MmsIedModelDirectory.cs", + "src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs", "src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs", - "src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs", - "src/AR.Iec61850/Transport/TpktClient.cs" + "src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs", + "src/AR.Iec61850/Osi/TpktClient.cs" ], "AllowedPostPhysicalAuthorityPaths": [ "evidence/smart-discovery-repeat-run.authority.json", From d7b3033c1637cfa87c223eff3d57688fc0183dae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 14:53:30 +0700 Subject: [PATCH 085/243] P0-5g fix full-history checkout for promotion guard --- .github/workflows/smart-discovery-production-promotion.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml index 279cfaa10..bbfd1789d 100644 --- a/.github/workflows/smart-discovery-production-promotion.yml +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -13,7 +13,7 @@ jobs: shell: powershell run: | $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } - git clone --quiet --depth 0 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + git clone --quiet --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester $arsasHead = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() "ARSAS_HEAD=$arsasHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "P0-5g ARSAS head: $arsasHead" @@ -21,7 +21,7 @@ jobs: - name: Checkout ARIEC61850 PR 134 head shell: powershell run: | - git clone --quiet --depth 0 --branch perf/smart-ied-discovery https://github.com/masarray/ARIEC61850.git ARIEC61850 + git clone --quiet --branch perf/smart-ied-discovery https://github.com/masarray/ARIEC61850.git ARIEC61850 $engineHead = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() "ENGINE_HEAD=$engineHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "P0-5g engine PR head: $engineHead" From 2314ca7c21181909a56f7f9b9a98702c22cebae3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 14:54:31 +0700 Subject: [PATCH 086/243] P0-5g keep release notes clean-room neutral --- landing/release-notes.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/landing/release-notes.json b/landing/release-notes.json index 788535252..d53a0fc50 100644 --- a/landing/release-notes.json +++ b/landing/release-notes.json @@ -8,14 +8,14 @@ "summaryId": "ARSAS 1.6.37 adalah stable release Windows terverifikasi terbaru. Identitas paket, link download, checksum, dan publication evidence disinkronkan otomatis dari GitHub Release bertag.", "highlights": [ "fix(v1.6.36): smooth COMTRADE scrub presentation at display cadence", - "fix(v1.6.36): match IEDScout RCB instances and source export identity", + "fix(v1.6.36): converge RCB instances and source export identity with the trusted reference workflow", "COMTRADE: smooth scrubbing across analysis workspaces", "Release convergence: promote field-tested v1.6.36 trial fixes to main", "docs: synchronize documentation and landing with ARSAS 1.6.37" ], "highlightsId": [ "Perubahan rilis: fix(v1.6.36): smooth COMTRADE scrub presentation at display cadence", - "Perubahan rilis: fix(v1.6.36): match IEDScout RCB instances and source export identity", + "Perubahan rilis: fix(v1.6.36): samakan instance RCB dan identitas source export dengan workflow referensi tepercaya", "Perubahan rilis: COMTRADE: smooth scrubbing across analysis workspaces", "Perubahan rilis: Release convergence: promote field-tested v1.6.36 trial fixes to main", "Dokumentasi: synchronize documentation and landing with ARSAS 1.6.37" From 92339d7a11b543adc3ec9c834dd1195aaa05cec2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 14:56:45 +0700 Subject: [PATCH 087/243] P0-5g bind production switch to exact promotion authority --- ...y-smart-discovery-production-readiness.ps1 | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1 index e3014240f..7d6cec682 100644 --- a/scripts/verify-smart-discovery-production-readiness.ps1 +++ b/scripts/verify-smart-discovery-production-readiness.ps1 @@ -50,11 +50,20 @@ function Get-GitChangedPaths([string]$RepositoryPath, [string]$BaseCommit, [stri return @($lines | ForEach-Object { ([string]$_).Trim().Replace('\\','/') } | Where-Object { $_ } | Sort-Object -Unique) } -function Get-PromotionSwitch([string]$PropsFile) { +function Get-PromotionProps([string]$PropsFile) { [xml]$xml = Get-Content -LiteralPath $PropsFile -Raw - $node = $xml.Project.PropertyGroup.SmartDiscoveryProductionPromoted + $group = $xml.Project.PropertyGroup + $node = $group.SmartDiscoveryProductionPromoted if ($null -eq $node) { throw 'Promotion props does not define SmartDiscoveryProductionPromoted.' } - return ([string]$node).Trim().ToLowerInvariant() -eq 'true' + $promotedText = ([string]$node).Trim().ToLowerInvariant() + if ($promotedText -notin @('true','false')) { throw 'SmartDiscoveryProductionPromoted is not a boolean.' } + return [pscustomobject]@{ + Promoted = $promotedText -eq 'true' + EvidenceEngineCommit = ([string]$group.SmartDiscoveryEvidenceEngineCommit).Trim().ToLowerInvariant() + Phase = ([string]$group.SmartDiscoveryPromotionPhase).Trim() + AuthoritySha256 = ([string]$group.SmartDiscoveryPromotionAuthoritySha256).Trim().ToLowerInvariant() + ValidatedEngineHead = ([string]$group.SmartDiscoveryValidatedEngineHead).Trim().ToLowerInvariant() + } } $targetFile = Resolve-File $TargetPath 'P0-5g promotion target' @@ -69,6 +78,7 @@ $engineHead = $EngineHeadCommit.ToLowerInvariant() $target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json $engineLock = Get-Content -LiteralPath $engineLockFile -Raw | ConvertFrom-Json +$promotionProps = Get-PromotionProps $propsFile $blockers = [System.Collections.Generic.List[string]]::new() $warnings = [System.Collections.Generic.List[string]]::new() @@ -85,6 +95,12 @@ if ([string]$engineLock.repository -ne [string]$target.EngineRepository) { $bloc if (([string]$engineLock.commit).ToLowerInvariant() -ne $baseline) { $blockers.Add('ARSAS engine lock no longer points at the physical-evidence engine baseline.') } +if ($promotionProps.EvidenceEngineCommit -and $promotionProps.EvidenceEngineCommit -ne $baseline) { + $blockers.Add('Promotion props evidence engine commit differs from the P0-5g baseline.') +} +if ($promotionProps.Phase -and $promotionProps.Phase -ne 'P0-5g') { + $blockers.Add('Promotion props phase differs from P0-5g.') +} $engineIsDescendant = Test-GitAncestor $engineRepo $baseline $engineHead if (-not $engineIsDescendant) { @@ -104,7 +120,7 @@ if ($EngineHeadCiConclusion.Trim().ToLowerInvariant() -ne 'success') { $blockers.Add("Engine PR head CI is not green: '$EngineHeadCiConclusion'.") } -$productionSwitch = Get-PromotionSwitch $propsFile +$productionSwitch = [bool]$promotionProps.Promoted $physicalAuthority = $null $physicalAuthorityFile = $null if ([string]::IsNullOrWhiteSpace($PhysicalAuthorityPath) -or -not (Test-Path -LiteralPath $PhysicalAuthorityPath -PathType Leaf)) { @@ -153,7 +169,21 @@ if (-not [string]::IsNullOrWhiteSpace($PromotionAuthorityPath) -and (Test-Path - if (([string]$promotionAuthority.EngineHeadCommit).ToLowerInvariant() -ne $engineHead) { $blockers.Add('P0-5g promotion authority is bound to a different engine PR head.') } - if (-not $productionSwitch) { $blockers.Add('P0-5g authority exists but the tracked production switch is still false.') } + if (-not $productionSwitch) { + $blockers.Add('P0-5g authority exists but the tracked production switch is still false.') + } else { + $authorityHash = (Get-FileHash -LiteralPath $promotionAuthorityFile -Algorithm SHA256).Hash.ToLowerInvariant() + if ($promotionProps.AuthoritySha256 -notmatch '^[0-9a-f]{64}$') { + $blockers.Add('Production promotion props do not contain a valid promotion-authority SHA-256.') + } elseif ($promotionProps.AuthoritySha256 -ne $authorityHash) { + $blockers.Add('Production promotion props are bound to a different P0-5g promotion authority.') + } + if ($promotionProps.ValidatedEngineHead -notmatch '^[0-9a-f]{40}$') { + $blockers.Add('Production promotion props do not contain a valid validated engine head.') + } elseif ($promotionProps.ValidatedEngineHead -ne $engineHead) { + $blockers.Add('Production promotion props are bound to a different validated engine head.') + } + } } elseif ($productionSwitch) { $blockers.Add('Production switch is true without a tracked P0-5g promotion authority.') } @@ -168,7 +198,7 @@ if ($status -eq 'READY_TO_PROMOTE' -and $productionSwitch) { } $result = [ordered]@{ - SchemaVersion = 1 + SchemaVersion = 2 Phase = 'P0-5g' Verdict = $status ArsasHeadCommit = $arsasHead @@ -178,6 +208,8 @@ $result = [ordered]@{ EngineHeadIsEvidenceCompatibleDescendant = $engineIsDescendant -and $criticalChanges.Count -eq 0 DiscoveryCriticalChanges = @($criticalChanges) ProductionSwitchEnabled = $productionSwitch + PromotionAuthoritySha256 = $promotionProps.AuthoritySha256 + PromotionValidatedEngineHead = $promotionProps.ValidatedEngineHead PhysicalAuthorityPath = $physicalAuthorityFile PromotionAuthorityPath = $promotionAuthorityFile Blockers = @($blockers) From 31aaa7841a4452cc318fb8ffee7f4212685c2c9d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 14:57:01 +0700 Subject: [PATCH 088/243] P0-5g regress promotion authority hash binding --- ...martDiscoveryProductionPromotionRegressionTests.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index 7a87f186e..df9ab169c 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -25,6 +25,8 @@ public void P05g_TargetKeepsProductionPromotionFailClosed() Assert.True(contract.GetProperty("AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged").GetBoolean()); Assert.True(contract.GetProperty("RequireProductionSwitchFalseUntilAuthority").GetBoolean()); Assert.True(contract.GetProperty("RequireGenericBuildSuccessBeforeReadyForReview").GetBoolean()); + Assert.True(contract.GetProperty("RequireNoUnresolvedReviewThreadsBeforeReadyForReview").GetBoolean()); + Assert.True(contract.GetProperty("RequirePrRemainDraftUntilAllReadyGatesPass").GetBoolean()); } [Fact] @@ -43,7 +45,7 @@ public void P05g_DefaultBuildDoesNotPromoteFieldRoute() } [Fact] - public void P05g_ReadinessBindsPhysicalAuthorityAndEvidenceCompatibleEngineHead() + public void P05g_ReadinessBindsPhysicalAuthorityEvidenceCompatibleEngineAndExactPromotionProps() { var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-production-readiness.ps1")); @@ -55,6 +57,11 @@ public void P05g_ReadinessBindsPhysicalAuthorityAndEvidenceCompatibleEngineHead( Assert.Contains("READY_TO_PROMOTE", source, StringComparison.Ordinal); Assert.Contains("READY_FOR_REVIEW", source, StringComparison.Ordinal); Assert.Contains("production-promoted", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.Contains("Production promotion props are bound to a different P0-5g promotion authority", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + Assert.Contains("Production promotion props are bound to a different validated engine head", source, StringComparison.Ordinal); + Assert.Contains("SchemaVersion = 2", source, StringComparison.Ordinal); } [Fact] @@ -66,6 +73,8 @@ public void P05g_PromotionWriterHasNoFixtureBypassAndRequiresReadyProof() Assert.Contains("physical-finalized P0-5f authority", source, StringComparison.Ordinal); Assert.Contains("EngineHeadIsEvidenceCompatibleDescendant", source, StringComparison.Ordinal); Assert.Contains("SmartDiscoveryProductionPromoted>true", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); Assert.Contains("P0-5g-authority", source, StringComparison.Ordinal); Assert.DoesNotContain("AllowFixtureEvidence", source, StringComparison.Ordinal); } From 3c37bb0f18a87f47285731371277be1cefd7a289 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 14:57:16 +0700 Subject: [PATCH 089/243] P0-5g require exact promotion authority binding --- evidence/smart-discovery-production-promotion-target.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evidence/smart-discovery-production-promotion-target.json b/evidence/smart-discovery-production-promotion-target.json index 4f74831fe..442f81861 100644 --- a/evidence/smart-discovery-production-promotion-target.json +++ b/evidence/smart-discovery-production-promotion-target.json @@ -1,5 +1,5 @@ { - "SchemaVersion": 1, + "SchemaVersion": 2, "Phase": "P0-5g", "Status": "awaiting-physical-p0-5f-authority-and-green-mainline-gates", "ArsasPullRequest": 324, @@ -35,6 +35,8 @@ "AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged": true, "RequireFieldRouteExplicitOptInBeforePromotion": true, "RequireProductionSwitchFalseUntilAuthority": true, + "RequireExactPromotionAuthoritySha256Binding": true, + "RequireExactValidatedEngineHeadBinding": true, "RequirePurposeDiscoveryCiSuccess": true, "RequireGenericBuildSuccessBeforeReadyForReview": true, "RequireNoUnresolvedReviewThreadsBeforeReadyForReview": true, From 2f5e489831805942003578ffeaecd15bf7bd1620 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 14:57:32 +0700 Subject: [PATCH 090/243] P0-5g lock new promotion binding contract in tests --- .../SmartDiscoveryProductionPromotionRegressionTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index df9ab169c..253f6509f 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -13,6 +13,7 @@ public void P05g_TargetKeepsProductionPromotionFailClosed() using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-production-promotion-target.json"))); var root = document.RootElement; + Assert.Equal(2, root.GetProperty("SchemaVersion").GetInt32()); Assert.Equal("P0-5g", root.GetProperty("Phase").GetString()); Assert.Equal(EvidenceEngineCommit, root.GetProperty("EvidenceEngineBaselineCommit").GetString()); Assert.Equal(134, root.GetProperty("EnginePullRequest").GetInt32()); @@ -24,6 +25,8 @@ public void P05g_TargetKeepsProductionPromotionFailClosed() Assert.True(contract.GetProperty("RequireEngineHeadCiSuccess").GetBoolean()); Assert.True(contract.GetProperty("AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged").GetBoolean()); Assert.True(contract.GetProperty("RequireProductionSwitchFalseUntilAuthority").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactPromotionAuthoritySha256Binding").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactValidatedEngineHeadBinding").GetBoolean()); Assert.True(contract.GetProperty("RequireGenericBuildSuccessBeforeReadyForReview").GetBoolean()); Assert.True(contract.GetProperty("RequireNoUnresolvedReviewThreadsBeforeReadyForReview").GetBoolean()); Assert.True(contract.GetProperty("RequirePrRemainDraftUntilAllReadyGatesPass").GetBoolean()); From 311df48b88242bb0917589b3fc0e69e0ebfcf9f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 14:58:40 +0700 Subject: [PATCH 091/243] P0-5g bind authority to exact target and engine lock --- ...erify-smart-discovery-production-readiness.ps1 | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1 index 7d6cec682..f6f2b3301 100644 --- a/scripts/verify-smart-discovery-production-readiness.ps1 +++ b/scripts/verify-smart-discovery-production-readiness.ps1 @@ -79,6 +79,8 @@ $engineHead = $EngineHeadCommit.ToLowerInvariant() $target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json $engineLock = Get-Content -LiteralPath $engineLockFile -Raw | ConvertFrom-Json $promotionProps = Get-PromotionProps $propsFile +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$engineLockHash = (Get-FileHash -LiteralPath $engineLockFile -Algorithm SHA256).Hash.ToLowerInvariant() $blockers = [System.Collections.Generic.List[string]]::new() $warnings = [System.Collections.Generic.List[string]]::new() @@ -158,6 +160,15 @@ if (-not [string]::IsNullOrWhiteSpace($PromotionAuthorityPath) -and (Test-Path - if ($promotionAuthority.Phase -ne 'P0-5g-authority' -or $promotionAuthority.Status -ne 'production-promoted') { $blockers.Add('P0-5g promotion authority is not production-promoted.') } + if (([string]$promotionAuthority.EvidenceEngineBaselineCommit).ToLowerInvariant() -ne $baseline) { + $blockers.Add('P0-5g promotion authority evidence baseline differs from the current target.') + } + if (([string]$promotionAuthority.PromotionTargetSha256).ToLowerInvariant() -ne $targetHash) { + $blockers.Add('P0-5g promotion authority is bound to a different promotion target.') + } + if (([string]$promotionAuthority.EngineLockSha256).ToLowerInvariant() -ne $engineLockHash) { + $blockers.Add('P0-5g promotion authority is bound to a different engine lock.') + } if ($null -eq $physicalAuthorityFile) { $blockers.Add('P0-5g promotion authority exists without P0-5f physical authority.') } else { @@ -198,7 +209,7 @@ if ($status -eq 'READY_TO_PROMOTE' -and $productionSwitch) { } $result = [ordered]@{ - SchemaVersion = 2 + SchemaVersion = 3 Phase = 'P0-5g' Verdict = $status ArsasHeadCommit = $arsasHead @@ -207,6 +218,8 @@ $result = [ordered]@{ EngineHeadCiConclusion = $EngineHeadCiConclusion EngineHeadIsEvidenceCompatibleDescendant = $engineIsDescendant -and $criticalChanges.Count -eq 0 DiscoveryCriticalChanges = @($criticalChanges) + PromotionTargetSha256 = $targetHash + EngineLockSha256 = $engineLockHash ProductionSwitchEnabled = $productionSwitch PromotionAuthoritySha256 = $promotionProps.AuthoritySha256 PromotionValidatedEngineHead = $promotionProps.ValidatedEngineHead From aa647e3b94dd4f4d087cd17d1c7e8a0f39160d45 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 14:58:56 +0700 Subject: [PATCH 092/243] P0-5g regress target and lock provenance binding --- .../SmartDiscoveryProductionPromotionRegressionTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index 253f6509f..561199555 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -48,7 +48,7 @@ public void P05g_DefaultBuildDoesNotPromoteFieldRoute() } [Fact] - public void P05g_ReadinessBindsPhysicalAuthorityEvidenceCompatibleEngineAndExactPromotionProps() + public void P05g_ReadinessBindsAllPromotionProvenance() { var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-production-readiness.ps1")); @@ -64,7 +64,11 @@ public void P05g_ReadinessBindsPhysicalAuthorityEvidenceCompatibleEngineAndExact Assert.Contains("Production promotion props are bound to a different P0-5g promotion authority", source, StringComparison.Ordinal); Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); Assert.Contains("Production promotion props are bound to a different validated engine head", source, StringComparison.Ordinal); - Assert.Contains("SchemaVersion = 2", source, StringComparison.Ordinal); + Assert.Contains("promotion authority is bound to a different promotion target", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("promotion authority is bound to a different engine lock", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PromotionTargetSha256", source, StringComparison.Ordinal); + Assert.Contains("EngineLockSha256", source, StringComparison.Ordinal); + Assert.Contains("SchemaVersion = 3", source, StringComparison.Ordinal); } [Fact] From 85d9fa534d89d6ff6f285a29c300d1edba02aa11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:00:32 +0700 Subject: [PATCH 093/243] P0-5g normalize empty git diff results under StrictMode --- scripts/verify-smart-discovery-production-readiness.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1 index f6f2b3301..59f0139c7 100644 --- a/scripts/verify-smart-discovery-production-readiness.ps1 +++ b/scripts/verify-smart-discovery-production-readiness.ps1 @@ -112,7 +112,7 @@ if (-not $engineIsDescendant) { $criticalPaths = @($target.DiscoveryCriticalEnginePaths | ForEach-Object { [string]$_ }) $criticalChanges = @() if ($engineIsDescendant) { - $criticalChanges = Get-GitChangedPaths $engineRepo $baseline $engineHead $criticalPaths + $criticalChanges = @(Get-GitChangedPaths $engineRepo $baseline $engineHead $criticalPaths) if ($criticalChanges.Count -gt 0) { $blockers.Add("Engine head changed discovery-critical evidence paths after the physical baseline: $($criticalChanges -join ', ').") } @@ -143,7 +143,7 @@ if ([string]::IsNullOrWhiteSpace($PhysicalAuthorityPath) -or -not (Test-Path -Li } elseif (-not (Test-GitAncestor $arsasRepo $authorityArsas $arsasHead)) { $blockers.Add('Current ARSAS head is not a descendant of the physically validated ARSAS commit.') } else { - $allChanged = Get-GitChangedPaths $arsasRepo $authorityArsas $arsasHead @('.') + $allChanged = @(Get-GitChangedPaths $arsasRepo $authorityArsas $arsasHead @('.')) $allowed = @($target.AllowedPostPhysicalAuthorityPaths | ForEach-Object { ([string]$_).Replace('\\','/') }) $notAllowed = @($allChanged | Where-Object { $allowed -notcontains $_ }) if ($notAllowed.Count -gt 0) { From df1985a2d3aca1854bf8f9131715548f4768b11e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:02:00 +0700 Subject: [PATCH 094/243] P0-5g require complete physical authority provenance --- ...scovery-production-promotion-authority.ps1 | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/scripts/new-smart-discovery-production-promotion-authority.ps1 b/scripts/new-smart-discovery-production-promotion-authority.ps1 index 1e0073058..6317d3a60 100644 --- a/scripts/new-smart-discovery-production-promotion-authority.ps1 +++ b/scripts/new-smart-discovery-production-promotion-authority.ps1 @@ -16,6 +16,48 @@ function Resolve-File([string]$Path, [string]$Label) { return $resolved.Path } +function Assert-Sha256([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{64}$') { throw "$Label must be a 64-character SHA-256 value." } +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Assert-PhysicalAuthorityProvenance($Physical) { + if ([int]$Physical.SchemaVersion -lt 1 -or $Physical.Phase -ne 'P0-5f-authority' -or $Physical.Status -ne 'physical-finalized') { + throw 'Production promotion requires physical-finalized P0-5f authority.' + } + Assert-Commit ([string]$Physical.ArsasCommit) 'P0-5f ARSAS commit' + Assert-Commit ([string]$Physical.EngineCommit) 'P0-5f engine commit' + Assert-Sha256 ([string]$Physical.GoldenLockSha256) 'P0-5f golden lock SHA-256' + Assert-Sha256 ([string]$Physical.RepeatTargetSha256) 'P0-5f repeat target SHA-256' + Assert-Sha256 ([string]$Physical.FinalizationSha256) 'P0-5f finalization SHA-256' + + $count = [int]$Physical.IndependentAssociations + if ($count -lt 3) { throw 'P0-5f physical authority must contain at least three independent associations.' } + + $generations = @($Physical.AssociationGenerations) + $bundleHashes = @($Physical.RunBundleSha256) + $captureHashes = @($Physical.CaptureSha256) + $runtimeHashes = @($Physical.RuntimeEvidenceSha256) + foreach ($entry in @( + @{ Label = 'association generations'; Values = $generations }, + @{ Label = 'run bundle hashes'; Values = $bundleHashes }, + @{ Label = 'capture hashes'; Values = $captureHashes }, + @{ Label = 'runtime evidence hashes'; Values = $runtimeHashes })) { + if ($entry.Values.Count -ne $count) { throw "P0-5f physical authority $($entry.Label) count does not match IndependentAssociations." } + if (@($entry.Values | Sort-Object -Unique).Count -ne $count) { throw "P0-5f physical authority contains reused $($entry.Label)." } + } + foreach ($generation in $generations) { + if ([long]$generation -le 0) { throw 'P0-5f physical authority contains an invalid association generation.' } + } + foreach ($hash in @($bundleHashes + $captureHashes + $runtimeHashes)) { + Assert-Sha256 ([string]$hash) 'P0-5f evidence SHA-256' + } + if ($null -eq $Physical.Consensus) { throw 'P0-5f physical authority is missing consensus evidence.' } +} + $readinessFile = Resolve-File $ReadinessJson 'P0-5g readiness JSON' $targetFile = Resolve-File $TargetPath 'P0-5g promotion target' $physicalFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' @@ -41,9 +83,7 @@ if (-not [bool]$readiness.EngineHeadIsEvidenceCompatibleDescendant) { if ([string]$readiness.EngineHeadCiConclusion -ne 'success') { throw 'Engine PR head CI is not green.' } -if ($physical.Phase -ne 'P0-5f-authority' -or $physical.Status -ne 'physical-finalized') { - throw 'Production promotion requires physical-finalized P0-5f authority.' -} +Assert-PhysicalAuthorityProvenance $physical if ($target.Phase -ne 'P0-5g') { throw 'Promotion target is not P0-5g authority.' } $baseline = ([string]$target.EvidenceEngineBaselineCommit).ToLowerInvariant() @@ -53,10 +93,8 @@ if (([string]$physical.EngineCommit).ToLowerInvariant() -ne $baseline) { if (([string]$engineLock.commit).ToLowerInvariant() -ne $baseline) { throw 'ARSAS engine lock differs from the P0-5g evidence baseline.' } -if (([string]$readiness.ArsasHeadCommit).ToLowerInvariant() -notmatch '^[0-9a-f]{40}$' -or - ([string]$readiness.EngineHeadCommit).ToLowerInvariant() -notmatch '^[0-9a-f]{40}$') { - throw 'Readiness proof does not contain valid exact ARSAS/engine head commits.' -} +Assert-Commit ([string]$readiness.ArsasHeadCommit) 'Readiness ARSAS head commit' +Assert-Commit ([string]$readiness.EngineHeadCommit) 'Readiness engine head commit' $readinessHash = (Get-FileHash -LiteralPath $readinessFile -Algorithm SHA256).Hash.ToLowerInvariant() $targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() @@ -64,7 +102,7 @@ $physicalHash = (Get-FileHash -LiteralPath $physicalFile -Algorithm SHA256).Hash $engineLockHash = (Get-FileHash -LiteralPath $engineLockFile -Algorithm SHA256).Hash.ToLowerInvariant() $authority = [ordered]@{ - SchemaVersion = 1 + SchemaVersion = 2 Phase = 'P0-5g-authority' Status = 'production-promoted' DeviceIdentity = [string]$physical.DeviceIdentity @@ -82,6 +120,9 @@ $authority = [ordered]@{ DiscoveryCriticalChanges = @($readiness.DiscoveryCriticalChanges) IndependentPhysicalAssociations = [int]$physical.IndependentAssociations GoldenConsensus = $physical.Consensus + P05fGoldenLockSha256 = ([string]$physical.GoldenLockSha256).ToLowerInvariant() + P05fRepeatTargetSha256 = ([string]$physical.RepeatTargetSha256).ToLowerInvariant() + P05fFinalizationSha256 = ([string]$physical.FinalizationSha256).ToLowerInvariant() } $authorityDirectory = Split-Path -Parent $OutputAuthorityPath From 646b9e50f7574abc313dc460ea52b35a003e943a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:03:04 +0700 Subject: [PATCH 095/243] P0-5g fail closed on incomplete physical authority provenance --- ...y-smart-discovery-production-readiness.ps1 | 67 ++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1 index 59f0139c7..d52bdf782 100644 --- a/scripts/verify-smart-discovery-production-readiness.ps1 +++ b/scripts/verify-smart-discovery-production-readiness.ps1 @@ -66,6 +66,56 @@ function Get-PromotionProps([string]$PropsFile) { } } +function Get-SafeProperty($Object, [string]$Name) { + if ($null -eq $Object) { return $null } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return $property.Value +} + +function Get-PhysicalAuthorityProvenanceErrors($Physical) { + $errors = [System.Collections.Generic.List[string]]::new() + $schema = Get-SafeProperty $Physical 'SchemaVersion' + $phase = [string](Get-SafeProperty $Physical 'Phase') + $status = [string](Get-SafeProperty $Physical 'Status') + if ($null -eq $schema -or [int]$schema -lt 1 -or $phase -ne 'P0-5f-authority' -or $status -ne 'physical-finalized') { + $errors.Add('P0-5f authority is not physical-finalized production evidence.') + return @($errors) + } + + $arsasCommit = [string](Get-SafeProperty $Physical 'ArsasCommit') + $engineCommit = [string](Get-SafeProperty $Physical 'EngineCommit') + if ($arsasCommit -notmatch '^[0-9a-fA-F]{40}$') { $errors.Add('P0-5f authority ARSAS commit is invalid.') } + if ($engineCommit -notmatch '^[0-9a-fA-F]{40}$') { $errors.Add('P0-5f authority engine commit is invalid.') } + + foreach ($name in @('GoldenLockSha256','RepeatTargetSha256','FinalizationSha256')) { + $value = [string](Get-SafeProperty $Physical $name) + if ($value -notmatch '^[0-9a-fA-F]{64}$') { $errors.Add("P0-5f authority $name is missing or invalid.") } + } + + $countValue = Get-SafeProperty $Physical 'IndependentAssociations' + $count = if ($null -eq $countValue) { 0 } else { [int]$countValue } + if ($count -lt 3) { $errors.Add('P0-5f authority must contain at least three independent associations.') } + + $sets = @( + @{ Label = 'association generations'; Values = @(Get-SafeProperty $Physical 'AssociationGenerations') }, + @{ Label = 'run bundle hashes'; Values = @(Get-SafeProperty $Physical 'RunBundleSha256') }, + @{ Label = 'capture hashes'; Values = @(Get-SafeProperty $Physical 'CaptureSha256') }, + @{ Label = 'runtime evidence hashes'; Values = @(Get-SafeProperty $Physical 'RuntimeEvidenceSha256') }) + foreach ($set in $sets) { + if ($set.Values.Count -ne $count) { $errors.Add("P0-5f authority $($set.Label) count differs from IndependentAssociations.") } + elseif (@($set.Values | Sort-Object -Unique).Count -ne $count) { $errors.Add("P0-5f authority contains reused $($set.Label).") } + } + foreach ($generation in @($sets[0].Values)) { + if ([long]$generation -le 0) { $errors.Add('P0-5f authority contains an invalid association generation.'); break } + } + foreach ($hash in @($sets[1].Values + $sets[2].Values + $sets[3].Values)) { + if ([string]$hash -notmatch '^[0-9a-fA-F]{64}$') { $errors.Add('P0-5f authority contains an invalid evidence SHA-256.'); break } + } + if ($null -eq (Get-SafeProperty $Physical 'Consensus')) { $errors.Add('P0-5f authority is missing consensus evidence.') } + return @($errors) +} + $targetFile = Resolve-File $TargetPath 'P0-5g promotion target' $engineLockFile = Resolve-File $EngineLockPath 'ARIEC61850 engine lock' $propsFile = Resolve-File $PromotionPropsPath 'P0-5g promotion props' @@ -130,20 +180,19 @@ if ([string]::IsNullOrWhiteSpace($PhysicalAuthorityPath) -or -not (Test-Path -Li } else { $physicalAuthorityFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' $physicalAuthority = Get-Content -LiteralPath $physicalAuthorityFile -Raw | ConvertFrom-Json - if ($physicalAuthority.Phase -ne 'P0-5f-authority' -or $physicalAuthority.Status -ne 'physical-finalized') { - $blockers.Add('P0-5f authority is not physical-finalized production evidence.') - } - if (([string]$physicalAuthority.EngineCommit).ToLowerInvariant() -ne $baseline) { + foreach ($error in @(Get-PhysicalAuthorityProvenanceErrors $physicalAuthority)) { $blockers.Add($error) } + $physicalEngine = [string](Get-SafeProperty $physicalAuthority 'EngineCommit') + if ($physicalEngine -and $physicalEngine.ToLowerInvariant() -ne $baseline) { $blockers.Add('P0-5f authority engine commit differs from the evidence baseline.') } - $authorityArsas = ([string]$physicalAuthority.ArsasCommit).ToLowerInvariant() - if ($authorityArsas -notmatch '^[0-9a-f]{40}$') { + $authorityArsas = [string](Get-SafeProperty $physicalAuthority 'ArsasCommit') + if ($authorityArsas -notmatch '^[0-9a-fA-F]{40}$') { $blockers.Add('P0-5f authority does not contain a valid ARSAS commit.') - } elseif (-not (Test-GitAncestor $arsasRepo $authorityArsas $arsasHead)) { + } elseif (-not (Test-GitAncestor $arsasRepo $authorityArsas.ToLowerInvariant() $arsasHead)) { $blockers.Add('Current ARSAS head is not a descendant of the physically validated ARSAS commit.') } else { - $allChanged = @(Get-GitChangedPaths $arsasRepo $authorityArsas $arsasHead @('.')) + $allChanged = @(Get-GitChangedPaths $arsasRepo $authorityArsas.ToLowerInvariant() $arsasHead @('.')) $allowed = @($target.AllowedPostPhysicalAuthorityPaths | ForEach-Object { ([string]$_).Replace('\\','/') }) $notAllowed = @($allChanged | Where-Object { $allowed -notcontains $_ }) if ($notAllowed.Count -gt 0) { @@ -209,7 +258,7 @@ if ($status -eq 'READY_TO_PROMOTE' -and $productionSwitch) { } $result = [ordered]@{ - SchemaVersion = 3 + SchemaVersion = 4 Phase = 'P0-5g' Verdict = $status ArsasHeadCommit = $arsasHead From ee562511e9d3d5aff2748c872eee7a31cf09ebc6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:03:30 +0700 Subject: [PATCH 096/243] P0-5g regress physical authority provenance validation --- ...iscoveryProductionPromotionRegressionTests.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index 561199555..ff6c97744 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -22,6 +22,7 @@ public void P05g_TargetKeepsProductionPromotionFailClosed() var contract = root.GetProperty("ProductionPromotionContract"); Assert.True(contract.GetProperty("RequireP05fPhysicalAuthority").GetBoolean()); + Assert.True(contract.GetProperty("RequirePhysicalAuthorityProductionEvidenceOnly").GetBoolean()); Assert.True(contract.GetProperty("RequireEngineHeadCiSuccess").GetBoolean()); Assert.True(contract.GetProperty("AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged").GetBoolean()); Assert.True(contract.GetProperty("RequireProductionSwitchFalseUntilAuthority").GetBoolean()); @@ -53,6 +54,9 @@ public void P05g_ReadinessBindsAllPromotionProvenance() var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-production-readiness.ps1")); Assert.Contains("P0-5f physical-finalized authority is missing", source, StringComparison.Ordinal); + Assert.Contains("Get-PhysicalAuthorityProvenanceErrors", source, StringComparison.Ordinal); + Assert.Contains("at least three independent associations", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("contains reused", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("merge-base --is-ancestor", source, StringComparison.Ordinal); Assert.Contains("DiscoveryCriticalEnginePaths", source, StringComparison.Ordinal); Assert.Contains("Engine PR head CI is not green", source, StringComparison.Ordinal); @@ -68,21 +72,25 @@ public void P05g_ReadinessBindsAllPromotionProvenance() Assert.Contains("promotion authority is bound to a different engine lock", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("PromotionTargetSha256", source, StringComparison.Ordinal); Assert.Contains("EngineLockSha256", source, StringComparison.Ordinal); - Assert.Contains("SchemaVersion = 3", source, StringComparison.Ordinal); + Assert.Contains("SchemaVersion = 4", source, StringComparison.Ordinal); } [Fact] - public void P05g_PromotionWriterHasNoFixtureBypassAndRequiresReadyProof() + public void P05g_PromotionWriterHasNoFixtureBypassAndRequiresProductionPhysicalProvenance() { var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-production-promotion-authority.ps1")); Assert.Contains("READY_TO_PROMOTE", source, StringComparison.Ordinal); - Assert.Contains("physical-finalized P0-5f authority", source, StringComparison.Ordinal); - Assert.Contains("EngineHeadIsEvidenceCompatibleDescendant", source, StringComparison.Ordinal); + Assert.Contains("Assert-PhysicalAuthorityProvenance", source, StringComparison.Ordinal); + Assert.Contains("at least three independent associations", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GoldenLockSha256", source, StringComparison.Ordinal); + Assert.Contains("RepeatTargetSha256", source, StringComparison.Ordinal); + Assert.Contains("FinalizationSha256", source, StringComparison.Ordinal); Assert.Contains("SmartDiscoveryProductionPromoted>true", source, StringComparison.Ordinal); Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); Assert.Contains("P0-5g-authority", source, StringComparison.Ordinal); + Assert.Contains("SchemaVersion = 2", source, StringComparison.Ordinal); Assert.DoesNotContain("AllowFixtureEvidence", source, StringComparison.Ordinal); } From b425cc9448cba077eb69f2e5639c90f27af9ef10 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:21:51 +0700 Subject: [PATCH 097/243] P0-5g allow fail-closed props without promotion bindings --- ...y-smart-discovery-production-readiness.ps1 | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1 index d52bdf782..5b9b9a29f 100644 --- a/scripts/verify-smart-discovery-production-readiness.ps1 +++ b/scripts/verify-smart-discovery-production-readiness.ps1 @@ -50,19 +50,25 @@ function Get-GitChangedPaths([string]$RepositoryPath, [string]$BaseCommit, [stri return @($lines | ForEach-Object { ([string]$_).Trim().Replace('\\','/') } | Where-Object { $_ } | Sort-Object -Unique) } +function Get-XmlChildText($Group, [string]$Name) { + if ($null -eq $Group) { return '' } + $node = @($Group.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1) + if ($node.Count -eq 0 -or $null -eq $node[0]) { return '' } + return ([string]$node[0].InnerText).Trim() +} + function Get-PromotionProps([string]$PropsFile) { [xml]$xml = Get-Content -LiteralPath $PropsFile -Raw $group = $xml.Project.PropertyGroup - $node = $group.SmartDiscoveryProductionPromoted - if ($null -eq $node) { throw 'Promotion props does not define SmartDiscoveryProductionPromoted.' } - $promotedText = ([string]$node).Trim().ToLowerInvariant() + $promotedText = (Get-XmlChildText $group 'SmartDiscoveryProductionPromoted').ToLowerInvariant() + if ([string]::IsNullOrWhiteSpace($promotedText)) { throw 'Promotion props does not define SmartDiscoveryProductionPromoted.' } if ($promotedText -notin @('true','false')) { throw 'SmartDiscoveryProductionPromoted is not a boolean.' } return [pscustomobject]@{ Promoted = $promotedText -eq 'true' - EvidenceEngineCommit = ([string]$group.SmartDiscoveryEvidenceEngineCommit).Trim().ToLowerInvariant() - Phase = ([string]$group.SmartDiscoveryPromotionPhase).Trim() - AuthoritySha256 = ([string]$group.SmartDiscoveryPromotionAuthoritySha256).Trim().ToLowerInvariant() - ValidatedEngineHead = ([string]$group.SmartDiscoveryValidatedEngineHead).Trim().ToLowerInvariant() + EvidenceEngineCommit = (Get-XmlChildText $group 'SmartDiscoveryEvidenceEngineCommit').ToLowerInvariant() + Phase = Get-XmlChildText $group 'SmartDiscoveryPromotionPhase' + AuthoritySha256 = (Get-XmlChildText $group 'SmartDiscoveryPromotionAuthoritySha256').ToLowerInvariant() + ValidatedEngineHead = (Get-XmlChildText $group 'SmartDiscoveryValidatedEngineHead').ToLowerInvariant() } } @@ -294,4 +300,4 @@ Write-Host " production switch: $productionSwitch" foreach ($blocker in $blockers) { Write-Host " BLOCKER: $blocker" } Write-Host " readiness JSON: $OutputJson" -if ($result.Verdict -eq 'BLOCKED' -and -not $NoFailExit) { exit 1 } +if ($result.Verdict -eq 'BLOCKED' -and -not $NoFailExit) { exit 1 } \ No newline at end of file From 73a4ec6e5834a130629452e558089dd406c2c58e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:22:30 +0700 Subject: [PATCH 098/243] P0-5g regress fail-closed optional promotion bindings --- ...tDiscoveryProductionPromotionRegressionTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index ff6c97744..055ff4990 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -39,6 +39,8 @@ public void P05g_DefaultBuildDoesNotPromoteFieldRoute() var props = XDocument.Load(FindRepoFile("evidence/SmartDiscoveryPromotion.props")); var promoted = props.Descendants("SmartDiscoveryProductionPromoted").Single().Value.Trim(); Assert.Equal("false", promoted, ignoreCase: true); + Assert.Empty(props.Descendants("SmartDiscoveryPromotionAuthoritySha256")); + Assert.Empty(props.Descendants("SmartDiscoveryValidatedEngineHead")); var targets = File.ReadAllText(FindRepoFile("Directory.Build.targets")); Assert.Contains("GITHUB_WORKFLOW", targets, StringComparison.Ordinal); @@ -48,6 +50,18 @@ public void P05g_DefaultBuildDoesNotPromoteFieldRoute() Assert.Contains(">false", targets, StringComparison.Ordinal); } + [Fact] + public void P05g_ReadinessAllowsMissingPromotionBindingsWhileFailClosed() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-production-readiness.ps1")); + + Assert.Contains("Get-XmlChildText", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + Assert.DoesNotContain("$group.SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.DoesNotContain("$group.SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + } + [Fact] public void P05g_ReadinessBindsAllPromotionProvenance() { From ae945d9107c494aa6d651819fd0f089a165e65d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:23:35 +0700 Subject: [PATCH 099/243] P0-5g add fail-closed mainline readiness gate --- .../smart-discovery-mainline-readiness.yml | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .github/workflows/smart-discovery-mainline-readiness.yml diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml new file mode 100644 index 000000000..a4caba7e4 --- /dev/null +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -0,0 +1,94 @@ +name: Smart Discovery Mainline Readiness + +on: + pull_request: + workflow_dispatch: + +jobs: + mainline-readiness: + name: Require physical promotion before mainline review + runs-on: windows-latest + steps: + - name: Checkout ARSAS full history + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + $arsasHead = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + "ARSAS_HEAD=$arsasHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5g mainline ARSAS head: $arsasHead" + + - name: Checkout ARIEC61850 PR 134 head + shell: powershell + run: | + git clone --quiet --branch perf/smart-ied-discovery https://github.com/masarray/ARIEC61850.git ARIEC61850 + $engineHead = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + "ENGINE_HEAD=$engineHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5g mainline engine head: $engineHead" + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Validate exact engine head + shell: powershell + run: | + .\ARIEC61850\scripts\verify-source-clean.ps1 + dotnet restore .\ARIEC61850\ARIEC61850.sln + dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore + dotnet test .\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --no-build --no-restore + + - name: Require READY_FOR_REVIEW promotion state + shell: powershell + run: | + $root = '.\ArIED61850Tester' + $physical = "$root\evidence\smart-discovery-repeat-run.authority.json" + $promotion = "$root\evidence\smart-discovery-production-promotion-authority.json" + $output = "$root\TestResults\P0-5G-mainline-readiness.json" + New-Item -ItemType Directory -Force "$root\TestResults" | Out-Null + + if (-not (Test-Path $physical -PathType Leaf)) { + throw 'MAINLINE BLOCKED: P0-5f physical-finalized authority is not tracked.' + } + if (-not (Test-Path $promotion -PathType Leaf)) { + throw 'MAINLINE BLOCKED: P0-5g production promotion authority is not tracked.' + } + + & "$root\scripts\verify-smart-discovery-production-readiness.ps1" ` + -TargetPath "$root\evidence\smart-discovery-production-promotion-target.json" ` + -EngineLockPath "$root\engines\ARIEC61850.lock.json" ` + -PromotionPropsPath "$root\evidence\SmartDiscoveryPromotion.props" ` + -ArsasRepositoryPath $root ` + -EngineRepositoryPath '.\ARIEC61850' ` + -ArsasHeadCommit $env:ARSAS_HEAD ` + -EngineHeadCommit $env:ENGINE_HEAD ` + -EngineHeadCiConclusion success ` + -PhysicalAuthorityPath $physical ` + -PromotionAuthorityPath $promotion ` + -OutputJson $output ` + -NoFailExit + + $result = Get-Content $output -Raw | ConvertFrom-Json + if ($result.Verdict -ne 'READY_FOR_REVIEW') { + throw "MAINLINE BLOCKED: readiness verdict is '$($result.Verdict)': $(@($result.Blockers) -join '; ')" + } + if (-not [bool]$result.ProductionSwitchEnabled) { + throw 'MAINLINE BLOCKED: production smart-discovery switch is not enabled.' + } + + - name: Build and test promoted ARSAS path + shell: powershell + run: | + dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln + dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + + - name: Upload P0-5g mainline evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5g-mainline-readiness + path: ArIED61850Tester\TestResults\P0-5G-mainline-readiness.json + if-no-files-found: warn + retention-days: 14 From 01c4dddcebc22489c06ef6f8cc15bc7b51ef54cf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:23:48 +0700 Subject: [PATCH 100/243] P0-5g require dedicated mainline readiness gate --- evidence/smart-discovery-production-promotion-target.json | 1 + 1 file changed, 1 insertion(+) diff --git a/evidence/smart-discovery-production-promotion-target.json b/evidence/smart-discovery-production-promotion-target.json index 442f81861..c76a55fab 100644 --- a/evidence/smart-discovery-production-promotion-target.json +++ b/evidence/smart-discovery-production-promotion-target.json @@ -40,6 +40,7 @@ "RequirePurposeDiscoveryCiSuccess": true, "RequireGenericBuildSuccessBeforeReadyForReview": true, "RequireNoUnresolvedReviewThreadsBeforeReadyForReview": true, + "RequireDedicatedMainlineReadinessGateSuccess": true, "RequirePrRemainDraftUntilAllReadyGatesPass": true }, "PromotionAuthority": null From 8ee7c6276cc2826f97b4429282e7f9a599fd7d06 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:24:14 +0700 Subject: [PATCH 101/243] P0-5g lock dedicated mainline gate contract --- ...tDiscoveryProductionPromotionRegressionTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index 055ff4990..d49aa2bba 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -30,6 +30,7 @@ public void P05g_TargetKeepsProductionPromotionFailClosed() Assert.True(contract.GetProperty("RequireExactValidatedEngineHeadBinding").GetBoolean()); Assert.True(contract.GetProperty("RequireGenericBuildSuccessBeforeReadyForReview").GetBoolean()); Assert.True(contract.GetProperty("RequireNoUnresolvedReviewThreadsBeforeReadyForReview").GetBoolean()); + Assert.True(contract.GetProperty("RequireDedicatedMainlineReadinessGateSuccess").GetBoolean()); Assert.True(contract.GetProperty("RequirePrRemainDraftUntilAllReadyGatesPass").GetBoolean()); } @@ -62,6 +63,19 @@ public void P05g_ReadinessAllowsMissingPromotionBindingsWhileFailClosed() Assert.DoesNotContain("$group.SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); } + [Fact] + public void P05g_DedicatedMainlineGateRequiresReadyForReviewAndRealAuthorities() + { + var workflow = File.ReadAllText(FindRepoFile(".github/workflows/smart-discovery-mainline-readiness.yml")); + + Assert.Contains("Smart Discovery Mainline Readiness", workflow, StringComparison.Ordinal); + Assert.Contains("P0-5f physical-finalized authority is not tracked", workflow, StringComparison.Ordinal); + Assert.Contains("P0-5g production promotion authority is not tracked", workflow, StringComparison.Ordinal); + Assert.Contains("READY_FOR_REVIEW", workflow, StringComparison.Ordinal); + Assert.Contains("ProductionSwitchEnabled", workflow, StringComparison.Ordinal); + Assert.DoesNotContain("NoFailExit\n", workflow, StringComparison.Ordinal); + } + [Fact] public void P05g_ReadinessBindsAllPromotionProvenance() { From d688f745a7b1a59f8065dea600e415a1636cd994 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:26:20 +0700 Subject: [PATCH 102/243] P0-5g persist blocked mainline readiness evidence --- .../smart-discovery-mainline-readiness.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index a4caba7e4..79bdc79bb 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -45,16 +45,11 @@ jobs: $root = '.\ArIED61850Tester' $physical = "$root\evidence\smart-discovery-repeat-run.authority.json" $promotion = "$root\evidence\smart-discovery-production-promotion-authority.json" + $physicalArg = if (Test-Path $physical -PathType Leaf) { $physical } else { '' } + $promotionArg = if (Test-Path $promotion -PathType Leaf) { $promotion } else { '' } $output = "$root\TestResults\P0-5G-mainline-readiness.json" New-Item -ItemType Directory -Force "$root\TestResults" | Out-Null - if (-not (Test-Path $physical -PathType Leaf)) { - throw 'MAINLINE BLOCKED: P0-5f physical-finalized authority is not tracked.' - } - if (-not (Test-Path $promotion -PathType Leaf)) { - throw 'MAINLINE BLOCKED: P0-5g production promotion authority is not tracked.' - } - & "$root\scripts\verify-smart-discovery-production-readiness.ps1" ` -TargetPath "$root\evidence\smart-discovery-production-promotion-target.json" ` -EngineLockPath "$root\engines\ARIEC61850.lock.json" ` @@ -64,8 +59,8 @@ jobs: -ArsasHeadCommit $env:ARSAS_HEAD ` -EngineHeadCommit $env:ENGINE_HEAD ` -EngineHeadCiConclusion success ` - -PhysicalAuthorityPath $physical ` - -PromotionAuthorityPath $promotion ` + -PhysicalAuthorityPath $physicalArg ` + -PromotionAuthorityPath $promotionArg ` -OutputJson $output ` -NoFailExit @@ -90,5 +85,5 @@ jobs: with: name: ARSAS-p0-5g-mainline-readiness path: ArIED61850Tester\TestResults\P0-5G-mainline-readiness.json - if-no-files-found: warn + if-no-files-found: error retention-days: 14 From 65473aaabc2aebfa42540223d410730ac7daa1e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:26:42 +0700 Subject: [PATCH 103/243] P0-5g assert auditable blocked mainline evidence --- .../SmartDiscoveryProductionPromotionRegressionTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index d49aa2bba..9f75a51a3 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -69,11 +69,12 @@ public void P05g_DedicatedMainlineGateRequiresReadyForReviewAndRealAuthorities() var workflow = File.ReadAllText(FindRepoFile(".github/workflows/smart-discovery-mainline-readiness.yml")); Assert.Contains("Smart Discovery Mainline Readiness", workflow, StringComparison.Ordinal); - Assert.Contains("P0-5f physical-finalized authority is not tracked", workflow, StringComparison.Ordinal); - Assert.Contains("P0-5g production promotion authority is not tracked", workflow, StringComparison.Ordinal); + Assert.Contains("smart-discovery-repeat-run.authority.json", workflow, StringComparison.Ordinal); + Assert.Contains("smart-discovery-production-promotion-authority.json", workflow, StringComparison.Ordinal); Assert.Contains("READY_FOR_REVIEW", workflow, StringComparison.Ordinal); Assert.Contains("ProductionSwitchEnabled", workflow, StringComparison.Ordinal); - Assert.DoesNotContain("NoFailExit\n", workflow, StringComparison.Ordinal); + Assert.Contains("-NoFailExit", workflow, StringComparison.Ordinal); + Assert.Contains("if-no-files-found: error", workflow, StringComparison.Ordinal); } [Fact] From 38512341f3d2d29161b71219f277275d61bea6a4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:41:00 +0700 Subject: [PATCH 104/243] fix(discovery): compare readiness repository heads explicitly --- scripts/verify-smart-discovery-production-readiness.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1 index 5b9b9a29f..dba5d280d 100644 --- a/scripts/verify-smart-discovery-production-readiness.ps1 +++ b/scripts/verify-smart-discovery-production-readiness.ps1 @@ -147,8 +147,8 @@ if ([string]$target.EngineRepository -ne 'masarray/ARIEC61850' -or [int]$target. $baseline = ([string]$target.EvidenceEngineBaselineCommit).ToLowerInvariant() Assert-Commit $baseline 'Evidence engine baseline commit' -if (Get-GitHead $arsasRepo -ne $arsasHead) { $blockers.Add('ARSAS repository HEAD differs from the supplied readiness head.') } -if (Get-GitHead $engineRepo -ne $engineHead) { $blockers.Add('Engine repository HEAD differs from the supplied PR head.') } +if ((Get-GitHead $arsasRepo) -ne $arsasHead) { $blockers.Add('ARSAS repository HEAD differs from the supplied readiness head.') } +if ((Get-GitHead $engineRepo) -ne $engineHead) { $blockers.Add('Engine repository HEAD differs from the supplied PR head.') } if ([string]$engineLock.repository -ne [string]$target.EngineRepository) { $blockers.Add('ARSAS engine lock repository differs from the promotion target.') } if (([string]$engineLock.commit).ToLowerInvariant() -ne $baseline) { $blockers.Add('ARSAS engine lock no longer points at the physical-evidence engine baseline.') @@ -300,4 +300,4 @@ Write-Host " production switch: $productionSwitch" foreach ($blocker in $blockers) { Write-Host " BLOCKER: $blocker" } Write-Host " readiness JSON: $OutputJson" -if ($result.Verdict -eq 'BLOCKED' -and -not $NoFailExit) { exit 1 } \ No newline at end of file +if ($result.Verdict -eq 'BLOCKED' -and -not $NoFailExit) { exit 1 } From 5e0fe687381670f6396c54b4348e98b662df1417 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:41:26 +0700 Subject: [PATCH 105/243] fix(discovery): build fail-closed ARSAS against evidence engine pin --- .../workflows/smart-discovery-production-promotion.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml index bbfd1789d..f649aef4d 100644 --- a/.github/workflows/smart-discovery-production-promotion.yml +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -115,6 +115,16 @@ jobs: - name: Build and test default fail-closed ARSAS path shell: powershell run: | + # The current engine PR head is validated above, but ordinary pre-promotion + # ARSAS remains pinned to the physical-evidence engine baseline. Build the + # fail-closed route against that exact lock rather than an arbitrary newer head. + $lock = Get-Content '.\ArIED61850Tester\engines\ARIEC61850.lock.json' -Raw | ConvertFrom-Json + git -C .\ARIEC61850 checkout --quiet --detach $lock.commit + $actualEngine = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($actualEngine -ne ([string]$lock.commit).ToLowerInvariant()) { + throw "P0-5g fail-closed engine checkout mismatch: $actualEngine" + } + $native = '.\ArIED61850Tester\Services\NativeIec61850Client.cs' $before = Get-Content $native -Raw if ($before -match 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { From 76500b57097584adb93083925acbc529782876f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:54:59 +0700 Subject: [PATCH 106/243] test(discovery): define P0-5h mainline merge contract --- ...smart-discovery-mainline-merge-target.json | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 evidence/smart-discovery-mainline-merge-target.json diff --git a/evidence/smart-discovery-mainline-merge-target.json b/evidence/smart-discovery-mainline-merge-target.json new file mode 100644 index 000000000..9399d6471 --- /dev/null +++ b/evidence/smart-discovery-mainline-merge-target.json @@ -0,0 +1,37 @@ +{ + "SchemaVersion": 1, + "Phase": "P0-5h", + "Status": "blocked-until-p0-5g-ready-for-review", + "ArsasRepository": "masarray/arsas", + "ArsasPullRequest": 324, + "EngineRepository": "masarray/ARIEC61850", + "EnginePullRequest": 134, + "MergeOrder": [ + "engine", + "arsas" + ], + "PostMergeVerification": { + "RequireEngineHeadAncestorOfEngineMain": true, + "RequireArsasHeadAncestorOfArsasMain": true, + "RequireProductionPromotionAuthority": true, + "RequireProductionSwitchEnabled": true, + "RequireExactPromotionAuthorityHashBinding": true, + "RequireExactValidatedEngineHeadBinding": true, + "RequireEngineSourceHygiene": true, + "RequireEngineBuildAndTests": true, + "RequireArsasBuildAndTests": true, + "RequirePostMergeAttestationArtifact": true + }, + "ExecutionContract": { + "RequireP05gReadyForReview": true, + "RequireExactExpectedHeadShaOnMerge": true, + "RequireBaseShaUnchangedFromMergeManifest": true, + "RequireEngineMergeBeforeArsasMerge": true, + "RequireNoUnresolvedReviewThreads": true, + "RequireBothPullRequestsMergeable": true, + "ForbidAutoMergeBeforeAllPreconditions": true, + "ForbidFixtureOrForceBypass": true + }, + "MergeManifest": null, + "PostMergeAuthority": null +} From e73a716b0d16632896cb4aee24064d84da4b6169 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:55:34 +0700 Subject: [PATCH 107/243] test(discovery): add P0-5h merge execution manifest --- ...mart-discovery-mainline-merge-manifest.ps1 | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 scripts/new-smart-discovery-mainline-merge-manifest.ps1 diff --git a/scripts/new-smart-discovery-mainline-merge-manifest.ps1 b/scripts/new-smart-discovery-mainline-merge-manifest.ps1 new file mode 100644 index 000000000..bf61a4465 --- /dev/null +++ b/scripts/new-smart-discovery-mainline-merge-manifest.ps1 @@ -0,0 +1,149 @@ +param( + [Parameter(Mandatory=$true)][string]$ReadinessJson, + [Parameter(Mandatory=$true)][string]$PromotionAuthorityPath, + [Parameter(Mandatory=$true)][string]$PhysicalAuthorityPath, + [Parameter(Mandatory=$true)][string]$PromotionPropsPath, + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$ArsasHeadCommit, + [Parameter(Mandatory=$true)][string]$ArsasBaseCommit, + [Parameter(Mandatory=$true)][string]$EngineHeadCommit, + [Parameter(Mandatory=$true)][string]$EngineBaseCommit, + [Parameter(Mandatory=$true)][string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Assert-Sha256([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{64}$') { throw "$Label must be a 64-character SHA-256 value." } +} + +function Get-XmlChildText($Parent, [string]$Name) { + $node = @($Parent.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1) + if ($node.Count -eq 0) { return '' } + return ([string]$node[0].InnerText).Trim() +} + +$readinessFile = Resolve-File $ReadinessJson 'P0-5g readiness JSON' +$promotionFile = Resolve-File $PromotionAuthorityPath 'P0-5g promotion authority' +$physicalFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' +$propsFile = Resolve-File $PromotionPropsPath 'promotion props' +$targetFile = Resolve-File $TargetPath 'P0-5h merge target' + +foreach ($entry in @( + @{ Value = $ArsasHeadCommit; Label = 'ARSAS head commit' }, + @{ Value = $ArsasBaseCommit; Label = 'ARSAS base commit' }, + @{ Value = $EngineHeadCommit; Label = 'engine head commit' }, + @{ Value = $EngineBaseCommit; Label = 'engine base commit' })) { + Assert-Commit $entry.Value $entry.Label +} + +$arsasHead = $ArsasHeadCommit.ToLowerInvariant() +$arsasBase = $ArsasBaseCommit.ToLowerInvariant() +$engineHead = $EngineHeadCommit.ToLowerInvariant() +$engineBase = $EngineBaseCommit.ToLowerInvariant() +$readiness = Get-Content -LiteralPath $readinessFile -Raw | ConvertFrom-Json +$promotion = Get-Content -LiteralPath $promotionFile -Raw | ConvertFrom-Json +$physical = Get-Content -LiteralPath $physicalFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json + +if ($readiness.Phase -ne 'P0-5g' -or $readiness.Verdict -ne 'READY_FOR_REVIEW') { + throw 'P0-5h merge execution requires P0-5g READY_FOR_REVIEW.' +} +if (@($readiness.Blockers).Count -ne 0 -or -not [bool]$readiness.ProductionSwitchEnabled) { + throw 'P0-5h refuses readiness evidence with blockers or a disabled production switch.' +} +if ($promotion.Phase -ne 'P0-5g-authority' -or $promotion.Status -ne 'production-promoted') { + throw 'P0-5h requires a production-promoted P0-5g authority.' +} +if ($physical.Phase -ne 'P0-5f-authority' -or $physical.Status -ne 'physical-finalized') { + throw 'P0-5h requires physical-finalized P0-5f authority.' +} +if ($target.Phase -ne 'P0-5h' -or [int]$target.ArsasPullRequest -ne 324 -or [int]$target.EnginePullRequest -ne 134) { + throw 'P0-5h target repository/PR authority is invalid.' +} +if (([string]$readiness.ArsasHeadCommit).ToLowerInvariant() -ne $arsasHead -or + ([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $arsasHead) { + throw 'P0-5h ARSAS head differs from the P0-5g validated head.' +} +if (([string]$readiness.EngineHeadCommit).ToLowerInvariant() -ne $engineHead -or + ([string]$promotion.EngineHeadCommit).ToLowerInvariant() -ne $engineHead) { + throw 'P0-5h engine head differs from the P0-5g validated engine head.' +} +if ([string]$readiness.EngineHeadCiConclusion -ne 'success') { + throw 'P0-5h requires green engine-head CI evidence.' +} + +$physicalHash = (Get-FileHash -LiteralPath $physicalFile -Algorithm SHA256).Hash.ToLowerInvariant() +$promotionHash = (Get-FileHash -LiteralPath $promotionFile -Algorithm SHA256).Hash.ToLowerInvariant() +$readinessHash = (Get-FileHash -LiteralPath $readinessFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$propsHash = (Get-FileHash -LiteralPath $propsFile -Algorithm SHA256).Hash.ToLowerInvariant() +if (([string]$promotion.PhysicalAuthoritySha256).ToLowerInvariant() -ne $physicalHash) { + throw 'P0-5g promotion authority is bound to a different physical authority.' +} + +[xml]$props = Get-Content -LiteralPath $propsFile -Raw +$group = $props.Project.PropertyGroup +$promoted = (Get-XmlChildText $group 'SmartDiscoveryProductionPromoted').ToLowerInvariant() +$propsAuthority = (Get-XmlChildText $group 'SmartDiscoveryPromotionAuthoritySha256').ToLowerInvariant() +$propsEngineHead = (Get-XmlChildText $group 'SmartDiscoveryValidatedEngineHead').ToLowerInvariant() +if ($promoted -ne 'true') { throw 'P0-5h production switch is not enabled.' } +Assert-Sha256 $propsAuthority 'promotion props authority SHA-256' +if ($propsAuthority -ne $promotionHash) { throw 'Promotion props are bound to a different P0-5g authority.' } +if ($propsEngineHead -ne $engineHead) { throw 'Promotion props are bound to a different validated engine head.' } + +$manifest = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5h-merge-manifest' + Status = 'authorized-for-ordered-merge' + MergeOrder = @('engine','arsas') + Arsas = [ordered]@{ + Repository = 'masarray/arsas' + PullRequest = 324 + ExpectedHeadSha = $arsasHead + ExpectedBaseSha = $arsasBase + } + Engine = [ordered]@{ + Repository = 'masarray/ARIEC61850' + PullRequest = 134 + ExpectedHeadSha = $engineHead + ExpectedBaseSha = $engineBase + } + Provenance = [ordered]@{ + PhysicalAuthoritySha256 = $physicalHash + PromotionAuthoritySha256 = $promotionHash + P05gReadinessSha256 = $readinessHash + P05hTargetSha256 = $targetHash + PromotionPropsSha256 = $propsHash + } + RequiredExecutionChecks = @( + 'both-prs-open-and-mergeable', + 'no-unresolved-review-threads', + 'base-sha-unchanged', + 'expected-head-sha-match', + 'engine-merge-first', + 'engine-merge-success-before-arsas-merge', + 'post-merge-production-verification' + ) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +$manifestHash = (Get-FileHash -LiteralPath $OutputPath -Algorithm SHA256).Hash.ToLowerInvariant() +Write-Host 'P0-5h merge execution manifest: AUTHORIZED' +Write-Host " engine expected head: $engineHead" +Write-Host " ARSAS expected head: $arsasHead" +Write-Host " manifest SHA256: $manifestHash" +Write-Host " output: $OutputPath" From b85e7c901966ace545b0148b18a1b7bdcc90c92e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:56:11 +0700 Subject: [PATCH 108/243] test(discovery): lock P0-5h merge method --- evidence/smart-discovery-mainline-merge-target.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evidence/smart-discovery-mainline-merge-target.json b/evidence/smart-discovery-mainline-merge-target.json index 9399d6471..bb1f7cca1 100644 --- a/evidence/smart-discovery-mainline-merge-target.json +++ b/evidence/smart-discovery-mainline-merge-target.json @@ -6,6 +6,7 @@ "ArsasPullRequest": 324, "EngineRepository": "masarray/ARIEC61850", "EnginePullRequest": 134, + "MergeMethod": "merge", "MergeOrder": [ "engine", "arsas" @@ -27,6 +28,7 @@ "RequireExactExpectedHeadShaOnMerge": true, "RequireBaseShaUnchangedFromMergeManifest": true, "RequireEngineMergeBeforeArsasMerge": true, + "RequireMergeCommitMethod": true, "RequireNoUnresolvedReviewThreads": true, "RequireBothPullRequestsMergeable": true, "ForbidAutoMergeBeforeAllPreconditions": true, From f7c7332d8035057e948c50cb9bfc738387a8c359 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:56:34 +0700 Subject: [PATCH 109/243] test(discovery): add P0-5h post-merge production verifier --- ...-smart-discovery-post-merge-production.ps1 | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 scripts/verify-smart-discovery-post-merge-production.ps1 diff --git a/scripts/verify-smart-discovery-post-merge-production.ps1 b/scripts/verify-smart-discovery-post-merge-production.ps1 new file mode 100644 index 000000000..5f8767361 --- /dev/null +++ b/scripts/verify-smart-discovery-post-merge-production.ps1 @@ -0,0 +1,122 @@ +param( + [Parameter(Mandatory=$true)][string]$MergeManifestPath, + [Parameter(Mandatory=$true)][string]$PromotionAuthorityPath, + [Parameter(Mandatory=$true)][string]$PromotionPropsPath, + [Parameter(Mandatory=$true)][string]$ArsasRepositoryPath, + [Parameter(Mandatory=$true)][string]$EngineRepositoryPath, + [Parameter(Mandatory=$true)][string]$OutputJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Resolve-Directory([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { throw "$Label is not a directory: $Path" } + return $resolved.Path +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Test-GitAncestor([string]$RepositoryPath, [string]$Ancestor, [string]$Descendant) { + & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + +function Get-GitHead([string]$RepositoryPath) { + $value = (& git -C $RepositoryPath rev-parse HEAD 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $value) { throw "Could not resolve Git HEAD for '$RepositoryPath'." } + return ([string]$value).Trim().ToLowerInvariant() +} + +function Get-XmlChildText($Parent, [string]$Name) { + $node = @($Parent.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1) + if ($node.Count -eq 0) { return '' } + return ([string]$node[0].InnerText).Trim() +} + +$manifestFile = Resolve-File $MergeManifestPath 'P0-5h merge manifest' +$promotionFile = Resolve-File $PromotionAuthorityPath 'P0-5g promotion authority' +$propsFile = Resolve-File $PromotionPropsPath 'production promotion props' +$arsasRepo = Resolve-Directory $ArsasRepositoryPath 'ARSAS main checkout' +$engineRepo = Resolve-Directory $EngineRepositoryPath 'ARIEC61850 main checkout' + +$manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json +$promotion = Get-Content -LiteralPath $promotionFile -Raw | ConvertFrom-Json +if ($manifest.Phase -ne 'P0-5h-merge-manifest' -or $manifest.Status -ne 'authorized-for-ordered-merge') { + throw 'Post-merge verification requires an authorized P0-5h merge manifest.' +} +if ($promotion.Phase -ne 'P0-5g-authority' -or $promotion.Status -ne 'production-promoted') { + throw 'Post-merge verification requires production-promoted P0-5g authority.' +} +if ([string]$manifest.MergeMethod -and [string]$manifest.MergeMethod -ne 'merge') { + throw 'P0-5h requires merge-commit semantics.' +} + +$expectedArsasHead = ([string]$manifest.Arsas.ExpectedHeadSha).ToLowerInvariant() +$expectedEngineHead = ([string]$manifest.Engine.ExpectedHeadSha).ToLowerInvariant() +Assert-Commit $expectedArsasHead 'manifest ARSAS head' +Assert-Commit $expectedEngineHead 'manifest engine head' +$currentArsasMain = Get-GitHead $arsasRepo +$currentEngineMain = Get-GitHead $engineRepo + +$failures = [System.Collections.Generic.List[string]]::new() +if (-not (Test-GitAncestor $engineRepo $expectedEngineHead $currentEngineMain)) { + $failures.Add('Validated engine PR head is not an ancestor of engine main.') +} +if (-not (Test-GitAncestor $arsasRepo $expectedArsasHead $currentArsasMain)) { + $failures.Add('Validated ARSAS PR head is not an ancestor of ARSAS main.') +} +if (([string]$promotion.EngineHeadCommit).ToLowerInvariant() -ne $expectedEngineHead) { + $failures.Add('P0-5g promotion authority engine head differs from P0-5h merge manifest.') +} +if (([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $expectedArsasHead) { + $failures.Add('P0-5g promotion authority ARSAS head differs from P0-5h merge manifest.') +} + +$promotionHash = (Get-FileHash -LiteralPath $promotionFile -Algorithm SHA256).Hash.ToLowerInvariant() +$manifestHash = (Get-FileHash -LiteralPath $manifestFile -Algorithm SHA256).Hash.ToLowerInvariant() +[xml]$props = Get-Content -LiteralPath $propsFile -Raw +$group = $props.Project.PropertyGroup +$promoted = (Get-XmlChildText $group 'SmartDiscoveryProductionPromoted').ToLowerInvariant() +$propsAuthority = (Get-XmlChildText $group 'SmartDiscoveryPromotionAuthoritySha256').ToLowerInvariant() +$propsEngine = (Get-XmlChildText $group 'SmartDiscoveryValidatedEngineHead').ToLowerInvariant() +if ($promoted -ne 'true') { $failures.Add('Production smart-discovery switch is not enabled on main.') } +if ($propsAuthority -ne $promotionHash) { $failures.Add('Production props are not bound to the merged P0-5g promotion authority.') } +if ($propsEngine -ne $expectedEngineHead) { $failures.Add('Production props validated engine head differs from merge manifest.') } + +$result = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5h-post-merge' + Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } + MergeManifestSha256 = $manifestHash + PromotionAuthoritySha256 = $promotionHash + ExpectedArsasHead = $expectedArsasHead + CurrentArsasMainHead = $currentArsasMain + ExpectedEngineHead = $expectedEngineHead + CurrentEngineMainHead = $currentEngineMain + EngineHeadIsAncestorOfMain = Test-GitAncestor $engineRepo $expectedEngineHead $currentEngineMain + ArsasHeadIsAncestorOfMain = Test-GitAncestor $arsasRepo $expectedArsasHead $currentArsasMain + ProductionSwitchEnabled = $promoted -eq 'true' + AcceptanceFailures = @($failures) +} + +$outputDirectory = Split-Path -Parent $OutputJson +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 +Write-Host "P0-5h post-merge production verification: $($result.Verdict)" +Write-Host " engine main: $currentEngineMain" +Write-Host " ARSAS main: $currentArsasMain" +Write-Host " attestation: $OutputJson" +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + exit 1 +} From 8dd33c16ca4a6fc743a95449a777aec1f3902e87 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:57:04 +0700 Subject: [PATCH 110/243] test(discovery): bind P0-5h manifest to merge commit method --- scripts/new-smart-discovery-mainline-merge-manifest.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/new-smart-discovery-mainline-merge-manifest.ps1 b/scripts/new-smart-discovery-mainline-merge-manifest.ps1 index bf61a4465..a7b16b283 100644 --- a/scripts/new-smart-discovery-mainline-merge-manifest.ps1 +++ b/scripts/new-smart-discovery-mainline-merge-manifest.ps1 @@ -72,6 +72,9 @@ if ($physical.Phase -ne 'P0-5f-authority' -or $physical.Status -ne 'physical-fin if ($target.Phase -ne 'P0-5h' -or [int]$target.ArsasPullRequest -ne 324 -or [int]$target.EnginePullRequest -ne 134) { throw 'P0-5h target repository/PR authority is invalid.' } +if ([string]$target.MergeMethod -ne 'merge' -or @($target.MergeOrder) -join ',' -ne 'engine,arsas') { + throw 'P0-5h target must require merge-commit method and engine-first order.' +} if (([string]$readiness.ArsasHeadCommit).ToLowerInvariant() -ne $arsasHead -or ([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $arsasHead) { throw 'P0-5h ARSAS head differs from the P0-5g validated head.' @@ -107,6 +110,7 @@ $manifest = [ordered]@{ SchemaVersion = 1 Phase = 'P0-5h-merge-manifest' Status = 'authorized-for-ordered-merge' + MergeMethod = 'merge' MergeOrder = @('engine','arsas') Arsas = [ordered]@{ Repository = 'masarray/arsas' @@ -132,6 +136,7 @@ $manifest = [ordered]@{ 'no-unresolved-review-threads', 'base-sha-unchanged', 'expected-head-sha-match', + 'merge-method=merge', 'engine-merge-first', 'engine-merge-success-before-arsas-merge', 'post-merge-production-verification' @@ -143,6 +148,7 @@ if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | O $manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 $manifestHash = (Get-FileHash -LiteralPath $OutputPath -Algorithm SHA256).Hash.ToLowerInvariant() Write-Host 'P0-5h merge execution manifest: AUTHORIZED' +Write-Host " merge method: merge" Write-Host " engine expected head: $engineHead" Write-Host " ARSAS expected head: $arsasHead" Write-Host " manifest SHA256: $manifestHash" From bb6fb3a103e4f9a748e15f88a2c3150d5d984202 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:57:23 +0700 Subject: [PATCH 111/243] test(discovery): allow tracked P0-5h merge manifest after physical authority --- evidence/smart-discovery-production-promotion-target.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evidence/smart-discovery-production-promotion-target.json b/evidence/smart-discovery-production-promotion-target.json index c76a55fab..d2459dfc5 100644 --- a/evidence/smart-discovery-production-promotion-target.json +++ b/evidence/smart-discovery-production-promotion-target.json @@ -26,7 +26,8 @@ "evidence/smart-discovery-repeat-run.authority.json", "evidence/smart-discovery-production-promotion-authority.json", "evidence/SmartDiscoveryPromotion.props", - "evidence/smart-discovery-production-promotion-target.json" + "evidence/smart-discovery-production-promotion-target.json", + "evidence/smart-discovery-mainline-merge-manifest.json" ], "ProductionPromotionContract": { "RequireP05fPhysicalAuthority": true, From 48b0088f17e3b96df3bb93beee218ba5a167ac8a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:57:38 +0700 Subject: [PATCH 112/243] test(discovery): add P0-5h merge execution regressions --- ...ryMainlineMergeExecutionRegressionTests.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs diff --git a/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs new file mode 100644 index 000000000..aafa27fb1 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryMainlineMergeExecutionRegressionTests +{ + [Fact] + public void P05h_TargetLocksOrderedMergeCommitExecution() + { + using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-mainline-merge-target.json"))); + var root = document.RootElement; + + Assert.Equal("P0-5h", root.GetProperty("Phase").GetString()); + Assert.Equal("merge", root.GetProperty("MergeMethod").GetString()); + Assert.Equal(new[] { "engine", "arsas" }, root.GetProperty("MergeOrder").EnumerateArray().Select(x => x.GetString()).ToArray()); + + var contract = root.GetProperty("ExecutionContract"); + Assert.True(contract.GetProperty("RequireP05gReadyForReview").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactExpectedHeadShaOnMerge").GetBoolean()); + Assert.True(contract.GetProperty("RequireBaseShaUnchangedFromMergeManifest").GetBoolean()); + Assert.True(contract.GetProperty("RequireEngineMergeBeforeArsasMerge").GetBoolean()); + Assert.True(contract.GetProperty("RequireMergeCommitMethod").GetBoolean()); + Assert.True(contract.GetProperty("ForbidFixtureOrForceBypass").GetBoolean()); + } + + [Fact] + public void P05h_ManifestWriterHasNoBypassAndRequiresProductionAuthorities() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-mainline-merge-manifest.ps1")); + + Assert.Contains("READY_FOR_REVIEW", source, StringComparison.Ordinal); + Assert.Contains("physical-finalized", source, StringComparison.Ordinal); + Assert.Contains("production-promoted", source, StringComparison.Ordinal); + Assert.Contains("ExpectedHeadSha", source, StringComparison.Ordinal); + Assert.Contains("ExpectedBaseSha", source, StringComparison.Ordinal); + Assert.Contains("MergeMethod = 'merge'", source, StringComparison.Ordinal); + Assert.Contains("engine-merge-first", source, StringComparison.Ordinal); + Assert.DoesNotContain("AllowFixtureEvidence", source, StringComparison.Ordinal); + Assert.DoesNotContain("Force", source, StringComparison.Ordinal); + } + + [Fact] + public void P05h_PostMergeVerifierRequiresValidatedHeadsOnMainAndAuthorityBinding() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-post-merge-production.ps1")); + + Assert.Contains("merge-base --is-ancestor", source, StringComparison.Ordinal); + Assert.Contains("Validated engine PR head is not an ancestor of engine main", source, StringComparison.Ordinal); + Assert.Contains("Validated ARSAS PR head is not an ancestor of ARSAS main", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryProductionPromoted", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + Assert.Contains("P0-5h-post-merge", source, StringComparison.Ordinal); + } + + [Fact] + public void P05g_PostPhysicalAllowlistPermitsOnlyTheP05hManifestAddition() + { + using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-production-promotion-target.json"))); + var paths = document.RootElement.GetProperty("AllowedPostPhysicalAuthorityPaths") + .EnumerateArray().Select(x => x.GetString()).ToArray(); + + Assert.Contains("evidence/smart-discovery-mainline-merge-manifest.json", paths); + Assert.DoesNotContain(paths, path => path is not null && path.StartsWith("Services/", StringComparison.Ordinal)); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} From fbdc16e39e6f9891f16ab3962e8feb88d1b81889 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:58:14 +0700 Subject: [PATCH 113/243] test(discovery): close P0-5h self-reference gap --- evidence/smart-discovery-mainline-merge-target.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evidence/smart-discovery-mainline-merge-target.json b/evidence/smart-discovery-mainline-merge-target.json index bb1f7cca1..89c363233 100644 --- a/evidence/smart-discovery-mainline-merge-target.json +++ b/evidence/smart-discovery-mainline-merge-target.json @@ -13,7 +13,8 @@ ], "PostMergeVerification": { "RequireEngineHeadAncestorOfEngineMain": true, - "RequireArsasHeadAncestorOfArsasMain": true, + "RequireArsasValidatedHeadAncestorOfArsasMain": true, + "RequireTrackedMergeManifestOnArsasMain": true, "RequireProductionPromotionAuthority": true, "RequireProductionSwitchEnabled": true, "RequireExactPromotionAuthorityHashBinding": true, @@ -29,6 +30,7 @@ "RequireBaseShaUnchangedFromMergeManifest": true, "RequireEngineMergeBeforeArsasMerge": true, "RequireMergeCommitMethod": true, + "RequireOnlyMergeManifestChangeAfterValidatedArsasHead": true, "RequireNoUnresolvedReviewThreads": true, "RequireBothPullRequestsMergeable": true, "ForbidAutoMergeBeforeAllPreconditions": true, From 0678228a929d8a531fd1ab8400ec82ffebfa8d1c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:58:43 +0700 Subject: [PATCH 114/243] test(discovery): separate validated and live ARSAS merge heads --- ...mart-discovery-mainline-merge-manifest.ps1 | 73 +++++++------------ 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/scripts/new-smart-discovery-mainline-merge-manifest.ps1 b/scripts/new-smart-discovery-mainline-merge-manifest.ps1 index a7b16b283..7091b021d 100644 --- a/scripts/new-smart-discovery-mainline-merge-manifest.ps1 +++ b/scripts/new-smart-discovery-mainline-merge-manifest.ps1 @@ -4,7 +4,7 @@ param( [Parameter(Mandatory=$true)][string]$PhysicalAuthorityPath, [Parameter(Mandatory=$true)][string]$PromotionPropsPath, [Parameter(Mandatory=$true)][string]$TargetPath, - [Parameter(Mandatory=$true)][string]$ArsasHeadCommit, + [Parameter(Mandatory=$true)][string]$ArsasValidatedHeadCommit, [Parameter(Mandatory=$true)][string]$ArsasBaseCommit, [Parameter(Mandatory=$true)][string]$EngineHeadCommit, [Parameter(Mandatory=$true)][string]$EngineBaseCommit, @@ -19,15 +19,12 @@ function Resolve-File([string]$Path, [string]$Label) { if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } return $resolved.Path } - function Assert-Commit([string]$Value, [string]$Label) { if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } } - function Assert-Sha256([string]$Value, [string]$Label) { if ($Value -notmatch '^[0-9a-fA-F]{64}$') { throw "$Label must be a 64-character SHA-256 value." } } - function Get-XmlChildText($Parent, [string]$Name) { $node = @($Parent.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1) if ($node.Count -eq 0) { return '' } @@ -39,16 +36,13 @@ $promotionFile = Resolve-File $PromotionAuthorityPath 'P0-5g promotion authority $physicalFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' $propsFile = Resolve-File $PromotionPropsPath 'promotion props' $targetFile = Resolve-File $TargetPath 'P0-5h merge target' - foreach ($entry in @( - @{ Value = $ArsasHeadCommit; Label = 'ARSAS head commit' }, + @{ Value = $ArsasValidatedHeadCommit; Label = 'ARSAS validated head commit' }, @{ Value = $ArsasBaseCommit; Label = 'ARSAS base commit' }, @{ Value = $EngineHeadCommit; Label = 'engine head commit' }, - @{ Value = $EngineBaseCommit; Label = 'engine base commit' })) { - Assert-Commit $entry.Value $entry.Label -} + @{ Value = $EngineBaseCommit; Label = 'engine base commit' })) { Assert-Commit $entry.Value $entry.Label } -$arsasHead = $ArsasHeadCommit.ToLowerInvariant() +$arsasValidatedHead = $ArsasValidatedHeadCommit.ToLowerInvariant() $arsasBase = $ArsasBaseCommit.ToLowerInvariant() $engineHead = $EngineHeadCommit.ToLowerInvariant() $engineBase = $EngineBaseCommit.ToLowerInvariant() @@ -57,44 +51,22 @@ $promotion = Get-Content -LiteralPath $promotionFile -Raw | ConvertFrom-Json $physical = Get-Content -LiteralPath $physicalFile -Raw | ConvertFrom-Json $target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json -if ($readiness.Phase -ne 'P0-5g' -or $readiness.Verdict -ne 'READY_FOR_REVIEW') { - throw 'P0-5h merge execution requires P0-5g READY_FOR_REVIEW.' -} -if (@($readiness.Blockers).Count -ne 0 -or -not [bool]$readiness.ProductionSwitchEnabled) { - throw 'P0-5h refuses readiness evidence with blockers or a disabled production switch.' -} -if ($promotion.Phase -ne 'P0-5g-authority' -or $promotion.Status -ne 'production-promoted') { - throw 'P0-5h requires a production-promoted P0-5g authority.' -} -if ($physical.Phase -ne 'P0-5f-authority' -or $physical.Status -ne 'physical-finalized') { - throw 'P0-5h requires physical-finalized P0-5f authority.' -} -if ($target.Phase -ne 'P0-5h' -or [int]$target.ArsasPullRequest -ne 324 -or [int]$target.EnginePullRequest -ne 134) { - throw 'P0-5h target repository/PR authority is invalid.' -} -if ([string]$target.MergeMethod -ne 'merge' -or @($target.MergeOrder) -join ',' -ne 'engine,arsas') { - throw 'P0-5h target must require merge-commit method and engine-first order.' -} -if (([string]$readiness.ArsasHeadCommit).ToLowerInvariant() -ne $arsasHead -or - ([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $arsasHead) { - throw 'P0-5h ARSAS head differs from the P0-5g validated head.' -} -if (([string]$readiness.EngineHeadCommit).ToLowerInvariant() -ne $engineHead -or - ([string]$promotion.EngineHeadCommit).ToLowerInvariant() -ne $engineHead) { - throw 'P0-5h engine head differs from the P0-5g validated engine head.' -} -if ([string]$readiness.EngineHeadCiConclusion -ne 'success') { - throw 'P0-5h requires green engine-head CI evidence.' -} +if ($readiness.Phase -ne 'P0-5g' -or $readiness.Verdict -ne 'READY_FOR_REVIEW') { throw 'P0-5h merge execution requires P0-5g READY_FOR_REVIEW.' } +if (@($readiness.Blockers).Count -ne 0 -or -not [bool]$readiness.ProductionSwitchEnabled) { throw 'P0-5h refuses readiness evidence with blockers or a disabled production switch.' } +if ($promotion.Phase -ne 'P0-5g-authority' -or $promotion.Status -ne 'production-promoted') { throw 'P0-5h requires a production-promoted P0-5g authority.' } +if ($physical.Phase -ne 'P0-5f-authority' -or $physical.Status -ne 'physical-finalized') { throw 'P0-5h requires physical-finalized P0-5f authority.' } +if ($target.Phase -ne 'P0-5h' -or [int]$target.ArsasPullRequest -ne 324 -or [int]$target.EnginePullRequest -ne 134) { throw 'P0-5h target repository/PR authority is invalid.' } +if ([string]$target.MergeMethod -ne 'merge' -or @($target.MergeOrder) -join ',' -ne 'engine,arsas') { throw 'P0-5h target must require merge-commit method and engine-first order.' } +if (([string]$readiness.ArsasHeadCommit).ToLowerInvariant() -ne $arsasValidatedHead -or ([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $arsasValidatedHead) { throw 'P0-5h ARSAS validated head differs from P0-5g authority.' } +if (([string]$readiness.EngineHeadCommit).ToLowerInvariant() -ne $engineHead -or ([string]$promotion.EngineHeadCommit).ToLowerInvariant() -ne $engineHead) { throw 'P0-5h engine head differs from P0-5g authority.' } +if ([string]$readiness.EngineHeadCiConclusion -ne 'success') { throw 'P0-5h requires green engine-head CI evidence.' } $physicalHash = (Get-FileHash -LiteralPath $physicalFile -Algorithm SHA256).Hash.ToLowerInvariant() $promotionHash = (Get-FileHash -LiteralPath $promotionFile -Algorithm SHA256).Hash.ToLowerInvariant() $readinessHash = (Get-FileHash -LiteralPath $readinessFile -Algorithm SHA256).Hash.ToLowerInvariant() $targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() $propsHash = (Get-FileHash -LiteralPath $propsFile -Algorithm SHA256).Hash.ToLowerInvariant() -if (([string]$promotion.PhysicalAuthoritySha256).ToLowerInvariant() -ne $physicalHash) { - throw 'P0-5g promotion authority is bound to a different physical authority.' -} +if (([string]$promotion.PhysicalAuthoritySha256).ToLowerInvariant() -ne $physicalHash) { throw 'P0-5g promotion authority is bound to a different physical authority.' } [xml]$props = Get-Content -LiteralPath $propsFile -Raw $group = $props.Project.PropertyGroup @@ -115,14 +87,16 @@ $manifest = [ordered]@{ Arsas = [ordered]@{ Repository = 'masarray/arsas' PullRequest = 324 - ExpectedHeadSha = $arsasHead - ExpectedBaseSha = $arsasBase + ValidatedHeadSha = $arsasValidatedHead + BaseShaAtAuthorization = $arsasBase + LiveMergeHeadSha = $null + AllowedPostAuthorizationPaths = @('evidence/smart-discovery-mainline-merge-manifest.json') } Engine = [ordered]@{ Repository = 'masarray/ARIEC61850' PullRequest = 134 ExpectedHeadSha = $engineHead - ExpectedBaseSha = $engineBase + BaseShaAtAuthorization = $engineBase } Provenance = [ordered]@{ PhysicalAuthoritySha256 = $physicalHash @@ -135,7 +109,9 @@ $manifest = [ordered]@{ 'both-prs-open-and-mergeable', 'no-unresolved-review-threads', 'base-sha-unchanged', - 'expected-head-sha-match', + 'engine-expected-head-sha-match', + 'arsas-live-head-resolved-at-execution', + 'arsas-post-authorization-diff=merge-manifest-only', 'merge-method=merge', 'engine-merge-first', 'engine-merge-success-before-arsas-merge', @@ -148,8 +124,9 @@ if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | O $manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 $manifestHash = (Get-FileHash -LiteralPath $OutputPath -Algorithm SHA256).Hash.ToLowerInvariant() Write-Host 'P0-5h merge execution manifest: AUTHORIZED' -Write-Host " merge method: merge" +Write-Host ' merge method: merge' Write-Host " engine expected head: $engineHead" -Write-Host " ARSAS expected head: $arsasHead" +Write-Host " ARSAS validated head: $arsasValidatedHead" +Write-Host ' ARSAS live merge head: resolve immediately before merge after manifest-only diff check' Write-Host " manifest SHA256: $manifestHash" Write-Host " output: $OutputPath" From f3e799cbd75fd7e91fa8c9336e7d7619a71c2633 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:59:06 +0700 Subject: [PATCH 115/243] test(discovery): verify validated ARSAS ancestry after merge --- ...-smart-discovery-post-merge-production.ps1 | 49 ++++++------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/scripts/verify-smart-discovery-post-merge-production.ps1 b/scripts/verify-smart-discovery-post-merge-production.ps1 index 5f8767361..b757cae26 100644 --- a/scripts/verify-smart-discovery-post-merge-production.ps1 +++ b/scripts/verify-smart-discovery-post-merge-production.ps1 @@ -15,28 +15,23 @@ function Resolve-File([string]$Path, [string]$Label) { if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } return $resolved.Path } - function Resolve-Directory([string]$Path, [string]$Label) { $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop if (-not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { throw "$Label is not a directory: $Path" } return $resolved.Path } - function Assert-Commit([string]$Value, [string]$Label) { if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } } - function Test-GitAncestor([string]$RepositoryPath, [string]$Ancestor, [string]$Descendant) { & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null return $LASTEXITCODE -eq 0 } - function Get-GitHead([string]$RepositoryPath) { $value = (& git -C $RepositoryPath rev-parse HEAD 2>$null) if ($LASTEXITCODE -ne 0 -or -not $value) { throw "Could not resolve Git HEAD for '$RepositoryPath'." } return ([string]$value).Trim().ToLowerInvariant() } - function Get-XmlChildText($Parent, [string]$Name) { $node = @($Parent.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1) if ($node.Count -eq 0) { return '' } @@ -48,39 +43,27 @@ $promotionFile = Resolve-File $PromotionAuthorityPath 'P0-5g promotion authority $propsFile = Resolve-File $PromotionPropsPath 'production promotion props' $arsasRepo = Resolve-Directory $ArsasRepositoryPath 'ARSAS main checkout' $engineRepo = Resolve-Directory $EngineRepositoryPath 'ARIEC61850 main checkout' - $manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json $promotion = Get-Content -LiteralPath $promotionFile -Raw | ConvertFrom-Json -if ($manifest.Phase -ne 'P0-5h-merge-manifest' -or $manifest.Status -ne 'authorized-for-ordered-merge') { - throw 'Post-merge verification requires an authorized P0-5h merge manifest.' -} -if ($promotion.Phase -ne 'P0-5g-authority' -or $promotion.Status -ne 'production-promoted') { - throw 'Post-merge verification requires production-promoted P0-5g authority.' -} -if ([string]$manifest.MergeMethod -and [string]$manifest.MergeMethod -ne 'merge') { - throw 'P0-5h requires merge-commit semantics.' -} -$expectedArsasHead = ([string]$manifest.Arsas.ExpectedHeadSha).ToLowerInvariant() +if ($manifest.Phase -ne 'P0-5h-merge-manifest' -or $manifest.Status -ne 'authorized-for-ordered-merge') { throw 'Post-merge verification requires an authorized P0-5h merge manifest.' } +if ($promotion.Phase -ne 'P0-5g-authority' -or $promotion.Status -ne 'production-promoted') { throw 'Post-merge verification requires production-promoted P0-5g authority.' } +if ([string]$manifest.MergeMethod -ne 'merge' -or @($manifest.MergeOrder) -join ',' -ne 'engine,arsas') { throw 'P0-5h requires merge-commit semantics and engine-first order.' } + +$validatedArsasHead = ([string]$manifest.Arsas.ValidatedHeadSha).ToLowerInvariant() $expectedEngineHead = ([string]$manifest.Engine.ExpectedHeadSha).ToLowerInvariant() -Assert-Commit $expectedArsasHead 'manifest ARSAS head' +Assert-Commit $validatedArsasHead 'manifest validated ARSAS head' Assert-Commit $expectedEngineHead 'manifest engine head' $currentArsasMain = Get-GitHead $arsasRepo $currentEngineMain = Get-GitHead $engineRepo - $failures = [System.Collections.Generic.List[string]]::new() -if (-not (Test-GitAncestor $engineRepo $expectedEngineHead $currentEngineMain)) { - $failures.Add('Validated engine PR head is not an ancestor of engine main.') -} -if (-not (Test-GitAncestor $arsasRepo $expectedArsasHead $currentArsasMain)) { - $failures.Add('Validated ARSAS PR head is not an ancestor of ARSAS main.') -} -if (([string]$promotion.EngineHeadCommit).ToLowerInvariant() -ne $expectedEngineHead) { - $failures.Add('P0-5g promotion authority engine head differs from P0-5h merge manifest.') -} -if (([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $expectedArsasHead) { - $failures.Add('P0-5g promotion authority ARSAS head differs from P0-5h merge manifest.') -} + +$engineAncestor = Test-GitAncestor $engineRepo $expectedEngineHead $currentEngineMain +$arsasAncestor = Test-GitAncestor $arsasRepo $validatedArsasHead $currentArsasMain +if (-not $engineAncestor) { $failures.Add('Validated engine PR head is not an ancestor of engine main.') } +if (-not $arsasAncestor) { $failures.Add('Validated ARSAS PR head is not an ancestor of ARSAS main.') } +if (([string]$promotion.EngineHeadCommit).ToLowerInvariant() -ne $expectedEngineHead) { $failures.Add('P0-5g promotion authority engine head differs from P0-5h merge manifest.') } +if (([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $validatedArsasHead) { $failures.Add('P0-5g promotion authority ARSAS head differs from P0-5h merge manifest.') } $promotionHash = (Get-FileHash -LiteralPath $promotionFile -Algorithm SHA256).Hash.ToLowerInvariant() $manifestHash = (Get-FileHash -LiteralPath $manifestFile -Algorithm SHA256).Hash.ToLowerInvariant() @@ -99,12 +82,12 @@ $result = [ordered]@{ Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } MergeManifestSha256 = $manifestHash PromotionAuthoritySha256 = $promotionHash - ExpectedArsasHead = $expectedArsasHead + ValidatedArsasHead = $validatedArsasHead CurrentArsasMainHead = $currentArsasMain ExpectedEngineHead = $expectedEngineHead CurrentEngineMainHead = $currentEngineMain - EngineHeadIsAncestorOfMain = Test-GitAncestor $engineRepo $expectedEngineHead $currentEngineMain - ArsasHeadIsAncestorOfMain = Test-GitAncestor $arsasRepo $expectedArsasHead $currentArsasMain + EngineHeadIsAncestorOfMain = $engineAncestor + ArsasValidatedHeadIsAncestorOfMain = $arsasAncestor ProductionSwitchEnabled = $promoted -eq 'true' AcceptanceFailures = @($failures) } From fd40feeae87b555f5b9f0e4317ed890703a4adfa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:59:22 +0700 Subject: [PATCH 116/243] test(discovery): cover P0-5h live merge head resolution --- ...martDiscoveryMainlineMergeExecutionRegressionTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs index aafa27fb1..002d42aeb 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs @@ -20,6 +20,7 @@ public void P05h_TargetLocksOrderedMergeCommitExecution() Assert.True(contract.GetProperty("RequireBaseShaUnchangedFromMergeManifest").GetBoolean()); Assert.True(contract.GetProperty("RequireEngineMergeBeforeArsasMerge").GetBoolean()); Assert.True(contract.GetProperty("RequireMergeCommitMethod").GetBoolean()); + Assert.True(contract.GetProperty("RequireOnlyMergeManifestChangeAfterValidatedArsasHead").GetBoolean()); Assert.True(contract.GetProperty("ForbidFixtureOrForceBypass").GetBoolean()); } @@ -31,12 +32,14 @@ public void P05h_ManifestWriterHasNoBypassAndRequiresProductionAuthorities() Assert.Contains("READY_FOR_REVIEW", source, StringComparison.Ordinal); Assert.Contains("physical-finalized", source, StringComparison.Ordinal); Assert.Contains("production-promoted", source, StringComparison.Ordinal); - Assert.Contains("ExpectedHeadSha", source, StringComparison.Ordinal); - Assert.Contains("ExpectedBaseSha", source, StringComparison.Ordinal); + Assert.Contains("ValidatedHeadSha", source, StringComparison.Ordinal); + Assert.Contains("BaseShaAtAuthorization", source, StringComparison.Ordinal); + Assert.Contains("LiveMergeHeadSha = $null", source, StringComparison.Ordinal); + Assert.Contains("AllowedPostAuthorizationPaths", source, StringComparison.Ordinal); Assert.Contains("MergeMethod = 'merge'", source, StringComparison.Ordinal); + Assert.Contains("arsas-live-head-resolved-at-execution", source, StringComparison.Ordinal); Assert.Contains("engine-merge-first", source, StringComparison.Ordinal); Assert.DoesNotContain("AllowFixtureEvidence", source, StringComparison.Ordinal); - Assert.DoesNotContain("Force", source, StringComparison.Ordinal); } [Fact] From 405b653ac6289938c7fa3bea1018ddeed0fe3bfc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 15:59:43 +0700 Subject: [PATCH 117/243] test(discovery): add P0-5h merge execution guard --- .../smart-discovery-merge-execution-guard.yml | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/smart-discovery-merge-execution-guard.yml diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml new file mode 100644 index 000000000..47edbb974 --- /dev/null +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -0,0 +1,64 @@ +name: Smart Discovery Merge Execution Guard + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-merge-execution-contract: + name: Verify P0-5h ordered merge contract + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate P0-5h source contract + shell: powershell + run: | + $target = '.\evidence\smart-discovery-mainline-merge-target.json' + $writer = '.\scripts\new-smart-discovery-mainline-merge-manifest.ps1' + $postMerge = '.\scripts\verify-smart-discovery-post-merge-production.ps1' + $test = '.\tests\ARSAS.Tests\SmartDiscoveryMainlineMergeExecutionRegressionTests.cs' + $doc = '.\docs\P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md' + foreach ($path in @($target,$writer,$postMerge,$test,$doc)) { + if (-not (Test-Path $path -PathType Leaf)) { throw "P0-5h source missing: $path" } + } + foreach ($script in @($writer,$postMerge)) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script,[ref]$tokens,[ref]$errors) | Out-Null + if ($errors.Count -ne 0) { + $messages = @($errors | ForEach-Object { $_.Message }) -join '; ' + throw "PowerShell parse failure in ${script}: $messages" + } + } + $json = Get-Content $target -Raw | ConvertFrom-Json + if ($json.Phase -ne 'P0-5h' -or $json.MergeMethod -ne 'merge') { throw 'P0-5h target does not lock merge-commit execution.' } + if ((@($json.MergeOrder) -join ',') -ne 'engine,arsas') { throw 'P0-5h target does not require engine-first order.' } + if (-not [bool]$json.ExecutionContract.RequireOnlyMergeManifestChangeAfterValidatedArsasHead) { + throw 'P0-5h target does not close the tracked-manifest self-reference boundary.' + } + + $manifest = '.\evidence\smart-discovery-mainline-merge-manifest.json' + if (Test-Path $manifest -PathType Leaf) { + $m = Get-Content $manifest -Raw | ConvertFrom-Json + if ($m.Phase -ne 'P0-5h-merge-manifest' -or $m.Status -ne 'authorized-for-ordered-merge' -or $m.MergeMethod -ne 'merge') { + throw 'Tracked P0-5h merge manifest is invalid.' + } + if ((@($m.MergeOrder) -join ',') -ne 'engine,arsas') { throw 'Tracked merge manifest changed merge order.' } + if ([string]$m.Arsas.LiveMergeHeadSha) { throw 'Tracked manifest must not self-bind its own future commit SHA.' } + } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Run ARSAS regression tests + shell: powershell + run: | + dotnet restore .\ArIED61850Tester.sln + dotnet build .\ArIED61850Tester.sln -c Release --no-restore + dotnet test .\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore From f9fd7f9c09af162f80af6c21a43b22d553225e79 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 16:00:00 +0700 Subject: [PATCH 118/243] test(discovery): add P0-5h post-merge production verification --- .../smart-discovery-post-merge-production.yml | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/smart-discovery-post-merge-production.yml diff --git a/.github/workflows/smart-discovery-post-merge-production.yml b/.github/workflows/smart-discovery-post-merge-production.yml new file mode 100644 index 000000000..222603332 --- /dev/null +++ b/.github/workflows/smart-discovery-post-merge-production.yml @@ -0,0 +1,77 @@ +name: Smart Discovery Post-Merge Production Verification + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + post-merge-production: + name: Verify P0-5h production state on main + runs-on: windows-latest + steps: + - name: Checkout ARSAS main with history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Require tracked P0-5h production authorities + shell: powershell + run: | + foreach ($path in @( + '.\evidence\smart-discovery-mainline-merge-manifest.json', + '.\evidence\smart-discovery-production-promotion-authority.json', + '.\evidence\SmartDiscoveryPromotion.props', + '.\scripts\verify-smart-discovery-post-merge-production.ps1')) { + if (-not (Test-Path $path -PathType Leaf)) { throw "P0-5h post-merge authority missing on main: $path" } + } + + - name: Checkout ARIEC61850 main with history + shell: powershell + run: | + git clone --quiet https://github.com/masarray/ARIEC61850.git ARIEC61850 + $engineMain = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + "ENGINE_MAIN=$engineMain" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5h engine main: $engineMain" + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Validate and test engine main + shell: powershell + run: | + .\ARIEC61850\scripts\verify-source-clean.ps1 + dotnet restore .\ARIEC61850\ARIEC61850.sln + dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore + dotnet test .\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --no-build --no-restore + + - name: Validate post-merge ancestry and promotion binding + shell: powershell + run: | + New-Item -ItemType Directory -Force .\TestResults | Out-Null + .\scripts\verify-smart-discovery-post-merge-production.ps1 ` + -MergeManifestPath .\evidence\smart-discovery-mainline-merge-manifest.json ` + -PromotionAuthorityPath .\evidence\smart-discovery-production-promotion-authority.json ` + -PromotionPropsPath .\evidence\SmartDiscoveryPromotion.props ` + -ArsasRepositoryPath . ` + -EngineRepositoryPath .\ARIEC61850 ` + -OutputJson .\TestResults\P0-5H-post-merge-production.json + + - name: Build and test promoted ARSAS main + shell: powershell + run: | + dotnet restore .\ArIED61850Tester.sln + dotnet build .\ArIED61850Tester.sln -c Release --no-restore + dotnet test .\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + + - name: Upload P0-5h post-merge attestation + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5h-post-merge-production + path: .\TestResults\P0-5H-post-merge-production.json + if-no-files-found: error + retention-days: 30 From 78beeb68649e87d178c8bb0fc44ce2ba6c2cd282 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 16:00:18 +0700 Subject: [PATCH 119/243] docs(discovery): add P0-5h merge and post-merge protocol --- ..._MAINLINE_MERGE_POST_MERGE_VERIFICATION.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md diff --git a/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md b/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md new file mode 100644 index 000000000..c3358a43c --- /dev/null +++ b/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md @@ -0,0 +1,71 @@ +# P0-5h — Mainline Merge Execution & Post-Merge Production Verification + +P0-5h is the execution phase after P0-5g reaches `READY_FOR_REVIEW`. It does not weaken or bypass P0-5f/P0-5g evidence requirements. If physical authority is missing, the phase remains blocked and neither PR is merged. + +## Preconditions + +All of the following must be true on the exact final heads before merge execution: + +- P0-5f authority is `physical-finalized` and production evidence only; +- P0-5g promotion authority is `production-promoted`; +- `SmartDiscoveryProductionPromoted=true` and props are bound to the exact promotion-authority SHA-256 and validated engine head; +- P0-5g readiness is exactly `READY_FOR_REVIEW` with zero blockers; +- Smart Discovery Production Promotion Guard succeeds; +- Smart Discovery Mainline Readiness succeeds; +- Smart Discovery Field Capture Build, Golden Budget Lock, Golden Provenance, Repeat-Run Stability, generic Build ARSAS, installer/IO/SV/legacy guards succeed; +- engine PR #134 exact head CI succeeds; +- both PRs are open, mergeable, and have no unresolved review threads. + +## Why merge commits are mandatory + +P0-5h uses GitHub merge method `merge` for both repositories. Squash or rebase are not accepted because the physical and promotion evidence bind exact PR head commits. A merge commit preserves those validated commits as ancestors of `main`, which can be verified after merge. + +## Merge manifest + +After P0-5g is fully ready, generate `evidence/smart-discovery-mainline-merge-manifest.json` with `new-smart-discovery-mainline-merge-manifest.ps1`. + +The manifest records: + +- P0-5g validated ARSAS head; +- exact engine PR head; +- both base SHAs at authorization time; +- physical authority, promotion authority, readiness, target, and promotion-props hashes; +- merge method `merge`; +- merge order `engine -> arsas`. + +The tracked manifest intentionally does **not** store its own future ARSAS commit SHA. After adding the manifest, the only allowed post-authorization ARSAS change is that manifest file itself. At merge execution time the live PR head is resolved again and supplied to GitHub as `expected_head_sha`. + +## Exact execution sequence + +1. Re-fetch ARIEC61850 PR #134 and ARSAS PR #324. +2. Require each PR to still be open and mergeable. +3. Require current base SHA to equal the SHA captured by the P0-5h manifest. +4. Require no unresolved review threads on either PR. +5. Require current engine PR head to equal the manifest engine `ExpectedHeadSha`. +6. For ARSAS, compare the P0-5g validated head to the live PR head. The only changed path allowed is `evidence/smart-discovery-mainline-merge-manifest.json`. +7. Merge engine PR #134 first using merge method `merge` and its exact `expected_head_sha`. +8. Verify the engine merge succeeded and the validated engine head is now an ancestor of engine `main`. +9. Re-fetch ARSAS PR #324. Abort if its head/base/mergeability/review state changed. +10. Merge ARSAS PR #324 using merge method `merge` and the freshly resolved live ARSAS head as `expected_head_sha`. +11. Never enable auto-merge in this phase. + +Any mismatch aborts execution. There is no force or fixture bypass. + +## Post-merge production verification + +`Smart Discovery Post-Merge Production Verification` runs on pushes to ARSAS `main` and can also be dispatched manually. It: + +- requires the tracked P0-5h merge manifest and P0-5g promotion authority; +- clones current ARIEC61850 `main`; +- verifies the exact validated engine head is an ancestor of engine main; +- verifies the P0-5g validated ARSAS head is an ancestor of ARSAS main; +- verifies production promotion props remain enabled and hash-bound to the promotion authority and validated engine head; +- runs engine source hygiene, restore, build, and tests from engine main; +- runs ARSAS restore, build, and tests from ARSAS main; +- emits `P0-5H-post-merge-production.json` as the post-merge attestation artifact. + +P0-5h is complete only when both PRs are merged in the required order and this post-merge attestation reports `Verdict=PASS` from mainline state. + +## Current blocked state + +Until a real P0-5f physical-finalized authority exists, P0-5g remains `BLOCKED`, no P0-5h merge manifest may be authorized, and neither PR may be merged by this phase. From 62fec617296386538a36084fe92de7923bd39e2c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 16:01:46 +0700 Subject: [PATCH 120/243] test(discovery): add P0-5h live merge preflight --- ...y-smart-discovery-live-merge-preflight.ps1 | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 scripts/verify-smart-discovery-live-merge-preflight.ps1 diff --git a/scripts/verify-smart-discovery-live-merge-preflight.ps1 b/scripts/verify-smart-discovery-live-merge-preflight.ps1 new file mode 100644 index 000000000..3c5fab9de --- /dev/null +++ b/scripts/verify-smart-discovery-live-merge-preflight.ps1 @@ -0,0 +1,97 @@ +param( + [Parameter(Mandatory=$true)][string]$MergeManifestPath, + [Parameter(Mandatory=$true)][string]$ArsasRepositoryPath, + [Parameter(Mandatory=$true)][string]$LiveArsasHeadCommit, + [Parameter(Mandatory=$true)][string]$CurrentArsasBaseCommit, + [Parameter(Mandatory=$true)][string]$LiveEngineHeadCommit, + [Parameter(Mandatory=$true)][string]$CurrentEngineBaseCommit, + [Parameter(Mandatory=$true)][string]$OutputJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} +function Resolve-Directory([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { throw "$Label is not a directory: $Path" } + return $resolved.Path +} +function Test-GitAncestor([string]$RepositoryPath, [string]$Ancestor, [string]$Descendant) { + & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} +function Get-GitChangedPaths([string]$RepositoryPath, [string]$BaseCommit, [string]$HeadCommit) { + $lines = @(& git -C $RepositoryPath diff --name-only "$BaseCommit..$HeadCommit" -- 2>$null) + if ($LASTEXITCODE -ne 0) { throw "git diff failed for $BaseCommit..$HeadCommit." } + return @($lines | ForEach-Object { ([string]$_).Trim().Replace('\\','/') } | Where-Object { $_ } | Sort-Object -Unique) +} + +$manifestFile = Resolve-File $MergeManifestPath 'P0-5h merge manifest' +$repo = Resolve-Directory $ArsasRepositoryPath 'ARSAS repository' +foreach ($entry in @( + @{ Value = $LiveArsasHeadCommit; Label = 'live ARSAS head' }, + @{ Value = $CurrentArsasBaseCommit; Label = 'current ARSAS base' }, + @{ Value = $LiveEngineHeadCommit; Label = 'live engine head' }, + @{ Value = $CurrentEngineBaseCommit; Label = 'current engine base' })) { Assert-Commit $entry.Value $entry.Label } + +$manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json +if ($manifest.Phase -ne 'P0-5h-merge-manifest' -or $manifest.Status -ne 'authorized-for-ordered-merge') { throw 'Live merge preflight requires an authorized P0-5h merge manifest.' } +if ($manifest.MergeMethod -ne 'merge' -or (@($manifest.MergeOrder) -join ',') -ne 'engine,arsas') { throw 'Live merge preflight requires merge-commit method and engine-first order.' } + +$validatedArsas = ([string]$manifest.Arsas.ValidatedHeadSha).ToLowerInvariant() +$liveArsas = $LiveArsasHeadCommit.ToLowerInvariant() +$currentArsasBase = $CurrentArsasBaseCommit.ToLowerInvariant() +$expectedEngine = ([string]$manifest.Engine.ExpectedHeadSha).ToLowerInvariant() +$liveEngine = $LiveEngineHeadCommit.ToLowerInvariant() +$currentEngineBase = $CurrentEngineBaseCommit.ToLowerInvariant() +Assert-Commit $validatedArsas 'manifest validated ARSAS head' +Assert-Commit $expectedEngine 'manifest engine head' + +$failures = [System.Collections.Generic.List[string]]::new() +if (([string]$manifest.Arsas.BaseShaAtAuthorization).ToLowerInvariant() -ne $currentArsasBase) { $failures.Add('ARSAS base SHA changed after merge authorization.') } +if (([string]$manifest.Engine.BaseShaAtAuthorization).ToLowerInvariant() -ne $currentEngineBase) { $failures.Add('Engine base SHA changed after merge authorization.') } +if ($expectedEngine -ne $liveEngine) { $failures.Add('Engine PR head changed after merge authorization.') } +if (-not (Test-GitAncestor $repo $validatedArsas $liveArsas)) { $failures.Add('Live ARSAS PR head is not a descendant of the P0-5g validated head.') } + +$changed = @() +if ($failures.Count -eq 0 -or (Test-GitAncestor $repo $validatedArsas $liveArsas)) { + $changed = @(Get-GitChangedPaths $repo $validatedArsas $liveArsas) + $allowed = @($manifest.Arsas.AllowedPostAuthorizationPaths | ForEach-Object { [string]$_ }) + $unexpected = @($changed | Where-Object { $allowed -notcontains $_ }) + if ($unexpected.Count -gt 0) { $failures.Add("ARSAS changed after P0-5g validation outside merge-manifest allowlist: $($unexpected -join ', ').") } + if ($changed.Count -eq 0 -or $changed -notcontains 'evidence/smart-discovery-mainline-merge-manifest.json') { $failures.Add('Live ARSAS head does not contain the tracked P0-5h merge manifest change.') } +} + +$result = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5h-live-preflight' + Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } + MergeMethod = 'merge' + ValidatedArsasHead = $validatedArsas + LiveArsasHead = $liveArsas + ArsasBaseSha = $currentArsasBase + ExpectedEngineHead = $expectedEngine + LiveEngineHead = $liveEngine + EngineBaseSha = $currentEngineBase + ArsasPostAuthorizationChangedPaths = @($changed) + AcceptanceFailures = @($failures) +} +$outputDirectory = Split-Path -Parent $OutputJson +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $OutputJson -Encoding utf8 +Write-Host "P0-5h live merge preflight: $($result.Verdict)" +Write-Host " live engine head: $liveEngine" +Write-Host " live ARSAS head: $liveArsas" +Write-Host " changed paths after validation: $($changed -join ', ')" +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + exit 1 +} From eeed677e8acf8970828784dbcd07c8c00aa7e6fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 16:02:08 +0700 Subject: [PATCH 121/243] test(discovery): cover P0-5h live execution preflight --- ...scoveryMainlineMergeExecutionRegressionTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs index 002d42aeb..edcdf327a 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs @@ -42,6 +42,20 @@ public void P05h_ManifestWriterHasNoBypassAndRequiresProductionAuthorities() Assert.DoesNotContain("AllowFixtureEvidence", source, StringComparison.Ordinal); } + [Fact] + public void P05h_LivePreflightRejectsHeadBaseAndPostAuthorizationDrift() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-live-merge-preflight.ps1")); + + Assert.Contains("ARSAS base SHA changed after merge authorization", source, StringComparison.Ordinal); + Assert.Contains("Engine base SHA changed after merge authorization", source, StringComparison.Ordinal); + Assert.Contains("Engine PR head changed after merge authorization", source, StringComparison.Ordinal); + Assert.Contains("Live ARSAS PR head is not a descendant", source, StringComparison.Ordinal); + Assert.Contains("outside merge-manifest allowlist", source, StringComparison.Ordinal); + Assert.Contains("P0-5h-live-preflight", source, StringComparison.Ordinal); + Assert.Contains("git -C $RepositoryPath diff --name-only", source, StringComparison.Ordinal); + } + [Fact] public void P05h_PostMergeVerifierRequiresValidatedHeadsOnMainAndAuthorityBinding() { From 5027f051cba6f6cbcfc4a5c32f806704927dc979 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 16:02:25 +0700 Subject: [PATCH 122/243] test(discovery): validate P0-5h live merge preflight --- .../workflows/smart-discovery-merge-execution-guard.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml index 47edbb974..24a93da7b 100644 --- a/.github/workflows/smart-discovery-merge-execution-guard.yml +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -19,13 +19,14 @@ jobs: run: | $target = '.\evidence\smart-discovery-mainline-merge-target.json' $writer = '.\scripts\new-smart-discovery-mainline-merge-manifest.ps1' + $preflight = '.\scripts\verify-smart-discovery-live-merge-preflight.ps1' $postMerge = '.\scripts\verify-smart-discovery-post-merge-production.ps1' $test = '.\tests\ARSAS.Tests\SmartDiscoveryMainlineMergeExecutionRegressionTests.cs' $doc = '.\docs\P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md' - foreach ($path in @($target,$writer,$postMerge,$test,$doc)) { + foreach ($path in @($target,$writer,$preflight,$postMerge,$test,$doc)) { if (-not (Test-Path $path -PathType Leaf)) { throw "P0-5h source missing: $path" } } - foreach ($script in @($writer,$postMerge)) { + foreach ($script in @($writer,$preflight,$postMerge)) { $tokens = $null $errors = $null [System.Management.Automation.Language.Parser]::ParseFile($script,[ref]$tokens,[ref]$errors) | Out-Null @@ -49,6 +50,9 @@ jobs: } if ((@($m.MergeOrder) -join ',') -ne 'engine,arsas') { throw 'Tracked merge manifest changed merge order.' } if ([string]$m.Arsas.LiveMergeHeadSha) { throw 'Tracked manifest must not self-bind its own future commit SHA.' } + if ((@($m.Arsas.AllowedPostAuthorizationPaths) -join ',') -ne 'evidence/smart-discovery-mainline-merge-manifest.json') { + throw 'Tracked manifest widened the post-authorization ARSAS mutation boundary.' + } } - name: Setup .NET 8 From 608abc7759e5bac8df5ff42b2285f7fe79d176c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 16:02:51 +0700 Subject: [PATCH 123/243] docs(discovery): document P0-5h live merge preflight --- ..._MAINLINE_MERGE_POST_MERGE_VERIFICATION.md | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md b/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md index c3358a43c..9ce0e0408 100644 --- a/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md +++ b/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md @@ -33,7 +33,20 @@ The manifest records: - merge method `merge`; - merge order `engine -> arsas`. -The tracked manifest intentionally does **not** store its own future ARSAS commit SHA. After adding the manifest, the only allowed post-authorization ARSAS change is that manifest file itself. At merge execution time the live PR head is resolved again and supplied to GitHub as `expected_head_sha`. +The tracked manifest intentionally does **not** store its own future ARSAS commit SHA. After adding the manifest, the only allowed post-authorization ARSAS change is that manifest file itself. + +## Live merge preflight + +Immediately before any GitHub merge action, run `verify-smart-discovery-live-merge-preflight.ps1` against the live PR heads/base SHAs. It rejects: + +- ARSAS base drift after authorization; +- engine base drift after authorization; +- engine PR head drift; +- an ARSAS live head that is not a descendant of the P0-5g validated head; +- any ARSAS post-authorization path other than `evidence/smart-discovery-mainline-merge-manifest.json`; +- absence of the tracked P0-5h manifest change. + +A PASS writes `P0-5h-live-preflight` evidence containing the live ARSAS head. That live SHA—not a self-referential value stored in the manifest—is supplied to GitHub as the ARSAS `expected_head_sha`. ## Exact execution sequence @@ -41,13 +54,12 @@ The tracked manifest intentionally does **not** store its own future ARSAS commi 2. Require each PR to still be open and mergeable. 3. Require current base SHA to equal the SHA captured by the P0-5h manifest. 4. Require no unresolved review threads on either PR. -5. Require current engine PR head to equal the manifest engine `ExpectedHeadSha`. -6. For ARSAS, compare the P0-5g validated head to the live PR head. The only changed path allowed is `evidence/smart-discovery-mainline-merge-manifest.json`. -7. Merge engine PR #134 first using merge method `merge` and its exact `expected_head_sha`. -8. Verify the engine merge succeeded and the validated engine head is now an ancestor of engine `main`. -9. Re-fetch ARSAS PR #324. Abort if its head/base/mergeability/review state changed. -10. Merge ARSAS PR #324 using merge method `merge` and the freshly resolved live ARSAS head as `expected_head_sha`. -11. Never enable auto-merge in this phase. +5. Run the live merge preflight and require `PASS`. +6. Merge engine PR #134 first using merge method `merge` and its exact `expected_head_sha`. +7. Verify the engine merge succeeded and the validated engine head is now an ancestor of engine `main`. +8. Re-fetch ARSAS PR #324. Abort if its head/base/mergeability/review state changed after engine merge. +9. Merge ARSAS PR #324 using merge method `merge` and the freshly resolved live ARSAS head as `expected_head_sha`. +10. Never enable auto-merge in this phase. Any mismatch aborts execution. There is no force or fixture bypass. From 5f8184b425ffbef5b3edf1a9343b3557408b3266 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 17:23:10 +0700 Subject: [PATCH 124/243] fix(discovery): retain bounded semantic enrichment --- ...iveIec61850Client.SmartDiscoveryCapture.cs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index c4d96f3e4..fc5df66ae 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -7,9 +7,10 @@ namespace ArIED61850Tester.Services; public sealed partial class NativeIec61850Client { - // This path is intentionally isolated to the PR #134 Wireshark comparison build. + // This path is intentionally isolated to the PR #134 comparison build. // It keeps live MMS evidence authoritative while removing ARSAS's historical - // supplemental GetNameList/read/probe passes from the discovery critical path. + // supplemental naming/probe passes. Evidence-directed DataSet and report + // enrichment stays inside the same association-scoped single flight. private static bool SmartDiscoveryCaptureModeEnabled => true; private async Task> DiscoverSignalsSmartForCaptureAsync( @@ -32,9 +33,10 @@ private async Task> RunSmartDiscoveryAssociation long associationGeneration, IProgress? progress) { - // The complete directory -> GVA -> canonical model -> projection -> publish - // sequence owns the application MMS gate. Waiter cancellation is deliberately - // absent here: only association generation invalidation can make this owner stale. + // The complete directory -> semantic enrichment -> GVA -> canonical model -> + // projection -> publish sequence owns the application MMS gate. Waiter + // cancellation is deliberately absent here: only association generation + // invalidation can make this owner stale. await _mmsIoGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); try { @@ -130,7 +132,7 @@ private async Task> DiscoverSignalsSmartForCaptu $"{cachedBudget} " + $"TimingMs directory=0.0, types=0.0, model=0.0, projection={cachedProjectionWatch.Elapsed.TotalMilliseconds:F1}, " + $"reportHints={cachedReportWatch.Elapsed.TotalMilliseconds:F1}, identity={cachedIdentityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + - "Deferred: supplemental GetNameList, eager report attributes, DataSet directories, reflection fallback, adaptive sibling/equipment/reference/unit probes."; + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes."; if (!TryPublishSmartDiscoveryPresentation( associationGeneration, @@ -152,13 +154,15 @@ private async Task> DiscoverSignalsSmartForCaptu MaxVariableNamesPerDomain = 20000, MaxVariableListNamesPerDomain = 4096, MaxNameListPages = 64, - ProbeReportAttributes = false, - ReadDataSetDirectories = false + ProbeReportAttributes = true, + MaxReportAttributeProbes = 64, + ReadDataSetDirectories = true, + MaxDataSetDirectoryReads = 64 }; progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.DiscoveringDirectory, - "Smart MMS discovery: association-generation single-flight bounded directory scan…", + "Smart MMS discovery: bounded directory scan with evidence-directed DataSet/report enrichment…", 28d, 4, 10)); var directoryWatch = Stopwatch.StartNew(); @@ -221,8 +225,8 @@ private async Task> DiscoverSignalsSmartForCaptu out var projectionStats); projectionWatch.Stop(); - // Report hints derived from structural NamedVariable/NamedVariableList evidence - // remain available. Attribute reads and DataSet-directory reads are deferred. + // Structural hints, bounded report reads, and observed DataSet directories + // all belong to the authoritative single-flight evidence for this association. var reportWatch = Stopwatch.StartNew(); NativeReportDiscoveryMapper.ApplyReportHints(signals, reportInventory); reportWatch.Stop(); @@ -259,7 +263,7 @@ private async Task> DiscoverSignalsSmartForCaptu $"TimingMs directory={directoryWatch.Elapsed.TotalMilliseconds:F1}, types={typeWatch.Elapsed.TotalMilliseconds:F1}, " + $"model={modelWatch.Elapsed.TotalMilliseconds:F1}, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + - "Deferred: supplemental GetNameList, eager report attributes, DataSet directories, reflection fallback, adaptive sibling/equipment/reference/unit probes."; + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes."; // The generation check and state publication are atomic with Reset. A stale // owner can never write _lastDiscovery/_liveModel/identity into a new session. From 28770889e04ea07276418a2371e06510af3f70f6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 17:23:23 +0700 Subject: [PATCH 125/243] test(discovery): lock bounded semantic enrichment --- ...coverySemanticEnrichmentRegressionTests.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/ARSAS.Tests/SmartDiscoverySemanticEnrichmentRegressionTests.cs diff --git a/tests/ARSAS.Tests/SmartDiscoverySemanticEnrichmentRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoverySemanticEnrichmentRegressionTests.cs new file mode 100644 index 000000000..7ed1bbc97 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoverySemanticEnrichmentRegressionTests.cs @@ -0,0 +1,47 @@ +namespace ARSAS.Tests; + +public sealed class SmartDiscoverySemanticEnrichmentRegressionTests +{ + [Fact] + public void SmartCapture_KeepsDataSetAndReportEnrichmentEnabled() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + + Assert.Contains("ProbeReportAttributes = true", capture, StringComparison.Ordinal); + Assert.Contains("MaxReportAttributeProbes = 64", capture, StringComparison.Ordinal); + Assert.Contains("ReadDataSetDirectories = true", capture, StringComparison.Ordinal); + Assert.Contains("MaxDataSetDirectoryReads = 64", capture, StringComparison.Ordinal); + + Assert.DoesNotContain("ProbeReportAttributes = false", capture, StringComparison.Ordinal); + Assert.DoesNotContain("ReadDataSetDirectories = false", capture, StringComparison.Ordinal); + Assert.DoesNotContain("eager report attributes, DataSet directories", capture, StringComparison.Ordinal); + } + + [Fact] + public void SmartCapture_StillDefersOnlyBroadFallbackPasses() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + + Assert.Contains( + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes.", + capture, + StringComparison.Ordinal); + Assert.Contains("association flight=single-owner", capture, StringComparison.Ordinal); + Assert.Contains("app MMS gate=exclusive", capture, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 0c1c91a0f3da3db689ff99840a96bd2bc94fdf8a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 17 Sep 2026 18:29:09 +0700 Subject: [PATCH 126/243] fix(discovery): checkout pinned engine in P0-5h guard --- .../smart-discovery-merge-execution-guard.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml index 24a93da7b..831a0e318 100644 --- a/.github/workflows/smart-discovery-merge-execution-guard.yml +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -55,6 +55,25 @@ jobs: } } + - name: Checkout pinned ARIEC61850 dependency + shell: powershell + run: | + $lock = Get-Content .\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid ARIEC61850 engine lock.' + } + + $enginePath = Join-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) 'ARIEC61850' + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$($lock.repository).git" $enginePath + git -C $enginePath fetch --quiet --depth 1 origin $lock.commit + git -C $enginePath checkout --quiet --detach $lock.commit + + $actual = (git -C $enginePath rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -ne ([string]$lock.commit).ToLowerInvariant()) { + throw "Pinned engine checkout mismatch: expected $($lock.commit), got $actual" + } + Write-Host "P0-5h regression dependency engine: $actual" + - name: Setup .NET 8 uses: actions/setup-dotnet@v4 with: From e45e9962d585b7e0641628ee11b809ff65fc5678 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:43:32 +0700 Subject: [PATCH 127/243] feat(scl): retain canonical live association evidence --- .../NativeIec61850Client.CanonicalModel.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 Services/NativeIec61850Client.CanonicalModel.cs diff --git a/Services/NativeIec61850Client.CanonicalModel.cs b/Services/NativeIec61850Client.CanonicalModel.cs new file mode 100644 index 000000000..9433d17d2 --- /dev/null +++ b/Services/NativeIec61850Client.CanonicalModel.cs @@ -0,0 +1,33 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private LiveIedCanonicalModel? _liveCanonicalModel; + + /// + /// Canonical live snapshot bound to the accepted MMS association that produced + /// the discovery model. Safe SCL export consumes this snapshot so communication + /// parameters are evidence-backed rather than reconstructed from UI defaults. + /// + public LiveIedCanonicalModel? LastCanonicalModel => _liveCanonicalModel; + + private LiveIedCanonicalModel BuildCanonicalModel( + LiveIedModelDiscoveryDocument model) + { + ArgumentNullException.ThrowIfNull(model); + + var accessPointName = string.IsNullOrWhiteSpace(model.AccessPointName) + ? "AP1" + : model.AccessPointName.Trim(); + var communication = _session.GetAcceptedCommunicationEvidence(accessPointName); + return LiveIedCanonicalModelBuilder.Build(model, communication); + } + + private void PublishCanonicalModel(LiveIedModelDiscoveryDocument model) + => _liveCanonicalModel = BuildCanonicalModel(model); + + private void ClearCanonicalModel() + => _liveCanonicalModel = null; +} From c74082c2353b106944d7004709c703626dad9bde Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:43:52 +0700 Subject: [PATCH 128/243] feat(scl): bind canonical model to accepted association --- Services/NativeIec61850Client.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index f71c41a34..04a657e42 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -62,6 +62,7 @@ public async Task ConnectAsync(string ipAddress, int port, CancellationToken can LastConnectionTechnicalSummary = string.Empty; _lastDiscovery = null; _liveModel = null; + ClearCanonicalModel(); _reportMonitorSessions.Clear(); _reportMonitorCoverage.Clear(); ResetSemanticReportProjectionContext(); @@ -161,6 +162,7 @@ public async Task> DiscoverSignalsAsync(Cancella Port = _port, IncludeLowConfidenceTemplates = true }); + PublishCanonicalModel(_liveModel); var primarySnapshot = ToNativeSnapshot(discovery.Snapshot); progress?.Report(new IedDiscoveryProgress( @@ -672,6 +674,7 @@ private static string NormalizeReportedDataSetReference(string domain, string va Port = _port, IncludeLowConfidenceTemplates = true }); + PublishCanonicalModel(_liveModel); LastReportInventory = ToNativeInventory(discovery.ReportInventory); DetectedIdentity = Iec61850DeviceIdentityResolver.Resolve(discovery, _liveModel, Array.Empty()); LastDiscoverySummary = $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); {discovery.Summary} {_liveModel.Summary} Engine=ARIEC61850 live-model/schema/reporting."; From 8538c381ea6099b14e84e029f3eb02a5f5cbe4f3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:44:04 +0700 Subject: [PATCH 129/243] feat(scl): publish canonical smart discovery authority --- Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs index 6cbac46be..93ab03f28 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs @@ -32,6 +32,7 @@ private void ResetSmartDiscoveryAuthority() _smartDiscoveryFlightGeneration = -1; _smartDiscoveryAuthority = null; _smartDiscoveryModelAuthority = null; + ClearCanonicalModel(); _smartDiscoveryTypeProbeCount = 0; _smartDiscoverySuccessfulTypeProbeCount = 0; _smartDiscoveryAuthorityHost = string.Empty; @@ -107,6 +108,7 @@ private bool TryPublishSmartDiscoveryAuthority( _lastDiscovery = discovery; _liveModel = model; + PublishCanonicalModel(model); LastReportInventory = reportInventory; DetectedIdentity = identity; PublishSmartDiscoveryAuthority( From 96b65ff8d18f4744bd62d4238b67f0469cbb07be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:44:14 +0700 Subject: [PATCH 130/243] test(scl): require canonical smart authority for reuse --- Services/NativeIec61850Client.SmartDiscoveryOptimization.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs index 7248490a8..6a73b1fbb 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs @@ -25,6 +25,8 @@ private bool TryGetSmartDiscoveryAuthority( if (!IsSmartDiscoveryAuthorityBoundToCurrentAssociation() || _smartDiscoveryAuthority is null || _smartDiscoveryModelAuthority is null || + LastCanonicalModel is null || + !ReferenceEquals(LastCanonicalModel.Discovery, _smartDiscoveryModelAuthority) || !ReferenceEquals(_lastDiscovery, _smartDiscoveryAuthority) || !ReferenceEquals(_liveModel, _smartDiscoveryModelAuthority)) { From c63c09f05280a7a5bfd3fc514f76b49f414a8f09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:44:25 +0700 Subject: [PATCH 131/243] feat(scl): retain canonical snapshot on IED workspace --- Models/MonitorModels.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 784d08648..e37fc187e 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -34,6 +34,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject private int _unreadEventCount; private SclIedWorkspace? _sclWorkspace; private LiveIedModelDiscoveryDocument? _liveDiscoveryModel; + private LiveIedCanonicalModel? _liveCanonicalModel; private SclLiveModelComparisonResult? _sclComparison; private string _sclSourcePath = string.Empty; private string _sclSourceSha256 = string.Empty; @@ -70,6 +71,17 @@ public LiveIedModelDiscoveryDocument? LiveDiscoveryModel } } + public LiveIedCanonicalModel? LiveCanonicalModel + { + get => _liveCanonicalModel; + set + { + if (ReferenceEquals(_liveCanonicalModel, value)) return; + _liveCanonicalModel = value; + RefreshComputed(); + } + } + public SclLiveModelComparisonResult? SclComparison { get => _sclComparison; From 7b562060d52e8c94d200f359d02c3b8c3eed9661 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:44:37 +0700 Subject: [PATCH 132/243] feat(scl): publish canonical snapshot to device workspace --- Services/Iec61850MonitorRuntime.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index d9ed9af87..d172d5c65 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -137,6 +137,7 @@ public async Task> ConnectAndDiscoverAsync( var discovered = await session.Client.DiscoverSignalsAsync(cancellationToken, progress).ConfigureAwait(false); if (session.Client.LastLiveModel != null) device.LiveDiscoveryModel = session.Client.LastLiveModel; + device.LiveCanonicalModel = session.Client.LastCanonicalModel; var signals = discovered .Where(signal => signal.CanPublishAsSignal || signal.IsControlSignal) @@ -403,6 +404,7 @@ private async Task ConnectUsingSelectedFastPathAsync( throw new InvalidOperationException(result.Message); device.LiveDiscoveryModel = session.Client.LastLiveModel ?? device.SclWorkspace?.DesignModel; + device.LiveCanonicalModel = session.Client.LastCanonicalModel; Log("INFO", device.Name, $"Verified SCL authority active: SHA256={verified.Sha256}; IED={device.SclIedName}; AP={device.SclAccessPointName}; maxReadRefs={result.Preparation.InitialReadPlan?.MaximumVariableReferencesPerRead ?? 0}."); return path; From 597028f91d85b78d5597eb9f925866da4f429ef1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:44:47 +0700 Subject: [PATCH 133/243] feat(scl): route live save through canonical exporter --- MainWindow.xaml.cs | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 00ae66f8a..a7b64134c 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1631,15 +1631,37 @@ private void SaveTypedModelAsScl( SclSchemaProfileDescriptor schema, string outputPath) { - var result = LiveIedSclExporter.WriteFiles( - model, - outputPath, - new LiveIedSclExportOptions + LiveIedSclExportResult result; + if (device.SclWorkspace == null) + { + var canonical = device.LiveCanonicalModel + ?? throw new InvalidOperationException( + "The live discovery model is not bound to accepted MMS association evidence. Re-scan the IED before saving SCL."); + + if (!ReferenceEquals(canonical.Discovery, model)) { - Profile = "safe-connection", - SchemaProfile = schema.Profile, - IpAddress = device.IpAddress - }); + throw new InvalidOperationException( + "The canonical association snapshot does not belong to the current live discovery model. Re-scan the IED before saving SCL."); + } + + result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + outputPath, + schema.Profile, + profile: "safe-connection"); + } + else + { + result = LiveIedSclExporter.WriteFiles( + model, + outputPath, + new LiveIedSclExportOptions + { + Profile = "safe-connection", + SchemaProfile = schema.Profile, + IpAddress = device.IpAddress + }); + } AddLog( "INFO", From 301aa76d3d38ad09ae42246a0e61c2deff4225e5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:45:02 +0700 Subject: [PATCH 134/243] build(engine): pin canonical interoperability head --- engines/ARIEC61850.lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index e85247a75..da8f39e44 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,13 +2,13 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "commit": "30c8820cf922028d3d13b6f5a355df1ff60ea455", "sourcePullRequest": 134, - "purpose": "P0-5c ARSAS smart-discovery capture pin. Compiles against the exact P0-5b hierarchy-coverage/GVA-budget convergence engine commit, including live-LN suppression, LN -> unresolved DO -> distinct exact-leaf fallback, exact-repeat suppression, LastSmartTypeProbeBudget diagnostics, association-scoped engine directory single-flight, authoritative domain-variable reuse, and Control inventory injection. ARSAS P0-5c adds one application-level association-generation flight around directory discovery, GVA enrichment, canonical model build, projection, and publication so caller cancellation cannot restart GVA work on the same association and stale reconnect/dispose flights cannot publish into a replacement association. This trial pin changes discovery orchestration only; it preserves the complete field-proven reporting/control ancestry recorded in fieldProvenBaseline below.", + "purpose": "R7 interoperability trial pin. Uses the exact green PR #134 engine head that retains accepted-association communication evidence in the canonical live model, exports safe-connection SCL from that evidence, round-trips the generated ConnectedAP through the engine association planner, preserves physical ReportControl service capacity while projecting compatible numbered runtime RCB families to logical SCL controls, and keeps the existing bounded smart discovery/DataSet/type-enrichment behavior. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", "previousTrialPin": { - "commit": "57c8311c0dcf9e51da4d7dc31c757fc3f0912586", + "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, - "purpose": "Previous PR #134 R4 smart-discovery capture pin before P0-5b hierarchy request-budget convergence and P0-5c ARSAS association-generation flight integration." + "purpose": "Previous P0-5b/P0-5c field-capture engine pin retained as the physical R5 baseline before canonical communication evidence, logical RCB SCL projection, and round-trip association validation were added." }, "priorGoldenWirePin": { "commit": "0023ef9a4373855497464ed3979e359c4041c95d", From b1e836dbc1b08a0275667e94a6a5af21886ff3c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:45:19 +0700 Subject: [PATCH 135/243] test(scl): lock canonical live export authority --- .../CanonicalLiveSclExportRegressionTests.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs new file mode 100644 index 000000000..492195e91 --- /dev/null +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -0,0 +1,81 @@ +namespace ARSAS.Tests; + +public sealed class CanonicalLiveSclExportRegressionTests +{ + [Fact] + public void NativeClient_RetainsAcceptedAssociationCanonicalSnapshot() + { + var canonical = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.CanonicalModel.cs")); + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + + Assert.Contains("public LiveIedCanonicalModel? LastCanonicalModel", canonical, StringComparison.Ordinal); + Assert.Contains("_session.GetAcceptedCommunicationEvidence", canonical, StringComparison.Ordinal); + Assert.Contains("LiveIedCanonicalModelBuilder.Build", canonical, StringComparison.Ordinal); + Assert.Contains("PublishCanonicalModel(model);", lifecycle, StringComparison.Ordinal); + Assert.Contains("ClearCanonicalModel();", lifecycle, StringComparison.Ordinal); + } + + [Fact] + public void DeviceWorkspace_ReceivesCanonicalSnapshotFromSameClientSession() + { + var runtime = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var model = File.ReadAllText(FindRepoFile("Models/MonitorModels.cs")); + + Assert.Contains("public LiveIedCanonicalModel? LiveCanonicalModel", model, StringComparison.Ordinal); + Assert.Contains("device.LiveCanonicalModel = session.Client.LastCanonicalModel;", runtime, StringComparison.Ordinal); + } + + [Fact] + public void LiveSave_UsesCanonicalExporterAndFailsClosedOnStaleOrMissingEvidence() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs")); + var saveMethodStart = source.IndexOf( + "private void SaveTypedModelAsScl(", + StringComparison.Ordinal); + Assert.True(saveMethodStart >= 0); + + var saveMethod = source[saveMethodStart..]; + var canonicalIndex = saveMethod.IndexOf( + "CanonicalLiveIedSclExporter.WriteFiles", + StringComparison.Ordinal); + var staleGuardIndex = saveMethod.IndexOf( + "The canonical association snapshot does not belong to the current live discovery model.", + StringComparison.Ordinal); + var missingGuardIndex = saveMethod.IndexOf( + "The live discovery model is not bound to accepted MMS association evidence.", + StringComparison.Ordinal); + + Assert.True(missingGuardIndex >= 0); + Assert.True(staleGuardIndex >= 0); + Assert.True(canonicalIndex > staleGuardIndex); + Assert.Contains("if (device.SclWorkspace == null)", saveMethod, StringComparison.Ordinal); + } + + [Fact] + public void EnginePin_MatchesValidatedCanonicalInteroperabilityHead() + { + var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); + + Assert.Contains( + "\"commit\": \"30c8820cf922028d3d13b6f5a355df1ff60ea455\"", + lockFile, + StringComparison.Ordinal); + Assert.Contains("round-trip association validation", lockFile, StringComparison.Ordinal); + Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 5fe201f0df1f01c7d73fd12098b13c5b4258a5b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:46:15 +0700 Subject: [PATCH 136/243] docs(discovery): align field build semantic enrichment metadata --- .github/workflows/smart-discovery-capture-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 13b6ed527..5795cf075 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -256,7 +256,7 @@ jobs: "CI invariant: duplicate semantic request, duplicate GetNameList/GVA, second naming sweep, invoke-ID reuse and outstanding-window proof logic execute against deterministic fixtures", "Golden invariant: production hard budget is derived only from a fresh physical P0-5d PASS plus raw capture/proof SHA-256 provenance", "Field invariant: one association capture, zero duplicate semantic confirmed requests, peak outstanding never above negotiated calling limit", - "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" + "Deferred during discovery: supplemental naming, reflection fallback, and adaptive sibling/equipment/reference/unit probes; bounded report/DataSet semantic enrichment remains enabled" ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 - name: Upload smart field-capture build From 3e1da3f3e6423d00d396d40756916a09757efee2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:58:17 +0700 Subject: [PATCH 137/243] feat(scl): retain bounded initial FC read evidence --- .../NativeIec61850Client.CanonicalModel.cs | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Services/NativeIec61850Client.CanonicalModel.cs b/Services/NativeIec61850Client.CanonicalModel.cs index 9433d17d2..465ae76e1 100644 --- a/Services/NativeIec61850Client.CanonicalModel.cs +++ b/Services/NativeIec61850Client.CanonicalModel.cs @@ -1,20 +1,25 @@ using AR.Iec61850.Discovery; +using ArMms = AR.Iec61850.Mms; namespace ArIED61850Tester.Services; public sealed partial class NativeIec61850Client { private LiveIedCanonicalModel? _liveCanonicalModel; + private ArMms.InitialFcReadExecutionResult? _liveInitialFcRead; /// /// Canonical live snapshot bound to the accepted MMS association that produced /// the discovery model. Safe SCL export consumes this snapshot so communication - /// parameters are evidence-backed rather than reconstructed from UI defaults. + /// parameters and instance values are evidence-backed rather than reconstructed + /// from UI defaults. /// public LiveIedCanonicalModel? LastCanonicalModel => _liveCanonicalModel; + public ArMms.InitialFcReadExecutionResult? LastInitialFcRead => _liveInitialFcRead; private LiveIedCanonicalModel BuildCanonicalModel( - LiveIedModelDiscoveryDocument model) + LiveIedModelDiscoveryDocument model, + ArMms.InitialFcReadExecutionResult? initialRead = null) { ArgumentNullException.ThrowIfNull(model); @@ -22,12 +27,20 @@ private LiveIedCanonicalModel BuildCanonicalModel( ? "AP1" : model.AccessPointName.Trim(); var communication = _session.GetAcceptedCommunicationEvidence(accessPointName); - return LiveIedCanonicalModelBuilder.Build(model, communication); + return LiveIedCanonicalModelBuilder.Build(model, communication, initialRead); } - private void PublishCanonicalModel(LiveIedModelDiscoveryDocument model) - => _liveCanonicalModel = BuildCanonicalModel(model); + private void PublishCanonicalModel( + LiveIedModelDiscoveryDocument model, + ArMms.InitialFcReadExecutionResult? initialRead = null) + { + _liveInitialFcRead = initialRead; + _liveCanonicalModel = BuildCanonicalModel(model, initialRead); + } private void ClearCanonicalModel() - => _liveCanonicalModel = null; + { + _liveInitialFcRead = null; + _liveCanonicalModel = null; + } } From ed59d9ceb09facf6bd0860c81b381547e9febd21 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:58:27 +0700 Subject: [PATCH 138/243] feat(discovery): publish initial read evidence atomically --- Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs index 93ab03f28..41d68ddfe 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs @@ -95,6 +95,7 @@ private bool TryPublishSmartDiscoveryAuthority( long generation, ArMms.MmsDiscoveryResult discovery, LiveIedModelDiscoveryDocument model, + ArMms.InitialFcReadExecutionResult? initialRead, NativeReportInventory reportInventory, Iec61850DeviceIdentity identity, int typeProbeCount, @@ -108,7 +109,7 @@ private bool TryPublishSmartDiscoveryAuthority( _lastDiscovery = discovery; _liveModel = model; - PublishCanonicalModel(model); + PublishCanonicalModel(model, initialRead); LastReportInventory = reportInventory; DetectedIdentity = identity; PublishSmartDiscoveryAuthority( From 867aea7193749386f6973e28ae5a3aeb9d2512ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 07:58:58 +0700 Subject: [PATCH 139/243] feat(discovery): capture bounded FC-root instance evidence --- ...iveIec61850Client.SmartDiscoveryCapture.cs | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index fc5df66ae..3044ea902 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -84,7 +84,7 @@ private async Task> DiscoverSignalsSmartForCaptu progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.MappingSignals, "Reusing the authoritative smart discovery for this MMS association…", - 82d, 7, 10)); + 84d, 8, 11)); var cachedProjectionWatch = Stopwatch.StartNew(); var cachedSnapshot = ToNativeSnapshot(cachedDiscovery.Snapshot); @@ -103,7 +103,7 @@ private async Task> DiscoverSignalsSmartForCaptu progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.ResolvingIdentity, "Resolving IED identity from the cached canonical live model…", - 94d, 8, 10)); + 95d, 9, 11)); var cachedIdentityWatch = Stopwatch.StartNew(); var cachedIdentity = Iec61850DeviceIdentityResolver.Resolve( @@ -128,6 +128,7 @@ private async Task> DiscoverSignalsSmartForCaptu $"IEDName={(string.IsNullOrWhiteSpace(cachedIdentity.IedName) ? "unresolved" : cachedIdentity.IedName)} ({cachedIdentity.Source}); " + $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + + $"instance leaves={LastCanonicalModel?.InstanceValues.Count ?? 0}, " + $"indexed LN hints={cachedProjectionStats.LogicalNodeHints}, indexed fallback signals={cachedProjectionStats.AddedFallbackSignals}. " + $"{cachedBudget} " + $"TimingMs directory=0.0, types=0.0, model=0.0, projection={cachedProjectionWatch.Elapsed.TotalMilliseconds:F1}, " + @@ -209,6 +210,48 @@ private async Task> DiscoverSignalsSmartForCaptu variableTypeAttributes: variableTypes); modelWatch.Stop(); + // The reference capture proves that interoperable SCL requires instance + // evidence in addition to hierarchy/type discovery. Read FC roots in bounded + // batches so one structured response can populate many deterministic leaves. + // BR/RP are excluded because report enrichment already reads those controls. + const int maxInitialFcRootTargets = 1200; + var initialPlanSource = ArMms.InitialFcReadPlanner.FromSclModel( + liveModel, + maximumVariableReferencesPerRead: ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead); + var initialCandidates = initialPlanSource.Targets + .Where(target => + !string.Equals(target.FunctionalConstraint, "BR", StringComparison.OrdinalIgnoreCase) && + !string.Equals(target.FunctionalConstraint, "RP", StringComparison.OrdinalIgnoreCase)) + .Take(maxInitialFcRootTargets) + .ToArray(); + var initialPlan = ArMms.InitialFcReadPlanner.Build( + initialCandidates, + ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead); + ArMms.InitialFcReadExecutionResult? initialRead = null; + + var initialReadWatch = Stopwatch.StartNew(); + if (initialPlan.IsValid && IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.BuildingLiveModel, + $"Reading bounded FC-root instance evidence ({initialPlan.Targets.Count} roots, {initialPlan.Batches.Count} batch(es))…", + 74d, 7, 11)); + + initialRead = await _session.ExecuteInitialFcReadPlanSmartAsync( + initialPlan, + new ArMms.MmsSmartInitialFcReadOptions + { + MaxOutstandingBatches = 8, + UnknownPeerMaxOutstandingBatches = 4 + }, + CancellationToken.None) + .ConfigureAwait(false); + } + initialReadWatch.Stop(); + + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + var snapshot = ToNativeSnapshot(discovery.Snapshot); var reportInventory = ToNativeInventory(discovery.ReportInventory); @@ -261,7 +304,8 @@ private async Task> DiscoverSignalsSmartForCaptu $"indexed LN hints={projectionStats.LogicalNodeHints}, indexed fallback signals={projectionStats.AddedFallbackSignals}. " + $"{typeBudget} " + $"TimingMs directory={directoryWatch.Elapsed.TotalMilliseconds:F1}, types={typeWatch.Elapsed.TotalMilliseconds:F1}, " + - $"model={modelWatch.Elapsed.TotalMilliseconds:F1}, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"model={modelWatch.Elapsed.TotalMilliseconds:F1}, initialRead={initialReadWatch.Elapsed.TotalMilliseconds:F1}, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"initialFcRoots={initialPlan.Targets.Count}/{initialPlanSource.Targets.Count}, initialReadBatches={initialRead?.Batches.Count ?? 0}, instanceLeaves={initialRead?.ProjectedLeafCount ?? 0}, " + $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes."; @@ -271,6 +315,7 @@ private async Task> DiscoverSignalsSmartForCaptu associationGeneration, discovery, liveModel, + initialRead, reportInventory, identity, variableTypes.Count, From 011b7fce086b8fa4f3d8584556259c6d3c6d4805 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:15:29 +0700 Subject: [PATCH 140/243] fix(discovery): pin canonical instance-value exporter lineage --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index da8f39e44..63518ab1e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "30c8820cf922028d3d13b6f5a355df1ff60ea455", + "commit": "3ce8d4bce5bb9395b0300a83f289396ef1b95d10", "sourcePullRequest": 134, - "purpose": "R7 interoperability trial pin. Uses the exact green PR #134 engine head that retains accepted-association communication evidence in the canonical live model, exports safe-connection SCL from that evidence, round-trips the generated ConnectedAP through the engine association planner, preserves physical ReportControl service capacity while projecting compatible numbered runtime RCB families to logical SCL controls, and keeps the existing bounded smart discovery/DataSet/type-enrichment behavior. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", + "purpose": "R7 interoperability trial pin. Uses the isolated PR #134 canonical-export lineage that retains accepted-association communication evidence plus exact bounded FC-root instance-value evidence in the canonical live model, exports safe-connection SCL solely from that snapshot, materializes only type-proven instance values, round-trips the generated ConnectedAP through the engine association planner, preserves physical ReportControl service capacity while projecting compatible numbered runtime RCB families to logical SCL controls, and deliberately excludes the later RCB discovery-read optimization so the existing physical smart-discovery critical path is not broadened by this exporter trial. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From f3e0bb5c74ccd3e5bbf4c91306c0d3ff7f481cbb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:15:38 +0700 Subject: [PATCH 141/243] test(discovery): lock canonical instance-value engine pin --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 492195e91..1b80233e3 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -52,12 +52,12 @@ public void LiveSave_UsesCanonicalExporterAndFailsClosedOnStaleOrMissingEvidence } [Fact] - public void EnginePin_MatchesValidatedCanonicalInteroperabilityHead() + public void EnginePin_MatchesCanonicalInstanceValueInteroperabilityHead() { var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"30c8820cf922028d3d13b6f5a355df1ff60ea455\"", + "\"commit\": \"3ce8d4bce5bb9395b0300a83f289396ef1b95d10\"", lockFile, StringComparison.Ordinal); Assert.Contains("round-trip association validation", lockFile, StringComparison.Ordinal); From 62087ed7cfa5829be95a31be5bd8955a30b6e008 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:16:08 +0700 Subject: [PATCH 142/243] test(discovery): guard canonical instance evidence handoff --- .../CanonicalLiveSclExportRegressionTests.cs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 1b80233e3..25d36bcb1 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -10,11 +10,25 @@ public void NativeClient_RetainsAcceptedAssociationCanonicalSnapshot() Assert.Contains("public LiveIedCanonicalModel? LastCanonicalModel", canonical, StringComparison.Ordinal); Assert.Contains("_session.GetAcceptedCommunicationEvidence", canonical, StringComparison.Ordinal); - Assert.Contains("LiveIedCanonicalModelBuilder.Build", canonical, StringComparison.Ordinal); - Assert.Contains("PublishCanonicalModel(model);", lifecycle, StringComparison.Ordinal); + Assert.Contains("LiveIedCanonicalModelBuilder.Build(model, communication, initialRead)", canonical, StringComparison.Ordinal); + Assert.Contains("PublishCanonicalModel(model, initialRead);", lifecycle, StringComparison.Ordinal); Assert.Contains("ClearCanonicalModel();", lifecycle, StringComparison.Ordinal); } + [Fact] + public void SmartDiscovery_CapturesInitialFcEvidenceIntoSameCanonicalSnapshot() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + + Assert.Contains("InitialFcReadPlanner.FromSclModel", capture, StringComparison.Ordinal); + Assert.Contains("ExecuteInitialFcReadPlanSmartAsync", capture, StringComparison.Ordinal); + Assert.Contains("initialFcRoots={initialPlan.Targets.Count}/{initialPlanSource.Targets.Count}", capture, StringComparison.Ordinal); + Assert.Contains("TryPublishSmartDiscoveryAuthority(", capture, StringComparison.Ordinal); + Assert.Contains("ArMms.InitialFcReadExecutionResult? initialRead", lifecycle, StringComparison.Ordinal); + Assert.Contains("PublishCanonicalModel(model, initialRead);", lifecycle, StringComparison.Ordinal); + } + [Fact] public void DeviceWorkspace_ReceivesCanonicalSnapshotFromSameClientSession() { @@ -60,7 +74,8 @@ public void EnginePin_MatchesCanonicalInstanceValueInteroperabilityHead() "\"commit\": \"3ce8d4bce5bb9395b0300a83f289396ef1b95d10\"", lockFile, StringComparison.Ordinal); - Assert.Contains("round-trip association validation", lockFile, StringComparison.Ordinal); + Assert.Contains("exact bounded FC-root instance-value evidence", lockFile, StringComparison.Ordinal); + Assert.Contains("deliberately excludes the later RCB discovery-read optimization", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); } From d334bd073c17dfa5a26b3db1fd97b3dbf2402ff4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:25:04 +0700 Subject: [PATCH 143/243] ci(discovery): add isolated R7 SCL interoperability build --- .github/workflows/scl-interoperability-r7.yml | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 .github/workflows/scl-interoperability-r7.yml diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml new file mode 100644 index 000000000..bfa03cf18 --- /dev/null +++ b/.github/workflows/scl-interoperability-r7.yml @@ -0,0 +1,187 @@ +name: SCL Interoperability R7 Build + +on: + pull_request: + paths: + - "Services/NativeIec61850Client.CanonicalModel.cs" + - "Services/NativeIec61850Client.SmartDiscoveryCapture.cs" + - "Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs" + - "Models/MonitorModels.cs" + - "MainWindow.xaml.cs" + - "engines/ARIEC61850.lock.json" + - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" + - ".github/workflows/scl-interoperability-r7.yml" + workflow_dispatch: + +jobs: + build-r7-scl-interoperability: + name: Build canonical discovery to interoperable SCL test package + runs-on: windows-latest + + steps: + - name: Checkout ARSAS source + shell: pwsh + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS + $arsasCommit = (git -C .\ARSAS rev-parse HEAD).Trim() + "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Resolve isolated R7 engine pin + shell: pwsh + run: | + $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + if ($lock.repository -ne 'masarray/ARIEC61850' -or + $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid ARIEC61850 R7 lock.' + } + if ($lock.purpose -notmatch 'R7 interoperability trial pin') { + throw 'Engine lock is not explicitly scoped as the R7 interoperability trial.' + } + if ([int]$lock.sourcePullRequest -notin @(134, 135)) { + throw "Unexpected R7 engine source PR: $($lock.sourcePullRequest)" + } + + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_SOURCE_PR=$($lock.sourcePullRequest)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + $version = (Get-Content .\ARSAS\VERSION -Raw).Trim() + "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout exact ARIEC61850 engine revision + shell: pwsh + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT + git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { + throw "Engine SHA mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + } + + - name: Verify canonical SCL interoperability contracts + shell: pwsh + run: | + $canonical = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw + $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw + $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw + $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw + $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw + $save = Get-Content .\ARSAS\MainWindow.xaml.cs -Raw + $regression = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalLiveSclExportRegressionTests.cs -Raw + + foreach ($required in @( + 'class LiveIedCanonicalModel', + 'InstanceValues', + 'LiveIedCommunicationEvidence')) { + if ($canonical -notmatch [regex]::Escape($required)) { + throw "Canonical engine contract missing: $required" + } + } + + foreach ($required in @( + 'ValidateCanonicalCommunication', + 'PreserveRuntimeServiceCapacity', + 'ApplyCanonicalInstanceValues', + 'ValidateRoundTripAssociation')) { + if ($exporter -notmatch [regex]::Escape($required)) { + throw "Canonical SCL exporter contract missing: $required" + } + } + + if ($association -notmatch 'GetAcceptedCommunicationEvidence') { + throw 'Accepted association evidence API is missing.' + } + + if ($env:ARIEC61850_SOURCE_PR -eq '135') { + $wireReader = '.\ARIEC61850\src\AR.Iec61850\Acse\AcseAssociationRequestIdentityReader.cs' + if (!(Test-Path $wireReader) -or + $association -notmatch 'AcseAssociationRequestIdentityReader\.Read' -or + $association -match 'ApTitle\s*=\s*"1,1,1,999,1"') { + throw 'PR #135 must derive canonical communication evidence from exact accepted association bytes, not duplicated profile constants.' + } + } + + if ($consumer -notmatch 'LiveIedCanonicalModelBuilder\.Build\(model, communication, initialRead\)' -or + $capture -notmatch 'InitialFcReadPlanner\.FromSclModel' -or + $capture -notmatch 'ExecuteInitialFcReadPlanSmartAsync' -or + $lifecycle -notmatch 'PublishCanonicalModel\(model, initialRead\)' -or + $save -notmatch 'CanonicalLiveIedSclExporter\.WriteFiles' -or + $regression -notmatch 'SmartDiscovery_CapturesInitialFcEvidenceIntoSameCanonicalSnapshot') { + throw 'ARSAS canonical discovery -> instance evidence -> SCL export contract is incomplete.' + } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore ARSAS solution + run: dotnet restore .\ARSAS\ArIED61850Tester.sln + + - name: Build ARSAS Release + run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + + - name: Run ARSAS canonical-export regressions + run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults + + - name: Build engine and run engine tests + shell: pwsh + run: | + dotnet restore .\ARIEC61850\ARIEC61850.sln + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test .\ARIEC61850\ARIEC61850.sln -c Release --no-build --no-restore --logger "trx;LogFileName=ariec61850-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Publish R7 portable single EXE + shell: pwsh + run: | + .\ARSAS\scripts\publish-windows-portable.ps1 ` + -Version $env:ARSAS_VERSION ` + -Runtime win-x64 ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + + - name: Smoke test R7 portable executable + shell: pwsh + run: | + $exe = ".\ARSAS\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" + if (!(Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing: $exe" } + + $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-r7-scl-bundle-cache' + $process = Start-Process -FilePath $exe -ArgumentList @('--portable-smoke-test') -PassThru + if (-not $process.WaitForExit(30000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw 'Portable EXE smoke test timed out.' + } + if ($process.ExitCode -ne 0) { + throw "Portable EXE smoke test failed: $($process.ExitCode)" + } + + @( + 'ARSAS R7 SCL interoperability field-test build', + "ARSAS commit: $env:ARSAS_COMMIT", + "ARIEC61850 commit: $env:ARIEC61850_COMMIT", + "Engine source PR: $env:ARIEC61850_SOURCE_PR", + '', + 'Acceptance target:', + 'IED -> Smart Discovery -> Canonical IED Model -> Save SCL Ed1/Ed2 -> Reload -> exact association plan -> MMS association.', + '', + 'This artifact is NOT production-promotion authority and does NOT replace P0-5e/P0-5f physical discovery-budget evidence.' + ) | Set-Content .\ARSAS\dist\R7-SCL-INTEROP-BUILD.txt -Encoding utf8 + + - name: Upload R7 SCL interoperability build + uses: actions/upload-artifact@v4 + with: + name: ARSAS-r7-scl-interoperability-win-x64 + path: | + ARSAS\dist\ARSAS-*-win-x64-portable.exe + ARSAS\dist\R7-SCL-INTEROP-BUILD.txt + ARSAS\TestResults\*.trx + if-no-files-found: error + retention-days: 14 From db32f1a5b0d706d7126ef918a2d9025de39348c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:28:07 +0700 Subject: [PATCH 144/243] fix(discovery): pin wire-evidence canonical exporter --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 63518ab1e..99bc9453e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "3ce8d4bce5bb9395b0300a83f289396ef1b95d10", - "sourcePullRequest": 134, - "purpose": "R7 interoperability trial pin. Uses the isolated PR #134 canonical-export lineage that retains accepted-association communication evidence plus exact bounded FC-root instance-value evidence in the canonical live model, exports safe-connection SCL solely from that snapshot, materializes only type-proven instance values, round-trips the generated ConnectedAP through the engine association planner, preserves physical ReportControl service capacity while projecting compatible numbered runtime RCB families to logical SCL controls, and deliberately excludes the later RCB discovery-read optimization so the existing physical smart-discovery critical path is not broadened by this exporter trial. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", + "commit": "7915b9aa32859d3a76cacc1494835196f64ef89d", + "sourcePullRequest": 135, + "purpose": "R7 interoperability trial pin. Uses isolated engine PR #135 on top of the green 3ce8 canonical-export lineage: accepted remote AP-title/AE/PSEL/SSEL are decoded from the exact association request bytes accepted by the IED, TSEL is bound to the exact current COTP destination selector, exact bounded FC-root instance-value evidence remains part of the canonical live model, safe-connection SCL must preserve IP/AP-title/AE/PSEL/SSEL/TSEL exactly across serialize -> parse before an association plan is accepted, non-102 MMS sessions fail closed because the current SCL plan represents port 102 only, physical ReportControl service capacity remains distinct from logical RCB projection, and the later RCB discovery-read optimization remains excluded so this exporter trial does not broaden the field-qualified discovery request path. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From a29deb48bfd30ad362a7021b03394ed9da345c5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:28:20 +0700 Subject: [PATCH 145/243] test(discovery): lock wire-evidence R7 engine pin --- .../CanonicalLiveSclExportRegressionTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 25d36bcb1..004622a0c 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -66,16 +66,19 @@ public void LiveSave_UsesCanonicalExporterAndFailsClosedOnStaleOrMissingEvidence } [Fact] - public void EnginePin_MatchesCanonicalInstanceValueInteroperabilityHead() + public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() { var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"3ce8d4bce5bb9395b0300a83f289396ef1b95d10\"", + "\"commit\": \"7915b9aa32859d3a76cacc1494835196f64ef89d\"", lockFile, StringComparison.Ordinal); - Assert.Contains("exact bounded FC-root instance-value evidence", lockFile, StringComparison.Ordinal); - Assert.Contains("deliberately excludes the later RCB discovery-read optimization", lockFile, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); + Assert.Contains("exact association request bytes accepted by the IED", lockFile, StringComparison.Ordinal); + Assert.Contains("serialize -> parse", lockFile, StringComparison.Ordinal); + Assert.Contains("non-102 MMS sessions fail closed", lockFile, StringComparison.Ordinal); + Assert.Contains("later RCB discovery-read optimization remains excluded", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); } From 09eef497ce5c29a81710c1b3d89a3686998cedd5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:29:39 +0700 Subject: [PATCH 146/243] feat(scl): log canonical export round-trip evidence --- MainWindow.xaml.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index a7b64134c..8da862676 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1632,6 +1632,7 @@ private void SaveTypedModelAsScl( string outputPath) { LiveIedSclExportResult result; + AR.Iec61850.Discovery.LiveIedCanonicalModel? canonicalExportEvidence = null; if (device.SclWorkspace == null) { var canonical = device.LiveCanonicalModel @@ -1649,6 +1650,7 @@ private void SaveTypedModelAsScl( outputPath, schema.Profile, profile: "safe-connection"); + canonicalExportEvidence = canonical; } else { @@ -1668,6 +1670,20 @@ private void SaveTypedModelAsScl( "SCL Export", $"{device.Name}: saved {result.SclSchema} from {sourceDescription.ToLowerInvariant()}. LD={result.LogicalDeviceCount}, LN={result.LogicalNodeCount}, DataSet={result.DataSetCount}, RCB={result.ReportControlCount}, warnings={result.Warnings.Count}. SCL={result.SclPath}"); + if (canonicalExportEvidence is not null) + { + var communication = canonicalExportEvidence.Communication; + var association = communication.Association; + AddLog( + "INFO", + "SCL Export", + $"{device.Name}: canonical round-trip verified • profile={communication.AssociationProfileName} • " + + $"AP-Title={association.ApTitle} • AE={association.AeQualifier?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? ""} • " + + $"PSEL={association.PresentationSelector} • SSEL={association.SessionSelector} • TSEL={association.TransportSelector} • " + + $"instanceEvidence={canonicalExportEvidence.InstanceValues.Count} • runtimeRCB={canonicalExportEvidence.Discovery.ReportControls.Count} • " + + $"logicalExportRCB={result.ReportControlCount}."); + } + foreach (var warning in result.Warnings.Take(12)) { var reference = string.IsNullOrWhiteSpace(warning.Reference) From b3490f7ec242ae33e79eb792afd7414ef69ab000 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:29:52 +0700 Subject: [PATCH 147/243] test(scl): guard canonical export diagnostics --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 004622a0c..ab1d04cde 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -63,6 +63,10 @@ public void LiveSave_UsesCanonicalExporterAndFailsClosedOnStaleOrMissingEvidence Assert.True(staleGuardIndex >= 0); Assert.True(canonicalIndex > staleGuardIndex); Assert.Contains("if (device.SclWorkspace == null)", saveMethod, StringComparison.Ordinal); + Assert.Contains("canonical round-trip verified", saveMethod, StringComparison.Ordinal); + Assert.Contains("instanceEvidence={canonicalExportEvidence.InstanceValues.Count}", saveMethod, StringComparison.Ordinal); + Assert.Contains("runtimeRCB={canonicalExportEvidence.Discovery.ReportControls.Count}", saveMethod, StringComparison.Ordinal); + Assert.Contains("logicalExportRCB={result.ReportControlCount}", saveMethod, StringComparison.Ordinal); } [Fact] From b468242372301ed55e8448b8cb9a636134d92d36 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:31:37 +0700 Subject: [PATCH 148/243] fix(discovery): pin accepted COTP evidence exporter head --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 99bc9453e..effca26ce 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "7915b9aa32859d3a76cacc1494835196f64ef89d", + "commit": "c6fdee7f2cdd21dd051e39ffeee14998299ce901", "sourcePullRequest": 135, - "purpose": "R7 interoperability trial pin. Uses isolated engine PR #135 on top of the green 3ce8 canonical-export lineage: accepted remote AP-title/AE/PSEL/SSEL are decoded from the exact association request bytes accepted by the IED, TSEL is bound to the exact current COTP destination selector, exact bounded FC-root instance-value evidence remains part of the canonical live model, safe-connection SCL must preserve IP/AP-title/AE/PSEL/SSEL/TSEL exactly across serialize -> parse before an association plan is accepted, non-102 MMS sessions fail closed because the current SCL plan represents port 102 only, physical ReportControl service capacity remains distinct from logical RCB projection, and the later RCB discovery-read optimization remains excluded so this exporter trial does not broaden the field-qualified discovery request path. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", + "purpose": "R7 interoperability trial pin. Uses isolated engine PR #135 on top of the green 3ce8 canonical-export lineage: accepted remote AP-title/AE/PSEL/SSEL are decoded from the exact association request bytes accepted by the IED, TSEL is bound to the exact accepted COTP destination selector retained by the live session, exact bounded FC-root instance-value evidence remains part of the canonical live model, safe-connection SCL must preserve IP/AP-title/AE/PSEL/SSEL/TSEL exactly across serialize -> parse before an association plan is accepted, non-102 MMS sessions fail closed because the current SCL plan represents port 102 only, physical ReportControl service capacity remains distinct from logical RCB projection, and the later RCB discovery-read optimization remains excluded so this exporter trial does not broaden the field-qualified discovery request path. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From 10d9b3db634dbec2b220ecef403d2035a5a1a8b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:31:45 +0700 Subject: [PATCH 149/243] test(discovery): lock accepted COTP evidence engine head --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index ab1d04cde..36960821c 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -75,11 +75,11 @@ public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"7915b9aa32859d3a76cacc1494835196f64ef89d\"", + "\"commit\": \"c6fdee7f2cdd21dd051e39ffeee14998299ce901\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); - Assert.Contains("exact association request bytes accepted by the IED", lockFile, StringComparison.Ordinal); + Assert.Contains("exact association request bytes accepted by the IED", lockFile, StringComparison.Ordinal);\n Assert.Contains("exact accepted COTP destination selector retained by the live session", lockFile, StringComparison.Ordinal); Assert.Contains("serialize -> parse", lockFile, StringComparison.Ordinal); Assert.Contains("non-102 MMS sessions fail closed", lockFile, StringComparison.Ordinal); Assert.Contains("later RCB discovery-read optimization remains excluded", lockFile, StringComparison.Ordinal); From bdbeb13306d63f35fb3fb946485e736da025f45f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:31:58 +0700 Subject: [PATCH 150/243] ci(scl): verify accepted COTP evidence provenance --- .github/workflows/scl-interoperability-r7.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index bfa03cf18..f7f0ce044 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -65,6 +65,7 @@ jobs: $canonical = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw + $cotpClient = Get-Content .\ARIEC61850\src\AR.Iec61850\Osi\CotpClient.cs -Raw $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw @@ -98,8 +99,11 @@ jobs: $wireReader = '.\ARIEC61850\src\AR.Iec61850\Acse\AcseAssociationRequestIdentityReader.cs' if (!(Test-Path $wireReader) -or $association -notmatch 'AcseAssociationRequestIdentityReader\.Read' -or + $association -notmatch '_cotp\.LastConnectParameters\?\.DestinationTsap' -or + $cotpClient -notmatch 'public CotpConnectParameters\? LastConnectParameters' -or + $cotpClient -notmatch 'LastConnectParameters = acceptedParameters' -or $association -match 'ApTitle\s*=\s*"1,1,1,999,1"') { - throw 'PR #135 must derive canonical communication evidence from exact accepted association bytes, not duplicated profile constants.' + throw 'PR #135 must derive canonical AP/AE/selectors from exact accepted ACSE and COTP session evidence, not duplicated profile constants.' } } From 94b0bf3d397398708dc0277ab6e2283abc2b1f6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:32:43 +0700 Subject: [PATCH 151/243] fix(discovery): pin compile-safe COTP evidence head --- engines/ARIEC61850.lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index effca26ce..fe21c82e5 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "c6fdee7f2cdd21dd051e39ffeee14998299ce901", + "commit": "5b7857f0524f787a325df277908f3943c9336c8c", "sourcePullRequest": 135, "purpose": "R7 interoperability trial pin. Uses isolated engine PR #135 on top of the green 3ce8 canonical-export lineage: accepted remote AP-title/AE/PSEL/SSEL are decoded from the exact association request bytes accepted by the IED, TSEL is bound to the exact accepted COTP destination selector retained by the live session, exact bounded FC-root instance-value evidence remains part of the canonical live model, safe-connection SCL must preserve IP/AP-title/AE/PSEL/SSEL/TSEL exactly across serialize -> parse before an association plan is accepted, non-102 MMS sessions fail closed because the current SCL plan represents port 102 only, physical ReportControl service capacity remains distinct from logical RCB projection, and the later RCB discovery-read optimization remains excluded so this exporter trial does not broaden the field-qualified discovery request path. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", "previousTrialPin": { From 163ed75d82c306a0081fe50ad369e645a81a4804 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 08:32:48 +0700 Subject: [PATCH 152/243] test(discovery): lock compile-safe COTP evidence head --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 36960821c..7e6d07252 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -75,7 +75,7 @@ public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"c6fdee7f2cdd21dd051e39ffeee14998299ce901\"", + "\"commit\": \"5b7857f0524f787a325df277908f3943c9336c8c\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 606bad31d9f64865d04861cc4462a2835c228ab3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:01:06 +0700 Subject: [PATCH 153/243] fix(test): remove escaped newline from R7 pin regression --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 7e6d07252..86f911902 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -79,7 +79,8 @@ public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); - Assert.Contains("exact association request bytes accepted by the IED", lockFile, StringComparison.Ordinal);\n Assert.Contains("exact accepted COTP destination selector retained by the live session", lockFile, StringComparison.Ordinal); + Assert.Contains("exact association request bytes accepted by the IED", lockFile, StringComparison.Ordinal); + Assert.Contains("exact accepted COTP destination selector retained by the live session", lockFile, StringComparison.Ordinal); Assert.Contains("serialize -> parse", lockFile, StringComparison.Ordinal); Assert.Contains("non-102 MMS sessions fail closed", lockFile, StringComparison.Ordinal); Assert.Contains("later RCB discovery-read optimization remains excluded", lockFile, StringComparison.Ordinal); From d921bda85b56c86425ef4e18a9faf99b863b0f63 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:04:18 +0700 Subject: [PATCH 154/243] fix(scl): require ARSAS workspace reload before save success --- MainWindow.xaml.cs | 81 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 8da862676..67127ebd4 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1633,6 +1633,7 @@ private void SaveTypedModelAsScl( { LiveIedSclExportResult result; AR.Iec61850.Discovery.LiveIedCanonicalModel? canonicalExportEvidence = null; + SclIedWorkspace? canonicalReloadWorkspace = null; if (device.SclWorkspace == null) { var canonical = device.LiveCanonicalModel @@ -1650,6 +1651,7 @@ private void SaveTypedModelAsScl( outputPath, schema.Profile, profile: "safe-connection"); + canonicalReloadWorkspace = ValidateCanonicalSclWorkspaceReload(canonical, result); canonicalExportEvidence = canonical; } else @@ -1681,7 +1683,9 @@ private void SaveTypedModelAsScl( $"AP-Title={association.ApTitle} • AE={association.AeQualifier?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? ""} • " + $"PSEL={association.PresentationSelector} • SSEL={association.SessionSelector} • TSEL={association.TransportSelector} • " + $"instanceEvidence={canonicalExportEvidence.InstanceValues.Count} • runtimeRCB={canonicalExportEvidence.Discovery.ReportControls.Count} • " + - $"logicalExportRCB={result.ReportControlCount}."); + $"logicalExportRCB={result.ReportControlCount} • reloadLD={canonicalReloadWorkspace?.DesignModel.Coverage.LogicalDeviceCount ?? 0} • " + + $"reloadLN={canonicalReloadWorkspace?.DesignModel.Coverage.LogicalNodeCount ?? 0} • reloadDataSet={canonicalReloadWorkspace?.DataSets.Count ?? 0} • " + + $"reloadRCB={canonicalReloadWorkspace?.ReportControls.Count ?? 0}."); } foreach (var warning in result.Warnings.Take(12)) @@ -1698,6 +1702,81 @@ private void SaveTypedModelAsScl( ShowSclSaveSuccess(result.SclSchema, result.SclPath, result.ReportPath, result.SummaryPath); } + private SclIedWorkspace ValidateCanonicalSclWorkspaceReload( + AR.Iec61850.Discovery.LiveIedCanonicalModel canonical, + LiveIedSclExportResult result) + { + try + { + var reloaded = _sclWorkspaceService.Open( + result.SclPath, + new SclWorkspaceOpenOptions + { + IedName = canonical.IedName, + AccessPointName = canonical.AccessPointName + }); + + var workspace = reloaded.Ieds.SingleOrDefault() + ?? throw new InvalidOperationException( + $"Generated SCL could not be reopened as exactly one ARSAS workspace for '{canonical.IedName}/{canonical.AccessPointName}'."); + + if (!string.Equals(workspace.IedName, canonical.IedName, StringComparison.Ordinal) || + !string.Equals(workspace.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Generated SCL reload identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}', " + + $"reloaded '{workspace.IedName}/{workspace.AccessPointName}'."); + } + + var endpoint = workspace.PreferredEndpoint + ?? throw new InvalidOperationException( + "Generated SCL reload did not resolve a usable MMS endpoint."); + + if (!endpoint.HasUsableAddress || + !string.Equals(endpoint.IpAddress, canonical.Communication.Host, StringComparison.OrdinalIgnoreCase) || + endpoint.Port != 102) + { + throw new InvalidOperationException( + $"Generated SCL reload endpoint drifted. Expected '{canonical.Communication.Host}:102', " + + $"reloaded '{endpoint.EndpointText}'."); + } + + var reloadCoverage = workspace.DesignModel.Coverage; + var mismatches = new List(); + if (reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount) + mismatches.Add($"LD {reloadCoverage.LogicalDeviceCount}!={result.LogicalDeviceCount}"); + if (reloadCoverage.LogicalNodeCount != result.LogicalNodeCount) + mismatches.Add($"LN {reloadCoverage.LogicalNodeCount}!={result.LogicalNodeCount}"); + if (workspace.DataSets.Count != result.DataSetCount) + mismatches.Add($"DataSet {workspace.DataSets.Count}!={result.DataSetCount}"); + if (workspace.ReportControls.Count != result.ReportControlCount) + mismatches.Add($"RCB {workspace.ReportControls.Count}!={result.ReportControlCount}"); + + if (mismatches.Count > 0) + { + throw new InvalidOperationException( + "Generated SCL changed structural counts when reloaded by ARSAS: " + + string.Join(", ", mismatches) + "."); + } + + return workspace; + } + catch + { + try + { + if (File.Exists(result.SclPath)) + File.Delete(result.SclPath); + } + catch + { + // Preserve the original reload-validation failure. + } + + throw; + } + } + private void ShowSclSaveSuccess(string schema, string sclPath, string reportPath, string summaryPath) { MessageBox.Show( From 0d7b314c52b6410edb9d0892361f53925a6ca211 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:04:45 +0700 Subject: [PATCH 155/243] test(scl): guard ARSAS workspace reload validation --- .../CanonicalLiveSclExportRegressionTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 86f911902..9dcf67059 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -69,6 +69,46 @@ public void LiveSave_UsesCanonicalExporterAndFailsClosedOnStaleOrMissingEvidence Assert.Contains("logicalExportRCB={result.ReportControlCount}", saveMethod, StringComparison.Ordinal); } + [Fact] + public void LiveSave_ReopensGeneratedSclThroughWorkspaceParserBeforeSuccess() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs")); + var helperStart = source.IndexOf( + "private SclIedWorkspace ValidateCanonicalSclWorkspaceReload(", + StringComparison.Ordinal); + var successStart = source.IndexOf( + "private void ShowSclSaveSuccess(", + StringComparison.Ordinal); + + Assert.True(helperStart >= 0); + Assert.True(successStart > helperStart); + + var helper = source[helperStart..successStart]; + Assert.Contains("_sclWorkspaceService.Open(", helper, StringComparison.Ordinal); + Assert.Contains("IedName = canonical.IedName", helper, StringComparison.Ordinal); + Assert.Contains("AccessPointName = canonical.AccessPointName", helper, StringComparison.Ordinal); + Assert.Contains("endpoint.Port != 102", helper, StringComparison.Ordinal); + Assert.Contains("reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount", helper, StringComparison.Ordinal); + Assert.Contains("reloadCoverage.LogicalNodeCount != result.LogicalNodeCount", helper, StringComparison.Ordinal); + Assert.Contains("workspace.DataSets.Count != result.DataSetCount", helper, StringComparison.Ordinal); + Assert.Contains("workspace.ReportControls.Count != result.ReportControlCount", helper, StringComparison.Ordinal); + Assert.Contains("File.Delete(result.SclPath)", helper, StringComparison.Ordinal); + + var saveMethodStart = source.IndexOf( + "private void SaveTypedModelAsScl(", + StringComparison.Ordinal); + Assert.True(saveMethodStart >= 0); + var saveMethod = source[saveMethodStart..helperStart]; + var exportIndex = saveMethod.IndexOf("CanonicalLiveIedSclExporter.WriteFiles", StringComparison.Ordinal); + var reloadIndex = saveMethod.IndexOf("ValidateCanonicalSclWorkspaceReload(canonical, result)", StringComparison.Ordinal); + Assert.True(exportIndex >= 0 && reloadIndex > exportIndex); + Assert.Contains("reloadLD=", saveMethod, StringComparison.Ordinal); + Assert.Contains("reloadLN=", saveMethod, StringComparison.Ordinal); + Assert.Contains("reloadDataSet=", saveMethod, StringComparison.Ordinal); + Assert.Contains("reloadRCB=", saveMethod, StringComparison.Ordinal); + } + + [Fact] public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() { From 8e460a5003bc8e493b58f7e8f6a043c65adb4604 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:05:13 +0700 Subject: [PATCH 156/243] feat(scl): add pure canonical workspace reload validator --- Services/CanonicalSclReloadValidator.cs | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 Services/CanonicalSclReloadValidator.cs diff --git a/Services/CanonicalSclReloadValidator.cs b/Services/CanonicalSclReloadValidator.cs new file mode 100644 index 000000000..609aba38e --- /dev/null +++ b/Services/CanonicalSclReloadValidator.cs @@ -0,0 +1,71 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; +using AR.Iec61850.Scl.Workspace; + +namespace ArIED61850Tester.Services; + +public static class CanonicalSclReloadValidator +{ + public static SclIedWorkspace Validate( + SclWorkspaceService workspaceService, + LiveIedCanonicalModel canonical, + LiveIedSclExportResult result) + { + ArgumentNullException.ThrowIfNull(workspaceService); + ArgumentNullException.ThrowIfNull(canonical); + ArgumentNullException.ThrowIfNull(result); + + var reloaded = workspaceService.Open( + result.SclPath, + new SclWorkspaceOpenOptions + { + IedName = canonical.IedName, + AccessPointName = canonical.AccessPointName + }); + + var workspace = reloaded.Ieds.SingleOrDefault() + ?? throw new InvalidOperationException( + $"Generated SCL could not be reopened as exactly one ARSAS workspace for '{canonical.IedName}/{canonical.AccessPointName}'."); + + if (!string.Equals(workspace.IedName, canonical.IedName, StringComparison.Ordinal) || + !string.Equals(workspace.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Generated SCL reload identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}', " + + $"reloaded '{workspace.IedName}/{workspace.AccessPointName}'."); + } + + var endpoint = workspace.PreferredEndpoint + ?? throw new InvalidOperationException( + "Generated SCL reload did not resolve a usable MMS endpoint."); + + if (!endpoint.HasUsableAddress || + !string.Equals(endpoint.IpAddress, canonical.Communication.Host, StringComparison.OrdinalIgnoreCase) || + endpoint.Port != 102) + { + throw new InvalidOperationException( + $"Generated SCL reload endpoint drifted. Expected '{canonical.Communication.Host}:102', " + + $"reloaded '{endpoint.EndpointText}'."); + } + + var reloadCoverage = workspace.DesignModel.Coverage; + var mismatches = new List(); + if (reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount) + mismatches.Add($"LD {reloadCoverage.LogicalDeviceCount}!={result.LogicalDeviceCount}"); + if (reloadCoverage.LogicalNodeCount != result.LogicalNodeCount) + mismatches.Add($"LN {reloadCoverage.LogicalNodeCount}!={result.LogicalNodeCount}"); + if (workspace.DataSets.Count != result.DataSetCount) + mismatches.Add($"DataSet {workspace.DataSets.Count}!={result.DataSetCount}"); + if (workspace.ReportControls.Count != result.ReportControlCount) + mismatches.Add($"RCB {workspace.ReportControls.Count}!={result.ReportControlCount}"); + + if (mismatches.Count > 0) + { + throw new InvalidOperationException( + "Generated SCL changed structural counts when reloaded by ARSAS: " + + string.Join(", ", mismatches) + "."); + } + + return workspace; + } +} From bafbca66ab35b03105330691bf4a404ea7365bc5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:05:25 +0700 Subject: [PATCH 157/243] refactor(scl): use pure workspace reload validator --- MainWindow.xaml.cs | 97 ++++++++++------------------------------------ 1 file changed, 21 insertions(+), 76 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 67127ebd4..d112adc03 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1651,7 +1651,27 @@ private void SaveTypedModelAsScl( outputPath, schema.Profile, profile: "safe-connection"); - canonicalReloadWorkspace = ValidateCanonicalSclWorkspaceReload(canonical, result); + try + { + canonicalReloadWorkspace = CanonicalSclReloadValidator.Validate( + _sclWorkspaceService, + canonical, + result); + } + catch + { + try + { + if (File.Exists(result.SclPath)) + File.Delete(result.SclPath); + } + catch + { + // Preserve the original reload-validation failure. + } + + throw; + } canonicalExportEvidence = canonical; } else @@ -1702,81 +1722,6 @@ private void SaveTypedModelAsScl( ShowSclSaveSuccess(result.SclSchema, result.SclPath, result.ReportPath, result.SummaryPath); } - private SclIedWorkspace ValidateCanonicalSclWorkspaceReload( - AR.Iec61850.Discovery.LiveIedCanonicalModel canonical, - LiveIedSclExportResult result) - { - try - { - var reloaded = _sclWorkspaceService.Open( - result.SclPath, - new SclWorkspaceOpenOptions - { - IedName = canonical.IedName, - AccessPointName = canonical.AccessPointName - }); - - var workspace = reloaded.Ieds.SingleOrDefault() - ?? throw new InvalidOperationException( - $"Generated SCL could not be reopened as exactly one ARSAS workspace for '{canonical.IedName}/{canonical.AccessPointName}'."); - - if (!string.Equals(workspace.IedName, canonical.IedName, StringComparison.Ordinal) || - !string.Equals(workspace.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Generated SCL reload identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}', " + - $"reloaded '{workspace.IedName}/{workspace.AccessPointName}'."); - } - - var endpoint = workspace.PreferredEndpoint - ?? throw new InvalidOperationException( - "Generated SCL reload did not resolve a usable MMS endpoint."); - - if (!endpoint.HasUsableAddress || - !string.Equals(endpoint.IpAddress, canonical.Communication.Host, StringComparison.OrdinalIgnoreCase) || - endpoint.Port != 102) - { - throw new InvalidOperationException( - $"Generated SCL reload endpoint drifted. Expected '{canonical.Communication.Host}:102', " + - $"reloaded '{endpoint.EndpointText}'."); - } - - var reloadCoverage = workspace.DesignModel.Coverage; - var mismatches = new List(); - if (reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount) - mismatches.Add($"LD {reloadCoverage.LogicalDeviceCount}!={result.LogicalDeviceCount}"); - if (reloadCoverage.LogicalNodeCount != result.LogicalNodeCount) - mismatches.Add($"LN {reloadCoverage.LogicalNodeCount}!={result.LogicalNodeCount}"); - if (workspace.DataSets.Count != result.DataSetCount) - mismatches.Add($"DataSet {workspace.DataSets.Count}!={result.DataSetCount}"); - if (workspace.ReportControls.Count != result.ReportControlCount) - mismatches.Add($"RCB {workspace.ReportControls.Count}!={result.ReportControlCount}"); - - if (mismatches.Count > 0) - { - throw new InvalidOperationException( - "Generated SCL changed structural counts when reloaded by ARSAS: " + - string.Join(", ", mismatches) + "."); - } - - return workspace; - } - catch - { - try - { - if (File.Exists(result.SclPath)) - File.Delete(result.SclPath); - } - catch - { - // Preserve the original reload-validation failure. - } - - throw; - } - } - private void ShowSclSaveSuccess(string schema, string sclPath, string reportPath, string summaryPath) { MessageBox.Show( From 139b3da4b9cce99948801fbb7fd37265969486de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:05:47 +0700 Subject: [PATCH 158/243] test(scl): round-trip canonical IID and ICD through ARSAS workspace --- .../CanonicalSclReloadValidatorTests.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs diff --git a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs new file mode 100644 index 000000000..7c5b1580b --- /dev/null +++ b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs @@ -0,0 +1,123 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl; +using AR.Iec61850.Scl.Export; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class CanonicalSclReloadValidatorTests +{ + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift( + SclSchemaProfile schema) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-canonical-reload-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine( + root, + schema == SclSchemaProfile.Edition2V31 ? "relay.iid" : "relay.icd"); + + try + { + var canonical = CreateCanonical(); + var result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + path, + schema, + profile: "safe-connection"); + + var workspace = CanonicalSclReloadValidator.Validate( + new SclWorkspaceService(), + canonical, + result); + + Assert.Equal("IED", workspace.IedName); + Assert.Equal("AP1", workspace.AccessPointName); + Assert.NotNull(workspace.PreferredEndpoint); + Assert.Equal("10.20.30.40", workspace.PreferredEndpoint!.IpAddress); + Assert.Equal(102, workspace.PreferredEndpoint.Port); + Assert.Equal(result.LogicalDeviceCount, workspace.DesignModel.Coverage.LogicalDeviceCount); + Assert.Equal(result.LogicalNodeCount, workspace.DesignModel.Coverage.LogicalNodeCount); + Assert.Equal(result.DataSetCount, workspace.DataSets.Count); + Assert.Equal(result.ReportControlCount, workspace.ReportControls.Count); + Assert.Equal(1, result.LogicalDeviceCount); + Assert.Equal(1, result.LogicalNodeCount); + Assert.Equal(1, result.ReportControlCount); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static LiveIedCanonicalModel CreateCanonical() + => new() + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = "10.20.30.40", + Port = 102, + IedName = "IED", + AccessPointName = "AP1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "IEDLD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", + LnClass = "LLN0", + LnInst = string.Empty, + ProposedLnTypeId = "LN_LLN0_1" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "IEDLD0/LLN0$BR$BRCB01", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = "BRCB01", + Buffered = true, + Indexed = false, + ReportId = "RID_BRCB01", + ConfRev = "1", + TriggerOptions = "dchg,qchg,gi", + OptionalFields = "seqnum,timestamp,dataset,dataref", + BufferTimeMs = "10", + IntegrityPeriodMs = "1000" + } + ] + }, + Communication = new LiveIedCommunicationEvidence + { + Source = "AcceptedAssociationWireProfile", + AssociationProfileName = "BalancedApTitle", + Host = "10.20.30.40", + Port = 102, + AccessPointName = "AP1", + Association = new SclIsoAssociationAddress + { + ApTitle = "1,1,1,999,1", + AeQualifierText = "12", + AeQualifier = 12, + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + } + } + }; +} From fbef3e0aeeffddbe93c58a41f8e618dbda1c1a93 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:06:00 +0700 Subject: [PATCH 159/243] test(scl): guard pure canonical reload service integration --- .../CanonicalLiveSclExportRegressionTests.cs | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 9dcf67059..aa630bc11 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -70,44 +70,39 @@ public void LiveSave_UsesCanonicalExporterAndFailsClosedOnStaleOrMissingEvidence } [Fact] - public void LiveSave_ReopensGeneratedSclThroughWorkspaceParserBeforeSuccess() + public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() { var source = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs")); - var helperStart = source.IndexOf( - "private SclIedWorkspace ValidateCanonicalSclWorkspaceReload(", + var validator = File.ReadAllText(FindRepoFile("Services/CanonicalSclReloadValidator.cs")); + var saveMethodStart = source.IndexOf( + "private void SaveTypedModelAsScl(", StringComparison.Ordinal); var successStart = source.IndexOf( "private void ShowSclSaveSuccess(", + saveMethodStart, StringComparison.Ordinal); - Assert.True(helperStart >= 0); - Assert.True(successStart > helperStart); - - var helper = source[helperStart..successStart]; - Assert.Contains("_sclWorkspaceService.Open(", helper, StringComparison.Ordinal); - Assert.Contains("IedName = canonical.IedName", helper, StringComparison.Ordinal); - Assert.Contains("AccessPointName = canonical.AccessPointName", helper, StringComparison.Ordinal); - Assert.Contains("endpoint.Port != 102", helper, StringComparison.Ordinal); - Assert.Contains("reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount", helper, StringComparison.Ordinal); - Assert.Contains("reloadCoverage.LogicalNodeCount != result.LogicalNodeCount", helper, StringComparison.Ordinal); - Assert.Contains("workspace.DataSets.Count != result.DataSetCount", helper, StringComparison.Ordinal); - Assert.Contains("workspace.ReportControls.Count != result.ReportControlCount", helper, StringComparison.Ordinal); - Assert.Contains("File.Delete(result.SclPath)", helper, StringComparison.Ordinal); + Assert.True(saveMethodStart >= 0 && successStart > saveMethodStart); + var saveMethod = source[saveMethodStart..successStart]; - var saveMethodStart = source.IndexOf( - "private void SaveTypedModelAsScl(", - StringComparison.Ordinal); - Assert.True(saveMethodStart >= 0); - var saveMethod = source[saveMethodStart..helperStart]; var exportIndex = saveMethod.IndexOf("CanonicalLiveIedSclExporter.WriteFiles", StringComparison.Ordinal); - var reloadIndex = saveMethod.IndexOf("ValidateCanonicalSclWorkspaceReload(canonical, result)", StringComparison.Ordinal); + var reloadIndex = saveMethod.IndexOf("CanonicalSclReloadValidator.Validate", StringComparison.Ordinal); Assert.True(exportIndex >= 0 && reloadIndex > exportIndex); + Assert.Contains("File.Delete(result.SclPath)", saveMethod, StringComparison.Ordinal); Assert.Contains("reloadLD=", saveMethod, StringComparison.Ordinal); Assert.Contains("reloadLN=", saveMethod, StringComparison.Ordinal); Assert.Contains("reloadDataSet=", saveMethod, StringComparison.Ordinal); Assert.Contains("reloadRCB=", saveMethod, StringComparison.Ordinal); - } + Assert.Contains("workspaceService.Open(", validator, StringComparison.Ordinal); + Assert.Contains("IedName = canonical.IedName", validator, StringComparison.Ordinal); + Assert.Contains("AccessPointName = canonical.AccessPointName", validator, StringComparison.Ordinal); + Assert.Contains("endpoint.Port != 102", validator, StringComparison.Ordinal); + Assert.Contains("reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount", validator, StringComparison.Ordinal); + Assert.Contains("reloadCoverage.LogicalNodeCount != result.LogicalNodeCount", validator, StringComparison.Ordinal); + Assert.Contains("workspace.DataSets.Count != result.DataSetCount", validator, StringComparison.Ordinal); + Assert.Contains("workspace.ReportControls.Count != result.ReportControlCount", validator, StringComparison.Ordinal); + } [Fact] public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() From a6801be6ef94f9b8cb671866ed3e4505b5aec477 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:08:20 +0700 Subject: [PATCH 160/243] test(discovery): separate physical baseline from R7 integration pin --- ...yAssociationSingleFlightRegressionTests.cs | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs index c358e93f3..7e5c5c443 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs @@ -43,15 +43,34 @@ public void P05c_ReconnectAndDispose_InvalidateGenerationAndBlockStalePublish() } [Fact] - public void P05c_EnginePin_IsExactP05bBudgetConvergenceCommit() + public void P05c_PhysicalBaseline_RemainsExactP05bBudgetConvergenceCommit() { - var lockPath = FindRepoFile("engines/ARIEC61850.lock.json"); - using var document = JsonDocument.Parse(File.ReadAllText(lockPath)); - var commit = document.RootElement.GetProperty("commit").GetString(); - var workflow = File.ReadAllText(FindRepoFile(".github/workflows/smart-discovery-capture-build.yml")); + using var lockDocument = JsonDocument.Parse( + File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json"))); + using var targetDocument = JsonDocument.Parse( + File.ReadAllText(FindRepoFile("evidence/smart-discovery-golden-target.json"))); + var workflow = File.ReadAllText( + FindRepoFile(".github/workflows/smart-discovery-capture-build.yml")); - Assert.Equal(P05bEngineCommit, commit); - Assert.Contains(P05bEngineCommit, workflow, StringComparison.OrdinalIgnoreCase); + var physicalTargetCommit = targetDocument.RootElement + .GetProperty("EngineCommit") + .GetString(); + var previousTrialPin = lockDocument.RootElement + .GetProperty("previousTrialPin") + .GetProperty("commit") + .GetString(); + var currentIntegrationCommit = lockDocument.RootElement + .GetProperty("commit") + .GetString(); + + Assert.Equal(P05bEngineCommit, physicalTargetCommit); + Assert.Equal(P05bEngineCommit, previousTrialPin); + Assert.NotEqual(P05bEngineCommit, currentIntegrationCommit); + Assert.Contains( + $"if ($lock.commit -ne '{P05bEngineCommit}')", + workflow, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("Unexpected engine commit", workflow, StringComparison.Ordinal); Assert.Contains("LastSmartTypeProbeBudget", workflow, StringComparison.Ordinal); Assert.Contains("SuppressedExactRepeatRequests", workflow, StringComparison.Ordinal); } From 73e5f7b5a8ae78dac0ba002ff53db568e5928b72 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:09:11 +0700 Subject: [PATCH 161/243] fix(scl): clean all artifacts when workspace reload fails --- MainWindow.xaml.cs | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index d112adc03..4d6215d9c 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1660,16 +1660,7 @@ private void SaveTypedModelAsScl( } catch { - try - { - if (File.Exists(result.SclPath)) - File.Delete(result.SclPath); - } - catch - { - // Preserve the original reload-validation failure. - } - + DeleteFailedCanonicalExportArtifacts(result); throw; } canonicalExportEvidence = canonical; @@ -1722,6 +1713,28 @@ private void SaveTypedModelAsScl( ShowSclSaveSuccess(result.SclSchema, result.SclPath, result.ReportPath, result.SummaryPath); } + private static void DeleteFailedCanonicalExportArtifacts(LiveIedSclExportResult result) + { + foreach (var path in new[] + { + result.SclPath, + result.ReportPath, + result.SummaryPath, + result.ExcludedAttributesPath + }) + { + try + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + File.Delete(path); + } + catch + { + // Preserve the original reload-validation failure. + } + } + } + private void ShowSclSaveSuccess(string schema, string sclPath, string reportPath, string summaryPath) { MessageBox.Show( From 989ade6790fb7235dc2ff86dfe49913d65124836 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:09:23 +0700 Subject: [PATCH 162/243] test(scl): guard fail-closed companion artifact cleanup --- .../CanonicalLiveSclExportRegressionTests.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index aa630bc11..adffe3396 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -88,12 +88,23 @@ public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() var exportIndex = saveMethod.IndexOf("CanonicalLiveIedSclExporter.WriteFiles", StringComparison.Ordinal); var reloadIndex = saveMethod.IndexOf("CanonicalSclReloadValidator.Validate", StringComparison.Ordinal); Assert.True(exportIndex >= 0 && reloadIndex > exportIndex); - Assert.Contains("File.Delete(result.SclPath)", saveMethod, StringComparison.Ordinal); + Assert.Contains("DeleteFailedCanonicalExportArtifacts(result)", saveMethod, StringComparison.Ordinal); Assert.Contains("reloadLD=", saveMethod, StringComparison.Ordinal); Assert.Contains("reloadLN=", saveMethod, StringComparison.Ordinal); Assert.Contains("reloadDataSet=", saveMethod, StringComparison.Ordinal); Assert.Contains("reloadRCB=", saveMethod, StringComparison.Ordinal); + var cleanupStart = source.IndexOf( + "private static void DeleteFailedCanonicalExportArtifacts(", + StringComparison.Ordinal); + Assert.True(cleanupStart >= 0); + var cleanup = source[cleanupStart..successStart]; + Assert.Contains("result.SclPath", cleanup, StringComparison.Ordinal); + Assert.Contains("result.ReportPath", cleanup, StringComparison.Ordinal); + Assert.Contains("result.SummaryPath", cleanup, StringComparison.Ordinal); + Assert.Contains("result.ExcludedAttributesPath", cleanup, StringComparison.Ordinal); + Assert.Contains("File.Delete(path)", cleanup, StringComparison.Ordinal); + Assert.Contains("workspaceService.Open(", validator, StringComparison.Ordinal); Assert.Contains("IedName = canonical.IedName", validator, StringComparison.Ordinal); Assert.Contains("AccessPointName = canonical.AccessPointName", validator, StringComparison.Ordinal); From 1b3bd527f8ed93aa32adfbf8e28163024308d6d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:10:35 +0700 Subject: [PATCH 163/243] test(scl): round-trip golden 34-to-32 RCB shape through ARSAS --- .../CanonicalSclReloadValidatorTests.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs index 7c5b1580b..aa9ebfa0e 100644 --- a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs +++ b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs @@ -1,3 +1,4 @@ +using System.Xml.Linq; using AR.Iec61850.Discovery; using AR.Iec61850.Scl; using AR.Iec61850.Scl.Export; @@ -56,6 +57,112 @@ public void ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDr } } + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity( + SclSchemaProfile schema) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-canonical-rcb-reload-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine( + root, + schema == SclSchemaProfile.Edition2V31 ? "relay-rcb.iid" : "relay-rcb.icd"); + + try + { + var canonical = CreateGoldenRcbCanonical(); + Assert.Equal(34, canonical.Discovery.ReportControls.Count); + + var result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + path, + schema, + profile: "safe-connection"); + var workspace = CanonicalSclReloadValidator.Validate( + new SclWorkspaceService(), + canonical, + result); + + Assert.Equal(32, result.ReportControlCount); + Assert.Equal(32, workspace.ReportControls.Count); + + var document = XDocument.Load(result.SclPath); + var ns = document.Root!.Name.Namespace; + var conf = document.Descendants(ns + "ConfReportControl").Single(); + Assert.Equal("34", (string?)conf.Attribute("max")); + + var buffer = document.Descendants(ns + "ReportControl") + .Single(element => (string?)element.Attribute("name") == "Buffer"); + var unbuffer = document.Descendants(ns + "ReportControl") + .Single(element => (string?)element.Attribute("name") == "Unbuffer"); + Assert.Equal("true", (string?)buffer.Attribute("indexed")); + Assert.Equal("2", (string?)buffer.Element(ns + "RptEnabled")?.Attribute("max")); + Assert.Equal("true", (string?)unbuffer.Attribute("indexed")); + Assert.Equal("2", (string?)unbuffer.Element(ns + "RptEnabled")?.Attribute("max")); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static LiveIedCanonicalModel CreateGoldenRcbCanonical() + { + var baseline = CreateCanonical(); + var controls = Enumerable.Range(1, 30) + .Select(index => RuntimeControl( + $"Standalone_{index}_X", + buffered: false, + reportId: $"RID_{index}_X")) + .Concat( + [ + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01"), + RuntimeControl("Buffer02", buffered: true, reportId: "RID_Buffer02"), + RuntimeControl("Unbuffer01", buffered: false, reportId: "RID_Unbuffer01"), + RuntimeControl("Unbuffer02", buffered: false, reportId: "RID_Unbuffer02") + ]) + .ToArray(); + + return new LiveIedCanonicalModel + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = baseline.Discovery.Host, + Port = baseline.Discovery.Port, + IedName = baseline.Discovery.IedName, + AccessPointName = baseline.Discovery.AccessPointName, + LogicalDevices = baseline.Discovery.LogicalDevices, + ReportControls = controls + }, + Communication = baseline.Communication + }; + } + + private static LiveIedReportControlModel RuntimeControl( + string name, + bool buffered, + string reportId) + => new() + { + Reference = $"IEDLD0/LLN0${(buffered ? "BR" : "RP")}${name}", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = name, + Buffered = buffered, + DataSetReference = string.Empty, + ReportId = reportId, + ConfRev = "1", + TriggerOptions = "dchg,qchg,gi", + OptionalFields = "seqnum,timestamp,dataset,dataref", + BufferTimeMs = buffered ? "10" : "0", + IntegrityPeriodMs = "1000" + }; + + private static LiveIedCanonicalModel CreateCanonical() => new() { From 5bd21270b4dbeea609f45ca432d2db09187523a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:19:48 +0700 Subject: [PATCH 164/243] ci(scl): bind R7 artifact to exact ARSAS source SHA --- .github/workflows/scl-interoperability-r7.yml | 189 +++++++++++++++++- 1 file changed, 186 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index f7f0ce044..9482621c0 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -8,8 +8,10 @@ on: - "Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs" - "Models/MonitorModels.cs" - "MainWindow.xaml.cs" + - "Services/CanonicalSclReloadValidator.cs" - "engines/ARIEC61850.lock.json" - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" + - "tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs" - ".github/workflows/scl-interoperability-r7.yml" workflow_dispatch: @@ -19,12 +21,193 @@ jobs: runs-on: windows-latest steps: - - name: Checkout ARSAS source + - name: Checkout exact ARSAS source revision shell: pwsh + env: + ARSAS_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | - $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } - git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS + if ($env:ARSAS_SOURCE_SHA -notmatch '^[0-9a-f]{40} + - name: Resolve isolated R7 engine pin + shell: pwsh + run: | + $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + if ($lock.repository -ne 'masarray/ARIEC61850' -or + $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid ARIEC61850 R7 lock.' + } + if ($lock.purpose -notmatch 'R7 interoperability trial pin') { + throw 'Engine lock is not explicitly scoped as the R7 interoperability trial.' + } + if ([int]$lock.sourcePullRequest -notin @(134, 135)) { + throw "Unexpected R7 engine source PR: $($lock.sourcePullRequest)" + } + + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_SOURCE_PR=$($lock.sourcePullRequest)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + $version = (Get-Content .\ARSAS\VERSION -Raw).Trim() + "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout exact ARIEC61850 engine revision + shell: pwsh + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT + git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { + throw "Engine SHA mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + } + + - name: Verify canonical SCL interoperability contracts + shell: pwsh + run: | + $canonical = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw + $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw + $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw + $cotpClient = Get-Content .\ARIEC61850\src\AR.Iec61850\Osi\CotpClient.cs -Raw + $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw + $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw + $save = Get-Content .\ARSAS\MainWindow.xaml.cs -Raw + $reloadValidator = Get-Content .\ARSAS\Services\CanonicalSclReloadValidator.cs -Raw + $regression = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalLiveSclExportRegressionTests.cs -Raw + $reloadTests = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalSclReloadValidatorTests.cs -Raw + + foreach ($required in @( + 'class LiveIedCanonicalModel', + 'InstanceValues', + 'LiveIedCommunicationEvidence')) { + if ($canonical -notmatch [regex]::Escape($required)) { + throw "Canonical engine contract missing: $required" + } + } + + foreach ($required in @( + 'ValidateCanonicalCommunication', + 'PreserveRuntimeServiceCapacity', + 'ApplyCanonicalInstanceValues', + 'ValidateRoundTripAssociation')) { + if ($exporter -notmatch [regex]::Escape($required)) { + throw "Canonical SCL exporter contract missing: $required" + } + } + + if ($association -notmatch 'GetAcceptedCommunicationEvidence') { + throw 'Accepted association evidence API is missing.' + } + + if ($env:ARIEC61850_SOURCE_PR -eq '135') { + $wireReader = '.\ARIEC61850\src\AR.Iec61850\Acse\AcseAssociationRequestIdentityReader.cs' + if (!(Test-Path $wireReader) -or + $association -notmatch 'AcseAssociationRequestIdentityReader\.Read' -or + $association -notmatch '_cotp\.LastConnectParameters\?\.DestinationTsap' -or + $cotpClient -notmatch 'public CotpConnectParameters\? LastConnectParameters' -or + $cotpClient -notmatch 'LastConnectParameters = acceptedParameters' -or + $association -match 'ApTitle\s*=\s*"1,1,1,999,1"') { + throw 'PR #135 must derive canonical AP/AE/selectors from exact accepted ACSE and COTP session evidence, not duplicated profile constants.' + } + } + + if ($consumer -notmatch 'LiveIedCanonicalModelBuilder\.Build\(model, communication, initialRead\)' -or + $capture -notmatch 'InitialFcReadPlanner\.FromSclModel' -or + $capture -notmatch 'ExecuteInitialFcReadPlanSmartAsync' -or + $lifecycle -notmatch 'PublishCanonicalModel\(model, initialRead\)' -or + $save -notmatch 'CanonicalLiveIedSclExporter\.WriteFiles' -or + $save -notmatch 'CanonicalSclReloadValidator\.Validate' -or + $reloadValidator -notmatch 'workspaceService\.Open' -or + $reloadValidator -notmatch 'workspace\.ReportControls\.Count != result\.ReportControlCount' -or + $regression -notmatch 'SmartDiscovery_CapturesInitialFcEvidenceIntoSameCanonicalSnapshot' -or + $reloadTests -notmatch 'ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift' -or + $reloadTests -notmatch 'GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity' -or + $reloadTests -notmatch 'ConfReportControl') { + throw 'ARSAS canonical discovery -> instance evidence -> SCL export -> workspace reload contract is incomplete.' + } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore ARSAS solution + run: dotnet restore .\ARSAS\ArIED61850Tester.sln + + - name: Build ARSAS Release + run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + + - name: Run ARSAS canonical-export regressions + run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults + + - name: Build engine and run engine tests + shell: pwsh + run: | + dotnet restore .\ARIEC61850\ARIEC61850.sln + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test .\ARIEC61850\ARIEC61850.sln -c Release --no-build --no-restore --logger "trx;LogFileName=ariec61850-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Publish R7 portable single EXE + shell: pwsh + run: | + .\ARSAS\scripts\publish-windows-portable.ps1 ` + -Version $env:ARSAS_VERSION ` + -Runtime win-x64 ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + + - name: Smoke test R7 portable executable + shell: pwsh + run: | + $exe = ".\ARSAS\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" + if (!(Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing: $exe" } + + $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-r7-scl-bundle-cache' + $process = Start-Process -FilePath $exe -ArgumentList @('--portable-smoke-test') -PassThru + if (-not $process.WaitForExit(30000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw 'Portable EXE smoke test timed out.' + } + if ($process.ExitCode -ne 0) { + throw "Portable EXE smoke test failed: $($process.ExitCode)" + } + + @( + 'ARSAS R7 SCL interoperability field-test build', + "ARSAS commit: $env:ARSAS_COMMIT", + "ARIEC61850 commit: $env:ARIEC61850_COMMIT", + "Engine source PR: $env:ARIEC61850_SOURCE_PR", + '', + 'Acceptance target:', + 'IED -> Smart Discovery -> Canonical IED Model -> Save SCL Ed1/Ed2 -> ARSAS Workspace Reload -> exact association plan -> MMS association.', + 'CI reload invariant: identity, endpoint, LD/LN, DataSet and logical RCB counts survive ARSAS workspace reload; golden RCB shape is 34 runtime -> 32 logical with ConfReportControl max=34.', + '', + 'This artifact is NOT production-promotion authority and does NOT replace P0-5e/P0-5f physical discovery-budget evidence.' + ) | Set-Content .\ARSAS\dist\R7-SCL-INTEROP-BUILD.txt -Encoding utf8 + + - name: Upload R7 SCL interoperability build + uses: actions/upload-artifact@v4 + with: + name: ARSAS-r7-scl-interoperability-win-x64 + path: | + ARSAS\dist\ARSAS-*-win-x64-portable.exe + ARSAS\dist\R7-SCL-INTEROP-BUILD.txt + ARSAS\TestResults\*.trx + if-no-files-found: error + retention-days: 14 +) { + throw "Invalid ARSAS source SHA: $env:ARSAS_SOURCE_SHA" + } + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS + git -C .\ARSAS fetch --quiet --depth 1 origin $env:ARSAS_SOURCE_SHA + git -C .\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA $arsasCommit = (git -C .\ARSAS rev-parse HEAD).Trim() + if ($arsasCommit -ne $env:ARSAS_SOURCE_SHA) { + throw "ARSAS SHA mismatch. Expected $env:ARSAS_SOURCE_SHA, got $arsasCommit." + } "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - name: Resolve isolated R7 engine pin From 12db7ff9c456ff65ea231f81cbd2650fa9507a3a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:20:10 +0700 Subject: [PATCH 165/243] fix(ci): repair exact-SHA R7 checkout block --- .github/workflows/scl-interoperability-r7.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 9482621c0..10f6ad52f 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -26,7 +26,18 @@ jobs: env: ARSAS_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | - if ($env:ARSAS_SOURCE_SHA -notmatch '^[0-9a-f]{40} + if ($env:ARSAS_SOURCE_SHA -notmatch '^[0-9a-f]{40}$') { + throw "Invalid ARSAS source SHA: $env:ARSAS_SOURCE_SHA" + } + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS + git -C .\ARSAS fetch --quiet --depth 1 origin $env:ARSAS_SOURCE_SHA + git -C .\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA + $arsasCommit = (git -C .\ARSAS rev-parse HEAD).Trim() + if ($arsasCommit -ne $env:ARSAS_SOURCE_SHA) { + throw "ARSAS SHA mismatch. Expected $env:ARSAS_SOURCE_SHA, got $arsasCommit." + } + "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + - name: Resolve isolated R7 engine pin shell: pwsh run: | From 7873ca6d55bad04c5129d40bbb222b508f748f3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:20:50 +0700 Subject: [PATCH 166/243] ci(scl): rebuild exact-SHA R7 workflow cleanly --- .github/workflows/scl-interoperability-r7.yml | 174 ------------------ 1 file changed, 174 deletions(-) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 10f6ad52f..6e8441372 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -209,177 +209,3 @@ jobs: ARSAS\TestResults\*.trx if-no-files-found: error retention-days: 14 -) { - throw "Invalid ARSAS source SHA: $env:ARSAS_SOURCE_SHA" - } - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS - git -C .\ARSAS fetch --quiet --depth 1 origin $env:ARSAS_SOURCE_SHA - git -C .\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA - $arsasCommit = (git -C .\ARSAS rev-parse HEAD).Trim() - if ($arsasCommit -ne $env:ARSAS_SOURCE_SHA) { - throw "ARSAS SHA mismatch. Expected $env:ARSAS_SOURCE_SHA, got $arsasCommit." - } - "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Resolve isolated R7 engine pin - shell: pwsh - run: | - $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json - if ($lock.repository -ne 'masarray/ARIEC61850' -or - $lock.commit -notmatch '^[0-9a-f]{40}$') { - throw 'Invalid ARIEC61850 R7 lock.' - } - if ($lock.purpose -notmatch 'R7 interoperability trial pin') { - throw 'Engine lock is not explicitly scoped as the R7 interoperability trial.' - } - if ([int]$lock.sourcePullRequest -notin @(134, 135)) { - throw "Unexpected R7 engine source PR: $($lock.sourcePullRequest)" - } - - "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_SOURCE_PR=$($lock.sourcePullRequest)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - $version = (Get-Content .\ARSAS\VERSION -Raw).Trim() - "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Checkout exact ARIEC61850 engine revision - shell: pwsh - run: | - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 - git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT - git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT - $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() - if ($actual -ne $env:ARIEC61850_COMMIT) { - throw "Engine SHA mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." - } - - - name: Verify canonical SCL interoperability contracts - shell: pwsh - run: | - $canonical = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw - $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw - $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw - $cotpClient = Get-Content .\ARIEC61850\src\AR.Iec61850\Osi\CotpClient.cs -Raw - $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw - $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw - $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw - $save = Get-Content .\ARSAS\MainWindow.xaml.cs -Raw - $regression = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalLiveSclExportRegressionTests.cs -Raw - - foreach ($required in @( - 'class LiveIedCanonicalModel', - 'InstanceValues', - 'LiveIedCommunicationEvidence')) { - if ($canonical -notmatch [regex]::Escape($required)) { - throw "Canonical engine contract missing: $required" - } - } - - foreach ($required in @( - 'ValidateCanonicalCommunication', - 'PreserveRuntimeServiceCapacity', - 'ApplyCanonicalInstanceValues', - 'ValidateRoundTripAssociation')) { - if ($exporter -notmatch [regex]::Escape($required)) { - throw "Canonical SCL exporter contract missing: $required" - } - } - - if ($association -notmatch 'GetAcceptedCommunicationEvidence') { - throw 'Accepted association evidence API is missing.' - } - - if ($env:ARIEC61850_SOURCE_PR -eq '135') { - $wireReader = '.\ARIEC61850\src\AR.Iec61850\Acse\AcseAssociationRequestIdentityReader.cs' - if (!(Test-Path $wireReader) -or - $association -notmatch 'AcseAssociationRequestIdentityReader\.Read' -or - $association -notmatch '_cotp\.LastConnectParameters\?\.DestinationTsap' -or - $cotpClient -notmatch 'public CotpConnectParameters\? LastConnectParameters' -or - $cotpClient -notmatch 'LastConnectParameters = acceptedParameters' -or - $association -match 'ApTitle\s*=\s*"1,1,1,999,1"') { - throw 'PR #135 must derive canonical AP/AE/selectors from exact accepted ACSE and COTP session evidence, not duplicated profile constants.' - } - } - - if ($consumer -notmatch 'LiveIedCanonicalModelBuilder\.Build\(model, communication, initialRead\)' -or - $capture -notmatch 'InitialFcReadPlanner\.FromSclModel' -or - $capture -notmatch 'ExecuteInitialFcReadPlanSmartAsync' -or - $lifecycle -notmatch 'PublishCanonicalModel\(model, initialRead\)' -or - $save -notmatch 'CanonicalLiveIedSclExporter\.WriteFiles' -or - $regression -notmatch 'SmartDiscovery_CapturesInitialFcEvidenceIntoSameCanonicalSnapshot') { - throw 'ARSAS canonical discovery -> instance evidence -> SCL export contract is incomplete.' - } - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore ARSAS solution - run: dotnet restore .\ARSAS\ArIED61850Tester.sln - - - name: Build ARSAS Release - run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore - - - name: Run ARSAS canonical-export regressions - run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults - - - name: Build engine and run engine tests - shell: pwsh - run: | - dotnet restore .\ARIEC61850\ARIEC61850.sln - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - dotnet test .\ARIEC61850\ARIEC61850.sln -c Release --no-build --no-restore --logger "trx;LogFileName=ariec61850-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Publish R7 portable single EXE - shell: pwsh - run: | - .\ARSAS\scripts\publish-windows-portable.ps1 ` - -Version $env:ARSAS_VERSION ` - -Runtime win-x64 ` - -SingleFile $true ` - -SelfContained $true ` - -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` - -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" - - - name: Smoke test R7 portable executable - shell: pwsh - run: | - $exe = ".\ARSAS\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" - if (!(Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing: $exe" } - - $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-r7-scl-bundle-cache' - $process = Start-Process -FilePath $exe -ArgumentList @('--portable-smoke-test') -PassThru - if (-not $process.WaitForExit(30000)) { - Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue - throw 'Portable EXE smoke test timed out.' - } - if ($process.ExitCode -ne 0) { - throw "Portable EXE smoke test failed: $($process.ExitCode)" - } - - @( - 'ARSAS R7 SCL interoperability field-test build', - "ARSAS commit: $env:ARSAS_COMMIT", - "ARIEC61850 commit: $env:ARIEC61850_COMMIT", - "Engine source PR: $env:ARIEC61850_SOURCE_PR", - '', - 'Acceptance target:', - 'IED -> Smart Discovery -> Canonical IED Model -> Save SCL Ed1/Ed2 -> Reload -> exact association plan -> MMS association.', - '', - 'This artifact is NOT production-promotion authority and does NOT replace P0-5e/P0-5f physical discovery-budget evidence.' - ) | Set-Content .\ARSAS\dist\R7-SCL-INTEROP-BUILD.txt -Encoding utf8 - - - name: Upload R7 SCL interoperability build - uses: actions/upload-artifact@v4 - with: - name: ARSAS-r7-scl-interoperability-win-x64 - path: | - ARSAS\dist\ARSAS-*-win-x64-portable.exe - ARSAS\dist\R7-SCL-INTEROP-BUILD.txt - ARSAS\TestResults\*.trx - if-no-files-found: error - retention-days: 14 From 0ae7138c234a45ffc8484247ae8f01e50b4ccf4a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:21:09 +0700 Subject: [PATCH 167/243] test(scl): lock exact-SHA R7 workflow provenance --- .../CanonicalLiveSclExportRegressionTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index adffe3396..662bfa268 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -115,6 +115,56 @@ public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() Assert.Contains("workspace.ReportControls.Count != result.ReportControlCount", validator, StringComparison.Ordinal); } + [Fact] + public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() + { + var workflow = File.ReadAllText( + FindRepoFile(".github/workflows/scl-interoperability-r7.yml")); + + Assert.Contains( + "ARSAS_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "git -C .\\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "if ($arsasCommit -ne $env:ARSAS_SOURCE_SHA)", + workflow, + StringComparison.Ordinal); + Assert.DoesNotContain( + "git clone --quiet --depth 1 --branch $ref", + workflow, + StringComparison.Ordinal); + + Assert.Contains( + "Services/CanonicalSclReloadValidator.cs", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "CanonicalSclReloadValidator\\.Validate", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "34 runtime -> 32 logical with ConfReportControl max=34", + workflow, + StringComparison.Ordinal); + } + + [Fact] public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() { From 3488c3b7faa72403133bfd9062ff8a006b0f457f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:23:59 +0700 Subject: [PATCH 168/243] ci(scl): cancel stale R7 runs in favor of latest head --- .github/workflows/scl-interoperability-r7.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 6e8441372..0421d5a96 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -15,6 +15,10 @@ on: - ".github/workflows/scl-interoperability-r7.yml" workflow_dispatch: +concurrency: + group: scl-interoperability-r7-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-r7-scl-interoperability: name: Build canonical discovery to interoperable SCL test package From 008b00ed49e8df27c287ae8c34211ed69326d2f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:26:02 +0700 Subject: [PATCH 169/243] test(scl): prove generated IID and ICD rebuild exact association plan --- .../CanonicalSclReloadValidatorTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs index aa9ebfa0e..4acb98ab5 100644 --- a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs +++ b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs @@ -57,6 +57,63 @@ public void ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDr } } + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void ExportedCanonicalScl_PreparesExactSclAssistedAssociationPlan( + SclSchemaProfile schema) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-canonical-association-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine( + root, + schema == SclSchemaProfile.Edition2V31 ? "relay-association.iid" : "relay-association.icd"); + + try + { + var canonical = CreateCanonical(); + var result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + path, + schema, + profile: "safe-connection"); + + var preparation = SclAssistedConnectionPreparationBuilder.Build( + File.ReadAllText(result.SclPath), + canonical.IedName, + canonical.AccessPointName, + canonical.Communication.Host, + canonical.Communication.Port); + + Assert.True( + preparation.IsSuccess, + string.Join(" | ", preparation.Errors)); + var plan = Assert.IsType( + preparation.AssociationPlan); + + Assert.Equal(canonical.IedName, plan.IedName); + Assert.Equal(canonical.AccessPointName, plan.AccessPointName); + Assert.Equal(canonical.Communication.Host, plan.Host); + Assert.Equal(102, plan.Port); + + Assert.Equal("0001", Convert.ToHexString(plan.Cotp.DestinationTsap)); + Assert.Equal("00000001", Convert.ToHexString(plan.Association.Called.PresentationSelector)); + Assert.Equal("0001", Convert.ToHexString(plan.Association.Called.SessionSelector)); + Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, plan.Association.Called.ApTitle); + Assert.Equal(12, plan.Association.Called.AeQualifier); + Assert.NotEmpty(plan.CotpConnectRequest); + Assert.NotEmpty(plan.SessionPresentationAcseMmsRequest); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Theory] [InlineData(SclSchemaProfile.Edition2V31)] [InlineData(SclSchemaProfile.Edition1V16)] From 1b8e8716befa1ec27339d719cbbb33b2d3fcc9a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:26:38 +0700 Subject: [PATCH 170/243] fix(scl): require reconnect preparation before save success --- Services/CanonicalSclReloadValidator.cs | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Services/CanonicalSclReloadValidator.cs b/Services/CanonicalSclReloadValidator.cs index 609aba38e..38549e53c 100644 --- a/Services/CanonicalSclReloadValidator.cs +++ b/Services/CanonicalSclReloadValidator.cs @@ -66,6 +66,33 @@ public static SclIedWorkspace Validate( string.Join(", ", mismatches) + "."); } + var preparation = SclAssistedConnectionPreparationBuilder.Build( + File.ReadAllText(result.SclPath), + canonical.IedName, + canonical.AccessPointName, + canonical.Communication.Host, + canonical.Communication.Port); + if (!preparation.IsSuccess || preparation.AssociationPlan is null) + { + var detail = preparation.Errors.Count == 0 + ? "unknown SCL-assisted preparation failure" + : string.Join(" | ", preparation.Errors); + throw new InvalidOperationException( + "Generated SCL cannot rebuild the ARSAS SCL-assisted reconnect plan: " + detail); + } + + var plan = preparation.AssociationPlan; + if (!string.Equals(plan.IedName, canonical.IedName, StringComparison.Ordinal) || + !string.Equals(plan.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal) || + !string.Equals(plan.Host, canonical.Communication.Host, StringComparison.OrdinalIgnoreCase) || + plan.Port != canonical.Communication.Port) + { + throw new InvalidOperationException( + $"Generated SCL reconnect plan identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}' " + + $"at {canonical.Communication.Host}:{canonical.Communication.Port}, rebuilt " + + $"'{plan.IedName}/{plan.AccessPointName}' at {plan.Host}:{plan.Port}."); + } + return workspace; } } From 483344fb6020e95d6235ebc2f19a868d554e3e56 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:26:51 +0700 Subject: [PATCH 171/243] test(scl): guard reconnect preparation in save validation --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 662bfa268..8717f8940 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -113,6 +113,10 @@ public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() Assert.Contains("reloadCoverage.LogicalNodeCount != result.LogicalNodeCount", validator, StringComparison.Ordinal); Assert.Contains("workspace.DataSets.Count != result.DataSetCount", validator, StringComparison.Ordinal); Assert.Contains("workspace.ReportControls.Count != result.ReportControlCount", validator, StringComparison.Ordinal); + Assert.Contains("SclAssistedConnectionPreparationBuilder.Build(", validator, StringComparison.Ordinal); + Assert.Contains("preparation.IsSuccess", validator, StringComparison.Ordinal); + Assert.Contains("preparation.AssociationPlan", validator, StringComparison.Ordinal); + Assert.Contains("Generated SCL cannot rebuild the ARSAS SCL-assisted reconnect plan", validator, StringComparison.Ordinal); } [Fact] From 2b30303b9712e1b6f21b03b9c0cd96d6746ac340 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:27:46 +0700 Subject: [PATCH 172/243] fix(scl): compare rebuilt reconnect identity to canonical wire evidence --- Services/CanonicalSclReloadValidator.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Services/CanonicalSclReloadValidator.cs b/Services/CanonicalSclReloadValidator.cs index 38549e53c..83eb2f37d 100644 --- a/Services/CanonicalSclReloadValidator.cs +++ b/Services/CanonicalSclReloadValidator.cs @@ -93,6 +93,25 @@ public static SclIedWorkspace Validate( $"'{plan.IedName}/{plan.AccessPointName}' at {plan.Host}:{plan.Port}."); } + var canonicalAssociation = canonical.Communication.Association; + var rebuiltApTitle = string.Join(",", plan.Association.Called.ApTitle); + var rebuiltPsel = Convert.ToHexString(plan.Association.Called.PresentationSelector); + var rebuiltSsel = Convert.ToHexString(plan.Association.Called.SessionSelector); + var rebuiltTsel = Convert.ToHexString(plan.Cotp.DestinationTsap); + if (!string.Equals(rebuiltApTitle, canonicalAssociation.ApTitle, StringComparison.Ordinal) || + plan.Association.Called.AeQualifier != canonicalAssociation.AeQualifier || + !string.Equals(rebuiltPsel, canonicalAssociation.PresentationSelector, StringComparison.OrdinalIgnoreCase) || + !string.Equals(rebuiltSsel, canonicalAssociation.SessionSelector, StringComparison.OrdinalIgnoreCase) || + !string.Equals(rebuiltTsel, canonicalAssociation.TransportSelector, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Generated SCL reconnect association drifted from accepted canonical wire evidence. " + + $"Expected AP={canonicalAssociation.ApTitle}, AE={canonicalAssociation.AeQualifier}, " + + $"PSEL={canonicalAssociation.PresentationSelector}, SSEL={canonicalAssociation.SessionSelector}, " + + $"TSEL={canonicalAssociation.TransportSelector}; rebuilt AP={rebuiltApTitle}, " + + $"AE={plan.Association.Called.AeQualifier}, PSEL={rebuiltPsel}, SSEL={rebuiltSsel}, TSEL={rebuiltTsel}."); + } + return workspace; } } From d251bde09c3d82235128930d7cbee46ed96c6358 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:27:55 +0700 Subject: [PATCH 173/243] test(scl): guard exact rebuilt association comparison --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 8717f8940..16d95181c 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -117,6 +117,12 @@ public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() Assert.Contains("preparation.IsSuccess", validator, StringComparison.Ordinal); Assert.Contains("preparation.AssociationPlan", validator, StringComparison.Ordinal); Assert.Contains("Generated SCL cannot rebuild the ARSAS SCL-assisted reconnect plan", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.ApTitle", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.AeQualifier", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.PresentationSelector", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.SessionSelector", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.TransportSelector", validator, StringComparison.Ordinal); + Assert.Contains("Generated SCL reconnect association drifted from accepted canonical wire evidence", validator, StringComparison.Ordinal); } [Fact] From 31976d5bd816b8801aea61e67b00b811d6c43e70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:28:52 +0700 Subject: [PATCH 174/243] test(scl): reject reconnect association identity drift --- .../CanonicalSclReloadValidatorTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs index 4acb98ab5..e8a06e59c 100644 --- a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs +++ b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs @@ -114,6 +114,61 @@ public void ExportedCanonicalScl_PreparesExactSclAssistedAssociationPlan( } + [Theory] + [InlineData("OSI-AP-Title", "1,1,1,999,2")] + [InlineData("OSI-AE-Qualifier", "13")] + [InlineData("OSI-PSEL", "00000002")] + [InlineData("OSI-SSEL", "0002")] + [InlineData("OSI-TSEL", "0002")] + public void WorkspaceReload_RejectsValidButDifferentAssociationIdentity( + string parameterType, + string replacementValue) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-canonical-association-drift-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "relay-association-drift.iid"); + + try + { + var canonical = CreateCanonical(); + var result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + path, + SclSchemaProfile.Edition2V31, + profile: "safe-connection"); + + var document = XDocument.Load(result.SclPath); + var ns = document.Root!.Name.Namespace; + var parameter = document.Descendants(ns + "P") + .Single(element => + string.Equals( + (string?)element.Attribute("type"), + parameterType, + StringComparison.Ordinal)); + parameter.Value = replacementValue; + document.Save(result.SclPath); + + var error = Assert.Throws(() => + CanonicalSclReloadValidator.Validate( + new SclWorkspaceService(), + canonical, + result)); + + Assert.Contains( + "Generated SCL reconnect association drifted from accepted canonical wire evidence", + error.Message, + StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Theory] [InlineData(SclSchemaProfile.Edition2V31)] [InlineData(SclSchemaProfile.Edition1V16)] From 57e76981dc8958e2222366e4512df5dfc7888e41 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 09:32:37 +0700 Subject: [PATCH 175/243] test(scl): add real FC-root shape to reconnect fixture --- .../CanonicalSclReloadValidatorTests.cs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs index e8a06e59c..edfb3919c 100644 --- a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs +++ b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs @@ -47,7 +47,7 @@ public void ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDr Assert.Equal(result.DataSetCount, workspace.DataSets.Count); Assert.Equal(result.ReportControlCount, workspace.ReportControls.Count); Assert.Equal(1, result.LogicalDeviceCount); - Assert.Equal(1, result.LogicalNodeCount); + Assert.Equal(2, result.LogicalNodeCount); Assert.Equal(1, result.ReportControlCount); } finally @@ -298,6 +298,42 @@ private static LiveIedCanonicalModel CreateCanonical() LnClass = "LLN0", LnInst = string.Empty, ProposedLnTypeId = "LN_LLN0_1" + }, + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + ProposedLnTypeId = "LN_GGIO_1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDLD0/GGIO1.Ind1", + Name = "Ind1", + ProposedDoTypeId = "DO_SPS_Ind1", + InferredCdc = "SPS", + CdcConfidence = 0.99, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.High, + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = "IEDLD0/GGIO1.Ind1.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + MmsReference = "IEDLD0/GGIO1$ST$Ind1$stVal", + MmsItemName = "GGIO1$ST$Ind1$stVal", + Source = "GetNameList", + SclBType = "BOOLEAN", + MmsType = "Boolean", + TypeDiscoveryStatus = "Exact", + TypeSource = "GetVariableAccessAttributes", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] } ] } From eb264804b47961b70818c3f4d9affebd6eed22f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:17:12 +0700 Subject: [PATCH 176/243] fix(scl): reuse proven runtime calling identity on reconnect --- Services/SclAssistedConnectionPreparation.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Services/SclAssistedConnectionPreparation.cs b/Services/SclAssistedConnectionPreparation.cs index e37b849e9..c328b884d 100644 --- a/Services/SclAssistedConnectionPreparation.cs +++ b/Services/SclAssistedConnectionPreparation.cs @@ -122,9 +122,13 @@ public static SclAssistedConnectionPreparation Build( Parameters = sclRemote.Parameters }; + // Reuse the same calling-side identity as the proven native runtime + // association. The previous SclInteroperabilityDefault changed source TSEL + // to 0000 and calling AE qualifier to 23, so a generated SCL could round-trip + // its remote/called identity while still emitting a different wire handshake. var association = ArScl.SclAssistedMmsAssociationPlanBuilder.BuildExact( effectiveRemote, - ArScl.MmsLocalAssociationProfile.SclInteroperabilityDefault); + ArScl.MmsLocalAssociationProfile.ExistingRuntimeDefault); warnings.AddRange(association.Warnings); if (!association.IsSuccess || association.Plan is null) { From 0d1c06ac9e49fb950be71ba1d833b93326ee4be5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:17:20 +0700 Subject: [PATCH 177/243] fix(scl): preserve full discovered model in canonical export --- MainWindow.xaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 4d6215d9c..b2931880f 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1650,7 +1650,7 @@ private void SaveTypedModelAsScl( canonical, outputPath, schema.Profile, - profile: "safe-connection"); + profile: "full-model"); try { canonicalReloadWorkspace = CanonicalSclReloadValidator.Validate( From 0f18b0a955bf6befd260adb467e18378cfc57db7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:17:44 +0700 Subject: [PATCH 178/243] fix(scl): preserve full shape but read only safe FC roots --- Services/NativeIec61850Client.SclAssisted.cs | 35 ++++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index 008453011..969a9edfa 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -183,10 +183,39 @@ preparation.InitialReadDesign is null || }; } + var safeInitialTargets = preparation.InitialReadPlan.Targets + .Where(target => IsSafeTrustedSclInitialReadFc(target.FunctionalConstraint)) + .ToArray(); + var safeInitialPlan = ArMms.InitialFcReadPlanner.Build( + safeInitialTargets, + preparation.InitialReadPlan.MaximumVariableReferencesPerRead); + if (!safeInitialPlan.IsValid) + { + LastConnectionFailureKind = "SCL_INITIAL_FC_PLAN_INVALID"; + LastErrorMessage = string.Join(" | ", safeInitialPlan.Errors); + LastConnectionTechnicalSummary = LastErrorMessage; + await _session.DisposeAsync().ConfigureAwait(false); + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Online = online, + Warnings = preparation.Warnings, + AssociationValidationDuration = associationDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + var readWatch = Stopwatch.StartNew(); - var initialRead = await _session.ExecuteInitialFcReadPlanAsync( - preparation.InitialReadPlan, - TimeSpan.FromSeconds(5), + var initialRead = await _session.ExecuteInitialFcReadPlanSmartAsync( + safeInitialPlan, + new ArMms.MmsSmartInitialFcReadOptions + { + MaxOutstandingBatches = 8, + UnknownPeerMaxOutstandingBatches = 4, + PerBatchTimeout = TimeSpan.FromSeconds(5) + }, cancellationToken).ConfigureAwait(false); readWatch.Stop(); initialReadDuration = readWatch.Elapsed; From 1218c8fc944b92bfc96e7f5ae841d89c48403449 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:17:59 +0700 Subject: [PATCH 179/243] perf(discovery): defer FC-root value reads from smart scan --- ...iveIec61850Client.SmartDiscoveryCapture.cs | 41 +++---------------- 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index 3044ea902..bd6eab05d 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -210,43 +210,12 @@ private async Task> DiscoverSignalsSmartForCaptu variableTypeAttributes: variableTypes); modelWatch.Stop(); - // The reference capture proves that interoperable SCL requires instance - // evidence in addition to hierarchy/type discovery. Read FC roots in bounded - // batches so one structured response can populate many deterministic leaves. - // BR/RP are excluded because report enrichment already reads those controls. - const int maxInitialFcRootTargets = 1200; - var initialPlanSource = ArMms.InitialFcReadPlanner.FromSclModel( - liveModel, - maximumVariableReferencesPerRead: ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead); - var initialCandidates = initialPlanSource.Targets - .Where(target => - !string.Equals(target.FunctionalConstraint, "BR", StringComparison.OrdinalIgnoreCase) && - !string.Equals(target.FunctionalConstraint, "RP", StringComparison.OrdinalIgnoreCase)) - .Take(maxInitialFcRootTargets) - .ToArray(); - var initialPlan = ArMms.InitialFcReadPlanner.Build( - initialCandidates, - ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead); + // Physical R7 testing showed that eager FC-root instance Reads dominate + // discovery latency while generated connection SCL does not require Val + // elements to reconnect. Keep Smart Discovery structural/type-only and defer + // value acquisition to the normal monitoring or trusted-SCL online path. ArMms.InitialFcReadExecutionResult? initialRead = null; - var initialReadWatch = Stopwatch.StartNew(); - if (initialPlan.IsValid && IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) - { - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.BuildingLiveModel, - $"Reading bounded FC-root instance evidence ({initialPlan.Targets.Count} roots, {initialPlan.Batches.Count} batch(es))…", - 74d, 7, 11)); - - initialRead = await _session.ExecuteInitialFcReadPlanSmartAsync( - initialPlan, - new ArMms.MmsSmartInitialFcReadOptions - { - MaxOutstandingBatches = 8, - UnknownPeerMaxOutstandingBatches = 4 - }, - CancellationToken.None) - .ConfigureAwait(false); - } initialReadWatch.Stop(); if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) @@ -305,7 +274,7 @@ private async Task> DiscoverSignalsSmartForCaptu $"{typeBudget} " + $"TimingMs directory={directoryWatch.Elapsed.TotalMilliseconds:F1}, types={typeWatch.Elapsed.TotalMilliseconds:F1}, " + $"model={modelWatch.Elapsed.TotalMilliseconds:F1}, initialRead={initialReadWatch.Elapsed.TotalMilliseconds:F1}, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + - $"initialFcRoots={initialPlan.Targets.Count}/{initialPlanSource.Targets.Count}, initialReadBatches={initialRead?.Batches.Count ?? 0}, instanceLeaves={initialRead?.ProjectedLeafCount ?? 0}, " + + $"initialFcRoots=deferred, initialReadBatches=0, instanceLeaves=0, " + $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes."; From 2fa922c8e7d6e6115600fee4e0b7115a6c6c4336 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:18:20 +0700 Subject: [PATCH 180/243] test(scl): require full-model export and deferred discovery reads --- .../CanonicalLiveSclExportRegressionTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 16d95181c..a9565cfd0 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -16,14 +16,15 @@ public void NativeClient_RetainsAcceptedAssociationCanonicalSnapshot() } [Fact] - public void SmartDiscovery_CapturesInitialFcEvidenceIntoSameCanonicalSnapshot() + public void SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan() { var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); - Assert.Contains("InitialFcReadPlanner.FromSclModel", capture, StringComparison.Ordinal); - Assert.Contains("ExecuteInitialFcReadPlanSmartAsync", capture, StringComparison.Ordinal); - Assert.Contains("initialFcRoots={initialPlan.Targets.Count}/{initialPlanSource.Targets.Count}", capture, StringComparison.Ordinal); + Assert.DoesNotContain("InitialFcReadPlanner.FromSclModel", capture, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteInitialFcReadPlanSmartAsync", capture, StringComparison.Ordinal); + Assert.Contains("initialFcRoots=deferred", capture, StringComparison.Ordinal); + Assert.Contains("ArMms.InitialFcReadExecutionResult? initialRead = null", capture, StringComparison.Ordinal); Assert.Contains("TryPublishSmartDiscoveryAuthority(", capture, StringComparison.Ordinal); Assert.Contains("ArMms.InitialFcReadExecutionResult? initialRead", lifecycle, StringComparison.Ordinal); Assert.Contains("PublishCanonicalModel(model, initialRead);", lifecycle, StringComparison.Ordinal); @@ -63,6 +64,7 @@ public void LiveSave_UsesCanonicalExporterAndFailsClosedOnStaleOrMissingEvidence Assert.True(staleGuardIndex >= 0); Assert.True(canonicalIndex > staleGuardIndex); Assert.Contains("if (device.SclWorkspace == null)", saveMethod, StringComparison.Ordinal); + Assert.Contains("profile: \"full-model\"", saveMethod, StringComparison.Ordinal); Assert.Contains("canonical round-trip verified", saveMethod, StringComparison.Ordinal); Assert.Contains("instanceEvidence={canonicalExportEvidence.InstanceValues.Count}", saveMethod, StringComparison.Ordinal); Assert.Contains("runtimeRCB={canonicalExportEvidence.Discovery.ReportControls.Count}", saveMethod, StringComparison.Ordinal); From 1da5cc28479eeb227799433d413f49737fc93634 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:18:44 +0700 Subject: [PATCH 181/243] fix(scl): validate full model and exact calling handshake --- Services/CanonicalSclReloadValidator.cs | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Services/CanonicalSclReloadValidator.cs b/Services/CanonicalSclReloadValidator.cs index 83eb2f37d..d47e072a2 100644 --- a/Services/CanonicalSclReloadValidator.cs +++ b/Services/CanonicalSclReloadValidator.cs @@ -15,6 +15,18 @@ public static SclIedWorkspace Validate( ArgumentNullException.ThrowIfNull(canonical); ArgumentNullException.ThrowIfNull(result); + if (!string.Equals(result.Profile, "full-model", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Canonical live SCL must preserve the full discovered model; exporter profile was '{result.Profile}'."); + } + + if (result.ExcludedAttributes.Count != 0) + { + throw new InvalidOperationException( + $"Canonical full-model SCL unexpectedly excluded {result.ExcludedAttributes.Count} discovered attribute(s)."); + } + var reloaded = workspaceService.Open( result.SclPath, new SclWorkspaceOpenOptions @@ -112,6 +124,30 @@ public static SclIedWorkspace Validate( $"AE={plan.Association.Called.AeQualifier}, PSEL={rebuiltPsel}, SSEL={rebuiltSsel}, TSEL={rebuiltTsel}."); } + var local = AR.Iec61850.Scl.MmsLocalAssociationProfile.ExistingRuntimeDefault; + var callingApTitle = plan.Association.Calling.ApTitle; + if (!plan.Cotp.SourceTsap.SequenceEqual(local.TransportSelector) || + !plan.Association.Calling.SessionSelector.SequenceEqual(local.SessionSelector) || + !plan.Association.Calling.PresentationSelector.SequenceEqual(local.PresentationSelector) || + !callingApTitle.SequenceEqual(local.ApTitle) || + plan.Association.Calling.AeQualifier != local.AeQualifier) + { + throw new InvalidOperationException( + "Generated SCL reconnect plan changed the proven calling-side runtime identity."); + } + + var acceptedProfileName = canonical.Communication.AssociationProfileName?.Trim() ?? string.Empty; + var acceptedProfile = AR.Iec61850.Acse.AcseMmsInitiateRequest + .BuildAssociationProfiles() + .SingleOrDefault(profile => + string.Equals(profile.Name, acceptedProfileName, StringComparison.Ordinal)); + if (acceptedProfile is not null && + !plan.SessionPresentationAcseMmsRequest.SequenceEqual(acceptedProfile.Payload)) + { + throw new InvalidOperationException( + $"Generated SCL reconnect AARQ does not reproduce accepted association profile '{acceptedProfileName}' byte-for-byte."); + } + return workspace; } } From 20c0a9ae1f71cf841e1986153f4c3d3f90309277 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:19:25 +0700 Subject: [PATCH 182/243] test(scl): prove exact native handshake and physical RCB grouping --- .../CanonicalSclReloadValidatorTests.cs | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs index edfb3919c..0bab1c1a3 100644 --- a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs +++ b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs @@ -30,7 +30,7 @@ public void ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDr canonical, path, schema, - profile: "safe-connection"); + profile: "full-model"); var workspace = CanonicalSclReloadValidator.Validate( new SclWorkspaceService(), @@ -78,7 +78,7 @@ public void ExportedCanonicalScl_PreparesExactSclAssistedAssociationPlan( canonical, path, schema, - profile: "safe-connection"); + profile: "full-model"); var preparation = SclAssistedConnectionPreparationBuilder.Build( File.ReadAllText(result.SclPath), @@ -98,13 +98,23 @@ public void ExportedCanonicalScl_PreparesExactSclAssistedAssociationPlan( Assert.Equal(canonical.Communication.Host, plan.Host); Assert.Equal(102, plan.Port); + Assert.Equal("ExistingRuntimeDefault", plan.LocalProfileName); + Assert.Equal("0001", Convert.ToHexString(plan.Cotp.SourceTsap)); Assert.Equal("0001", Convert.ToHexString(plan.Cotp.DestinationTsap)); + Assert.Equal("00000001", Convert.ToHexString(plan.Association.Calling.PresentationSelector)); + Assert.Equal("0001", Convert.ToHexString(plan.Association.Calling.SessionSelector)); + Assert.Equal(new uint[] { 1, 1, 1, 999 }, plan.Association.Calling.ApTitle); + Assert.Equal(12, plan.Association.Calling.AeQualifier); Assert.Equal("00000001", Convert.ToHexString(plan.Association.Called.PresentationSelector)); Assert.Equal("0001", Convert.ToHexString(plan.Association.Called.SessionSelector)); Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, plan.Association.Called.ApTitle); Assert.Equal(12, plan.Association.Called.AeQualifier); - Assert.NotEmpty(plan.CotpConnectRequest); - Assert.NotEmpty(plan.SessionPresentationAcseMmsRequest); + Assert.Equal( + AR.Iec61850.Osi.CotpConnectRequest.BuildDefault(), + plan.CotpConnectRequest); + Assert.Equal( + AR.Iec61850.Acse.AcseMmsInitiateRequest.BuildDefaultAssociationPayload(), + plan.SessionPresentationAcseMmsRequest); } finally { @@ -137,7 +147,7 @@ public void WorkspaceReload_RejectsValidButDifferentAssociationIdentity( canonical, path, SclSchemaProfile.Edition2V31, - profile: "safe-connection"); + profile: "full-model"); var document = XDocument.Load(result.SclPath); var ns = document.Root!.Name.Namespace; @@ -192,7 +202,7 @@ public void GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhys canonical, path, schema, - profile: "safe-connection"); + profile: "full-model"); var workspace = CanonicalSclReloadValidator.Validate( new SclWorkspaceService(), canonical, @@ -232,10 +242,28 @@ private static LiveIedCanonicalModel CreateGoldenRcbCanonical() reportId: $"RID_{index}_X")) .Concat( [ - RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01"), - RuntimeControl("Buffer02", buffered: true, reportId: "RID_Buffer02"), - RuntimeControl("Unbuffer01", buffered: false, reportId: "RID_Unbuffer01"), - RuntimeControl("Unbuffer02", buffered: false, reportId: "RID_Unbuffer02") + RuntimeControl( + "Buffer01", + buffered: true, + reportId: "RID_Buffer01", + integrityPeriodMs: "0", + optionalFields: "seqnum,timestamp,reason,dataset,configref"), + RuntimeControl( + "Buffer02", + buffered: true, + reportId: "RID_Buffer02", + integrityPeriodMs: "5000", + optionalFields: "seqnum,timestamp,reason,dataset,entryid,bufoverflow,configref"), + RuntimeControl( + "Unbuffer01", + buffered: false, + reportId: "RID_Unbuffer01", + integrityPeriodMs: "0"), + RuntimeControl( + "Unbuffer02", + buffered: false, + reportId: "RID_Unbuffer02", + integrityPeriodMs: "5000") ]) .ToArray(); @@ -257,7 +285,9 @@ private static LiveIedCanonicalModel CreateGoldenRcbCanonical() private static LiveIedReportControlModel RuntimeControl( string name, bool buffered, - string reportId) + string reportId, + string integrityPeriodMs = "1000", + string optionalFields = "seqnum,timestamp,dataset,dataref") => new() { Reference = $"IEDLD0/LLN0${(buffered ? "BR" : "RP")}${name}", @@ -269,9 +299,9 @@ private static LiveIedReportControlModel RuntimeControl( ReportId = reportId, ConfRev = "1", TriggerOptions = "dchg,qchg,gi", - OptionalFields = "seqnum,timestamp,dataset,dataref", + OptionalFields = optionalFields, BufferTimeMs = buffered ? "10" : "0", - IntegrityPeriodMs = "1000" + IntegrityPeriodMs = integrityPeriodMs }; From 19cf1ca51e48b5531bc1472c1ffa3150b401fe0b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:20:22 +0700 Subject: [PATCH 183/243] ci(scl): enforce physical full-model reconnect contract --- .github/workflows/scl-interoperability-r7.yml | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 0421d5a96..46f46a95a 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -6,6 +6,8 @@ on: - "Services/NativeIec61850Client.CanonicalModel.cs" - "Services/NativeIec61850Client.SmartDiscoveryCapture.cs" - "Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs" + - "Services/NativeIec61850Client.SclAssisted.cs" + - "Services/SclAssistedConnectionPreparation.cs" - "Models/MonitorModels.cs" - "MainWindow.xaml.cs" - "Services/CanonicalSclReloadValidator.cs" @@ -81,9 +83,12 @@ jobs: $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw $cotpClient = Get-Content .\ARIEC61850\src\AR.Iec61850\Osi\CotpClient.cs -Raw + $rcbProjector = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveRcbLogicalGroupProjector.cs -Raw $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw + $sclClient = Get-Content .\ARSAS\Services\NativeIec61850Client.SclAssisted.cs -Raw + $sclPreparation = Get-Content .\ARSAS\Services\SclAssistedConnectionPreparation.cs -Raw $save = Get-Content .\ARSAS\MainWindow.xaml.cs -Raw $reloadValidator = Get-Content .\ARSAS\Services\CanonicalSclReloadValidator.cs -Raw $regression = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalLiveSclExportRegressionTests.cs -Raw @@ -125,18 +130,26 @@ jobs: } if ($consumer -notmatch 'LiveIedCanonicalModelBuilder\.Build\(model, communication, initialRead\)' -or - $capture -notmatch 'InitialFcReadPlanner\.FromSclModel' -or - $capture -notmatch 'ExecuteInitialFcReadPlanSmartAsync' -or + $capture -match 'InitialFcReadPlanner\.FromSclModel' -or + $capture -match 'ExecuteInitialFcReadPlanSmartAsync' -or + $capture -notmatch 'initialFcRoots=deferred' -or $lifecycle -notmatch 'PublishCanonicalModel\(model, initialRead\)' -or $save -notmatch 'CanonicalLiveIedSclExporter\.WriteFiles' -or + $save -notmatch 'profile:\s*"full-model"' -or $save -notmatch 'CanonicalSclReloadValidator\.Validate' -or + $sclPreparation -notmatch 'MmsLocalAssociationProfile\.ExistingRuntimeDefault' -or + $sclClient -notmatch 'IsSafeTrustedSclInitialReadFc' -or + $sclClient -notmatch 'ExecuteInitialFcReadPlanSmartAsync' -or $reloadValidator -notmatch 'workspaceService\.Open' -or - $reloadValidator -notmatch 'workspace\.ReportControls\.Count != result\.ReportControlCount' -or - $regression -notmatch 'SmartDiscovery_CapturesInitialFcEvidenceIntoSameCanonicalSnapshot' -or - $reloadTests -notmatch 'ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift' -or + $reloadValidator -notmatch 'result\.Profile.*full-model' -or + $reloadValidator -notmatch 'changed the proven calling-side runtime identity' -or + $reloadValidator -notmatch 'reproduce accepted association profile' -or + $regression -notmatch 'SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan' -or + $reloadTests -notmatch 'BuildDefaultAssociationPayload' -or $reloadTests -notmatch 'GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity' -or - $reloadTests -notmatch 'ConfReportControl') { - throw 'ARSAS canonical discovery -> instance evidence -> SCL export -> workspace reload contract is incomplete.' + $rcbProjector -match 'SameNumericText\(left\.IntegrityPeriodMs, right\.IntegrityPeriodMs\)' -or + $rcbProjector -match 'Same\(left\.OptionalFields, right\.OptionalFields\)') { + throw 'ARSAS canonical discovery -> full-model SCL -> exact native reconnect -> safe initial value-read contract is incomplete.' } - name: Setup .NET 8 @@ -197,8 +210,8 @@ jobs: "Engine source PR: $env:ARIEC61850_SOURCE_PR", '', 'Acceptance target:', - 'IED -> Smart Discovery -> Canonical IED Model -> Save SCL Ed1/Ed2 -> ARSAS Workspace Reload -> exact association plan -> MMS association.', - 'CI reload invariant: identity, endpoint, LD/LN, DataSet and logical RCB counts survive ARSAS workspace reload; golden RCB shape is 34 runtime -> 32 logical with ConfReportControl max=34.', + 'IED -> fast structural Smart Discovery -> full-model SCL Ed1/Ed2 -> ARSAS Workspace Reload -> exact native calling/called association -> bounded safe FC-root snapshot.', + 'CI invariant: full-model export excludes no discovered attributes; SCL rebuilds the accepted native handshake; golden RCB shape is 34 runtime -> 32 logical with ConfReportControl max=34.', '', 'This artifact is NOT production-promotion authority and does NOT replace P0-5e/P0-5f physical discovery-budget evidence.' ) | Set-Content .\ARSAS\dist\R7-SCL-INTEROP-BUILD.txt -Encoding utf8 From 5ab3c5181c0d4a92e7e588aafaae1ac97ecc31f2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:21:35 +0700 Subject: [PATCH 184/243] test(scl): guard full model and exact calling identity --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index a9565cfd0..17c9d300f 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -125,6 +125,12 @@ public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() Assert.Contains("canonicalAssociation.SessionSelector", validator, StringComparison.Ordinal); Assert.Contains("canonicalAssociation.TransportSelector", validator, StringComparison.Ordinal); Assert.Contains("Generated SCL reconnect association drifted from accepted canonical wire evidence", validator, StringComparison.Ordinal); + Assert.Contains("result.Profile", validator, StringComparison.Ordinal); + Assert.Contains("full-model", validator, StringComparison.Ordinal); + Assert.Contains("MmsLocalAssociationProfile.ExistingRuntimeDefault", validator, StringComparison.Ordinal); + Assert.Contains("changed the proven calling-side runtime identity", validator, StringComparison.Ordinal); + Assert.Contains("BuildAssociationProfiles()", validator, StringComparison.Ordinal); + Assert.Contains("reproduce accepted association profile", validator, StringComparison.Ordinal); } [Fact] From ad12728f09a8f87d691e71702c502d1d053cdf22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:21:46 +0700 Subject: [PATCH 185/243] test(scl): lock native reconnect and safe parallel FC reads --- .../CanonicalLiveSclExportRegressionTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 17c9d300f..c76b4966f 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -133,6 +133,20 @@ public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() Assert.Contains("reproduce accepted association profile", validator, StringComparison.Ordinal); } + [Fact] + public void SclAssistedReconnect_UsesNativeCallingIdentityAndSafeParallelValueReads() + { + var preparation = File.ReadAllText(FindRepoFile("Services/SclAssistedConnectionPreparation.cs")); + var client = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SclAssisted.cs")); + + Assert.Contains("MmsLocalAssociationProfile.ExistingRuntimeDefault", preparation, StringComparison.Ordinal); + Assert.DoesNotContain("MmsLocalAssociationProfile.SclInteroperabilityDefault", preparation, StringComparison.Ordinal); + Assert.Contains("IsSafeTrustedSclInitialReadFc", client, StringComparison.Ordinal); + Assert.Contains("ExecuteInitialFcReadPlanSmartAsync", client, StringComparison.Ordinal); + Assert.Contains("\"ST\" or \"MX\" or \"SP\" or \"SV\" or \"CF\" or \"DC\" or \"EX\" or \"BL\" or \"OR\" or \"SR\"", client, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteInitialFcReadPlanAsync(", client, StringComparison.Ordinal); + } + [Fact] public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() { From bac90113680139d84232d038e0db19939d8762a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:24:55 +0700 Subject: [PATCH 186/243] chore(scl): pin physical RCB projection engine fix --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index fe21c82e5..0b6421698 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "5b7857f0524f787a325df277908f3943c9336c8c", + "commit": "60af0822b4f83db9bf936e1d5f9585caffbaf302", "sourcePullRequest": 135, - "purpose": "R7 interoperability trial pin. Uses isolated engine PR #135 on top of the green 3ce8 canonical-export lineage: accepted remote AP-title/AE/PSEL/SSEL are decoded from the exact association request bytes accepted by the IED, TSEL is bound to the exact accepted COTP destination selector retained by the live session, exact bounded FC-root instance-value evidence remains part of the canonical live model, safe-connection SCL must preserve IP/AP-title/AE/PSEL/SSEL/TSEL exactly across serialize -> parse before an association plan is accepted, non-102 MMS sessions fail closed because the current SCL plan represents port 102 only, physical ReportControl service capacity remains distinct from logical RCB projection, and the later RCB discovery-read optimization remains excluded so this exporter trial does not broaden the field-qualified discovery request path. Production promotion remains fail-closed and requires fresh physical evidence on this exact engine lineage.", + "purpose": "R7 physical SCL repair pin. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 60af0822 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From cbfa4214fde1fadcc24b35b363c99bc5bfdb9627 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:25:05 +0700 Subject: [PATCH 187/243] test(scl): bind R7 to physical RCB projection engine head --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index c76b4966f..c035e1c5f 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -203,7 +203,7 @@ public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"5b7857f0524f787a325df277908f3943c9336c8c\"", + "\"commit\": \"60af0822b4f83db9bf936e1d5f9585caffbaf302\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 0761605284e4cf9220827cac2cb508704871e1a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:25:21 +0700 Subject: [PATCH 188/243] fix(scl): add trusted SCL safe FC predicate --- Services/NativeIec61850Client.SclAssisted.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index 969a9edfa..a50703c86 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -569,6 +569,11 @@ private static string StaticRcbLeaf(string reference) : reference; } + private static bool IsSafeTrustedSclInitialReadFc(string? functionalConstraint) + => (functionalConstraint ?? string.Empty).Trim().ToUpperInvariant() is + "ST" or "MX" or "SP" or "SV" or "CF" or "DC" or "EX" or "BL" or "OR" or "SR"; + private static string NormalizeTrustedSclReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.'); + => (reference ?? string.Empty).Trim().Replace('} +, '.'); } From 114653c4f193c948702696f769cca5256806ba70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:26:04 +0700 Subject: [PATCH 189/243] test(scl): align engine pin guard with physical repair --- .../CanonicalLiveSclExportRegressionTests.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index c035e1c5f..fec1e1696 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -198,7 +198,7 @@ public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() [Fact] - public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() + public void EnginePin_MatchesPhysicalSclRepairHead() { var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); @@ -208,13 +208,19 @@ public void EnginePin_MatchesWireEvidenceCanonicalInteroperabilityHead() StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); Assert.Contains("exact association request bytes accepted by the IED", lockFile, StringComparison.Ordinal); - Assert.Contains("exact accepted COTP destination selector retained by the live session", lockFile, StringComparison.Ordinal); - Assert.Contains("serialize -> parse", lockFile, StringComparison.Ordinal); - Assert.Contains("non-102 MMS sessions fail closed", lockFile, StringComparison.Ordinal); - Assert.Contains("later RCB discovery-read optimization remains excluded", lockFile, StringComparison.Ordinal); + Assert.Contains("accepted COTP destination selector", lockFile, StringComparison.Ordinal); + Assert.Contains("runtime-mutable", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DataSet/ConfRev/domain/LN/buffered identity", lockFile, StringComparison.Ordinal); + Assert.Contains("full-model SCL", lockFile, StringComparison.Ordinal); + Assert.Contains("bounded FC-read policy", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); + Assert.Contains( + "\"commit\": \"4467124775d8d9d76f3db194f9fbfd97144767a8\"", + lockFile, + StringComparison.Ordinal); } + private static string FindRepoFile(string relativePath) { DirectoryInfo? directory = new(AppContext.BaseDirectory); From 3d960326e4a44861e499a96596f7bccd82af0bf5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:26:09 +0700 Subject: [PATCH 190/243] ci(scl): accept physical repair engine authority --- .github/workflows/scl-interoperability-r7.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 46f46a95a..3ff64b6b4 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -52,8 +52,8 @@ jobs: $lock.commit -notmatch '^[0-9a-f]{40}$') { throw 'Invalid ARIEC61850 R7 lock.' } - if ($lock.purpose -notmatch 'R7 interoperability trial pin') { - throw 'Engine lock is not explicitly scoped as the R7 interoperability trial.' + if ($lock.purpose -notmatch 'R7 physical SCL repair pin') { + throw 'Engine lock is not explicitly scoped as the R7 physical SCL repair.' } if ([int]$lock.sourcePullRequest -notin @(134, 135)) { throw "Unexpected R7 engine source PR: $($lock.sourcePullRequest)" From 6007f95b4c1c54d017ed2cc176e901a36c85efdc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 11:27:15 +0700 Subject: [PATCH 191/243] test(scl): require zero-exclusion full-model round trip --- tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs index 0bab1c1a3..6ad4cbe24 100644 --- a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs +++ b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs @@ -32,6 +32,9 @@ public void ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDr schema, profile: "full-model"); + Assert.Equal("full-model", result.Profile); + Assert.Empty(result.ExcludedAttributes); + var workspace = CanonicalSclReloadValidator.Validate( new SclWorkspaceService(), canonical, From 1cca3b50f9c8b6c24b6cc7ff07b848d16cb8dae7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 12:58:56 +0700 Subject: [PATCH 192/243] fix(scl): repair trusted SCL reference normalization From 1d11a8e7bc55abd80951e343eaf18a0fd40e3c54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 13:00:59 +0700 Subject: [PATCH 193/243] fix(scl): rewrite trusted SCL reference helper cleanly --- Services/NativeIec61850Client.SclAssisted.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index a50703c86..dc14d8cbf 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -574,6 +574,5 @@ private static bool IsSafeTrustedSclInitialReadFc(string? functionalConstraint) "ST" or "MX" or "SP" or "SV" or "CF" or "DC" or "EX" or "BL" or "OR" or "SR"; private static string NormalizeTrustedSclReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('} -, '.'); + => (reference ?? string.Empty).Trim().Replace((char)36, '.'); } From 9a823c3d16e4349a07f15c5da69c72feec94c917 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 13:08:56 +0700 Subject: [PATCH 194/243] ci(scl): require workspace reload regression by name --- .github/workflows/scl-interoperability-r7.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 3ff64b6b4..a5294be54 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -145,6 +145,7 @@ jobs: $reloadValidator -notmatch 'changed the proven calling-side runtime identity' -or $reloadValidator -notmatch 'reproduce accepted association profile' -or $regression -notmatch 'SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan' -or + $reloadTests -notmatch 'ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift' -or $reloadTests -notmatch 'BuildDefaultAssociationPayload' -or $reloadTests -notmatch 'GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity' -or $rcbProjector -match 'SameNumericText\(left\.IntegrityPeriodMs, right\.IntegrityPeriodMs\)' -or From 21243cff74eeee7953502994cd78ed2d46ae5c08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 13:09:01 +0700 Subject: [PATCH 195/243] docs(scl): state runtime-mutable RCB identity boundary --- engines/ARIEC61850.lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 0b6421698..64fd62540 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -4,7 +4,7 @@ "ref": "main", "commit": "60af0822b4f83db9bf936e1d5f9585caffbaf302", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 60af0822 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 60af0822 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From 0306ae5370f3c0c5ab640c71a1abcab131d8c701 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:11:29 +0700 Subject: [PATCH 196/243] fix(discovery): enable smart route in R7 field-test build --- Directory.Build.targets | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index 2148450ce..2db26be69 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -4,8 +4,8 @@ - true + exercise the smart route before production promotion is authorized; this includes the R7 interoperability field-test lane. --> + true From 3210d617e9528428bb35dec15f8f8523b7f469a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:11:34 +0700 Subject: [PATCH 197/243] ci(discovery): require smart route in R7 artifact --- .github/workflows/scl-interoperability-r7.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index a5294be54..2612a1d06 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -86,6 +86,7 @@ jobs: $rcbProjector = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveRcbLogicalGroupProjector.cs -Raw $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $buildTargets = Get-Content .\ARSAS\Directory.Build.targets -Raw $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw $sclClient = Get-Content .\ARSAS\Services\NativeIec61850Client.SclAssisted.cs -Raw $sclPreparation = Get-Content .\ARSAS\Services\SclAssistedConnectionPreparation.cs -Raw @@ -133,6 +134,8 @@ jobs: $capture -match 'InitialFcReadPlanner\.FromSclModel' -or $capture -match 'ExecuteInitialFcReadPlanSmartAsync' -or $capture -notmatch 'initialFcRoots=deferred' -or + $buildTargets -notmatch 'SCL Interoperability R7 Build' -or + $buildTargets -notmatch 'EnableSmartDiscoveryCaptureRoute' -or $lifecycle -notmatch 'PublishCanonicalModel\(model, initialRead\)' -or $save -notmatch 'CanonicalLiveIedSclExporter\.WriteFiles' -or $save -notmatch 'profile:\s*"full-model"' -or From 54fbf9f2e300d0317183d8d30ab3dc62c5b07f89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:11:37 +0700 Subject: [PATCH 198/243] chore(discovery): pin SCL repair atop full smart performance head --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 64fd62540..f7883d86f 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "60af0822b4f83db9bf936e1d5f9585caffbaf302", + "commit": "2928afc9649ef5af22c43016ac06ceb301b7da11", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 60af0822 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 2928afc9 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From 74bface236bf9a0390387cfaa9b50b84ee6a68ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:11:40 +0700 Subject: [PATCH 199/243] test(discovery): lock R7 smart-route and full engine lineage --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index fec1e1696..ee7a440f7 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -194,6 +194,10 @@ public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() "34 runtime -> 32 logical with ConfReportControl max=34", workflow, StringComparison.Ordinal); + + var buildTargets = File.ReadAllText(FindRepoFile("Directory.Build.targets")); + Assert.Contains("SCL Interoperability R7 Build", buildTargets, StringComparison.Ordinal); + Assert.Contains("EnableSmartDiscoveryCaptureRoute", buildTargets, StringComparison.Ordinal); } @@ -203,7 +207,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"60af0822b4f83db9bf936e1d5f9585caffbaf302\"", + "\"commit\": \"2928afc9649ef5af22c43016ac06ceb301b7da11\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); @@ -213,6 +217,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() Assert.Contains("DataSet/ConfRev/domain/LN/buffered identity", lockFile, StringComparison.Ordinal); Assert.Contains("full-model SCL", lockFile, StringComparison.Ordinal); Assert.Contains("bounded FC-read policy", lockFile, StringComparison.Ordinal); + Assert.Contains("complete PR #134 smart-discovery performance head", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); Assert.Contains( "\"commit\": \"4467124775d8d9d76f3db194f9fbfd97144767a8\"", From a061a4f2609cb476cd3a63682b511081251bdf15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:12:18 +0700 Subject: [PATCH 200/243] ci(discovery): fail R7 if smart route is not compiled --- .github/workflows/scl-interoperability-r7.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 2612a1d06..397180f2f 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -5,6 +5,8 @@ on: paths: - "Services/NativeIec61850Client.CanonicalModel.cs" - "Services/NativeIec61850Client.SmartDiscoveryCapture.cs" + - "Directory.Build.targets" + - "scripts/enable-smart-discovery-capture.ps1" - "Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs" - "Services/NativeIec61850Client.SclAssisted.cs" - "Services/SclAssistedConnectionPreparation.cs" @@ -167,6 +169,18 @@ jobs: - name: Build ARSAS Release run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + - name: Verify R7 smart discovery route was compiled + shell: pwsh + run: | + $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.cs -Raw + if ($consumer -notmatch 'DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'R7 build compiled without the smart discovery route; refusing legacy-path artifact.' + } + if ($consumer -notmatch 'if \(SmartDiscoveryCaptureModeEnabled\)') { + throw 'R7 build did not install the smart discovery gate at DiscoverSignalsAsync.' + } + Write-Host 'R7 smart discovery route is installed in the compiled source.' + - name: Run ARSAS canonical-export regressions run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults From b06fc8e860542fe2b0bcf12dfd4db70373ecb113 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:27:29 +0700 Subject: [PATCH 201/243] chore(scl): pin prefixed LN identity repair --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index f7883d86f..f07c18aa3 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "2928afc9649ef5af22c43016ac06ceb301b7da11", + "commit": "92d5f363dc4ac8e7086aa5b0bc1e5ddac0d36831", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 2928afc9 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 92d5f363 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From 15066e44ec7bd235abc18631b25cc4f30f6ba50a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:27:36 +0700 Subject: [PATCH 202/243] test(scl): bind R7 to logical-node identity repair --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index ee7a440f7..f6307cf4b 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -207,7 +207,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"2928afc9649ef5af22c43016ac06ceb301b7da11\"", + "\"commit\": \"92d5f363dc4ac8e7086aa5b0bc1e5ddac0d36831\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 1e124dfedb1308ef1bac3b7d2d85bd1ac7262ee1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:33:07 +0700 Subject: [PATCH 203/243] chore(scl): pin semantic SDO and FC repair --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index f07c18aa3..e07488799 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "92d5f363dc4ac8e7086aa5b0bc1e5ddac0d36831", + "commit": "15a4b995a0247d7932896b7f56ea2dcf12e7d412", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 92d5f363 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 15a4b995 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity plus CDC-aware WYE/DEL/SEQ SDO and FC ownership; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From f1a2d91b88bc8a80d08ada0ae44f6096124f2ae7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:33:13 +0700 Subject: [PATCH 204/243] test(scl): bind R7 to SDO and FC semantic repair --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index f6307cf4b..ce6c4a500 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -207,7 +207,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"92d5f363dc4ac8e7086aa5b0bc1e5ddac0d36831\"", + "\"commit\": \"15a4b995a0247d7932896b7f56ea2dcf12e7d412\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); @@ -216,6 +216,8 @@ public void EnginePin_MatchesPhysicalSclRepairHead() Assert.Contains("runtime-mutable", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataSet/ConfRev/domain/LN/buffered identity", lockFile, StringComparison.Ordinal); Assert.Contains("full-model SCL", lockFile, StringComparison.Ordinal); + Assert.Contains("CDC-aware WYE/DEL/SEQ SDO", lockFile, StringComparison.Ordinal); + Assert.Contains("FC ownership", lockFile, StringComparison.Ordinal); Assert.Contains("bounded FC-read policy", lockFile, StringComparison.Ordinal); Assert.Contains("complete PR #134 smart-discovery performance head", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); From 208a5dcb0badf1ebc36e5340e966c32efadc7c59 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:36:56 +0700 Subject: [PATCH 205/243] chore(scl): pin analyzer-clean semantic repair --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index e07488799..cca9b07ba 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "15a4b995a0247d7932896b7f56ea2dcf12e7d412", + "commit": "8e917cc4cda2f1062dd23430e3200a56f6e03bd9", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 15a4b995 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity plus CDC-aware WYE/DEL/SEQ SDO and FC ownership; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 8e917cc4 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity plus CDC-aware WYE/DEL/SEQ SDO and FC ownership; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From 129acac0f6a90b128f0de6ef8b2664982294da67 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:37:01 +0700 Subject: [PATCH 206/243] test(scl): bind R7 to analyzer-clean semantic repair --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index ce6c4a500..2fd5205f1 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -207,7 +207,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"15a4b995a0247d7932896b7f56ea2dcf12e7d412\"", + "\"commit\": \"8e917cc4cda2f1062dd23430e3200a56f6e03bd9\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 3ea4923544b2b4adfc855038d410cf54ab89a0ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:43:25 +0700 Subject: [PATCH 207/243] chore(scl): pin standard physical-model CDC authority --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index cca9b07ba..5bb66edee 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "8e917cc4cda2f1062dd23430e3200a56f6e03bd9", + "commit": "6b37ea440c468f986aa1516008d203208e71578c", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 8e917cc4 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity plus CDC-aware WYE/DEL/SEQ SDO and FC ownership; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 6b37ea44 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From caf9890040471f85ca51c770684628d10045c744 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 14:43:31 +0700 Subject: [PATCH 208/243] test(scl): bind R7 to standard physical-model CDC authority --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 2fd5205f1..a7b4e6ef0 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -207,7 +207,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"8e917cc4cda2f1062dd23430e3200a56f6e03bd9\"", + "\"commit\": \"6b37ea440c468f986aa1516008d203208e71578c\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); @@ -218,6 +218,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() Assert.Contains("full-model SCL", lockFile, StringComparison.Ordinal); Assert.Contains("CDC-aware WYE/DEL/SEQ SDO", lockFile, StringComparison.Ordinal); Assert.Contains("FC ownership", lockFile, StringComparison.Ordinal); + Assert.Contains("TCTR/TVTR/LTIM/EEName/MltLev", lockFile, StringComparison.Ordinal); Assert.Contains("bounded FC-read policy", lockFile, StringComparison.Ordinal); Assert.Contains("complete PR #134 smart-discovery performance head", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); From 1b2f9a4ca9d921c16f370f9fc6cd393c2423893b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:02:33 +0700 Subject: [PATCH 209/243] chore(convergence): pin complete IEDScout parity engine head --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5bb66edee..4210f67d1 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "6b37ea440c468f986aa1516008d203208e71578c", + "commit": "4900427cb1710b433fb74d3ad99099b28ab27ef7", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 6b37ea44 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 4900427c preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From 318c68291ffd8293be9ac4c8fd723825a548e372 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:02:38 +0700 Subject: [PATCH 210/243] test(convergence): bind ARSAS to complete IEDScout parity engine --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index a7b4e6ef0..c363c7888 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -207,7 +207,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"6b37ea440c468f986aa1516008d203208e71578c\"", + "\"commit\": \"4900427cb1710b433fb74d3ad99099b28ab27ef7\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); @@ -219,6 +219,8 @@ public void EnginePin_MatchesPhysicalSclRepairHead() Assert.Contains("CDC-aware WYE/DEL/SEQ SDO", lockFile, StringComparison.Ordinal); Assert.Contains("FC ownership", lockFile, StringComparison.Ordinal); Assert.Contains("TCTR/TVTR/LTIM/EEName/MltLev", lockFile, StringComparison.Ordinal); + Assert.Contains("LTRK service-tracking", lockFile, StringComparison.Ordinal); + Assert.Contains("Edition-1 schema downgrade protection", lockFile, StringComparison.Ordinal); Assert.Contains("bounded FC-read policy", lockFile, StringComparison.Ordinal); Assert.Contains("complete PR #134 smart-discovery performance head", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); From 4b8caa2c0c83d6a9c8fcc4ecf1e9f55a8fc302c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:03:25 +0700 Subject: [PATCH 211/243] docs(convergence): define IEDScout discovery and SCL parity contract --- evidence/iedscout-convergence-target.json | 130 ++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 evidence/iedscout-convergence-target.json diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json new file mode 100644 index 000000000..741d89123 --- /dev/null +++ b/evidence/iedscout-convergence-target.json @@ -0,0 +1,130 @@ +{ + "schemaVersion": 1, + "name": "IEDScout discovery and SCL convergence", + "status": "physical-retest-required", + "target": { + "discovery": "Reach IEDScout-like MMS discovery efficiency without sacrificing exact IEC 61850 model evidence.", + "model": "Build the canonical IEC 61850 model from structure-first MMS evidence with correct LN identity, DO/SDO hierarchy, FC ownership, DataSet/RCB semantics, and schema-aware CDC handling.", + "scl": "Save Edition 2 IID / Edition 1 ICD that reopens in ARSAS, reconnects to the relay, and is usable as trusted SCL." + }, + "activeStack": { + "enginePerformance": { + "repository": "masarray/ARIEC61850", + "pullRequest": 134, + "head": "a31e396f0bbc9507215a8c9d125e910555ed6f0a" + }, + "engineModelAndScl": { + "repository": "masarray/ARIEC61850", + "pullRequest": 135, + "basePullRequest": 134, + "head": "4900427cb1710b433fb74d3ad99099b28ab27ef7" + }, + "consumerIntegration": { + "repository": "masarray/arsas", + "pullRequest": 324, + "branch": "test/smart-ied-discovery-pr134" + } + }, + "physicalReference": { + "relay": "AA1E1F06R4", + "logicalDevices": 32, + "logicalNodes": 119, + "dataSets": 2, + "runtimeReportControls": 34, + "logicalSclReportControls": 32, + "iedScoutCapture": { + "associations": 1, + "confirmedMmsRequests": 417, + "getVariableAccessAttributes": 119, + "reads": 156, + "note": "Reference capture supplied by the physical comparison; values are acceptance reference, not CI-simulated proof." + }, + "rejectedLegacyArsasCapture": { + "associations": 2, + "confirmedMmsRequests": 30552, + "getVariableAccessAttributes": 23724, + "reads": 6548, + "note": "Regression signature. A future field build showing this pattern is rejected." + }, + "iedScoutSclReference": { + "lDevices": 32, + "logicalNodes": 119, + "dataSets": 2, + "fcda": 58, + "logicalReportControls": 32, + "expandedTopLevelDataObjects": 860, + "expandedDataObjectsIncludingSdo": 906, + "expandedScalarLeaves": 4925 + } + }, + "discoveryAcceptance": { + "exactlyOneAssociation": true, + "supplementalLegacyAssociationForbidden": true, + "recursivePerLeafGvaStormForbidden": true, + "secondFullGetNameListSweepForbidden": true, + "eagerInitialFcValueReadDuringStructuralDiscovery": false, + "typeStrategy": "LN/root structure first, bounded leaf fallback only when exact structure evidence is unavailable.", + "physicalPerformanceGate": "Compare request count, GVA count, Read count, peak outstanding calls, TTFI and total discovery time against IEDScout on the same relay." + }, + "modelAcceptance": { + "prefixedLogicalNodeIdentityExamples": [ + "RPRE_MMXU1 => prefix=RPRE_, lnClass=MMXU, lnInst=1", + "FPRE_MMXN1 => prefix=FPRE_, lnClass=MMXN, lnInst=1", + "I01ATCTR1 => prefix=I01A, lnClass=TCTR, lnInst=1" + ], + "standardSdoProjection": [ + "WYE", + "DEL", + "SEQ" + ], + "descendantFunctionalConstraintOwnership": true, + "standardRegistryAuthority": [ + "TCTR", + "TVTR", + "LTIM", + "EEName", + "MltLev" + ], + "edition2ServiceTrackingCdc": [ + "CST", + "BTS", + "UTS", + "STS", + "CTS" + ], + "edition1TrackingDowngrade": "omit with explicit warning; never emit invalid Edition 1 SCL" + }, + "sclAcceptance": { + "profile": "full-model", + "reopenInArsasRequired": true, + "exactAssociationRebuildRequired": true, + "nativeCallingIdentityRequired": true, + "initialTrustedSclReads": "safe bounded FC-root reads after trusted-SCL reconnect", + "rcbProjection": "34 runtime RCB -> 32 logical ReportControl; ConfReportControl max=34", + "zeroSilentModelDeletion": true, + "physicalReconnectRequired": true + }, + "promotion": { + "productionPromoted": false, + "mergeAllowedBeforePhysicalRetest": false, + "physicalRetestRequired": true, + "requiredEvidence": [ + "new ARSAS PCAP", + "new generated Ed2 IID", + "new generated Ed1 ICD", + "diagnostic report", + "same-relay comparison against IEDScout" + ] + }, + "legacyPolicy": { + "rule": "Historical PRs may remain as provenance only; they must not be used as new branch bases after an active convergence authority supersedes them.", + "knownInvalidAuthority": [ + "PR #125 calling identity profile" + ], + "activeAuthorityOnly": [ + "ARIEC61850 #134", + "ARIEC61850 #135", + "ARSAS #324" + ] + } +} From a4971e7abd891f93406c72da02250a3093cbbec2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:03:28 +0700 Subject: [PATCH 212/243] docs(convergence): document single active discovery SCL stack --- docs/IEDSCOUT_CONVERGENCE.md | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/IEDSCOUT_CONVERGENCE.md diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md new file mode 100644 index 000000000..8d52510ef --- /dev/null +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -0,0 +1,51 @@ +# IEDScout Convergence Contract + +## Product target + +ARSAS has one active IEC 61850 convergence target: + +1. **Discovery parity:** one accepted MMS association, structure-first bounded discovery, no supplemental legacy browse, no recursive per-leaf GVA storm, and physical performance comparable to IEDScout on the same relay. +2. **Canonical model correctness:** exact Logical Node identity, correct DO/SDO hierarchy, Functional Constraint ownership from evidence, complete standard semantic authority where proven, and no model deletion used as a safety mechanism. +3. **Saved SCL usability:** full-model IID/ICD must reopen in ARSAS, rebuild the exact accepted association plan, reconnect to the physical relay, and remain usable for bounded initial reads and static reporting. + +The machine-readable authority is `evidence/iedscout-convergence-target.json`. + +## Active stacked PRs + +Only this stack is active for this target: + +- ARIEC61850 PR #134 — discovery/performance authority. +- ARIEC61850 PR #135 — canonical model, association evidence, semantic SCL and schema authority, stacked on #134. +- ARSAS PR #324 — exact consumer integration and physical-evidence build. + +No new discovery/SCL work should branch from older trial PRs. Old PRs remain provenance only unless explicitly revalidated and restacked onto the active authority. + +## Regression signatures that are forbidden + +A build is rejected if it restores any of these patterns: + +- public discovery routes to legacy `DiscoverAsync` instead of the smart field-test route; +- a second supplemental MMS association is opened for discovery; +- recursive per-leaf GetVariableAccessAttributes expansion replaces structure-first probing; +- thousands of speculative sibling/engineering-unit Reads return to the discovery critical path; +- prefixed LN names are split from the first uppercase run rather than the numeric instance boundary; +- WYE/DEL/SEQ nested Data Objects are flattened into Data Attributes; +- descendant CF attributes inherit MX from a measurement parent; +- standard TCTR/TVTR/LTIM/EEName/MltLev objects are discarded because heuristic CDC inference is incomplete; +- Edition 2 LTRK tracking CDCs are emitted into Edition 1 SCL; +- runtime RCB siblings are exported as separate logical ReportControl objects solely because mutable RCB settings differ; +- canonical save silently succeeds without reopen + association-plan validation. + +## Physical acceptance + +CI can prove source contracts, deterministic semantics, round-trip parsing, build integrity and portable smoke. It cannot prove IEDScout parity. + +Production promotion remains blocked until AA1E1F06R4 is retested with the exact candidate artifact and produces: + +- new PCAP; +- new Edition 2 IID; +- new Edition 1 ICD; +- diagnostic report; +- same-relay comparison against IEDScout. + +The field result, not test count alone, decides whether the convergence target has been reached. From 38e189d34d724a21465e5316b047b862e6ad0776 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:04:08 +0700 Subject: [PATCH 213/243] ci(convergence): enforce IEDScout discovery and SCL single authority --- .github/workflows/scl-interoperability-r7.yml | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 397180f2f..911f2e468 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -14,6 +14,8 @@ on: - "MainWindow.xaml.cs" - "Services/CanonicalSclReloadValidator.cs" - "engines/ARIEC61850.lock.json" + - "evidence/iedscout-convergence-target.json" + - "docs/IEDSCOUT_CONVERGENCE.md" - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" - "tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs" - ".github/workflows/scl-interoperability-r7.yml" @@ -50,6 +52,7 @@ jobs: shell: pwsh run: | $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + $convergence = Get-Content .\ARSAS\evidence\iedscout-convergence-target.json -Raw | ConvertFrom-Json if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { throw 'Invalid ARIEC61850 R7 lock.' @@ -57,6 +60,16 @@ jobs: if ($lock.purpose -notmatch 'R7 physical SCL repair pin') { throw 'Engine lock is not explicitly scoped as the R7 physical SCL repair.' } + + if ($convergence.status -ne 'physical-retest-required' -or + [int]$convergence.activeStack.enginePerformance.pullRequest -ne 134 -or + [int]$convergence.activeStack.engineModelAndScl.pullRequest -ne 135 -or + [int]$convergence.activeStack.consumerIntegration.pullRequest -ne 324 -or + $convergence.activeStack.engineModelAndScl.head -ne $lock.commit -or + [bool]$convergence.promotion.productionPromoted -or + [bool]$convergence.promotion.mergeAllowedBeforePhysicalRetest) { + throw 'IEDScout convergence single-source-of-truth does not match the active #134 -> #135 -> #324 stack.' + } if ([int]$lock.sourcePullRequest -notin @(134, 135)) { throw "Unexpected R7 engine source PR: $($lock.sourcePullRequest)" } @@ -86,8 +99,16 @@ jobs: $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw $cotpClient = Get-Content .\ARIEC61850\src\AR.Iec61850\Osi\CotpClient.cs -Raw $rcbProjector = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveRcbLogicalGroupProjector.cs -Raw + $smartDiscovery = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw + $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw + $smartGva = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartVariableAccessAttributes.cs -Raw + $referenceParts = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850ReferenceParts.cs -Raw + $liveExporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveIedSclExporter.cs -Raw + $standardRegistry = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850StandardModelRegistry.cs -Raw + $cdcInference = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\CdcInferenceEngine.cs -Raw $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $convergence = Get-Content .\ARSAS\evidence\iedscout-convergence-target.json -Raw | ConvertFrom-Json $buildTargets = Get-Content .\ARSAS\Directory.Build.targets -Raw $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw $sclClient = Get-Content .\ARSAS\Services\NativeIec61850Client.SclAssisted.cs -Raw @@ -132,6 +153,37 @@ jobs: } } + if ($smartDiscovery -notmatch 'DiscoverSmartAsync' -or + $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $smartGva -notmatch 'GetVariableAccessAttributesSmartAsync' -or + $referenceParts -notmatch 'instanceStart' -or + $referenceParts -notmatch 'IsFourLetterLnClass' -or + $liveExporter -notmatch 'TryResolveStandardSubDataObjectCdc' -or + $liveExporter -notmatch 'DataObjectReferencePaths' -or + $liveExporter -notmatch 'IsEdition2ServiceTrackingCdc' -or + $standardRegistry -notmatch 'Key\("TCTR", "ARtg"\)' -or + $standardRegistry -notmatch 'Key\("TVTR", "VRtg"\)' -or + $standardRegistry -notmatch 'Key\("LTIM", "TmChgDT"\)' -or + $standardRegistry -notmatch 'Key\("LTRK", "BrcbTrk"\)' -or + $standardRegistry -notmatch 'Key\("XCBR", "EEName"\)' -or + $standardRegistry -notmatch 'Key\("LLN0", "MltLev"\)' -or + $cdcInference -notmatch '"CST".*"BTS".*"UTS".*"STS".*"CTS"' -or + $capture -match 'TryBuildSupplementalGetNameListSnapshotAsync' -or + $capture -match 'DiscoverDomainVariableTypeTreeNamesAsync' -or + $capture -match 'AddAdaptiveLogicalNodeSiblingProbeSignalsAsync' -or + $capture -match 'EnrichEngineeringUnitsAsync') { + throw 'IEDScout convergence source contract regressed: smart structure-first discovery or semantic SCL authority is missing, or a forbidden legacy browse path returned.' + } + + if ([int]$convergence.physicalReference.iedScoutCapture.associations -ne 1 -or + [int]$convergence.physicalReference.rejectedLegacyArsasCapture.associations -ne 2 -or + -not [bool]$convergence.discoveryAcceptance.exactlyOneAssociation -or + -not [bool]$convergence.discoveryAcceptance.supplementalLegacyAssociationForbidden -or + -not [bool]$convergence.sclAcceptance.reopenInArsasRequired -or + -not [bool]$convergence.sclAcceptance.physicalReconnectRequired) { + throw 'IEDScout physical acceptance target was weakened.' + } + if ($consumer -notmatch 'LiveIedCanonicalModelBuilder\.Build\(model, communication, initialRead\)' -or $capture -match 'InitialFcReadPlanner\.FromSclModel' -or $capture -match 'ExecuteInitialFcReadPlanSmartAsync' -or From bf862c52f02dba1f666a01d6284029b94a417005 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:04:41 +0700 Subject: [PATCH 214/243] test(convergence): lock single IEDScout authority and physical gate --- .../CanonicalLiveSclExportRegressionTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index c363c7888..b840f54b4 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -198,6 +198,33 @@ public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() var buildTargets = File.ReadAllText(FindRepoFile("Directory.Build.targets")); Assert.Contains("SCL Interoperability R7 Build", buildTargets, StringComparison.Ordinal); Assert.Contains("EnableSmartDiscoveryCaptureRoute", buildTargets, StringComparison.Ordinal); + Assert.Contains("evidence/iedscout-convergence-target.json", workflow, StringComparison.Ordinal); + Assert.Contains("IEDScout convergence source contract regressed", workflow, StringComparison.Ordinal); + Assert.Contains("TryResolveStandardSubDataObjectCdc", workflow, StringComparison.Ordinal); + Assert.Contains("IsEdition2ServiceTrackingCdc", workflow, StringComparison.Ordinal); + Assert.Contains("TryBuildSupplementalGetNameListSnapshotAsync", workflow, StringComparison.Ordinal); + } + + [Fact] + public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysicalRetest() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + var documentation = File.ReadAllText(FindRepoFile("docs/IEDSCOUT_CONVERGENCE.md")); + + Assert.Contains("\"status\": \"physical-retest-required\"", contract, StringComparison.Ordinal); + Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); + Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); + Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); + Assert.Contains("4900427cb1710b433fb74d3ad99099b28ab27ef7", contract, StringComparison.Ordinal); + Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"reopenInArsasRequired\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"physicalReconnectRequired\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"productionPromoted\": false", contract, StringComparison.Ordinal); + Assert.Contains("\"mergeAllowedBeforePhysicalRetest\": false", contract, StringComparison.Ordinal); + Assert.Contains("Only this stack is active for this target", documentation, StringComparison.Ordinal); + Assert.Contains("The field result, not test count alone", documentation, StringComparison.Ordinal); } From deb461566adc3bdaf521f66e9943c42e1d3b15f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:07:18 +0700 Subject: [PATCH 215/243] ci(convergence): bind field artifact to convergence contract hash --- .github/workflows/scl-interoperability-r7.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 911f2e468..199bebeed 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -273,15 +273,23 @@ jobs: throw "Portable EXE smoke test failed: $($process.ExitCode)" } + $convergencePath = ".\ARSAS\evidence\iedscout-convergence-target.json" + $convergenceHash = (Get-FileHash $convergencePath -Algorithm SHA256).Hash.ToLowerInvariant() + $convergence = Get-Content $convergencePath -Raw | ConvertFrom-Json + @( 'ARSAS R7 SCL interoperability field-test build', "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "Engine source PR: $env:ARIEC61850_SOURCE_PR", + "Convergence contract SHA256: $convergenceHash", + "Convergence status: $($convergence.status)", + 'Active stack: ARIEC61850 #134 -> #135 -> ARSAS #324', '', 'Acceptance target:', 'IED -> fast structural Smart Discovery -> full-model SCL Ed1/Ed2 -> ARSAS Workspace Reload -> exact native calling/called association -> bounded safe FC-root snapshot.', 'CI invariant: full-model export excludes no discovered attributes; SCL rebuilds the accepted native handshake; golden RCB shape is 34 runtime -> 32 logical with ConfReportControl max=34.', + 'Physical gate: one MMS association; no supplemental legacy browse; no recursive per-leaf GVA storm; same-relay PCAP + Ed2 IID + Ed1 ICD + diagnostic required before promotion.', '', 'This artifact is NOT production-promotion authority and does NOT replace P0-5e/P0-5f physical discovery-budget evidence.' ) | Set-Content .\ARSAS\dist\R7-SCL-INTEROP-BUILD.txt -Encoding utf8 @@ -293,6 +301,8 @@ jobs: path: | ARSAS\dist\ARSAS-*-win-x64-portable.exe ARSAS\dist\R7-SCL-INTEROP-BUILD.txt + ARSAS\evidence\iedscout-convergence-target.json + ARSAS\docs\IEDSCOUT_CONVERGENCE.md ARSAS\TestResults\*.trx if-no-files-found: error retention-days: 14 From 8580e3a6d81d6c7b5afcde7eed76b002e9dfc0fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:08:00 +0700 Subject: [PATCH 216/243] ci(convergence): add required-check candidate for IEDScout parity --- .../workflows/iedscout-convergence-guard.yml | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 .github/workflows/iedscout-convergence-guard.yml diff --git a/.github/workflows/iedscout-convergence-guard.yml b/.github/workflows/iedscout-convergence-guard.yml new file mode 100644 index 000000000..1b78643bf --- /dev/null +++ b/.github/workflows/iedscout-convergence-guard.yml @@ -0,0 +1,131 @@ +name: IEDScout Convergence Guard + +on: + pull_request: + paths: + - "Services/NativeIec61850Client*.cs" + - "Services/SclAssistedConnectionPreparation.cs" + - "Services/CanonicalSclReloadValidator.cs" + - "MainWindow.xaml.cs" + - "Directory.Build.targets" + - "scripts/enable-smart-discovery-capture.ps1" + - "engines/ARIEC61850.lock.json" + - "evidence/iedscout-convergence-target.json" + - "docs/IEDSCOUT_CONVERGENCE.md" + - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" + - ".github/workflows/iedscout-convergence-guard.yml" + - ".github/workflows/scl-interoperability-r7.yml" + workflow_dispatch: + +concurrency: + group: iedscout-convergence-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + iedscout-convergence-contract: + name: iedscout-convergence-contract + runs-on: windows-latest + + steps: + - name: Checkout exact ARSAS revision + shell: pwsh + env: + ARSAS_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if ($env:ARSAS_SOURCE_SHA -notmatch '^[0-9a-f]{40}$') { + throw "Invalid ARSAS source SHA: $env:ARSAS_SOURCE_SHA" + } + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS + git -C .\ARSAS fetch --quiet --depth 1 origin $env:ARSAS_SOURCE_SHA + git -C .\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA + $actual = (git -C .\ARSAS rev-parse HEAD).Trim() + if ($actual -ne $env:ARSAS_SOURCE_SHA) { + throw "ARSAS SHA mismatch. Expected $env:ARSAS_SOURCE_SHA, got $actual." + } + + - name: Verify single convergence authority + shell: pwsh + run: | + $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + $target = Get-Content .\ARSAS\evidence\iedscout-convergence-target.json -Raw | ConvertFrom-Json + + if ($target.status -ne 'physical-retest-required') { + throw "Convergence status changed unexpectedly: $($target.status)" + } + if ([int]$target.activeStack.enginePerformance.pullRequest -ne 134 -or + [int]$target.activeStack.engineModelAndScl.pullRequest -ne 135 -or + [int]$target.activeStack.engineModelAndScl.basePullRequest -ne 134 -or + [int]$target.activeStack.consumerIntegration.pullRequest -ne 324) { + throw 'Active stack must remain ARIEC61850 #134 -> #135 -> ARSAS #324.' + } + if ($lock.commit -ne $target.activeStack.engineModelAndScl.head -or + [int]$lock.sourcePullRequest -ne 135) { + throw 'Engine lock drifted from the convergence single source of truth.' + } + if ([bool]$target.promotion.productionPromoted -or + [bool]$target.promotion.mergeAllowedBeforePhysicalRetest -or + -not [bool]$target.promotion.physicalRetestRequired) { + throw 'Physical retest gate was weakened.' + } + if ([int]$target.physicalReference.iedScoutCapture.associations -ne 1 -or + [int]$target.physicalReference.iedScoutCapture.getVariableAccessAttributes -ne 119 -or + [int]$target.physicalReference.rejectedLegacyArsasCapture.associations -ne 2 -or + [int]$target.physicalReference.rejectedLegacyArsasCapture.getVariableAccessAttributes -lt 20000) { + throw 'Physical IEDScout/legacy regression reference changed unexpectedly.' + } + + "ENGINE_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ENGINE_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout exact convergence engine + shell: pwsh + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ENGINE_REPOSITORY.git" ARIEC61850 + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ENGINE_COMMIT + git -C .\ARIEC61850 checkout --quiet --detach $env:ENGINE_COMMIT + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ENGINE_COMMIT) { + throw "Engine SHA mismatch. Expected $env:ENGINE_COMMIT, got $actual." + } + + - name: Verify discovery and SCL anti-regression contracts + shell: pwsh + run: | + $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $targets = Get-Content .\ARSAS\Directory.Build.targets -Raw + $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw + $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw + $smartGva = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartVariableAccessAttributes.cs -Raw + $lnParts = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850ReferenceParts.cs -Raw + $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveIedSclExporter.cs -Raw + $registry = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850StandardModelRegistry.cs -Raw + $cdc = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\CdcInferenceEngine.cs -Raw + + if ($targets -notmatch 'SCL Interoperability R7 Build' -or + $targets -notmatch 'EnableSmartDiscoveryCaptureRoute' -or + $capture -match 'TryBuildSupplementalGetNameListSnapshotAsync' -or + $capture -match 'DiscoverDomainVariableTypeTreeNamesAsync' -or + $capture -match 'AddAdaptiveLogicalNodeSiblingProbeSignalsAsync' -or + $capture -match 'EnrichEngineeringUnitsAsync' -or + $smart -notmatch 'DiscoverSmartAsync' -or + $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $smartGva -notmatch 'GetVariableAccessAttributesSmartAsync') { + throw 'Discovery regressed away from the IEDScout convergence path.' + } + + if ($lnParts -notmatch 'instanceStart' -or + $lnParts -notmatch 'IsFourLetterLnClass' -or + $exporter -notmatch 'TryResolveStandardSubDataObjectCdc' -or + $exporter -notmatch 'DataObjectReferencePaths' -or + $exporter -notmatch 'IsEdition2ServiceTrackingCdc' -or + $registry -notmatch 'Key\("TCTR", "ARtg"\)' -or + $registry -notmatch 'Key\("TVTR", "VRtg"\)' -or + $registry -notmatch 'Key\("LTIM", "TmChgDT"\)' -or + $registry -notmatch 'Key\("LTRK", "BrcbTrk"\)' -or + $registry -notmatch 'Key\("XCBR", "EEName"\)' -or + $registry -notmatch 'Key\("LLN0", "MltLev"\)' -or + $cdc -notmatch '"CST".*"BTS".*"UTS".*"STS".*"CTS"') { + throw 'Canonical IEC model / SCL semantic authority regressed.' + } + + Write-Host 'IEDScout convergence contract PASS.' From 0b9e293013c68024cc00a2f56312ec16d12cb5b8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 15:08:20 +0700 Subject: [PATCH 217/243] test(convergence): require dedicated IEDScout guard workflow --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index b840f54b4..bdf89276a 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -225,6 +225,13 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"mergeAllowedBeforePhysicalRetest\": false", contract, StringComparison.Ordinal); Assert.Contains("Only this stack is active for this target", documentation, StringComparison.Ordinal); Assert.Contains("The field result, not test count alone", documentation, StringComparison.Ordinal); + + var guard = File.ReadAllText(FindRepoFile(".github/workflows/iedscout-convergence-guard.yml")); + Assert.Contains("name: IEDScout Convergence Guard", guard, StringComparison.Ordinal); + Assert.Contains("name: iedscout-convergence-contract", guard, StringComparison.Ordinal); + Assert.Contains("Active stack must remain ARIEC61850 #134 -> #135 -> ARSAS #324", guard, StringComparison.Ordinal); + Assert.Contains("Discovery regressed away from the IEDScout convergence path", guard, StringComparison.Ordinal); + Assert.Contains("Canonical IEC model / SCL semantic authority regressed", guard, StringComparison.Ordinal); } From def371ee4586587f81294ecedc8469f02030da0b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 16:03:08 +0700 Subject: [PATCH 218/243] chore(convergence): pin R8 SCL model-order repair --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 4210f67d1..2931051bd 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "4900427cb1710b433fb74d3ad99099b28ab27ef7", + "commit": "d9964a4f8fa3ed7a645ff8f9ad1c8003185e52b9", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 4900427c preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head d9964a4f preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From c0ec48a79eaf23d5ee52e4ba2a37dc76578c6273 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 16:03:12 +0700 Subject: [PATCH 219/243] docs(convergence): bind R8 SCL reuse root-cause repairs --- evidence/iedscout-convergence-target.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 741d89123..e9ed3bc5c 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "4900427cb1710b433fb74d3ad99099b28ab27ef7" + "head": "d9964a4f8fa3ed7a645ff8f9ad1c8003185e52b9" }, "consumerIntegration": { "repository": "masarray/arsas", @@ -92,7 +92,10 @@ "STS", "CTS" ], - "edition1TrackingDowngrade": "omit with explicit warning; never emit invalid Edition 1 SCL" + "edition1TrackingDowngrade": "omit with explicit warning; never emit invalid Edition 1 SCL", + "mmsTypeDeclarationOrder": "LN-root TypeSpecification declaration order is authoritative through model, SCL and FC-root projection.", + "settingFunctionalConstraints": "SG/SE are setting data; only actual SGCB produces SettingControl.", + "mhaiThdProjection": "MHAI ThdA/ThdPhV are WYE with CMV phase SDOs." }, "sclAcceptance": { "profile": "full-model", From 3b889705f3ef667982d9c56be32ac2f200c8b1c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 16:03:17 +0700 Subject: [PATCH 220/243] test(convergence): bind R8 declaration-order and setting repair --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index bdf89276a..da8069f9d 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("4900427cb1710b433fb74d3ad99099b28ab27ef7", contract, StringComparison.Ordinal); + Assert.Contains("d9964a4f8fa3ed7a645ff8f9ad1c8003185e52b9", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -241,7 +241,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"4900427cb1710b433fb74d3ad99099b28ab27ef7\"", + "\"commit\": \"d9964a4f8fa3ed7a645ff8f9ad1c8003185e52b9\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); @@ -255,6 +255,9 @@ public void EnginePin_MatchesPhysicalSclRepairHead() Assert.Contains("TCTR/TVTR/LTIM/EEName/MltLev", lockFile, StringComparison.Ordinal); Assert.Contains("LTRK service-tracking", lockFile, StringComparison.Ordinal); Assert.Contains("Edition-1 schema downgrade protection", lockFile, StringComparison.Ordinal); + Assert.Contains("TypeSpecification declaration order", lockFile, StringComparison.Ordinal); + Assert.Contains("SG/SE as setting data", lockFile, StringComparison.Ordinal); + Assert.Contains("MHAI THD phase groups as WYE/CMV", lockFile, StringComparison.Ordinal); Assert.Contains("bounded FC-read policy", lockFile, StringComparison.Ordinal); Assert.Contains("complete PR #134 smart-discovery performance head", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); From 6d1a8bbea40cdb1824f4eaa908f2fdc2046c06a7 Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 18 Sep 2026 16:35:43 +0700 Subject: [PATCH 221/243] ci(convergence): separate candidate and physical baseline pins --- .../workflows/smart-discovery-capture-build.yml | 13 ++++++++++--- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- scripts/verify-source-clean.ps1 | 16 ++++++++++++++-- .../CanonicalLiveSclExportRegressionTests.cs | 16 ++++++++++++++-- ...veryAssociationSingleFlightRegressionTests.cs | 8 +++++++- 6 files changed, 48 insertions(+), 11 deletions(-) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 5795cf075..ed1756611 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -22,15 +22,21 @@ jobs: if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { throw 'Invalid ARIEC61850 field-capture pin.' } - if ($lock.commit -ne '4467124775d8d9d76f3db194f9fbfd97144767a8') { - throw "Unexpected engine commit: $($lock.commit)" + $baselineEngineCommit = $lock.previousTrialPin.commit + if ($baselineEngineCommit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid physical baseline engine pin.' } + if ($baselineEngineCommit -ne '4467124775d8d9d76f3db194f9fbfd97144767a8') { + throw "Unexpected engine commit (physical baseline): $baselineEngineCommit" + } + $integrationEngineCommit = $lock.commit $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() if ($arsasCommit -notmatch '^[0-9a-f]{40}$') { throw "Invalid cloned ARSAS commit: $arsasCommit" } "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$integrationEngineCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_BASELINE_COMMIT=$baselineEngineCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append $version = (Get-Content .\ArIED61850Tester\VERSION -Raw).Trim() "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append @@ -251,6 +257,7 @@ jobs: "ARSAS smart discovery field-capture build", "ARSAS commit: $env:ARSAS_COMMIT", "ARIEC61850 commit: $env:ARIEC61850_COMMIT", + "ARIEC61850 physical baseline commit: $env:ARIEC61850_BASELINE_COMMIT", "Engine PR: 134", "Mode: P0-5e golden capture acceptance over P0-5d physical wire proof, P0-5c association single-flight and P0-5b hierarchy request-budget convergence", "CI invariant: duplicate semantic request, duplicate GetNameList/GVA, second naming sweep, invoke-ID reuse and outstanding-window proof logic execute against deterministic fixtures", diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 2931051bd..a0e03ca85 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "d9964a4f8fa3ed7a645ff8f9ad1c8003185e52b9", + "commit": "5f454fdf1e29323fd6585f5efe3852ecdc30ddf7", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head d9964a4f preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 5f454fdf1e29323fd6585f5efe3852ecdc30ddf7 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index e9ed3bc5c..68ea92de2 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "d9964a4f8fa3ed7a645ff8f9ad1c8003185e52b9" + "head": "5f454fdf1e29323fd6585f5efe3852ecdc30ddf7" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/scripts/verify-source-clean.ps1 b/scripts/verify-source-clean.ps1 index b6dac1935..46a1e406a 100644 --- a/scripts/verify-source-clean.ps1 +++ b/scripts/verify-source-clean.ps1 @@ -56,6 +56,17 @@ $TextExtensions = @( ".props", ".targets", ".sln", ".slnx", ".txt" ) +# These are first-party convergence authorities. They intentionally contain the +# external interoperability label so the acceptance contract remains discoverable. +$ApprovedConvergenceIdentifierPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +@( + ".github/workflows/iedscout-convergence-guard.yml", + ".github/workflows/scl-interoperability-r7.yml", + "docs/IEDSCOUT_CONVERGENCE.md", + "evidence/iedscout-convergence-target.json", + "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" +) | ForEach-Object { [void]$ApprovedConvergenceIdentifierPaths.Add($_) } + $Problems = New-Object System.Collections.Generic.List[string] function Normalize-RelativePath { @@ -125,7 +136,8 @@ foreach ($relative in (Get-TrackedRelativePaths)) { } } - if (Test-ContainsForbiddenIdentifier $relative) { + $identifierScanExempt = $ApprovedConvergenceIdentifierPaths.Contains($relative) + if (-not $identifierScanExempt -and (Test-ContainsForbiddenIdentifier $relative)) { $Problems.Add("Forbidden external identifier in path: $relative") } @@ -133,7 +145,7 @@ foreach ($relative in (Get-TrackedRelativePaths)) { if ($TextExtensions -notcontains [IO.Path]::GetExtension($relative).ToLowerInvariant()) { continue } $content = Get-Content -LiteralPath $fullPath -Raw -ErrorAction SilentlyContinue - if (Test-ContainsForbiddenIdentifier $content) { + if (-not $identifierScanExempt -and (Test-ContainsForbiddenIdentifier $content)) { $Problems.Add("Forbidden external identifier in text: $relative") } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index da8069f9d..7265d4618 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("d9964a4f8fa3ed7a645ff8f9ad1c8003185e52b9", contract, StringComparison.Ordinal); + Assert.Contains("5f454fdf1e29323fd6585f5efe3852ecdc30ddf7", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -234,6 +234,18 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("Canonical IEC model / SCL semantic authority regressed", guard, StringComparison.Ordinal); } + [Fact] + public void SourceClean_GuardsApprovedFirstPartyConvergenceAuthorities() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-source-clean.ps1")); + + Assert.Contains("$ApprovedConvergenceIdentifierPaths", source, StringComparison.Ordinal); + Assert.Contains("docs/IEDSCOUT_CONVERGENCE.md", source, StringComparison.Ordinal); + Assert.Contains("evidence/iedscout-convergence-target.json", source, StringComparison.Ordinal); + Assert.Contains("CanonicalLiveSclExportRegressionTests.cs", source, StringComparison.Ordinal); + Assert.Contains("identifierScanExempt", source, StringComparison.Ordinal); + } + [Fact] public void EnginePin_MatchesPhysicalSclRepairHead() @@ -241,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"d9964a4f8fa3ed7a645ff8f9ad1c8003185e52b9\"", + "\"commit\": \"5f454fdf1e29323fd6585f5efe3852ecdc30ddf7\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs index 7e5c5c443..0e002ac18 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs @@ -67,9 +67,15 @@ public void P05c_PhysicalBaseline_RemainsExactP05bBudgetConvergenceCommit() Assert.Equal(P05bEngineCommit, previousTrialPin); Assert.NotEqual(P05bEngineCommit, currentIntegrationCommit); Assert.Contains( - $"if ($lock.commit -ne '{P05bEngineCommit}')", + "$baselineEngineCommit = $lock.previousTrialPin.commit", workflow, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + $"if ($baselineEngineCommit -ne '{P05bEngineCommit}')", + workflow, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("ARIEC61850_COMMIT=$integrationEngineCommit", workflow, StringComparison.Ordinal); + Assert.Contains("ARIEC61850_BASELINE_COMMIT=$baselineEngineCommit", workflow, StringComparison.Ordinal); Assert.Contains("Unexpected engine commit", workflow, StringComparison.Ordinal); Assert.Contains("LastSmartTypeProbeBudget", workflow, StringComparison.Ordinal); Assert.Contains("SuppressedExactRepeatRequests", workflow, StringComparison.Ordinal); From 709568e6e35da342f242a0c4ebb118f7707565cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 17:42:08 +0700 Subject: [PATCH 222/243] chore(convergence): pin case-safe SCL engine head --- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index a0e03ca85..53c9a5461 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "5f454fdf1e29323fd6585f5efe3852ecdc30ddf7", + "commit": "4486c6d4ef726b88aeb5ebd5300c2787718832cf", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 5f454fdf1e29323fd6585f5efe3852ecdc30ddf7 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 4486c6d4ef726b88aeb5ebd5300c2787718832cf preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 68ea92de2..11b093cbe 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "5f454fdf1e29323fd6585f5efe3852ecdc30ddf7" + "head": "4486c6d4ef726b88aeb5ebd5300c2787718832cf" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 7265d4618..920b834b9 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("5f454fdf1e29323fd6585f5efe3852ecdc30ddf7", contract, StringComparison.Ordinal); + Assert.Contains("4486c6d4ef726b88aeb5ebd5300c2787718832cf", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"5f454fdf1e29323fd6585f5efe3852ecdc30ddf7\"", + "\"commit\": \"4486c6d4ef726b88aeb5ebd5300c2787718832cf\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From a23c0023ee6a336ab400a622a64cbe8ca299fe87 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 17:42:35 +0700 Subject: [PATCH 223/243] docs(convergence): lock case-distinct tracking members --- docs/IEDSCOUT_CONVERGENCE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 8d52510ef..e74567f41 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -33,6 +33,7 @@ A build is rejected if it restores any of these patterns: - descendant CF attributes inherit MX from a measurement parent; - standard TCTR/TVTR/LTIM/EEName/MltLev objects are discarded because heuristic CDC inference is incomplete; - Edition 2 LTRK tracking CDCs are emitted into Edition 1 SCL; +- case-distinct MMS/SCL member names such as LTRK `t` and `T` are collapsed by case-insensitive indexing or export trees; - runtime RCB siblings are exported as separate logical ReportControl objects solely because mutable RCB settings differ; - canonical save silently succeeds without reopen + association-plan validation. From 3dbe7aaa892b7e2ded97262add37f1045e467907 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 17:45:05 +0700 Subject: [PATCH 224/243] chore(convergence): pin case-safe instance evidence head --- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 53c9a5461..cca7a75ba 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "4486c6d4ef726b88aeb5ebd5300c2787718832cf", + "commit": "88594fb3e2a966a7946040883f09d7c82a2ebadd", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 4486c6d4ef726b88aeb5ebd5300c2787718832cf preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 88594fb3e2a966a7946040883f09d7c82a2ebadd preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 11b093cbe..1f4596f82 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "4486c6d4ef726b88aeb5ebd5300c2787718832cf" + "head": "88594fb3e2a966a7946040883f09d7c82a2ebadd" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 920b834b9..787decba1 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("4486c6d4ef726b88aeb5ebd5300c2787718832cf", contract, StringComparison.Ordinal); + Assert.Contains("88594fb3e2a966a7946040883f09d7c82a2ebadd", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"4486c6d4ef726b88aeb5ebd5300c2787718832cf\"", + "\"commit\": \"88594fb3e2a966a7946040883f09d7c82a2ebadd\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From d5355960cdaddcdd8b770d0349e35ee66f12070e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 17:45:14 +0700 Subject: [PATCH 225/243] docs(convergence): pin final R8 model repair authority --- docs/IEDSCOUT_CONVERGENCE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index e74567f41..88d0445ad 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,6 +10,8 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. +Current R8 model-repair engine authority: `88594fb3e2a966a7946040883f09d7c82a2ebadd`. + ## Active stacked PRs Only this stack is active for this target: From a89d6ef230951fde98f2b35e38dbc2dc84b6382f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 17:57:50 +0700 Subject: [PATCH 226/243] chore(convergence): pin exact case-safe SCL engine fix --- docs/IEDSCOUT_CONVERGENCE.md | 2 +- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 88d0445ad..cc4735a79 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `88594fb3e2a966a7946040883f09d7c82a2ebadd`. +Current R8 model-repair engine authority: `3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90`. ## Active stacked PRs diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index cca7a75ba..b8db2e68f 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "88594fb3e2a966a7946040883f09d7c82a2ebadd", + "commit": "3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 88594fb3e2a966a7946040883f09d7c82a2ebadd preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 1f4596f82..3eae50cc2 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "88594fb3e2a966a7946040883f09d7c82a2ebadd" + "head": "3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 787decba1..f295e7572 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("88594fb3e2a966a7946040883f09d7c82a2ebadd", contract, StringComparison.Ordinal); + Assert.Contains("3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"88594fb3e2a966a7946040883f09d7c82a2ebadd\"", + "\"commit\": \"3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 2632ef6e1ee54a4156d3943fc33f98f2f07a3c39 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 18:21:09 +0700 Subject: [PATCH 227/243] chore(convergence): pin R9 singleton RCB name repair --- docs/IEDSCOUT_CONVERGENCE.md | 2 +- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index cc4735a79..6e21130f0 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90`. +Current R8 model-repair engine authority: `95cdfd047257e9227fd356335f3eb59b914194ee`. ## Active stacked PRs diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index b8db2e68f..dfa9c260d 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90", + "commit": "95cdfd047257e9227fd356335f3eb59b914194ee", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 95cdfd047257e9227fd356335f3eb59b914194ee preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 3eae50cc2..06463f0fb 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90" + "head": "95cdfd047257e9227fd356335f3eb59b914194ee" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index f295e7572..d456b1bd4 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90", contract, StringComparison.Ordinal); + Assert.Contains("95cdfd047257e9227fd356335f3eb59b914194ee", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90\"", + "\"commit\": \"95cdfd047257e9227fd356335f3eb59b914194ee\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 59c7ebaefdc233279d9aff6f1253979315bff64a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 18:23:26 +0700 Subject: [PATCH 228/243] chore(convergence): pin R9 CDC semantic repair --- docs/IEDSCOUT_CONVERGENCE.md | 2 +- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 6e21130f0..6f875814d 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `95cdfd047257e9227fd356335f3eb59b914194ee`. +Current R8 model-repair engine authority: `bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c`. ## Active stacked PRs diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index dfa9c260d..c1d42a27e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "95cdfd047257e9227fd356335f3eb59b914194ee", + "commit": "bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 95cdfd047257e9227fd356335f3eb59b914194ee preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 06463f0fb..a898433f5 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "95cdfd047257e9227fd356335f3eb59b914194ee" + "head": "bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index d456b1bd4..fa0a0c4d0 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("95cdfd047257e9227fd356335f3eb59b914194ee", contract, StringComparison.Ordinal); + Assert.Contains("bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"95cdfd047257e9227fd356335f3eb59b914194ee\"", + "\"commit\": \"bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 735152439c3065543bd3af944c2c6b64818b562e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 18:29:55 +0700 Subject: [PATCH 229/243] chore(convergence): pin R9 standard type repair --- docs/IEDSCOUT_CONVERGENCE.md | 2 +- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 6f875814d..fbbe443d1 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c`. +Current R8 model-repair engine authority: `e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c`. ## Active stacked PRs diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index c1d42a27e..5ec089c30 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c", + "commit": "e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index a898433f5..5d84a2109 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c" + "head": "e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index fa0a0c4d0..ff423e88d 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c", contract, StringComparison.Ordinal); + Assert.Contains("e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"bb6e5c1075eec7ebfb1f2bf9336e366bd7e41e3c\"", + "\"commit\": \"e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 05185d40eed35c869b4a2bb639e53038ffb18c7b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 18:31:01 +0700 Subject: [PATCH 230/243] chore(convergence): pin clean RCB and CDC parity engine --- docs/IEDSCOUT_CONVERGENCE.md | 2 +- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index fbbe443d1..7c41ec270 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c`. +Current R8 model-repair engine authority: `eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92`. ## Active stacked PRs diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5ec089c30..2b1ca3460 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c", + "commit": "eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 5d84a2109..5bacbc8a7 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c" + "head": "eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index ff423e88d..94cbdcb5c 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c", contract, StringComparison.Ordinal); + Assert.Contains("eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"e2cfcebf25b7b54bb6d6b6e28b9d060b6b1f7f2c\"", + "\"commit\": \"eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 96e37ab0a378b0aab31f1fef4d851c9a67040a59 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 18:32:58 +0700 Subject: [PATCH 231/243] chore(convergence): pin engineering-unit semantic repair --- docs/IEDSCOUT_CONVERGENCE.md | 2 +- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 7c41ec270..ff21c747e 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92`. +Current R8 model-repair engine authority: `64840e5571ac8661a8a13a75ccdd85bef134cb4a`. ## Active stacked PRs diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 2b1ca3460..babb8f5a9 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92", + "commit": "64840e5571ac8661a8a13a75ccdd85bef134cb4a", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 64840e5571ac8661a8a13a75ccdd85bef134cb4a preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 5bacbc8a7..ef458123b 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92" + "head": "64840e5571ac8661a8a13a75ccdd85bef134cb4a" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 94cbdcb5c..dd273c9c5 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92", contract, StringComparison.Ordinal); + Assert.Contains("64840e5571ac8661a8a13a75ccdd85bef134cb4a", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"eb3629b32ea68ea3b2d9b92e9c75f601d77bbb92\"", + "\"commit\": \"64840e5571ac8661a8a13a75ccdd85bef134cb4a\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 102df02adf99cf5db102ebf7434286055c55ff16 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 18:36:49 +0700 Subject: [PATCH 232/243] feat(scl): enrich instance values only at save time --- MainWindow.xaml.cs | 13 ++++- Services/Iec61850MonitorRuntime.cs | 19 ++++++ .../NativeIec61850Client.CanonicalModel.cs | 58 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index b2931880f..69eae95b6 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1514,7 +1514,7 @@ private async void IedRescan_Click(object sender, RoutedEventArgs e) await ConnectAndConfigureDeviceAsync(device, openWizard: false); } - private void IedSaveScl_Click(object sender, RoutedEventArgs e) + private async void IedSaveScl_Click(object sender, RoutedEventArgs e) { if (!TryGetDeviceFromButton(sender, out var device) || device.IsBusy) return; @@ -1557,6 +1557,17 @@ private void IedSaveScl_Click(object sender, RoutedEventArgs e) try { + if (device.SclWorkspace == null && device.IsConnected) + { + SetStatus($"{device.Name}: enriching Save SCL with bounded live FC-root values…"); + var instanceEvidence = await _runtime + .EnrichCanonicalForSclSaveAsync(device, _applicationCancellation.Token); + AddLog( + "INFO", + "SCL Export", + $"{device.Name}: save-time enrichment completed with {instanceEvidence:N0} exact instance-value leaf/leaves. Fast discovery remained unchanged."); + } + if (device.SclWorkspace != null && schema.IsEdition2 && !string.IsNullOrWhiteSpace(device.SclSourcePath) && diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index d172d5c65..cb5a7d36b 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -90,6 +90,25 @@ private sealed class DeviceSession public int ConnectedDeviceCount => _sessions.Values.Count(session => session.Client.IsConnected); public int MonitoringDeviceCount => _sessions.Values.Count(session => session.Device.IsMonitoring); + public async Task EnrichCanonicalForSclSaveAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + if (device.IsMonitoring) + throw new InvalidOperationException($"{device.Name}: stop monitoring before Save-SCL value enrichment."); + if (!_sessions.TryGetValue(device.DeviceId, out var session) || !session.Client.IsConnected) + throw new InvalidOperationException($"{device.Name}: Save-SCL value enrichment requires the active MMS connection."); + + var canonical = await session.Client + .EnrichCanonicalForSclSaveAsync(cancellationToken) + .ConfigureAwait(false); + device.LiveCanonicalModel = canonical; + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + $"Save SCL enrichment complete; instanceEvidence={canonical.InstanceValues.Count}"); + return canonical.InstanceValues.Count; + } + public async Task> ConnectAndDiscoverAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken, diff --git a/Services/NativeIec61850Client.CanonicalModel.cs b/Services/NativeIec61850Client.CanonicalModel.cs index 465ae76e1..ffb53e4e1 100644 --- a/Services/NativeIec61850Client.CanonicalModel.cs +++ b/Services/NativeIec61850Client.CanonicalModel.cs @@ -38,6 +38,64 @@ private void PublishCanonicalModel( _liveCanonicalModel = BuildCanonicalModel(model, initialRead); } + private static readonly HashSet SaveSclEnrichmentFunctionalConstraints = new( + ["ST", "MX", "SV", "CF", "DC", "SG", "SE", "SR", "OR", "BL", "EX", "SP"], + StringComparer.Ordinal); + + /// + /// Enriches the canonical snapshot only when the user explicitly saves SCL. + /// Smart Discovery stays structure/type-only; this bounded phase reads safe FC + /// roots on the already accepted association and projects exact scalar leaves. + /// Control/report service FCs (CO/RP/BR/LG/GO/GS/MS/US) are never read here. + /// + public async Task EnrichCanonicalForSclSaveAsync( + CancellationToken cancellationToken = default) + { + if (!_session.IsMmsInitiated || !_session.IsTransportConnected) + throw new InvalidOperationException("Save-SCL enrichment requires the existing initiated MMS association."); + if (_liveModel is null) + throw new InvalidOperationException("Save-SCL enrichment requires a successful live discovery model."); + + var sourcePlan = ArMms.InitialFcReadPlanner.FromSclModel(_liveModel); + var safeTargets = sourcePlan.Targets + .Where(target => SaveSclEnrichmentFunctionalConstraints.Contains( + (target.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant())) + .ToArray(); + var plan = ArMms.InitialFcReadPlanner.Build( + safeTargets, + sourcePlan.MaximumVariableReferencesPerRead); + if (!plan.IsValid) + { + throw new InvalidOperationException( + "Save-SCL enrichment could not build a safe FC-root Read plan: " + + string.Join(" | ", plan.Errors)); + } + + var initialRead = await _session.ExecuteInitialFcReadPlanSmartAsync( + plan, + new ArMms.MmsSmartInitialFcReadOptions + { + MaxOutstandingBatches = 8, + UnknownPeerMaxOutstandingBatches = 4, + PerBatchTimeout = TimeSpan.FromSeconds(5) + }, + cancellationToken) + .ConfigureAwait(false); + + if (initialRead.Status is ArMms.InitialFcReadExecutionStatus.InvalidPlan + or ArMms.InitialFcReadExecutionStatus.SessionNotReady + or ArMms.InitialFcReadExecutionStatus.TimedOut + or ArMms.InitialFcReadExecutionStatus.TransportFailure) + { + throw new InvalidOperationException( + $"Save-SCL enrichment failed ({initialRead.Status}): {initialRead.Message}"); + } + + PublishCanonicalModel(_liveModel, initialRead); + return _liveCanonicalModel + ?? throw new InvalidOperationException("Save-SCL enrichment did not publish a canonical model."); + } + private void ClearCanonicalModel() { _liveInitialFcRead = null; From 7ca37f8f60eca21eaade856c5350a7fa34507b8a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 18:37:42 +0700 Subject: [PATCH 233/243] chore(convergence): pin save-enrichment engine evidence --- docs/IEDSCOUT_CONVERGENCE.md | 2 +- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index ff21c747e..5762c3f7b 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `64840e5571ac8661a8a13a75ccdd85bef134cb4a`. +Current R8 model-repair engine authority: `1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0`. ## Active stacked PRs diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index babb8f5a9..471cbae96 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "64840e5571ac8661a8a13a75ccdd85bef134cb4a", + "commit": "1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 64840e5571ac8661a8a13a75ccdd85bef134cb4a preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index ef458123b..6055614f0 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "64840e5571ac8661a8a13a75ccdd85bef134cb4a" + "head": "1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index dd273c9c5..54d117962 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -215,7 +215,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("64840e5571ac8661a8a13a75ccdd85bef134cb4a", contract, StringComparison.Ordinal); + Assert.Contains("1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +253,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"64840e5571ac8661a8a13a75ccdd85bef134cb4a\"", + "\"commit\": \"1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 7ccfa52de836c374c404a94e3c2f4d209c3d0c6e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 19:07:52 +0700 Subject: [PATCH 234/243] test(convergence): lock R9 SCL reuse evidence and open gaps --- .../workflows/iedscout-convergence-guard.yml | 36 ++++++++ Services/NativeIec61850Client.SclAssisted.cs | 18 +++- docs/IEDSCOUT_CONVERGENCE.md | 25 ++++- engines/ARIEC61850.lock.json | 4 +- evidence/iedscout-convergence-target.json | 92 ++++++++++++++++++- .../CanonicalLiveSclExportRegressionTests.cs | 36 +++++++- 6 files changed, 201 insertions(+), 10 deletions(-) diff --git a/.github/workflows/iedscout-convergence-guard.yml b/.github/workflows/iedscout-convergence-guard.yml index 1b78643bf..67e8f8947 100644 --- a/.github/workflows/iedscout-convergence-guard.yml +++ b/.github/workflows/iedscout-convergence-guard.yml @@ -74,6 +74,42 @@ jobs: throw 'Physical IEDScout/legacy regression reference changed unexpectedly.' } + + $r9 = $target.physicalEvidence.arsasR9 + if ([int]$r9.discovery.associations -ne 1 -or + [int]$r9.discovery.confirmedMmsRequests -ne 323 -or + [int]$r9.discovery.getVariableAccessAttributes -ne 119 -or + [int]$r9.discovery.topLevelDataObjects -ne 860 -or + [int]$r9.discovery.dataObjectsIncludingSdo -ne 906 -or + [int]$r9.discovery.scalarLeaves -ne 4925) { + throw 'R9 physical discovery/model acceptance evidence drifted.' + } + + if ([int]$r9.reuseEdition2.expectedDomains -ne 32 -or + [int]$r9.reuseEdition2.matchedDomains -ne 32 -or + [int]$r9.reuseEdition2.successfulReads -ne [int]$r9.reuseEdition2.fcRoots -or + [int]$r9.reuseEdition2.failedReads -ne 0 -or + [int]$r9.reuseEdition2.reportBackedRuntimePoints -ne 58 -or + [int]$r9.reuseEdition2.unresolvedRuntimePoints -ne 0 -or + -not [bool]$r9.reuseEdition2.actualInformationReportObserved -or + [int]$r9.reuseEdition1.expectedDomains -ne 32 -or + [int]$r9.reuseEdition1.matchedDomains -ne 32 -or + [int]$r9.reuseEdition1.successfulReads -ne [int]$r9.reuseEdition1.fcRoots -or + [int]$r9.reuseEdition1.failedReads -ne 0 -or + [int]$r9.reuseEdition1.reportBackedRuntimePoints -ne 58 -or + [int]$r9.reuseEdition1.unresolvedRuntimePoints -ne 0 -or + -not [bool]$r9.reuseEdition1.actualInformationReportObserved) { + throw 'R9 trusted-SCL reconnect/reporting acceptance evidence regressed.' + } + + if ([int]$target.openGaps.trustedSclProjectionParity.targetProjectionErrors -ne 0 -or + [int]$target.openGaps.trustedSclProjectionParity.observedEdition2 -ne [int]$r9.reuseEdition2.projectionErrors -or + [int]$target.openGaps.trustedSclProjectionParity.observedEdition1 -ne [int]$r9.reuseEdition1.projectionErrors -or + [int]$target.openGaps.edition2CaseDistinctInitialValueCache.observedProjectedMinusCached -ne 11 -or + [int]$target.openGaps.preallocatedAddReportControls.observedWithoutDataSet -ne 30) { + throw 'Known R9 semantic gaps were silently weakened or detached from physical evidence.' + } + "ENGINE_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ENGINE_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index dc14d8cbf..758fd4fce 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -294,8 +294,16 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut LastReportInventory = ToNativeInventory(reportInventory); _trustedSclOnlineAuthorityActive = true; - var projectionErrors = initialRead.Batches - .Sum(batch => batch.Projections.Sum(projection => projection.Errors.Count)); + var projectionErrorDetails = initialRead.Batches + .SelectMany(batch => batch.Projections) + .SelectMany(projection => projection.Errors.Select(error => + $"{projection.Target.MmsReference}: {error}")) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var projectionErrors = projectionErrorDetails.Length; + var projectionErrorSamples = projectionErrorDetails + .Take(8) + .ToArray(); var extraDomains = online.Domains?.ExtraObservedDomains.Count ?? 0; var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial; LastDiscoverySummary = @@ -303,7 +311,10 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut $"FC-roots={initialRead.Plan.Targets.Count}, successfulReads={initialRead.SuccessfulTargetCount}, " + $"failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + $"initialValueCache={_trustedSclInitialValues.Count}, projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, " + - $"staticDataSets={dataSetDirectories.Count}, staticRCB={reportInventory.ReportControls.Count}, fullDiscovery=skipped."; + $"staticDataSets={dataSetDirectories.Count}, staticRCB={reportInventory.ReportControls.Count}, fullDiscovery=skipped." + + (projectionErrorSamples.Length == 0 + ? string.Empty + : $" projectionErrorSamples=[{string.Join(" || ", projectionErrorSamples)}]"); LastConnectionFailureKind = string.Empty; LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message; LastErrorMessage = partial @@ -315,6 +326,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut ? new[] { $"IED exposes {extraDomains} extra online MMS domain(s); they remain evidence only and do not mutate the SCL model." } : Array.Empty()) .Concat(partial ? new[] { LastErrorMessage } : Array.Empty()) + .Concat(projectionErrorSamples.Select(error => $"SCL initial projection: {error}")) .Where(message => !string.IsNullOrWhiteSpace(message)) .Distinct(StringComparer.Ordinal) .ToArray(); diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 5762c3f7b..b808acce4 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0`. +Current R8 model-repair engine authority: `5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33`. ## Active stacked PRs @@ -52,3 +52,26 @@ Production promotion remains blocked until AA1E1F06R4 is retested with the exact - same-relay comparison against IEDScout. The field result, not test count alone, decides whether the convergence target has been reached. + + +## R9 physical reuse lock — AA1E1F06R4 + +The R9 artifact (ARSAS `a89d6ef...`, engine `3e12fb9...`) established a split acceptance result that must not be flattened into a single pass/fail label. + +**Locked as working and non-regressible** + +- Smart Discovery remains one association and structure-first: 323 confirmed MMS requests, 138 GetNameList, 119 LN-root GVA, 2 GetNamedVariableListAttributes and 64 Reads. +- Edition 2 structural export reached 32 LD / 119 LN / 860 top-level DO / 906 DO+SDO / 4925 scalar leaves / 2 DataSets / 58 FCDA / 32 logical ReportControls / 1 SettingControl. +- Reopened Ed2 IID and Ed1 ICD both rebuilt the accepted association and matched 32/32 MMS domains. +- All trusted-SCL initial FC-root Reads completed on both editions (Ed2 563/563; Ed1 562/562). +- Both editions preserved 2 static DataSets and the two configured reporting plans (Digital BRCB + Analog URCB), resolved 58/58 runtime points with 0 unavailable points, disabled cyclic process polling, and received actual InformationReport traffic. + +**Still open and must not be marked converged** + +- Both editions still report exactly 46 initial FC projection errors. This is a semantic SCL/MMS shape problem, not a transport/association problem; target is zero. +- Ed2 projected 4250 leaves but cached only 4239. The exact 11-leaf loss matches the previously isolated LTRK case-distinct `t` / `T` collapse. The source fix is present but remains pending physical retest. +- R9 emitted 30 ADD preallocated URCB slots without a DataSet and with concrete runtime `...01` names. Never invent a DataSet. rptID-backed singleton slots must export as indexed logical ReportControl with `RptEnabled max=1`; unassigned indexed slots are warnings, not fatal missing-DataSet errors. +- R9 exported zero instance `` elements. Save-time bounded enrichment is a separate explicit phase and must not reintroduce eager FC-root Reads into Smart Discovery. +- Template deduplication remains secondary and must not trade away semantic correctness. + +Future SCL-assisted diagnostics must include representative projection-error details (root + mismatch) so the remaining 46 errors can be fixed from direct evidence rather than inferred from a summary count. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 471cbae96..21ff6db20 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0", + "commit": "5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 6055614f0..26a18cbdd 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0" + "head": "5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33" }, "consumerIntegration": { "repository": "masarray/arsas", @@ -105,7 +105,9 @@ "initialTrustedSclReads": "safe bounded FC-root reads after trusted-SCL reconnect", "rcbProjection": "34 runtime RCB -> 32 logical ReportControl; ConfReportControl max=34", "zeroSilentModelDeletion": true, - "physicalReconnectRequired": true + "physicalReconnectRequired": true, + "reuseTransportAndReportingLock": "Both Edition 2 and Edition 1 must match 32/32 MMS domains, complete all planned safe FC-root Reads without transport failures, preserve both static DataSets, arm exactly the configured Analog/Digital report plans, resolve all 58 runtime points, and observe actual InformationReport traffic.", + "semanticProjectionTarget": "projectionErrors=0 and projected leaf cache has no case-collapse loss." }, "promotion": { "productionPromoted": false, @@ -116,6 +118,8 @@ "new generated Ed2 IID", "new generated Ed1 ICD", "diagnostic report", + "Ed2 SCL-assisted reuse diagnostic with projectionErrorSamples", + "Ed1 SCL-assisted reuse diagnostic with projectionErrorSamples", "same-relay comparison against IEDScout" ] }, @@ -129,5 +133,89 @@ "ARIEC61850 #135", "ARSAS #324" ] + }, + "physicalEvidence": { + "arsasR9": { + "relay": "AA1E1F06R4", + "testedArtifact": { + "appHead": "a89d6ef230951fde98f2b35e38dbc2dc84b6382f", + "engineHead": "3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90" + }, + "discovery": { + "associations": 1, + "confirmedMmsRequests": 323, + "getNameList": 138, + "getVariableAccessAttributes": 119, + "getNamedVariableListAttributes": 2, + "reads": 64, + "topLevelDataObjects": 860, + "dataObjectsIncludingSdo": 906, + "scalarLeaves": 4925, + "dataSets": 2, + "fcda": 58, + "logicalReportControls": 32, + "settingControls": 1 + }, + "reuseEdition2": { + "expectedDomains": 32, + "matchedDomains": 32, + "fcRoots": 563, + "successfulReads": 563, + "failedReads": 0, + "projectedLeaves": 4250, + "initialValueCache": 4239, + "projectionErrors": 46, + "staticDataSets": 2, + "staticReportControls": 32, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "actualInformationReportObserved": true + }, + "reuseEdition1": { + "expectedDomains": 32, + "matchedDomains": 32, + "fcRoots": 562, + "successfulReads": 562, + "failedReads": 0, + "projectedLeaves": 4055, + "initialValueCache": 4055, + "projectionErrors": 46, + "staticDataSets": 2, + "staticReportControls": 32, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "actualInformationReportObserved": true + } + } + }, + "openGaps": { + "trustedSclProjectionParity": { + "targetProjectionErrors": 0, + "observedEdition2": 46, + "observedEdition1": 46, + "status": "unresolved", + "rule": "Do not classify SCL reuse as semantically converged until projectionErrors reaches zero on both editions. Successful MMS Reads alone are insufficient." + }, + "edition2CaseDistinctInitialValueCache": { + "targetLoss": 0, + "observedProjectedMinusCached": 11, + "status": "source-fix-pending-physical-retest", + "rule": "LTRK t and T must remain distinct through initial FC projection and cache materialization." + }, + "preallocatedAddReportControls": { + "observedWithoutDataSet": 30, + "status": "source-fix-pending-physical-retest", + "rule": "Do not invent datSet. rptID-backed singleton runtime names ending 01 must project to indexed logical ReportControl max=1; an unassigned indexed slot is a warning, not a fatal missing-DataSet error." + }, + "saveTimeInstanceValues": { + "observedR9ExportValCount": 0, + "referenceIedScoutValCount": 1529, + "status": "source-fix-pending-physical-retest", + "rule": "Keep structural discovery read-free; acquire bounded safe FC-root values only during explicit Save SCL and verify resulting instance evidence physically." + }, + "templateReuse": { + "status": "secondary-after-semantic-parity", + "rule": "Reduce duplicate LNodeType/DOType/DAType templates only after wire and semantic reuse parity is stable." + } } } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 54d117962..fd4a99718 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -147,6 +147,38 @@ public void SclAssistedReconnect_UsesNativeCallingIdentityAndSafeParallelValueRe Assert.DoesNotContain("ExecuteInitialFcReadPlanAsync(", client, StringComparison.Ordinal); } + [Fact] + public void R9PhysicalReuse_LocksWorkingPathAndKeepsSemanticProjectionGapOpen() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + + Assert.Contains("\"confirmedMmsRequests\": 323", contract, StringComparison.Ordinal); + Assert.Contains("\"getVariableAccessAttributes\": 119", contract, StringComparison.Ordinal); + Assert.Contains("\"topLevelDataObjects\": 860", contract, StringComparison.Ordinal); + Assert.Contains("\"dataObjectsIncludingSdo\": 906", contract, StringComparison.Ordinal); + Assert.Contains("\"scalarLeaves\": 4925", contract, StringComparison.Ordinal); + + Assert.Contains("\"fcRoots\": 563", contract, StringComparison.Ordinal); + Assert.Contains("\"successfulReads\": 563", contract, StringComparison.Ordinal); + Assert.Contains("\"fcRoots\": 562", contract, StringComparison.Ordinal); + Assert.Contains("\"successfulReads\": 562", contract, StringComparison.Ordinal); + Assert.Contains("\"reportBackedRuntimePoints\": 58", contract, StringComparison.Ordinal); + Assert.Contains("\"unresolvedRuntimePoints\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"actualInformationReportObserved\": true", contract, StringComparison.Ordinal); + + Assert.Contains("\"targetProjectionErrors\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"observedEdition2\": 46", contract, StringComparison.Ordinal); + Assert.Contains("\"observedEdition1\": 46", contract, StringComparison.Ordinal); + Assert.Contains("\"observedProjectedMinusCached\": 11", contract, StringComparison.Ordinal); + Assert.Contains("\"observedWithoutDataSet\": 30", contract, StringComparison.Ordinal); + Assert.Contains("\"observedR9ExportValCount\": 0", contract, StringComparison.Ordinal); + + var client = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SclAssisted.cs")); + Assert.Contains("projectionErrorSamples", client, StringComparison.Ordinal); + Assert.Contains("SCL initial projection:", client, StringComparison.Ordinal); + Assert.Contains("fullDiscovery=skipped", client, StringComparison.Ordinal); + } + [Fact] public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() { @@ -215,7 +247,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0", contract, StringComparison.Ordinal); + Assert.Contains("5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -253,7 +285,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"1be4bfc9200b1c21e86fec6ddb0e0ccdeb6d21f0\"", + "\"commit\": \"5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 76d6c0d2e78634a5afa590054059d4182c6bdefe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 19:10:47 +0700 Subject: [PATCH 235/243] fix(scl): route save enrichment through UI runtime facade --- Services/UiResponsiveIec61850MonitorRuntimeFacade.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs index 8b3949823..9fd8d8e4b 100644 --- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -74,6 +74,14 @@ public Iec61850MonitorRuntime() public int ConnectedDeviceCount => _inner.ConnectedDeviceCount; public int MonitoringDeviceCount => _inner.MonitoringDeviceCount; + public Task EnrichCanonicalForSclSaveAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken) + => RunDeviceOperationAsync( + device?.DeviceId, + cancellationToken, + token => _inner.EnrichCanonicalForSclSaveAsync(device, token)); + public Task> ConnectAndDiscoverAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken, From f3b07a2aef647e62a8e110acd58f92baa5ed1f5d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 19:15:43 +0700 Subject: [PATCH 236/243] chore(convergence): pin R9-locked CDC regression head --- docs/IEDSCOUT_CONVERGENCE.md | 2 +- engines/ARIEC61850.lock.json | 4 ++-- evidence/iedscout-convergence-target.json | 2 +- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index b808acce4..7f9971240 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33`. +Current R8 model-repair engine authority: `45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0`. ## Active stacked PRs diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 21ff6db20..01ea4f8a3 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33", + "commit": "45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 26a18cbdd..b942851f8 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33" + "head": "45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0" }, "consumerIntegration": { "repository": "masarray/arsas", diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index fd4a99718..cdad0710c 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -247,7 +247,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33", contract, StringComparison.Ordinal); + Assert.Contains("45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -285,7 +285,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"5f15fecec2d6e97d985b1b41c9762e1b8c0a9c33\"", + "\"commit\": \"45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From d946c41a468e10cecd69e7069e27aed593161e4d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 19:32:43 +0700 Subject: [PATCH 237/243] test(discovery): freeze R9 structural wire contract --- .../workflows/iedscout-convergence-guard.yml | 71 ++++++++++++++++++- ...iveIec61850Client.SmartDiscoveryCapture.cs | 38 ++++++---- docs/IEDSCOUT_CONVERGENCE.md | 28 ++++++++ evidence/iedscout-convergence-target.json | 49 +++++++++++++ .../CanonicalLiveSclExportRegressionTests.cs | 45 ++++++++++++ 5 files changed, 215 insertions(+), 16 deletions(-) diff --git a/.github/workflows/iedscout-convergence-guard.yml b/.github/workflows/iedscout-convergence-guard.yml index 67e8f8947..bdea6b80d 100644 --- a/.github/workflows/iedscout-convergence-guard.yml +++ b/.github/workflows/iedscout-convergence-guard.yml @@ -75,6 +75,56 @@ jobs: } + $p0 = $target.p0StructuralDiscoveryFreeze + if ($p0.contractId -ne 'P0-R9-STRUCTURAL' -or + $p0.status -ne 'implemented-and-r9-physically-proven' -or + -not [bool]$p0.sourcePolicy.smartRouteRequired -or + -not [bool]$p0.sourcePolicy.associationScopedSingleFlightRequired -or + -not [bool]$p0.sourcePolicy.applicationMmsGateExclusive -or + -not [bool]$p0.sourcePolicy.logicalNodeRootTypeStrategyRequired -or + -not [bool]$p0.sourcePolicy.eagerInitialFcReadsForbidden -or + -not [bool]$p0.sourcePolicy.supplementalLegacyBrowseForbidden -or + -not [bool]$p0.sourcePolicy.secondFullGetNameListSweepForbidden -or + -not [bool]$p0.sourcePolicy.recursivePerLeafGvaStormForbidden -or + -not [bool]$p0.sourcePolicy.saveTimeAndTrustedSclValueReadsRemainSeparate) { + throw 'P0 structural-discovery freeze policy was weakened.' + } + + if ([int]$p0.frozenOptions.maxConcurrentChains -ne 8 -or + [int]$p0.frozenOptions.unknownPeerMaxConcurrentChains -ne 4 -or + [int]$p0.frozenOptions.maxDomains -ne 256 -or + [int]$p0.frozenOptions.maxVariableNamesPerDomain -ne 20000 -or + [int]$p0.frozenOptions.maxVariableListNamesPerDomain -ne 4096 -or + [int]$p0.frozenOptions.maxNameListPages -ne 64 -or + -not [bool]$p0.frozenOptions.probeReportAttributes -or + [int]$p0.frozenOptions.maxReportAttributeProbes -ne 64 -or + -not [bool]$p0.frozenOptions.readDataSetDirectories -or + [int]$p0.frozenOptions.maxDataSetDirectoryReads -ne 64) { + throw 'P0 frozen smart-discovery options drifted.' + } + + $p0Relay = $p0.aa1e1f06r4Acceptance + if ([int]$p0Relay.associations -ne 1 -or + [int]$p0Relay.referenceConfirmedMmsRequests -ne 323 -or + [int]$p0Relay.maximumConfirmedMmsRequests -ne 417 -or + [int]$p0Relay.referenceGetNameList -ne 138 -or + [int]$p0Relay.referenceGetVariableAccessAttributes -ne 119 -or + [int]$p0Relay.maximumGetVariableAccessAttributes -ne 119 -or + [int]$p0Relay.referenceGetNamedVariableListAttributes -ne 2 -or + [int]$p0Relay.referenceReads -ne 64 -or + [int]$p0Relay.maximumReads -ne 156 -or + [int]$p0Relay.logicalDevices -ne 32 -or + [int]$p0Relay.logicalNodes -ne 119 -or + [int]$p0Relay.topLevelDataObjects -ne 860 -or + [int]$p0Relay.dataObjectsIncludingSdo -ne 906 -or + [int]$p0Relay.scalarLeaves -ne 4925 -or + [int]$p0Relay.dataSets -ne 2 -or + [int]$p0Relay.fcda -ne 58 -or + [int]$p0Relay.logicalReportControls -ne 32 -or + [int]$p0Relay.settingControls -ne 1) { + throw 'P0 AA1E1F06R4 discovery acceptance contract drifted.' + } + $r9 = $target.physicalEvidence.arsasR9 if ([int]$r9.discovery.associations -ne 1 -or [int]$r9.discovery.confirmedMmsRequests -ne 323 -or @@ -139,14 +189,33 @@ jobs: if ($targets -notmatch 'SCL Interoperability R7 Build' -or $targets -notmatch 'EnableSmartDiscoveryCaptureRoute' -or + $capture -notmatch 'SmartDiscoveryStructuralFreezeContract = "P0-R9-STRUCTURAL"' -or + $capture -notmatch 'CreateP0FrozenSmartDiscoveryOptions' -or + $capture -notmatch 'MaxConcurrentChains = 8' -or + $capture -notmatch 'UnknownPeerMaxConcurrentChains = 4' -or + $capture -notmatch 'MaxNameListPages = 64' -or + $capture -notmatch 'ProbeReportAttributes = true' -or + $capture -notmatch 'MaxReportAttributeProbes = 64' -or + $capture -notmatch 'ReadDataSetDirectories = true' -or + $capture -notmatch 'MaxDataSetDirectoryReads = 64' -or + $capture -notmatch 'GetOrCreateSmartDiscoveryAssociationFlight' -or + $capture -notmatch 'DiscoverSmartSingleFlightAsync' -or + $capture -notmatch 'ProbeSmartAsync' -or + $capture -notmatch 'initialFcRoots=deferred' -or + $capture -notmatch 'InitialFcReadExecutionResult\? initialRead = null' -or + $capture -match '_session\.DiscoverAsync\(' -or + $capture -match 'DiscoverDomainVariableNamesAsync' -or $capture -match 'TryBuildSupplementalGetNameListSnapshotAsync' -or $capture -match 'DiscoverDomainVariableTypeTreeNamesAsync' -or $capture -match 'AddAdaptiveLogicalNodeSiblingProbeSignalsAsync' -or $capture -match 'EnrichEngineeringUnitsAsync' -or + $capture -match 'InitialFcReadPlanner\.FromSclModel' -or + $capture -match 'ExecuteInitialFcReadPlanSmartAsync' -or $smart -notmatch 'DiscoverSmartAsync' -or $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $singleFlight -notmatch 'DiscoverSmartAsync\(options, CancellationToken\.None\)' -or $smartGva -notmatch 'GetVariableAccessAttributesSmartAsync') { - throw 'Discovery regressed away from the IEDScout convergence path.' + throw 'P0 structural discovery freeze regressed away from the accepted IEDScout convergence path.' } if ($lnParts -notmatch 'instanceStart' -or diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs index bd6eab05d..6fa8c0f76 100644 --- a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -11,8 +11,28 @@ public sealed partial class NativeIec61850Client // It keeps live MMS evidence authoritative while removing ARSAS's historical // supplemental naming/probe passes. Evidence-directed DataSet and report // enrichment stays inside the same association-scoped single flight. + private const string SmartDiscoveryStructuralFreezeContract = "P0-R9-STRUCTURAL"; private static bool SmartDiscoveryCaptureModeEnabled => true; + // P0 structural-discovery freeze. + // These values are the exact ARSAS policy that produced the accepted R9 wire shape. + // They are intentionally centralized so later SCL/value work cannot silently move + // additional MMS traffic back into the discovery critical path. + private static ArMms.MmsSmartDiscoveryOptions CreateP0FrozenSmartDiscoveryOptions() + => new() + { + MaxConcurrentChains = 8, + UnknownPeerMaxConcurrentChains = 4, + MaxDomains = 256, + MaxVariableNamesPerDomain = 20000, + MaxVariableListNamesPerDomain = 4096, + MaxNameListPages = 64, + ProbeReportAttributes = true, + MaxReportAttributeProbes = 64, + ReadDataSetDirectories = true, + MaxDataSetDirectoryReads = 64 + }; + private async Task> DiscoverSignalsSmartForCaptureAsync( CancellationToken cancellationToken, IProgress? progress) @@ -124,7 +144,7 @@ private async Task> DiscoverSignalsSmartForCaptu var cachedRawVariables = cachedSnapshot.DomainVariables.Values.Sum(values => values.Count); var cachedBudget = _session.LastSmartTypeProbeBudget?.Summary ?? "Smart type budget unavailable."; var cachedSummary = - $"SMART-CAPTURE PR134 P0-5c; association authority=reused; association flight=new-wire-free; control inventory=authoritative; wire discovery=skipped; " + + $"SMART-CAPTURE PR134 P0-5c; freeze={SmartDiscoveryStructuralFreezeContract}; association authority=reused; association flight=new-wire-free; control inventory=authoritative; wire discovery=skipped; discoveryValues=deferred; " + $"IEDName={(string.IsNullOrWhiteSpace(cachedIdentity.IedName) ? "unresolved" : cachedIdentity.IedName)} ({cachedIdentity.Source}); " + $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + @@ -147,19 +167,7 @@ private async Task> DiscoverSignalsSmartForCaptu return cachedSignals; } - var smartOptions = new ArMms.MmsSmartDiscoveryOptions - { - MaxConcurrentChains = 8, - UnknownPeerMaxConcurrentChains = 4, - MaxDomains = 256, - MaxVariableNamesPerDomain = 20000, - MaxVariableListNamesPerDomain = 4096, - MaxNameListPages = 64, - ProbeReportAttributes = true, - MaxReportAttributeProbes = 64, - ReadDataSetDirectories = true, - MaxDataSetDirectoryReads = 64 - }; + var smartOptions = CreateP0FrozenSmartDiscoveryOptions(); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.DiscoveringDirectory, @@ -266,7 +274,7 @@ private async Task> DiscoverSignalsSmartForCaptu totalWatch.Stop(); var summary = - $"SMART-CAPTURE PR134 P0-5c; association authority=new; association flight=single-owner; app MMS gate=exclusive; control inventory=authoritative; " + + $"SMART-CAPTURE PR134 P0-5c; freeze={SmartDiscoveryStructuralFreezeContract}; association authority=new; association flight=single-owner; app MMS gate=exclusive; control inventory=authoritative; discoveryValues=deferred; " + $"IEDName={(string.IsNullOrWhiteSpace(identity.IedName) ? "unresolved" : identity.IedName)} ({identity.Source}); " + $"{discovery.Summary} {liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}, " + diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 7f9971240..19de6ce35 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -22,6 +22,34 @@ Only this stack is active for this target: No new discovery/SCL work should branch from older trial PRs. Old PRs remain provenance only unless explicitly revalidated and restacked onto the active authority. + +## P0 — structural discovery freeze + +P0 is complete at source/CI level and is anchored to the physical R9 AA1E1F06R4 result. + +Runtime contract ID: `P0-R9-STRUCTURAL`. + +The frozen ARSAS discovery path is: + +- one accepted MMS association; +- one association-scoped single-flight owner; +- exclusive application MMS gate while the discovery owner is active; +- bounded directory discovery through `DiscoverSmartSingleFlightAsync`; +- coverage-aware LN/root type probing through `ProbeSmartAsync`; +- maximum discovery chains 8, or 4 when the peer's negotiated calling window is unknown; +- bounded report metadata and DataSet-directory enrichment only; +- no eager initial FC-root value snapshot; +- no supplemental legacy directory browse; +- no second full GetNameList sweep; +- no recursive per-leaf GVA expansion; +- no speculative engineering-unit/sibling/reflection scan on the critical path. + +The physical R9 AA1E1F06R4 reference remains 1 association, 323 confirmed MMS requests, 138 GetNameList, 119 GetVariableAccessAttributes, 2 GetNamedVariableListAttributes and 64 Reads, while yielding the accepted 32 LD / 119 LN / 860 top-level DO / 906 DO+SDO / 4925 scalar-leaf model. IEDScout's same-relay reference remains the upper comparison envelope at 417 confirmed requests, 119 GVA and 156 Reads. + +Those counts are physical acceptance evidence for AA1E1F06R4, not constants that ARSAS forces onto unrelated IEDs. The source contract instead freezes the discovery algorithm and bounded policy. Future instance-value work must stay in explicit Save SCL or trusted-SCL reconnect phases. + +P1+ semantic/SCL work may change canonical interpretation and export, but it must not add discovery-critical-path MMS traffic or weaken this contract. + ## Regression signatures that are forbidden A build is rejected if it restores any of these patterns: diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index b942851f8..6630ef259 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -217,5 +217,54 @@ "status": "secondary-after-semantic-parity", "rule": "Reduce duplicate LNodeType/DOType/DAType templates only after wire and semantic reuse parity is stable." } + }, + "p0StructuralDiscoveryFreeze": { + "contractId": "P0-R9-STRUCTURAL", + "status": "implemented-and-r9-physically-proven", + "scope": "AA1E1F06R4 physical acceptance plus source/CI freeze. Counts are evidence and gates for this relay, not generic hardcoded runtime behavior for other IEDs.", + "sourcePolicy": { + "smartRouteRequired": true, + "associationScopedSingleFlightRequired": true, + "applicationMmsGateExclusive": true, + "logicalNodeRootTypeStrategyRequired": true, + "eagerInitialFcReadsForbidden": true, + "supplementalLegacyBrowseForbidden": true, + "secondFullGetNameListSweepForbidden": true, + "recursivePerLeafGvaStormForbidden": true, + "saveTimeAndTrustedSclValueReadsRemainSeparate": true + }, + "frozenOptions": { + "maxConcurrentChains": 8, + "unknownPeerMaxConcurrentChains": 4, + "maxDomains": 256, + "maxVariableNamesPerDomain": 20000, + "maxVariableListNamesPerDomain": 4096, + "maxNameListPages": 64, + "probeReportAttributes": true, + "maxReportAttributeProbes": 64, + "readDataSetDirectories": true, + "maxDataSetDirectoryReads": 64 + }, + "aa1e1f06r4Acceptance": { + "associations": 1, + "referenceConfirmedMmsRequests": 323, + "maximumConfirmedMmsRequests": 417, + "referenceGetNameList": 138, + "referenceGetVariableAccessAttributes": 119, + "maximumGetVariableAccessAttributes": 119, + "referenceGetNamedVariableListAttributes": 2, + "referenceReads": 64, + "maximumReads": 156, + "logicalDevices": 32, + "logicalNodes": 119, + "topLevelDataObjects": 860, + "dataObjectsIncludingSdo": 906, + "scalarLeaves": 4925, + "dataSets": 2, + "fcda": 58, + "logicalReportControls": 32, + "settingControls": 1 + }, + "rule": "Do not optimize or enrich structural discovery further while SCL semantic convergence is still open. Any required instance-value acquisition belongs to explicit Save SCL or trusted-SCL reconnect phases." } } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index cdad0710c..3ba43facc 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -21,6 +21,24 @@ public void SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan() var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + Assert.Contains("SmartDiscoveryStructuralFreezeContract = \"P0-R9-STRUCTURAL\"", capture, StringComparison.Ordinal); + Assert.Contains("CreateP0FrozenSmartDiscoveryOptions()", capture, StringComparison.Ordinal); + Assert.Contains("MaxConcurrentChains = 8", capture, StringComparison.Ordinal); + Assert.Contains("UnknownPeerMaxConcurrentChains = 4", capture, StringComparison.Ordinal); + Assert.Contains("MaxNameListPages = 64", capture, StringComparison.Ordinal); + Assert.Contains("ProbeReportAttributes = true", capture, StringComparison.Ordinal); + Assert.Contains("MaxReportAttributeProbes = 64", capture, StringComparison.Ordinal); + Assert.Contains("ReadDataSetDirectories = true", capture, StringComparison.Ordinal); + Assert.Contains("MaxDataSetDirectoryReads = 64", capture, StringComparison.Ordinal); + Assert.Contains("GetOrCreateSmartDiscoveryAssociationFlight(", capture, StringComparison.Ordinal); + Assert.Contains("DiscoverSmartSingleFlightAsync(smartOptions, CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.Contains("ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.DoesNotContain("_session.DiscoverAsync(", capture, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverDomainVariableNamesAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("TryBuildSupplementalGetNameListSnapshotAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverDomainVariableTypeTreeNamesAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("AddAdaptiveLogicalNodeSiblingProbeSignalsAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("EnrichEngineeringUnitsAsync", capture, StringComparison.Ordinal); Assert.DoesNotContain("InitialFcReadPlanner.FromSclModel", capture, StringComparison.Ordinal); Assert.DoesNotContain("ExecuteInitialFcReadPlanSmartAsync", capture, StringComparison.Ordinal); Assert.Contains("initialFcRoots=deferred", capture, StringComparison.Ordinal); @@ -147,6 +165,33 @@ public void SclAssistedReconnect_UsesNativeCallingIdentityAndSafeParallelValueRe Assert.DoesNotContain("ExecuteInitialFcReadPlanAsync(", client, StringComparison.Ordinal); } + [Fact] + public void P0StructuralDiscoveryFreeze_LocksR9WireAndModelBudget() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + + Assert.Contains("\"contractId\": \"P0-R9-STRUCTURAL\"", contract, StringComparison.Ordinal); + Assert.Contains("\"status\": \"implemented-and-r9-physically-proven\"", contract, StringComparison.Ordinal); + Assert.Contains("\"associationScopedSingleFlightRequired\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"eagerInitialFcReadsForbidden\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"supplementalLegacyBrowseForbidden\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"maxConcurrentChains\": 8", contract, StringComparison.Ordinal); + Assert.Contains("\"unknownPeerMaxConcurrentChains\": 4", contract, StringComparison.Ordinal); + Assert.Contains("\"referenceConfirmedMmsRequests\": 323", contract, StringComparison.Ordinal); + Assert.Contains("\"maximumConfirmedMmsRequests\": 417", contract, StringComparison.Ordinal); + Assert.Contains("\"referenceGetVariableAccessAttributes\": 119", contract, StringComparison.Ordinal); + Assert.Contains("\"maximumGetVariableAccessAttributes\": 119", contract, StringComparison.Ordinal); + Assert.Contains("\"referenceReads\": 64", contract, StringComparison.Ordinal); + Assert.Contains("\"maximumReads\": 156", contract, StringComparison.Ordinal); + Assert.Contains("\"topLevelDataObjects\": 860", contract, StringComparison.Ordinal); + Assert.Contains("\"dataObjectsIncludingSdo\": 906", contract, StringComparison.Ordinal); + Assert.Contains("\"scalarLeaves\": 4925", contract, StringComparison.Ordinal); + Assert.Contains("\"dataSets\": 2", contract, StringComparison.Ordinal); + Assert.Contains("\"fcda\": 58", contract, StringComparison.Ordinal); + Assert.Contains("\"logicalReportControls\": 32", contract, StringComparison.Ordinal); + Assert.Contains("\"settingControls\": 1", contract, StringComparison.Ordinal); + } + [Fact] public void R9PhysicalReuse_LocksWorkingPathAndKeepsSemanticProjectionGapOpen() { From 05ddd943ca8c2fdfca396c4582c90d6ce5b03af8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 19:35:35 +0700 Subject: [PATCH 238/243] test(discovery): align P0 freeze guard assertion --- tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 3ba43facc..8d363388e 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -307,7 +307,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("name: IEDScout Convergence Guard", guard, StringComparison.Ordinal); Assert.Contains("name: iedscout-convergence-contract", guard, StringComparison.Ordinal); Assert.Contains("Active stack must remain ARIEC61850 #134 -> #135 -> ARSAS #324", guard, StringComparison.Ordinal); - Assert.Contains("Discovery regressed away from the IEDScout convergence path", guard, StringComparison.Ordinal); + Assert.Contains("P0 structural discovery freeze regressed away from the accepted IEDScout convergence path", guard, StringComparison.Ordinal); Assert.Contains("Canonical IEC model / SCL semantic authority regressed", guard, StringComparison.Ordinal); } From 00e09300128b20148299f0c2d4b69a573c39e30b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 20:12:02 +0700 Subject: [PATCH 239/243] chore(convergence): pin P1 CF projection-order repair --- docs/IEDSCOUT_CONVERGENCE.md | 22 +++++++++++++- engines/ARIEC61850.lock.json | 4 +-- evidence/iedscout-convergence-target.json | 29 ++++++++++++++++--- .../CanonicalLiveSclExportRegressionTests.cs | 22 ++++++++++++-- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 19de6ce35..1eda9122b 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0`. +Current R8 model-repair engine authority: `fd807a4eb6d19235edae08a3eb3c4d2a826ff79b`. ## Active stacked PRs @@ -50,6 +50,26 @@ Those counts are physical acceptance evidence for AA1E1F06R4, not constants that P1+ semantic/SCL work may change canonical interpretation and export, but it must not add discovery-critical-path MMS traffic or weaken this contract. + +## P1 — trusted-SCL CF projection-order repair + +P1 is implemented in source and remains pending physical retest. + +The R9 PCAP and the exact R9 Edition 2 IID reproduce the field symptom deterministically: the old positional projector produces exactly **46 errors affecting 191 SCL leaves**, all under **CF**, across **18 FC roots**. The IID contains no `count=` array declarations, so arrays are not the root cause. + +The root cause is representational: SCL `LNodeType` contains one global DataObject order, while MMS exposes a separate DataObject declaration order inside each Functional Constraint structure. For several TCTR, TVTR, MMXU, MSQI, MMTR, RSYN and MHAI nodes, the CF order on wire differs from the SCL DO order. Positional FC-root projection can therefore either fail on leaf-count differences or, worse, silently attach a same-shaped value to the wrong DataObject. + +P1 removes that ambiguity without changing P0 discovery: + +- multi-DO CF groups are planned as exact structured `LN$CF$DO` Read targets; +- each DO response is projected only into that named SCL DataObject; +- the existing Read batching limit remains in force, so this does not become per-leaf traffic; +- non-CF FC-root hydration remains unchanged; +- no extra GetVariableAccessAttributes request is added; +- no second association, discovery pass or supplemental browse is added. + +Physical acceptance for P1 is `projectionErrors=0`, no cross-DO value swap, the same accepted association, and no change to the frozen P0 structural-discovery PCAP signature. + ## Regression signatures that are forbidden A build is rejected if it restores any of these patterns: diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 01ea4f8a3..42eaba3c2 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0", + "commit": "fd807a4eb6d19235edae08a3eb3c4d2a826ff79b", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head fd807a4eb6d19235edae08a3eb3c4d2a826ff79b preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 6630ef259..f0f248869 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0" + "head": "fd807a4eb6d19235edae08a3eb3c4d2a826ff79b" }, "consumerIntegration": { "repository": "masarray/arsas", @@ -102,7 +102,7 @@ "reopenInArsasRequired": true, "exactAssociationRebuildRequired": true, "nativeCallingIdentityRequired": true, - "initialTrustedSclReads": "safe bounded FC-root reads after trusted-SCL reconnect", + "initialTrustedSclReads": "safe bounded SCL-guided reads after trusted-SCL reconnect; multi-DO CF groups use DO-scoped structured Reads because SCL LNodeType order is not authoritative for MMS FC-root DO order.", "rcbProjection": "34 runtime RCB -> 32 logical ReportControl; ConfReportControl max=34", "zeroSilentModelDeletion": true, "physicalReconnectRequired": true, @@ -193,8 +193,10 @@ "targetProjectionErrors": 0, "observedEdition2": 46, "observedEdition1": 46, - "status": "unresolved", - "rule": "Do not classify SCL reuse as semantically converged until projectionErrors reaches zero on both editions. Successful MMS Reads alone are insufficient." + "status": "source-fix-pending-physical-retest", + "rule": "Do not classify SCL reuse as semantically converged until projectionErrors reaches zero on both editions. Successful MMS Reads alone are insufficient.", + "rootCause": "R9 PCAP + Ed2 IID reproduction matched all 46 physical errors: every mismatch is in CF, across 18 FC roots / 191 affected SCL leaves, caused by cross-DO positional ordering differences between SCL LNodeType order and the MMS CF structure. No SCL count= arrays are present.", + "sourceRepair": "For multi-DO CF groups, InitialFcReadPlanner now emits exact LN$CF$DO targets. InitialFcValueProjector projects each DO value directly, eliminating cross-DO positional mapping while retaining batching." }, "edition2CaseDistinctInitialValueCache": { "targetLoss": 0, @@ -266,5 +268,24 @@ "settingControls": 1 }, "rule": "Do not optimize or enrich structural discovery further while SCL semantic convergence is still open. Any required instance-value acquisition belongs to explicit Save SCL or trusted-SCL reconnect phases." + }, + "p1ProjectionOrderRepair": { + "contractId": "P1-CF-DO-SCOPED", + "status": "implemented-source-pending-physical-retest", + "preservesP0StructuralDiscovery": true, + "physicalR9Reproduction": { + "projectionErrors": 46, + "affectedFcRoots": 18, + "affectedSclLeaves": 191, + "functionalConstraint": "CF", + "sclArrayCountAttributes": 0 + }, + "rule": "Never use SCL LNodeType cross-DO order as authority for an MMS multi-DO CF structure. Read each CF DataObject by exact MMS object name and batch those references with the existing bounded Read executor.", + "expectedPhysicalOutcome": { + "projectionErrors": 0, + "noSilentCrossDoValueSwap": true, + "sameAssociation": true, + "noAdditionalDiscoveryGva": true + } } } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 8d363388e..812ee0fb9 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -192,6 +192,24 @@ public void P0StructuralDiscoveryFreeze_LocksR9WireAndModelBudget() Assert.Contains("\"settingControls\": 1", contract, StringComparison.Ordinal); } + [Fact] + public void P1ProjectionOrderRepair_LocksPhysicalRootCauseAndExactStrategy() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + + Assert.Contains("\"contractId\": \"P1-CF-DO-SCOPED\"", contract, StringComparison.Ordinal); + Assert.Contains("\"status\": \"implemented-source-pending-physical-retest\"", contract, StringComparison.Ordinal); + Assert.Contains("\"projectionErrors\": 46", contract, StringComparison.Ordinal); + Assert.Contains("\"affectedFcRoots\": 18", contract, StringComparison.Ordinal); + Assert.Contains("\"affectedSclLeaves\": 191", contract, StringComparison.Ordinal); + Assert.Contains("\"functionalConstraint\": \"CF\"", contract, StringComparison.Ordinal); + Assert.Contains("\"sclArrayCountAttributes\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"projectionErrors\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"noSilentCrossDoValueSwap\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"noAdditionalDiscoveryGva\": true", contract, StringComparison.Ordinal); + Assert.Contains("LN$CF$DO", contract, StringComparison.Ordinal); + } + [Fact] public void R9PhysicalReuse_LocksWorkingPathAndKeepsSemanticProjectionGapOpen() { @@ -292,7 +310,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0", contract, StringComparison.Ordinal); + Assert.Contains("fd807a4eb6d19235edae08a3eb3c4d2a826ff79b", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -330,7 +348,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"45eab0fbc765a6aa3a1c7a3b72a0293b97eb3fb0\"", + "\"commit\": \"fd807a4eb6d19235edae08a3eb3c4d2a826ff79b\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From 0a9bb3463a9a2998f9bef6b76f89b2b9a172e7a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 20:14:32 +0700 Subject: [PATCH 240/243] chore(scl): expose P1 scoped-read diagnostics --- Services/NativeIec61850Client.SclAssisted.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index 758fd4fce..1a5b2a61b 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -288,7 +288,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut IedDirectory = new ArMms.MmsIedModelDirectory(Array.Empty()), DataSetDirectories = dataSetDirectories, Summary = - "Trusted SCL authority: Domain/VMD validation and bounded FC-root snapshot completed; " + + "Trusted SCL authority: Domain/VMD validation and bounded SCL-guided structured snapshot completed; " + "static DataSet/RCB authority retained locally; full live discovery intentionally skipped." }; LastReportInventory = ToNativeInventory(reportInventory); @@ -305,11 +305,13 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut .Take(8) .ToArray(); var extraDomains = online.Domains?.ExtraObservedDomains.Count ?? 0; + var dataObjectScopedTargets = initialRead.Plan.Targets.Count(target => target.IsDataObjectScoped); + var fcRootTargets = initialRead.Plan.Targets.Count - dataObjectScopedTargets; var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial; LastDiscoverySummary = $"SCL-assisted MMS: domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " + - $"FC-roots={initialRead.Plan.Targets.Count}, successfulReads={initialRead.SuccessfulTargetCount}, " + - $"failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + + $"initialTargets={initialRead.Plan.Targets.Count}, fcRootTargets={fcRootTargets}, doScopedTargets={dataObjectScopedTargets}, " + + $"successfulReads={initialRead.SuccessfulTargetCount}, failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + $"initialValueCache={_trustedSclInitialValues.Count}, projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, " + $"staticDataSets={dataSetDirectories.Count}, staticRCB={reportInventory.ReportControls.Count}, fullDiscovery=skipped." + (projectionErrorSamples.Length == 0 From eb8eb13d491f9aa265205852b8a4bab07af440ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 18 Sep 2026 20:32:49 +0700 Subject: [PATCH 241/243] fix(scl): make trusted value caching exact-case and lossless --- .../workflows/iedscout-convergence-guard.yml | 38 ++++++++ Services/NativeIec61850Client.SclAssisted.cs | 31 ++++-- docs/IEDSCOUT_CONVERGENCE.md | 94 ++++++++++++++++++- engines/ARIEC61850.lock.json | 4 +- evidence/iedscout-convergence-target.json | 39 +++++++- .../CanonicalLiveSclExportRegressionTests.cs | 30 +++++- 6 files changed, 222 insertions(+), 14 deletions(-) diff --git a/.github/workflows/iedscout-convergence-guard.yml b/.github/workflows/iedscout-convergence-guard.yml index bdea6b80d..725208cfc 100644 --- a/.github/workflows/iedscout-convergence-guard.yml +++ b/.github/workflows/iedscout-convergence-guard.yml @@ -125,6 +125,23 @@ jobs: throw 'P0 AA1E1F06R4 discovery acceptance contract drifted.' } + $p2 = $target.p2CaseSensitiveValuePipeline + if ($p2.contractId -ne 'P2-CASE-EXACT-VALUES' -or + $p2.status -ne 'implemented-source-pending-physical-retest' -or + -not [bool]$p2.preservesP0StructuralDiscovery -or + -not [bool]$p2.preservesP1CfScopedHydration -or + [int]$p2.physicalR9Evidence.observedCacheLoss -ne 11 -or + [int]$p2.expectedPhysicalOutcome.edition2CacheLoss -ne 0 -or + [int]$p2.expectedPhysicalOutcome.edition1CacheLoss -ne 0 -or + -not [bool]$p2.expectedPhysicalOutcome.ltrkLowercaseTAndUppercaseTRemainDistinct -or + -not [bool]$p2.expectedPhysicalOutcome.noAdditionalDiscoveryGva -or + -not [bool]$p2.expectedPhysicalOutcome.sameAssociation -or + $p2.sourceInvariants.trustedSclValueCacheComparer -ne 'StringComparer.Ordinal' -or + $p2.sourceInvariants.typeSpecificationMemberComparison -ne 'StringComparison.Ordinal' -or + $p2.sourceInvariants.sclDataObjectInstanceTargetComparison -ne 'StringComparison.Ordinal') { + throw 'P2 exact-case value pipeline contract was weakened.' + } + $r9 = $target.physicalEvidence.arsasR9 if ([int]$r9.discovery.associations -ne 1 -or [int]$r9.discovery.confirmedMmsRequests -ne 323 -or @@ -178,6 +195,10 @@ jobs: shell: pwsh run: | $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $sclAssisted = Get-Content .\ARSAS\Services\NativeIec61850Client.SclAssisted.cs -Raw + $canonicalModel = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw + $typeHierarchy = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedVariableTypeHierarchy.cs -Raw + $canonicalExporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw $targets = Get-Content .\ARSAS\Directory.Build.targets -Raw $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw @@ -187,6 +208,23 @@ jobs: $registry = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850StandardModelRegistry.cs -Raw $cdc = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\CdcInferenceEngine.cs -Raw + $valueCacheIndex = $sclAssisted.IndexOf('_trustedSclInitialValues =', [System.StringComparison]::Ordinal) + if ($valueCacheIndex -lt 0) { + throw 'P2 trusted-SCL value cache declaration is missing.' + } + $valueCacheLength = [Math]::Min(260, $sclAssisted.Length - $valueCacheIndex) + $valueCacheSegment = $sclAssisted.Substring($valueCacheIndex, $valueCacheLength) + if ($valueCacheSegment -notmatch 'StringComparer\.Ordinal' -or + $valueCacheSegment -match 'StringComparer\.OrdinalIgnoreCase' -or + $sclAssisted -notmatch 'projectedUniqueValues' -or + $sclAssisted -notmatch 'initialValueCacheLoss' -or + $sclAssisted -notmatch 'cacheLoss=\{initialValueCacheLoss\}' -or + $canonicalModel -notmatch 'new HashSet\(StringComparer\.Ordinal\)' -or + $typeHierarchy -notmatch 'current\.Children\[index\]\.Name, part, StringComparison\.Ordinal\)' -or + $canonicalExporter -notmatch 'dataObjectName\.Trim\(\),\s*[\s\S]*?StringComparison\.Ordinal\)') { + throw 'P2 case-sensitive value path implementation regressed.' + } + if ($targets -notmatch 'SCL Interoperability R7 Build' -or $targets -notmatch 'EnableSmartDiscoveryCaptureRoute' -or $capture -notmatch 'SmartDiscoveryStructuralFreezeContract = "P0-R9-STRUCTURAL"' -or diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index 1a5b2a61b..3c5c17940 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -31,7 +31,9 @@ public sealed partial class NativeIec61850Client private readonly Dictionary _trustedSclDataSetDirectories = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _trustedSclInitialValues = - new(StringComparer.OrdinalIgnoreCase); + // IEC 61850 object/member identity is case-sensitive. Edition 2 tracking + // can legally expose distinct paths such as "...t" and "...T". + new(StringComparer.Ordinal); private bool _trustedSclOnlineAuthorityActive; internal bool HasTrustedSclOnlineAuthority => _trustedSclOnlineAuthorityActive; @@ -261,6 +263,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut NormalizeTrustedSclReference(directory.DataSetReference)] = directory; } + var projectedInitialValueKeys = new HashSet(StringComparer.Ordinal); foreach (var leaf in initialRead.Batches .SelectMany(batch => batch.Projections) .SelectMany(projection => projection.Leaves)) @@ -268,7 +271,12 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut if (string.IsNullOrWhiteSpace(leaf.Reference)) continue; - _trustedSclInitialValues[NormalizeTrustedSclReference(leaf.Reference)] = new TrustedSclInitialValue + var normalizedReference = NormalizeTrustedSclReference(leaf.Reference); + if (string.IsNullOrWhiteSpace(normalizedReference)) + continue; + + projectedInitialValueKeys.Add(normalizedReference); + _trustedSclInitialValues[normalizedReference] = new TrustedSclInitialValue { Reference = leaf.Reference, FunctionalConstraint = leaf.FunctionalConstraint, @@ -277,6 +285,11 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut }; } + var projectedUniqueValues = projectedInitialValueKeys.Count; + var initialValueCacheLoss = Math.Max( + 0, + projectedUniqueValues - _trustedSclInitialValues.Count); + _lastDiscovery = new ArMms.MmsDiscoveryResult { Snapshot = new ArMms.MmsDiscoverySnapshot @@ -307,21 +320,25 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut var extraDomains = online.Domains?.ExtraObservedDomains.Count ?? 0; var dataObjectScopedTargets = initialRead.Plan.Targets.Count(target => target.IsDataObjectScoped); var fcRootTargets = initialRead.Plan.Targets.Count - dataObjectScopedTargets; - var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial; + var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial || + initialValueCacheLoss > 0; LastDiscoverySummary = $"SCL-assisted MMS: domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " + $"initialTargets={initialRead.Plan.Targets.Count}, fcRootTargets={fcRootTargets}, doScopedTargets={dataObjectScopedTargets}, " + $"successfulReads={initialRead.SuccessfulTargetCount}, failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + - $"initialValueCache={_trustedSclInitialValues.Count}, projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, " + + $"projectedUniqueValues={projectedUniqueValues}, initialValueCache={_trustedSclInitialValues.Count}, cacheLoss={initialValueCacheLoss}, " + + $"projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, " + $"staticDataSets={dataSetDirectories.Count}, staticRCB={reportInventory.ReportControls.Count}, fullDiscovery=skipped." + (projectionErrorSamples.Length == 0 ? string.Empty : $" projectionErrorSamples=[{string.Join(" || ", projectionErrorSamples)}]"); LastConnectionFailureKind = string.Empty; LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message; - LastErrorMessage = partial - ? "SCL-assisted association is healthy, but one or more initial FC-root values could not be read or projected. The trusted SCL model was preserved." - : string.Empty; + LastErrorMessage = initialValueCacheLoss > 0 + ? $"SCL-assisted association is healthy, but exact case-sensitive value caching lost {initialValueCacheLoss} projected value(s). The trusted SCL model was preserved and semantic convergence remains incomplete." + : partial + ? "SCL-assisted association is healthy, but one or more initial SCL-guided values could not be read or projected. The trusted SCL model was preserved." + : string.Empty; var warnings = preparation.Warnings .Concat(extraDomains > 0 diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 1eda9122b..470bab84e 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -10,7 +10,7 @@ ARSAS has one active IEC 61850 convergence target: The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `fd807a4eb6d19235edae08a3eb3c4d2a826ff79b`. +Current R8 model-repair engine authority: `9935d6902d786cc69b299260fe36b835944d5e81`. ## Active stacked PRs @@ -70,6 +70,98 @@ P1 removes that ambiguity without changing P0 discovery: Physical acceptance for P1 is `projectionErrors=0`, no cross-DO value swap, the same accepted association, and no change to the frozen P0 structural-discovery PCAP signature. + +## P2 — lossless case-sensitive value pipeline + +P2 is implemented in source and remains pending physical retest. + +R9 Edition 2 projected 4250 scalar values but retained only 4239 in the ARSAS trusted-SCL cache: an exact loss of 11. Edition 1 projected and retained 4055/4055. The remaining consumer-side cause was explicit: `_trustedSclInitialValues` used `StringComparer.OrdinalIgnoreCase`, so legal IEC 61850 paths that differ only by case could overwrite one another. + +P2 makes instance-value identity exact-case across the full path: + +- LN-root TypeSpecification member resolution compares component names with `StringComparison.Ordinal`; +- canonical initial-value dedup uses `StringComparer.Ordinal`; +- ARSAS trusted-SCL initial-value cache uses `StringComparer.Ordinal`; +- canonical SCL DataObject targeting is case-sensitive; +- DA/BDA/SDO path targeting remains case-sensitive; +- reference normalization may change MMS separators (`# IEDScout Convergence Contract + +## Product target + +ARSAS has one active IEC 61850 convergence target: + +1. **Discovery parity:** one accepted MMS association, structure-first bounded discovery, no supplemental legacy browse, no recursive per-leaf GVA storm, and physical performance comparable to IEDScout on the same relay. +2. **Canonical model correctness:** exact Logical Node identity, correct DO/SDO hierarchy, Functional Constraint ownership from evidence, complete standard semantic authority where proven, and no model deletion used as a safety mechanism. +3. **Saved SCL usability:** full-model IID/ICD must reopen in ARSAS, rebuild the exact accepted association plan, reconnect to the physical relay, and remain usable for bounded initial reads and static reporting. + +The machine-readable authority is `evidence/iedscout-convergence-target.json`. + +Current R8 model-repair engine authority: `9935d6902d786cc69b299260fe36b835944d5e81`. + +## Active stacked PRs + +Only this stack is active for this target: + +- ARIEC61850 PR #134 — discovery/performance authority. +- ARIEC61850 PR #135 — canonical model, association evidence, semantic SCL and schema authority, stacked on #134. +- ARSAS PR #324 — exact consumer integration and physical-evidence build. + +No new discovery/SCL work should branch from older trial PRs. Old PRs remain provenance only unless explicitly revalidated and restacked onto the active authority. + + +## P0 — structural discovery freeze + +P0 is complete at source/CI level and is anchored to the physical R9 AA1E1F06R4 result. + +Runtime contract ID: `P0-R9-STRUCTURAL`. + +The frozen ARSAS discovery path is: + +- one accepted MMS association; +- one association-scoped single-flight owner; +- exclusive application MMS gate while the discovery owner is active; +- bounded directory discovery through `DiscoverSmartSingleFlightAsync`; +- coverage-aware LN/root type probing through `ProbeSmartAsync`; +- maximum discovery chains 8, or 4 when the peer's negotiated calling window is unknown; +- bounded report metadata and DataSet-directory enrichment only; +- no eager initial FC-root value snapshot; +- no supplemental legacy directory browse; +- no second full GetNameList sweep; +- no recursive per-leaf GVA expansion; +- no speculative engineering-unit/sibling/reflection scan on the critical path. + +The physical R9 AA1E1F06R4 reference remains 1 association, 323 confirmed MMS requests, 138 GetNameList, 119 GetVariableAccessAttributes, 2 GetNamedVariableListAttributes and 64 Reads, while yielding the accepted 32 LD / 119 LN / 860 top-level DO / 906 DO+SDO / 4925 scalar-leaf model. IEDScout's same-relay reference remains the upper comparison envelope at 417 confirmed requests, 119 GVA and 156 Reads. + +Those counts are physical acceptance evidence for AA1E1F06R4, not constants that ARSAS forces onto unrelated IEDs. The source contract instead freezes the discovery algorithm and bounded policy. Future instance-value work must stay in explicit Save SCL or trusted-SCL reconnect phases. + +P1+ semantic/SCL work may change canonical interpretation and export, but it must not add discovery-critical-path MMS traffic or weaken this contract. + + +## P1 — trusted-SCL CF projection-order repair + +P1 is implemented in source and remains pending physical retest. + +The R9 PCAP and the exact R9 Edition 2 IID reproduce the field symptom deterministically: the old positional projector produces exactly **46 errors affecting 191 SCL leaves**, all under **CF**, across **18 FC roots**. The IID contains no `count=` array declarations, so arrays are not the root cause. + +The root cause is representational: SCL `LNodeType` contains one global DataObject order, while MMS exposes a separate DataObject declaration order inside each Functional Constraint structure. For several TCTR, TVTR, MMXU, MSQI, MMTR, RSYN and MHAI nodes, the CF order on wire differs from the SCL DO order. Positional FC-root projection can therefore either fail on leaf-count differences or, worse, silently attach a same-shaped value to the wrong DataObject. + +P1 removes that ambiguity without changing P0 discovery: + +- multi-DO CF groups are planned as exact structured `LN$CF$DO` Read targets; +- each DO response is projected only into that named SCL DataObject; +- the existing Read batching limit remains in force, so this does not become per-leaf traffic; +- non-CF FC-root hydration remains unchanged; +- no extra GetVariableAccessAttributes request is added; +- no second association, discovery pass or supplemental browse is added. + +Physical acceptance for P1 is `projectionErrors=0`, no cross-DO value swap, the same accepted association, and no change to the frozen P0 structural-discovery PCAP signature. + + -> `.`) but never folds case. + +The trusted-SCL diagnostic now reports `projectedUniqueValues`, `initialValueCache`, and `cacheLoss`. Any nonzero cache loss makes the reuse result semantically partial instead of silently successful. + +Physical P2 acceptance is `cacheLoss=0` for both Edition 2 and Edition 1, with LTRK `t` and `T` demonstrably present as separate values. P2 must not add any discovery GVA, association, or P0 wire traffic. + ## Regression signatures that are forbidden A build is rejected if it restores any of these patterns: diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 42eaba3c2..95e84ee1c 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "fd807a4eb6d19235edae08a3eb3c4d2a826ff79b", + "commit": "9935d6902d786cc69b299260fe36b835944d5e81", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head fd807a4eb6d19235edae08a3eb3c4d2a826ff79b preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 9935d6902d786cc69b299260fe36b835944d5e81 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged. P2 makes instance-value identity exact-case end-to-end: ARSAS trusted-SCL caching uses StringComparer.Ordinal and reports projectedUniqueValues/cacheLoss; engine TypeSpecification member resolution and canonical DO/DA instance-value targeting are case-sensitive so legal paths such as tracking t/T cannot collapse or cross-resolve. Physical acceptance requires cacheLoss=0.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index f0f248869..367a03eb7 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -17,7 +17,7 @@ "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "fd807a4eb6d19235edae08a3eb3c4d2a826ff79b" + "head": "9935d6902d786cc69b299260fe36b835944d5e81" }, "consumerIntegration": { "repository": "masarray/arsas", @@ -202,7 +202,8 @@ "targetLoss": 0, "observedProjectedMinusCached": 11, "status": "source-fix-pending-physical-retest", - "rule": "LTRK t and T must remain distinct through initial FC projection and cache materialization." + "rule": "LTRK t and T and any other case-distinct IEC 61850 paths must remain separate through TypeSpecification mapping, initial projection, ARSAS cache, canonical instance evidence, DAI/Val export and reopen. cacheLoss must be zero.", + "sourceRepair": "ARSAS trusted-SCL initial-value cache now uses StringComparer.Ordinal. Engine canonical value dedup, TypeSpecification member resolution, and canonical SCL instance-value targeting are exact-case. Diagnostics expose projectedUniqueValues and cacheLoss." }, "preallocatedAddReportControls": { "observedWithoutDataSet": 30, @@ -287,5 +288,39 @@ "sameAssociation": true, "noAdditionalDiscoveryGva": true } + }, + "p2CaseSensitiveValuePipeline": { + "contractId": "P2-CASE-EXACT-VALUES", + "status": "implemented-source-pending-physical-retest", + "preservesP0StructuralDiscovery": true, + "preservesP1CfScopedHydration": true, + "physicalR9Evidence": { + "edition2ProjectedLeaves": 4250, + "edition2InitialValueCache": 4239, + "observedCacheLoss": 11, + "edition1ProjectedLeaves": 4055, + "edition1InitialValueCache": 4055, + "edition1ObservedCacheLoss": 0 + }, + "sourceInvariants": { + "trustedSclValueCacheComparer": "StringComparer.Ordinal", + "canonicalInstanceValueDedupComparer": "StringComparer.Ordinal", + "typeSpecificationMemberComparison": "StringComparison.Ordinal", + "sclDataObjectInstanceTargetComparison": "StringComparison.Ordinal", + "sclDaBdaSdoComponentComparison": "StringComparison.Ordinal", + "normalizationMayNotFoldCase": true + }, + "diagnosticContract": [ + "projectedUniqueValues", + "initialValueCache", + "cacheLoss" + ], + "expectedPhysicalOutcome": { + "edition2CacheLoss": 0, + "edition1CacheLoss": 0, + "ltrkLowercaseTAndUppercaseTRemainDistinct": true, + "noAdditionalDiscoveryGva": true, + "sameAssociation": true + } } } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 812ee0fb9..c5253bfce 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -210,6 +210,32 @@ public void P1ProjectionOrderRepair_LocksPhysicalRootCauseAndExactStrategy() Assert.Contains("LN$CF$DO", contract, StringComparison.Ordinal); } + [Fact] + public void P2CaseSensitiveValuePipeline_LocksLosslessExactPathIdentity() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + var client = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SclAssisted.cs")); + + Assert.Contains("\"contractId\": \"P2-CASE-EXACT-VALUES\"", contract, StringComparison.Ordinal); + Assert.Contains("\"observedCacheLoss\": 11", contract, StringComparison.Ordinal); + Assert.Contains("\"edition2CacheLoss\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"edition1CacheLoss\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"ltrkLowercaseTAndUppercaseTRemainDistinct\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"noAdditionalDiscoveryGva\": true", contract, StringComparison.Ordinal); + + var cacheDeclaration = client.IndexOf("_trustedSclInitialValues =", StringComparison.Ordinal); + Assert.True(cacheDeclaration >= 0); + var cacheSegment = client.Substring(cacheDeclaration, Math.Min(260, client.Length - cacheDeclaration)); + Assert.Contains("StringComparer.Ordinal", cacheSegment, StringComparison.Ordinal); + Assert.DoesNotContain("StringComparer.OrdinalIgnoreCase", cacheSegment, StringComparison.Ordinal); + + Assert.Contains("projectedInitialValueKeys", client, StringComparison.Ordinal); + Assert.Contains("projectedUniqueValues", client, StringComparison.Ordinal); + Assert.Contains("initialValueCacheLoss", client, StringComparison.Ordinal); + Assert.Contains("cacheLoss={initialValueCacheLoss}", client, StringComparison.Ordinal); + Assert.Contains("NormalizeTrustedSclReference", client, StringComparison.Ordinal); + } + [Fact] public void R9PhysicalReuse_LocksWorkingPathAndKeepsSemanticProjectionGapOpen() { @@ -310,7 +336,7 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("fd807a4eb6d19235edae08a3eb3c4d2a826ff79b", contract, StringComparison.Ordinal); + Assert.Contains("9935d6902d786cc69b299260fe36b835944d5e81", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -348,7 +374,7 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"fd807a4eb6d19235edae08a3eb3c4d2a826ff79b\"", + "\"commit\": \"9935d6902d786cc69b299260fe36b835944d5e81\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From e2aafb2e9e76b279d130ccd08bfacc52c743ec0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 08:22:55 +0700 Subject: [PATCH 242/243] test(convergence): lock R10 physical P1 P2 acceptance --- .../workflows/iedscout-convergence-guard.yml | 65 ++++- docs/IEDSCOUT_CONVERGENCE.md | 261 +++++++----------- engines/ARIEC61850.lock.json | 9 +- evidence/iedscout-convergence-target.json | 210 ++++++++++++-- .../CanonicalLiveSclExportRegressionTests.cs | 45 ++- 5 files changed, 396 insertions(+), 194 deletions(-) diff --git a/.github/workflows/iedscout-convergence-guard.yml b/.github/workflows/iedscout-convergence-guard.yml index 725208cfc..9e977475f 100644 --- a/.github/workflows/iedscout-convergence-guard.yml +++ b/.github/workflows/iedscout-convergence-guard.yml @@ -49,14 +49,14 @@ jobs: $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json $target = Get-Content .\ARSAS\evidence\iedscout-convergence-target.json -Raw | ConvertFrom-Json - if ($target.status -ne 'physical-retest-required') { + if ($target.status -ne 'physical-retest-passed-merge-ready') { throw "Convergence status changed unexpectedly: $($target.status)" } if ([int]$target.activeStack.enginePerformance.pullRequest -ne 134 -or [int]$target.activeStack.engineModelAndScl.pullRequest -ne 135 -or [int]$target.activeStack.engineModelAndScl.basePullRequest -ne 134 -or [int]$target.activeStack.consumerIntegration.pullRequest -ne 324) { - throw 'Active stack must remain ARIEC61850 #134 -> #135 -> ARSAS #324.' + throw 'Merged engine authority must remain PR #134 + PR #135 with ARSAS #324 provenance.' } if ($lock.commit -ne $target.activeStack.engineModelAndScl.head -or [int]$lock.sourcePullRequest -ne 135) { @@ -64,8 +64,10 @@ jobs: } if ([bool]$target.promotion.productionPromoted -or [bool]$target.promotion.mergeAllowedBeforePhysicalRetest -or - -not [bool]$target.promotion.physicalRetestRequired) { - throw 'Physical retest gate was weakened.' + [bool]$target.promotion.physicalRetestRequired -or + -not [bool]$target.promotion.mergeAllowedAfterPhysicalRetest -or + -not [bool]$target.promotion.physicalRetestPassed) { + throw 'R10 physical-retest/merge gate drifted.' } if ([int]$target.physicalReference.iedScoutCapture.associations -ne 1 -or [int]$target.physicalReference.iedScoutCapture.getVariableAccessAttributes -ne 119 -or @@ -125,9 +127,22 @@ jobs: throw 'P0 AA1E1F06R4 discovery acceptance contract drifted.' } + $p1 = $target.p1ProjectionOrderRepair + if ($p1.contractId -ne 'P1-CF-DO-SCOPED' -or + $p1.status -ne 'physically-proven-r10' -or + [int]$p1.physicalR10Outcome.edition2.projectionErrors -ne 0 -or + [int]$p1.physicalR10Outcome.edition1.projectionErrors -ne 0 -or + [int]$p1.physicalR10Outcome.edition2.failedReads -ne 0 -or + [int]$p1.physicalR10Outcome.edition1.failedReads -ne 0 -or + [int]$p1.physicalR10Outcome.reportBackedRuntimePoints -ne 58 -or + [int]$p1.physicalR10Outcome.unresolvedRuntimePoints -ne 0 -or + -not [bool]$p1.physicalR10Outcome.actualInformationReportObserved) { + throw 'P1 physical projection-order proof regressed.' + } + $p2 = $target.p2CaseSensitiveValuePipeline if ($p2.contractId -ne 'P2-CASE-EXACT-VALUES' -or - $p2.status -ne 'implemented-source-pending-physical-retest' -or + $p2.status -ne 'physically-proven-r10' -or -not [bool]$p2.preservesP0StructuralDiscovery -or -not [bool]$p2.preservesP1CfScopedHydration -or [int]$p2.physicalR9Evidence.observedCacheLoss -ne 11 -or @@ -136,12 +151,44 @@ jobs: -not [bool]$p2.expectedPhysicalOutcome.ltrkLowercaseTAndUppercaseTRemainDistinct -or -not [bool]$p2.expectedPhysicalOutcome.noAdditionalDiscoveryGva -or -not [bool]$p2.expectedPhysicalOutcome.sameAssociation -or + [int]$p2.physicalR10Outcome.edition2.cacheLoss -ne 0 -or + [int]$p2.physicalR10Outcome.edition1.cacheLoss -ne 0 -or + [int]$p2.physicalR10Outcome.edition2.projectedUniqueValues -ne [int]$p2.physicalR10Outcome.edition2.initialValueCache -or + [int]$p2.physicalR10Outcome.edition1.projectedUniqueValues -ne [int]$p2.physicalR10Outcome.edition1.initialValueCache -or $p2.sourceInvariants.trustedSclValueCacheComparer -ne 'StringComparer.Ordinal' -or $p2.sourceInvariants.typeSpecificationMemberComparison -ne 'StringComparison.Ordinal' -or $p2.sourceInvariants.sclDataObjectInstanceTargetComparison -ne 'StringComparison.Ordinal') { throw 'P2 exact-case value pipeline contract was weakened.' } + $r10 = $target.physicalEvidence.arsasR10 + if ($r10.testedArtifact.appHead -ne 'eb8eb13d491f9aa265205852b8a4bab07af440ff' -or + $r10.testedArtifact.engineTestedHead -ne '9935d6902d786cc69b299260fe36b835944d5e81' -or + $r10.testedArtifact.engineMergedMain -ne '648124097621046f5f127ceb1cf853fea54db730' -or + [int]$r10.discovery.associations -ne 1 -or + [int]$r10.discovery.confirmedMmsRequests -ne 323 -or + [int]$r10.discovery.getVariableAccessAttributes -ne 119 -or + [int]$r10.reuseEdition2.matchedDomains -ne 32 -or + [int]$r10.reuseEdition2.initialTargets -ne 709 -or + [int]$r10.reuseEdition2.successfulReads -ne 709 -or + [int]$r10.reuseEdition2.failedReads -ne 0 -or + [int]$r10.reuseEdition2.projectionErrors -ne 0 -or + [int]$r10.reuseEdition2.cacheLoss -ne 0 -or + [int]$r10.reuseEdition2.reportBackedRuntimePoints -ne 58 -or + [int]$r10.reuseEdition2.unresolvedRuntimePoints -ne 0 -or + -not [bool]$r10.reuseEdition2.actualInformationReportObserved -or + [int]$r10.reuseEdition1.matchedDomains -ne 32 -or + [int]$r10.reuseEdition1.initialTargets -ne 708 -or + [int]$r10.reuseEdition1.successfulReads -ne 708 -or + [int]$r10.reuseEdition1.failedReads -ne 0 -or + [int]$r10.reuseEdition1.projectionErrors -ne 0 -or + [int]$r10.reuseEdition1.cacheLoss -ne 0 -or + [int]$r10.reuseEdition1.reportBackedRuntimePoints -ne 58 -or + [int]$r10.reuseEdition1.unresolvedRuntimePoints -ne 0 -or + -not [bool]$r10.reuseEdition1.actualInformationReportObserved) { + throw 'R10 physical SCL reuse acceptance evidence regressed.' + } + $r9 = $target.physicalEvidence.arsasR9 if ([int]$r9.discovery.associations -ne 1 -or [int]$r9.discovery.confirmedMmsRequests -ne 323 -or @@ -172,9 +219,13 @@ jobs: if ([int]$target.openGaps.trustedSclProjectionParity.targetProjectionErrors -ne 0 -or [int]$target.openGaps.trustedSclProjectionParity.observedEdition2 -ne [int]$r9.reuseEdition2.projectionErrors -or [int]$target.openGaps.trustedSclProjectionParity.observedEdition1 -ne [int]$r9.reuseEdition1.projectionErrors -or + [int]$target.openGaps.trustedSclProjectionParity.r10ObservedEdition2 -ne 0 -or + [int]$target.openGaps.trustedSclProjectionParity.r10ObservedEdition1 -ne 0 -or [int]$target.openGaps.edition2CaseDistinctInitialValueCache.observedProjectedMinusCached -ne 11 -or - [int]$target.openGaps.preallocatedAddReportControls.observedWithoutDataSet -ne 30) { - throw 'Known R9 semantic gaps were silently weakened or detached from physical evidence.' + [int]$target.openGaps.edition2CaseDistinctInitialValueCache.r10ObservedProjectedMinusCached -ne 0 -or + [int]$target.openGaps.preallocatedAddReportControls.observedWithoutDataSet -ne 30 -or + [int]$target.openGaps.preallocatedAddReportControls.r10FatalMissingDatasetErrors -ne 0) { + throw 'R9 history or R10 resolved semantic evidence drifted.' } "ENGINE_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md index 470bab84e..673294a00 100644 --- a/docs/IEDSCOUT_CONVERGENCE.md +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -2,216 +2,159 @@ ## Product target -ARSAS has one active IEC 61850 convergence target: +ARSAS targets IEDScout-equivalent IEC 61850 engineering semantics with lower wire cost where possible: -1. **Discovery parity:** one accepted MMS association, structure-first bounded discovery, no supplemental legacy browse, no recursive per-leaf GVA storm, and physical performance comparable to IEDScout on the same relay. -2. **Canonical model correctness:** exact Logical Node identity, correct DO/SDO hierarchy, Functional Constraint ownership from evidence, complete standard semantic authority where proven, and no model deletion used as a safety mechanism. -3. **Saved SCL usability:** full-model IID/ICD must reopen in ARSAS, rebuild the exact accepted association plan, reconnect to the physical relay, and remain usable for bounded initial reads and static reporting. +1. one accepted MMS association and bounded structure-first discovery; +2. a complete canonical model with exact LN/DO/SDO/DA/FC identity; +3. Edition 2 IID / Edition 1 ICD that can be reopened by ARSAS, reconnect to the same relay, hydrate values without full discovery, and run configured static reporting. The machine-readable authority is `evidence/iedscout-convergence-target.json`. -Current R8 model-repair engine authority: `9935d6902d786cc69b299260fe36b835944d5e81`. +## Merged proven baseline -## Active stacked PRs +The physical R10 baseline was tested with: -Only this stack is active for this target: +- ARSAS `eb8eb13d491f9aa265205852b8a4bab07af440ff`; +- ARIEC61850 tested head `9935d6902d786cc69b299260fe36b835944d5e81`; +- ARIEC61850 merged-main commit `648124097621046f5f127ceb1cf853fea54db730`. -- ARIEC61850 PR #134 — discovery/performance authority. -- ARIEC61850 PR #135 — canonical model, association evidence, semantic SCL and schema authority, stacked on #134. -- ARSAS PR #324 — exact consumer integration and physical-evidence build. +The tested engine head and merged-main commit have the identical tree SHA +`1cf7e08f333f24994625e8fe8416dbd0a16195b1`. -No new discovery/SCL work should branch from older trial PRs. Old PRs remain provenance only unless explicitly revalidated and restacked onto the active authority. +Merged engine provenance: +- PR #134 → main merge `e6779ff74e5716af4fcfc3dc926dae0567b3cdb0`: Smart Discovery performance authority. +- PR #135 → main merge `648124097621046f5f127ceb1cf853fea54db730`: canonical model, SCL interoperability, P1/P2 value pipeline. +- ARSAS PR #324: consumer integration and physical R10 proof. ## P0 — structural discovery freeze -P0 is complete at source/CI level and is anchored to the physical R9 AA1E1F06R4 result. +Contract: `P0-R9-STRUCTURAL`. -Runtime contract ID: `P0-R9-STRUCTURAL`. +AA1E1F06R4 physical reference is locked at one association, 323 confirmed MMS +requests, 138 GetNameList, 119 GetVariableAccessAttributes, 2 +GetNamedVariableListAttributes and 64 Reads, while preserving 32 LD / 119 LN / +860 top-level DO / 906 DO+SDO / 4925 scalar leaves / 2 DataSets / 58 FCDA / 32 +logical ReportControls / 1 SettingControl. -The frozen ARSAS discovery path is: +The same-relay IEDScout comparison remains about 417 confirmed requests, 119 GVA +and 156 Reads. ARSAS must not add traffic merely to imitate IEDScout. -- one accepted MMS association; -- one association-scoped single-flight owner; -- exclusive application MMS gate while the discovery owner is active; -- bounded directory discovery through `DiscoverSmartSingleFlightAsync`; -- coverage-aware LN/root type probing through `ProbeSmartAsync`; -- maximum discovery chains 8, or 4 when the peer's negotiated calling window is unknown; -- bounded report metadata and DataSet-directory enrichment only; -- no eager initial FC-root value snapshot; -- no supplemental legacy directory browse; -- no second full GetNameList sweep; -- no recursive per-leaf GVA expansion; -- no speculative engineering-unit/sibling/reflection scan on the critical path. +Forbidden regressions include a second discovery association, legacy supplemental +browse, a second full GetNameList sweep, recursive per-leaf GVA, and eager FC-root +value hydration on the discovery critical path. -The physical R9 AA1E1F06R4 reference remains 1 association, 323 confirmed MMS requests, 138 GetNameList, 119 GetVariableAccessAttributes, 2 GetNamedVariableListAttributes and 64 Reads, while yielding the accepted 32 LD / 119 LN / 860 top-level DO / 906 DO+SDO / 4925 scalar-leaf model. IEDScout's same-relay reference remains the upper comparison envelope at 417 confirmed requests, 119 GVA and 156 Reads. +## P1 — CF projection-order repair: physically proven -Those counts are physical acceptance evidence for AA1E1F06R4, not constants that ARSAS forces onto unrelated IEDs. The source contract instead freezes the discovery algorithm and bounded policy. Future instance-value work must stay in explicit Save SCL or trusted-SCL reconnect phases. +Contract: `P1-CF-DO-SCOPED`. -P1+ semantic/SCL work may change canonical interpretation and export, but it must not add discovery-critical-path MMS traffic or weaken this contract. +R9 exposed 46 projection errors because SCL LNodeType DO order was incorrectly used +as MMS CF-root child order. P1 reads multi-DO CF data through exact `LN$CF$DO` +references and batches those structured reads. +R10 physical reuse closes P1: -## P1 — trusted-SCL CF projection-order repair +- Ed2: 709 initial targets, 525 FC-root targets, 184 DO-scoped targets, + 709/709 successful, 0 failed, `projectionErrors=0`. +- Ed1: 708 initial targets, 524 FC-root targets, 184 DO-scoped targets, + 708/708 successful, 0 failed, `projectionErrors=0`. -P1 is implemented in source and remains pending physical retest. +No extra discovery GVA or second association was introduced. -The R9 PCAP and the exact R9 Edition 2 IID reproduce the field symptom deterministically: the old positional projector produces exactly **46 errors affecting 191 SCL leaves**, all under **CF**, across **18 FC roots**. The IID contains no `count=` array declarations, so arrays are not the root cause. +## P2 — exact-case value pipeline: physically proven -The root cause is representational: SCL `LNodeType` contains one global DataObject order, while MMS exposes a separate DataObject declaration order inside each Functional Constraint structure. For several TCTR, TVTR, MMXU, MSQI, MMTR, RSYN and MHAI nodes, the CF order on wire differs from the SCL DO order. Positional FC-root projection can therefore either fail on leaf-count differences or, worse, silently attach a same-shaped value to the wrong DataObject. +Contract: `P2-CASE-EXACT-VALUES`. -P1 removes that ambiguity without changing P0 discovery: +R9 Ed2 lost 11 projected values because a consumer cache used case-insensitive +identity. P2 uses exact-case identity through TypeSpecification mapping, initial +projection, trusted-SCL caching, canonical evidence and SCL instance-value targeting. -- multi-DO CF groups are planned as exact structured `LN$CF$DO` Read targets; -- each DO response is projected only into that named SCL DataObject; -- the existing Read batching limit remains in force, so this does not become per-leaf traffic; -- non-CF FC-root hydration remains unchanged; -- no extra GetVariableAccessAttributes request is added; -- no second association, discovery pass or supplemental browse is added. +R10 physical reuse closes P2: -Physical acceptance for P1 is `projectionErrors=0`, no cross-DO value swap, the same accepted association, and no change to the frozen P0 structural-discovery PCAP signature. +- Ed2: `projectedUniqueValues=4441`, `initialValueCache=4441`, `cacheLoss=0`. +- Ed1: `projectedUniqueValues=4246`, `initialValueCache=4246`, `cacheLoss=0`. +## R10 round-trip/reporting acceptance -## P2 — lossless case-sensitive value pipeline +Both generated editions reopen as trusted SCL and reconnect without full discovery: -P2 is implemented in source and remains pending physical retest. +- expected/observed/matched MMS domains: 32/32/32; +- DataSets: 2, members: 58, missing members: 0; +- configured report plans: Digital BRCB 36 members + Analog URCB 22 members; +- report-covered runtime points: 58; +- final unresolved runtime points: 0; +- cyclic MMS process polling: 0; +- actual InformationReport traffic observed on both editions. -R9 Edition 2 projected 4250 scalar values but retained only 4239 in the ARSAS trusted-SCL cache: an exact loss of 11. Edition 1 projected and retained 4055/4055. The remaining consumer-side cause was explicit: `_trustedSclInitialValues` used `StringComparer.OrdinalIgnoreCase`, so legal IEC 61850 paths that differ only by case could overwrite one another. +The early UI field `Primary unresolved=2` is not an operational loss: the exact +static DataSet schema resolves all 58 runtime points before reporting starts. -P2 makes instance-value identity exact-case across the full path: +## Save-time instance values -- LN-root TypeSpecification member resolution compares component names with `StringComparison.Ordinal`; -- canonical initial-value dedup uses `StringComparer.Ordinal`; -- ARSAS trusted-SCL initial-value cache uses `StringComparer.Ordinal`; -- canonical SCL DataObject targeting is case-sensitive; -- DA/BDA/SDO path targeting remains case-sensitive; -- reference normalization may change MMS separators (`# IEDScout Convergence Contract +R10 physically proves bounded Save SCL enrichment while fast discovery stays +unchanged: -## Product target - -ARSAS has one active IEC 61850 convergence target: - -1. **Discovery parity:** one accepted MMS association, structure-first bounded discovery, no supplemental legacy browse, no recursive per-leaf GVA storm, and physical performance comparable to IEDScout on the same relay. -2. **Canonical model correctness:** exact Logical Node identity, correct DO/SDO hierarchy, Functional Constraint ownership from evidence, complete standard semantic authority where proven, and no model deletion used as a safety mechanism. -3. **Saved SCL usability:** full-model IID/ICD must reopen in ARSAS, rebuild the exact accepted association plan, reconnect to the physical relay, and remain usable for bounded initial reads and static reporting. - -The machine-readable authority is `evidence/iedscout-convergence-target.json`. - -Current R8 model-repair engine authority: `9935d6902d786cc69b299260fe36b835944d5e81`. - -## Active stacked PRs - -Only this stack is active for this target: - -- ARIEC61850 PR #134 — discovery/performance authority. -- ARIEC61850 PR #135 — canonical model, association evidence, semantic SCL and schema authority, stacked on #134. -- ARSAS PR #324 — exact consumer integration and physical-evidence build. - -No new discovery/SCL work should branch from older trial PRs. Old PRs remain provenance only unless explicitly revalidated and restacked onto the active authority. - - -## P0 — structural discovery freeze - -P0 is complete at source/CI level and is anchored to the physical R9 AA1E1F06R4 result. +- canonical instance evidence: 3854 leaves; +- Ed2 exported `Val`: 3202; +- Ed1 exported `Val`: 3106. -Runtime contract ID: `P0-R9-STRUCTURAL`. +IEDScout's golden file has about 1529 `Val` elements. A larger count is not +automatically better; the remaining task is semantic path/value comparison, not +count chasing. -The frozen ARSAS discovery path is: +## RCB lock -- one accepted MMS association; -- one association-scoped single-flight owner; -- exclusive application MMS gate while the discovery owner is active; -- bounded directory discovery through `DiscoverSmartSingleFlightAsync`; -- coverage-aware LN/root type probing through `ProbeSmartAsync`; -- maximum discovery chains 8, or 4 when the peer's negotiated calling window is unknown; -- bounded report metadata and DataSet-directory enrichment only; -- no eager initial FC-root value snapshot; -- no supplemental legacy directory browse; -- no second full GetNameList sweep; -- no recursive per-leaf GVA expansion; -- no speculative engineering-unit/sibling/reflection scan on the critical path. +The accepted representation remains: -The physical R9 AA1E1F06R4 reference remains 1 association, 323 confirmed MMS requests, 138 GetNameList, 119 GetVariableAccessAttributes, 2 GetNamedVariableListAttributes and 64 Reads, while yielding the accepted 32 LD / 119 LN / 860 top-level DO / 906 DO+SDO / 4925 scalar-leaf model. IEDScout's same-relay reference remains the upper comparison envelope at 417 confirmed requests, 119 GVA and 156 Reads. +- 34 runtime RCB objects → 32 logical SCL ReportControls; +- `Services/ConfReportControl max=34`; +- Buffer/Digital and Unbuffer/Analog remain the two configured report authorities; +- preallocated ADD slots without DataSet never receive an invented `datSet`. -Those counts are physical acceptance evidence for AA1E1F06R4, not constants that ARSAS forces onto unrelated IEDs. The source contract instead freezes the discovery algorithm and bounded policy. Future instance-value work must stay in explicit Save SCL or trusted-SCL reconnect phases. +## Next improvements — do not disturb the proven wire/reuse path -P1+ semantic/SCL work may change canonical interpretation and export, but it must not add discovery-critical-path MMS traffic or weaken this contract. +### 1. Template interning +R10 Ed2 is semantically correct but verbose: -## P1 — trusted-SCL CF projection-order repair +- ARSAS: 119 LNodeType / 906 DOType / 752 DAType / about 731 KB; +- IEDScout reference: about 38 / 60 / 17 / about 247 KB. -P1 is implemented in source and remains pending physical retest. +Next work may intern only templates with identical ordered semantic fingerprints. +Expanded model counts, FC ownership, values, DataSets/RCBs and round-trip behavior +must remain unchanged. -The R9 PCAP and the exact R9 Edition 2 IID reproduce the field symptom deterministically: the old positional projector produces exactly **46 errors affecting 191 SCL leaves**, all under **CF**, across **18 FC roots**. The IID contains no `count=` array declarations, so arrays are not the root cause. +### 2. Reuse one save-enrichment snapshot across Ed2 and Ed1 -The root cause is representational: SCL `LNodeType` contains one global DataObject order, while MMS exposes a separate DataObject declaration order inside each Functional Constraint structure. For several TCTR, TVTR, MMXU, MSQI, MMTR, RSYN and MHAI nodes, the CF order on wire differs from the SCL DO order. Positional FC-root projection can therefore either fail on leaf-count differences or, worse, silently attach a same-shaped value to the wrong DataObject. +When Ed2 and Ed1 are saved in the same unchanged live association/model generation, +both currently derive the same 3854 instance-evidence leaves. A later optimization +may reuse that bounded snapshot across serializers, but never across reconnect, +model-generation change or explicit refresh. -P1 removes that ambiguity without changing P0 discovery: +### 3. Semantic Val diff -- multi-DO CF groups are planned as exact structured `LN$CF$DO` Read targets; -- each DO response is projected only into that named SCL DataObject; -- the existing Read batching limit remains in force, so this does not become per-leaf traffic; -- non-CF FC-root hydration remains unchanged; -- no extra GetVariableAccessAttributes request is added; -- no second association, discovery pass or supplemental browse is added. - -Physical acceptance for P1 is `projectionErrors=0`, no cross-DO value swap, the same accepted association, and no change to the frozen P0 structural-discovery PCAP signature. - - -> `.`) but never folds case. - -The trusted-SCL diagnostic now reports `projectedUniqueValues`, `initialValueCache`, and `cacheLoss`. Any nonzero cache loss makes the reuse result semantically partial instead of silently successful. - -Physical P2 acceptance is `cacheLoss=0` for both Edition 2 and Edition 1, with LTRK `t` and `T` demonstrably present as separate values. P2 must not add any discovery GVA, association, or P0 wire traffic. +Compare ARSAS vs IEDScout by exact +`LD/LN/DO/SDO/DA/BDA/FC/bType/value` path, not raw XML position and not total +`Val` count. ## Regression signatures that are forbidden A build is rejected if it restores any of these patterns: -- public discovery routes to legacy `DiscoverAsync` instead of the smart field-test route; -- a second supplemental MMS association is opened for discovery; -- recursive per-leaf GetVariableAccessAttributes expansion replaces structure-first probing; -- thousands of speculative sibling/engineering-unit Reads return to the discovery critical path; -- prefixed LN names are split from the first uppercase run rather than the numeric instance boundary; -- WYE/DEL/SEQ nested Data Objects are flattened into Data Attributes; -- descendant CF attributes inherit MX from a measurement parent; -- standard TCTR/TVTR/LTIM/EEName/MltLev objects are discarded because heuristic CDC inference is incomplete; -- Edition 2 LTRK tracking CDCs are emitted into Edition 1 SCL; -- case-distinct MMS/SCL member names such as LTRK `t` and `T` are collapsed by case-insensitive indexing or export trees; -- runtime RCB siblings are exported as separate logical ReportControl objects solely because mutable RCB settings differ; -- canonical save silently succeeds without reopen + association-plan validation. - -## Physical acceptance - -CI can prove source contracts, deterministic semantics, round-trip parsing, build integrity and portable smoke. It cannot prove IEDScout parity. - -Production promotion remains blocked until AA1E1F06R4 is retested with the exact candidate artifact and produces: - -- new PCAP; -- new Edition 2 IID; -- new Edition 1 ICD; -- diagnostic report; -- same-relay comparison against IEDScout. - -The field result, not test count alone, decides whether the convergence target has been reached. - - -## R9 physical reuse lock — AA1E1F06R4 - -The R9 artifact (ARSAS `a89d6ef...`, engine `3e12fb9...`) established a split acceptance result that must not be flattened into a single pass/fail label. - -**Locked as working and non-regressible** - -- Smart Discovery remains one association and structure-first: 323 confirmed MMS requests, 138 GetNameList, 119 LN-root GVA, 2 GetNamedVariableListAttributes and 64 Reads. -- Edition 2 structural export reached 32 LD / 119 LN / 860 top-level DO / 906 DO+SDO / 4925 scalar leaves / 2 DataSets / 58 FCDA / 32 logical ReportControls / 1 SettingControl. -- Reopened Ed2 IID and Ed1 ICD both rebuilt the accepted association and matched 32/32 MMS domains. -- All trusted-SCL initial FC-root Reads completed on both editions (Ed2 563/563; Ed1 562/562). -- Both editions preserved 2 static DataSets and the two configured reporting plans (Digital BRCB + Analog URCB), resolved 58/58 runtime points with 0 unavailable points, disabled cyclic process polling, and received actual InformationReport traffic. +- legacy `DiscoverAsync` as the public discovery route; +- a second supplemental discovery association; +- recursive per-leaf GVA expansion; +- speculative thousands of Reads on the discovery path; +- cross-DO positional CF projection; +- case-insensitive IEC 61850 member/value identity; +- WYE/DEL/SEQ SDO flattening; +- Edition 2 tracking CDCs in Edition 1; +- invented DataSet bindings for unassigned RCB slots; +- silent canonical-save success without reopen/association validation. -**Still open and must not be marked converged** +## Promotion state -- Both editions still report exactly 46 initial FC projection errors. This is a semantic SCL/MMS shape problem, not a transport/association problem; target is zero. -- Ed2 projected 4250 leaves but cached only 4239. The exact 11-leaf loss matches the previously isolated LTRK case-distinct `t` / `T` collapse. The source fix is present but remains pending physical retest. -- R9 emitted 30 ADD preallocated URCB slots without a DataSet and with concrete runtime `...01` names. Never invent a DataSet. rptID-backed singleton slots must export as indexed logical ReportControl with `RptEnabled max=1`; unassigned indexed slots are warnings, not fatal missing-DataSet errors. -- R9 exported zero instance `` elements. Save-time bounded enrichment is a separate explicit phase and must not reintroduce eager FC-root Reads into Smart Discovery. -- Template deduplication remains secondary and must not trade away semantic correctness. +The R10 physical retest has passed and merge is allowed. Production promotion is +still a separate release decision. -Future SCL-assisted diagnostics must include representative projection-error details (root + mismatch) so the remaining 46 errors can be fixed from direct evidence rather than inferred from a summary count. +The field result, not test count alone, decides interoperability acceptance. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 95e84ee1c..360e72a96 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "9935d6902d786cc69b299260fe36b835944d5e81", + "commit": "648124097621046f5f127ceb1cf853fea54db730", "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 9935d6902d786cc69b299260fe36b835944d5e81 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged. P2 makes instance-value identity exact-case end-to-end: ARSAS trusted-SCL caching uses StringComparer.Ordinal and reports projectedUniqueValues/cacheLoss; engine TypeSpecification member resolution and canonical DO/DA instance-value targeting are case-sensitive so legal paths such as tracking t/T cannot collapse or cross-resolve. Physical acceptance requires cacheLoss=0.", + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 648124097621046f5f127ceb1cf853fea54db730 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged. P2 makes instance-value identity exact-case end-to-end: ARSAS trusted-SCL caching uses StringComparer.Ordinal and reports projectedUniqueValues/cacheLoss; engine TypeSpecification member resolution and canonical DO/DA instance-value targeting are case-sensitive so legal paths such as tracking t/T cannot collapse or cross-resolve. R10 physical acceptance passed on AA1E1F06R4: Ed2 and Ed1 both reached projectionErrors=0 and cacheLoss=0, all planned reads succeeded, 58/58 runtime points were report-backed, and actual InformationReport traffic was observed. The exact tested commit 9935d6902d786cc69b299260fe36b835944d5e81 and merged main commit 648124097621046f5f127ceb1cf853fea54db730 have the identical source tree 1cf7e08f333f24994625e8fe8416dbd0a16195b1.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -19,5 +19,8 @@ "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", "sourcePullRequest": 111, "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." - } + }, + "physicalTestedCommit": "9935d6902d786cc69b299260fe36b835944d5e81", + "mergedMainCommit": "648124097621046f5f127ceb1cf853fea54db730", + "mergedMainTree": "1cf7e08f333f24994625e8fe8416dbd0a16195b1" } diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json index 367a03eb7..a853fd0ea 100644 --- a/evidence/iedscout-convergence-target.json +++ b/evidence/iedscout-convergence-target.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "name": "IEDScout discovery and SCL convergence", - "status": "physical-retest-required", + "status": "physical-retest-passed-merge-ready", "target": { "discovery": "Reach IEDScout-like MMS discovery efficiency without sacrificing exact IEC 61850 model evidence.", "model": "Build the canonical IEC 61850 model from structure-first MMS evidence with correct LN identity, DO/SDO hierarchy, FC ownership, DataSet/RCB semantics, and schema-aware CDC handling.", @@ -11,18 +11,25 @@ "enginePerformance": { "repository": "masarray/ARIEC61850", "pullRequest": 134, - "head": "a31e396f0bbc9507215a8c9d125e910555ed6f0a" + "head": "a31e396f0bbc9507215a8c9d125e910555ed6f0a", + "status": "merged", + "testedHead": "a31e396f0bbc9507215a8c9d125e910555ed6f0a", + "mainMerge": "e6779ff74e5716af4fcfc3dc926dae0567b3cdb0" }, "engineModelAndScl": { "repository": "masarray/ARIEC61850", "pullRequest": 135, "basePullRequest": 134, - "head": "9935d6902d786cc69b299260fe36b835944d5e81" + "head": "648124097621046f5f127ceb1cf853fea54db730", + "status": "merged", + "testedHead": "9935d6902d786cc69b299260fe36b835944d5e81", + "mainMerge": "648124097621046f5f127ceb1cf853fea54db730" }, "consumerIntegration": { "repository": "masarray/arsas", "pullRequest": 324, - "branch": "test/smart-ied-discovery-pr134" + "branch": "test/smart-ied-discovery-pr134", + "status": "physical-proven-merge-ready" } }, "physicalReference": { @@ -112,7 +119,7 @@ "promotion": { "productionPromoted": false, "mergeAllowedBeforePhysicalRetest": false, - "physicalRetestRequired": true, + "physicalRetestRequired": false, "requiredEvidence": [ "new ARSAS PCAP", "new generated Ed2 IID", @@ -121,7 +128,11 @@ "Ed2 SCL-assisted reuse diagnostic with projectionErrorSamples", "Ed1 SCL-assisted reuse diagnostic with projectionErrorSamples", "same-relay comparison against IEDScout" - ] + ], + "mergeAllowedAfterPhysicalRetest": true, + "physicalRetestPassed": true, + "physicalRetestDate": "2026-09-19", + "note": "R10 physical discovery/save/reopen evidence passed for the exact ARSAS/engine source tree. Merge is allowed; production promotion remains a separate release decision." }, "legacyPolicy": { "rule": "Historical PRs may remain as provenance only; they must not be used as new branch bases after an active convergence authority supersedes them.", @@ -186,6 +197,105 @@ "unresolvedRuntimePoints": 0, "actualInformationReportObserved": true } + }, + "arsasR10": { + "relay": "AA1E1F06R4", + "testedArtifact": { + "appHead": "eb8eb13d491f9aa265205852b8a4bab07af440ff", + "engineTestedHead": "9935d6902d786cc69b299260fe36b835944d5e81", + "engineMergedMain": "648124097621046f5f127ceb1cf853fea54db730", + "engineTree": "1cf7e08f333f24994625e8fe8416dbd0a16195b1" + }, + "discovery": { + "associations": 1, + "confirmedMmsRequests": 323, + "getNameList": 138, + "getVariableAccessAttributes": 119, + "getNamedVariableListAttributes": 2, + "reads": 64, + "topLevelDataObjects": 860, + "dataObjectsIncludingSdo": 906, + "scalarLeaves": 4925, + "dataSets": 2, + "fcda": 58, + "logicalReportControls": 32, + "settingControls": 1 + }, + "saveScl": { + "instanceEvidence": 3854, + "edition2ValCount": 3202, + "edition1ValCount": 3106, + "edition2RoundTrip": { + "logicalDevices": 32, + "logicalNodes": 119, + "dataSets": 2, + "logicalReportControls": 32 + }, + "edition1RoundTrip": { + "logicalDevices": 32, + "logicalNodes": 119, + "dataSets": 2, + "logicalReportControls": 32 + } + }, + "reuseEdition2": { + "expectedDomains": 32, + "observedDomains": 32, + "matchedDomains": 32, + "missingDomains": 0, + "extraDomains": 0, + "logicalDevices": 32, + "logicalNodes": 119, + "topLevelDataObjects": 860, + "dataAttributes": 5677, + "initialTargets": 709, + "fcRootTargets": 525, + "doScopedTargets": 184, + "successfulReads": 709, + "failedReads": 0, + "projectedLeaves": 4441, + "projectedUniqueValues": 4441, + "initialValueCache": 4441, + "cacheLoss": 0, + "projectionErrors": 0, + "staticDataSets": 2, + "staticMembers": 58, + "staticReportControls": 32, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "primaryUnresolvedAtSelection": 2, + "actualInformationReportObserved": true, + "cyclicMmsProcessPolling": 0 + }, + "reuseEdition1": { + "expectedDomains": 32, + "observedDomains": 32, + "matchedDomains": 32, + "missingDomains": 0, + "extraDomains": 0, + "logicalDevices": 32, + "logicalNodes": 119, + "topLevelDataObjects": 845, + "dataAttributes": 5470, + "initialTargets": 708, + "fcRootTargets": 524, + "doScopedTargets": 184, + "successfulReads": 708, + "failedReads": 0, + "projectedLeaves": 4246, + "projectedUniqueValues": 4246, + "initialValueCache": 4246, + "cacheLoss": 0, + "projectionErrors": 0, + "staticDataSets": 2, + "staticMembers": 58, + "staticReportControls": 32, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "primaryUnresolvedAtSelection": 2, + "actualInformationReportObserved": true, + "cyclicMmsProcessPolling": 0 + } } }, "openGaps": { @@ -193,32 +303,64 @@ "targetProjectionErrors": 0, "observedEdition2": 46, "observedEdition1": 46, - "status": "source-fix-pending-physical-retest", + "status": "resolved-r10-physical", "rule": "Do not classify SCL reuse as semantically converged until projectionErrors reaches zero on both editions. Successful MMS Reads alone are insufficient.", "rootCause": "R9 PCAP + Ed2 IID reproduction matched all 46 physical errors: every mismatch is in CF, across 18 FC roots / 191 affected SCL leaves, caused by cross-DO positional ordering differences between SCL LNodeType order and the MMS CF structure. No SCL count= arrays are present.", - "sourceRepair": "For multi-DO CF groups, InitialFcReadPlanner now emits exact LN$CF$DO targets. InitialFcValueProjector projects each DO value directly, eliminating cross-DO positional mapping while retaining batching." + "sourceRepair": "For multi-DO CF groups, InitialFcReadPlanner now emits exact LN$CF$DO targets. InitialFcValueProjector projects each DO value directly, eliminating cross-DO positional mapping while retaining batching.", + "r10ObservedEdition2": 0, + "r10ObservedEdition1": 0, + "note": "R9 historical 46/46 evidence is retained for provenance; R10 physically proves the repair on both editions." }, "edition2CaseDistinctInitialValueCache": { "targetLoss": 0, "observedProjectedMinusCached": 11, - "status": "source-fix-pending-physical-retest", + "status": "resolved-r10-physical", "rule": "LTRK t and T and any other case-distinct IEC 61850 paths must remain separate through TypeSpecification mapping, initial projection, ARSAS cache, canonical instance evidence, DAI/Val export and reopen. cacheLoss must be zero.", - "sourceRepair": "ARSAS trusted-SCL initial-value cache now uses StringComparer.Ordinal. Engine canonical value dedup, TypeSpecification member resolution, and canonical SCL instance-value targeting are exact-case. Diagnostics expose projectedUniqueValues and cacheLoss." + "sourceRepair": "ARSAS trusted-SCL initial-value cache now uses StringComparer.Ordinal. Engine canonical value dedup, TypeSpecification member resolution, and canonical SCL instance-value targeting are exact-case. Diagnostics expose projectedUniqueValues and cacheLoss.", + "r10ObservedProjectedMinusCached": 0, + "r10Edition2ProjectedUniqueValues": 4441, + "r10Edition2InitialValueCache": 4441, + "note": "R9 historical loss=11 is retained for provenance; R10 physically proves exact-case cache preservation." }, "preallocatedAddReportControls": { "observedWithoutDataSet": 30, - "status": "source-fix-pending-physical-retest", - "rule": "Do not invent datSet. rptID-backed singleton runtime names ending 01 must project to indexed logical ReportControl max=1; an unassigned indexed slot is a warning, not a fatal missing-DataSet error." + "status": "resolved-r10-export-and-reuse", + "rule": "Do not invent datSet. rptID-backed singleton runtime names ending 01 must project to indexed logical ReportControl max=1; an unassigned indexed slot is a warning, not a fatal missing-DataSet error.", + "r10FatalMissingDatasetErrors": 0, + "r10LogicalReportControls": 32, + "note": "Unassigned indexed ADD slots remain without invented datSet and no longer block reuse/reporting." }, "saveTimeInstanceValues": { "observedR9ExportValCount": 0, "referenceIedScoutValCount": 1529, - "status": "source-fix-pending-physical-retest", - "rule": "Keep structural discovery read-free; acquire bounded safe FC-root values only during explicit Save SCL and verify resulting instance evidence physically." + "status": "mechanism-physically-proven-semantic-diff-open", + "rule": "Keep structural discovery read-free; acquire bounded safe FC-root values only during explicit Save SCL and verify resulting instance evidence physically.", + "r10InstanceEvidence": 3854, + "r10Edition2ValCount": 3202, + "r10Edition1ValCount": 3106, + "note": "Save-time value enrichment is physically proven. More Val elements than IEDScout is not treated as automatically better; exact semantic path/value comparison remains open." }, "templateReuse": { - "status": "secondary-after-semantic-parity", - "rule": "Reduce duplicate LNodeType/DOType/DAType templates only after wire and semantic reuse parity is stable." + "status": "next-improvement", + "rule": "Reduce duplicate LNodeType/DOType/DAType templates only after wire and semantic reuse parity is stable.", + "r10Edition2": { + "lNodeType": 119, + "doType": 906, + "daType": 752, + "fileBytes": 731266 + }, + "iedScoutReference": { + "lNodeType": 38, + "doType": 60, + "daType": 17, + "fileBytesApprox": 247000 + }, + "acceptance": "Intern only structurally and semantically identical templates. Expanded 32/119/860/906/4925 model, FC ownership, values and round-trip behavior must remain unchanged." + }, + "saveEnrichmentReuse": { + "status": "next-optimization", + "observation": "Ed2 and Ed1 saves in one unchanged live session both produced instanceEvidence=3854; the bounded enrichment snapshot can be reused across edition serializers when association/model generation is unchanged.", + "rule": "Do not cache across reconnect, model-generation change, or explicit refresh. Optimization must not alter P0 discovery." } }, "p0StructuralDiscoveryFreeze": { @@ -272,7 +414,7 @@ }, "p1ProjectionOrderRepair": { "contractId": "P1-CF-DO-SCOPED", - "status": "implemented-source-pending-physical-retest", + "status": "physically-proven-r10", "preservesP0StructuralDiscovery": true, "physicalR9Reproduction": { "projectionErrors": 46, @@ -287,11 +429,30 @@ "noSilentCrossDoValueSwap": true, "sameAssociation": true, "noAdditionalDiscoveryGva": true + }, + "physicalR10Outcome": { + "edition2": { + "initialTargets": 709, + "doScopedTargets": 184, + "successfulReads": 709, + "failedReads": 0, + "projectionErrors": 0 + }, + "edition1": { + "initialTargets": 708, + "doScopedTargets": 184, + "successfulReads": 708, + "failedReads": 0, + "projectionErrors": 0 + }, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "actualInformationReportObserved": true } }, "p2CaseSensitiveValuePipeline": { "contractId": "P2-CASE-EXACT-VALUES", - "status": "implemented-source-pending-physical-retest", + "status": "physically-proven-r10", "preservesP0StructuralDiscovery": true, "preservesP1CfScopedHydration": true, "physicalR9Evidence": { @@ -321,6 +482,19 @@ "ltrkLowercaseTAndUppercaseTRemainDistinct": true, "noAdditionalDiscoveryGva": true, "sameAssociation": true + }, + "physicalR10Outcome": { + "edition2": { + "projectedUniqueValues": 4441, + "initialValueCache": 4441, + "cacheLoss": 0 + }, + "edition1": { + "projectedUniqueValues": 4246, + "initialValueCache": 4246, + "cacheLoss": 0 + }, + "actualInformationReportObserved": true } } } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index c5253bfce..67364653b 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -198,7 +198,7 @@ public void P1ProjectionOrderRepair_LocksPhysicalRootCauseAndExactStrategy() var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); Assert.Contains("\"contractId\": \"P1-CF-DO-SCOPED\"", contract, StringComparison.Ordinal); - Assert.Contains("\"status\": \"implemented-source-pending-physical-retest\"", contract, StringComparison.Ordinal); + Assert.Contains("\"status\": \"physically-proven-r10\"", contract, StringComparison.Ordinal); Assert.Contains("\"projectionErrors\": 46", contract, StringComparison.Ordinal); Assert.Contains("\"affectedFcRoots\": 18", contract, StringComparison.Ordinal); Assert.Contains("\"affectedSclLeaves\": 191", contract, StringComparison.Ordinal); @@ -268,6 +268,30 @@ public void R9PhysicalReuse_LocksWorkingPathAndKeepsSemanticProjectionGapOpen() Assert.Contains("fullDiscovery=skipped", client, StringComparison.Ordinal); } + [Fact] + public void R10PhysicalReuse_ClosesP1P2AndLocksReportBackedRoundTrip() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + + Assert.Contains("\"arsasR10\"", contract, StringComparison.Ordinal); + Assert.Contains("\"initialTargets\": 709", contract, StringComparison.Ordinal); + Assert.Contains("\"fcRootTargets\": 525", contract, StringComparison.Ordinal); + Assert.Contains("\"doScopedTargets\": 184", contract, StringComparison.Ordinal); + Assert.Contains("\"projectedUniqueValues\": 4441", contract, StringComparison.Ordinal); + Assert.Contains("\"initialValueCache\": 4441", contract, StringComparison.Ordinal); + Assert.Contains("\"initialTargets\": 708", contract, StringComparison.Ordinal); + Assert.Contains("\"fcRootTargets\": 524", contract, StringComparison.Ordinal); + Assert.Contains("\"projectedUniqueValues\": 4246", contract, StringComparison.Ordinal); + Assert.Contains("\"initialValueCache\": 4246", contract, StringComparison.Ordinal); + Assert.Contains("\"cacheLoss\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"projectionErrors\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"reportBackedRuntimePoints\": 58", contract, StringComparison.Ordinal); + Assert.Contains("\"unresolvedRuntimePoints\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"actualInformationReportObserved\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"cyclicMmsProcessPolling\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"status\": \"resolved-r10-physical\"", contract, StringComparison.Ordinal); + } + [Fact] public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() { @@ -327,16 +351,16 @@ public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() } [Fact] - public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysicalRetest() + public void IedScoutConvergenceContract_PhysicalRetestPassedAndMergeReady() { var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); var documentation = File.ReadAllText(FindRepoFile("docs/IEDSCOUT_CONVERGENCE.md")); - Assert.Contains("\"status\": \"physical-retest-required\"", contract, StringComparison.Ordinal); + Assert.Contains("\"status\": \"physical-retest-passed-merge-ready\"", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); - Assert.Contains("9935d6902d786cc69b299260fe36b835944d5e81", contract, StringComparison.Ordinal); + Assert.Contains("648124097621046f5f127ceb1cf853fea54db730", contract, StringComparison.Ordinal); Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); @@ -344,13 +368,16 @@ public void IedScoutConvergenceContract_IsSingleActiveAuthorityAndRequiresPhysic Assert.Contains("\"physicalReconnectRequired\": true", contract, StringComparison.Ordinal); Assert.Contains("\"productionPromoted\": false", contract, StringComparison.Ordinal); Assert.Contains("\"mergeAllowedBeforePhysicalRetest\": false", contract, StringComparison.Ordinal); - Assert.Contains("Only this stack is active for this target", documentation, StringComparison.Ordinal); + Assert.Contains("\"mergeAllowedAfterPhysicalRetest\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"physicalRetestRequired\": false", contract, StringComparison.Ordinal); + Assert.Contains("\"physicalRetestPassed\": true", contract, StringComparison.Ordinal); + Assert.Contains("Merged proven baseline", documentation, StringComparison.Ordinal); Assert.Contains("The field result, not test count alone", documentation, StringComparison.Ordinal); var guard = File.ReadAllText(FindRepoFile(".github/workflows/iedscout-convergence-guard.yml")); Assert.Contains("name: IEDScout Convergence Guard", guard, StringComparison.Ordinal); Assert.Contains("name: iedscout-convergence-contract", guard, StringComparison.Ordinal); - Assert.Contains("Active stack must remain ARIEC61850 #134 -> #135 -> ARSAS #324", guard, StringComparison.Ordinal); + Assert.Contains("Merged engine authority must remain PR #134 + PR #135 with ARSAS #324 provenance", guard, StringComparison.Ordinal); Assert.Contains("P0 structural discovery freeze regressed away from the accepted IEDScout convergence path", guard, StringComparison.Ordinal); Assert.Contains("Canonical IEC model / SCL semantic authority regressed", guard, StringComparison.Ordinal); } @@ -374,7 +401,11 @@ public void EnginePin_MatchesPhysicalSclRepairHead() var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"9935d6902d786cc69b299260fe36b835944d5e81\"", + "\"commit\": \"648124097621046f5f127ceb1cf853fea54db730\"", + lockFile, + StringComparison.Ordinal); + Assert.Contains( + "\"physicalTestedCommit\": \"9935d6902d786cc69b299260fe36b835944d5e81\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); From b0f25569a398c7bbdc3a3a34409dbd74ebf49444 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 19 Sep 2026 08:24:07 +0700 Subject: [PATCH 243/243] ci(convergence): accept R10 physical merge-ready lifecycle --- .github/workflows/scl-interoperability-r7.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 199bebeed..17a436da3 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -61,14 +61,17 @@ jobs: throw 'Engine lock is not explicitly scoped as the R7 physical SCL repair.' } - if ($convergence.status -ne 'physical-retest-required' -or + if ($convergence.status -ne 'physical-retest-passed-merge-ready' -or [int]$convergence.activeStack.enginePerformance.pullRequest -ne 134 -or [int]$convergence.activeStack.engineModelAndScl.pullRequest -ne 135 -or [int]$convergence.activeStack.consumerIntegration.pullRequest -ne 324 -or $convergence.activeStack.engineModelAndScl.head -ne $lock.commit -or [bool]$convergence.promotion.productionPromoted -or - [bool]$convergence.promotion.mergeAllowedBeforePhysicalRetest) { - throw 'IEDScout convergence single-source-of-truth does not match the active #134 -> #135 -> #324 stack.' + [bool]$convergence.promotion.mergeAllowedBeforePhysicalRetest -or + [bool]$convergence.promotion.physicalRetestRequired -or + -not [bool]$convergence.promotion.mergeAllowedAfterPhysicalRetest -or + -not [bool]$convergence.promotion.physicalRetestPassed) { + throw 'IEDScout convergence single-source-of-truth does not match the physically proven merged-engine + ARSAS #324 authority.' } if ([int]$lock.sourcePullRequest -notin @(134, 135)) { throw "Unexpected R7 engine source PR: $($lock.sourcePullRequest)"