From 6887ea12cdf80b15be0fd84481582346dd92cab2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 16 Sep 2026 20:17:38 +0700 Subject: [PATCH 001/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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/126] 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: