diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml
new file mode 100644
index 000000000..13b6ed527
--- /dev/null
+++ b/.github/workflows/smart-discovery-capture-build.yml
@@ -0,0 +1,288 @@
+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 '4467124775d8d9d76f3db194f9fbfd97144767a8') {
+ 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 P0-5c smart capture sources
+ shell: powershell
+ 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 'DiscoverSmartSingleFlightAsync' -or
+ $helper -notmatch 'LiveIedVariableTypeProbeExecutor' -or
+ $helper -notmatch 'variableTypeAttributes' -or
+ $helper -notmatch 'SMART-CAPTURE PR134 P0-5c' -or
+ $helper -notmatch 'control inventory=authoritative' -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 '_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 'P0-5c association-scoped enrichment single-flight, authority publication, or lifecycle invalidation is incomplete.'
+ }
+ if ($helper -match 'ProbeSmartAsync\(_session, discovery\.IedDirectory, smartOptions, cancellationToken\)' -or
+ $helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or
+ $helper -match 'FinalizeDiscoveredSignals') {
+ 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
+ $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: |
+ 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
+ $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
+ $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
+ $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 P0-5b engine does not expose the required smart discovery, hierarchy-budget, and authoritative-Control invariants.'
+ }
+
+ - 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 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\(\);\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.'
+ }
+
+ - 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:ARSAS_COMMIT",
+ "ARIEC61850 commit: $env:ARIEC61850_COMMIT",
+ "Engine PR: 134",
+ "Mode: P0-5e golden capture acceptance over P0-5d physical wire proof, P0-5c association single-flight and P0-5b hierarchy request-budget convergence",
+ "CI invariant: duplicate semantic request, duplicate GetNameList/GVA, second naming sweep, invoke-ID reuse and outstanding-window proof logic execute against deterministic fixtures",
+ "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
+
+ - 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
+ 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
+
+ - name: Upload regression evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ARSAS-smart-discovery-pr134-test-evidence
+ path: |
+ ArIED61850Tester\TestResults\*.trx
+ ArIED61850Tester\TestResults\P0-5D-*.json
+ ArIED61850Tester\TestResults\P0-5E-*.json
+ if-no-files-found: warn
+ retention-days: 14
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..0459d702f
--- /dev/null
+++ b/.github/workflows/smart-discovery-golden-budget-lock.yml
@@ -0,0 +1,188 @@
+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().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 `
+ -CapturePath $capture `
+ -DeviceIdentity 'AA1E1F06R4' `
+ -ArsasCommit $arsasCommit `
+ -EngineCommit $engineCommit `
+ -TargetPath $target `
+ -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}$' -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.'
+ }
+
+ $acceptPath = Join-Path $results 'P0-5E-fixture-accept-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 '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' `
+ -CandidateArsasCommit $arsasCommit `
+ -CandidateEngineCommit $engineCommit `
+ -TargetPath $target `
+ -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' `
+ -CandidateArsasCommit $arsasCommit `
+ -CandidateEngineCommit $engineCommit `
+ -TargetPath $target `
+ -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
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
diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml
new file mode 100644
index 000000000..79bdc79bb
--- /dev/null
+++ b/.github/workflows/smart-discovery-mainline-readiness.yml
@@ -0,0 +1,89 @@
+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"
+ $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
+
+ & "$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 $physicalArg `
+ -PromotionAuthorityPath $promotionArg `
+ -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: error
+ retention-days: 14
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..831a0e318
--- /dev/null
+++ b/.github/workflows/smart-discovery-merge-execution-guard.yml
@@ -0,0 +1,87 @@
+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'
+ $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,$preflight,$postMerge,$test,$doc)) {
+ if (-not (Test-Path $path -PathType Leaf)) { throw "P0-5h source missing: $path" }
+ }
+ foreach ($script in @($writer,$preflight,$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.' }
+ if ((@($m.Arsas.AllowedPostAuthorizationPaths) -join ',') -ne 'evidence/smart-discovery-mainline-merge-manifest.json') {
+ throw 'Tracked manifest widened the post-authorization ARSAS mutation boundary.'
+ }
+ }
+
+ - 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:
+ 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
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
diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml
new file mode 100644
index 000000000..f649aef4d
--- /dev/null
+++ b/.github/workflows/smart-discovery-production-promotion.yml
@@ -0,0 +1,166 @@
+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 --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 --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: |
+ # 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\)') {
+ 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
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..123c12a80
--- /dev/null
+++ b/.github/workflows/smart-discovery-repeat-run-stability.yml
@@ -0,0 +1,227 @@
+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'
+ $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'
+ $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, $authorityWriter)) {
+ $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.'
+ }
+ if ($null -ne $targetJson.FinalizationAuthority) {
+ throw 'CI source must not pre-populate physical FinalizationAuthority.'
+ }
+
+ - 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'
+ $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()
+ $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.'
+ }
+
+ $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
+ $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 and field toolkit
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ 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
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 000000000..2148450ce
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,22 @@
+
+
+
+
+
+ true
+
+
+ true
+ false
+
+
+
+
+
+
+
diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs
index 7d7c89f64..fbe25ad7d 100644
--- a/MainWindow.RcbExport.cs
+++ b/MainWindow.RcbExport.cs
@@ -396,36 +396,106 @@ 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);
+ effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row);
+ selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference);
}
var filteredModel = SclReportControlFilter.FilterLiveModel(exportModel, row.Reference);
@@ -444,7 +514,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 +523,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 selectedReportControl is not null
+ ? (selectedReportControl.DataSetReference ?? string.Empty).Trim()
+ : (row.DataSetReference ?? string.Empty).Trim();
+ }
+
+ private static LiveIedDataSetModel? 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;
diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs
new file mode 100644
index 000000000..fc5df66ae
--- /dev/null
+++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs
@@ -0,0 +1,318 @@
+using System.Diagnostics;
+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 comparison build.
+ // It keeps live MMS evidence authoritative while removing ARSAS's historical
+ // supplemental naming/probe passes. Evidence-directed DataSet and report
+ // enrichment stays inside the same association-scoped single flight.
+ private static bool SmartDiscoveryCaptureModeEnabled => true;
+
+ private async Task> DiscoverSignalsSmartForCaptureAsync(
+ CancellationToken cancellationToken,
+ IProgress? progress)
+ {
+ 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 -> 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
+ {
+ if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration))
+ return Array.Empty();
+
+ return await DiscoverSignalsSmartForCaptureCoreAsync(
+ associationGeneration,
+ progress)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _mmsIoGate.Release();
+ }
+ }
+
+ private async Task> DiscoverSignalsSmartForCaptureCoreAsync(
+ long associationGeneration,
+ IProgress? progress)
+ {
+ if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration))
+ return Array.Empty();
+
+ LastDiscoverySummary = string.Empty;
+ if (!_session.IsMmsInitiated)
+ {
+ 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 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. 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(
+ IedDiscoveryStage.MappingSignals,
+ "Reusing the authoritative smart discovery for this MMS association…",
+ 82d, 7, 10));
+
+ var cachedProjectionWatch = Stopwatch.StartNew();
+ var cachedSnapshot = ToNativeSnapshot(cachedDiscovery.Snapshot);
+ var cachedInventory = ToNativeInventory(cachedDiscovery.ReportInventory);
+ var cachedSignals = BuildSmartCaptureSignalProjection(
+ cachedModel,
+ cachedSnapshot,
+ cachedInventory,
+ out var cachedProjectionStats);
+ cachedProjectionWatch.Stop();
+
+ var cachedReportWatch = Stopwatch.StartNew();
+ NativeReportDiscoveryMapper.ApplyReportHints(cachedSignals, cachedInventory);
+ cachedReportWatch.Stop();
+
+ progress?.Report(new IedDiscoveryProgress(
+ IedDiscoveryStage.ResolvingIdentity,
+ "Resolving IED identity from the cached canonical live model…",
+ 94d, 8, 10));
+
+ var cachedIdentityWatch = Stopwatch.StartNew();
+ 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);
+ 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, reflection fallback, adaptive sibling/equipment/reference/unit probes.";
+
+ if (!TryPublishSmartDiscoveryPresentation(
+ associationGeneration,
+ cachedInventory,
+ cachedIdentity,
+ cachedSummary))
+ {
+ return Array.Empty();
+ }
+
+ return cachedSignals;
+ }
+
+ var smartOptions = new ArMms.MmsSmartDiscoveryOptions
+ {
+ MaxConcurrentChains = 8,
+ UnknownPeerMaxConcurrentChains = 4,
+ MaxDomains = 256,
+ MaxVariableNamesPerDomain = 20000,
+ MaxVariableListNamesPerDomain = 4096,
+ MaxNameListPages = 64,
+ ProbeReportAttributes = true,
+ MaxReportAttributeProbes = 64,
+ ReadDataSetDirectories = true,
+ MaxDataSetDirectoryReads = 64
+ };
+
+ progress?.Report(new IedDiscoveryProgress(
+ IedDiscoveryStage.DiscoveringDirectory,
+ "Smart MMS discovery: bounded directory scan with evidence-directed DataSet/report enrichment…",
+ 28d, 4, 10));
+
+ var directoryWatch = Stopwatch.StartNew();
+ var discovery = await _session
+ .DiscoverSmartSingleFlightAsync(smartOptions, CancellationToken.None)
+ .ConfigureAwait(false);
+ directoryWatch.Stop();
+
+ // 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: coverage-aware Logical Node hierarchy probes…",
+ 52d, 5, 10));
+
+ var typeWatch = Stopwatch.StartNew();
+ var variableTypes = await LiveIedVariableTypeProbeExecutor
+ .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();
+ var liveModel = LiveIedModelDiscoveryBuilder.Build(
+ discovery,
+ new LiveIedModelDiscoveryBuildOptions
+ {
+ Host = _host,
+ Port = _port,
+ IncludeLowConfidenceTemplates = true
+ },
+ variableTypeAttributes: variableTypes);
+ modelWatch.Stop();
+
+ var snapshot = ToNativeSnapshot(discovery.Snapshot);
+ var reportInventory = ToNativeInventory(discovery.ReportInventory);
+
+ progress?.Report(new IedDiscoveryProgress(
+ IedDiscoveryStage.MappingSignals,
+ "Mapping canonical smart evidence with indexed fallbacks…",
+ 82d, 7, 10));
+
+ var projectionWatch = Stopwatch.StartNew();
+ var signals = BuildSmartCaptureSignalProjection(
+ liveModel,
+ snapshot,
+ reportInventory,
+ out var projectionStats);
+ projectionWatch.Stop();
+
+ // 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();
+
+ progress?.Report(new IedDiscoveryProgress(
+ IedDiscoveryStage.ResolvingIdentity,
+ "Resolving IED identity from the canonical live model…",
+ 94d, 8, 10));
+
+ var identityWatch = Stopwatch.StartNew();
+ 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))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count();
+ var rawVariables = snapshot.DomainVariables.Values.Sum(values => values.Count);
+ var successfulTypeRoots = variableTypes.Count(result => result.IsSuccess);
+ var typeBudget = _session.LastSmartTypeProbeBudget?.Summary ?? "Smart type budget unavailable.";
+
+ totalWatch.Stop();
+ 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, 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.
+ if (!TryPublishSmartDiscoveryAuthority(
+ associationGeneration,
+ discovery,
+ liveModel,
+ reportInventory,
+ identity,
+ variableTypes.Count,
+ successfulTypeRoots,
+ summary))
+ {
+ 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)
+ {
+ totalWatch.Stop();
+ 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();
+ }
+ }
+}
diff --git a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs
new file mode 100644
index 000000000..6cbac46be
--- /dev/null
+++ b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs
@@ -0,0 +1,155 @@
+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 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()
+ {
+ lock (_smartDiscoveryFlightSync)
+ {
+ unchecked
+ {
+ _smartDiscoveryAssociationGeneration++;
+ }
+
+ _smartDiscoveryAssociationFlight = null;
+ _smartDiscoveryFlightGeneration = -1;
+ _smartDiscoveryAuthority = null;
+ _smartDiscoveryModelAuthority = null;
+ _smartDiscoveryTypeProbeCount = 0;
+ _smartDiscoverySuccessfulTypeProbeCount = 0;
+ _smartDiscoveryAuthorityHost = string.Empty;
+ _smartDiscoveryAuthorityPort = 0;
+ }
+ }
+
+ private bool IsCurrentSmartDiscoveryAssociationGeneration(long generation)
+ {
+ lock (_smartDiscoveryFlightSync)
+ return generation == _smartDiscoveryAssociationGeneration;
+ }
+
+ private Task> GetOrCreateSmartDiscoveryAssociationFlight(
+ Func>> ownerFactory)
+ {
+ ArgumentNullException.ThrowIfNull(ownerFactory);
+
+ lock (_smartDiscoveryFlightSync)
+ {
+ var generation = _smartDiscoveryAssociationGeneration;
+ if (_smartDiscoveryAssociationFlight is not null &&
+ _smartDiscoveryFlightGeneration == generation)
+ {
+ return _smartDiscoveryAssociationFlight;
+ }
+
+ var flight = ownerFactory(generation);
+ _smartDiscoveryAssociationFlight = flight;
+ _smartDiscoveryFlightGeneration = generation;
+ _ = flight.ContinueWith(
+ completed => ClearSmartDiscoveryFlight(generation, completed),
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ return flight;
+ }
+ }
+
+ private void ClearSmartDiscoveryFlight(
+ long generation,
+ Task> flight)
+ {
+ // Observe a detached owner's fault if every waiter cancelled independently.
+ _ = 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()
+ => _session.IsMmsInitiated &&
+ string.Equals(_smartDiscoveryAuthorityHost, _host, StringComparison.OrdinalIgnoreCase) &&
+ _smartDiscoveryAuthorityPort == _port;
+
+ private void BindSmartDiscoveryAuthorityToCurrentAssociation()
+ {
+ _smartDiscoveryAuthorityHost = _host;
+ _smartDiscoveryAuthorityPort = _port;
+ }
+}
diff --git a/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs
new file mode 100644
index 000000000..7248490a8
--- /dev/null
+++ b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs
@@ -0,0 +1,224 @@
+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 (!IsSmartDiscoveryAuthorityBoundToCurrentAssociation() ||
+ _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;
+ BindSmartDiscoveryAuthorityToCurrentAssociation();
+ }
+
+ 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);
+ }
+}
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);
+ }
+}
diff --git a/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md b/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md
new file mode 100644
index 000000000..b3573d47e
--- /dev/null
+++ b/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md
@@ -0,0 +1,98 @@
+# 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 packet-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 the decoder exposes it;
+8. optional explicit total-request and GVA budgets are respected;
+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.
+
+## 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 a trusted reference-tool capture:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.ps1 `
+ -PcapPath .\ARSAS_P0-5d.pcapng `
+ -ReferencePcapPath .\REFERENCE_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.
+
+## 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.
+
+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.
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..2156c155c
--- /dev/null
+++ b/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md
@@ -0,0 +1,106 @@
+# 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.
+
+## Production evidence inputs
+
+A production lock requires all of the following:
+
+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.
+
+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.
+
+`-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-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 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 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
+
+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 `
+ -CandidateArsasCommit `
+ -CandidateEngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 `
+ -TargetPath .\evidence\smart-discovery-golden-target.json
+```
+
+A default PASS requires:
+
+- candidate P0-5d proof is PASS;
+- exact same IED identity;
+- 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.
+
+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 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 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.
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..33d62b08a
--- /dev/null
+++ b/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md
@@ -0,0 +1,111 @@
+# 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.
+
+## 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 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 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.
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.
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..9ce0e0408
--- /dev/null
+++ b/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md
@@ -0,0 +1,83 @@
+# 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.
+
+## 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
+
+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. 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.
+
+## 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.
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index e4e2a9548..e85247a75 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,13 +2,18 @@
"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.",
+ "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. 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,
+ "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",
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
+
+
diff --git a/evidence/smart-discovery-golden-target.json b/evidence/smart-discovery-golden-target.json
new file mode 100644
index 000000000..1bdfb9870
--- /dev/null
+++ b/evidence/smart-discovery-golden-target.json
@@ -0,0 +1,27 @@
+{
+ "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,
+ "RuntimeReportControlInstances": 34,
+ "IndexedBufferedFamilyMax": 2,
+ "IndexedUnbufferedFamilyMax": 2,
+ "SyntheticReportControlInstancesAllowed": 0
+ },
+ "BudgetAuthority": {
+ "RequiredProofPhase": "P0-5d",
+ "RequiredProofVerdict": "PASS",
+ "RequireRawCaptureSha256": true,
+ "RequireExactArsasCommit": true,
+ "RequireExactEngineCommit": true,
+ "BudgetValues": null
+ }
+}
diff --git a/evidence/smart-discovery-mainline-merge-target.json b/evidence/smart-discovery-mainline-merge-target.json
new file mode 100644
index 000000000..89c363233
--- /dev/null
+++ b/evidence/smart-discovery-mainline-merge-target.json
@@ -0,0 +1,41 @@
+{
+ "SchemaVersion": 1,
+ "Phase": "P0-5h",
+ "Status": "blocked-until-p0-5g-ready-for-review",
+ "ArsasRepository": "masarray/arsas",
+ "ArsasPullRequest": 324,
+ "EngineRepository": "masarray/ARIEC61850",
+ "EnginePullRequest": 134,
+ "MergeMethod": "merge",
+ "MergeOrder": [
+ "engine",
+ "arsas"
+ ],
+ "PostMergeVerification": {
+ "RequireEngineHeadAncestorOfEngineMain": true,
+ "RequireArsasValidatedHeadAncestorOfArsasMain": true,
+ "RequireTrackedMergeManifestOnArsasMain": 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,
+ "RequireMergeCommitMethod": true,
+ "RequireOnlyMergeManifestChangeAfterValidatedArsasHead": true,
+ "RequireNoUnresolvedReviewThreads": true,
+ "RequireBothPullRequestsMergeable": true,
+ "ForbidAutoMergeBeforeAllPreconditions": true,
+ "ForbidFixtureOrForceBypass": true
+ },
+ "MergeManifest": null,
+ "PostMergeAuthority": null
+}
diff --git a/evidence/smart-discovery-production-promotion-target.json b/evidence/smart-discovery-production-promotion-target.json
new file mode 100644
index 000000000..d2459dfc5
--- /dev/null
+++ b/evidence/smart-discovery-production-promotion-target.json
@@ -0,0 +1,48 @@
+{
+ "SchemaVersion": 2,
+ "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/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/MmsDataSetDirectory.cs",
+ "src/AR.Iec61850/Mms/MmsIedModelDirectory.cs",
+ "src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs",
+ "src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs",
+ "src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs",
+ "src/AR.Iec61850/Osi/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",
+ "evidence/smart-discovery-mainline-merge-manifest.json"
+ ],
+ "ProductionPromotionContract": {
+ "RequireP05fPhysicalAuthority": true,
+ "RequirePhysicalAuthorityProductionEvidenceOnly": true,
+ "RequireEngineHeadCiSuccess": true,
+ "AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged": true,
+ "RequireFieldRouteExplicitOptInBeforePromotion": true,
+ "RequireProductionSwitchFalseUntilAuthority": true,
+ "RequireExactPromotionAuthoritySha256Binding": true,
+ "RequireExactValidatedEngineHeadBinding": true,
+ "RequirePurposeDiscoveryCiSuccess": true,
+ "RequireGenericBuildSuccessBeforeReadyForReview": true,
+ "RequireNoUnresolvedReviewThreadsBeforeReadyForReview": true,
+ "RequireDedicatedMainlineReadinessGateSuccess": true,
+ "RequirePrRemainDraftUntilAllReadyGatesPass": true
+ },
+ "PromotionAuthority": null
+}
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
+}
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"
diff --git a/scripts/enable-smart-discovery-capture.ps1 b/scripts/enable-smart-discovery-capture.ps1
new file mode 100644
index 000000000..e25090bf4
--- /dev/null
+++ b/scripts/enable-smart-discovery-capture.ps1
@@ -0,0 +1,101 @@
+$ErrorActionPreference = 'Stop'
+
+$sourcePath = Join-Path $PSScriptRoot '..\Services\NativeIec61850Client.cs'
+$sourcePath = [System.IO.Path]::GetFullPath($sourcePath)
+$text = [System.IO.File]::ReadAllText($sourcePath)
+$changed = $false
+
+$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.'
+ }
+
+ $injection = @'
+
+ if (SmartDiscoveryCaptureModeEnabled)
+ return await DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress).ConfigureAwait(false);
+'@
+
+ $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 = '_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) {
+ $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.'
+ }
+
+ $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 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 'P0-5c DisposeAsync association 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)))
+}
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..0d11b22a0
--- /dev/null
+++ b/scripts/finalize-smart-discovery-repeat-run-stability.ps1
@@ -0,0 +1,210 @@
+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-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) {
+ 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()
+$repeatTargetHash = (Get-FileHash -LiteralPath $repeatTargetFile -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 '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.") }
+}
+
+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)'.") }
+ }
+
+ $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)
+$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 = 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
+ 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
+ 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
+ }
+ } 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 " association generations: $($result.Consensus.AssociationGenerations -join ', ')"
+ 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 }
+}
diff --git a/scripts/new-smart-discovery-golden-lock.ps1 b/scripts/new-smart-discovery-golden-lock.ps1
new file mode 100644
index 000000000..11d7970a9
--- /dev/null
+++ b/scripts/new-smart-discovery-golden-lock.ps1
@@ -0,0 +1,220 @@
+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,
+ [Parameter(Mandatory=$true)][string]$TargetPath,
+ [Parameter(Mandatory=$true)][string]$BuildManifestPath,
+ [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-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
+}
+
+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.'
+}
+$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.'
+}
+
+$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.' }
+$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 = 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 = 2
+ 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
+ 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
+ MaxServiceRequests = $serviceBudget
+ MaxDuplicateSemanticRequests = 0
+ MaxDuplicateGetNameListRequests = 0
+ MaxDuplicateGvaRequests = 0
+ MaxInvokeIdReuseWhileOutstanding = 0
+ MaxOrphanResponses = 0
+ MaxUnansweredRequestsAtCaptureEnd = 0
+ MaxPeakOutstandingRequests = $maxOutstanding
+ ForbidSecondGetNameListSweep = $true
+ ForbidUnexpectedServices = $true
+ }
+ SemanticTarget = $target.SemanticTarget
+}
+
+$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 " 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)"
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..7091b021d
--- /dev/null
+++ b/scripts/new-smart-discovery-mainline-merge-manifest.ps1
@@ -0,0 +1,132 @@
+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]$ArsasValidatedHeadCommit,
+ [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 = $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 }
+
+$arsasValidatedHead = $ArsasValidatedHeadCommit.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]$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.' }
+
+[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'
+ MergeMethod = 'merge'
+ MergeOrder = @('engine','arsas')
+ Arsas = [ordered]@{
+ Repository = 'masarray/arsas'
+ PullRequest = 324
+ ValidatedHeadSha = $arsasValidatedHead
+ BaseShaAtAuthorization = $arsasBase
+ LiveMergeHeadSha = $null
+ AllowedPostAuthorizationPaths = @('evidence/smart-discovery-mainline-merge-manifest.json')
+ }
+ Engine = [ordered]@{
+ Repository = 'masarray/ARIEC61850'
+ PullRequest = 134
+ ExpectedHeadSha = $engineHead
+ BaseShaAtAuthorization = $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',
+ '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',
+ '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 ' merge method: merge'
+Write-Host " engine expected head: $engineHead"
+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"
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..6317d3a60
--- /dev/null
+++ b/scripts/new-smart-discovery-production-promotion-authority.ps1
@@ -0,0 +1,154 @@
+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
+}
+
+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'
+$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.'
+}
+Assert-PhysicalAuthorityProvenance $physical
+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.'
+}
+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()
+$physicalHash = (Get-FileHash -LiteralPath $physicalFile -Algorithm SHA256).Hash.ToLowerInvariant()
+$engineLockHash = (Get-FileHash -LiteralPath $engineLockFile -Algorithm SHA256).Hash.ToLowerInvariant()
+
+$authority = [ordered]@{
+ SchemaVersion = 2
+ 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
+ P05fGoldenLockSha256 = ([string]$physical.GoldenLockSha256).ToLowerInvariant()
+ P05fRepeatTargetSha256 = ([string]$physical.RepeatTargetSha256).ToLowerInvariant()
+ P05fFinalizationSha256 = ([string]$physical.FinalizationSha256).ToLowerInvariant()
+}
+
+$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"
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"
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)"
diff --git a/scripts/verify-smart-discovery-golden-lock.ps1 b/scripts/verify-smart-discovery-golden-lock.ps1
new file mode 100644
index 000000000..e58f6340b
--- /dev/null
+++ b/scripts/verify-smart-discovery-golden-lock.ps1
@@ -0,0 +1,153 @@
+param(
+ [Parameter(Mandatory=$true)][string]$LockPath,
+ [Parameter(Mandatory=$true)][string]$ProofJson,
+ [Parameter(Mandatory=$true)][string]$DeviceIdentity,
+ [Parameter(Mandatory=$true)][string]$CandidateArsasCommit,
+ [Parameter(Mandatory=$true)][string]$CandidateEngineCommit,
+ [Parameter(Mandatory=$true)][string]$TargetPath,
+ [switch]$AllowDifferentArsasCommit,
+ [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'
+$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 ($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
+$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 = 2
+ 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
+ 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 }
+ }
+ 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 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) {
+ foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue }
+ if (-not $NoFailExit) { exit 1 }
+}
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
+}
diff --git a/scripts/verify-smart-discovery-pcap.ps1 b/scripts/verify-smart-discovery-pcap.ps1
new file mode 100644
index 000000000..fba5eb523
--- /dev/null
+++ b/scripts/verify-smart-discovery-pcap.ps1
@@ -0,0 +1,349 @@
+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 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" }
+ 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 = Sum-IntProperty $duplicateDetails "DuplicateAttempts"
+
+ $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 = Sum-IntProperty $gnlDuplicates "DuplicateAttempts"
+ DuplicateGvaRequests = Sum-IntProperty $gvaDuplicates "DuplicateAttempts"
+ 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 }
+}
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..b757cae26
--- /dev/null
+++ b/scripts/verify-smart-discovery-post-merge-production.ps1
@@ -0,0 +1,105 @@
+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 -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 $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()
+
+$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()
+[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
+ ValidatedArsasHead = $validatedArsasHead
+ CurrentArsasMainHead = $currentArsasMain
+ ExpectedEngineHead = $expectedEngineHead
+ CurrentEngineMainHead = $currentEngineMain
+ EngineHeadIsAncestorOfMain = $engineAncestor
+ ArsasValidatedHeadIsAncestorOfMain = $arsasAncestor
+ 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
+}
diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1
new file mode 100644
index 000000000..dba5d280d
--- /dev/null
+++ b/scripts/verify-smart-discovery-production-readiness.ps1
@@ -0,0 +1,303 @@
+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-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
+ $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 = (Get-XmlChildText $group 'SmartDiscoveryEvidenceEngineCommit').ToLowerInvariant()
+ Phase = Get-XmlChildText $group 'SmartDiscoveryPromotionPhase'
+ AuthoritySha256 = (Get-XmlChildText $group 'SmartDiscoveryPromotionAuthoritySha256').ToLowerInvariant()
+ ValidatedEngineHead = (Get-XmlChildText $group 'SmartDiscoveryValidatedEngineHead').ToLowerInvariant()
+ }
+}
+
+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'
+$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
+$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()
+
+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.')
+}
+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) {
+ $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 = [bool]$promotionProps.Promoted
+$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
+ 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](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.ToLowerInvariant() $arsasHead)) {
+ $blockers.Add('Current ARSAS head is not a descendant of the physically validated ARSAS commit.')
+ } else {
+ $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) {
+ $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 (([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 {
+ $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.')
+ } 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.')
+}
+
+$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 = 4
+ Phase = 'P0-5g'
+ Verdict = $status
+ ArsasHeadCommit = $arsasHead
+ EngineEvidenceBaselineCommit = $baseline
+ EngineHeadCommit = $engineHead
+ EngineHeadCiConclusion = $EngineHeadCiConclusion
+ EngineHeadIsEvidenceCompatibleDescendant = $engineIsDescendant -and $criticalChanges.Count -eq 0
+ DiscoveryCriticalChanges = @($criticalChanges)
+ PromotionTargetSha256 = $targetHash
+ EngineLockSha256 = $engineLockHash
+ ProductionSwitchEnabled = $productionSwitch
+ PromotionAuthoritySha256 = $promotionProps.AuthoritySha256
+ PromotionValidatedEngineHead = $promotionProps.ValidatedEngineHead
+ 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 }
diff --git a/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs b/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs
new file mode 100644
index 000000000..af1a6147f
--- /dev/null
+++ b/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs
@@ -0,0 +1,131 @@
+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.");
+ }
+
+ [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);
+ 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}'.");
+ }
+}
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}'.");
+ }
+}
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}'.");
+ }
+}
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}'.");
+ }
+}
diff --git a/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs
new file mode 100644
index 000000000..edcdf327a
--- /dev/null
+++ b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs
@@ -0,0 +1,97 @@
+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("RequireOnlyMergeManifestChangeAfterValidatedArsasHead").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("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);
+ }
+
+ [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()
+ {
+ 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}'.");
+ }
+}
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}'.");
+ }
+}
diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs
new file mode 100644
index 000000000..9f75a51a3
--- /dev/null
+++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs
@@ -0,0 +1,139 @@
+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(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());
+ 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("RequirePhysicalAuthorityProductionEvidenceOnly").GetBoolean());
+ 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("RequireDedicatedMainlineReadinessGateSuccess").GetBoolean());
+ Assert.True(contract.GetProperty("RequirePrRemainDraftUntilAllReadyGatesPass").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);
+ 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);
+ 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_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_DedicatedMainlineGateRequiresReadyForReviewAndRealAuthorities()
+ {
+ var workflow = File.ReadAllText(FindRepoFile(".github/workflows/smart-discovery-mainline-readiness.yml"));
+
+ Assert.Contains("Smart Discovery Mainline Readiness", 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.Contains("-NoFailExit", workflow, StringComparison.Ordinal);
+ Assert.Contains("if-no-files-found: error", workflow, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ 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);
+ 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);
+ 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("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 = 4", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ 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("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);
+ }
+
+ 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}'.");
+ }
+}
diff --git a/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs
new file mode 100644
index 000000000..c74609f5e
--- /dev/null
+++ b/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs
@@ -0,0 +1,108 @@
+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);
+ Assert.Equal(JsonValueKind.Null, root.GetProperty("FinalizationAuthority").ValueKind);
+
+ 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);
+ }
+
+ [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);
+ 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}'.");
+ }
+}
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}'.");
+ }
+}