Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions .github/workflows/interoperability-reference-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
run: |
$lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json
$target = Get-Content .\ARSAS\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json
$association = $target.postBaselineAcceptance.sclAssociationInteroperability

if ($target.status -ne 'physical-retest-passed-merge-ready') {
throw "Convergence status changed unexpectedly: $($target.status)"
Expand All @@ -58,9 +59,31 @@ jobs:
[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 ($lock.mergedMainCommit -ne $target.activeStack.engineModelAndScl.head -or
$lock.previousStablePin.commit -ne $lock.mergedMainCommit -or
[int]$lock.previousStablePin.sourcePullRequest -ne 135) {
throw 'Historical engine authority drifted from the R10 convergence single source of truth.'
}

if ($association.contractId -ne 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -or
$association.status -ne 'physically-accepted-engine-merged-consumer-pending-merge' -or
-not [bool]$association.preservesR10Baseline -or
[int]$association.engine.pullRequest -ne 143 -or
$association.engine.testedHead -ne $lock.commit -or
$association.engine.mergedMain -ne $lock.sclAssociationInteroperability.mergedEngineCommit -or
$association.engine.tree -ne $lock.sclAssociationInteroperability.engineTree -or
[int]$lock.sourcePullRequest -ne 143 -or
$lock.sclAssociationInteroperability.testedEngineCommit -ne $lock.commit -or
-not [bool]$association.physicalAcceptance.fullDiscoverySkipped -or
[int]$association.physicalAcceptance.associationAttempts -ne 1 -or
[int]$association.physicalAcceptance.matchedDomains -ne 5 -or
[int]$association.physicalAcceptance.failedReads -ne 0 -or
[int]$association.physicalAcceptance.projectionErrors -ne 0 -or
[int]$association.physicalAcceptance.cacheLoss -ne 0 -or
[int]$association.physicalAcceptance.unresolvedRuntimePoints -ne 0 -or
[int]$association.physicalAcceptance.cyclicMmsProcessPolling -ne 0 -or
-not [bool]$association.physicalAcceptance.actualInformationReportObserved) {
throw 'Accepted post-baseline SCL association authority drifted or lost physical evidence.'
}
if ([bool]$target.promotion.productionPromoted -or
[bool]$target.promotion.mergeAllowedBeforePhysicalRetest -or
Expand Down
261 changes: 252 additions & 9 deletions .github/workflows/scl-interoperability-r7.yml

Large diffs are not rendered by default.

32 changes: 25 additions & 7 deletions .github/workflows/smart-discovery-mainline-readiness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,33 +69,51 @@ jobs:
}
}

if ($lock.commit -ne $target.activeStack.engineModelAndScl.mainMerge -or
$lock.commit -ne $r10.testedArtifact.engineMergedMain -or
if ($lock.mergedMainCommit -ne $target.activeStack.engineModelAndScl.mainMerge -or
$lock.mergedMainCommit -ne $r10.testedArtifact.engineMergedMain -or
$lock.physicalTestedCommit -ne $r10.testedArtifact.engineTestedHead -or
$lock.mergedMainTree -ne $r10.testedArtifact.engineTree) {
throw 'ARSAS engine lock no longer matches the R10 physical authority.'
$lock.mergedMainTree -ne $r10.testedArtifact.engineTree -or
$lock.previousStablePin.commit -ne $lock.mergedMainCommit -or
[int]$lock.previousStablePin.sourcePullRequest -ne 135) {
throw 'Historical ARSAS engine baseline no longer matches the R10 physical authority.'
}

$association = $target.postBaselineAcceptance.sclAssociationInteroperability
if ($association.contractId -ne 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -or
$association.status -ne 'physically-accepted-engine-merged-consumer-pending-merge' -or
-not [bool]$association.preservesR10Baseline -or
$association.engine.testedHead -ne $lock.commit -or
[int]$association.engine.pullRequest -ne [int]$lock.sourcePullRequest -or
[int]$lock.sourcePullRequest -ne 143 -or
-not [bool]$association.physicalAcceptance.fullDiscoverySkipped -or
[int]$association.physicalAcceptance.failedReads -ne 0 -or
[int]$association.physicalAcceptance.projectionErrors -ne 0 -or
[int]$association.physicalAcceptance.cacheLoss -ne 0 -or
[int]$association.physicalAcceptance.unresolvedRuntimePoints -ne 0 -or
-not [bool]$association.physicalAcceptance.actualInformationReportObserved) {
throw 'Post-baseline SCL association acceptance is missing or inconsistent.'
}

"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 merged ARIEC61850 authority
- name: Checkout exact accepted ARIEC61850 integration authority
shell: powershell
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().ToLowerInvariant()
if ($actual -ne $env:ENGINE_COMMIT) {
throw "R10 engine authority mismatch. Expected $env:ENGINE_COMMIT, got $actual."
throw "Accepted engine authority mismatch. Expected $env:ENGINE_COMMIT, got $actual."
}

- name: Setup .NET 8
uses: actions/setup-dotnet@v6
with:
dotnet-version: 8.0.x

- name: Validate and test merged engine authority
- name: Validate and test accepted engine authority
shell: powershell
run: |
dotnet restore ..\ARIEC61850\ARIEC61850.sln
Expand Down
9 changes: 6 additions & 3 deletions Services/NativeIec61850Client.SclAssisted.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public async Task<SclAssistedClientConnectResult> ConnectUsingSclAsync(
_port,
maximumVariableReferencesPerRead);
if (!preparation.IsSuccess ||
preparation.AssociationPlan is null ||
preparation.AssociationResolution is null ||
preparation.InitialReadDesign is null ||
preparation.InitialReadPlan is null)
{
Expand All @@ -161,7 +161,7 @@ preparation.InitialReadDesign is null ||
{
var associationWatch = Stopwatch.StartNew();
var online = await _session.ConnectSclAssistedAsync(
preparation.AssociationPlan,
preparation.AssociationResolution,
preparation.DomainInventory,
TimeSpan.FromSeconds(8),
cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -324,7 +324,9 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut
var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial ||
initialValueCacheLoss > 0;
LastDiscoverySummary =
$"SCL-assisted MMS: domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " +
$"SCL-assisted MMS: associationCandidate={online.SelectedAssociationCandidateName}, " +
$"associationSource={online.SelectedAssociationCandidateSource}, associationAttempts={online.AssociationAttemptCount}, " +
$"domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " +
$"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}, " +
Expand All @@ -342,6 +344,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut
: string.Empty;

var warnings = preparation.Warnings
.Concat(online.AssociationResolutionNotes)
.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<string>())
Expand Down
54 changes: 36 additions & 18 deletions Services/SclAssistedConnectionPreparation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ namespace ArIED61850Tester.Services;
public sealed class SclAssistedConnectionPreparation
{
public ArScl.SclAssistedMmsAssociationPlan? AssociationPlan { get; init; }
public ArScl.SclAssistedMmsAssociationResolution? AssociationResolution { get; init; }
public ArScl.SclMmsDomainInventory DomainInventory { get; init; } = new();
public ArScl.SclInitialFcReadDesign? InitialReadDesign { get; init; }
public ArMms.InitialFcReadPlan? InitialReadPlan { get; init; }
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
public bool IsSuccess =>
Errors.Count == 0 &&
AssociationPlan is not null &&
AssociationResolution?.IsSuccess == true &&
DomainInventory.IsSuccess &&
InitialReadDesign?.IsSuccess == true &&
InitialReadPlan?.IsValid == true;
Expand Down Expand Up @@ -126,28 +127,42 @@ public static SclAssistedConnectionPreparation Build(
// 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(
// Preserve the exact complete-SCL plan as a compatibility/round-trip contract,
// but do not treat an incomplete optional association identity as fatal. The
// engine resolver distinguishes missing from invalid/ambiguous fields and owns
// every interoperability candidate.
var exactAssociation = ArScl.SclAssistedMmsAssociationPlanBuilder.BuildExact(
effectiveRemote,
ArScl.MmsLocalAssociationProfile.ExistingRuntimeDefault);
warnings.AddRange(association.Warnings);
if (!association.IsSuccess || association.Plan is null)
warnings.AddRange(exactAssociation.Warnings);

ArScl.SclAssistedMmsAssociationPlan? runtimePlan = null;
if (exactAssociation.IsSuccess && exactAssociation.Plan is not null)
{
errors.AddRange(association.Errors);
return Fail(errors, warnings);
runtimePlan = new ArScl.SclAssistedMmsAssociationPlan
{
Host = normalizedHost,
Port = normalizedPort,
IedName = exactAssociation.Plan.IedName,
AccessPointName = exactAssociation.Plan.AccessPointName,
LocalProfileName = exactAssociation.Plan.LocalProfileName,
Cotp = exactAssociation.Plan.Cotp,
Association = exactAssociation.Plan.Association,
CotpConnectRequest = exactAssociation.Plan.CotpConnectRequest,
SessionPresentationAcseMmsRequest = exactAssociation.Plan.SessionPresentationAcseMmsRequest
};
}

var runtimePlan = new ArScl.SclAssistedMmsAssociationPlan
var associationResolution = ArScl.SclAssistedMmsAssociationCandidateResolver.Resolve(
effectiveRemote,
ArScl.MmsLocalAssociationProfile.ExistingRuntimeDefault,
normalizedPort);
warnings.AddRange(associationResolution.Warnings);
if (!associationResolution.IsSuccess)
{
Host = normalizedHost,
Port = normalizedPort,
IedName = association.Plan.IedName,
AccessPointName = association.Plan.AccessPointName,
LocalProfileName = association.Plan.LocalProfileName,
Cotp = association.Plan.Cotp,
Association = association.Plan.Association,
CotpConnectRequest = association.Plan.CotpConnectRequest,
SessionPresentationAcseMmsRequest = association.Plan.SessionPresentationAcseMmsRequest
};
errors.AddRange(associationResolution.Errors);
return Fail(errors, warnings, associationResolution);
}

var domains = ArScl.SclMmsDomainInventoryReader.Read(sclXml, normalizedIed, normalizedAccessPoint);
warnings.AddRange(domains.Warnings);
Expand All @@ -174,6 +189,7 @@ public static SclAssistedConnectionPreparation Build(
return new SclAssistedConnectionPreparation
{
AssociationPlan = runtimePlan,
AssociationResolution = associationResolution,
DomainInventory = domains,
InitialReadDesign = design,
InitialReadPlan = initialReadPlan,
Expand All @@ -184,9 +200,11 @@ public static SclAssistedConnectionPreparation Build(

private static SclAssistedConnectionPreparation Fail(
IReadOnlyCollection<string> errors,
IReadOnlyCollection<string> warnings)
IReadOnlyCollection<string> warnings,
ArScl.SclAssistedMmsAssociationResolution? associationResolution = null)
=> new()
{
AssociationResolution = associationResolution,
Errors = errors.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray(),
Warnings = warnings.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray()
};
Expand Down
33 changes: 33 additions & 0 deletions docs/INTEROPERABILITY_REFERENCE_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,39 @@ Merged engine provenance:
- PR #135 → main merge `648124097621046f5f127ceb1cf853fea54db730`: canonical model, SCL interoperability, P1/P2 value pipeline.
- ARSAS PR #324: consumer integration and physical R10 proof.

## Post-baseline SCL association interoperability — physically accepted

A later, narrowly scoped interoperability layer extends trusted-SCL reconnect when a
ConnectedAP omits called AP-title and/or AE qualifier. It does **not** replace the
R10 Smart Discovery/model baseline above.

Accepted source authority:

- ARIEC61850 PR #143 tested head `84e9820e5a32690475960e49d5ef74e6637847fd`;
- engine main merge `e5deed1d8aa11d97991695c6e390baafea7ab797`;
- both engine commits have identical tree
`d92676dd6d6243ec997749dd0355aa8c36ad83bd`;
- ARSAS PR #425 physical merge-test tree
`9dc35d722b54872ed8c506eba062a80945761aa9`.

The physical trusted-SCL session accepted the bounded `BalancedApTitle` candidate on
attempt 1, matched all 5 expected MMS domains, completed 395/395 bounded initial
reads, produced `projectionErrors=0` and `cacheLoss=0`, skipped full discovery,
kept cyclic MMS process polling at zero, and observed live InformationReport traffic
with final runtime unresolved count zero.

The contract remains fail-closed:

- explicit valid SCL association fields are immutable constraints;
- malformed or conflicting explicit values are not repaired by guessing;
- only genuinely unspecified fields may be completed by bounded engine-owned profiles;
- each candidate starts on a fresh transport;
- the source SCL is never rewritten by association negotiation;
- SCL-assisted success does not trigger NamedVariable/DataSet rediscovery or full discovery.

The historical R10 baseline remains the discovery/model authority. This section is a
post-baseline accepted layer, not a rewrite of R10 evidence.

## P0 — structural discovery freeze

Contract: `P0-R9-STRUCTURAL`.
Expand Down
Loading
Loading