diff --git a/.github/workflows/iedscout-convergence-guard.yml b/.github/workflows/iedscout-convergence-guard.yml new file mode 100644 index 000000000..9e977475f --- /dev/null +++ b/.github/workflows/iedscout-convergence-guard.yml @@ -0,0 +1,325 @@ +name: IEDScout Convergence Guard + +on: + pull_request: + paths: + - "Services/NativeIec61850Client*.cs" + - "Services/SclAssistedConnectionPreparation.cs" + - "Services/CanonicalSclReloadValidator.cs" + - "MainWindow.xaml.cs" + - "Directory.Build.targets" + - "scripts/enable-smart-discovery-capture.ps1" + - "engines/ARIEC61850.lock.json" + - "evidence/iedscout-convergence-target.json" + - "docs/IEDSCOUT_CONVERGENCE.md" + - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" + - ".github/workflows/iedscout-convergence-guard.yml" + - ".github/workflows/scl-interoperability-r7.yml" + workflow_dispatch: + +concurrency: + group: iedscout-convergence-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + iedscout-convergence-contract: + name: iedscout-convergence-contract + runs-on: windows-latest + + steps: + - name: Checkout exact ARSAS revision + shell: pwsh + env: + ARSAS_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if ($env:ARSAS_SOURCE_SHA -notmatch '^[0-9a-f]{40}$') { + throw "Invalid ARSAS source SHA: $env:ARSAS_SOURCE_SHA" + } + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS + git -C .\ARSAS fetch --quiet --depth 1 origin $env:ARSAS_SOURCE_SHA + git -C .\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA + $actual = (git -C .\ARSAS rev-parse HEAD).Trim() + if ($actual -ne $env:ARSAS_SOURCE_SHA) { + throw "ARSAS SHA mismatch. Expected $env:ARSAS_SOURCE_SHA, got $actual." + } + + - name: Verify single convergence authority + shell: pwsh + run: | + $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + $target = Get-Content .\ARSAS\evidence\iedscout-convergence-target.json -Raw | ConvertFrom-Json + + if ($target.status -ne 'physical-retest-passed-merge-ready') { + throw "Convergence status changed unexpectedly: $($target.status)" + } + if ([int]$target.activeStack.enginePerformance.pullRequest -ne 134 -or + [int]$target.activeStack.engineModelAndScl.pullRequest -ne 135 -or + [int]$target.activeStack.engineModelAndScl.basePullRequest -ne 134 -or + [int]$target.activeStack.consumerIntegration.pullRequest -ne 324) { + throw 'Merged engine authority must remain PR #134 + PR #135 with ARSAS #324 provenance.' + } + if ($lock.commit -ne $target.activeStack.engineModelAndScl.head -or + [int]$lock.sourcePullRequest -ne 135) { + throw 'Engine lock drifted from the convergence single source of truth.' + } + if ([bool]$target.promotion.productionPromoted -or + [bool]$target.promotion.mergeAllowedBeforePhysicalRetest -or + [bool]$target.promotion.physicalRetestRequired -or + -not [bool]$target.promotion.mergeAllowedAfterPhysicalRetest -or + -not [bool]$target.promotion.physicalRetestPassed) { + throw 'R10 physical-retest/merge gate drifted.' + } + if ([int]$target.physicalReference.iedScoutCapture.associations -ne 1 -or + [int]$target.physicalReference.iedScoutCapture.getVariableAccessAttributes -ne 119 -or + [int]$target.physicalReference.rejectedLegacyArsasCapture.associations -ne 2 -or + [int]$target.physicalReference.rejectedLegacyArsasCapture.getVariableAccessAttributes -lt 20000) { + throw 'Physical IEDScout/legacy regression reference changed unexpectedly.' + } + + + $p0 = $target.p0StructuralDiscoveryFreeze + if ($p0.contractId -ne 'P0-R9-STRUCTURAL' -or + $p0.status -ne 'implemented-and-r9-physically-proven' -or + -not [bool]$p0.sourcePolicy.smartRouteRequired -or + -not [bool]$p0.sourcePolicy.associationScopedSingleFlightRequired -or + -not [bool]$p0.sourcePolicy.applicationMmsGateExclusive -or + -not [bool]$p0.sourcePolicy.logicalNodeRootTypeStrategyRequired -or + -not [bool]$p0.sourcePolicy.eagerInitialFcReadsForbidden -or + -not [bool]$p0.sourcePolicy.supplementalLegacyBrowseForbidden -or + -not [bool]$p0.sourcePolicy.secondFullGetNameListSweepForbidden -or + -not [bool]$p0.sourcePolicy.recursivePerLeafGvaStormForbidden -or + -not [bool]$p0.sourcePolicy.saveTimeAndTrustedSclValueReadsRemainSeparate) { + throw 'P0 structural-discovery freeze policy was weakened.' + } + + if ([int]$p0.frozenOptions.maxConcurrentChains -ne 8 -or + [int]$p0.frozenOptions.unknownPeerMaxConcurrentChains -ne 4 -or + [int]$p0.frozenOptions.maxDomains -ne 256 -or + [int]$p0.frozenOptions.maxVariableNamesPerDomain -ne 20000 -or + [int]$p0.frozenOptions.maxVariableListNamesPerDomain -ne 4096 -or + [int]$p0.frozenOptions.maxNameListPages -ne 64 -or + -not [bool]$p0.frozenOptions.probeReportAttributes -or + [int]$p0.frozenOptions.maxReportAttributeProbes -ne 64 -or + -not [bool]$p0.frozenOptions.readDataSetDirectories -or + [int]$p0.frozenOptions.maxDataSetDirectoryReads -ne 64) { + throw 'P0 frozen smart-discovery options drifted.' + } + + $p0Relay = $p0.aa1e1f06r4Acceptance + if ([int]$p0Relay.associations -ne 1 -or + [int]$p0Relay.referenceConfirmedMmsRequests -ne 323 -or + [int]$p0Relay.maximumConfirmedMmsRequests -ne 417 -or + [int]$p0Relay.referenceGetNameList -ne 138 -or + [int]$p0Relay.referenceGetVariableAccessAttributes -ne 119 -or + [int]$p0Relay.maximumGetVariableAccessAttributes -ne 119 -or + [int]$p0Relay.referenceGetNamedVariableListAttributes -ne 2 -or + [int]$p0Relay.referenceReads -ne 64 -or + [int]$p0Relay.maximumReads -ne 156 -or + [int]$p0Relay.logicalDevices -ne 32 -or + [int]$p0Relay.logicalNodes -ne 119 -or + [int]$p0Relay.topLevelDataObjects -ne 860 -or + [int]$p0Relay.dataObjectsIncludingSdo -ne 906 -or + [int]$p0Relay.scalarLeaves -ne 4925 -or + [int]$p0Relay.dataSets -ne 2 -or + [int]$p0Relay.fcda -ne 58 -or + [int]$p0Relay.logicalReportControls -ne 32 -or + [int]$p0Relay.settingControls -ne 1) { + throw 'P0 AA1E1F06R4 discovery acceptance contract drifted.' + } + + $p1 = $target.p1ProjectionOrderRepair + if ($p1.contractId -ne 'P1-CF-DO-SCOPED' -or + $p1.status -ne 'physically-proven-r10' -or + [int]$p1.physicalR10Outcome.edition2.projectionErrors -ne 0 -or + [int]$p1.physicalR10Outcome.edition1.projectionErrors -ne 0 -or + [int]$p1.physicalR10Outcome.edition2.failedReads -ne 0 -or + [int]$p1.physicalR10Outcome.edition1.failedReads -ne 0 -or + [int]$p1.physicalR10Outcome.reportBackedRuntimePoints -ne 58 -or + [int]$p1.physicalR10Outcome.unresolvedRuntimePoints -ne 0 -or + -not [bool]$p1.physicalR10Outcome.actualInformationReportObserved) { + throw 'P1 physical projection-order proof regressed.' + } + + $p2 = $target.p2CaseSensitiveValuePipeline + if ($p2.contractId -ne 'P2-CASE-EXACT-VALUES' -or + $p2.status -ne 'physically-proven-r10' -or + -not [bool]$p2.preservesP0StructuralDiscovery -or + -not [bool]$p2.preservesP1CfScopedHydration -or + [int]$p2.physicalR9Evidence.observedCacheLoss -ne 11 -or + [int]$p2.expectedPhysicalOutcome.edition2CacheLoss -ne 0 -or + [int]$p2.expectedPhysicalOutcome.edition1CacheLoss -ne 0 -or + -not [bool]$p2.expectedPhysicalOutcome.ltrkLowercaseTAndUppercaseTRemainDistinct -or + -not [bool]$p2.expectedPhysicalOutcome.noAdditionalDiscoveryGva -or + -not [bool]$p2.expectedPhysicalOutcome.sameAssociation -or + [int]$p2.physicalR10Outcome.edition2.cacheLoss -ne 0 -or + [int]$p2.physicalR10Outcome.edition1.cacheLoss -ne 0 -or + [int]$p2.physicalR10Outcome.edition2.projectedUniqueValues -ne [int]$p2.physicalR10Outcome.edition2.initialValueCache -or + [int]$p2.physicalR10Outcome.edition1.projectedUniqueValues -ne [int]$p2.physicalR10Outcome.edition1.initialValueCache -or + $p2.sourceInvariants.trustedSclValueCacheComparer -ne 'StringComparer.Ordinal' -or + $p2.sourceInvariants.typeSpecificationMemberComparison -ne 'StringComparison.Ordinal' -or + $p2.sourceInvariants.sclDataObjectInstanceTargetComparison -ne 'StringComparison.Ordinal') { + throw 'P2 exact-case value pipeline contract was weakened.' + } + + $r10 = $target.physicalEvidence.arsasR10 + if ($r10.testedArtifact.appHead -ne 'eb8eb13d491f9aa265205852b8a4bab07af440ff' -or + $r10.testedArtifact.engineTestedHead -ne '9935d6902d786cc69b299260fe36b835944d5e81' -or + $r10.testedArtifact.engineMergedMain -ne '648124097621046f5f127ceb1cf853fea54db730' -or + [int]$r10.discovery.associations -ne 1 -or + [int]$r10.discovery.confirmedMmsRequests -ne 323 -or + [int]$r10.discovery.getVariableAccessAttributes -ne 119 -or + [int]$r10.reuseEdition2.matchedDomains -ne 32 -or + [int]$r10.reuseEdition2.initialTargets -ne 709 -or + [int]$r10.reuseEdition2.successfulReads -ne 709 -or + [int]$r10.reuseEdition2.failedReads -ne 0 -or + [int]$r10.reuseEdition2.projectionErrors -ne 0 -or + [int]$r10.reuseEdition2.cacheLoss -ne 0 -or + [int]$r10.reuseEdition2.reportBackedRuntimePoints -ne 58 -or + [int]$r10.reuseEdition2.unresolvedRuntimePoints -ne 0 -or + -not [bool]$r10.reuseEdition2.actualInformationReportObserved -or + [int]$r10.reuseEdition1.matchedDomains -ne 32 -or + [int]$r10.reuseEdition1.initialTargets -ne 708 -or + [int]$r10.reuseEdition1.successfulReads -ne 708 -or + [int]$r10.reuseEdition1.failedReads -ne 0 -or + [int]$r10.reuseEdition1.projectionErrors -ne 0 -or + [int]$r10.reuseEdition1.cacheLoss -ne 0 -or + [int]$r10.reuseEdition1.reportBackedRuntimePoints -ne 58 -or + [int]$r10.reuseEdition1.unresolvedRuntimePoints -ne 0 -or + -not [bool]$r10.reuseEdition1.actualInformationReportObserved) { + throw 'R10 physical SCL reuse acceptance evidence regressed.' + } + + $r9 = $target.physicalEvidence.arsasR9 + if ([int]$r9.discovery.associations -ne 1 -or + [int]$r9.discovery.confirmedMmsRequests -ne 323 -or + [int]$r9.discovery.getVariableAccessAttributes -ne 119 -or + [int]$r9.discovery.topLevelDataObjects -ne 860 -or + [int]$r9.discovery.dataObjectsIncludingSdo -ne 906 -or + [int]$r9.discovery.scalarLeaves -ne 4925) { + throw 'R9 physical discovery/model acceptance evidence drifted.' + } + + if ([int]$r9.reuseEdition2.expectedDomains -ne 32 -or + [int]$r9.reuseEdition2.matchedDomains -ne 32 -or + [int]$r9.reuseEdition2.successfulReads -ne [int]$r9.reuseEdition2.fcRoots -or + [int]$r9.reuseEdition2.failedReads -ne 0 -or + [int]$r9.reuseEdition2.reportBackedRuntimePoints -ne 58 -or + [int]$r9.reuseEdition2.unresolvedRuntimePoints -ne 0 -or + -not [bool]$r9.reuseEdition2.actualInformationReportObserved -or + [int]$r9.reuseEdition1.expectedDomains -ne 32 -or + [int]$r9.reuseEdition1.matchedDomains -ne 32 -or + [int]$r9.reuseEdition1.successfulReads -ne [int]$r9.reuseEdition1.fcRoots -or + [int]$r9.reuseEdition1.failedReads -ne 0 -or + [int]$r9.reuseEdition1.reportBackedRuntimePoints -ne 58 -or + [int]$r9.reuseEdition1.unresolvedRuntimePoints -ne 0 -or + -not [bool]$r9.reuseEdition1.actualInformationReportObserved) { + throw 'R9 trusted-SCL reconnect/reporting acceptance evidence regressed.' + } + + if ([int]$target.openGaps.trustedSclProjectionParity.targetProjectionErrors -ne 0 -or + [int]$target.openGaps.trustedSclProjectionParity.observedEdition2 -ne [int]$r9.reuseEdition2.projectionErrors -or + [int]$target.openGaps.trustedSclProjectionParity.observedEdition1 -ne [int]$r9.reuseEdition1.projectionErrors -or + [int]$target.openGaps.trustedSclProjectionParity.r10ObservedEdition2 -ne 0 -or + [int]$target.openGaps.trustedSclProjectionParity.r10ObservedEdition1 -ne 0 -or + [int]$target.openGaps.edition2CaseDistinctInitialValueCache.observedProjectedMinusCached -ne 11 -or + [int]$target.openGaps.edition2CaseDistinctInitialValueCache.r10ObservedProjectedMinusCached -ne 0 -or + [int]$target.openGaps.preallocatedAddReportControls.observedWithoutDataSet -ne 30 -or + [int]$target.openGaps.preallocatedAddReportControls.r10FatalMissingDatasetErrors -ne 0) { + throw 'R9 history or R10 resolved semantic evidence drifted.' + } + + "ENGINE_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ENGINE_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout exact convergence engine + shell: pwsh + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ENGINE_REPOSITORY.git" ARIEC61850 + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ENGINE_COMMIT + git -C .\ARIEC61850 checkout --quiet --detach $env:ENGINE_COMMIT + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ENGINE_COMMIT) { + throw "Engine SHA mismatch. Expected $env:ENGINE_COMMIT, got $actual." + } + + - name: Verify discovery and SCL anti-regression contracts + shell: pwsh + run: | + $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $sclAssisted = Get-Content .\ARSAS\Services\NativeIec61850Client.SclAssisted.cs -Raw + $canonicalModel = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw + $typeHierarchy = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedVariableTypeHierarchy.cs -Raw + $canonicalExporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw + $targets = Get-Content .\ARSAS\Directory.Build.targets -Raw + $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw + $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw + $smartGva = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartVariableAccessAttributes.cs -Raw + $lnParts = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850ReferenceParts.cs -Raw + $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveIedSclExporter.cs -Raw + $registry = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850StandardModelRegistry.cs -Raw + $cdc = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\CdcInferenceEngine.cs -Raw + + $valueCacheIndex = $sclAssisted.IndexOf('_trustedSclInitialValues =', [System.StringComparison]::Ordinal) + if ($valueCacheIndex -lt 0) { + throw 'P2 trusted-SCL value cache declaration is missing.' + } + $valueCacheLength = [Math]::Min(260, $sclAssisted.Length - $valueCacheIndex) + $valueCacheSegment = $sclAssisted.Substring($valueCacheIndex, $valueCacheLength) + if ($valueCacheSegment -notmatch 'StringComparer\.Ordinal' -or + $valueCacheSegment -match 'StringComparer\.OrdinalIgnoreCase' -or + $sclAssisted -notmatch 'projectedUniqueValues' -or + $sclAssisted -notmatch 'initialValueCacheLoss' -or + $sclAssisted -notmatch 'cacheLoss=\{initialValueCacheLoss\}' -or + $canonicalModel -notmatch 'new HashSet\(StringComparer\.Ordinal\)' -or + $typeHierarchy -notmatch 'current\.Children\[index\]\.Name, part, StringComparison\.Ordinal\)' -or + $canonicalExporter -notmatch 'dataObjectName\.Trim\(\),\s*[\s\S]*?StringComparison\.Ordinal\)') { + throw 'P2 case-sensitive value path implementation regressed.' + } + + if ($targets -notmatch 'SCL Interoperability R7 Build' -or + $targets -notmatch 'EnableSmartDiscoveryCaptureRoute' -or + $capture -notmatch 'SmartDiscoveryStructuralFreezeContract = "P0-R9-STRUCTURAL"' -or + $capture -notmatch 'CreateP0FrozenSmartDiscoveryOptions' -or + $capture -notmatch 'MaxConcurrentChains = 8' -or + $capture -notmatch 'UnknownPeerMaxConcurrentChains = 4' -or + $capture -notmatch 'MaxNameListPages = 64' -or + $capture -notmatch 'ProbeReportAttributes = true' -or + $capture -notmatch 'MaxReportAttributeProbes = 64' -or + $capture -notmatch 'ReadDataSetDirectories = true' -or + $capture -notmatch 'MaxDataSetDirectoryReads = 64' -or + $capture -notmatch 'GetOrCreateSmartDiscoveryAssociationFlight' -or + $capture -notmatch 'DiscoverSmartSingleFlightAsync' -or + $capture -notmatch 'ProbeSmartAsync' -or + $capture -notmatch 'initialFcRoots=deferred' -or + $capture -notmatch 'InitialFcReadExecutionResult\? initialRead = null' -or + $capture -match '_session\.DiscoverAsync\(' -or + $capture -match 'DiscoverDomainVariableNamesAsync' -or + $capture -match 'TryBuildSupplementalGetNameListSnapshotAsync' -or + $capture -match 'DiscoverDomainVariableTypeTreeNamesAsync' -or + $capture -match 'AddAdaptiveLogicalNodeSiblingProbeSignalsAsync' -or + $capture -match 'EnrichEngineeringUnitsAsync' -or + $capture -match 'InitialFcReadPlanner\.FromSclModel' -or + $capture -match 'ExecuteInitialFcReadPlanSmartAsync' -or + $smart -notmatch 'DiscoverSmartAsync' -or + $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $singleFlight -notmatch 'DiscoverSmartAsync\(options, CancellationToken\.None\)' -or + $smartGva -notmatch 'GetVariableAccessAttributesSmartAsync') { + throw 'P0 structural discovery freeze regressed away from the accepted IEDScout convergence path.' + } + + if ($lnParts -notmatch 'instanceStart' -or + $lnParts -notmatch 'IsFourLetterLnClass' -or + $exporter -notmatch 'TryResolveStandardSubDataObjectCdc' -or + $exporter -notmatch 'DataObjectReferencePaths' -or + $exporter -notmatch 'IsEdition2ServiceTrackingCdc' -or + $registry -notmatch 'Key\("TCTR", "ARtg"\)' -or + $registry -notmatch 'Key\("TVTR", "VRtg"\)' -or + $registry -notmatch 'Key\("LTIM", "TmChgDT"\)' -or + $registry -notmatch 'Key\("LTRK", "BrcbTrk"\)' -or + $registry -notmatch 'Key\("XCBR", "EEName"\)' -or + $registry -notmatch 'Key\("LLN0", "MltLev"\)' -or + $cdc -notmatch '"CST".*"BTS".*"UTS".*"STS".*"CTS"') { + throw 'Canonical IEC model / SCL semantic authority regressed.' + } + + Write-Host 'IEDScout convergence contract PASS.' diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml new file mode 100644 index 000000000..17a436da3 --- /dev/null +++ b/.github/workflows/scl-interoperability-r7.yml @@ -0,0 +1,311 @@ +name: SCL Interoperability R7 Build + +on: + pull_request: + paths: + - "Services/NativeIec61850Client.CanonicalModel.cs" + - "Services/NativeIec61850Client.SmartDiscoveryCapture.cs" + - "Directory.Build.targets" + - "scripts/enable-smart-discovery-capture.ps1" + - "Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs" + - "Services/NativeIec61850Client.SclAssisted.cs" + - "Services/SclAssistedConnectionPreparation.cs" + - "Models/MonitorModels.cs" + - "MainWindow.xaml.cs" + - "Services/CanonicalSclReloadValidator.cs" + - "engines/ARIEC61850.lock.json" + - "evidence/iedscout-convergence-target.json" + - "docs/IEDSCOUT_CONVERGENCE.md" + - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" + - "tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs" + - ".github/workflows/scl-interoperability-r7.yml" + workflow_dispatch: + +concurrency: + group: scl-interoperability-r7-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-r7-scl-interoperability: + name: Build canonical discovery to interoperable SCL test package + runs-on: windows-latest + + steps: + - name: Checkout exact ARSAS source revision + shell: pwsh + env: + ARSAS_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if ($env:ARSAS_SOURCE_SHA -notmatch '^[0-9a-f]{40}$') { + throw "Invalid ARSAS source SHA: $env:ARSAS_SOURCE_SHA" + } + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS + git -C .\ARSAS fetch --quiet --depth 1 origin $env:ARSAS_SOURCE_SHA + git -C .\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA + $arsasCommit = (git -C .\ARSAS rev-parse HEAD).Trim() + if ($arsasCommit -ne $env:ARSAS_SOURCE_SHA) { + throw "ARSAS SHA mismatch. Expected $env:ARSAS_SOURCE_SHA, got $arsasCommit." + } + "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Resolve isolated R7 engine pin + shell: pwsh + run: | + $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + $convergence = Get-Content .\ARSAS\evidence\iedscout-convergence-target.json -Raw | ConvertFrom-Json + if ($lock.repository -ne 'masarray/ARIEC61850' -or + $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid ARIEC61850 R7 lock.' + } + if ($lock.purpose -notmatch 'R7 physical SCL repair pin') { + throw 'Engine lock is not explicitly scoped as the R7 physical SCL repair.' + } + + if ($convergence.status -ne 'physical-retest-passed-merge-ready' -or + [int]$convergence.activeStack.enginePerformance.pullRequest -ne 134 -or + [int]$convergence.activeStack.engineModelAndScl.pullRequest -ne 135 -or + [int]$convergence.activeStack.consumerIntegration.pullRequest -ne 324 -or + $convergence.activeStack.engineModelAndScl.head -ne $lock.commit -or + [bool]$convergence.promotion.productionPromoted -or + [bool]$convergence.promotion.mergeAllowedBeforePhysicalRetest -or + [bool]$convergence.promotion.physicalRetestRequired -or + -not [bool]$convergence.promotion.mergeAllowedAfterPhysicalRetest -or + -not [bool]$convergence.promotion.physicalRetestPassed) { + throw 'IEDScout convergence single-source-of-truth does not match the physically proven merged-engine + ARSAS #324 authority.' + } + if ([int]$lock.sourcePullRequest -notin @(134, 135)) { + throw "Unexpected R7 engine source PR: $($lock.sourcePullRequest)" + } + + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_SOURCE_PR=$($lock.sourcePullRequest)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + $version = (Get-Content .\ARSAS\VERSION -Raw).Trim() + "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout exact ARIEC61850 engine revision + shell: pwsh + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT + git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { + throw "Engine SHA mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + } + + - name: Verify canonical SCL interoperability contracts + shell: pwsh + run: | + $canonical = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw + $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw + $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw + $cotpClient = Get-Content .\ARIEC61850\src\AR.Iec61850\Osi\CotpClient.cs -Raw + $rcbProjector = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveRcbLogicalGroupProjector.cs -Raw + $smartDiscovery = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw + $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw + $smartGva = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartVariableAccessAttributes.cs -Raw + $referenceParts = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850ReferenceParts.cs -Raw + $liveExporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveIedSclExporter.cs -Raw + $standardRegistry = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850StandardModelRegistry.cs -Raw + $cdcInference = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\CdcInferenceEngine.cs -Raw + $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw + $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $convergence = Get-Content .\ARSAS\evidence\iedscout-convergence-target.json -Raw | ConvertFrom-Json + $buildTargets = Get-Content .\ARSAS\Directory.Build.targets -Raw + $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw + $sclClient = Get-Content .\ARSAS\Services\NativeIec61850Client.SclAssisted.cs -Raw + $sclPreparation = Get-Content .\ARSAS\Services\SclAssistedConnectionPreparation.cs -Raw + $save = Get-Content .\ARSAS\MainWindow.xaml.cs -Raw + $reloadValidator = Get-Content .\ARSAS\Services\CanonicalSclReloadValidator.cs -Raw + $regression = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalLiveSclExportRegressionTests.cs -Raw + $reloadTests = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalSclReloadValidatorTests.cs -Raw + + foreach ($required in @( + 'class LiveIedCanonicalModel', + 'InstanceValues', + 'LiveIedCommunicationEvidence')) { + if ($canonical -notmatch [regex]::Escape($required)) { + throw "Canonical engine contract missing: $required" + } + } + + foreach ($required in @( + 'ValidateCanonicalCommunication', + 'PreserveRuntimeServiceCapacity', + 'ApplyCanonicalInstanceValues', + 'ValidateRoundTripAssociation')) { + if ($exporter -notmatch [regex]::Escape($required)) { + throw "Canonical SCL exporter contract missing: $required" + } + } + + if ($association -notmatch 'GetAcceptedCommunicationEvidence') { + throw 'Accepted association evidence API is missing.' + } + + if ($env:ARIEC61850_SOURCE_PR -eq '135') { + $wireReader = '.\ARIEC61850\src\AR.Iec61850\Acse\AcseAssociationRequestIdentityReader.cs' + if (!(Test-Path $wireReader) -or + $association -notmatch 'AcseAssociationRequestIdentityReader\.Read' -or + $association -notmatch '_cotp\.LastConnectParameters\?\.DestinationTsap' -or + $cotpClient -notmatch 'public CotpConnectParameters\? LastConnectParameters' -or + $cotpClient -notmatch 'LastConnectParameters = acceptedParameters' -or + $association -match 'ApTitle\s*=\s*"1,1,1,999,1"') { + throw 'PR #135 must derive canonical AP/AE/selectors from exact accepted ACSE and COTP session evidence, not duplicated profile constants.' + } + } + + if ($smartDiscovery -notmatch 'DiscoverSmartAsync' -or + $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $smartGva -notmatch 'GetVariableAccessAttributesSmartAsync' -or + $referenceParts -notmatch 'instanceStart' -or + $referenceParts -notmatch 'IsFourLetterLnClass' -or + $liveExporter -notmatch 'TryResolveStandardSubDataObjectCdc' -or + $liveExporter -notmatch 'DataObjectReferencePaths' -or + $liveExporter -notmatch 'IsEdition2ServiceTrackingCdc' -or + $standardRegistry -notmatch 'Key\("TCTR", "ARtg"\)' -or + $standardRegistry -notmatch 'Key\("TVTR", "VRtg"\)' -or + $standardRegistry -notmatch 'Key\("LTIM", "TmChgDT"\)' -or + $standardRegistry -notmatch 'Key\("LTRK", "BrcbTrk"\)' -or + $standardRegistry -notmatch 'Key\("XCBR", "EEName"\)' -or + $standardRegistry -notmatch 'Key\("LLN0", "MltLev"\)' -or + $cdcInference -notmatch '"CST".*"BTS".*"UTS".*"STS".*"CTS"' -or + $capture -match 'TryBuildSupplementalGetNameListSnapshotAsync' -or + $capture -match 'DiscoverDomainVariableTypeTreeNamesAsync' -or + $capture -match 'AddAdaptiveLogicalNodeSiblingProbeSignalsAsync' -or + $capture -match 'EnrichEngineeringUnitsAsync') { + throw 'IEDScout convergence source contract regressed: smart structure-first discovery or semantic SCL authority is missing, or a forbidden legacy browse path returned.' + } + + if ([int]$convergence.physicalReference.iedScoutCapture.associations -ne 1 -or + [int]$convergence.physicalReference.rejectedLegacyArsasCapture.associations -ne 2 -or + -not [bool]$convergence.discoveryAcceptance.exactlyOneAssociation -or + -not [bool]$convergence.discoveryAcceptance.supplementalLegacyAssociationForbidden -or + -not [bool]$convergence.sclAcceptance.reopenInArsasRequired -or + -not [bool]$convergence.sclAcceptance.physicalReconnectRequired) { + throw 'IEDScout physical acceptance target was weakened.' + } + + if ($consumer -notmatch 'LiveIedCanonicalModelBuilder\.Build\(model, communication, initialRead\)' -or + $capture -match 'InitialFcReadPlanner\.FromSclModel' -or + $capture -match 'ExecuteInitialFcReadPlanSmartAsync' -or + $capture -notmatch 'initialFcRoots=deferred' -or + $buildTargets -notmatch 'SCL Interoperability R7 Build' -or + $buildTargets -notmatch 'EnableSmartDiscoveryCaptureRoute' -or + $lifecycle -notmatch 'PublishCanonicalModel\(model, initialRead\)' -or + $save -notmatch 'CanonicalLiveIedSclExporter\.WriteFiles' -or + $save -notmatch 'profile:\s*"full-model"' -or + $save -notmatch 'CanonicalSclReloadValidator\.Validate' -or + $sclPreparation -notmatch 'MmsLocalAssociationProfile\.ExistingRuntimeDefault' -or + $sclClient -notmatch 'IsSafeTrustedSclInitialReadFc' -or + $sclClient -notmatch 'ExecuteInitialFcReadPlanSmartAsync' -or + $reloadValidator -notmatch 'workspaceService\.Open' -or + $reloadValidator -notmatch 'result\.Profile.*full-model' -or + $reloadValidator -notmatch 'changed the proven calling-side runtime identity' -or + $reloadValidator -notmatch 'reproduce accepted association profile' -or + $regression -notmatch 'SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan' -or + $reloadTests -notmatch 'ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift' -or + $reloadTests -notmatch 'BuildDefaultAssociationPayload' -or + $reloadTests -notmatch 'GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity' -or + $rcbProjector -match 'SameNumericText\(left\.IntegrityPeriodMs, right\.IntegrityPeriodMs\)' -or + $rcbProjector -match 'Same\(left\.OptionalFields, right\.OptionalFields\)') { + throw 'ARSAS canonical discovery -> full-model SCL -> exact native reconnect -> safe initial value-read contract is incomplete.' + } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore ARSAS solution + run: dotnet restore .\ARSAS\ArIED61850Tester.sln + + - name: Build ARSAS Release + run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + + - name: Verify R7 smart discovery route was compiled + shell: pwsh + run: | + $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.cs -Raw + if ($consumer -notmatch 'DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'R7 build compiled without the smart discovery route; refusing legacy-path artifact.' + } + if ($consumer -notmatch 'if \(SmartDiscoveryCaptureModeEnabled\)') { + throw 'R7 build did not install the smart discovery gate at DiscoverSignalsAsync.' + } + Write-Host 'R7 smart discovery route is installed in the compiled source.' + + - name: Run ARSAS canonical-export regressions + run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults + + - name: Build engine and run engine tests + shell: pwsh + run: | + dotnet restore .\ARIEC61850\ARIEC61850.sln + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test .\ARIEC61850\ARIEC61850.sln -c Release --no-build --no-restore --logger "trx;LogFileName=ariec61850-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Publish R7 portable single EXE + shell: pwsh + run: | + .\ARSAS\scripts\publish-windows-portable.ps1 ` + -Version $env:ARSAS_VERSION ` + -Runtime win-x64 ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + + - name: Smoke test R7 portable executable + shell: pwsh + run: | + $exe = ".\ARSAS\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" + if (!(Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing: $exe" } + + $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-r7-scl-bundle-cache' + $process = Start-Process -FilePath $exe -ArgumentList @('--portable-smoke-test') -PassThru + if (-not $process.WaitForExit(30000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw 'Portable EXE smoke test timed out.' + } + if ($process.ExitCode -ne 0) { + throw "Portable EXE smoke test failed: $($process.ExitCode)" + } + + $convergencePath = ".\ARSAS\evidence\iedscout-convergence-target.json" + $convergenceHash = (Get-FileHash $convergencePath -Algorithm SHA256).Hash.ToLowerInvariant() + $convergence = Get-Content $convergencePath -Raw | ConvertFrom-Json + + @( + 'ARSAS R7 SCL interoperability field-test build', + "ARSAS commit: $env:ARSAS_COMMIT", + "ARIEC61850 commit: $env:ARIEC61850_COMMIT", + "Engine source PR: $env:ARIEC61850_SOURCE_PR", + "Convergence contract SHA256: $convergenceHash", + "Convergence status: $($convergence.status)", + 'Active stack: ARIEC61850 #134 -> #135 -> ARSAS #324', + '', + 'Acceptance target:', + 'IED -> fast structural Smart Discovery -> full-model SCL Ed1/Ed2 -> ARSAS Workspace Reload -> exact native calling/called association -> bounded safe FC-root snapshot.', + 'CI invariant: full-model export excludes no discovered attributes; SCL rebuilds the accepted native handshake; golden RCB shape is 34 runtime -> 32 logical with ConfReportControl max=34.', + 'Physical gate: one MMS association; no supplemental legacy browse; no recursive per-leaf GVA storm; same-relay PCAP + Ed2 IID + Ed1 ICD + diagnostic required before promotion.', + '', + 'This artifact is NOT production-promotion authority and does NOT replace P0-5e/P0-5f physical discovery-budget evidence.' + ) | Set-Content .\ARSAS\dist\R7-SCL-INTEROP-BUILD.txt -Encoding utf8 + + - name: Upload R7 SCL interoperability build + uses: actions/upload-artifact@v4 + with: + name: ARSAS-r7-scl-interoperability-win-x64 + path: | + ARSAS\dist\ARSAS-*-win-x64-portable.exe + ARSAS\dist\R7-SCL-INTEROP-BUILD.txt + ARSAS\evidence\iedscout-convergence-target.json + ARSAS\docs\IEDSCOUT_CONVERGENCE.md + ARSAS\TestResults\*.trx + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml new file mode 100644 index 000000000..ed1756611 --- /dev/null +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -0,0 +1,295 @@ +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.' + } + $baselineEngineCommit = $lock.previousTrialPin.commit + if ($baselineEngineCommit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid physical baseline engine pin.' + } + if ($baselineEngineCommit -ne '4467124775d8d9d76f3db194f9fbfd97144767a8') { + throw "Unexpected engine commit (physical baseline): $baselineEngineCommit" + } + $integrationEngineCommit = $lock.commit + $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() + if ($arsasCommit -notmatch '^[0-9a-f]{40}$') { + throw "Invalid cloned ARSAS commit: $arsasCommit" + } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$integrationEngineCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_BASELINE_COMMIT=$baselineEngineCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + $version = (Get-Content .\ArIED61850Tester\VERSION -Raw).Trim() + "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + 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", + "ARIEC61850 physical baseline commit: $env:ARIEC61850_BASELINE_COMMIT", + "Engine PR: 134", + "Mode: P0-5e golden capture acceptance over P0-5d physical wire proof, P0-5c association single-flight and P0-5b hierarchy request-budget convergence", + "CI invariant: duplicate semantic request, duplicate GetNameList/GVA, second naming sweep, invoke-ID reuse and outstanding-window proof logic execute against deterministic fixtures", + "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, reflection fallback, and adaptive sibling/equipment/reference/unit probes; bounded report/DataSet semantic enrichment remains enabled" + ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 + + - name: Upload smart field-capture build + 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..2db26be69 --- /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/MainWindow.xaml.cs b/MainWindow.xaml.cs index 00ae66f8a..69eae95b6 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1514,7 +1514,7 @@ private async void IedRescan_Click(object sender, RoutedEventArgs e) await ConnectAndConfigureDeviceAsync(device, openWizard: false); } - private void IedSaveScl_Click(object sender, RoutedEventArgs e) + private async void IedSaveScl_Click(object sender, RoutedEventArgs e) { if (!TryGetDeviceFromButton(sender, out var device) || device.IsBusy) return; @@ -1557,6 +1557,17 @@ private void IedSaveScl_Click(object sender, RoutedEventArgs e) try { + if (device.SclWorkspace == null && device.IsConnected) + { + SetStatus($"{device.Name}: enriching Save SCL with bounded live FC-root values…"); + var instanceEvidence = await _runtime + .EnrichCanonicalForSclSaveAsync(device, _applicationCancellation.Token); + AddLog( + "INFO", + "SCL Export", + $"{device.Name}: save-time enrichment completed with {instanceEvidence:N0} exact instance-value leaf/leaves. Fast discovery remained unchanged."); + } + if (device.SclWorkspace != null && schema.IsEdition2 && !string.IsNullOrWhiteSpace(device.SclSourcePath) && @@ -1631,21 +1642,74 @@ private void SaveTypedModelAsScl( SclSchemaProfileDescriptor schema, string outputPath) { - var result = LiveIedSclExporter.WriteFiles( - model, - outputPath, - new LiveIedSclExportOptions + LiveIedSclExportResult result; + AR.Iec61850.Discovery.LiveIedCanonicalModel? canonicalExportEvidence = null; + SclIedWorkspace? canonicalReloadWorkspace = null; + if (device.SclWorkspace == null) + { + var canonical = device.LiveCanonicalModel + ?? throw new InvalidOperationException( + "The live discovery model is not bound to accepted MMS association evidence. Re-scan the IED before saving SCL."); + + if (!ReferenceEquals(canonical.Discovery, model)) { - Profile = "safe-connection", - SchemaProfile = schema.Profile, - IpAddress = device.IpAddress - }); + throw new InvalidOperationException( + "The canonical association snapshot does not belong to the current live discovery model. Re-scan the IED before saving SCL."); + } + + result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + outputPath, + schema.Profile, + profile: "full-model"); + try + { + canonicalReloadWorkspace = CanonicalSclReloadValidator.Validate( + _sclWorkspaceService, + canonical, + result); + } + catch + { + DeleteFailedCanonicalExportArtifacts(result); + throw; + } + canonicalExportEvidence = canonical; + } + else + { + result = LiveIedSclExporter.WriteFiles( + model, + outputPath, + new LiveIedSclExportOptions + { + Profile = "safe-connection", + SchemaProfile = schema.Profile, + IpAddress = device.IpAddress + }); + } AddLog( "INFO", "SCL Export", $"{device.Name}: saved {result.SclSchema} from {sourceDescription.ToLowerInvariant()}. LD={result.LogicalDeviceCount}, LN={result.LogicalNodeCount}, DataSet={result.DataSetCount}, RCB={result.ReportControlCount}, warnings={result.Warnings.Count}. SCL={result.SclPath}"); + if (canonicalExportEvidence is not null) + { + var communication = canonicalExportEvidence.Communication; + var association = communication.Association; + AddLog( + "INFO", + "SCL Export", + $"{device.Name}: canonical round-trip verified • profile={communication.AssociationProfileName} • " + + $"AP-Title={association.ApTitle} • AE={association.AeQualifier?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? ""} • " + + $"PSEL={association.PresentationSelector} • SSEL={association.SessionSelector} • TSEL={association.TransportSelector} • " + + $"instanceEvidence={canonicalExportEvidence.InstanceValues.Count} • runtimeRCB={canonicalExportEvidence.Discovery.ReportControls.Count} • " + + $"logicalExportRCB={result.ReportControlCount} • reloadLD={canonicalReloadWorkspace?.DesignModel.Coverage.LogicalDeviceCount ?? 0} • " + + $"reloadLN={canonicalReloadWorkspace?.DesignModel.Coverage.LogicalNodeCount ?? 0} • reloadDataSet={canonicalReloadWorkspace?.DataSets.Count ?? 0} • " + + $"reloadRCB={canonicalReloadWorkspace?.ReportControls.Count ?? 0}."); + } + foreach (var warning in result.Warnings.Take(12)) { var reference = string.IsNullOrWhiteSpace(warning.Reference) @@ -1660,6 +1724,28 @@ private void SaveTypedModelAsScl( ShowSclSaveSuccess(result.SclSchema, result.SclPath, result.ReportPath, result.SummaryPath); } + private static void DeleteFailedCanonicalExportArtifacts(LiveIedSclExportResult result) + { + foreach (var path in new[] + { + result.SclPath, + result.ReportPath, + result.SummaryPath, + result.ExcludedAttributesPath + }) + { + try + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + File.Delete(path); + } + catch + { + // Preserve the original reload-validation failure. + } + } + } + private void ShowSclSaveSuccess(string schema, string sclPath, string reportPath, string summaryPath) { MessageBox.Show( diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 784d08648..e37fc187e 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -34,6 +34,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject private int _unreadEventCount; private SclIedWorkspace? _sclWorkspace; private LiveIedModelDiscoveryDocument? _liveDiscoveryModel; + private LiveIedCanonicalModel? _liveCanonicalModel; private SclLiveModelComparisonResult? _sclComparison; private string _sclSourcePath = string.Empty; private string _sclSourceSha256 = string.Empty; @@ -70,6 +71,17 @@ public LiveIedModelDiscoveryDocument? LiveDiscoveryModel } } + public LiveIedCanonicalModel? LiveCanonicalModel + { + get => _liveCanonicalModel; + set + { + if (ReferenceEquals(_liveCanonicalModel, value)) return; + _liveCanonicalModel = value; + RefreshComputed(); + } + } + public SclLiveModelComparisonResult? SclComparison { get => _sclComparison; diff --git a/Services/CanonicalSclReloadValidator.cs b/Services/CanonicalSclReloadValidator.cs new file mode 100644 index 000000000..d47e072a2 --- /dev/null +++ b/Services/CanonicalSclReloadValidator.cs @@ -0,0 +1,153 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; +using AR.Iec61850.Scl.Workspace; + +namespace ArIED61850Tester.Services; + +public static class CanonicalSclReloadValidator +{ + public static SclIedWorkspace Validate( + SclWorkspaceService workspaceService, + LiveIedCanonicalModel canonical, + LiveIedSclExportResult result) + { + ArgumentNullException.ThrowIfNull(workspaceService); + ArgumentNullException.ThrowIfNull(canonical); + ArgumentNullException.ThrowIfNull(result); + + if (!string.Equals(result.Profile, "full-model", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Canonical live SCL must preserve the full discovered model; exporter profile was '{result.Profile}'."); + } + + if (result.ExcludedAttributes.Count != 0) + { + throw new InvalidOperationException( + $"Canonical full-model SCL unexpectedly excluded {result.ExcludedAttributes.Count} discovered attribute(s)."); + } + + var reloaded = workspaceService.Open( + result.SclPath, + new SclWorkspaceOpenOptions + { + IedName = canonical.IedName, + AccessPointName = canonical.AccessPointName + }); + + var workspace = reloaded.Ieds.SingleOrDefault() + ?? throw new InvalidOperationException( + $"Generated SCL could not be reopened as exactly one ARSAS workspace for '{canonical.IedName}/{canonical.AccessPointName}'."); + + if (!string.Equals(workspace.IedName, canonical.IedName, StringComparison.Ordinal) || + !string.Equals(workspace.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Generated SCL reload identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}', " + + $"reloaded '{workspace.IedName}/{workspace.AccessPointName}'."); + } + + var endpoint = workspace.PreferredEndpoint + ?? throw new InvalidOperationException( + "Generated SCL reload did not resolve a usable MMS endpoint."); + + if (!endpoint.HasUsableAddress || + !string.Equals(endpoint.IpAddress, canonical.Communication.Host, StringComparison.OrdinalIgnoreCase) || + endpoint.Port != 102) + { + throw new InvalidOperationException( + $"Generated SCL reload endpoint drifted. Expected '{canonical.Communication.Host}:102', " + + $"reloaded '{endpoint.EndpointText}'."); + } + + var reloadCoverage = workspace.DesignModel.Coverage; + var mismatches = new List(); + if (reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount) + mismatches.Add($"LD {reloadCoverage.LogicalDeviceCount}!={result.LogicalDeviceCount}"); + if (reloadCoverage.LogicalNodeCount != result.LogicalNodeCount) + mismatches.Add($"LN {reloadCoverage.LogicalNodeCount}!={result.LogicalNodeCount}"); + if (workspace.DataSets.Count != result.DataSetCount) + mismatches.Add($"DataSet {workspace.DataSets.Count}!={result.DataSetCount}"); + if (workspace.ReportControls.Count != result.ReportControlCount) + mismatches.Add($"RCB {workspace.ReportControls.Count}!={result.ReportControlCount}"); + + if (mismatches.Count > 0) + { + throw new InvalidOperationException( + "Generated SCL changed structural counts when reloaded by ARSAS: " + + string.Join(", ", mismatches) + "."); + } + + var preparation = SclAssistedConnectionPreparationBuilder.Build( + File.ReadAllText(result.SclPath), + canonical.IedName, + canonical.AccessPointName, + canonical.Communication.Host, + canonical.Communication.Port); + if (!preparation.IsSuccess || preparation.AssociationPlan is null) + { + var detail = preparation.Errors.Count == 0 + ? "unknown SCL-assisted preparation failure" + : string.Join(" | ", preparation.Errors); + throw new InvalidOperationException( + "Generated SCL cannot rebuild the ARSAS SCL-assisted reconnect plan: " + detail); + } + + var plan = preparation.AssociationPlan; + if (!string.Equals(plan.IedName, canonical.IedName, StringComparison.Ordinal) || + !string.Equals(plan.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal) || + !string.Equals(plan.Host, canonical.Communication.Host, StringComparison.OrdinalIgnoreCase) || + plan.Port != canonical.Communication.Port) + { + throw new InvalidOperationException( + $"Generated SCL reconnect plan identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}' " + + $"at {canonical.Communication.Host}:{canonical.Communication.Port}, rebuilt " + + $"'{plan.IedName}/{plan.AccessPointName}' at {plan.Host}:{plan.Port}."); + } + + var canonicalAssociation = canonical.Communication.Association; + var rebuiltApTitle = string.Join(",", plan.Association.Called.ApTitle); + var rebuiltPsel = Convert.ToHexString(plan.Association.Called.PresentationSelector); + var rebuiltSsel = Convert.ToHexString(plan.Association.Called.SessionSelector); + var rebuiltTsel = Convert.ToHexString(plan.Cotp.DestinationTsap); + if (!string.Equals(rebuiltApTitle, canonicalAssociation.ApTitle, StringComparison.Ordinal) || + plan.Association.Called.AeQualifier != canonicalAssociation.AeQualifier || + !string.Equals(rebuiltPsel, canonicalAssociation.PresentationSelector, StringComparison.OrdinalIgnoreCase) || + !string.Equals(rebuiltSsel, canonicalAssociation.SessionSelector, StringComparison.OrdinalIgnoreCase) || + !string.Equals(rebuiltTsel, canonicalAssociation.TransportSelector, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Generated SCL reconnect association drifted from accepted canonical wire evidence. " + + $"Expected AP={canonicalAssociation.ApTitle}, AE={canonicalAssociation.AeQualifier}, " + + $"PSEL={canonicalAssociation.PresentationSelector}, SSEL={canonicalAssociation.SessionSelector}, " + + $"TSEL={canonicalAssociation.TransportSelector}; rebuilt AP={rebuiltApTitle}, " + + $"AE={plan.Association.Called.AeQualifier}, PSEL={rebuiltPsel}, SSEL={rebuiltSsel}, TSEL={rebuiltTsel}."); + } + + var local = AR.Iec61850.Scl.MmsLocalAssociationProfile.ExistingRuntimeDefault; + var callingApTitle = plan.Association.Calling.ApTitle; + if (!plan.Cotp.SourceTsap.SequenceEqual(local.TransportSelector) || + !plan.Association.Calling.SessionSelector.SequenceEqual(local.SessionSelector) || + !plan.Association.Calling.PresentationSelector.SequenceEqual(local.PresentationSelector) || + !callingApTitle.SequenceEqual(local.ApTitle) || + plan.Association.Calling.AeQualifier != local.AeQualifier) + { + throw new InvalidOperationException( + "Generated SCL reconnect plan changed the proven calling-side runtime identity."); + } + + var acceptedProfileName = canonical.Communication.AssociationProfileName?.Trim() ?? string.Empty; + var acceptedProfile = AR.Iec61850.Acse.AcseMmsInitiateRequest + .BuildAssociationProfiles() + .SingleOrDefault(profile => + string.Equals(profile.Name, acceptedProfileName, StringComparison.Ordinal)); + if (acceptedProfile is not null && + !plan.SessionPresentationAcseMmsRequest.SequenceEqual(acceptedProfile.Payload)) + { + throw new InvalidOperationException( + $"Generated SCL reconnect AARQ does not reproduce accepted association profile '{acceptedProfileName}' byte-for-byte."); + } + + return workspace; + } +} diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index d9ed9af87..cb5a7d36b 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -90,6 +90,25 @@ private sealed class DeviceSession public int ConnectedDeviceCount => _sessions.Values.Count(session => session.Client.IsConnected); public int MonitoringDeviceCount => _sessions.Values.Count(session => session.Device.IsMonitoring); + public async Task EnrichCanonicalForSclSaveAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + if (device.IsMonitoring) + throw new InvalidOperationException($"{device.Name}: stop monitoring before Save-SCL value enrichment."); + if (!_sessions.TryGetValue(device.DeviceId, out var session) || !session.Client.IsConnected) + throw new InvalidOperationException($"{device.Name}: Save-SCL value enrichment requires the active MMS connection."); + + var canonical = await session.Client + .EnrichCanonicalForSclSaveAsync(cancellationToken) + .ConfigureAwait(false); + device.LiveCanonicalModel = canonical; + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + $"Save SCL enrichment complete; instanceEvidence={canonical.InstanceValues.Count}"); + return canonical.InstanceValues.Count; + } + public async Task> ConnectAndDiscoverAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken, @@ -137,6 +156,7 @@ public async Task> ConnectAndDiscoverAsync( var discovered = await session.Client.DiscoverSignalsAsync(cancellationToken, progress).ConfigureAwait(false); if (session.Client.LastLiveModel != null) device.LiveDiscoveryModel = session.Client.LastLiveModel; + device.LiveCanonicalModel = session.Client.LastCanonicalModel; var signals = discovered .Where(signal => signal.CanPublishAsSignal || signal.IsControlSignal) @@ -403,6 +423,7 @@ private async Task ConnectUsingSelectedFastPathAsync( throw new InvalidOperationException(result.Message); device.LiveDiscoveryModel = session.Client.LastLiveModel ?? device.SclWorkspace?.DesignModel; + device.LiveCanonicalModel = session.Client.LastCanonicalModel; Log("INFO", device.Name, $"Verified SCL authority active: SHA256={verified.Sha256}; IED={device.SclIedName}; AP={device.SclAccessPointName}; maxReadRefs={result.Preparation.InitialReadPlan?.MaximumVariableReferencesPerRead ?? 0}."); return path; diff --git a/Services/NativeIec61850Client.CanonicalModel.cs b/Services/NativeIec61850Client.CanonicalModel.cs new file mode 100644 index 000000000..ffb53e4e1 --- /dev/null +++ b/Services/NativeIec61850Client.CanonicalModel.cs @@ -0,0 +1,104 @@ +using AR.Iec61850.Discovery; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private LiveIedCanonicalModel? _liveCanonicalModel; + private ArMms.InitialFcReadExecutionResult? _liveInitialFcRead; + + /// + /// Canonical live snapshot bound to the accepted MMS association that produced + /// the discovery model. Safe SCL export consumes this snapshot so communication + /// parameters and instance values are evidence-backed rather than reconstructed + /// from UI defaults. + /// + public LiveIedCanonicalModel? LastCanonicalModel => _liveCanonicalModel; + public ArMms.InitialFcReadExecutionResult? LastInitialFcRead => _liveInitialFcRead; + + private LiveIedCanonicalModel BuildCanonicalModel( + LiveIedModelDiscoveryDocument model, + ArMms.InitialFcReadExecutionResult? initialRead = null) + { + ArgumentNullException.ThrowIfNull(model); + + var accessPointName = string.IsNullOrWhiteSpace(model.AccessPointName) + ? "AP1" + : model.AccessPointName.Trim(); + var communication = _session.GetAcceptedCommunicationEvidence(accessPointName); + return LiveIedCanonicalModelBuilder.Build(model, communication, initialRead); + } + + private void PublishCanonicalModel( + LiveIedModelDiscoveryDocument model, + ArMms.InitialFcReadExecutionResult? initialRead = null) + { + _liveInitialFcRead = initialRead; + _liveCanonicalModel = BuildCanonicalModel(model, initialRead); + } + + private static readonly HashSet SaveSclEnrichmentFunctionalConstraints = new( + ["ST", "MX", "SV", "CF", "DC", "SG", "SE", "SR", "OR", "BL", "EX", "SP"], + StringComparer.Ordinal); + + /// + /// Enriches the canonical snapshot only when the user explicitly saves SCL. + /// Smart Discovery stays structure/type-only; this bounded phase reads safe FC + /// roots on the already accepted association and projects exact scalar leaves. + /// Control/report service FCs (CO/RP/BR/LG/GO/GS/MS/US) are never read here. + /// + public async Task EnrichCanonicalForSclSaveAsync( + CancellationToken cancellationToken = default) + { + if (!_session.IsMmsInitiated || !_session.IsTransportConnected) + throw new InvalidOperationException("Save-SCL enrichment requires the existing initiated MMS association."); + if (_liveModel is null) + throw new InvalidOperationException("Save-SCL enrichment requires a successful live discovery model."); + + var sourcePlan = ArMms.InitialFcReadPlanner.FromSclModel(_liveModel); + var safeTargets = sourcePlan.Targets + .Where(target => SaveSclEnrichmentFunctionalConstraints.Contains( + (target.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant())) + .ToArray(); + var plan = ArMms.InitialFcReadPlanner.Build( + safeTargets, + sourcePlan.MaximumVariableReferencesPerRead); + if (!plan.IsValid) + { + throw new InvalidOperationException( + "Save-SCL enrichment could not build a safe FC-root Read plan: " + + string.Join(" | ", plan.Errors)); + } + + var initialRead = await _session.ExecuteInitialFcReadPlanSmartAsync( + plan, + new ArMms.MmsSmartInitialFcReadOptions + { + MaxOutstandingBatches = 8, + UnknownPeerMaxOutstandingBatches = 4, + PerBatchTimeout = TimeSpan.FromSeconds(5) + }, + cancellationToken) + .ConfigureAwait(false); + + if (initialRead.Status is ArMms.InitialFcReadExecutionStatus.InvalidPlan + or ArMms.InitialFcReadExecutionStatus.SessionNotReady + or ArMms.InitialFcReadExecutionStatus.TimedOut + or ArMms.InitialFcReadExecutionStatus.TransportFailure) + { + throw new InvalidOperationException( + $"Save-SCL enrichment failed ({initialRead.Status}): {initialRead.Message}"); + } + + PublishCanonicalModel(_liveModel, initialRead); + return _liveCanonicalModel + ?? throw new InvalidOperationException("Save-SCL enrichment did not publish a canonical model."); + } + + private void ClearCanonicalModel() + { + _liveInitialFcRead = null; + _liveCanonicalModel = null; + } +} diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index 008453011..3c5c17940 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -31,7 +31,9 @@ public sealed partial class NativeIec61850Client private readonly Dictionary _trustedSclDataSetDirectories = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _trustedSclInitialValues = - new(StringComparer.OrdinalIgnoreCase); + // IEC 61850 object/member identity is case-sensitive. Edition 2 tracking + // can legally expose distinct paths such as "...t" and "...T". + new(StringComparer.Ordinal); private bool _trustedSclOnlineAuthorityActive; internal bool HasTrustedSclOnlineAuthority => _trustedSclOnlineAuthorityActive; @@ -183,10 +185,39 @@ preparation.InitialReadDesign is null || }; } + var safeInitialTargets = preparation.InitialReadPlan.Targets + .Where(target => IsSafeTrustedSclInitialReadFc(target.FunctionalConstraint)) + .ToArray(); + var safeInitialPlan = ArMms.InitialFcReadPlanner.Build( + safeInitialTargets, + preparation.InitialReadPlan.MaximumVariableReferencesPerRead); + if (!safeInitialPlan.IsValid) + { + LastConnectionFailureKind = "SCL_INITIAL_FC_PLAN_INVALID"; + LastErrorMessage = string.Join(" | ", safeInitialPlan.Errors); + LastConnectionTechnicalSummary = LastErrorMessage; + await _session.DisposeAsync().ConfigureAwait(false); + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Online = online, + Warnings = preparation.Warnings, + AssociationValidationDuration = associationDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + var readWatch = Stopwatch.StartNew(); - var initialRead = await _session.ExecuteInitialFcReadPlanAsync( - preparation.InitialReadPlan, - TimeSpan.FromSeconds(5), + var initialRead = await _session.ExecuteInitialFcReadPlanSmartAsync( + safeInitialPlan, + new ArMms.MmsSmartInitialFcReadOptions + { + MaxOutstandingBatches = 8, + UnknownPeerMaxOutstandingBatches = 4, + PerBatchTimeout = TimeSpan.FromSeconds(5) + }, cancellationToken).ConfigureAwait(false); readWatch.Stop(); initialReadDuration = readWatch.Elapsed; @@ -232,6 +263,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut NormalizeTrustedSclReference(directory.DataSetReference)] = directory; } + var projectedInitialValueKeys = new HashSet(StringComparer.Ordinal); foreach (var leaf in initialRead.Batches .SelectMany(batch => batch.Projections) .SelectMany(projection => projection.Leaves)) @@ -239,7 +271,12 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut if (string.IsNullOrWhiteSpace(leaf.Reference)) continue; - _trustedSclInitialValues[NormalizeTrustedSclReference(leaf.Reference)] = new TrustedSclInitialValue + var normalizedReference = NormalizeTrustedSclReference(leaf.Reference); + if (string.IsNullOrWhiteSpace(normalizedReference)) + continue; + + projectedInitialValueKeys.Add(normalizedReference); + _trustedSclInitialValues[normalizedReference] = new TrustedSclInitialValue { Reference = leaf.Reference, FunctionalConstraint = leaf.FunctionalConstraint, @@ -248,6 +285,11 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut }; } + var projectedUniqueValues = projectedInitialValueKeys.Count; + var initialValueCacheLoss = Math.Max( + 0, + projectedUniqueValues - _trustedSclInitialValues.Count); + _lastDiscovery = new ArMms.MmsDiscoveryResult { Snapshot = new ArMms.MmsDiscoverySnapshot @@ -259,33 +301,51 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut IedDirectory = new ArMms.MmsIedModelDirectory(Array.Empty()), DataSetDirectories = dataSetDirectories, Summary = - "Trusted SCL authority: Domain/VMD validation and bounded FC-root snapshot completed; " + + "Trusted SCL authority: Domain/VMD validation and bounded SCL-guided structured snapshot completed; " + "static DataSet/RCB authority retained locally; full live discovery intentionally skipped." }; LastReportInventory = ToNativeInventory(reportInventory); _trustedSclOnlineAuthorityActive = true; - var projectionErrors = initialRead.Batches - .Sum(batch => batch.Projections.Sum(projection => projection.Errors.Count)); + var projectionErrorDetails = initialRead.Batches + .SelectMany(batch => batch.Projections) + .SelectMany(projection => projection.Errors.Select(error => + $"{projection.Target.MmsReference}: {error}")) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var projectionErrors = projectionErrorDetails.Length; + var projectionErrorSamples = projectionErrorDetails + .Take(8) + .ToArray(); var extraDomains = online.Domains?.ExtraObservedDomains.Count ?? 0; - var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial; + var dataObjectScopedTargets = initialRead.Plan.Targets.Count(target => target.IsDataObjectScoped); + var fcRootTargets = initialRead.Plan.Targets.Count - dataObjectScopedTargets; + var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial || + initialValueCacheLoss > 0; LastDiscoverySummary = $"SCL-assisted MMS: domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " + - $"FC-roots={initialRead.Plan.Targets.Count}, successfulReads={initialRead.SuccessfulTargetCount}, " + - $"failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + - $"initialValueCache={_trustedSclInitialValues.Count}, projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, " + - $"staticDataSets={dataSetDirectories.Count}, staticRCB={reportInventory.ReportControls.Count}, fullDiscovery=skipped."; + $"initialTargets={initialRead.Plan.Targets.Count}, fcRootTargets={fcRootTargets}, doScopedTargets={dataObjectScopedTargets}, " + + $"successfulReads={initialRead.SuccessfulTargetCount}, failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + + $"projectedUniqueValues={projectedUniqueValues}, initialValueCache={_trustedSclInitialValues.Count}, cacheLoss={initialValueCacheLoss}, " + + $"projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, " + + $"staticDataSets={dataSetDirectories.Count}, staticRCB={reportInventory.ReportControls.Count}, fullDiscovery=skipped." + + (projectionErrorSamples.Length == 0 + ? string.Empty + : $" projectionErrorSamples=[{string.Join(" || ", projectionErrorSamples)}]"); LastConnectionFailureKind = string.Empty; LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message; - LastErrorMessage = partial - ? "SCL-assisted association is healthy, but one or more initial FC-root values could not be read or projected. The trusted SCL model was preserved." - : string.Empty; + LastErrorMessage = initialValueCacheLoss > 0 + ? $"SCL-assisted association is healthy, but exact case-sensitive value caching lost {initialValueCacheLoss} projected value(s). The trusted SCL model was preserved and semantic convergence remains incomplete." + : partial + ? "SCL-assisted association is healthy, but one or more initial SCL-guided values could not be read or projected. The trusted SCL model was preserved." + : string.Empty; var warnings = preparation.Warnings .Concat(extraDomains > 0 ? new[] { $"IED exposes {extraDomains} extra online MMS domain(s); they remain evidence only and do not mutate the SCL model." } : Array.Empty()) .Concat(partial ? new[] { LastErrorMessage } : Array.Empty()) + .Concat(projectionErrorSamples.Select(error => $"SCL initial projection: {error}")) .Where(message => !string.IsNullOrWhiteSpace(message)) .Distinct(StringComparer.Ordinal) .ToArray(); @@ -540,6 +600,10 @@ private static string StaticRcbLeaf(string reference) : reference; } + private static bool IsSafeTrustedSclInitialReadFc(string? functionalConstraint) + => (functionalConstraint ?? string.Empty).Trim().ToUpperInvariant() is + "ST" or "MX" or "SP" or "SV" or "CF" or "DC" or "EX" or "BL" or "OR" or "SR"; + private static string NormalizeTrustedSclReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.'); + => (reference ?? string.Empty).Trim().Replace((char)36, '.'); } diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs new file mode 100644 index 000000000..6fa8c0f76 --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -0,0 +1,340 @@ +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 const string SmartDiscoveryStructuralFreezeContract = "P0-R9-STRUCTURAL"; + private static bool SmartDiscoveryCaptureModeEnabled => true; + + // P0 structural-discovery freeze. + // These values are the exact ARSAS policy that produced the accepted R9 wire shape. + // They are intentionally centralized so later SCL/value work cannot silently move + // additional MMS traffic back into the discovery critical path. + private static ArMms.MmsSmartDiscoveryOptions CreateP0FrozenSmartDiscoveryOptions() + => new() + { + MaxConcurrentChains = 8, + UnknownPeerMaxConcurrentChains = 4, + MaxDomains = 256, + MaxVariableNamesPerDomain = 20000, + MaxVariableListNamesPerDomain = 4096, + MaxNameListPages = 64, + ProbeReportAttributes = true, + MaxReportAttributeProbes = 64, + ReadDataSetDirectories = true, + MaxDataSetDirectoryReads = 64 + }; + + private async Task> DiscoverSignalsSmartForCaptureAsync( + CancellationToken cancellationToken, + IProgress? progress) + { + 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…", + 84d, 8, 11)); + + 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…", + 95d, 9, 11)); + + 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; freeze={SmartDiscoveryStructuralFreezeContract}; association authority=reused; association flight=new-wire-free; control inventory=authoritative; wire discovery=skipped; discoveryValues=deferred; " + + $"IEDName={(string.IsNullOrWhiteSpace(cachedIdentity.IedName) ? "unresolved" : cachedIdentity.IedName)} ({cachedIdentity.Source}); " + + $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + + $"instance leaves={LastCanonicalModel?.InstanceValues.Count ?? 0}, " + + $"indexed LN hints={cachedProjectionStats.LogicalNodeHints}, indexed fallback signals={cachedProjectionStats.AddedFallbackSignals}. " + + $"{cachedBudget} " + + $"TimingMs directory=0.0, types=0.0, model=0.0, projection={cachedProjectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"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 = CreateP0FrozenSmartDiscoveryOptions(); + + 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(); + + // Physical R7 testing showed that eager FC-root instance Reads dominate + // discovery latency while generated connection SCL does not require Val + // elements to reconnect. Keep Smart Discovery structural/type-only and defer + // value acquisition to the normal monitoring or trusted-SCL online path. + ArMms.InitialFcReadExecutionResult? initialRead = null; + var initialReadWatch = Stopwatch.StartNew(); + initialReadWatch.Stop(); + + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + + 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; freeze={SmartDiscoveryStructuralFreezeContract}; association authority=new; association flight=single-owner; app MMS gate=exclusive; control inventory=authoritative; discoveryValues=deferred; " + + $"IEDName={(string.IsNullOrWhiteSpace(identity.IedName) ? "unresolved" : identity.IedName)} ({identity.Source}); " + + $"{discovery.Summary} {liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + + $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}, " + + $"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}, initialRead={initialReadWatch.Elapsed.TotalMilliseconds:F1}, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"initialFcRoots=deferred, initialReadBatches=0, instanceLeaves=0, " + + $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes."; + + // 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, + initialRead, + 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..41d68ddfe --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs @@ -0,0 +1,158 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private readonly object _smartDiscoveryFlightSync = new(); + private Task>? _smartDiscoveryAssociationFlight; + private long _smartDiscoveryAssociationGeneration; + private long _smartDiscoveryFlightGeneration = -1; + private string _smartDiscoveryAuthorityHost = string.Empty; + private int _smartDiscoveryAuthorityPort; + + /// + /// Explicitly invalidates every association-scoped smart-discovery authority 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; + ClearCanonicalModel(); + _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, + ArMms.InitialFcReadExecutionResult? initialRead, + NativeReportInventory reportInventory, + Iec61850DeviceIdentity identity, + int typeProbeCount, + int successfulTypeProbeCount, + string summary) + { + lock (_smartDiscoveryFlightSync) + { + if (generation != _smartDiscoveryAssociationGeneration || !_session.IsMmsInitiated) + return false; + + _lastDiscovery = discovery; + _liveModel = model; + PublishCanonicalModel(model, initialRead); + 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..6a73b1fbb --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs @@ -0,0 +1,226 @@ +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 || + LastCanonicalModel is null || + !ReferenceEquals(LastCanonicalModel.Discovery, _smartDiscoveryModelAuthority) || + !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/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index f71c41a34..04a657e42 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -62,6 +62,7 @@ public async Task ConnectAsync(string ipAddress, int port, CancellationToken can LastConnectionTechnicalSummary = string.Empty; _lastDiscovery = null; _liveModel = null; + ClearCanonicalModel(); _reportMonitorSessions.Clear(); _reportMonitorCoverage.Clear(); ResetSemanticReportProjectionContext(); @@ -161,6 +162,7 @@ public async Task> DiscoverSignalsAsync(Cancella Port = _port, IncludeLowConfidenceTemplates = true }); + PublishCanonicalModel(_liveModel); var primarySnapshot = ToNativeSnapshot(discovery.Snapshot); progress?.Report(new IedDiscoveryProgress( @@ -672,6 +674,7 @@ private static string NormalizeReportedDataSetReference(string domain, string va Port = _port, IncludeLowConfidenceTemplates = true }); + PublishCanonicalModel(_liveModel); LastReportInventory = ToNativeInventory(discovery.ReportInventory); DetectedIdentity = Iec61850DeviceIdentityResolver.Resolve(discovery, _liveModel, Array.Empty()); LastDiscoverySummary = $"IEDName={(string.IsNullOrWhiteSpace(DetectedIedName) ? "unresolved" : DetectedIedName)} ({DetectedIdentity.Source}); {discovery.Summary} {_liveModel.Summary} Engine=ARIEC61850 live-model/schema/reporting."; diff --git a/Services/SclAssistedConnectionPreparation.cs b/Services/SclAssistedConnectionPreparation.cs index e37b849e9..c328b884d 100644 --- a/Services/SclAssistedConnectionPreparation.cs +++ b/Services/SclAssistedConnectionPreparation.cs @@ -122,9 +122,13 @@ public static SclAssistedConnectionPreparation Build( Parameters = sclRemote.Parameters }; + // Reuse the same calling-side identity as the proven native runtime + // association. The previous SclInteroperabilityDefault changed source TSEL + // to 0000 and calling AE qualifier to 23, so a generated SCL could round-trip + // its remote/called identity while still emitting a different wire handshake. var association = ArScl.SclAssistedMmsAssociationPlanBuilder.BuildExact( effectiveRemote, - ArScl.MmsLocalAssociationProfile.SclInteroperabilityDefault); + ArScl.MmsLocalAssociationProfile.ExistingRuntimeDefault); warnings.AddRange(association.Warnings); if (!association.IsSuccess || association.Plan is null) { diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs index 8b3949823..9fd8d8e4b 100644 --- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -74,6 +74,14 @@ public Iec61850MonitorRuntime() public int ConnectedDeviceCount => _inner.ConnectedDeviceCount; public int MonitoringDeviceCount => _inner.MonitoringDeviceCount; + public Task EnrichCanonicalForSclSaveAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken) + => RunDeviceOperationAsync( + device?.DeviceId, + cancellationToken, + token => _inner.EnrichCanonicalForSclSaveAsync(device, token)); + public Task> ConnectAndDiscoverAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken, diff --git a/docs/IEDSCOUT_CONVERGENCE.md b/docs/IEDSCOUT_CONVERGENCE.md new file mode 100644 index 000000000..673294a00 --- /dev/null +++ b/docs/IEDSCOUT_CONVERGENCE.md @@ -0,0 +1,160 @@ +# IEDScout Convergence Contract + +## Product target + +ARSAS targets IEDScout-equivalent IEC 61850 engineering semantics with lower wire cost where possible: + +1. one accepted MMS association and bounded structure-first discovery; +2. a complete canonical model with exact LN/DO/SDO/DA/FC identity; +3. Edition 2 IID / Edition 1 ICD that can be reopened by ARSAS, reconnect to the same relay, hydrate values without full discovery, and run configured static reporting. + +The machine-readable authority is `evidence/iedscout-convergence-target.json`. + +## Merged proven baseline + +The physical R10 baseline was tested with: + +- ARSAS `eb8eb13d491f9aa265205852b8a4bab07af440ff`; +- ARIEC61850 tested head `9935d6902d786cc69b299260fe36b835944d5e81`; +- ARIEC61850 merged-main commit `648124097621046f5f127ceb1cf853fea54db730`. + +The tested engine head and merged-main commit have the identical tree SHA +`1cf7e08f333f24994625e8fe8416dbd0a16195b1`. + +Merged engine provenance: + +- PR #134 → main merge `e6779ff74e5716af4fcfc3dc926dae0567b3cdb0`: Smart Discovery performance authority. +- PR #135 → main merge `648124097621046f5f127ceb1cf853fea54db730`: canonical model, SCL interoperability, P1/P2 value pipeline. +- ARSAS PR #324: consumer integration and physical R10 proof. + +## P0 — structural discovery freeze + +Contract: `P0-R9-STRUCTURAL`. + +AA1E1F06R4 physical reference is locked at one association, 323 confirmed MMS +requests, 138 GetNameList, 119 GetVariableAccessAttributes, 2 +GetNamedVariableListAttributes and 64 Reads, while preserving 32 LD / 119 LN / +860 top-level DO / 906 DO+SDO / 4925 scalar leaves / 2 DataSets / 58 FCDA / 32 +logical ReportControls / 1 SettingControl. + +The same-relay IEDScout comparison remains about 417 confirmed requests, 119 GVA +and 156 Reads. ARSAS must not add traffic merely to imitate IEDScout. + +Forbidden regressions include a second discovery association, legacy supplemental +browse, a second full GetNameList sweep, recursive per-leaf GVA, and eager FC-root +value hydration on the discovery critical path. + +## P1 — CF projection-order repair: physically proven + +Contract: `P1-CF-DO-SCOPED`. + +R9 exposed 46 projection errors because SCL LNodeType DO order was incorrectly used +as MMS CF-root child order. P1 reads multi-DO CF data through exact `LN$CF$DO` +references and batches those structured reads. + +R10 physical reuse closes P1: + +- Ed2: 709 initial targets, 525 FC-root targets, 184 DO-scoped targets, + 709/709 successful, 0 failed, `projectionErrors=0`. +- Ed1: 708 initial targets, 524 FC-root targets, 184 DO-scoped targets, + 708/708 successful, 0 failed, `projectionErrors=0`. + +No extra discovery GVA or second association was introduced. + +## P2 — exact-case value pipeline: physically proven + +Contract: `P2-CASE-EXACT-VALUES`. + +R9 Ed2 lost 11 projected values because a consumer cache used case-insensitive +identity. P2 uses exact-case identity through TypeSpecification mapping, initial +projection, trusted-SCL caching, canonical evidence and SCL instance-value targeting. + +R10 physical reuse closes P2: + +- Ed2: `projectedUniqueValues=4441`, `initialValueCache=4441`, `cacheLoss=0`. +- Ed1: `projectedUniqueValues=4246`, `initialValueCache=4246`, `cacheLoss=0`. + +## R10 round-trip/reporting acceptance + +Both generated editions reopen as trusted SCL and reconnect without full discovery: + +- expected/observed/matched MMS domains: 32/32/32; +- DataSets: 2, members: 58, missing members: 0; +- configured report plans: Digital BRCB 36 members + Analog URCB 22 members; +- report-covered runtime points: 58; +- final unresolved runtime points: 0; +- cyclic MMS process polling: 0; +- actual InformationReport traffic observed on both editions. + +The early UI field `Primary unresolved=2` is not an operational loss: the exact +static DataSet schema resolves all 58 runtime points before reporting starts. + +## Save-time instance values + +R10 physically proves bounded Save SCL enrichment while fast discovery stays +unchanged: + +- canonical instance evidence: 3854 leaves; +- Ed2 exported `Val`: 3202; +- Ed1 exported `Val`: 3106. + +IEDScout's golden file has about 1529 `Val` elements. A larger count is not +automatically better; the remaining task is semantic path/value comparison, not +count chasing. + +## RCB lock + +The accepted representation remains: + +- 34 runtime RCB objects → 32 logical SCL ReportControls; +- `Services/ConfReportControl max=34`; +- Buffer/Digital and Unbuffer/Analog remain the two configured report authorities; +- preallocated ADD slots without DataSet never receive an invented `datSet`. + +## Next improvements — do not disturb the proven wire/reuse path + +### 1. Template interning + +R10 Ed2 is semantically correct but verbose: + +- ARSAS: 119 LNodeType / 906 DOType / 752 DAType / about 731 KB; +- IEDScout reference: about 38 / 60 / 17 / about 247 KB. + +Next work may intern only templates with identical ordered semantic fingerprints. +Expanded model counts, FC ownership, values, DataSets/RCBs and round-trip behavior +must remain unchanged. + +### 2. Reuse one save-enrichment snapshot across Ed2 and Ed1 + +When Ed2 and Ed1 are saved in the same unchanged live association/model generation, +both currently derive the same 3854 instance-evidence leaves. A later optimization +may reuse that bounded snapshot across serializers, but never across reconnect, +model-generation change or explicit refresh. + +### 3. Semantic Val diff + +Compare ARSAS vs IEDScout by exact +`LD/LN/DO/SDO/DA/BDA/FC/bType/value` path, not raw XML position and not total +`Val` count. + +## Regression signatures that are forbidden + +A build is rejected if it restores any of these patterns: + +- legacy `DiscoverAsync` as the public discovery route; +- a second supplemental discovery association; +- recursive per-leaf GVA expansion; +- speculative thousands of Reads on the discovery path; +- cross-DO positional CF projection; +- case-insensitive IEC 61850 member/value identity; +- WYE/DEL/SEQ SDO flattening; +- Edition 2 tracking CDCs in Edition 1; +- invented DataSet bindings for unassigned RCB slots; +- silent canonical-save success without reopen/association validation. + +## Promotion state + +The R10 physical retest has passed and merge is allowed. Production promotion is +still a separate release decision. + +The field result, not test count alone, decides interoperability acceptance. 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..360e72a96 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,17 +2,25 @@ "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": "648124097621046f5f127ceb1cf853fea54db730", + "sourcePullRequest": 135, + "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 648124097621046f5f127ceb1cf853fea54db730 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged. P2 makes instance-value identity exact-case end-to-end: ARSAS trusted-SCL caching uses StringComparer.Ordinal and reports projectedUniqueValues/cacheLoss; engine TypeSpecification member resolution and canonical DO/DA instance-value targeting are case-sensitive so legal paths such as tracking t/T cannot collapse or cross-resolve. R10 physical acceptance passed on AA1E1F06R4: Ed2 and Ed1 both reached projectionErrors=0 and cacheLoss=0, all planned reads succeeded, 58/58 runtime points were report-backed, and actual InformationReport traffic was observed. The exact tested commit 9935d6902d786cc69b299260fe36b835944d5e81 and merged main commit 648124097621046f5f127ceb1cf853fea54db730 have the identical source tree 1cf7e08f333f24994625e8fe8416dbd0a16195b1.", "previousTrialPin": { + "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "sourcePullRequest": 134, + "purpose": "Previous P0-5b/P0-5c field-capture engine pin retained as the physical R5 baseline before canonical communication evidence, logical RCB SCL projection, and round-trip association validation were added." + }, + "priorGoldenWirePin": { "commit": "0023ef9a4373855497464ed3979e359c4041c95d", "sourcePullRequest": 132, - "purpose": "Previous ARSAS 1.6.36 combined golden-wire convergence pin retained for explicit ancestry." + "purpose": "Earlier ARSAS 1.6.36 combined golden-wire convergence trial retained for explicit ancestry." }, "fieldProvenBaseline": { "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", "sourcePullRequest": 111, "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." - } + }, + "physicalTestedCommit": "9935d6902d786cc69b299260fe36b835944d5e81", + "mergedMainCommit": "648124097621046f5f127ceb1cf853fea54db730", + "mergedMainTree": "1cf7e08f333f24994625e8fe8416dbd0a16195b1" } 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/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json new file mode 100644 index 000000000..a853fd0ea --- /dev/null +++ b/evidence/iedscout-convergence-target.json @@ -0,0 +1,500 @@ +{ + "schemaVersion": 1, + "name": "IEDScout discovery and SCL convergence", + "status": "physical-retest-passed-merge-ready", + "target": { + "discovery": "Reach IEDScout-like MMS discovery efficiency without sacrificing exact IEC 61850 model evidence.", + "model": "Build the canonical IEC 61850 model from structure-first MMS evidence with correct LN identity, DO/SDO hierarchy, FC ownership, DataSet/RCB semantics, and schema-aware CDC handling.", + "scl": "Save Edition 2 IID / Edition 1 ICD that reopens in ARSAS, reconnects to the relay, and is usable as trusted SCL." + }, + "activeStack": { + "enginePerformance": { + "repository": "masarray/ARIEC61850", + "pullRequest": 134, + "head": "a31e396f0bbc9507215a8c9d125e910555ed6f0a", + "status": "merged", + "testedHead": "a31e396f0bbc9507215a8c9d125e910555ed6f0a", + "mainMerge": "e6779ff74e5716af4fcfc3dc926dae0567b3cdb0" + }, + "engineModelAndScl": { + "repository": "masarray/ARIEC61850", + "pullRequest": 135, + "basePullRequest": 134, + "head": "648124097621046f5f127ceb1cf853fea54db730", + "status": "merged", + "testedHead": "9935d6902d786cc69b299260fe36b835944d5e81", + "mainMerge": "648124097621046f5f127ceb1cf853fea54db730" + }, + "consumerIntegration": { + "repository": "masarray/arsas", + "pullRequest": 324, + "branch": "test/smart-ied-discovery-pr134", + "status": "physical-proven-merge-ready" + } + }, + "physicalReference": { + "relay": "AA1E1F06R4", + "logicalDevices": 32, + "logicalNodes": 119, + "dataSets": 2, + "runtimeReportControls": 34, + "logicalSclReportControls": 32, + "iedScoutCapture": { + "associations": 1, + "confirmedMmsRequests": 417, + "getVariableAccessAttributes": 119, + "reads": 156, + "note": "Reference capture supplied by the physical comparison; values are acceptance reference, not CI-simulated proof." + }, + "rejectedLegacyArsasCapture": { + "associations": 2, + "confirmedMmsRequests": 30552, + "getVariableAccessAttributes": 23724, + "reads": 6548, + "note": "Regression signature. A future field build showing this pattern is rejected." + }, + "iedScoutSclReference": { + "lDevices": 32, + "logicalNodes": 119, + "dataSets": 2, + "fcda": 58, + "logicalReportControls": 32, + "expandedTopLevelDataObjects": 860, + "expandedDataObjectsIncludingSdo": 906, + "expandedScalarLeaves": 4925 + } + }, + "discoveryAcceptance": { + "exactlyOneAssociation": true, + "supplementalLegacyAssociationForbidden": true, + "recursivePerLeafGvaStormForbidden": true, + "secondFullGetNameListSweepForbidden": true, + "eagerInitialFcValueReadDuringStructuralDiscovery": false, + "typeStrategy": "LN/root structure first, bounded leaf fallback only when exact structure evidence is unavailable.", + "physicalPerformanceGate": "Compare request count, GVA count, Read count, peak outstanding calls, TTFI and total discovery time against IEDScout on the same relay." + }, + "modelAcceptance": { + "prefixedLogicalNodeIdentityExamples": [ + "RPRE_MMXU1 => prefix=RPRE_, lnClass=MMXU, lnInst=1", + "FPRE_MMXN1 => prefix=FPRE_, lnClass=MMXN, lnInst=1", + "I01ATCTR1 => prefix=I01A, lnClass=TCTR, lnInst=1" + ], + "standardSdoProjection": [ + "WYE", + "DEL", + "SEQ" + ], + "descendantFunctionalConstraintOwnership": true, + "standardRegistryAuthority": [ + "TCTR", + "TVTR", + "LTIM", + "EEName", + "MltLev" + ], + "edition2ServiceTrackingCdc": [ + "CST", + "BTS", + "UTS", + "STS", + "CTS" + ], + "edition1TrackingDowngrade": "omit with explicit warning; never emit invalid Edition 1 SCL", + "mmsTypeDeclarationOrder": "LN-root TypeSpecification declaration order is authoritative through model, SCL and FC-root projection.", + "settingFunctionalConstraints": "SG/SE are setting data; only actual SGCB produces SettingControl.", + "mhaiThdProjection": "MHAI ThdA/ThdPhV are WYE with CMV phase SDOs." + }, + "sclAcceptance": { + "profile": "full-model", + "reopenInArsasRequired": true, + "exactAssociationRebuildRequired": true, + "nativeCallingIdentityRequired": true, + "initialTrustedSclReads": "safe bounded SCL-guided reads after trusted-SCL reconnect; multi-DO CF groups use DO-scoped structured Reads because SCL LNodeType order is not authoritative for MMS FC-root DO order.", + "rcbProjection": "34 runtime RCB -> 32 logical ReportControl; ConfReportControl max=34", + "zeroSilentModelDeletion": true, + "physicalReconnectRequired": true, + "reuseTransportAndReportingLock": "Both Edition 2 and Edition 1 must match 32/32 MMS domains, complete all planned safe FC-root Reads without transport failures, preserve both static DataSets, arm exactly the configured Analog/Digital report plans, resolve all 58 runtime points, and observe actual InformationReport traffic.", + "semanticProjectionTarget": "projectionErrors=0 and projected leaf cache has no case-collapse loss." + }, + "promotion": { + "productionPromoted": false, + "mergeAllowedBeforePhysicalRetest": false, + "physicalRetestRequired": false, + "requiredEvidence": [ + "new ARSAS PCAP", + "new generated Ed2 IID", + "new generated Ed1 ICD", + "diagnostic report", + "Ed2 SCL-assisted reuse diagnostic with projectionErrorSamples", + "Ed1 SCL-assisted reuse diagnostic with projectionErrorSamples", + "same-relay comparison against IEDScout" + ], + "mergeAllowedAfterPhysicalRetest": true, + "physicalRetestPassed": true, + "physicalRetestDate": "2026-09-19", + "note": "R10 physical discovery/save/reopen evidence passed for the exact ARSAS/engine source tree. Merge is allowed; production promotion remains a separate release decision." + }, + "legacyPolicy": { + "rule": "Historical PRs may remain as provenance only; they must not be used as new branch bases after an active convergence authority supersedes them.", + "knownInvalidAuthority": [ + "PR #125 calling identity profile" + ], + "activeAuthorityOnly": [ + "ARIEC61850 #134", + "ARIEC61850 #135", + "ARSAS #324" + ] + }, + "physicalEvidence": { + "arsasR9": { + "relay": "AA1E1F06R4", + "testedArtifact": { + "appHead": "a89d6ef230951fde98f2b35e38dbc2dc84b6382f", + "engineHead": "3e12fb9d84ae27ddbebb2389ed2eb58d0fde6d90" + }, + "discovery": { + "associations": 1, + "confirmedMmsRequests": 323, + "getNameList": 138, + "getVariableAccessAttributes": 119, + "getNamedVariableListAttributes": 2, + "reads": 64, + "topLevelDataObjects": 860, + "dataObjectsIncludingSdo": 906, + "scalarLeaves": 4925, + "dataSets": 2, + "fcda": 58, + "logicalReportControls": 32, + "settingControls": 1 + }, + "reuseEdition2": { + "expectedDomains": 32, + "matchedDomains": 32, + "fcRoots": 563, + "successfulReads": 563, + "failedReads": 0, + "projectedLeaves": 4250, + "initialValueCache": 4239, + "projectionErrors": 46, + "staticDataSets": 2, + "staticReportControls": 32, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "actualInformationReportObserved": true + }, + "reuseEdition1": { + "expectedDomains": 32, + "matchedDomains": 32, + "fcRoots": 562, + "successfulReads": 562, + "failedReads": 0, + "projectedLeaves": 4055, + "initialValueCache": 4055, + "projectionErrors": 46, + "staticDataSets": 2, + "staticReportControls": 32, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "actualInformationReportObserved": true + } + }, + "arsasR10": { + "relay": "AA1E1F06R4", + "testedArtifact": { + "appHead": "eb8eb13d491f9aa265205852b8a4bab07af440ff", + "engineTestedHead": "9935d6902d786cc69b299260fe36b835944d5e81", + "engineMergedMain": "648124097621046f5f127ceb1cf853fea54db730", + "engineTree": "1cf7e08f333f24994625e8fe8416dbd0a16195b1" + }, + "discovery": { + "associations": 1, + "confirmedMmsRequests": 323, + "getNameList": 138, + "getVariableAccessAttributes": 119, + "getNamedVariableListAttributes": 2, + "reads": 64, + "topLevelDataObjects": 860, + "dataObjectsIncludingSdo": 906, + "scalarLeaves": 4925, + "dataSets": 2, + "fcda": 58, + "logicalReportControls": 32, + "settingControls": 1 + }, + "saveScl": { + "instanceEvidence": 3854, + "edition2ValCount": 3202, + "edition1ValCount": 3106, + "edition2RoundTrip": { + "logicalDevices": 32, + "logicalNodes": 119, + "dataSets": 2, + "logicalReportControls": 32 + }, + "edition1RoundTrip": { + "logicalDevices": 32, + "logicalNodes": 119, + "dataSets": 2, + "logicalReportControls": 32 + } + }, + "reuseEdition2": { + "expectedDomains": 32, + "observedDomains": 32, + "matchedDomains": 32, + "missingDomains": 0, + "extraDomains": 0, + "logicalDevices": 32, + "logicalNodes": 119, + "topLevelDataObjects": 860, + "dataAttributes": 5677, + "initialTargets": 709, + "fcRootTargets": 525, + "doScopedTargets": 184, + "successfulReads": 709, + "failedReads": 0, + "projectedLeaves": 4441, + "projectedUniqueValues": 4441, + "initialValueCache": 4441, + "cacheLoss": 0, + "projectionErrors": 0, + "staticDataSets": 2, + "staticMembers": 58, + "staticReportControls": 32, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "primaryUnresolvedAtSelection": 2, + "actualInformationReportObserved": true, + "cyclicMmsProcessPolling": 0 + }, + "reuseEdition1": { + "expectedDomains": 32, + "observedDomains": 32, + "matchedDomains": 32, + "missingDomains": 0, + "extraDomains": 0, + "logicalDevices": 32, + "logicalNodes": 119, + "topLevelDataObjects": 845, + "dataAttributes": 5470, + "initialTargets": 708, + "fcRootTargets": 524, + "doScopedTargets": 184, + "successfulReads": 708, + "failedReads": 0, + "projectedLeaves": 4246, + "projectedUniqueValues": 4246, + "initialValueCache": 4246, + "cacheLoss": 0, + "projectionErrors": 0, + "staticDataSets": 2, + "staticMembers": 58, + "staticReportControls": 32, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "primaryUnresolvedAtSelection": 2, + "actualInformationReportObserved": true, + "cyclicMmsProcessPolling": 0 + } + } + }, + "openGaps": { + "trustedSclProjectionParity": { + "targetProjectionErrors": 0, + "observedEdition2": 46, + "observedEdition1": 46, + "status": "resolved-r10-physical", + "rule": "Do not classify SCL reuse as semantically converged until projectionErrors reaches zero on both editions. Successful MMS Reads alone are insufficient.", + "rootCause": "R9 PCAP + Ed2 IID reproduction matched all 46 physical errors: every mismatch is in CF, across 18 FC roots / 191 affected SCL leaves, caused by cross-DO positional ordering differences between SCL LNodeType order and the MMS CF structure. No SCL count= arrays are present.", + "sourceRepair": "For multi-DO CF groups, InitialFcReadPlanner now emits exact LN$CF$DO targets. InitialFcValueProjector projects each DO value directly, eliminating cross-DO positional mapping while retaining batching.", + "r10ObservedEdition2": 0, + "r10ObservedEdition1": 0, + "note": "R9 historical 46/46 evidence is retained for provenance; R10 physically proves the repair on both editions." + }, + "edition2CaseDistinctInitialValueCache": { + "targetLoss": 0, + "observedProjectedMinusCached": 11, + "status": "resolved-r10-physical", + "rule": "LTRK t and T and any other case-distinct IEC 61850 paths must remain separate through TypeSpecification mapping, initial projection, ARSAS cache, canonical instance evidence, DAI/Val export and reopen. cacheLoss must be zero.", + "sourceRepair": "ARSAS trusted-SCL initial-value cache now uses StringComparer.Ordinal. Engine canonical value dedup, TypeSpecification member resolution, and canonical SCL instance-value targeting are exact-case. Diagnostics expose projectedUniqueValues and cacheLoss.", + "r10ObservedProjectedMinusCached": 0, + "r10Edition2ProjectedUniqueValues": 4441, + "r10Edition2InitialValueCache": 4441, + "note": "R9 historical loss=11 is retained for provenance; R10 physically proves exact-case cache preservation." + }, + "preallocatedAddReportControls": { + "observedWithoutDataSet": 30, + "status": "resolved-r10-export-and-reuse", + "rule": "Do not invent datSet. rptID-backed singleton runtime names ending 01 must project to indexed logical ReportControl max=1; an unassigned indexed slot is a warning, not a fatal missing-DataSet error.", + "r10FatalMissingDatasetErrors": 0, + "r10LogicalReportControls": 32, + "note": "Unassigned indexed ADD slots remain without invented datSet and no longer block reuse/reporting." + }, + "saveTimeInstanceValues": { + "observedR9ExportValCount": 0, + "referenceIedScoutValCount": 1529, + "status": "mechanism-physically-proven-semantic-diff-open", + "rule": "Keep structural discovery read-free; acquire bounded safe FC-root values only during explicit Save SCL and verify resulting instance evidence physically.", + "r10InstanceEvidence": 3854, + "r10Edition2ValCount": 3202, + "r10Edition1ValCount": 3106, + "note": "Save-time value enrichment is physically proven. More Val elements than IEDScout is not treated as automatically better; exact semantic path/value comparison remains open." + }, + "templateReuse": { + "status": "next-improvement", + "rule": "Reduce duplicate LNodeType/DOType/DAType templates only after wire and semantic reuse parity is stable.", + "r10Edition2": { + "lNodeType": 119, + "doType": 906, + "daType": 752, + "fileBytes": 731266 + }, + "iedScoutReference": { + "lNodeType": 38, + "doType": 60, + "daType": 17, + "fileBytesApprox": 247000 + }, + "acceptance": "Intern only structurally and semantically identical templates. Expanded 32/119/860/906/4925 model, FC ownership, values and round-trip behavior must remain unchanged." + }, + "saveEnrichmentReuse": { + "status": "next-optimization", + "observation": "Ed2 and Ed1 saves in one unchanged live session both produced instanceEvidence=3854; the bounded enrichment snapshot can be reused across edition serializers when association/model generation is unchanged.", + "rule": "Do not cache across reconnect, model-generation change, or explicit refresh. Optimization must not alter P0 discovery." + } + }, + "p0StructuralDiscoveryFreeze": { + "contractId": "P0-R9-STRUCTURAL", + "status": "implemented-and-r9-physically-proven", + "scope": "AA1E1F06R4 physical acceptance plus source/CI freeze. Counts are evidence and gates for this relay, not generic hardcoded runtime behavior for other IEDs.", + "sourcePolicy": { + "smartRouteRequired": true, + "associationScopedSingleFlightRequired": true, + "applicationMmsGateExclusive": true, + "logicalNodeRootTypeStrategyRequired": true, + "eagerInitialFcReadsForbidden": true, + "supplementalLegacyBrowseForbidden": true, + "secondFullGetNameListSweepForbidden": true, + "recursivePerLeafGvaStormForbidden": true, + "saveTimeAndTrustedSclValueReadsRemainSeparate": true + }, + "frozenOptions": { + "maxConcurrentChains": 8, + "unknownPeerMaxConcurrentChains": 4, + "maxDomains": 256, + "maxVariableNamesPerDomain": 20000, + "maxVariableListNamesPerDomain": 4096, + "maxNameListPages": 64, + "probeReportAttributes": true, + "maxReportAttributeProbes": 64, + "readDataSetDirectories": true, + "maxDataSetDirectoryReads": 64 + }, + "aa1e1f06r4Acceptance": { + "associations": 1, + "referenceConfirmedMmsRequests": 323, + "maximumConfirmedMmsRequests": 417, + "referenceGetNameList": 138, + "referenceGetVariableAccessAttributes": 119, + "maximumGetVariableAccessAttributes": 119, + "referenceGetNamedVariableListAttributes": 2, + "referenceReads": 64, + "maximumReads": 156, + "logicalDevices": 32, + "logicalNodes": 119, + "topLevelDataObjects": 860, + "dataObjectsIncludingSdo": 906, + "scalarLeaves": 4925, + "dataSets": 2, + "fcda": 58, + "logicalReportControls": 32, + "settingControls": 1 + }, + "rule": "Do not optimize or enrich structural discovery further while SCL semantic convergence is still open. Any required instance-value acquisition belongs to explicit Save SCL or trusted-SCL reconnect phases." + }, + "p1ProjectionOrderRepair": { + "contractId": "P1-CF-DO-SCOPED", + "status": "physically-proven-r10", + "preservesP0StructuralDiscovery": true, + "physicalR9Reproduction": { + "projectionErrors": 46, + "affectedFcRoots": 18, + "affectedSclLeaves": 191, + "functionalConstraint": "CF", + "sclArrayCountAttributes": 0 + }, + "rule": "Never use SCL LNodeType cross-DO order as authority for an MMS multi-DO CF structure. Read each CF DataObject by exact MMS object name and batch those references with the existing bounded Read executor.", + "expectedPhysicalOutcome": { + "projectionErrors": 0, + "noSilentCrossDoValueSwap": true, + "sameAssociation": true, + "noAdditionalDiscoveryGva": true + }, + "physicalR10Outcome": { + "edition2": { + "initialTargets": 709, + "doScopedTargets": 184, + "successfulReads": 709, + "failedReads": 0, + "projectionErrors": 0 + }, + "edition1": { + "initialTargets": 708, + "doScopedTargets": 184, + "successfulReads": 708, + "failedReads": 0, + "projectionErrors": 0 + }, + "reportBackedRuntimePoints": 58, + "unresolvedRuntimePoints": 0, + "actualInformationReportObserved": true + } + }, + "p2CaseSensitiveValuePipeline": { + "contractId": "P2-CASE-EXACT-VALUES", + "status": "physically-proven-r10", + "preservesP0StructuralDiscovery": true, + "preservesP1CfScopedHydration": true, + "physicalR9Evidence": { + "edition2ProjectedLeaves": 4250, + "edition2InitialValueCache": 4239, + "observedCacheLoss": 11, + "edition1ProjectedLeaves": 4055, + "edition1InitialValueCache": 4055, + "edition1ObservedCacheLoss": 0 + }, + "sourceInvariants": { + "trustedSclValueCacheComparer": "StringComparer.Ordinal", + "canonicalInstanceValueDedupComparer": "StringComparer.Ordinal", + "typeSpecificationMemberComparison": "StringComparison.Ordinal", + "sclDataObjectInstanceTargetComparison": "StringComparison.Ordinal", + "sclDaBdaSdoComponentComparison": "StringComparison.Ordinal", + "normalizationMayNotFoldCase": true + }, + "diagnosticContract": [ + "projectedUniqueValues", + "initialValueCache", + "cacheLoss" + ], + "expectedPhysicalOutcome": { + "edition2CacheLoss": 0, + "edition1CacheLoss": 0, + "ltrkLowercaseTAndUppercaseTRemainDistinct": true, + "noAdditionalDiscoveryGva": true, + "sameAssociation": true + }, + "physicalR10Outcome": { + "edition2": { + "projectedUniqueValues": 4441, + "initialValueCache": 4441, + "cacheLoss": 0 + }, + "edition1": { + "projectedUniqueValues": 4246, + "initialValueCache": 4246, + "cacheLoss": 0 + }, + "actualInformationReportObserved": true + } + } +} 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 7813c5f59..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 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 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/scripts/verify-source-clean.ps1 b/scripts/verify-source-clean.ps1 index b6dac1935..46a1e406a 100644 --- a/scripts/verify-source-clean.ps1 +++ b/scripts/verify-source-clean.ps1 @@ -56,6 +56,17 @@ $TextExtensions = @( ".props", ".targets", ".sln", ".slnx", ".txt" ) +# These are first-party convergence authorities. They intentionally contain the +# external interoperability label so the acceptance contract remains discoverable. +$ApprovedConvergenceIdentifierPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +@( + ".github/workflows/iedscout-convergence-guard.yml", + ".github/workflows/scl-interoperability-r7.yml", + "docs/IEDSCOUT_CONVERGENCE.md", + "evidence/iedscout-convergence-target.json", + "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" +) | ForEach-Object { [void]$ApprovedConvergenceIdentifierPaths.Add($_) } + $Problems = New-Object System.Collections.Generic.List[string] function Normalize-RelativePath { @@ -125,7 +136,8 @@ foreach ($relative in (Get-TrackedRelativePaths)) { } } - if (Test-ContainsForbiddenIdentifier $relative) { + $identifierScanExempt = $ApprovedConvergenceIdentifierPaths.Contains($relative) + if (-not $identifierScanExempt -and (Test-ContainsForbiddenIdentifier $relative)) { $Problems.Add("Forbidden external identifier in path: $relative") } @@ -133,7 +145,7 @@ foreach ($relative in (Get-TrackedRelativePaths)) { if ($TextExtensions -notcontains [IO.Path]::GetExtension($relative).ToLowerInvariant()) { continue } $content = Get-Content -LiteralPath $fullPath -Raw -ErrorAction SilentlyContinue - if (Test-ContainsForbiddenIdentifier $content) { + if (-not $identifierScanExempt -and (Test-ContainsForbiddenIdentifier $content)) { $Problems.Add("Forbidden external identifier in text: $relative") } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs new file mode 100644 index 000000000..67364653b --- /dev/null +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -0,0 +1,449 @@ +namespace ARSAS.Tests; + +public sealed class CanonicalLiveSclExportRegressionTests +{ + [Fact] + public void NativeClient_RetainsAcceptedAssociationCanonicalSnapshot() + { + var canonical = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.CanonicalModel.cs")); + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + + Assert.Contains("public LiveIedCanonicalModel? LastCanonicalModel", canonical, StringComparison.Ordinal); + Assert.Contains("_session.GetAcceptedCommunicationEvidence", canonical, StringComparison.Ordinal); + Assert.Contains("LiveIedCanonicalModelBuilder.Build(model, communication, initialRead)", canonical, StringComparison.Ordinal); + Assert.Contains("PublishCanonicalModel(model, initialRead);", lifecycle, StringComparison.Ordinal); + Assert.Contains("ClearCanonicalModel();", lifecycle, StringComparison.Ordinal); + } + + [Fact] + public void SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + + Assert.Contains("SmartDiscoveryStructuralFreezeContract = \"P0-R9-STRUCTURAL\"", capture, StringComparison.Ordinal); + Assert.Contains("CreateP0FrozenSmartDiscoveryOptions()", capture, StringComparison.Ordinal); + Assert.Contains("MaxConcurrentChains = 8", capture, StringComparison.Ordinal); + Assert.Contains("UnknownPeerMaxConcurrentChains = 4", capture, StringComparison.Ordinal); + Assert.Contains("MaxNameListPages = 64", capture, StringComparison.Ordinal); + Assert.Contains("ProbeReportAttributes = true", capture, StringComparison.Ordinal); + Assert.Contains("MaxReportAttributeProbes = 64", capture, StringComparison.Ordinal); + Assert.Contains("ReadDataSetDirectories = true", capture, StringComparison.Ordinal); + Assert.Contains("MaxDataSetDirectoryReads = 64", capture, StringComparison.Ordinal); + Assert.Contains("GetOrCreateSmartDiscoveryAssociationFlight(", capture, StringComparison.Ordinal); + Assert.Contains("DiscoverSmartSingleFlightAsync(smartOptions, CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.Contains("ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.DoesNotContain("_session.DiscoverAsync(", capture, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverDomainVariableNamesAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("TryBuildSupplementalGetNameListSnapshotAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverDomainVariableTypeTreeNamesAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("AddAdaptiveLogicalNodeSiblingProbeSignalsAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("EnrichEngineeringUnitsAsync", capture, StringComparison.Ordinal); + Assert.DoesNotContain("InitialFcReadPlanner.FromSclModel", capture, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteInitialFcReadPlanSmartAsync", capture, StringComparison.Ordinal); + Assert.Contains("initialFcRoots=deferred", capture, StringComparison.Ordinal); + Assert.Contains("ArMms.InitialFcReadExecutionResult? initialRead = null", capture, StringComparison.Ordinal); + Assert.Contains("TryPublishSmartDiscoveryAuthority(", capture, StringComparison.Ordinal); + Assert.Contains("ArMms.InitialFcReadExecutionResult? initialRead", lifecycle, StringComparison.Ordinal); + Assert.Contains("PublishCanonicalModel(model, initialRead);", lifecycle, StringComparison.Ordinal); + } + + [Fact] + public void DeviceWorkspace_ReceivesCanonicalSnapshotFromSameClientSession() + { + var runtime = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var model = File.ReadAllText(FindRepoFile("Models/MonitorModels.cs")); + + Assert.Contains("public LiveIedCanonicalModel? LiveCanonicalModel", model, StringComparison.Ordinal); + Assert.Contains("device.LiveCanonicalModel = session.Client.LastCanonicalModel;", runtime, StringComparison.Ordinal); + } + + [Fact] + public void LiveSave_UsesCanonicalExporterAndFailsClosedOnStaleOrMissingEvidence() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs")); + var saveMethodStart = source.IndexOf( + "private void SaveTypedModelAsScl(", + StringComparison.Ordinal); + Assert.True(saveMethodStart >= 0); + + var saveMethod = source[saveMethodStart..]; + var canonicalIndex = saveMethod.IndexOf( + "CanonicalLiveIedSclExporter.WriteFiles", + StringComparison.Ordinal); + var staleGuardIndex = saveMethod.IndexOf( + "The canonical association snapshot does not belong to the current live discovery model.", + StringComparison.Ordinal); + var missingGuardIndex = saveMethod.IndexOf( + "The live discovery model is not bound to accepted MMS association evidence.", + StringComparison.Ordinal); + + Assert.True(missingGuardIndex >= 0); + Assert.True(staleGuardIndex >= 0); + Assert.True(canonicalIndex > staleGuardIndex); + Assert.Contains("if (device.SclWorkspace == null)", saveMethod, StringComparison.Ordinal); + Assert.Contains("profile: \"full-model\"", saveMethod, StringComparison.Ordinal); + Assert.Contains("canonical round-trip verified", saveMethod, StringComparison.Ordinal); + Assert.Contains("instanceEvidence={canonicalExportEvidence.InstanceValues.Count}", saveMethod, StringComparison.Ordinal); + Assert.Contains("runtimeRCB={canonicalExportEvidence.Discovery.ReportControls.Count}", saveMethod, StringComparison.Ordinal); + Assert.Contains("logicalExportRCB={result.ReportControlCount}", saveMethod, StringComparison.Ordinal); + } + + [Fact] + public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.xaml.cs")); + var validator = File.ReadAllText(FindRepoFile("Services/CanonicalSclReloadValidator.cs")); + var saveMethodStart = source.IndexOf( + "private void SaveTypedModelAsScl(", + StringComparison.Ordinal); + var successStart = source.IndexOf( + "private void ShowSclSaveSuccess(", + saveMethodStart, + StringComparison.Ordinal); + + Assert.True(saveMethodStart >= 0 && successStart > saveMethodStart); + var saveMethod = source[saveMethodStart..successStart]; + + var exportIndex = saveMethod.IndexOf("CanonicalLiveIedSclExporter.WriteFiles", StringComparison.Ordinal); + var reloadIndex = saveMethod.IndexOf("CanonicalSclReloadValidator.Validate", StringComparison.Ordinal); + Assert.True(exportIndex >= 0 && reloadIndex > exportIndex); + Assert.Contains("DeleteFailedCanonicalExportArtifacts(result)", saveMethod, StringComparison.Ordinal); + Assert.Contains("reloadLD=", saveMethod, StringComparison.Ordinal); + Assert.Contains("reloadLN=", saveMethod, StringComparison.Ordinal); + Assert.Contains("reloadDataSet=", saveMethod, StringComparison.Ordinal); + Assert.Contains("reloadRCB=", saveMethod, StringComparison.Ordinal); + + var cleanupStart = source.IndexOf( + "private static void DeleteFailedCanonicalExportArtifacts(", + StringComparison.Ordinal); + Assert.True(cleanupStart >= 0); + var cleanup = source[cleanupStart..successStart]; + Assert.Contains("result.SclPath", cleanup, StringComparison.Ordinal); + Assert.Contains("result.ReportPath", cleanup, StringComparison.Ordinal); + Assert.Contains("result.SummaryPath", cleanup, StringComparison.Ordinal); + Assert.Contains("result.ExcludedAttributesPath", cleanup, StringComparison.Ordinal); + Assert.Contains("File.Delete(path)", cleanup, StringComparison.Ordinal); + + Assert.Contains("workspaceService.Open(", validator, StringComparison.Ordinal); + Assert.Contains("IedName = canonical.IedName", validator, StringComparison.Ordinal); + Assert.Contains("AccessPointName = canonical.AccessPointName", validator, StringComparison.Ordinal); + Assert.Contains("endpoint.Port != 102", validator, StringComparison.Ordinal); + Assert.Contains("reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount", validator, StringComparison.Ordinal); + Assert.Contains("reloadCoverage.LogicalNodeCount != result.LogicalNodeCount", validator, StringComparison.Ordinal); + Assert.Contains("workspace.DataSets.Count != result.DataSetCount", validator, StringComparison.Ordinal); + Assert.Contains("workspace.ReportControls.Count != result.ReportControlCount", validator, StringComparison.Ordinal); + Assert.Contains("SclAssistedConnectionPreparationBuilder.Build(", validator, StringComparison.Ordinal); + Assert.Contains("preparation.IsSuccess", validator, StringComparison.Ordinal); + Assert.Contains("preparation.AssociationPlan", validator, StringComparison.Ordinal); + Assert.Contains("Generated SCL cannot rebuild the ARSAS SCL-assisted reconnect plan", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.ApTitle", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.AeQualifier", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.PresentationSelector", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.SessionSelector", validator, StringComparison.Ordinal); + Assert.Contains("canonicalAssociation.TransportSelector", validator, StringComparison.Ordinal); + Assert.Contains("Generated SCL reconnect association drifted from accepted canonical wire evidence", validator, StringComparison.Ordinal); + Assert.Contains("result.Profile", validator, StringComparison.Ordinal); + Assert.Contains("full-model", validator, StringComparison.Ordinal); + Assert.Contains("MmsLocalAssociationProfile.ExistingRuntimeDefault", validator, StringComparison.Ordinal); + Assert.Contains("changed the proven calling-side runtime identity", validator, StringComparison.Ordinal); + Assert.Contains("BuildAssociationProfiles()", validator, StringComparison.Ordinal); + Assert.Contains("reproduce accepted association profile", validator, StringComparison.Ordinal); + } + + [Fact] + public void SclAssistedReconnect_UsesNativeCallingIdentityAndSafeParallelValueReads() + { + var preparation = File.ReadAllText(FindRepoFile("Services/SclAssistedConnectionPreparation.cs")); + var client = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SclAssisted.cs")); + + Assert.Contains("MmsLocalAssociationProfile.ExistingRuntimeDefault", preparation, StringComparison.Ordinal); + Assert.DoesNotContain("MmsLocalAssociationProfile.SclInteroperabilityDefault", preparation, StringComparison.Ordinal); + Assert.Contains("IsSafeTrustedSclInitialReadFc", client, StringComparison.Ordinal); + Assert.Contains("ExecuteInitialFcReadPlanSmartAsync", client, StringComparison.Ordinal); + Assert.Contains("\"ST\" or \"MX\" or \"SP\" or \"SV\" or \"CF\" or \"DC\" or \"EX\" or \"BL\" or \"OR\" or \"SR\"", client, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteInitialFcReadPlanAsync(", client, StringComparison.Ordinal); + } + + [Fact] + public void P0StructuralDiscoveryFreeze_LocksR9WireAndModelBudget() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + + Assert.Contains("\"contractId\": \"P0-R9-STRUCTURAL\"", contract, StringComparison.Ordinal); + Assert.Contains("\"status\": \"implemented-and-r9-physically-proven\"", contract, StringComparison.Ordinal); + Assert.Contains("\"associationScopedSingleFlightRequired\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"eagerInitialFcReadsForbidden\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"supplementalLegacyBrowseForbidden\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"maxConcurrentChains\": 8", contract, StringComparison.Ordinal); + Assert.Contains("\"unknownPeerMaxConcurrentChains\": 4", contract, StringComparison.Ordinal); + Assert.Contains("\"referenceConfirmedMmsRequests\": 323", contract, StringComparison.Ordinal); + Assert.Contains("\"maximumConfirmedMmsRequests\": 417", contract, StringComparison.Ordinal); + Assert.Contains("\"referenceGetVariableAccessAttributes\": 119", contract, StringComparison.Ordinal); + Assert.Contains("\"maximumGetVariableAccessAttributes\": 119", contract, StringComparison.Ordinal); + Assert.Contains("\"referenceReads\": 64", contract, StringComparison.Ordinal); + Assert.Contains("\"maximumReads\": 156", contract, StringComparison.Ordinal); + Assert.Contains("\"topLevelDataObjects\": 860", contract, StringComparison.Ordinal); + Assert.Contains("\"dataObjectsIncludingSdo\": 906", contract, StringComparison.Ordinal); + Assert.Contains("\"scalarLeaves\": 4925", contract, StringComparison.Ordinal); + Assert.Contains("\"dataSets\": 2", contract, StringComparison.Ordinal); + Assert.Contains("\"fcda\": 58", contract, StringComparison.Ordinal); + Assert.Contains("\"logicalReportControls\": 32", contract, StringComparison.Ordinal); + Assert.Contains("\"settingControls\": 1", contract, StringComparison.Ordinal); + } + + [Fact] + public void P1ProjectionOrderRepair_LocksPhysicalRootCauseAndExactStrategy() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + + Assert.Contains("\"contractId\": \"P1-CF-DO-SCOPED\"", contract, StringComparison.Ordinal); + Assert.Contains("\"status\": \"physically-proven-r10\"", contract, StringComparison.Ordinal); + Assert.Contains("\"projectionErrors\": 46", contract, StringComparison.Ordinal); + Assert.Contains("\"affectedFcRoots\": 18", contract, StringComparison.Ordinal); + Assert.Contains("\"affectedSclLeaves\": 191", contract, StringComparison.Ordinal); + Assert.Contains("\"functionalConstraint\": \"CF\"", contract, StringComparison.Ordinal); + Assert.Contains("\"sclArrayCountAttributes\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"projectionErrors\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"noSilentCrossDoValueSwap\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"noAdditionalDiscoveryGva\": true", contract, StringComparison.Ordinal); + Assert.Contains("LN$CF$DO", contract, StringComparison.Ordinal); + } + + [Fact] + public void P2CaseSensitiveValuePipeline_LocksLosslessExactPathIdentity() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + var client = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SclAssisted.cs")); + + Assert.Contains("\"contractId\": \"P2-CASE-EXACT-VALUES\"", contract, StringComparison.Ordinal); + Assert.Contains("\"observedCacheLoss\": 11", contract, StringComparison.Ordinal); + Assert.Contains("\"edition2CacheLoss\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"edition1CacheLoss\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"ltrkLowercaseTAndUppercaseTRemainDistinct\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"noAdditionalDiscoveryGva\": true", contract, StringComparison.Ordinal); + + var cacheDeclaration = client.IndexOf("_trustedSclInitialValues =", StringComparison.Ordinal); + Assert.True(cacheDeclaration >= 0); + var cacheSegment = client.Substring(cacheDeclaration, Math.Min(260, client.Length - cacheDeclaration)); + Assert.Contains("StringComparer.Ordinal", cacheSegment, StringComparison.Ordinal); + Assert.DoesNotContain("StringComparer.OrdinalIgnoreCase", cacheSegment, StringComparison.Ordinal); + + Assert.Contains("projectedInitialValueKeys", client, StringComparison.Ordinal); + Assert.Contains("projectedUniqueValues", client, StringComparison.Ordinal); + Assert.Contains("initialValueCacheLoss", client, StringComparison.Ordinal); + Assert.Contains("cacheLoss={initialValueCacheLoss}", client, StringComparison.Ordinal); + Assert.Contains("NormalizeTrustedSclReference", client, StringComparison.Ordinal); + } + + [Fact] + public void R9PhysicalReuse_LocksWorkingPathAndKeepsSemanticProjectionGapOpen() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + + Assert.Contains("\"confirmedMmsRequests\": 323", contract, StringComparison.Ordinal); + Assert.Contains("\"getVariableAccessAttributes\": 119", contract, StringComparison.Ordinal); + Assert.Contains("\"topLevelDataObjects\": 860", contract, StringComparison.Ordinal); + Assert.Contains("\"dataObjectsIncludingSdo\": 906", contract, StringComparison.Ordinal); + Assert.Contains("\"scalarLeaves\": 4925", contract, StringComparison.Ordinal); + + Assert.Contains("\"fcRoots\": 563", contract, StringComparison.Ordinal); + Assert.Contains("\"successfulReads\": 563", contract, StringComparison.Ordinal); + Assert.Contains("\"fcRoots\": 562", contract, StringComparison.Ordinal); + Assert.Contains("\"successfulReads\": 562", contract, StringComparison.Ordinal); + Assert.Contains("\"reportBackedRuntimePoints\": 58", contract, StringComparison.Ordinal); + Assert.Contains("\"unresolvedRuntimePoints\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"actualInformationReportObserved\": true", contract, StringComparison.Ordinal); + + Assert.Contains("\"targetProjectionErrors\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"observedEdition2\": 46", contract, StringComparison.Ordinal); + Assert.Contains("\"observedEdition1\": 46", contract, StringComparison.Ordinal); + Assert.Contains("\"observedProjectedMinusCached\": 11", contract, StringComparison.Ordinal); + Assert.Contains("\"observedWithoutDataSet\": 30", contract, StringComparison.Ordinal); + Assert.Contains("\"observedR9ExportValCount\": 0", contract, StringComparison.Ordinal); + + var client = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SclAssisted.cs")); + Assert.Contains("projectionErrorSamples", client, StringComparison.Ordinal); + Assert.Contains("SCL initial projection:", client, StringComparison.Ordinal); + Assert.Contains("fullDiscovery=skipped", client, StringComparison.Ordinal); + } + + [Fact] + public void R10PhysicalReuse_ClosesP1P2AndLocksReportBackedRoundTrip() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + + Assert.Contains("\"arsasR10\"", contract, StringComparison.Ordinal); + Assert.Contains("\"initialTargets\": 709", contract, StringComparison.Ordinal); + Assert.Contains("\"fcRootTargets\": 525", contract, StringComparison.Ordinal); + Assert.Contains("\"doScopedTargets\": 184", contract, StringComparison.Ordinal); + Assert.Contains("\"projectedUniqueValues\": 4441", contract, StringComparison.Ordinal); + Assert.Contains("\"initialValueCache\": 4441", contract, StringComparison.Ordinal); + Assert.Contains("\"initialTargets\": 708", contract, StringComparison.Ordinal); + Assert.Contains("\"fcRootTargets\": 524", contract, StringComparison.Ordinal); + Assert.Contains("\"projectedUniqueValues\": 4246", contract, StringComparison.Ordinal); + Assert.Contains("\"initialValueCache\": 4246", contract, StringComparison.Ordinal); + Assert.Contains("\"cacheLoss\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"projectionErrors\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"reportBackedRuntimePoints\": 58", contract, StringComparison.Ordinal); + Assert.Contains("\"unresolvedRuntimePoints\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"actualInformationReportObserved\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"cyclicMmsProcessPolling\": 0", contract, StringComparison.Ordinal); + Assert.Contains("\"status\": \"resolved-r10-physical\"", contract, StringComparison.Ordinal); + } + + [Fact] + public void R7Workflow_BindsArtifactToExactSourceHeadAndReloadContracts() + { + var workflow = File.ReadAllText( + FindRepoFile(".github/workflows/scl-interoperability-r7.yml")); + + Assert.Contains( + "ARSAS_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "git -C .\\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "if ($arsasCommit -ne $env:ARSAS_SOURCE_SHA)", + workflow, + StringComparison.Ordinal); + Assert.DoesNotContain( + "git clone --quiet --depth 1 --branch $ref", + workflow, + StringComparison.Ordinal); + + Assert.Contains( + "Services/CanonicalSclReloadValidator.cs", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "CanonicalSclReloadValidator\\.Validate", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity", + workflow, + StringComparison.Ordinal); + Assert.Contains( + "34 runtime -> 32 logical with ConfReportControl max=34", + workflow, + StringComparison.Ordinal); + + var buildTargets = File.ReadAllText(FindRepoFile("Directory.Build.targets")); + Assert.Contains("SCL Interoperability R7 Build", buildTargets, StringComparison.Ordinal); + Assert.Contains("EnableSmartDiscoveryCaptureRoute", buildTargets, StringComparison.Ordinal); + Assert.Contains("evidence/iedscout-convergence-target.json", workflow, StringComparison.Ordinal); + Assert.Contains("IEDScout convergence source contract regressed", workflow, StringComparison.Ordinal); + Assert.Contains("TryResolveStandardSubDataObjectCdc", workflow, StringComparison.Ordinal); + Assert.Contains("IsEdition2ServiceTrackingCdc", workflow, StringComparison.Ordinal); + Assert.Contains("TryBuildSupplementalGetNameListSnapshotAsync", workflow, StringComparison.Ordinal); + } + + [Fact] + public void IedScoutConvergenceContract_PhysicalRetestPassedAndMergeReady() + { + var contract = File.ReadAllText(FindRepoFile("evidence/iedscout-convergence-target.json")); + var documentation = File.ReadAllText(FindRepoFile("docs/IEDSCOUT_CONVERGENCE.md")); + + Assert.Contains("\"status\": \"physical-retest-passed-merge-ready\"", contract, StringComparison.Ordinal); + Assert.Contains("\"pullRequest\": 134", contract, StringComparison.Ordinal); + Assert.Contains("\"pullRequest\": 135", contract, StringComparison.Ordinal); + Assert.Contains("\"pullRequest\": 324", contract, StringComparison.Ordinal); + Assert.Contains("648124097621046f5f127ceb1cf853fea54db730", contract, StringComparison.Ordinal); + Assert.Contains("\"exactlyOneAssociation\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"supplementalLegacyAssociationForbidden\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"recursivePerLeafGvaStormForbidden\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"reopenInArsasRequired\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"physicalReconnectRequired\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"productionPromoted\": false", contract, StringComparison.Ordinal); + Assert.Contains("\"mergeAllowedBeforePhysicalRetest\": false", contract, StringComparison.Ordinal); + Assert.Contains("\"mergeAllowedAfterPhysicalRetest\": true", contract, StringComparison.Ordinal); + Assert.Contains("\"physicalRetestRequired\": false", contract, StringComparison.Ordinal); + Assert.Contains("\"physicalRetestPassed\": true", contract, StringComparison.Ordinal); + Assert.Contains("Merged proven baseline", documentation, StringComparison.Ordinal); + Assert.Contains("The field result, not test count alone", documentation, StringComparison.Ordinal); + + var guard = File.ReadAllText(FindRepoFile(".github/workflows/iedscout-convergence-guard.yml")); + Assert.Contains("name: IEDScout Convergence Guard", guard, StringComparison.Ordinal); + Assert.Contains("name: iedscout-convergence-contract", guard, StringComparison.Ordinal); + Assert.Contains("Merged engine authority must remain PR #134 + PR #135 with ARSAS #324 provenance", guard, StringComparison.Ordinal); + Assert.Contains("P0 structural discovery freeze regressed away from the accepted IEDScout convergence path", guard, StringComparison.Ordinal); + Assert.Contains("Canonical IEC model / SCL semantic authority regressed", guard, StringComparison.Ordinal); + } + + [Fact] + public void SourceClean_GuardsApprovedFirstPartyConvergenceAuthorities() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-source-clean.ps1")); + + Assert.Contains("$ApprovedConvergenceIdentifierPaths", source, StringComparison.Ordinal); + Assert.Contains("docs/IEDSCOUT_CONVERGENCE.md", source, StringComparison.Ordinal); + Assert.Contains("evidence/iedscout-convergence-target.json", source, StringComparison.Ordinal); + Assert.Contains("CanonicalLiveSclExportRegressionTests.cs", source, StringComparison.Ordinal); + Assert.Contains("identifierScanExempt", source, StringComparison.Ordinal); + } + + + [Fact] + public void EnginePin_MatchesPhysicalSclRepairHead() + { + var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); + + Assert.Contains( + "\"commit\": \"648124097621046f5f127ceb1cf853fea54db730\"", + lockFile, + StringComparison.Ordinal); + Assert.Contains( + "\"physicalTestedCommit\": \"9935d6902d786cc69b299260fe36b835944d5e81\"", + lockFile, + StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); + Assert.Contains("exact association request bytes accepted by the IED", lockFile, StringComparison.Ordinal); + Assert.Contains("accepted COTP destination selector", lockFile, StringComparison.Ordinal); + Assert.Contains("runtime-mutable", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DataSet/ConfRev/domain/LN/buffered identity", lockFile, StringComparison.Ordinal); + Assert.Contains("full-model SCL", lockFile, StringComparison.Ordinal); + Assert.Contains("CDC-aware WYE/DEL/SEQ SDO", lockFile, StringComparison.Ordinal); + Assert.Contains("FC ownership", lockFile, StringComparison.Ordinal); + Assert.Contains("TCTR/TVTR/LTIM/EEName/MltLev", lockFile, StringComparison.Ordinal); + Assert.Contains("LTRK service-tracking", lockFile, StringComparison.Ordinal); + Assert.Contains("Edition-1 schema downgrade protection", lockFile, StringComparison.Ordinal); + Assert.Contains("TypeSpecification declaration order", lockFile, StringComparison.Ordinal); + Assert.Contains("SG/SE as setting data", lockFile, StringComparison.Ordinal); + Assert.Contains("MHAI THD phase groups as WYE/CMV", lockFile, StringComparison.Ordinal); + Assert.Contains("bounded FC-read policy", lockFile, StringComparison.Ordinal); + Assert.Contains("complete PR #134 smart-discovery performance head", lockFile, StringComparison.Ordinal); + Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); + Assert.Contains( + "\"commit\": \"4467124775d8d9d76f3db194f9fbfd97144767a8\"", + lockFile, + StringComparison.Ordinal); + } + + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs new file mode 100644 index 000000000..6ad4cbe24 --- /dev/null +++ b/tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs @@ -0,0 +1,411 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl; +using AR.Iec61850.Scl.Export; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class CanonicalSclReloadValidatorTests +{ + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift( + SclSchemaProfile schema) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-canonical-reload-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine( + root, + schema == SclSchemaProfile.Edition2V31 ? "relay.iid" : "relay.icd"); + + try + { + var canonical = CreateCanonical(); + var result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + path, + schema, + profile: "full-model"); + + Assert.Equal("full-model", result.Profile); + Assert.Empty(result.ExcludedAttributes); + + var workspace = CanonicalSclReloadValidator.Validate( + new SclWorkspaceService(), + canonical, + result); + + Assert.Equal("IED", workspace.IedName); + Assert.Equal("AP1", workspace.AccessPointName); + Assert.NotNull(workspace.PreferredEndpoint); + Assert.Equal("10.20.30.40", workspace.PreferredEndpoint!.IpAddress); + Assert.Equal(102, workspace.PreferredEndpoint.Port); + Assert.Equal(result.LogicalDeviceCount, workspace.DesignModel.Coverage.LogicalDeviceCount); + Assert.Equal(result.LogicalNodeCount, workspace.DesignModel.Coverage.LogicalNodeCount); + Assert.Equal(result.DataSetCount, workspace.DataSets.Count); + Assert.Equal(result.ReportControlCount, workspace.ReportControls.Count); + Assert.Equal(1, result.LogicalDeviceCount); + Assert.Equal(2, result.LogicalNodeCount); + Assert.Equal(1, result.ReportControlCount); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void ExportedCanonicalScl_PreparesExactSclAssistedAssociationPlan( + SclSchemaProfile schema) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-canonical-association-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine( + root, + schema == SclSchemaProfile.Edition2V31 ? "relay-association.iid" : "relay-association.icd"); + + try + { + var canonical = CreateCanonical(); + var result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + path, + schema, + profile: "full-model"); + + var preparation = SclAssistedConnectionPreparationBuilder.Build( + File.ReadAllText(result.SclPath), + canonical.IedName, + canonical.AccessPointName, + canonical.Communication.Host, + canonical.Communication.Port); + + Assert.True( + preparation.IsSuccess, + string.Join(" | ", preparation.Errors)); + var plan = Assert.IsType( + preparation.AssociationPlan); + + Assert.Equal(canonical.IedName, plan.IedName); + Assert.Equal(canonical.AccessPointName, plan.AccessPointName); + Assert.Equal(canonical.Communication.Host, plan.Host); + Assert.Equal(102, plan.Port); + + Assert.Equal("ExistingRuntimeDefault", plan.LocalProfileName); + Assert.Equal("0001", Convert.ToHexString(plan.Cotp.SourceTsap)); + Assert.Equal("0001", Convert.ToHexString(plan.Cotp.DestinationTsap)); + Assert.Equal("00000001", Convert.ToHexString(plan.Association.Calling.PresentationSelector)); + Assert.Equal("0001", Convert.ToHexString(plan.Association.Calling.SessionSelector)); + Assert.Equal(new uint[] { 1, 1, 1, 999 }, plan.Association.Calling.ApTitle); + Assert.Equal(12, plan.Association.Calling.AeQualifier); + Assert.Equal("00000001", Convert.ToHexString(plan.Association.Called.PresentationSelector)); + Assert.Equal("0001", Convert.ToHexString(plan.Association.Called.SessionSelector)); + Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, plan.Association.Called.ApTitle); + Assert.Equal(12, plan.Association.Called.AeQualifier); + Assert.Equal( + AR.Iec61850.Osi.CotpConnectRequest.BuildDefault(), + plan.CotpConnectRequest); + Assert.Equal( + AR.Iec61850.Acse.AcseMmsInitiateRequest.BuildDefaultAssociationPayload(), + plan.SessionPresentationAcseMmsRequest); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + + [Theory] + [InlineData("OSI-AP-Title", "1,1,1,999,2")] + [InlineData("OSI-AE-Qualifier", "13")] + [InlineData("OSI-PSEL", "00000002")] + [InlineData("OSI-SSEL", "0002")] + [InlineData("OSI-TSEL", "0002")] + public void WorkspaceReload_RejectsValidButDifferentAssociationIdentity( + string parameterType, + string replacementValue) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-canonical-association-drift-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "relay-association-drift.iid"); + + try + { + var canonical = CreateCanonical(); + var result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + path, + SclSchemaProfile.Edition2V31, + profile: "full-model"); + + var document = XDocument.Load(result.SclPath); + var ns = document.Root!.Name.Namespace; + var parameter = document.Descendants(ns + "P") + .Single(element => + string.Equals( + (string?)element.Attribute("type"), + parameterType, + StringComparison.Ordinal)); + parameter.Value = replacementValue; + document.Save(result.SclPath); + + var error = Assert.Throws(() => + CanonicalSclReloadValidator.Validate( + new SclWorkspaceService(), + canonical, + result)); + + Assert.Contains( + "Generated SCL reconnect association drifted from accepted canonical wire evidence", + error.Message, + StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity( + SclSchemaProfile schema) + { + var root = Path.Combine( + Path.GetTempPath(), + "arsas-canonical-rcb-reload-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine( + root, + schema == SclSchemaProfile.Edition2V31 ? "relay-rcb.iid" : "relay-rcb.icd"); + + try + { + var canonical = CreateGoldenRcbCanonical(); + Assert.Equal(34, canonical.Discovery.ReportControls.Count); + + var result = CanonicalLiveIedSclExporter.WriteFiles( + canonical, + path, + schema, + profile: "full-model"); + var workspace = CanonicalSclReloadValidator.Validate( + new SclWorkspaceService(), + canonical, + result); + + Assert.Equal(32, result.ReportControlCount); + Assert.Equal(32, workspace.ReportControls.Count); + + var document = XDocument.Load(result.SclPath); + var ns = document.Root!.Name.Namespace; + var conf = document.Descendants(ns + "ConfReportControl").Single(); + Assert.Equal("34", (string?)conf.Attribute("max")); + + var buffer = document.Descendants(ns + "ReportControl") + .Single(element => (string?)element.Attribute("name") == "Buffer"); + var unbuffer = document.Descendants(ns + "ReportControl") + .Single(element => (string?)element.Attribute("name") == "Unbuffer"); + Assert.Equal("true", (string?)buffer.Attribute("indexed")); + Assert.Equal("2", (string?)buffer.Element(ns + "RptEnabled")?.Attribute("max")); + Assert.Equal("true", (string?)unbuffer.Attribute("indexed")); + Assert.Equal("2", (string?)unbuffer.Element(ns + "RptEnabled")?.Attribute("max")); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static LiveIedCanonicalModel CreateGoldenRcbCanonical() + { + var baseline = CreateCanonical(); + var controls = Enumerable.Range(1, 30) + .Select(index => RuntimeControl( + $"Standalone_{index}_X", + buffered: false, + reportId: $"RID_{index}_X")) + .Concat( + [ + RuntimeControl( + "Buffer01", + buffered: true, + reportId: "RID_Buffer01", + integrityPeriodMs: "0", + optionalFields: "seqnum,timestamp,reason,dataset,configref"), + RuntimeControl( + "Buffer02", + buffered: true, + reportId: "RID_Buffer02", + integrityPeriodMs: "5000", + optionalFields: "seqnum,timestamp,reason,dataset,entryid,bufoverflow,configref"), + RuntimeControl( + "Unbuffer01", + buffered: false, + reportId: "RID_Unbuffer01", + integrityPeriodMs: "0"), + RuntimeControl( + "Unbuffer02", + buffered: false, + reportId: "RID_Unbuffer02", + integrityPeriodMs: "5000") + ]) + .ToArray(); + + return new LiveIedCanonicalModel + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = baseline.Discovery.Host, + Port = baseline.Discovery.Port, + IedName = baseline.Discovery.IedName, + AccessPointName = baseline.Discovery.AccessPointName, + LogicalDevices = baseline.Discovery.LogicalDevices, + ReportControls = controls + }, + Communication = baseline.Communication + }; + } + + private static LiveIedReportControlModel RuntimeControl( + string name, + bool buffered, + string reportId, + string integrityPeriodMs = "1000", + string optionalFields = "seqnum,timestamp,dataset,dataref") + => new() + { + Reference = $"IEDLD0/LLN0${(buffered ? "BR" : "RP")}${name}", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = name, + Buffered = buffered, + DataSetReference = string.Empty, + ReportId = reportId, + ConfRev = "1", + TriggerOptions = "dchg,qchg,gi", + OptionalFields = optionalFields, + BufferTimeMs = buffered ? "10" : "0", + IntegrityPeriodMs = integrityPeriodMs + }; + + + private static LiveIedCanonicalModel CreateCanonical() + => new() + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = "10.20.30.40", + Port = 102, + IedName = "IED", + AccessPointName = "AP1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "IEDLD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", + LnClass = "LLN0", + LnInst = string.Empty, + ProposedLnTypeId = "LN_LLN0_1" + }, + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + ProposedLnTypeId = "LN_GGIO_1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDLD0/GGIO1.Ind1", + Name = "Ind1", + ProposedDoTypeId = "DO_SPS_Ind1", + InferredCdc = "SPS", + CdcConfidence = 0.99, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.High, + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = "IEDLD0/GGIO1.Ind1.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + MmsReference = "IEDLD0/GGIO1$ST$Ind1$stVal", + MmsItemName = "GGIO1$ST$Ind1$stVal", + Source = "GetNameList", + SclBType = "BOOLEAN", + MmsType = "Boolean", + TypeDiscoveryStatus = "Exact", + TypeSource = "GetVariableAccessAttributes", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "IEDLD0/LLN0$BR$BRCB01", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = "BRCB01", + Buffered = true, + Indexed = false, + ReportId = "RID_BRCB01", + ConfRev = "1", + TriggerOptions = "dchg,qchg,gi", + OptionalFields = "seqnum,timestamp,dataset,dataref", + BufferTimeMs = "10", + IntegrityPeriodMs = "1000" + } + ] + }, + Communication = new LiveIedCommunicationEvidence + { + Source = "AcceptedAssociationWireProfile", + AssociationProfileName = "BalancedApTitle", + Host = "10.20.30.40", + Port = 102, + AccessPointName = "AP1", + Association = new SclIsoAssociationAddress + { + ApTitle = "1,1,1,999,1", + AeQualifierText = "12", + AeQualifier = 12, + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + } + } + }; +} 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..0e002ac18 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs @@ -0,0 +1,98 @@ +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_PhysicalBaseline_RemainsExactP05bBudgetConvergenceCommit() + { + using var lockDocument = JsonDocument.Parse( + File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json"))); + using var targetDocument = JsonDocument.Parse( + File.ReadAllText(FindRepoFile("evidence/smart-discovery-golden-target.json"))); + var workflow = File.ReadAllText( + FindRepoFile(".github/workflows/smart-discovery-capture-build.yml")); + + var physicalTargetCommit = targetDocument.RootElement + .GetProperty("EngineCommit") + .GetString(); + var previousTrialPin = lockDocument.RootElement + .GetProperty("previousTrialPin") + .GetProperty("commit") + .GetString(); + var currentIntegrationCommit = lockDocument.RootElement + .GetProperty("commit") + .GetString(); + + Assert.Equal(P05bEngineCommit, physicalTargetCommit); + Assert.Equal(P05bEngineCommit, previousTrialPin); + Assert.NotEqual(P05bEngineCommit, currentIntegrationCommit); + Assert.Contains( + "$baselineEngineCommit = $lock.previousTrialPin.commit", + workflow, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + $"if ($baselineEngineCommit -ne '{P05bEngineCommit}')", + workflow, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("ARIEC61850_COMMIT=$integrationEngineCommit", workflow, StringComparison.Ordinal); + Assert.Contains("ARIEC61850_BASELINE_COMMIT=$baselineEngineCommit", workflow, StringComparison.Ordinal); + Assert.Contains("Unexpected engine commit", workflow, StringComparison.Ordinal); + Assert.Contains("LastSmartTypeProbeBudget", workflow, StringComparison.Ordinal); + Assert.Contains("SuppressedExactRepeatRequests", workflow, StringComparison.Ordinal); + } + + 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}'."); + } +}