Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
177 commits
Select commit Hold shift + click to select a range
1a9806c
fix(reporting): add typed RCB scalar mutation contract (#144)
masarray Oct 7, 2026
eef0614
fix(reporting): route RCB scalar writes through semantic contract (#144)
masarray Oct 7, 2026
18f8ae2
fix(reporting): route RCB scalar writes through semantic contract (#144)
masarray Oct 7, 2026
1bd8813
fix(reporting): route RCB scalar writes through semantic contract (#144)
masarray Oct 7, 2026
7fd2fa6
test(reporting): lock RCB semantic wire types (#144)
masarray Oct 7, 2026
6f66dd9
fix(reporting): complete scalar semantic write migration (#144)
masarray Oct 7, 2026
23a4b45
fix(reporting): complete scalar semantic write migration (#144)
masarray Oct 7, 2026
488e678
fix(reporting): keep ResvTms codec semantic-neutral within INT16 (#144)
masarray Oct 7, 2026
cebd6b2
test(reporting): cover full signed ResvTms INT16 representation (#144)
masarray Oct 7, 2026
5d301be
refactor(reporting): converge SCL and discovery on configured static …
masarray Oct 7, 2026
20c7b40
test(reporting): lock source-neutral configured static entry point (#…
masarray Oct 7, 2026
c5989b0
feat(reporting): add bounded live type evidence for RCB scalar mutati…
masarray Oct 7, 2026
338cd4f
feat(reporting): preserve typed write failure evidence (#144)
masarray Oct 7, 2026
794ebea
fix(reporting): scope semantic type evidence to one association (#144)
masarray Oct 7, 2026
c386bb8
test(reporting): distinguish missing from conflicting type evidence (…
masarray Oct 7, 2026
3e94749
fix(reporting): preserve exact-case identity in type-evidence cache (…
masarray Oct 7, 2026
34964f5
feat(mms): classify write failures from structured DataAccessError ev…
masarray Oct 7, 2026
fbedf54
feat(reporting): carry structured interoperability failure evidence (…
masarray Oct 7, 2026
b201497
refactor(reporting): drive type recovery from structured failure clas…
masarray Oct 7, 2026
6b81b9c
test(mms): lock structured interoperability recovery classification (…
masarray Oct 7, 2026
4e6b521
feat(canonical): add source-neutral static report coverage resolver (…
masarray Oct 7, 2026
13b84fe
test(canonical): prove source-neutral static coverage and ordered Dat…
masarray Oct 7, 2026
ac73e5e
chore(canonical): keep static coverage resolver allocation-minimal (#…
masarray Oct 7, 2026
9cfc1ae
fix(canonical): converge SCL and live configured reference syntax in …
masarray Oct 7, 2026
c8addff
test(canonical): prove Discovery and Open-SCL builder coverage conver…
masarray Oct 7, 2026
fab541d
fix(canonical): repair configured reference normalizer syntax (#144)
masarray Oct 7, 2026
8f8b024
feat(reporting): model reduced configured-static RCB eligibility (#144)
masarray Oct 7, 2026
c04c07b
feat(reporting): gate reduced configured-static eligibility explicitl…
masarray Oct 7, 2026
beda723
fix(reporting): preserve existing reduced-confidence explicit-free se…
masarray Oct 7, 2026
ba392fe
fix(reporting): rank explicit-free static eligibility without changin…
masarray Oct 7, 2026
6bdb6b7
test(reporting): lock missing-vs-blocking static RCB evidence policy …
masarray Oct 7, 2026
e1d4d34
test(reporting): prove reduced static policy is explicit and static-f…
masarray Oct 7, 2026
70e6e9e
test(reporting): keep reduced static interoperability disabled by def…
masarray Oct 7, 2026
4cc9b49
fix(reporting): route fresh static hybrid plans through configured li…
masarray Oct 7, 2026
4a915bf
feat(diagnostics): surface typed RCB mutation failure evidence (#144)
masarray Oct 7, 2026
45e8855
test(diagnostics): lock typed RCB interoperability counters (#144)
masarray Oct 7, 2026
815d226
feat(reporting): classify static activation failures from typed evide…
masarray Oct 7, 2026
706ad53
refactor(canonical): normalize configured DataSet and RCB identity at…
masarray Oct 7, 2026
18d0ac1
test(canonical): require configured identity equality across Discover…
masarray Oct 7, 2026
302f788
fix(canonical): repair configured-reference ingress normalizer after …
masarray Oct 7, 2026
ca41ca2
feat(reporting): add JIT configured-static readback proof policy (#144)
masarray Oct 7, 2026
c2c2799
fix(reporting): fail closed when configured-static RptEna readback is…
masarray Oct 7, 2026
ad6bfc5
test(reporting): lock configured-static JIT readback proof contract (…
masarray Oct 7, 2026
97363b9
perf(reporting): add minimal JIT RCB snapshot fast path (#144)
masarray Oct 7, 2026
ac6094e
feat(reporting): retain exact JIT RCB state evidence in snapshots (#144)
masarray Oct 7, 2026
7c6f3f6
perf(reporting): prove configured static activation with one exact re…
masarray Oct 7, 2026
86915ca
perf(reporting): use minimal JIT preflight and single exact post-enab…
masarray Oct 7, 2026
f7675a6
test(reporting): lock minimal one-readback configured-static proof (#…
masarray Oct 7, 2026
12bb6bb
test(reporting): fix busy-evidence fixture construction (#144)
masarray Oct 7, 2026
327d499
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
048d39d
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
1b64dfb
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
b33ce7f
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
7ad1193
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
e78f007
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
d129faa
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
1722093
ci(#144): refresh targeted hot-path read-budget fixes
masarray Oct 7, 2026
bad81df
ci(#144): refresh targeted hot-path read-budget fixes
masarray Oct 7, 2026
1a033ab
ci(#144): refresh structure type-prewarm optimization
masarray Oct 7, 2026
7cef117
ci(#144): refresh structure type-prewarm optimization
masarray Oct 7, 2026
a39e932
ci(#144): overlay asynchronous armed-to-traffic proof state
masarray Oct 7, 2026
f5e8a45
ci(#144): overlay canonical-to-live RCB target resolver
masarray Oct 7, 2026
7f7328e
ci(#144): overlay canonical-to-live RCB target resolver
masarray Oct 7, 2026
4c31f11
ci(#144): refresh canonical targeted-RCB integration
masarray Oct 7, 2026
c69bebf
ci(#144): refresh canonical targeted-RCB integration
masarray Oct 7, 2026
1868ea8
ci(#144): refresh canonical targeted-RCB integration
masarray Oct 7, 2026
c0609d5
ci(#144): refresh canonical targeted-RCB integration
masarray Oct 7, 2026
cba751f
perf(reporting): integrate event-driven report wake for exact-head CI…
masarray Oct 7, 2026
471c1f1
perf(reporting): integrate event-driven report wake for exact-head CI…
masarray Oct 7, 2026
d5d82c5
perf(reporting): integrate event-driven report wake for exact-head CI…
masarray Oct 7, 2026
71a8a52
test(reporting): prove burst reports coalesce without stale wake (#144)
masarray Oct 7, 2026
9bb2f93
feat(reporting): integrate P6 source-aware acquisition preflight for …
masarray Oct 7, 2026
93e1a80
feat(reporting): integrate P6 source-aware acquisition preflight for …
masarray Oct 7, 2026
edf41a8
feat(reporting): integrate source-bound P6 preflight for exact-head C…
masarray Oct 7, 2026
959ca81
feat(reporting): integrate source-bound P6 preflight for exact-head C…
masarray Oct 7, 2026
060a217
feat(reporting): integrate source-bound P6 preflight for exact-head C…
masarray Oct 7, 2026
866b10d
feat(reporting): integrate source-bound P6 preflight for exact-head C…
masarray Oct 7, 2026
89c655e
test(reporting): prove live MMS inventory authority is stamped at dis…
masarray Oct 7, 2026
cd7002d
feat(reporting): integrate canonical structural inventory projection …
masarray Oct 7, 2026
b0860f6
feat(reporting): integrate canonical structural inventory projection …
masarray Oct 7, 2026
4d97751
feat(reporting): integrate canonical structural inventory projection …
masarray Oct 7, 2026
b8ff24b
test(reporting): use canonical SCL evidence enum name (#144)
masarray Oct 7, 2026
51fa709
test(reporting): use canonical SCL evidence enum name (#144)
masarray Oct 7, 2026
5858a86
feat(reporting): integrate P6.1 targeted live RCB reconciliation for …
masarray Oct 7, 2026
9c52925
feat(reporting): integrate P6.1 targeted live RCB reconciliation for …
masarray Oct 7, 2026
79f9de0
fix(scl): resolve same-IED ServerAt chains with fail-closed owner sem…
masarray Oct 8, 2026
8b57df8
fix(scl): preserve referenced Server model for ServerAt endpoint proj…
masarray Oct 8, 2026
f111948
fix(scl): recognize valid ServerAt in engineering profile; flag inval…
masarray Oct 8, 2026
ebffc41
test(scl): Siemens-style J Server plus F ServerAt dual endpoint regre…
masarray Oct 8, 2026
cee8021
fix(scl): resolve ServerAt owner for exact MMS domain inventory on se…
masarray Oct 8, 2026
90ec4f2
fix(scl): carry ServerAt model through SCL-assisted initial FC-read p…
masarray Oct 8, 2026
0f32a71
test(scl): ServerAt must preserve both MMS domains and SCL-assisted i…
masarray Oct 8, 2026
e45259b
test(scl): import exact domain and initial-read authority namespace
masarray Oct 8, 2026
352c81e
test(scl): retain no-server fail-closed check with ServerAt-aware dia…
masarray Oct 8, 2026
fd1e8f4
feat(discovery): close static dataset LN schemas without full name en…
masarray Oct 9, 2026
f42318c
test(discovery): gate new LN roots on exact dataset plus successful G…
masarray Oct 9, 2026
8b0c0f7
fix(discovery): restore complete policy source and deterministic memb…
masarray Oct 9, 2026
d8dc2d2
feat(identity): exact trusted-SCL IED/LD mapping on pinned smart-disc…
masarray Oct 10, 2026
d34757e
fix(scl): thread verified IED/LD identity through canonical live expo…
masarray Oct 10, 2026
00ad281
test(identity): construct canonical fixture with an observed logical …
masarray Oct 11, 2026
5559406
fix(simulation): serialize MMS InformationReport emission per associa…
masarray Oct 11, 2026
ef0fe44
ci: validate engine staging pull requests in existing Windows .NET wo…
masarray Oct 11, 2026
fa93c9f
fix(mms): quarantine contradictory RptID/DatSet reports before proces…
masarray Oct 11, 2026
d700b0d
fix(mms): fail closed instead of silently dropping overflowed report …
masarray Oct 11, 2026
9bd0190
fix(mms): bound canonical report ingress and fail closed on overflow/…
masarray Oct 12, 2026
98f2119
fix(scl): decode unambiguous BER-packed AP-title in trusted associati…
masarray Oct 12, 2026
67c6635
fix(control): identify BSC TapChg from live ValWithTrans status path
masarray Oct 12, 2026
a43b9b4
P10.9K: gate generic control semantics and reuse per-association MMS …
masarray Oct 12, 2026
55fa34c
P10.9L: add bounded read-only exact MMS ctlModel evidence probe
masarray Oct 12, 2026
5e17da5
fix: remove escaped newline from P10.9L probe declaration
masarray Oct 12, 2026
f7a4e3b
P10.9Q: explicit guarded one-shot CF ctlModel commissioning with exac…
masarray Oct 12, 2026
d1dd8fc
P10.9R: support explicit ctlModel status-only value 0 in one-shot CF …
masarray Oct 12, 2026
f3860d9
P10.9T: verify structured MMS status; allow normal SBO without option…
masarray Oct 12, 2026
9d82523
P10.9T: repair structured-status implementation syntax and keep typed…
masarray Oct 12, 2026
0ae297c
P10.9U: resolve CSWI Pos ST status via single typed MMS read when dis…
masarray Oct 12, 2026
1a7ce83
P10.9U: fix xUnit analyzer violations in truncated-NameList control t…
masarray Oct 12, 2026
ad43399
P10.9U: align structured position regression with exactly one typed S…
masarray Oct 12, 2026
38420f1
fix(control): honor exact typed live BSC over stale SPC projection
masarray Oct 13, 2026
036e2c0
test(control): cover stale SPC to exact BSC and fail-closed mismatches
masarray Oct 13, 2026
cfd2a54
fix(m217k): project typed ST enum status FCD tuples for C264 Mod/PhyH…
masarray Oct 17, 2026
54bdc4b
test(m217k): physical C264 typed Mod/Beh/PhyHealth ST FCD and strict …
masarray Oct 17, 2026
e61989f
ci(m217k): run engine Windows test gate on pinned PR169 stacked branch
masarray Oct 17, 2026
51c2eef
test(m217k): incomplete live schema must not suppress typed C264 ST F…
masarray Oct 17, 2026
b27842a
test(identity): reproduce truncated device-name suffix and unverified…
masarray Oct 19, 2026
bcc8e22
fix(identity): preserve exact shared prefix and mark unverified domai…
masarray Oct 19, 2026
e1ba425
test(identity): align pre-existing shared-prefix expectation with pro…
masarray Oct 19, 2026
f2bf05e
test(identity): require all MMS-only and operator identities remain u…
masarray Oct 19, 2026
5b30f51
test(identity): require provisional confidence for suffix and operato…
masarray Oct 19, 2026
fbd6c4f
test(identity): require exact five-LD SCL and fail-closed AP/IP/domai…
masarray Oct 19, 2026
4cc3bde
test(identity): five-LD SCL proof and fail-closed missing endpoint
masarray Oct 19, 2026
10b3584
fix(identity): do not mark operator aliases or MMS suffix heuristics …
masarray Oct 19, 2026
1f3a601
fix(identity): mark all MMS suffix inference and operator alias as pr…
masarray Oct 19, 2026
f2bdb7f
fix(identity): require exact SCL domain/AP and connected endpoint proof
masarray Oct 19, 2026
a662834
fix(identity): fail closed on topology-equivalent AP with missing end…
masarray Oct 19, 2026
9e6b142
fix(identity): reject all duplicate topology-compatible APs before en…
masarray Oct 19, 2026
2dbfc21
test(identity): preserve distinct case-sensitive live MMS LD domain n…
masarray Oct 19, 2026
3c841f2
test(identity): AP selector cannot conceal another indistinguishable AP
masarray Oct 19, 2026
a12cee6
fix(identity): evaluate all AP ambiguity before applying operator AP …
masarray Oct 19, 2026
cd3b631
fix(identity): preserve case-sensitive MMS domain inventory and exact…
masarray Oct 19, 2026
313af3c
test(identity): distinct MMS domain casing must not overwrite alias i…
masarray Oct 19, 2026
ccf31f6
fix(identity): retain exact case-sensitive MMS aliases in resolved id…
masarray Oct 19, 2026
0504654
test(rcb): reproduce 66/300 candidate global guard and require exact-…
masarray Oct 19, 2026
dbf1189
feat(rcb): exact-target indexed read admission with stable bounded co…
masarray Oct 19, 2026
b2d3519
fix(rcb): replace global 64/128 admission abort with exact dataset sc…
masarray Oct 19, 2026
dc31e0f
fix(rcb): fence continuation cursor to accepted MMS association lifetime
masarray Oct 19, 2026
f2765f5
fix(rcb): fence continuation cursor to accepted MMS association lifetime
masarray Oct 19, 2026
f9f0189
fix(rcb): fence continuation cursor to accepted MMS association lifetime
masarray Oct 19, 2026
7f96daf
fix(rcb): fence continuation cursor to accepted MMS association lifetime
masarray Oct 19, 2026
adffc51
test(rcb): prove occupied matching RCB blocked while exact free sibli…
masarray Oct 19, 2026
37cd183
perf(rcb): prioritize indexed configured RCB instances for read-only …
masarray Oct 19, 2026
c79ee7d
perf(rcb): hash-index prioritized candidate exclusion rather than rep…
masarray Oct 19, 2026
954e48f
perf(rcb): stop paged reads after each exact DataSet has strong non-b…
masarray Oct 19, 2026
3d4599f
test(rcb): prioritize two indexed instances in 302 catalog without fa…
masarray Oct 19, 2026
6391244
test(rcb): correct 300 unrelated plus two relevant catalog cardinality
masarray Oct 19, 2026
1a3d82b
test(rcb): reject wrong live DatSet even when configured RCB referenc…
masarray Oct 19, 2026
edf9294
fix(rcb): refuse exact-name fallback when live DatSet binding contrad…
masarray Oct 19, 2026
59ecf1b
feat(rcb): bounded resumable exact-target availability pager without …
masarray Oct 19, 2026
15f9d55
test(rcb): replace preflight global target budget rejection with 66/3…
masarray Oct 19, 2026
e8b6be6
fix(rcb): page exact-target availability and stop on safe eligible ca…
masarray Oct 19, 2026
d9e7952
fix(rcb): count deferred exact targets by actual probe admission, not…
masarray Oct 19, 2026
ad7e65b
fix(mms): reject malformed primitive wire payloads instead of fabrica…
masarray Oct 19, 2026
b1682e8
fix(mms): enforce BER boolean payload width before typed admission
masarray Oct 19, 2026
5dbccbc
test(mms): malformed boolean/int/float/bitstring cannot become synthe…
masarray Oct 19, 2026
91894b3
feat(mms): typed access-failure taxonomy distinct from read result pa…
masarray Oct 19, 2026
1c8e105
feat(mms): propagate typed single read failure evidence without synth…
masarray Oct 19, 2026
e58f33c
feat(mms): propagate typed ordered batch read failure evidence withou…
masarray Oct 19, 2026
186b844
fix(mms): quarantine entire batch when access-result ordinal count mi…
masarray Oct 19, 2026
1414948
ci(p0-c): run full Windows engine gate on P0-B-stacked typed read branch
masarray Oct 19, 2026
ba21d88
test(p0-c): typed per-access MMS Read failure, invoke fence, strict o…
masarray Oct 19, 2026
c85d4e2
fix: isolate RCB reports from control indications and expose control …
masarray Oct 20, 2026
3aa0caf
feat(p0-c): attribute SBOw latency to negative-indication observation…
masarray Oct 20, 2026
9c7cb40
test(p0-c): reject immediate and delayed SBOw LastApplError without s…
masarray Oct 20, 2026
7929969
perf(mms): bound independent RCB attribute enrichment by negotiated o…
masarray Oct 21, 2026
07c86b8
testable(policy): negotiated-window and candidate bounded report-enri…
masarray Oct 21, 2026
63f2536
perf(mms): use tested negotiated RCB worker-policy admission
masarray Oct 21, 2026
4829f9d
test(p0): deterministic report window caps and no extra MMS Read/writ…
masarray Oct 21, 2026
d095b3a
fix(discovery): mark interrupted RCB worker on association loss inste…
masarray Oct 21, 2026
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
4 changes: 3 additions & 1 deletion .github/workflows/dotnet-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ on:
- "*.slnx"
- "Directory.Build.props"
pull_request:
branches: [ main ]
# The live ARSAS product pins PR #169 until targeted engine changes pass CI.
# Stacked engine PRs must run the exact same Windows build/test gate.
branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe, fix/p0-b-target-scoped-rcb-pager-20261019 ]
workflow_dispatch:

permissions:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Control/report isolation trial — 2026-10-20

- Reject non-RCB InformationReport shapes before persistent single-monitor fallback; control termination and LastApplError remain available to independent control subscribers.
- Include monotonic service duration and post-response wait in each control wire-step detail. UTC marks local service start, not wire send. This separates SBOw/Operate service latency from asynchronous completion wait without changing timeout, checks, or command sequencing.
- Synthetic regression coverage added; physical latency improvement is not yet established.

All notable public changes to ARIEC61850 are recorded here. The project is still evolving toward stable semantic releases; entries are grouped by engineering milestone until a formal versioning policy is adopted.

## Unreleased — public wording, provenance and release assurance
Expand Down
61 changes: 49 additions & 12 deletions src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Threading.Channels;
using AR.Iec61850.Asn1;
using AR.Iec61850.Mms;

Expand Down Expand Up @@ -192,6 +193,17 @@ public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDi
private readonly Dictionary<string, Timer> _integrityTimers = new(StringComparer.OrdinalIgnoreCase);
private readonly object _gate = new();
private readonly CancellationTokenSource _cts = new();
// One FIFO per MMS association. A distinct Task.Run per report previously
// allowed send delegates to acquire the socket lock in a different order
// from the SqNum assignment, manufacturing on-wire discontinuities.
private readonly Channel<(MmsRcbRuntimeState State, byte Reason)> _sendQueue =
Channel.CreateUnbounded<(MmsRcbRuntimeState, byte)>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
private bool _disposed;

public MmsAssociationReportingRuntime(
Expand Down Expand Up @@ -226,6 +238,12 @@ public MmsAssociationReportingRuntime(
IntgPd = (uint)Math.Max(0, rcb.IntegrityPeriodMs)
};
}

// One asynchronous worker is allocated per association, not per report.
// Capture the token here: a delayed worker must never access a CTS
// after Dispose() has disposed its source.
var senderToken = _cts.Token;
_ = Task.Run(() => DrainReportQueueAsync(senderToken));
}

public IReadOnlyCollection<MmsRcbRuntimeState> States => _states.Values;
Expand Down Expand Up @@ -414,6 +432,7 @@ public void Dispose()
return;

_disposed = true;
_sendQueue.Writer.TryComplete();
_cts.Cancel();
lock (_gate)
{
Expand Down Expand Up @@ -449,21 +468,39 @@ private void QueueReport(MmsRcbRuntimeState state, byte reason)
if (_disposed)
return;

_ = Task.Run(async () =>
// Channel FIFO is the report ordering authority. No per-report Task.Run,
// no competing asynchronous socket writers and no silent report drop.
_sendQueue.Writer.TryWrite((state, reason));
}

private async Task DrainReportQueueAsync(CancellationToken cancellationToken)
{
try
{
try
{
await SendReportAsync(state, reason, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Association closing.
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException)
await foreach (var request in _sendQueue.Reader.ReadAllAsync(cancellationToken)
.ConfigureAwait(false))
{
_activity?.Invoke(state.MmsReference, false, $"InformationReport send failed: {ex.Message}");
try
{
await SendReportAsync(request.State, request.Reason, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or
System.Net.Sockets.SocketException or InvalidOperationException)
{
_activity?.Invoke(request.State.MmsReference, false,
$"InformationReport send failed: {ex.Message}");
}
}
}, CancellationToken.None);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Association closing: pending reports are not sent on a dead socket.
}
}

private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, CancellationToken cancellationToken)
Expand Down
4 changes: 3 additions & 1 deletion src/AR.Iec61850/Control/Iec61850ControlModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ public sealed class Iec61850ControlObjectDescriptor
ControlModel is not Iec61850ControlModel.StatusOnly and not Iec61850ControlModel.Unknown &&
!string.IsNullOrWhiteSpace(ObjectReference) &&
!string.IsNullOrWhiteSpace(CtlValSpecification.MmsType) &&
(!RequiresSelect || CancelSpecification != null) &&
// Normal SBO Select -> Operate does not depend on optional Cancel.
// Enhanced SBO still requires Cancel for sequence recovery.
(ControlModel != Iec61850ControlModel.SelectBeforeOperateEnhanced || CancelSpecification != null) &&
(ControlModel != Iec61850ControlModel.SelectBeforeOperateEnhanced || SelectWithValueSpecification != null) &&
(!IsEnhanced || SupportsCommandTermination);

Expand Down
44 changes: 38 additions & 6 deletions src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ private sealed class AssociationControlState
private CancellationTokenSource? _selectionLeaseCts;
private bool _ownsLock;
private bool _disposed;
private DateTimeOffset _serviceStartedUtc;
private TimeSpan _serviceElapsed;
private long _serviceCompletedTimestamp;


internal Iec61850ControlObjectSession(
IIec61850ControlTransport transport,
Expand Down Expand Up @@ -115,7 +119,7 @@ private async Task<Iec61850ControlActionResult> SelectCoreAsync(
try
{
var context = CreateContext(request);
var read = await _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken).ConfigureAwait(false);
var read = await MeasureServiceAsync(() => _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken)).ConfigureAwait(false);
if (!read.IsSuccess || !IsPositiveSboSelection(read.Value, Descriptor.References))
{
ReleaseObject();
Expand Down Expand Up @@ -172,7 +176,7 @@ private async Task<Iec61850ControlActionResult> SelectWithValueCoreAsync(
context,
Descriptor.SelectWithValueSpecification,
_options.RequireExactNamedControlFields);
var write = await _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken).ConfigureAwait(false);
var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken)).ConfigureAwait(false);
if (!write.IsSuccess)
{
var appError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false);
Expand Down Expand Up @@ -289,7 +293,7 @@ private async Task<Iec61850ControlActionResult> OperateCoreAsync(
context,
Descriptor.OperSpecification,
_options.RequireExactNamedControlFields);
var write = await _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token).ConfigureAwait(false);
var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token)).ConfigureAwait(false);
if (!write.IsSuccess)
{
var applicationError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false);
Expand Down Expand Up @@ -432,7 +436,7 @@ private async Task<Iec61850ControlActionResult> CancelCoreAsync(CancellationToke
return Unsupported(Iec61850ControlAction.Cancel, "IED did not expose a decodable Cancel type specification.");

var value = Iec61850ControlStructureBuilder.BuildCancel(context, Descriptor.CancelSpecification, _options.RequireExactNamedControlFields);
var write = await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false);
var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false);
return write.IsSuccess
? Accepted(Iec61850ControlAction.Cancel, context, stopwatch.Elapsed, "Cancel accepted; local SBO ownership released.")
: FromWriteFailure(Iec61850ControlAction.Cancel, write, null, context, stopwatch.Elapsed);
Expand Down Expand Up @@ -691,7 +695,7 @@ private async Task BestEffortCancelAsync(CancellationToken cancellationToken)
context,
Descriptor.CancelSpecification,
_options.RequireExactNamedControlFields);
await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false);
await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false);
}
}
catch
Expand Down Expand Up @@ -782,6 +786,20 @@ private Iec61850ControlActionResult FromApplicationRejection(
BuildWireStep(action, requestAccepted: true, clientMessage))
};

// Serialized by _operationGate. These are local service boundaries, not
// packet capture timestamps; elapsed measurement uses the monotonic clock.
private async Task<T> MeasureServiceAsync<T>(Func<Task<T>> service)
{
_serviceStartedUtc = DateTimeOffset.UtcNow;
var started = Stopwatch.GetTimestamp();
try { return await service().ConfigureAwait(false); }
finally
{
_serviceElapsed = Stopwatch.GetElapsedTime(started);
_serviceCompletedTimestamp = Stopwatch.GetTimestamp();
}
}

private Iec61850ControlWireStep BuildWireStep(
Iec61850ControlAction action,
bool requestAccepted,
Expand All @@ -796,14 +814,28 @@ private Iec61850ControlWireStep BuildWireStep(
_ => Descriptor.References.Oper
};

// Distinguish protocol service response time from the selection
// negative-indication observation gate. The latter dominates the
// field sample (~400 ms); it must not be misreported as slow MMS I/O.
// This is telemetry only: do not reduce the grace period or bypass
// LastApplError and enhanced-security CommandTermination.
var postResponseStage = action switch
{
Iec61850ControlAction.SelectWithValue => "sbo-application-error-observation",
Iec61850ControlAction.Operate => "operate-termination-observation",
_ => "post-service-completion"
};
var graceBudget = action == Iec61850ControlAction.SelectWithValue
? _options.ApplicationErrorGracePeriod.TotalMilliseconds : 0d;
return new Iec61850ControlWireStep
{
Action = action,
Reference = $"{reference.Domain}/{reference.Item}",
RequestAccepted = requestAccepted,
RequestHex = _transport.LastRequestHex,
ResponseHex = _transport.LastResponseHex,
Detail = detail
Detail = FormattableString.Invariant(
$"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; postResponseStage={postResponseStage}; configuredApplicationErrorGraceMs={graceBudget:0.###}; timingScope=local-service-not-wire")
};
}

Expand Down
106 changes: 106 additions & 0 deletions src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using AR.Iec61850.Mms;

namespace AR.Iec61850.Control;

/// <summary>
/// Resolves *command* semantics from a declared CDC and corroborating live MMS
/// evidence. A primitive MMS type is an encoding, not permission to Operate.
/// This is vendor-independent and never sends an MMS request.
/// </summary>
public static class Iec61850ControlSemanticResolver
{
public static Iec61850ControlSemanticResolution Resolve(
Iec61850ControlObjectDescriptor descriptor,
string? declaredCdc,
string? declaredStatusReference = null)
{
ArgumentNullException.ThrowIfNull(descriptor);
var declared = (declaredCdc ?? string.Empty).Trim().ToUpperInvariant();
var inferred = (descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant();
var status = descriptor.StatusReference ?? string.Empty;
var mmsType = (descriptor.CtlValSpecification.MmsType ?? string.Empty).Trim().ToLowerInvariant();
var size = descriptor.CtlValSpecification.Size;
// A saved SCL/DataSet SPC can describe a process projection, not the
// command payload. Only an EXACT same-object ST step status plus a
// typed two-bit MMS ctlVal establishes live directional BSC authority.
// A bit-string alone (or a similarly named object) proves nothing.
var exactLiveBsc =
inferred == "BSC" &&
mmsType == "bit-string" && size == 2 &&
status.Equals(descriptor.ObjectReference + ".valWTr.posVal",
StringComparison.OrdinalIgnoreCase);
var supersededAdvisorySpc = declared == "SPC" && exactLiveBsc;
var cdc = supersededAdvisorySpc
? "BSC"
: string.IsNullOrWhiteSpace(declared) ? inferred : declared;

Iec61850ControlSemanticResolution Deny(string reason)
=> new(false, cdc, reason);

if (!descriptor.IsOperationallyReady)
return Deny("The live control descriptor or ctlModel is not operationally ready.");
if (string.IsNullOrWhiteSpace(status))
return Deny("No live status reference corroborates the control semantics.");

// A stale persisted feedback reference must not silently override a
// different live BSC status object when commands are involved.
if (cdc == "BSC" && !string.IsNullOrWhiteSpace(declaredStatusReference) &&
!string.Equals(declaredStatusReference.Trim(), status, StringComparison.OrdinalIgnoreCase))
return Deny("Declared BSC feedback reference disagrees with the live control descriptor.");

if (cdc != inferred &&
!(inferred == "INC/ISC" && cdc is "INC" or "ISC") &&
!(cdc == "DPC" && inferred == "SPC" && IsStandardPosition(descriptor.ObjectReference)))
return Deny($"Declared CDC '{cdc}' conflicts with live CDC evidence '{inferred}'.");

var statusIsStep = status.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase);
var statusIsState = status.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase);
var supported = cdc switch
{
"BSC" => statusIsStep &&
(mmsType is "integer" or "unsigned" ||
(mmsType == "bit-string" && size == 2)),
"DPC" => statusIsState &&
((mmsType == "bit-string" && size == 2) ||
(mmsType == "boolean" && IsStandardPosition(descriptor.ObjectReference))),
"SPC" => statusIsState && mmsType == "boolean",
"INC" or "ISC" => declared == cdc && statusIsState &&
mmsType is "integer" or "unsigned" or "bcd",
"APC" => declared == "APC" &&
(mmsType == "floating-point" || IsAnalogStructure(descriptor.CtlValSpecification)),
_ => false // Other CDCs require an independently audited typed codec.
};

if (!supported)
return Deny($"CDC '{cdc}' cannot be safely bound to live ctlVal '{descriptor.CtlValSpecification.Signature}' and status '{status}'.");

var provenance = supersededAdvisorySpc
? " Saved/DataSet CDC=SPC was advisory and superseded by exact live BSC control evidence."
: string.Empty;
return new(true, cdc,
$"Verified CDC={cdc}; live ctlModel={descriptor.ControlModel}; ctlVal={descriptor.CtlValSpecification.Signature}; status={status}.{provenance}");
}

internal static bool IsStandardPosition(string reference)
{
var suffix = reference[(reference.LastIndexOf('/') + 1)..];
var dot = suffix.IndexOf('.');
if (dot <= 0 || !suffix[(dot + 1)..].Equals("Pos", StringComparison.OrdinalIgnoreCase))
return false;
var ln = suffix[..dot].ToUpperInvariant();
return ln.StartsWith("CSWI", StringComparison.Ordinal) ||
ln.StartsWith("XCBR", StringComparison.Ordinal) ||
ln.StartsWith("XSWI", StringComparison.Ordinal);
}

private static bool IsAnalogStructure(MmsTypeSpecificationNode ctlVal)
{
if (!ctlVal.MmsType.Equals("structure", StringComparison.OrdinalIgnoreCase))
return false;
var numeric = ctlVal.Children.Where(child =>
child.MmsType is "integer" or "unsigned" or "floating-point").ToArray();
return numeric.Length == 1 && ctlVal.Children.Count == 1;
}
}

public sealed record Iec61850ControlSemanticResolution(bool IsCommandSafe, string Cdc, string Reason);
Loading