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
13 changes: 13 additions & 0 deletions .github/workflows/scl-interoperability-r7.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ on:
- "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md"
- "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs"
- "tests/ARSAS.Tests/CanonicalSclReloadValidatorTests.cs"
- "scripts/verify-p7-dual-ingress-diagnostic.py"
- "scripts/test-p7-dual-ingress-diagnostic.py"
- ".github/workflows/scl-interoperability-r7.yml"
workflow_dispatch:

Expand Down Expand Up @@ -48,6 +50,17 @@ jobs:
}
"ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append

- name: Set up Python for read-only physical-intake guard
uses: actions/setup-python@v6
with:
python-version: '3.12'

- name: P7.6 verify field diagnostic parser safety fixtures
shell: pwsh
run: |
python .\ARSAS\scripts\test-p7-dual-ingress-diagnostic.py
if ($LASTEXITCODE -ne 0) { throw 'P7.6 diagnostic qualification safety fixtures failed.' }

- name: Resolve current engine while preserving R7/R10 authority
shell: pwsh
run: |
Expand Down
37 changes: 37 additions & 0 deletions docs/audits/P7_6_FIELD_QUALIFICATION_INTAKE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# P7.6 — Read-only dual-ingress field diagnostic intake (draft)

Parent: issue #446, stacked after draft P7.5 PR #476. No Studio runtime, MMS engine, IED association, RCB planner, native installer, physical R10 or release authority changes.

## Purpose

P7.3 records semantic Discovery/Open-SCL fingerprints; P7.4 separately tracks actual routed values on each exact static BRCB and URCB target. The native Studio Copy Diagnostic truncates individual RCB target lines to eight per ingress. Semantic MATCH alone is not traffic proof.

This milestone adds a fail-closed local validator of one **full native Studio Copy Diagnostic** for exactly one IED. It requires a matching SHA-256 fingerprint in both independent ingress summaries, explicit dual-ingress traffic qualification, nonzero selected static BRCB and URCB coverage, zero uncovered signals, an unambiguous complete per-ingress target inventory, accepted ROUTED status per distinct exact RCB, and a timezone-aware first routed timestamp. It rejects missing/duplicate IED sections, emergency reports, mismatched ingresses, empty or ambiguous target identities, any target inventory truncated beyond eight and wrong selected AccessPoint.

Its JSON output is explicitly DIAGNOSTIC_CONSISTENCY_PASS_NOT_PHYSICAL_VERIFIED. Both physicalVerification and wirePcapVerification are **false** regardless of an otherwise valid diagnostic. Offline text can be copied or forged; this validator is not independent wire or physical verification.

## Local 7SX85 field procedure

1. Use the live IED Discovery IP path and choose a Static DataSet report-only selection covering BRCB and URCB. Wait for genuine routed process values on all configured targets, not only RptEna/GI.
2. Open the same SCD, choose the exact MMS AccessPoint J or F from the existing native WPF control, reconnect using a fresh MMS association, and repeat. Do not use cyclic MMS process polling as a reporting fallback.
3. Save the **full Copy Diagnostic** only after both ingresses have run in that device session. Keep customer names, endpoints, addresses and the original unredacted report outside the repository.
4. Run locally with the actual IED and case-specific AP and thresholds:

python scripts/verify-p7-dual-ingress-diagnostic.py --diagnostic ./private-diagnostic.txt --ied GR_X_7SX85 --selected-ap F --min-brcb-signals 6 --min-urcb-signals 6 --output ./private-result.json

5. Independently review **real MMS/PCAP** for both APs and both planned report families; inspect association/RCB/DataSet, accepted InformationReports, timestamps and absence of cyclic MMS process polling. Securely record source/engine SHA and PCAP provenance. The offline tool cannot assert physical identity, packet origin, or actual AP J/F connectivity.

## Anti-naive acceptance limitations

- The source-native format must include exactly one explicit MATCH and DUAL INGRESS TRAFFIC PROVEN for the exact IED, plus two different ingress identities. It does not rely on matching indexed RCB instance names across different associations.
- The tool enumerates all reported concrete targets and demands equality with the planning count. Because Copy Diagnostic intentionally prints at most eight, it refuses any larger target plan rather than accepting a hidden or partial list.
- Minimum BRCB and URCB signal thresholds are caller-supplied. For a 6+6 physical scenario use 6 and 6; never infer missing signals.
- It cannot verify all APs were physically tested, packet-level causality or real-time freshness. No output authorizes merge, release, engine promotion, or retirement of R10 field authority.

## Tests, ownership and rollback

The new scripts/verify-p7-dual-ingress-diagnostic.py is read-only and uses Python standard library. The companion scripts/test-p7-dual-ingress-diagnostic.py has **16 sanitized negative/positive fixtures** and makes no network call. An early check in the existing SCL Interoperability R7 Windows CI runs these fixtures, with no new workflow and no additional native/.NET build.

Implementation lives in a new stacked **draft child PR** based on the exact P7.5 head. It does not commit onto P7.5 or other threads' branches. Software CI alone is CodeVerified only. The draft must not be merged into main until separately authorized physical test evidence is accepted.

Rollback: remove only the two scripts, this document and the small R7 path/fixture step; never change runtime/engine code to accommodate a diagnostic-only test.
130 changes: 130 additions & 0 deletions scripts/test-p7-dual-ingress-diagnostic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Sanitized P7.6 fail-closed qualification fixtures; no device/network access."""
from __future__ import annotations

import importlib.util
from pathlib import Path
import unittest

SCRIPT = Path(__file__).with_name("verify-p7-dual-ingress-diagnostic.py")
spec = importlib.util.spec_from_file_location("p7_qualifier", SCRIPT)
assert spec and spec.loader
qualifier = importlib.util.module_from_spec(spec)
spec.loader.exec_module(qualifier)
SHA = "a" * 64
STAMP = "2026-10-08T10:01:02.0000000+00:00"


def ingress(label: str, runtime: str, *, fingerprint: str = SHA,
brcb: int = 6, urcb: int = 6,
planned: int = 2, routed: int = 2,
first: str = STAMP, last_status: str = "ROUTED") -> str:
return (
f" {label} : {runtime}: fingerprint={fingerprint}, requested=12, "
f"plans={planned}, staticBRCB={brcb}, staticURCB={urcb}, uncovered=0\n"
f" traffic : {routed}/{planned} exact static RCB target(s) routed; first={first}\n"
f" runtime : static-brcb|dataset=ied/lln0.events|rcb=ied/lln0.br.rpt_ind01|bindings=6 [ROUTED]\n"
f" runtime : static-urcb|dataset=ied/lln0.analog|rcb=ied/lln0.rp.rpt_meas01|bindings=6 [{last_status}]\n"
)


def diagnostic(*, discovery: str | None = None,
scl: str | None = None, traffic: bool = True, ap: str = "F") -> str:
routed = "DUAL INGRESS TRAFFIC PROVEN (routed static RCB data from both paths)" if traffic else "TRAFFIC PENDING"
return (
"ARSAS Diagnostic Report\n"
"IED : GR_X_7SX85\n"
"Endpoint : 198.51.100.12:102\n"
f"SCD selected AP : {ap}\n"
f"Static parity : MATCH • {SHA}\n"
f"Routed traffic : {routed}\n"
+ (discovery if discovery is not None else ingress("Discovery", "LiveDiscovery"))
+ (scl if scl is not None else ingress("Open SCL ", "OpenScl"))
)


class QualificationTests(unittest.TestCase):
def assertNotQualified(self, text: str, message: str, **kwargs) -> None:
with self.assertRaisesRegex(qualifier.QualificationError, message):
qualifier.qualify(text, ied="GR_X_7SX85", **kwargs)

def test_exact_two_ingresses_both_brcb_urcb_with_source_identity(self):
result = qualifier.qualify(diagnostic(), ied="GR_X_7SX85",
min_brcb=6, min_urcb=6, selected_ap="F")
self.assertFalse(result["physicalVerification"])
self.assertFalse(result["wirePcapVerification"])
self.assertIn("NOT_PHYSICAL_VERIFIED", result["qualification"])
self.assertEqual(result["discovery"]["plannedTargets"], 2)
self.assertEqual(result["openScl"]["routedTargets"], 2)

def test_semantic_match_alone_does_not_qualify(self):
self.assertNotQualified(diagnostic(traffic=False), "dual-ingress routed traffic proof")

def test_single_ingress_does_not_qualify(self):
self.assertNotQualified(diagnostic(scl=""), "Both independent ingress")

def test_pending_target_does_not_qualify(self):
self.assertNotQualified(
diagnostic(scl=ingress("Open SCL ", "OpenScl", last_status="PENDING")),
"unrouted")

def test_forged_count_or_truncated_inventory_does_not_qualify(self):
self.assertNotQualified(
diagnostic(scl=ingress("Open SCL ", "OpenScl", planned=9, routed=9)),
"incomplete/truncated")

def test_brcb_only_is_not_urcb_proof(self):
self.assertNotQualified(diagnostic(scl=ingress("Open SCL ", "OpenScl")
.replace("static-urcb|", "static-brcb|")
.replace("rpt_meas01", "rpt_ind02")), "both BRCB and URCB")

def test_mismatched_semantic_sha_is_rejected(self):
self.assertNotQualified(
diagnostic(scl=ingress("Open SCL ", "OpenScl", fingerprint="b" * 64)),
"fingerprint does not match")

def test_wrong_ied_is_rejected(self):
with self.assertRaisesRegex(qualifier.QualificationError, "Exact IED identity"):
qualifier.qualify(diagnostic(), ied="DIFFERENT")

def test_two_sections_cannot_borrow_traffic_from_another_ied(self):
text = (diagnostic(scl="") +
"IED : OTHER_IED\n" +
ingress("Open SCL ", "OpenScl"))
self.assertNotQualified(text, "Both independent ingress")

def test_duplicate_ied_block_is_rejected(self):
self.assertNotQualified(diagnostic() + diagnostic(), "IED identity")

def test_selected_ap_must_be_source_exact(self):
self.assertNotQualified(diagnostic(), "AccessPoint", selected_ap="J")

def test_missing_timestamp_is_not_traffic_proof(self):
self.assertNotQualified(
diagnostic(scl=ingress("Open SCL ", "OpenScl", first="pending")),
"timestamp")

def test_duplicate_target_is_rejected(self):
target = "static-urcb|dataset=ied/lln0.analog|rcb=ied/lln0.rp.rpt_meas01"
dup = ingress("Open SCL ", "OpenScl").replace(target,
"static-brcb|dataset=ied/lln0.events|rcb=ied/lln0.br.rpt_ind01")
self.assertNotQualified(diagnostic(scl=dup), "duplicate RCB target")

def test_incomplete_or_emergency_report_not_accepted(self):
self.assertNotQualified(
diagnostic().replace("ARSAS Diagnostic Report", "ARSAS Emergency Diagnostic Report"),
"Full Copy Diagnostic")

def test_explicit_6_signal_threshold_is_not_downgraded(self):
self.assertNotQualified(diagnostic(scl=ingress("Open SCL ", "OpenScl", brcb=5)),
"insufficient static", min_brcb=6, min_urcb=6)

def test_missing_full_count_even_if_summary_says_proven(self):
self.assertNotQualified(
diagnostic(discovery=ingress("Discovery", "LiveDiscovery").split(
" runtime : static-urcb|")[0]),
"incomplete/truncated")


if __name__ == "__main__":
unittest.main()
Loading