Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e6b908f
P6.1 stop static failures from triggering dynamic writes
masarray Aug 18, 2026
d893ca7
P6.1 lock static failure isolation
masarray Aug 18, 2026
cefd991
P6.1 keep static failures side effect free
masarray Aug 18, 2026
bb2a586
Pin ARSAS to ARIEC P6.1 static-preservation engine
masarray Aug 18, 2026
9882adb
P6.1 update engine provenance regression
masarray Aug 18, 2026
b3eb97f
Keep workspace regression independent of reviewed engine revision
masarray Aug 18, 2026
1f2fb68
Keep visual regression independent of reviewed engine revision
masarray Aug 18, 2026
12cdcc9
Update DataSet provenance guard for P6.1 engine pin
masarray Aug 18, 2026
9d8b2d3
fix(p6.1): isolate static RCB from dynamic association-abort probation
masarray Aug 18, 2026
01b950c
test(p6.1): lock mixed dynamic probation before static arming
masarray Aug 18, 2026
c0dfe5c
P6.2: pin dynamic NVL probation engine
masarray Aug 18, 2026
2606d68
P6.2: guard exact NVL probe evidence through ARSAS
masarray Aug 18, 2026
0b939c6
P6.2: pin fail-closed NVL probe engine
masarray Aug 18, 2026
f2f1a8d
P6.2: advance capability provenance guard to NVL probe engine
masarray Aug 18, 2026
3aab0e5
P6.2: preserve report history while pinning probe engine
masarray Aug 18, 2026
6d0a401
chore(p6.2-b): pin stability projection engine
masarray Aug 19, 2026
db5f922
test(p6.2-b): update capability provenance and dynamic quarantine
masarray Aug 19, 2026
1c934d4
test(p6.2-b): preserve report history with stability engine pin
masarray Aug 19, 2026
de03a8f
test(p6.2-b): lock association stability and report safety
masarray Aug 19, 2026
38508fd
chore(p6.2-b): pin reviewed stability engine head
masarray Aug 19, 2026
5998bb1
test(p6.2-b): align capability provenance with reviewed engine head
masarray Aug 19, 2026
6316d89
test(p6.2-b): pin final reviewed stability engine head
masarray Aug 19, 2026
3844d45
test(p6.2-b): align field stability fixture with reviewed engine head
masarray Aug 19, 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
191 changes: 24 additions & 167 deletions Services/NativeIec61850Client.HybridReporting.P4.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,184 +7,41 @@ namespace ArIED61850Tester.Services;
public sealed partial class NativeIec61850Client
{
/// <summary>
/// P4 recovery path used only after an engine-authoritative static report can no longer
/// be activated from fresh association evidence. Static planning is disabled for this
/// recovery pass so the same signal must either obtain a dynamic BRCB/URCB attempt or
/// return an explicit engine reason explaining why polling is the final fallback.
/// P6.1 baseline-safety compatibility hook.
///
/// P4 originally converted a failed static activation into a brand-new dynamic
/// DataSet/RCB write attempt. That changed the proven pre-P0 failure semantics and made
/// one static problem capable of mutating another RCB or destabilizing the association.
/// Static failure is now isolated again: no dynamic DataSet is created, no alternate RCB
/// is written, and bounded MMS polling remains the fallback for the affected signal set.
///
/// The method name is retained temporarily so existing call-sites stay source-compatible;
/// its behavior is deliberately fail-closed and side-effect free.
/// </summary>
private async Task<NativeReportMonitorStartResult> TryStartDynamicRecoveryAfterStaticFailureP4Async(
private Task<NativeReportMonitorStartResult> TryStartDynamicRecoveryAfterStaticFailureP4Async(
ReportControlPlan appPlan,
AuthoritativeHybridSubscription authoritative,
ArMms.MmsDiscoveryResult discovery,
ArMms.MmsRcbAvailabilityResult freshAvailability,
string staticFailure,
CancellationToken cancellationToken)
{
// P6: a previous real dynamic write failure is field evidence that this device must
// not receive another temporary DataSet/RCB write in the current application run.
// Static reporting remains eligible on every fresh association; only this recovery
// path is circuit-broken so reconnect cannot become a dynamic-write retry loop.
if (!string.IsNullOrWhiteSpace(appPlan.RelayId) &&
DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId, out var circuitReason))
{
return new NativeReportMonitorStartResult
{
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"{staticFailure} P6 dynamic recovery is circuit-broken after a previous real activation failure ({circuitReason}); MMS polling is the bounded fallback for this residual signal set.",
UsedDynamicDataSet = true,
DynamicAttempted = false,
DynamicAttemptState = "Skipped",
FailureReason = "DynamicWriteCircuitOpen",
PollingFallbackReason = "DynamicWriteCircuitOpen"
};
}

if (!authoritative.Options.AllowDynamicBrcb && !authoritative.Options.AllowDynamicUrcb)
{
return new NativeReportMonitorStartResult
{
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"{staticFailure} Dynamic recovery is disabled by policy; MMS polling is the final fallback.",
DynamicAttemptState = "Skipped",
FailureReason = "StaticActivationFailed",
PollingFallbackReason = ArMms.MmsHybridPollingFallbackReason.DynamicDisabledByPolicy.ToString()
};
}
_ = authoritative;
_ = discovery;
_ = freshAvailability;
_ = cancellationToken;

var dynamicOnlyOptions = new ArMms.MmsHybridReportAcquisitionOptions
return Task.FromResult(new NativeReportMonitorStartResult
{
AllowStaticBrcb = false,
AllowStaticUrcb = false,
AllowDynamicBrcb = authoritative.Options.AllowDynamicBrcb,
AllowDynamicUrcb = authoritative.Options.AllowDynamicUrcb,
AllowCallerOwnedReports = false,
AllowPollingFallback = true,
RequireExactAvailabilityEvidence = authoritative.Options.RequireExactAvailabilityEvidence,
MaxDynamicMembersPerReport = authoritative.Options.MaxDynamicMembersPerReport,
MaxDynamicReportPlans = authoritative.Options.MaxDynamicReportPlans
};

var recovery = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build(
authoritative.Catalog,
authoritative.Signals,
discovery.ReportInventory,
freshAvailability,
discovery.IedDirectory,
_session.LastNegotiatedCapabilities,
dynamicOnlyOptions);
var attemptEvidence = ArMms.MmsHybridDynamicAttemptEvidenceBuilder.Build(recovery, dynamicOnlyOptions);
var dynamicSegment = recovery.AcquisitionPlan.Segments.FirstOrDefault(segment =>
segment.ReportPlan is not null &&
segment.Kind is ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ArMms.MmsHybridAcquisitionKind.DynamicUrcb);

if (dynamicSegment?.ReportPlan is null)
{
var fallbackReasons = attemptEvidence
.Where(item => item.DynamicAttemptDisposition == ArMms.MmsHybridDynamicAttemptDisposition.Skipped)
.Select(item => item.PollingFallbackReason.ToString())
.Where(reason => !reason.Equals("None", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var reason = fallbackReasons.Length == 0
? ArMms.MmsHybridPollingFallbackReason.DynamicPlanUnavailableAfterCapabilityQualification.ToString()
: string.Join(",", fallbackReasons);

return new NativeReportMonitorStartResult
{
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"{staticFailure} ARIEC P4 dynamic recovery produced no ready dynamic report segment; MMS polling is now the final fallback. reason={reason}.",
DynamicAttempted = false,
DynamicAttemptState = "Skipped",
FailureReason = "StaticActivationFailed",
PollingFallbackReason = reason,
Warnings = recovery.Warnings
.Concat(recovery.Blockers)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()
};
}

var subscription = dynamicSegment.ReportPlan;
var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members);
var attempt = await RunMmsOperationAsync(
() => _session.StartPersistentReportMonitorWithAttemptEvidenceAsync(
subscription,
triggerGeneralInterrogation: true,
deleteDynamicDataSetOnStop: true,
discovery.IedDirectory,
cancellationToken),
cancellationToken).ConfigureAwait(false);
var start = attempt.StartResult;
var warnings = start.Warnings
.Concat(subscription.Warnings)
.Concat(attempt.CleanupWarnings)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

if (!attempt.IsSuccess || start.Session is null)
{
if (attempt.DynamicAttempted && !string.IsNullOrWhiteSpace(appPlan.RelayId))
{
var failure = attempt.FailureReason.ToString();
if (string.IsNullOrWhiteSpace(failure) || failure.Equals("None", StringComparison.OrdinalIgnoreCase))
failure = "DynamicRecoveryActivationFailed";
DynamicWriteCircuitByDevice[appPlan.RelayId] = failure;
}

return new NativeReportMonitorStartResult
{
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"{staticFailure} Dynamic recovery was {(attempt.DynamicAttempted ? "attempted" : "not attempted")} and failed: {start.Message}",
SubscriptionSummary = subscription.Summary,
MemberCount = subscription.Members.Count,
WriteStepCount = start.WriteSteps.Count,
UsedDynamicDataSet = true,
DynamicAttempted = attempt.DynamicAttempted,
DynamicAttemptState = attempt.DynamicAttemptState.ToString(),
FailureReason = attempt.FailureReason.ToString(),
PollingFallbackReason = attempt.DynamicAttempted ? "DynamicActivationFailed" : "DynamicActivationNotAttempted",
CleanupAttempted = attempt.CleanupAttempted,
CleanupSucceeded = attempt.CleanupSucceeded,
CoveredReferences = coveredReferences,
Warnings = warnings
};
}

appPlan.EngineAcquisitionKind = dynamicSegment.Kind.ToString();
appPlan.AllowDynamicDataSetWrites = true;
appPlan.Buffered = dynamicSegment.Kind == ArMms.MmsHybridAcquisitionKind.DynamicBrcb;
appPlan.Mode = $"ARIEC Hybrid • {dynamicSegment.Kind} • P4 static recovery";
appPlan.Status = $"{dynamicSegment.Kind} active after static recovery";
if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference))
appPlan.ReportControlReference = start.Session.ReportControl.Reference;
if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference))
appPlan.DataSetReference = start.Session.Plan.DataSetReference;

_reportMonitorSessions[appPlan.PlanId] = start.Session;
_reportMonitorCoverage[appPlan.PlanId] = coveredReferences;

return new NativeReportMonitorStartResult
{
IsSuccess = true,
IsSuccess = false,
PlanId = appPlan.PlanId,
Message = $"ARIEC P4 recovered failed static acquisition with {dynamicSegment.Kind}. {start.Message}",
SubscriptionSummary = subscription.Summary,
MemberCount = subscription.Members.Count,
WriteStepCount = start.WriteSteps.Count,
UsedDynamicDataSet = true,
DynamicAttempted = true,
DynamicAttemptState = attempt.DynamicAttemptState.ToString(),
FailureReason = string.Empty,
ReportControlReference = appPlan.ReportControlReference,
DataSetReference = appPlan.DataSetReference,
AcquisitionLabel = $"ARIEC Hybrid: {dynamicSegment.Kind} (P4 recovery)",
CoveredReferences = coveredReferences,
Warnings = warnings
};
Message = $"{staticFailure} P6.1 preserved baseline static-failure isolation: no dynamic DataSet/RCB write was attempted; bounded MMS polling remains active for this affected signal set.",
UsedDynamicDataSet = false,
DynamicAttempted = false,
DynamicAttemptState = "Skipped",
FailureReason = "StaticActivationFailed",
PollingFallbackReason = "StaticActivationFailed"
});
}

private static bool IsStaticHybridKind(ArMms.MmsHybridAcquisitionKind kind)
Expand Down
27 changes: 26 additions & 1 deletion Services/NativeIec61850Client.HybridReporting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ public async Task<NativeHybridReportPlanningResult> BuildHybridReportPlansAsync(
reportPlans.Add(appPlan);
}

var activationPlans = OrderHybridActivationPlans(reportPlans);

var polling = enginePlan.Assignments
.Where(assignment => assignment.Kind == ArMms.MmsHybridAcquisitionKind.MmsPollingFallback)
.Select(assignment => FindPointKeyForAssignment(assignment.SignalReference, descriptorPoints))
Expand Down Expand Up @@ -280,6 +282,11 @@ public async Task<NativeHybridReportPlanningResult> BuildHybridReportPlansAsync(
p6Warnings.Add(
$"P6 static inventory bridge mapped {staticInventoryMappedCount} selected point(s) through ARIEC mandatory DataSet member evidence before broad catalog matching.");
}
if (activationPlans.Count > 1 && activationPlans[0].AllowDynamicDataSetWrites && activationPlans.Any(plan => !plan.AllowDynamicDataSetWrites))
{
p6Warnings.Add(
"P6.1 mixed-plan safety: dynamic DataSet mutation is probationed before static RCB activation so a relay-aborting dynamic write cannot orphan an already-armed static RCB. Static coverage priority is unchanged.");
}
if (dynamicWriteCircuitOpen)
{
p6Warnings.Add(
Expand All @@ -304,7 +311,7 @@ public async Task<NativeHybridReportPlanningResult> BuildHybridReportPlansAsync(
Summary = $"{enginePlan.Summary} {associationCapability.Summary}" +
(staticInventoryMappedCount > 0 ? $" Static inventory bridge={staticInventoryMappedCount}." : string.Empty) +
(dynamicWriteCircuitOpen ? " Dynamic writes circuit-broken after field failure evidence." : string.Empty),
ReportPlans = reportPlans,
ReportPlans = activationPlans,
PollingPointKeys = polling,
UncoveredPointKeys = uncovered,
UnmappedPointKeys = unmapped.Select(point => point.PointKey).ToArray(),
Expand All @@ -321,6 +328,24 @@ public async Task<NativeHybridReportPlanningResult> BuildHybridReportPlansAsync(
};
}

internal static IReadOnlyList<ReportControlPlan> OrderHybridActivationPlans(IEnumerable<ReportControlPlan> plans)
{
var materialized = plans.ToArray();
var hasDynamic = materialized.Any(plan => plan.IsEngineAuthoritative && plan.AllowDynamicDataSetWrites);
var hasStatic = materialized.Any(plan => plan.IsEngineAuthoritative && !plan.AllowDynamicDataSetWrites);
if (!hasDynamic || !hasStatic)
return materialized;

// Coverage precedence remains static -> dynamic -> polling. This ordering controls
// only activation side effects. A dynamic DefineNamedVariableList write is the risky
// operation on IEDs that abort the association. Run that probation before arming the
// already-proven static RCB so a failed dynamic attempt cannot leave that static RCB
// transiently enabled/owned and then unavailable to the immediate reconnect.
return materialized
.OrderByDescending(plan => plan.IsEngineAuthoritative && plan.AllowDynamicDataSetWrites)
.ToArray();
}

public async Task<NativeReportMonitorStartResult> StartHybridReportMonitorAsync(
ReportControlPlan plan,
CancellationToken cancellationToken)
Expand Down
6 changes: 3 additions & 3 deletions engines/ARIEC61850.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
"commit": "2a932e183931eb65c775fe01cf8a47bf8a9af458",
"sourcePullRequest": 86,
"purpose": "Pins the ARIEC61850 engine used by ARSAS. 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 resolved runtime primary leaves from the 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 so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, and PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation."
"commit": "249fb130e0e18e7a98e07e8894f24610bdb5642e",
"sourcePullRequest": 89,
"purpose": "Pins the ARIEC61850 engine used by ARSAS. 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 resolved runtime primary leaves from the 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 so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation, PR #88 adds a fail-closed single-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList probation before any dynamic RCB mutation while preserving exact invokeID, request/response BER, routing, member, DataSet, association-state, and cleanup evidence for field root-cause analysis, and PR #89 quarantines automatic full dynamic DataSet activation after field evidence that a successful one-member NVL probation does not guarantee association survival, while preserving static RCB eligibility and adding fail-closed projection of field-observed two-process-value MX FCD structures into explicit instMag/mag or instCVal/cVal leaves with shared decoded quality/timestamp; ambiguous structures remain raw and MMS verification remains authoritative."
}
Loading
Loading