From 409048c5bf16efb67f97233d9d875760ac477f67 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 04:19:36 +0700 Subject: [PATCH 01/99] ci(p0): stabilize workflow topology and freeze CI-sprawl baseline Add deterministic workflow inventory, synthetic corruption tests, a reviewed CI-sprawl budget, and a lightweight workflow-integrity guard. No existing workflow is retired and no IEC 61850, physical-evidence, release, or runtime semantics are changed. --- .../workflows/ci-p0-workflow-integrity.yml | 74 +++++ docs/audits/CI_P0_WORKFLOW_BASELINE.md | 149 +++++++++ evidence/ci-workflow-budget.json | 36 +++ scripts/audit-ci-workflows.py | 302 ++++++++++++++++++ scripts/test-ci-workflows.py | 201 ++++++++++++ 5 files changed, 762 insertions(+) create mode 100644 .github/workflows/ci-p0-workflow-integrity.yml create mode 100644 docs/audits/CI_P0_WORKFLOW_BASELINE.md create mode 100644 evidence/ci-workflow-budget.json create mode 100644 scripts/audit-ci-workflows.py create mode 100644 scripts/test-ci-workflows.py diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml new file mode 100644 index 000000000..a54c8f313 --- /dev/null +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -0,0 +1,74 @@ +name: CI P0 Workflow Integrity + +on: + push: + branches: [ main ] + paths: + - ".github/workflows/**" + - "scripts/audit-ci-workflows.py" + - "scripts/test-ci-workflows.py" + - "evidence/ci-workflow-budget.json" + - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" + pull_request: + paths: + - ".github/workflows/**" + - "scripts/audit-ci-workflows.py" + - "scripts/test-ci-workflows.py" + - "evidence/ci-workflow-budget.json" + - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-p0-workflow-integrity-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + workflow-integrity: + name: Validate workflow structure and CI-sprawl budget + runs-on: ubuntu-latest + steps: + - name: Checkout exact candidate + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Verify standard-library Python is available + run: python3 --version + + - name: Run offline CI workflow guard tests + run: python3 scripts/test-ci-workflows.py + + - name: Validate tracked workflow topology and budget + run: | + python3 scripts/audit-ci-workflows.py \ + --root . \ + --budget evidence/ci-workflow-budget.json \ + --output "${RUNNER_TEMP}/ci-workflow-inventory.json" + + - name: Summarize CI topology + shell: bash + run: | + python3 - <<'PY' + import json + import os + from pathlib import Path + + report = json.loads( + (Path(os.environ["RUNNER_TEMP"]) / "ci-workflow-inventory.json").read_text() + ) + totals = report["totals"] + summary = Path(os.environ["GITHUB_STEP_SUMMARY"]) + with summary.open("a", encoding="utf-8") as handle: + handle.write("## CI-P0 workflow inventory\n\n") + handle.write(f"- workflow files: {totals['workflowFiles']}\n") + handle.write(f"- .NET restore/build/test: {totals['dotnetRestore']}/{totals['dotnetBuild']}/{totals['dotnetTest']}\n") + handle.write(f"- setup-dotnet uses: {totals['actionsSetupDotnet']}\n") + handle.write(f"- checkout uses: {totals['actionsCheckout']}\n") + handle.write(f"- workflows containing .NET build: {totals['workflowsWithDotnetBuild']}\n") + handle.write(f"- workflows containing .NET test: {totals['workflowsWithDotnetTest']}\n") + handle.write("\nNo structural or budget violation was detected.\n") + PY diff --git a/docs/audits/CI_P0_WORKFLOW_BASELINE.md b/docs/audits/CI_P0_WORKFLOW_BASELINE.md new file mode 100644 index 000000000..661e66d05 --- /dev/null +++ b/docs/audits/CI_P0_WORKFLOW_BASELINE.md @@ -0,0 +1,149 @@ +# CI-P0 — Workflow Stabilization Baseline + +Issue: #426 + +CI-P0 freezes the current GitHub Actions estate before any consolidation. It is deliberately non-semantic: no IEC 61850 runtime path, physical acceptance rule, release authority, or domain test contract is removed or weakened in this phase. + +## Why P0 exists + +The repository has accumulated many independently triggered workflows that repeat checkout, .NET setup, restore, build and test work. The failure mode is not only slower CI. Duplicated policy implementations drift, and an unrelated authority update can make several pipelines disagree about the same source of truth. + +The P0 goal is therefore: + +> make current CI topology observable, deterministic and corruption-resistant before optimizing it. + +## Main baseline + +Source: `71e8d7e864b22c2f2146e5f8576cef0126c5f6e7`. + +Observed before adding the CI-P0 integrity workflow: + +| Indicator | Count | +| --- | ---: | +| Tracked workflow files | 28 | +| `actions/checkout` uses | 31 | +| `actions/setup-dotnet` uses | 12 | +| `actions/setup-python` uses | 13 | +| `dotnet restore` invocations | 15 | +| `dotnet build` invocations | 15 | +| `dotnet test` invocations | 17 | +| `actions/upload-artifact` uses | 33 | +| Workflows containing a .NET build | 12 | +| Workflows containing a .NET test | 11 | +| Reusable `workflow_call` entry points | 0 | + +These are textual cost indicators, not runtime-duration measurements. They intentionally over-simplify complex workflows so growth and consolidation can be reviewed consistently. + +## CI-P0 guard + +The tracked budget lives in: + +`evidence/ci-workflow-budget.json` + +The deterministic inventory lives in: + +`scripts/audit-ci-workflows.py` + +Offline regression tests live in: + +`scripts/test-ci-workflows.py` + +The guard performs two independent checks: + +1. **Structural integrity** + - exactly one top-level workflow `name`; + - exactly one top-level `jobs` block; + - no duplicate job IDs; + - no duplicate step names inside the same job. + + Duplicate step names across different jobs remain legal. This catches the class of accidental copy/paste corruption that duplicated large sections of the R7 workflow without pretending to replace GitHub's YAML parser. + +2. **CI-sprawl budget** + - new workflows/builds/tests/checkouts cannot silently increase the frozen maxima; + - a deliberate increase must update the budget in the same reviewed change; + - reductions never require a budget increase. + +The P0 workflow itself is intentionally cheap: one checkout plus standard-library Python. It performs no .NET restore/build/test and uploads no artifact. + +## Ownership map + +### Core build / package + +- `.github/workflows/build.yml` — canonical PR/main Windows build, regression suite, portable EXE. +- `.github/workflows/installer-windows.yml` — installer-specific validation. +- `.github/workflows/release-windows.yml` — release-only Windows packaging and publication path. + +P0 does not merge these responsibilities yet. + +### IEC 61850 / SCL / physical authority + +- `interoperability-reference-guard.yml` +- `scl-interoperability-r7.yml` +- `smart-discovery-capture-build.yml` +- `smart-discovery-golden-budget-lock.yml` +- `smart-discovery-golden-provenance.yml` +- `smart-discovery-mainline-readiness.yml` +- `smart-discovery-merge-execution-guard.yml` +- `smart-discovery-post-merge-production.yml` +- `smart-discovery-production-promotion.yml` +- `smart-discovery-repeat-run-stability.yml` +- `rcb-export-guard.yml` + +These workflows currently encode multiple layers of historical, physical and promotion authority. P0 records them unchanged. P1/P2 may consolidate execution only after equivalent outcomes are proven. + +### Focused technical gates + +- `validate-io-testing.yml` +- `validate-sv-evidence.yml` +- `comtrade-viewer-integration.yml` +- `progressive-static-bench.yml` + +### Product site / adoption / measurement + +- `pages.yml` +- `adoption-proof.yml` +- `measurement-contract.yml` +- `search-growth.yml` +- `site-measurement.yml` +- `production-health.yml` + +### Release metadata / supply chain + +- `publish-verified-release.yml` +- `release-supply-chain.yml` +- `sync-release-documentation.yml` +- `sync-release-evidence.yml` + +## P0 invariants + +P0 must not: + +- retire an existing workflow; +- weaken a required physical or release gate; +- change engine/runtime source; +- reinterpret historical acceptance evidence; +- make one current engine pin silently replace a historical tested baseline; +- change product behavior merely to satisfy CI. + +P0 may: + +- add read-only inventory; +- add corruption detection; +- freeze duplicate-CI growth; +- document ownership and dependencies; +- restore a workflow to its known-good baseline when accidental text corruption is proven. + +## Handoff to CI-P1 + +CI-P1 should introduce the first reusable primitives and a single fast PR gate, but only after P0 is merged and stable. + +Recommended P1 order: + +1. reusable immutable engine resolver; +2. reusable .NET restore/build/test primitive; +3. one PR change classifier; +4. artifact reuse instead of repeated compilation; +5. repository-wide PR concurrency/cancellation; +6. shadow comparison before retiring any legacy workflow. + +The P0 budget becomes the before-state. P1 is successful when the expensive counts decrease while semantic guards remain equivalent. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json new file mode 100644 index 000000000..cae9d3171 --- /dev/null +++ b/evidence/ci-workflow-budget.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", + "baselineName": "CI-P0 workflow-sprawl freeze", + "policy": "These are maximum textual CI-cost indicators after adding the lightweight CI-P0 integrity workflow. Raising a limit requires an explicit reviewed budget change. Lowering a limit is always allowed when workflows are consolidated.", + "preP0Observed": { + "workflowFiles": 28, + "actionsCheckout": 31, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11, + "workflowCall": 0 + }, + "limits": { + "workflowFiles": 29, + "actionsCheckout": 32, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "interpretation": [ + "The limits freeze CI sprawl; they are not performance targets and do not prove semantic necessity.", + "CI-P1 is expected to reduce restore/build/test counts and introduce reusable workflow_call contracts. Any temporary migration increase must be explicit in the same reviewed change.", + "Release, physical evidence and IEC 61850 acceptance semantics remain owned by their existing contracts during CI-P0." + ] +} diff --git a/scripts/audit-ci-workflows.py b/scripts/audit-ci-workflows.py new file mode 100644 index 000000000..5ca972771 --- /dev/null +++ b/scripts/audit-ci-workflows.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Deterministic, read-only inventory and integrity guard for GitHub Actions workflows. + +CI-P0 deliberately avoids a YAML dependency. It inspects the stable structural subset +used by this repository: tracked workflow files, top-level workflow keys, job IDs, +step names and expensive CI primitives. GitHub remains the YAML parser of record. + +The guard does not decide whether an existing workflow is semantically necessary. +It prevents accidental workflow duplication/corruption and makes CI-cost growth +explicit through a reviewed budget file. +""" +from __future__ import annotations + +import argparse +import collections +import json +import re +import subprocess +from pathlib import Path +from typing import Iterable + +WORKFLOW_PREFIX = ".github/workflows/" +WORKFLOW_SUFFIXES = (".yml", ".yaml") + +PRIMITIVES = { + "actionsCheckout": "actions/checkout@", + "actionsSetupDotnet": "actions/setup-dotnet@", + "actionsSetupPython": "actions/setup-python@", + "actionsUploadArtifact": "actions/upload-artifact@", + "actionsDownloadArtifact": "actions/download-artifact@", + "dotnetRestore": "dotnet restore", + "dotnetBuild": "dotnet build", + "dotnetTest": "dotnet test", + "dotnetPublish": "dotnet publish", + "workflowCall": "workflow_call:", + "concurrencyBlocks": "concurrency:", +} + + +def git(root: Path, *args: str) -> bytes: + return subprocess.check_output( + ("git", "-C", str(root), *args), + stderr=subprocess.PIPE, + ) + + +def tracked_paths(root: Path) -> list[str]: + return sorted( + { + item.decode("utf-8", "surrogateescape").replace("\\", "/") + for item in git(root, "ls-files", "-z").split(b"\0") + if item + } + ) + + +def workflow_paths(root: Path) -> list[str]: + return [ + path + for path in tracked_paths(root) + if path.startswith(WORKFLOW_PREFIX) + and Path(path).suffix.lower() in WORKFLOW_SUFFIXES + ] + + +def events(content: str) -> list[str]: + found: set[str] = set() + in_on = False + for line in content.splitlines(): + if re.match(r"^on:\s*(?:#.*)?$", line): + in_on = True + continue + inline = re.match(r"^on:\s*\[([^]]+)\]", line) + if inline: + found.update( + item.strip().strip("'\"") + for item in inline.group(1).split(",") + if item.strip() + ) + break + if in_on and line and not line[0].isspace() and not line.lstrip().startswith("#"): + break + if in_on: + match = re.match(r"^ ([\w-]+):(?:\s|$)", line) + if match: + found.add(match.group(1)) + return sorted(found) + + +def primitive_counts(content: str) -> dict[str, int]: + return {name: content.count(token) for name, token in PRIMITIVES.items()} + + +def structure_findings(path: str, content: str) -> tuple[dict, list[str]]: + lines = content.splitlines() + errors: list[str] = [] + + top_name_count = sum(1 for line in lines if re.match(r"^name:\s*\S", line)) + jobs_count = sum(1 for line in lines if re.match(r"^jobs:\s*(?:#.*)?$", line)) + if top_name_count != 1: + errors.append( + f"{path}: expected exactly one top-level workflow name, found {top_name_count}." + ) + if jobs_count != 1: + errors.append(f"{path}: expected exactly one top-level jobs block, found {jobs_count}.") + + in_jobs = False + current_job: str | None = None + job_ids: list[str] = [] + step_names: dict[str, list[str]] = collections.defaultdict(list) + + for line in lines: + if re.match(r"^jobs:\s*(?:#.*)?$", line): + in_jobs = True + current_job = None + continue + + if in_jobs and line and not line[0].isspace() and not line.lstrip().startswith("#"): + in_jobs = False + current_job = None + + if not in_jobs: + continue + + job_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$", line) + if job_match: + current_job = job_match.group(1) + job_ids.append(current_job) + continue + + if current_job is None: + continue + + step_match = re.match(r"^\s{6}-\s+name:\s*(.+?)\s*$", line) + if step_match: + raw_name = step_match.group(1).strip().strip("'\"") + step_names[current_job].append(raw_name) + + duplicate_jobs = sorted( + job for job, count in collections.Counter(job_ids).items() if count > 1 + ) + for job in duplicate_jobs: + errors.append(f"{path}: duplicate job id '{job}'.") + + duplicate_steps: dict[str, list[str]] = {} + for job, names in step_names.items(): + duplicates = sorted( + name for name, count in collections.Counter(names).items() if count > 1 + ) + if duplicates: + duplicate_steps[job] = duplicates + for name in duplicates: + errors.append( + f"{path}: job '{job}' contains duplicate step name '{name}'." + ) + + return { + "topLevelNameCount": top_name_count, + "topLevelJobsCount": jobs_count, + "jobIds": job_ids, + "duplicateJobIds": duplicate_jobs, + "duplicateStepNamesByJob": duplicate_steps, + }, errors + + +def inventory(root: Path) -> dict: + workflows: list[dict] = [] + structure_errors: list[str] = [] + totals = collections.Counter() + + for path in workflow_paths(root): + full = root / path + if not full.is_file(): + raise FileNotFoundError(f"Tracked workflow is missing: {path}") + content = full.read_text(encoding="utf-8-sig", errors="replace") + + metrics = primitive_counts(content) + structure, errors = structure_findings(path, content) + structure_errors.extend(errors) + totals.update(metrics) + + workflows.append( + { + "path": path, + "events": events(content), + "metrics": metrics, + "structure": structure, + } + ) + + totals["workflowFiles"] = len(workflows) + totals["workflowsWithDotnetBuild"] = sum( + 1 for workflow in workflows if workflow["metrics"]["dotnetBuild"] > 0 + ) + totals["workflowsWithDotnetTest"] = sum( + 1 for workflow in workflows if workflow["metrics"]["dotnetTest"] > 0 + ) + totals["pullRequestWorkflows"] = sum( + 1 for workflow in workflows if "pull_request" in workflow["events"] + ) + + return { + "schemaVersion": 1, + "sourceCommit": git(root, "rev-parse", "HEAD").decode("ascii").strip(), + "scope": ( + "Tracked GitHub Actions workflows only. Counts are textual CI-cost indicators; " + "they do not prove runtime duration or semantic necessity." + ), + "totals": dict(sorted(totals.items())), + "structureErrors": sorted(structure_errors), + "workflows": workflows, + } + + +def load_budget(path: Path) -> dict: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schemaVersion") != 1: + raise ValueError("Unsupported CI workflow budget schema.") + if not isinstance(data.get("limits"), dict): + raise ValueError("CI workflow budget is missing 'limits'.") + return data + + +def budget_violations(report: dict, budget: dict) -> list[str]: + violations: list[str] = [] + totals = report["totals"] + for metric, limit in sorted(budget["limits"].items()): + if metric not in totals: + violations.append(f"Budget references unknown metric '{metric}'.") + continue + actual = int(totals[metric]) + maximum = int(limit) + if actual > maximum: + violations.append( + f"CI budget exceeded: {metric}={actual}, allowed maximum={maximum}." + ) + return violations + + +def evaluate(root: Path, budget_path: Path | None = None) -> dict: + report = inventory(root) + budget_errors: list[str] = [] + if budget_path is not None: + budget = load_budget(budget_path) + budget_errors = budget_violations(report, budget) + report["budget"] = { + "path": str(budget_path).replace("\\", "/"), + "baselineSourceCommit": budget.get("baselineSourceCommit", ""), + "violations": budget_errors, + } + else: + report["budget"] = {"violations": []} + + report["isHealthy"] = not report["structureErrors"] and not budget_errors + return report + + +def print_summary(report: dict) -> None: + totals = report["totals"] + print( + "CI workflow inventory: " + f"workflows={totals.get('workflowFiles', 0)}, " + f"dotnetBuild={totals.get('dotnetBuild', 0)}, " + f"dotnetTest={totals.get('dotnetTest', 0)}, " + f"dotnetRestore={totals.get('dotnetRestore', 0)}, " + f"setupDotnet={totals.get('actionsSetupDotnet', 0)}, " + f"checkout={totals.get('actionsCheckout', 0)}" + ) + for error in report["structureErrors"]: + print("STRUCTURE ERROR: " + error) + for error in report["budget"].get("violations", []): + print("BUDGET ERROR: " + error) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parent.parent, + ) + parser.add_argument("--budget", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + root = args.root.resolve() + budget = args.budget.resolve() if args.budget else None + report = evaluate(root, budget) + payload = json.dumps(report, indent=2, ensure_ascii=False) + "\n" + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload, encoding="utf-8") + + print_summary(report) + if not report["isHealthy"]: + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-ci-workflows.py b/scripts/test-ci-workflows.py new file mode 100644 index 000000000..47fa8554d --- /dev/null +++ b/scripts/test-ci-workflows.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Offline tests for the CI-P0 workflow inventory and integrity guard.""" +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).with_name("audit-ci-workflows.py") +spec = importlib.util.spec_from_file_location("arsas_ci_inventory", SOURCE) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class CiWorkflowInventoryTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + subprocess.run(["git", "-C", str(self.root), "init", "-q"], check=True) + + def tearDown(self): + self.tmp.cleanup() + + def write(self, path: str, body: str) -> None: + dest = self.root / path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(body, encoding="utf-8") + + def commit(self) -> None: + subprocess.run(["git", "-C", str(self.root), "add", "-A"], check=True) + subprocess.run( + [ + "git", + "-C", + str(self.root), + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=CI P0 Test", + "commit", + "-qm", + "synthetic fixture", + ], + check=True, + ) + + def test_inventory_counts_expensive_primitives_deterministically(self): + self.write( + ".github/workflows/check.yml", + """name: Check +on: + pull_request: + workflow_dispatch: +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup + uses: actions/setup-dotnet@v6 + - name: Build + run: | + dotnet restore App.sln + dotnet build App.sln --no-restore + dotnet test Tests.csproj --no-build +""", + ) + self.commit() + + first = module.inventory(self.root) + second = module.inventory(self.root) + self.assertEqual( + json.dumps(first, sort_keys=True), + json.dumps(second, sort_keys=True), + ) + self.assertEqual(first["totals"]["workflowFiles"], 1) + self.assertEqual(first["totals"]["actionsCheckout"], 1) + self.assertEqual(first["totals"]["actionsSetupDotnet"], 1) + self.assertEqual(first["totals"]["dotnetRestore"], 1) + self.assertEqual(first["totals"]["dotnetBuild"], 1) + self.assertEqual(first["totals"]["dotnetTest"], 1) + self.assertEqual(first["totals"]["workflowsWithDotnetBuild"], 1) + self.assertEqual(first["totals"]["workflowsWithDotnetTest"], 1) + self.assertEqual(first["totals"]["pullRequestWorkflows"], 1) + self.assertEqual(first["structureErrors"], []) + + def test_duplicate_step_name_inside_same_job_is_rejected(self): + self.write( + ".github/workflows/corrupt.yml", + """name: Corrupt +on: + pull_request: +jobs: + build: + runs-on: windows-latest + steps: + - name: Setup .NET + run: echo first + - name: Setup .NET + run: echo duplicate +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertFalse(not report["structureErrors"]) + self.assertTrue( + any("duplicate step name 'Setup .NET'" in item for item in report["structureErrors"]) + ) + + def test_same_step_name_in_different_jobs_is_allowed(self): + self.write( + ".github/workflows/parallel.yml", + """name: Parallel +on: [push, pull_request] +jobs: + first: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + second: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertEqual(report["structureErrors"], []) + self.assertEqual(report["totals"]["actionsCheckout"], 2) + + def test_duplicate_job_id_is_rejected(self): + self.write( + ".github/workflows/duplicate-job.yml", + """name: Duplicate Job +on: + push: +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: First + run: echo first + validate: + runs-on: windows-latest + steps: + - name: Second + run: echo second +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertTrue( + any("duplicate job id 'validate'" in item for item in report["structureErrors"]) + ) + + def test_budget_rejects_new_duplicate_build_cost(self): + self.write( + ".github/workflows/build.yml", + """name: Build +on: + pull_request: +jobs: + build: + runs-on: windows-latest + steps: + - name: Build + run: dotnet build App.sln +""", + ) + self.write( + "budget.json", + json.dumps( + { + "schemaVersion": 1, + "baselineSourceCommit": "synthetic", + "limits": {"dotnetBuild": 0}, + } + ), + ) + self.commit() + + report = module.evaluate(self.root, self.root / "budget.json") + self.assertFalse(report["isHealthy"]) + self.assertIn( + "CI budget exceeded: dotnetBuild=1, allowed maximum=0.", + report["budget"]["violations"], + ) + + +if __name__ == "__main__": + unittest.main() From 7ca3f3e77456af7193eb01f61d4d8df87980f403 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 09:45:10 +0700 Subject: [PATCH 02/99] ci(p1): share immutable engine resolution and opt-in Windows test shadow Introduce conservative source change classification, a reusable exact-SHA engine resolver, and an opt-in Windows restore/build/full-test shadow with typed provenance and fail-closed TRX evidence. Preserve all legacy CI, physical acceptance and release authorities. 10/10 triggered workflow checks passed on the PR merge tree. --- .../actions/resolve-ariec-engine/action.yml | 40 +++ .../workflows/_ci-p1-windows-build-test.yml | 148 ++++++++++++ .../workflows/ci-p0-workflow-integrity.yml | 9 + .github/workflows/ci-p1-shadow.yml | 87 +++++++ docs/audits/CI_P1_SHADOW_CONTRACT.md | 111 +++++++++ evidence/ci-workflow-budget.json | 53 +++- scripts/classify-ci-changes.py | 228 ++++++++++++++++++ scripts/resolve-ariec-lock.ps1 | 101 ++++++++ scripts/test-classify-ci-changes.py | 113 +++++++++ scripts/test-resolve-ariec-lock.ps1 | 86 +++++++ 10 files changed, 964 insertions(+), 12 deletions(-) create mode 100644 .github/actions/resolve-ariec-engine/action.yml create mode 100644 .github/workflows/_ci-p1-windows-build-test.yml create mode 100644 .github/workflows/ci-p1-shadow.yml create mode 100644 docs/audits/CI_P1_SHADOW_CONTRACT.md create mode 100644 scripts/classify-ci-changes.py create mode 100644 scripts/resolve-ariec-lock.ps1 create mode 100644 scripts/test-classify-ci-changes.py create mode 100644 scripts/test-resolve-ariec-lock.ps1 diff --git a/.github/actions/resolve-ariec-engine/action.yml b/.github/actions/resolve-ariec-engine/action.yml new file mode 100644 index 000000000..5d37b8e06 --- /dev/null +++ b/.github/actions/resolve-ariec-engine/action.yml @@ -0,0 +1,40 @@ +name: Resolve immutable ARIEC61850 engine lock +description: Validate the tracked ARIEC61850 exact SHA and expose it without rewriting historical field authority. +inputs: + lock-path: + description: Path to tracked engine lock, relative to the runner workspace + required: false + default: ARSAS/engines/ARIEC61850.lock.json +outputs: + repository: + description: Trusted pinned engine repository + value: ${{ steps.resolve.outputs.repository }} + ref: + description: Canonical repository ref metadata + value: ${{ steps.resolve.outputs.ref }} + commit: + description: Exact immutable integration engine SHA + value: ${{ steps.resolve.outputs.commit }} + physical-tested-commit: + description: Historical physical engine head; never substituted for integration SHA + value: ${{ steps.resolve.outputs.physical_tested_commit }} + merged-main-commit: + description: Historical merged-main engine head + value: ${{ steps.resolve.outputs.merged_main_commit }} + baseline-is-current: + description: Whether integration SHA equals historical merged-main baseline + value: ${{ steps.resolve.outputs.baseline_is_current }} + +runs: + using: composite + steps: + - id: resolve + name: Validate pinned engine lock without remote lookups + shell: pwsh + env: + CI_LOCK_PATH: ${{ inputs.lock-path }} + run: | + $ErrorActionPreference = 'Stop' + $resolver = Join-Path $env:GITHUB_ACTION_PATH '..\..\..\scripts\resolve-ariec-lock.ps1' + $lockPath = Join-Path $env:GITHUB_WORKSPACE $env:CI_LOCK_PATH + & $resolver -LockPath $lockPath -GitHubOutput $env:GITHUB_OUTPUT | Out-Null diff --git a/.github/workflows/_ci-p1-windows-build-test.yml b/.github/workflows/_ci-p1-windows-build-test.yml new file mode 100644 index 000000000..92eaa2e61 --- /dev/null +++ b/.github/workflows/_ci-p1-windows-build-test.yml @@ -0,0 +1,148 @@ +name: Reusable ARSAS Windows Build and Test + +on: + workflow_call: + inputs: + source_sha: + description: Exact ARSAS source SHA (PR merge tree or main commit) + type: string + required: true + shadow_mode: + description: Prevent this primitive from being mistaken for release/promotion authority + type: boolean + default: true + +permissions: + contents: read + +jobs: + windows-build-and-test: + name: Shared exact-SHA Windows compilation and regression + runs-on: windows-latest + timeout-minutes: 45 + steps: + - name: Checkout exact ARSAS source + uses: actions/checkout@v7 + with: + path: ARSAS + ref: ${{ inputs.source_sha }} + persist-credentials: false + fetch-depth: 1 + + - name: Assert source identity and shadow-only execution + shell: pwsh + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + SHADOW_ONLY: ${{ inputs.shadow_mode }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:SHADOW_ONLY -ne 'true') { + throw 'CI-P1 reusable job is not approved as production/release authority.' + } + if ($env:EXPECTED_SOURCE_SHA -cnotmatch '^[0-9a-f]{40}$') { + throw 'CI-P1 source_sha must be an immutable 40-character Git SHA.' + } + $actual = (git -C .\ARSAS rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:EXPECTED_SOURCE_SHA) { + throw "ARSAS source SHA mismatch: expected $env:EXPECTED_SOURCE_SHA, got $actual." + } + + - name: Verify immutable engine resolver synthetic fixtures + shell: pwsh + run: .\ARSAS\scripts\test-resolve-ariec-lock.ps1 + + - id: engine + name: Resolve exact pinned ARIEC61850 engine identity + uses: ./ARSAS/.github/actions/resolve-ariec-engine + with: + lock-path: ARSAS/engines/ARIEC61850.lock.json + + - name: Checkout exact engine SHA + uses: actions/checkout@v7 + with: + repository: ${{ steps.engine.outputs.repository }} + ref: ${{ steps.engine.outputs.commit }} + path: ARIEC61850 + fetch-depth: 1 + persist-credentials: false + + - name: Verify exact engine and immutable history provenance + shell: pwsh + env: + EXPECTED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + run: | + $ErrorActionPreference = 'Stop' + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:EXPECTED_ENGINE_SHA) { + throw "ARIEC61850 pinned checkout drifted: expected $env:EXPECTED_ENGINE_SHA, got $actual." + } + Write-Host "Reproducible compilation authority: ARSAS + pinned ARIEC61850." + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 8.0.x + + - name: Cache immutable NuGet packages only + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ci-p1-nuget-win-x64-${{ hashFiles('ARSAS/**/*.csproj', 'ARIEC61850/**/*.csproj', 'ARSAS/Directory.Packages.props', 'ARIEC61850/Directory.Packages.props') }} + restore-keys: | + ci-p1-nuget-win-x64- + + - name: Restore solution once + run: dotnet restore .\ARSAS\ArIED61850Tester.sln + + - name: Build solution once + run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + + - name: Run full ARSAS regression suite once + run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=ci-p1-shadow.trx" --results-directory .\ARSAS\TestResults + + - name: Write non-promotion build authority manifest + if: always() + shell: pwsh + env: + ENGINE_SHA: ${{ steps.engine.outputs.commit }} + ENGINE_REPOSITORY: ${{ steps.engine.outputs.repository }} + HISTORICAL_ENGINE_SHA: ${{ steps.engine.outputs.merged-main-commit }} + PHYSICAL_TESTED_ENGINE_SHA: ${{ steps.engine.outputs.physical-tested-commit }} + run: | + $ErrorActionPreference = 'Stop' + $sourceSha = (git -C .\ARSAS rev-parse HEAD).Trim().ToLowerInvariant() + $engineActual = if (Test-Path -LiteralPath .\ARIEC61850\.git) { + (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + } else { '' } + $reportFiles = @(Get-ChildItem -Path .\ARSAS\TestResults -Filter '*.trx' -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name) + $manifest = [ordered]@{ + schemaVersion = 1 + kind = 'ci-p1-nonpromotion-shadow' + exactArsasSha = $sourceSha + exactEngineSha = $engineActual + expectedEngineSha = $env:ENGINE_SHA + engineRepository = $env:ENGINE_REPOSITORY + historicalMergedEngineSha = $env:HISTORICAL_ENGINE_SHA + historicalPhysicalTestedSha = $env:PHYSICAL_TESTED_ENGINE_SHA + engineExactShaVerified = ($engineActual -ceq $env:ENGINE_SHA) + testReports = $reportFiles + workflowRunId = $env:GITHUB_RUN_ID + noReleaseOrPromotion = $true + } + $path = Join-Path $env:RUNNER_TEMP 'ci-p1-shadow-manifest.json' + $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path -Encoding UTF8 + if ($manifest.engineExactShaVerified -ne $true -or $reportFiles.Count -eq 0) { + throw 'CI-P1 shadow SHA/TRX proof is incomplete; fail closed.' + } + + - name: Upload inspectable shadow TRX and SHA manifest + if: always() + uses: actions/upload-artifact@v7 + with: + name: ci-p1-shadow-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ARSAS/TestResults/*.trx + ${{ runner.temp }}/ci-p1-shadow-manifest.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index a54c8f313..ec041c0d9 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -7,6 +7,9 @@ on: - ".github/workflows/**" - "scripts/audit-ci-workflows.py" - "scripts/test-ci-workflows.py" + - "scripts/classify-ci-changes.py" + - "scripts/test-classify-ci-changes.py" + - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" pull_request: @@ -14,6 +17,9 @@ on: - ".github/workflows/**" - "scripts/audit-ci-workflows.py" - "scripts/test-ci-workflows.py" + - "scripts/classify-ci-changes.py" + - "scripts/test-classify-ci-changes.py" + - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" workflow_dispatch: @@ -42,6 +48,9 @@ jobs: - name: Run offline CI workflow guard tests run: python3 scripts/test-ci-workflows.py + - name: Run conservative CI P1 classifier fixtures + run: python3 scripts/test-classify-ci-changes.py + - name: Validate tracked workflow topology and budget run: | python3 scripts/audit-ci-workflows.py \ diff --git a/.github/workflows/ci-p1-shadow.yml b/.github/workflows/ci-p1-shadow.yml new file mode 100644 index 000000000..cb522daad --- /dev/null +++ b/.github/workflows/ci-p1-shadow.yml @@ -0,0 +1,87 @@ +name: CI P1 Advisory Classifier and Opt-in Shadow + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + paths: + - ".github/workflows/**" + - ".github/actions/**" + - "scripts/classify-ci-changes.py" + - "scripts/test-classify-ci-changes.py" + - "scripts/resolve-ariec-lock.ps1" + - "scripts/test-resolve-ariec-lock.ps1" + - "engines/**" + - "Services/**" + - "Models/**" + - "tests/**" + - "evidence/**" + - "engine-patches/**" + - "*.cs" + - "*.csproj" + - "*.sln" + - "*.xaml" + - "MainWindow*.cs" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-p1-shadow-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + classify: + name: Classify changed areas (advisory, fail-safe) + runs-on: ubuntu-latest + outputs: + build: ${{ steps.changed.outputs.build }} + tests: ${{ steps.changed.outputs.tests }} + iec: ${{ steps.changed.outputs.iec }} + sv: ${{ steps.changed.outputs.sv }} + io: ${{ steps.changed.outputs.io }} + packaging: ${{ steps.changed.outputs.packaging }} + ci: ${{ steps.changed.outputs.ci }} + risk: ${{ steps.changed.outputs.risk }} + steps: + - name: Checkout PR merge source + uses: actions/checkout@v7 + with: + fetch-depth: 2 + persist-credentials: false + + - name: Run conservative classifier fixtures + run: python3 scripts/test-classify-ci-changes.py + + - id: changed + name: Classify exact PR merge diff or conservative fallback + shell: bash + run: | + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + python3 scripts/classify-ci-changes.py \ + --from-pr-merge \ + --github-output "$GITHUB_OUTPUT" \ + --summary "$GITHUB_STEP_SUMMARY" + else + python3 scripts/classify-ci-changes.py \ + --paths "__unclassified_source_change__" \ + --github-output "$GITHUB_OUTPUT" \ + --summary "$GITHUB_STEP_SUMMARY" + fi + + windows-shadow: + name: Compare reusable build/test with legacy authority + needs: classify + if: >- + ${{ + (github.event_name == 'workflow_dispatch' || + github.head_ref == 'ci/p1-fast-gate-shadow' || + contains(github.event.pull_request.labels.*.name, 'ci-p1-shadow')) && + (needs.classify.outputs.build == 'true' || + github.head_ref == 'ci/p1-fast-gate-shadow') + }} + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} diff --git a/docs/audits/CI_P1_SHADOW_CONTRACT.md b/docs/audits/CI_P1_SHADOW_CONTRACT.md new file mode 100644 index 000000000..bb197aa37 --- /dev/null +++ b/docs/audits/CI_P1_SHADOW_CONTRACT.md @@ -0,0 +1,111 @@ +# CI-P1 — Reusable Exact-SHA Build/Test and Opt-in Shadow + +Issue: [#428](https://github.com/masarray/arsas/issues/428) +Predecessor: [CI-P0 workflow inventory](CI_P0_WORKFLOW_BASELINE.md). + +## Intent + +CI-P1 introduces a reusable engine resolver, a conservative changed-area +classifier, and an exact-SHA Windows compilation/test primitive. Existing +workflows remain the canonical authorities until a measured parity gate is +satisfied. **Shadow PASS is not a production, release or physical-device proof.** + +This phase intentionally introduces one extra .NET job *only on opt-in shadow +runs*. The P0 budget records that temporary overhead explicitly. + +## Contracts and single owners + +| Contract | New owner | Invariant | +| --- | --- | --- | +| Changed path classification | `scripts/classify-ci-changes.py` | Advisory, unknown/deleted changes cannot silently skip | +| Engine pin resolution | `scripts/resolve-ariec-lock.ps1` | Exact SHA, trusted repository, historical authority preserved | +| Composite resolver | `.github/actions/resolve-ariec-engine/action.yml` | Reuses resolver in the same job, no runner allocated | +| Build and test | `.github/workflows/_ci-p1-windows-build-test.yml` | One restore + build + full tests, no publish or release | +| Opt-in caller | `.github/workflows/ci-p1-shadow.yml` | Read-only PR merge SHA, conservative classifier, branch/label opt-in | +| Structural CI budget | `evidence/ci-workflow-budget.json` | Existing P0 baseline immutable; migration delta explicitly capped | + +The canonical existing `build.yml`, IEC/SCL guards, physical evidence, RCB, +FAT/IO and SV workflows are unchanged. + +## Reproduce classifier validation + +```bash +python3 scripts/test-classify-ci-changes.py +python3 scripts/classify-ci-changes.py --paths Services/NativeIec61850Client.SclAssisted.cs +``` + +On a PR, `ci-p1-shadow.yml` fetches the synthetic GitHub PR merge commit with +two parents and inspects its diff against the first parent. This tests the +candidate **as it would merge into main**, not an arbitrary developer branch +snapshot. If a merge parent cannot be resolved or the diff is empty, the +classifier deliberately selects broad verification. + +The classifier never disables a required legacy gate. It provides advisory +area flags so CI-P2 can make a reviewed targeted-job decision. + +## Reproduce immutable lock resolver validation + +```powershell +pwsh -NoProfile -File scripts/test-resolve-ariec-lock.ps1 +pwsh -NoProfile -File scripts/resolve-ariec-lock.ps1 -LockPath engines/ARIEC61850.lock.json +``` + +The resolver only reads the lock. It rejects untrusted repositories, symbolic +engine commit refs, invalid hashes, and inconsistent historical accepted-layer +references. The reusable job then checks out that exact commit and verifies +`git rev-parse HEAD` before compilation. Historical physical SHA and current +build SHA remain separate manifest fields. + +## Shadow opt-in + +The Windows shadow runs when: + +- the PR branch is `ci/p1-fast-gate-shadow` (CI-P1 self-validation); +- a PR has label `ci-p1-shadow`; or +- `workflow_dispatch` is explicitly requested on an available branch. + +Otherwise the lightweight classifier runs (subject to path filters), while +legacy CI remains unchanged. For a labeled PR, the shadow only compiles if the +classifier indicates a .NET-impacting change. + +The called Windows job does **not** create a public Release, publish an +installer, write evidence back to Git, or mutate GitHub settings. + +## Exact-sha parity gate + +Before a legacy job may be retired, compare: + +1. The synthetic PR merge source SHA used by canonical Build ARSAS and P1. +2. The `engines/ARIEC61850.lock.json` resolved commit and the checked-out engine SHA. +3. Full regression test outcomes (not just selected tests), including failure + diagnostics and TRX. +4. Coverage of source, release, field-authority and post-merge checks that are + **not** included in the reusable build/test primitive. +5. The packaged binary SHA when packaging is eventually moved; a shadow TRX + artifact alone must never serve as a release binary. + +Shadow metadata uploads `ci-p1-shadow-manifest.json` and full test TRX as +short-retention, non-promotion evidence. It deliberately contains neither +source CID files nor physical device identifiers. + +## Temporary migration cost + +The P0 immutable observed baseline is still **29 workflows**, **15 restore**, +**15 build** and **17 test** after the P0 guard. CI-P1 adds two workflow files: +one lightweight classifier and one reusable Windows shadow. The guarded maximum +during migration is **31 workflows**, **16 restore**, **16 build** and **18 test** +invocations in workflow source. + +These are static source counts, not simultaneous runtime invocations and not a +claim that CI is faster already. CI-P2 should reduce these counts through +verified replacement and artifact reuse, not by weakening guard semantics. + +## Safety gate for promotion of P1 + +P1 may merge to main when P0 structural/classifier tests, reusable Windows +build/test, and unchanged legacy checks are green on the same tested PR merge +head. The post-merge P0 guard must remain green. + +P1 does not approve reducing physical tests or disabling existing Build ARSAS. +A future CI-P2 PR must prove parity with accepted field evidence and tag/release +boundaries before retiring each specific legacy workflow. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index cae9d3171..a8061b770 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P0 workflow-sprawl freeze", - "policy": "These are maximum textual CI-cost indicators after adding the lightweight CI-P0 integrity workflow. Raising a limit requires an explicit reviewed budget change. Lowering a limit is always allowed when workflows are consolidated.", + "baselineName": "CI-P0 baseline + explicitly bounded CI-P1 shadow migration", + "policy": "CI-P0 observed baseline remains immutable below. Temporary CI-P1 classifier/reusable shadow overhead is explicitly approved for parity only, without changing legacy acceptance semantics. The next consolidation PR must reduce these maxima rather than treating them as a permanent efficiency target.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -17,20 +17,49 @@ "workflowCall": 0 }, "limits": { - "workflowFiles": 29, - "actionsCheckout": 32, - "actionsSetupDotnet": 12, + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 13, "actionsSetupPython": 13, - "dotnetRestore": 15, - "dotnetBuild": 15, - "dotnetTest": 17, - "actionsUploadArtifact": 33, - "workflowsWithDotnetBuild": 12, - "workflowsWithDotnetTest": 11 + "dotnetRestore": 16, + "dotnetBuild": 16, + "dotnetTest": 18, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 13, + "workflowsWithDotnetTest": 12 }, "interpretation": [ "The limits freeze CI sprawl; they are not performance targets and do not prove semantic necessity.", "CI-P1 is expected to reduce restore/build/test counts and introduce reusable workflow_call contracts. Any temporary migration increase must be explicit in the same reviewed change.", "Release, physical evidence and IEC 61850 acceptance semantics remain owned by their existing contracts during CI-P0." - ] + ], + "ciP1MigrationShadow": { + "issueNumber": 428, + "purpose": "Two additive workflows: one advisory change classifier and one reusable exact-SHA Windows compile/test job for opt-in shadow parity. No legacy workflow retired or replaced.", + "originalP0Limits": { + "workflowFiles": 29, + "actionsCheckout": 32, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "temporaryIncrement": { + "workflowFiles": 2, + "actionsCheckout": 3, + "actionsSetupDotnet": 1, + "actionsSetupPython": 0, + "dotnetRestore": 1, + "dotnetBuild": 1, + "dotnetTest": 1, + "actionsUploadArtifact": 1, + "workflowsWithDotnetBuild": 1, + "workflowsWithDotnetTest": 1 + }, + "exitGate": "Only retire a legacy workflow or lower limits after shadow parity on matching source/engine SHAs, equivalent test verdicts, and explicit review of physical/release gate ownership. No release/promotion claim based on shadow results." + } } diff --git a/scripts/classify-ci-changes.py b/scripts/classify-ci-changes.py new file mode 100644 index 000000000..55f20da35 --- /dev/null +++ b/scripts/classify-ci-changes.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""CI-P1: classify changed paths conservatively without network/API access. + +The PR-merge mode diffs HEAD^1..HEAD, where HEAD is the checkout of GitHub's +synthetic PR merge commit. If this relationship is unavailable, select the full +build/test surface; never silently skip verification. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +from pathlib import Path + +SOURCE_EXTENSIONS = {".cs", ".csproj", ".sln", ".slnx", ".xaml", ".props", ".targets", + ".resx", ".razor", ".fs", ".fsproj", ".config", ".manifest"} +DOC_EXTENSIONS = {".md", ".rst", ".adoc", ".txt"} +WEBSITE_EXTENSIONS = {".html", ".css", ".js", ".mjs", ".png", ".jpg", ".jpeg", + ".webp", ".svg", ".gif", ".ico", ".woff", ".woff2"} +SCL_TOKENS = ("scl", "mms", "iec61850", "discovery", "report", "rcb", "control", + "dataset", "fcda", "ldmodel", "ied", "sasi") +SV_TOKENS = ("smv", "sampledvalue", "sampled-value", "processbus", "goose", "sv-evidence") +IO_TOKENS = ("iotest", "iolist", "iofat", "fatdataset", "fatscl", "io-testing") + + +def classify(paths: list[str]) -> dict: + """Pure classifier. Unknown files intentionally require the broad build.""" + def normalize(name: str) -> str: + normalized = name.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + return normalized + + changed = sorted({normalize(name) for name in paths if name.strip()}) + areas: set[str] = set() + build = tests = iec = sv = io = packaging = website = ci = unknown = False + + if not changed: + # A missing/unsupported comparison is never interpreted as a docs-only change. + changed = ["__unclassified_source_change__"] + + for path in changed: + lower = path.lower() + basename = Path(lower).name + suffix = Path(lower).suffix + if path == "__unclassified_source_change__": + areas.add("unknown") + build = tests = iec = sv = io = packaging = unknown = True + continue + + if lower.startswith("landing/") or lower.startswith("website/"): + areas.add("website") + website = True + continue + + if lower.startswith(".github/"): + areas.add("ci") + ci = True + if not (lower.startswith(".github/workflows/") or + lower.startswith(".github/actions/")): + build = tests = True + # CI-only changes are covered by the P0 guard and the opt-in P1 shadow. + continue + + if lower.startswith("docs/") and lower != "docs/interoperability_reference_contract.md": + areas.add("docs") + continue + + if lower.startswith("scripts/"): + areas.add("ci") + ci = True + if basename not in { + "classify-ci-changes.py", + "test-classify-ci-changes.py", + "audit-ci-workflows.py", + "test-ci-workflows.py", + }: + build = tests = True # scripts can affect release/build semantics + continue + + if lower.startswith("evidence/"): + areas.add("authority") + iec = True + build = tests = True # existing authority workflows remain mandatory + continue + + if lower.startswith("engines/") or lower.startswith("engine-patches/"): + areas.add("engine") + build = tests = iec = sv = io = True + continue + + if lower.startswith("installer/") or lower.startswith(".release/") or lower == "version": + areas.add("packaging") + packaging = True + build = tests = True + continue + + if lower == "docs/interoperability_reference_contract.md": + areas.add("authority") + iec = True + build = tests = True + continue + + if lower.startswith("assets/") or lower.startswith("resources/"): + areas.add("app") + build = tests = True + continue + + if suffix in SOURCE_EXTENSIONS or lower in { + "global.json", "nuget.config", "directory.build.props", + "directory.build.targets", "directory.packages.props", "app.manifest" + }: + areas.add("app") + build = tests = True + if any(token in lower for token in SCL_TOKENS): + iec = True + areas.add("iec61850") + if any(token in lower for token in SV_TOKENS): + sv = True + areas.add("process-bus") + if any(token in lower for token in IO_TOKENS): + io = True + areas.add("io-fat") + continue + + if lower.startswith("tests/"): + # Non-source test assets can change regression truth. + areas.add("tests") + build = tests = True + continue + + if suffix in DOC_EXTENSIONS or lower in {"readme", "license", "notice"}: + areas.add("docs") + continue + + if suffix in WEBSITE_EXTENSIONS: + areas.add("website") + website = True + continue + + # Unclassified paths can affect the build, packaging, or evidence. + areas.add("unknown") + build = tests = iec = sv = io = packaging = unknown = True + + flags = { + "build": build, + "tests": tests, + "iec": iec, + "sv": sv, + "io": io, + "packaging": packaging, + "website": website, + "ci": ci, + "unknown": unknown, + } + risk = "broad" if unknown or "engine" in areas else ("targeted" if build else "none") + return { + "schemaVersion": 1, + "areas": sorted(areas), + "changedFiles": changed, + "flags": flags, + "risk": risk, + "requiresLegacyAuthority": bool(iec or packaging), + "interpretation": ( + "Advisory classification only. Legacy CI remains authoritative; " + "unknown input always selects broad verification." + ), + } + + +def merge_diff(root: Path) -> list[str]: + """Collect PR merge-tree changes; absence of a merge parent is a full-build signal.""" + try: + parents = subprocess.check_output( + ["git", "-C", str(root), "rev-list", "--parents", "-n", "1", "HEAD"], + stderr=subprocess.PIPE, + ).decode("ascii", "strict").split() + if len(parents) != 3: + return ["__unclassified_source_change__"] + + raw = subprocess.check_output( + ["git", "-C", str(root), "diff", "--name-only", "-z", parents[1], "HEAD"], + stderr=subprocess.PIPE, + ) + return [name.decode("utf-8", "surrogateescape") + for name in raw.split(b"\0") if name] or ["__unclassified_source_change__"] + except (subprocess.CalledProcessError, UnicodeError, OSError): + return ["__unclassified_source_change__"] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--from-pr-merge", action="store_true") + parser.add_argument("--paths", nargs="*", default=None) + parser.add_argument("--github-output", type=Path) + parser.add_argument("--summary", type=Path) + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + report = classify( + merge_diff(args.root.resolve()) if args.from_pr_merge else (args.paths or []) + ) + payload = json.dumps(report, sort_keys=True, ensure_ascii=False) + print(payload) + + if args.github_output: + with args.github_output.open("a", encoding="utf-8") as stream: + for key, value in sorted(report["flags"].items()): + stream.write(f"{key}={str(value).lower()}\n") + stream.write(f"risk={report['risk']}\n") + + if args.json_output: + args.json_output.write_text(payload + "\n", encoding="utf-8") + + if args.summary: + with args.summary.open("a", encoding="utf-8") as stream: + stream.write("## CI-P1 advisory change classifier\n\n") + stream.write(f"- areas: {', '.join(report['areas'])}\n") + stream.write(f"- risk: {report['risk']}\n") + stream.write(f"- full Windows build required: {report['flags']['build']}\n") + stream.write("- legacy authority remains required and unchanged\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resolve-ariec-lock.ps1 b/scripts/resolve-ariec-lock.ps1 new file mode 100644 index 000000000..9d4ccd5de --- /dev/null +++ b/scripts/resolve-ariec-lock.ps1 @@ -0,0 +1,101 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS +Resolve the exact, immutable ARIEC61850 integration SHA from a tracked lock. +.DESCRIPTION +Read-only and fail-closed. Historical physical evidence remains separate from +the current integration pin; this resolver never rewrites either one. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$LockPath, + + [string]$GitHubOutput = '' +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $LockPath -PathType Leaf)) { + throw "ARIEC61850 lock file does not exist: $LockPath" +} + +try { + $lock = Get-Content -LiteralPath $LockPath -Raw -Encoding UTF8 | + ConvertFrom-Json -Depth 40 +} +catch { + throw "ARIEC61850 lock cannot be parsed: $($_.Exception.Message)" +} + +if ([int]$lock.schemaVersion -ne 1) { + throw 'ARIEC61850 lock schemaVersion must be 1.' +} +if ([string]$lock.repository -cne 'masarray/ARIEC61850') { + throw 'ARIEC61850 repository must match the trusted masarray/ARIEC61850 source.' +} +if ([string]$lock.ref -cne 'main') { + throw 'ARIEC61850 integration ref must remain main; checkout is pinned by SHA.' +} + +function Assert-Sha { + param([string]$Value, [string]$Name) + if ($Value -cnotmatch '^[0-9a-f]{40}$') { + throw "Invalid immutable SHA field $Name." + } +} + +Assert-Sha -Value ([string]$lock.commit) -Name 'commit' + +if ($null -ne $lock.physicalTestedCommit) { + Assert-Sha -Value ([string]$lock.physicalTestedCommit) -Name 'physicalTestedCommit' +} +if ($null -ne $lock.mergedMainCommit) { + Assert-Sha -Value ([string]$lock.mergedMainCommit) -Name 'mergedMainCommit' +} +if ($null -ne $lock.mergedMainTree) { + Assert-Sha -Value ([string]$lock.mergedMainTree) -Name 'mergedMainTree' +} +if ($null -ne $lock.previousStablePin) { + Assert-Sha -Value ([string]$lock.previousStablePin.commit) -Name 'previousStablePin.commit' + if ($null -ne $lock.mergedMainCommit -and + [string]$lock.previousStablePin.commit -cne [string]$lock.mergedMainCommit) { + throw 'Previous stable pin no longer matches historical merged-main authority.' + } +} +if ($null -ne $lock.sclAssociationInteroperability) { + $association = $lock.sclAssociationInteroperability + if ($null -ne $association.testedEngineCommit) { + Assert-Sha -Value ([string]$association.testedEngineCommit) -Name 'sclAssociationInteroperability.testedEngineCommit' + if ([string]$association.testedEngineCommit -cne [string]$lock.commit) { + throw 'SCL association physical-tested engine head differs from integration lock.' + } + } + if ($null -ne $association.mergedEngineCommit) { + Assert-Sha -Value ([string]$association.mergedEngineCommit) -Name 'sclAssociationInteroperability.mergedEngineCommit' + } +} + +$resolved = [pscustomobject]@{ + repository = [string]$lock.repository + ref = [string]$lock.ref + commit = [string]$lock.commit + physicalTestedCommit = [string]$lock.physicalTestedCommit + mergedMainCommit = [string]$lock.mergedMainCommit + baselineIsCurrent = ([string]$lock.commit -ceq [string]$lock.mergedMainCommit) +} + +if ($GitHubOutput) { + @( + "repository=$($resolved.repository)" + "ref=$($resolved.ref)" + "commit=$($resolved.commit)" + "physical_tested_commit=$($resolved.physicalTestedCommit)" + "merged_main_commit=$($resolved.mergedMainCommit)" + "baseline_is_current=$($resolved.baselineIsCurrent.ToString().ToLowerInvariant())" + ) | Out-File -LiteralPath $GitHubOutput -Encoding UTF8 -Append +} + +Write-Host "Verified immutable ARIEC61850 integration SHA $($resolved.commit)." +Write-Host "Historical engine main authority remains $($resolved.mergedMainCommit)." +return $resolved diff --git a/scripts/test-classify-ci-changes.py b/scripts/test-classify-ci-changes.py new file mode 100644 index 000000000..71f420be8 --- /dev/null +++ b/scripts/test-classify-ci-changes.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Offline policy/regression tests for advisory CI-P1 PR classification.""" +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).with_name("classify-ci-changes.py") +spec = importlib.util.spec_from_file_location("arsas_p1_classifier", SOURCE) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class ClassifierTests(unittest.TestCase): + def test_trusted_scl_change_requires_full_tests_and_iec(self): + r = module.classify(["Services/NativeIec61850Client.SclAssisted.cs"]) + self.assertTrue(r["flags"]["build"]) + self.assertTrue(r["flags"]["tests"]) + self.assertTrue(r["flags"]["iec"]) + self.assertFalse(r["flags"]["sv"]) + self.assertTrue(r["requiresLegacyAuthority"]) + + def test_sv_and_goose_change_is_process_bus(self): + r = module.classify(["Services/SmvSnapshotCaptureService.cs"]) + self.assertTrue(r["flags"]["sv"]) + self.assertTrue(r["flags"]["build"]) + r2 = module.classify(["Services/GooseSubscriberRuntime.cs"]) + self.assertTrue(r2["flags"]["sv"]) + + def test_fat_io_change_is_targeted(self): + r = module.classify(["Services/IoTesting/IoFatOrchestrator.cs"]) + self.assertTrue(r["flags"]["io"]) + self.assertTrue(r["flags"]["tests"]) + self.assertFalse(r["flags"]["iec"]) + + def test_engine_lock_change_is_broad(self): + r = module.classify(["engines/ARIEC61850.lock.json"]) + self.assertEqual(r["risk"], "broad") + self.assertTrue(all(r["flags"][name] for name in ("build", "tests", "iec", "io", "sv"))) + + def test_pure_documentation_skips_dotnet_advisory(self): + r = module.classify(["docs/audits/OTHER.md", "README.md"]) + self.assertFalse(r["flags"]["build"]) + self.assertEqual(r["risk"], "none") + self.assertEqual(r["areas"], ["docs"]) + + def test_ci_change_detected_separately_from_application(self): + r = module.classify([".github/workflows/ci-p1-shadow.yml"]) + self.assertTrue(r["flags"]["ci"]) + self.assertFalse(r["flags"]["build"]) + self.assertFalse(r["requiresLegacyAuthority"]) + + def test_scripts_outside_ci_infrastructure_are_conservative(self): + r = module.classify(["scripts/publish-windows-portable.ps1"]) + self.assertTrue(r["flags"]["build"]) + + def test_unknown_extension_fails_open_to_full_build(self): + r = module.classify(["config/custom-device.assetbin"]) + self.assertTrue(r["flags"]["unknown"]) + self.assertTrue(r["flags"]["iec"]) + self.assertTrue(r["flags"]["tests"]) + self.assertEqual(r["risk"], "broad") + + def test_empty_paths_do_not_silently_skip(self): + r = module.classify([]) + self.assertTrue(r["flags"]["unknown"]) + self.assertTrue(r["flags"]["build"]) + + def test_dedup_and_order_are_deterministic(self): + paths = ["README.md", "Services/MmsReporting.cs", "README.md"] + self.assertEqual( + json.dumps(module.classify(paths), sort_keys=True), + json.dumps(module.classify(list(reversed(paths))), sort_keys=True), + ) + self.assertEqual(len(module.classify(paths)["changedFiles"]), 2) + + def test_pr_merge_diff_uses_first_parent_and_falls_back_if_absent(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + def git(*args: str) -> None: + subprocess.run(["git", "-C", str(root), *args], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + git("init", "-q") + git("config", "user.name", "CI Test") + git("config", "user.email", "test@example.invalid") + (root / "README.md").write_text("base\n") + git("add", "-A") + git("commit", "-qm", "base") + self.assertEqual( + module.merge_diff(root), ["__unclassified_source_change__"] + ) + base_branch = subprocess.check_output( + ["git", "-C", str(root), "branch", "--show-current"] + ).decode("utf-8").strip() + git("checkout", "-qb", "feature") + (root / "Services").mkdir() + (root / "Services" / "MmsReporting.cs").write_text("// change\n") + git("add", "-A") + git("commit", "-qm", "feature") + git("checkout", "-q", base_branch) + git("merge", "--no-ff", "-qm", "merge feature", "feature") + self.assertEqual( + module.merge_diff(root), ["Services/MmsReporting.cs"] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-resolve-ariec-lock.ps1 b/scripts/test-resolve-ariec-lock.ps1 new file mode 100644 index 000000000..4f1afc5f2 --- /dev/null +++ b/scripts/test-resolve-ariec-lock.ps1 @@ -0,0 +1,86 @@ +#Requires -Version 7.0 +# Pure synthetic resolver tests. Never contacts a network endpoint or alters repo locks. +$ErrorActionPreference = 'Stop' +$resolver = Join-Path $PSScriptRoot 'resolve-ariec-lock.ps1' +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ("arsas-ci-p1-resolver-" + [guid]::NewGuid().ToString('N')) +New-Item -Path $temp -ItemType Directory -Force | Out-Null +$checks = 0 + +function Assert-Resolves { + param([hashtable]$Data) + $script:checks++ + $path = Join-Path $temp ("valid-$script:checks.json") + ConvertTo-Json -InputObject $Data -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + $result = & $resolver -LockPath $path + if ([string]$result.commit -cne [string]$Data.commit) { + throw "CI-P1 resolver valid fixture did not preserve integration SHA." + } +} + +function Assert-Rejects { + param([hashtable]$Data, [string]$Expected) + $script:checks++ + $path = Join-Path $temp ("invalid-$script:checks.json") + ConvertTo-Json -InputObject $Data -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + $rejected = $false + try { + $null = & $resolver -LockPath $path + } + catch { + if ($_.Exception.Message -notlike "*$Expected*") { + throw "CI-P1 resolver returned an unexpected error: $($_.Exception.Message)" + } + $rejected = $true + } + if (-not $rejected) { + throw "CI-P1 resolver accepted a forbidden fixture: $Expected" + } +} + +try { + $stable = @{ + schemaVersion = 1 + repository = 'masarray/ARIEC61850' + ref = 'main' + commit = ('a' * 40) + physicalTestedCommit = ('b' * 40) + mergedMainCommit = ('a' * 40) + } + Assert-Resolves $stable + + $candidate = $stable.Clone() + $candidate.commit = ('c' * 40) + $candidate.previousStablePin = @{ commit = ('a' * 40) } + $candidate.sclAssociationInteroperability = @{ + testedEngineCommit = ('c' * 40) + mergedEngineCommit = ('d' * 40) + } + Assert-Resolves $candidate + + $wrongRepository = $stable.Clone() + $wrongRepository.repository = 'untrusted/ARIEC61850' + Assert-Rejects $wrongRepository 'repository' + + $wrongSha = $stable.Clone() + $wrongSha.commit = 'main' + Assert-Rejects $wrongSha 'immutable SHA' + + $wrongRef = $stable.Clone() + $wrongRef.ref = 'feature' + Assert-Rejects $wrongRef 'ref' + + $wrongHistorical = $candidate.Clone() + $wrongHistorical.previousStablePin = @{ commit = ('f' * 40) } + Assert-Rejects $wrongHistorical 'historical' + + $wrongAssociation = $candidate.Clone() + $wrongAssociation.sclAssociationInteroperability = @{ + testedEngineCommit = ('f' * 40) + } + Assert-Rejects $wrongAssociation 'physical-tested' + + Write-Host "CI-P1 immutable engine resolver: $checks synthetic cases PASS." +} +finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue +} From 95518c5991713260eb8696498d0c2c1d6a08cd70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 10:08:40 +0700 Subject: [PATCH 03/99] ci(p2a): reuse canonical exact-SHA regression in merge guard Consume immutable successful Build ARSAS test artifact with exact PR merge SHA, pinned engine SHA, run/attempt and all-pass TRX digest checks instead of rerunning one Windows restore/build/test lane. Preserve P0-5h source contract, read-only access, manual reusable fallback and historical R10/P141 physical authority. All 10 triggering workflows passed. --- .github/workflows/build.yml | 20 +- .../workflows/ci-p0-workflow-integrity.yml | 9 + .../smart-discovery-merge-execution-guard.yml | 94 ++++-- .../CI_P2A_CANONICAL_REGRESSION_REUSE.md | 118 +++++++ evidence/ci-workflow-budget.json | 54 +++- scripts/test-ci-canonical-reuse.py | 212 +++++++++++++ scripts/verify-ci-canonical-reuse.py | 297 ++++++++++++++++++ scripts/write-ci-canonical-proof.py | 145 +++++++++ 8 files changed, 911 insertions(+), 38 deletions(-) create mode 100644 docs/audits/CI_P2A_CANONICAL_REGRESSION_REUSE.md create mode 100644 scripts/test-ci-canonical-reuse.py create mode 100644 scripts/verify-ci-canonical-reuse.py create mode 100644 scripts/write-ci-canonical-proof.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d11d32785..f56abc80b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -356,12 +356,30 @@ jobs: - name: Run ARSAS application regression tests run: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-tests.trx" --results-directory .\ArIED61850Tester\TestResults + - name: Write exact-SHA canonical test attestation + shell: powershell + run: | + python .\ArIED61850Tester\scripts\write-ci-canonical-proof.py ` + --app-dir .\ArIED61850Tester ` + --engine-dir .\ARIEC61850 ` + --test-results-dir .\ArIED61850Tester\TestResults ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --run-id $env:GITHUB_RUN_ID ` + --attempt $env:GITHUB_RUN_ATTEMPT ` + --event-name $env:GITHUB_EVENT_NAME + if ($LASTEXITCODE -ne 0) { + throw 'Canonical exact-SHA full-regression attestation failed.' + } + - name: Upload test evidence if: always() uses: actions/upload-artifact@v7 with: name: ARSAS-test-evidence - path: ArIED61850Tester\TestResults\*.trx + path: | + ArIED61850Tester\TestResults\*.trx + ArIED61850Tester\TestResults\ci-canonical-authority.json if-no-files-found: error - name: Publish real portable single EXE x64 diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index ec041c0d9..bda091ed5 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -9,6 +9,9 @@ on: - "scripts/test-ci-workflows.py" - "scripts/classify-ci-changes.py" - "scripts/test-classify-ci-changes.py" + - "scripts/write-ci-canonical-proof.py" + - "scripts/verify-ci-canonical-reuse.py" + - "scripts/test-ci-canonical-reuse.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -19,6 +22,9 @@ on: - "scripts/test-ci-workflows.py" - "scripts/classify-ci-changes.py" - "scripts/test-classify-ci-changes.py" + - "scripts/write-ci-canonical-proof.py" + - "scripts/verify-ci-canonical-reuse.py" + - "scripts/test-ci-canonical-reuse.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -51,6 +57,9 @@ jobs: - name: Run conservative CI P1 classifier fixtures run: python3 scripts/test-classify-ci-changes.py + - name: Run canonical CI P2 evidence safety fixtures + run: python3 scripts/test-ci-canonical-reuse.py + - name: Validate tracked workflow topology and budget run: | python3 scripts/audit-ci-workflows.py \ diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml index d02de3beb..69c3fa982 100644 --- a/.github/workflows/smart-discovery-merge-execution-guard.yml +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -34,10 +34,19 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: smart-discovery-merge-execution-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: verify-merge-execution-contract: name: Verify P0-5h ordered merge contract - runs-on: windows-latest + runs-on: ubuntu-latest + timeout-minutes: 23 steps: - name: Checkout ARSAS branch uses: actions/checkout@v7 @@ -45,7 +54,7 @@ jobs: fetch-depth: 0 - name: Validate P0-5h source contract - shell: powershell + shell: pwsh run: | $target = '.\evidence\smart-discovery-mainline-merge-target.json' $writer = '.\scripts\new-smart-discovery-mainline-merge-manifest.ps1' @@ -59,7 +68,8 @@ jobs: foreach ($script in @($writer,$preflight,$postMerge)) { $tokens = $null $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($script,[ref]$tokens,[ref]$errors) | Out-Null + $scriptAbsolute = (Resolve-Path -LiteralPath $script).Path + [System.Management.Automation.Language.Parser]::ParseFile($scriptAbsolute,[ref]$tokens,[ref]$errors) | Out-Null if ($errors.Count -ne 0) { $messages = @($errors | ForEach-Object { $_.Message }) -join '; ' throw "PowerShell parse failure in ${script}: $messages" @@ -85,33 +95,59 @@ jobs: } } - - 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 + - name: Validate offline canonical evidence producer/consumer fixtures + run: python3 scripts/test-ci-canonical-reuse.py - $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@v6 + - id: pinned-engine + name: Resolve exact integration engine authority + uses: ./.github/actions/resolve-ariec-engine with: - dotnet-version: 8.0.x + lock-path: engines/ARIEC61850.lock.json + + - name: Require same-head canonical Build ARSAS regression proof + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + PINNED_ENGINE_SHA: ${{ steps.pinned-engine.outputs.commit }} + REPOSITORY: ${{ github.repository }} + run: | + python3 scripts/verify-ci-canonical-reuse.py \ + --repository "$REPOSITORY" \ + --head-branch "$PR_HEAD_BRANCH" \ + --head-sha "$PR_HEAD_SHA" \ + --merge-sha "$PR_MERGE_SHA" \ + --engine-sha "$PINNED_ENGINE_SHA" \ + --wait-seconds 960 \ + --output "$RUNNER_TEMP/ci-canonical-reuse-proof.json" - - name: Run ARSAS regression tests - shell: powershell + - name: Summarize exact canonical-regression reuse + if: github.event_name == 'pull_request' 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 + python3 - <<'PY' + import json + import os + from pathlib import Path + + proof = json.loads( + (Path(os.environ["RUNNER_TEMP"]) / "ci-canonical-reuse-proof.json").read_text() + ) + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as out: + out.write("## Canonical ARSAS regression reuse — fail-closed PASS\\n\\n") + out.write(f"- canonical Build ARSAS run: {proof['canonicalRunId']}, attempt {proof['canonicalRunAttempt']}\\n") + out.write(f"- exact PR merge SHA: {proof['mergeSha']}\\n") + out.write(f"- exact engine SHA: {proof['engineSha']}\\n") + out.write(f"- full regression: {proof['passed']} passed, {proof['failed']} failed\\n") + out.write("- Source P0-5h ordered-merge contract: passed independently\\n") + PY + + manual-regression: + name: Manual exact-SHA reusable full regression + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} diff --git a/docs/audits/CI_P2A_CANONICAL_REGRESSION_REUSE.md b/docs/audits/CI_P2A_CANONICAL_REGRESSION_REUSE.md new file mode 100644 index 000000000..f082a9452 --- /dev/null +++ b/docs/audits/CI_P2A_CANONICAL_REGRESSION_REUSE.md @@ -0,0 +1,118 @@ +# CI-P2A — Canonical Regression Proof Reuse + +Issue: [#430](https://github.com/masarray/arsas/issues/430) +Predecessor: [CI-P1 reusable/shadow architecture](CI_P1_SHADOW_CONTRACT.md). + +## Scope and optimization + +The Smart Discovery Merge Execution Guard used to re-run the same .NET +restore/build/full ARSAS regression suite already executed unconditionally by +canonical `Build ARSAS` on **every PR**. + +P2A removes those duplicate PR compilation steps, **without** losing the +historical `Verify P0-5h ordered merge contract` check or its regression +requirement. + +The specialized check now has two independent, fail-closed stages: + +1. Validate the original ordered-merge evidence, scripts, PowerShell parser + output and mutation-scope constraints. +2. Consume and independently verify the successful canonical Build ARSAS test + artifact for the *same source and engine*. It may not pass on historical or + stale green results. + +This is a single-lane reduction, not permission to disable other Smart +Discovery, physical-retest, IEC 61850 or release checks. + +## Canonical evidence producer + +Canonical `.github/workflows/build.yml` still builds and tests normally. +After its successful full regression step, it runs +`scripts/write-ci-canonical-proof.py` and adds a tiny manifest to its **existing** +`ARSAS-test-evidence` artifact. No additional artifact upload job or +release/publication authority is created. + +`ci-canonical-authority.json` binds: + +- exact checked-out ARSAS SHA; +- exact checked-out ARIEC61850 SHA from the immutable lock; +- canonical workflow name, run ID and attempt; +- SHA-256 of the actual full-regression `arsas-tests.trx`; +- exact full test counters, including zero failed/not-executed; +- explicit `releasePromotionAuthority=false`. + +The manifest is a CI claim, **not a cryptographic signature**, and does not +replace real physical IED evidence. + +## Specialized consumer + +`scripts/verify-ci-canonical-reuse.py` has access only to a token with +`actions: read` and `contents: read`. It: + +1. locates the latest `Build ARSAS` pull-request workflow for the exact + PR head SHA and branch (not an older successful attempt); +2. requires the run to be `completed/success`, including its portable + package/smoke tests; +3. downloads only the canonical `ARSAS-test-evidence` artifact; +4. parses the ZIP **as data** without extracting or executing it; +5. validates artifact safety, manifest schema, exact synthetic PR merge SHA, + exact engine SHA, workflow run/attempt ID, TRX content SHA-256 and all + test counters; +6. rejects missing/incorrect artifacts, failed runs, conflicting test counts, + stale merge trees and timeouts. + +GitHub archive redirect handling removes bearer credentials on cross-origin +redirects to artifact blob storage. + +The bounded waiting policy is 16 minutes; timeout is **FAIL**, not skipped. +A canonical failure is reflected as specialized check failure, regardless +of which checks are configured as branch-protection requirements. + +## Manual execution + +For `workflow_dispatch`, the P0-5h source guard still runs. The reusable +`_ci-p1-windows-build-test.yml` job supplies a fresh exact-SHA full test, +rather than requiring a nonexistent PR Build run. It remains test-only and +cannot promote a release or mutate physical evidence. + +## Before and after + +The immutable P0 observed baseline is unchanged and retained in +`evidence/ci-workflow-budget.json`. P2A changes the guarded *workflow-source* +maximums: + +| Indicator | P1 maximum | P2A maximum | +| --- | ---: | ---: | +| Workflow files | 31 | 31 | +| setup-dotnet uses | 13 | 12 | +| .NET restore invocations | 16 | 15 | +| .NET build invocations | 16 | 15 | +| .NET test invocations | 18 | 17 | +| Workflows containing .NET build | 13 | 12 | +| Workflows containing .NET test | 12 | 11 | + +Those are static counts, **not** evidence that every PR is 1/16 faster. +The removed duplicated Windows compilation frees compute and reduces +independent failure surfaces; the specialized guard still waits for canonical +completion on an Ubuntu runner, so its verdict cannot precede canonical CI. + +## Validation and rollback + +Offline synthetic fixtures: + +```bash +python3 scripts/test-ci-canonical-reuse.py +python3 scripts/test-ci-workflows.py +``` + +Before merge, prove the P0 inventory guard, canonical Build ARSAS, new +specialized check, R10 readiness, Golden/Promotion and Field Capture workflows +all green on the same exact PR head and merge tree. + +If the run metadata, artifact format or permission model creates unacceptable +flakiness, revert only the specialized verifier/consumer lane to the previous +direct build/test commands; **never disable** the P0-5h source contract, and +never treat failed proof reuse as a green skip. + +Remaining CI-P2 work should target other duplicated lanes separately. +No other workflow retirement is authorized by this P2A change. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index a8061b770..8988a8343 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P0 baseline + explicitly bounded CI-P1 shadow migration", - "policy": "CI-P0 observed baseline remains immutable below. Temporary CI-P1 classifier/reusable shadow overhead is explicitly approved for parity only, without changing legacy acceptance semantics. The next consolidation PR must reduce these maxima rather than treating them as a permanent efficiency target.", + "baselineName": "CI-P2A canonical regression reuse — first verified duplicate removal", + "policy": "CI-P0 observed baseline is immutable. CI-P1 bounded shadow remains opt-in. CI-P2A removes the redundant Merge Execution Guard restore/build/full-test pipeline; that guard must consume a completed successful canonical Build ARSAS proof for identical PR merge source and engine SHAs, otherwise fail closed. Subsequent reductions require equivalent explicit evidence.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -19,14 +19,14 @@ "limits": { "workflowFiles": 31, "actionsCheckout": 35, - "actionsSetupDotnet": 13, + "actionsSetupDotnet": 12, "actionsSetupPython": 13, - "dotnetRestore": 16, - "dotnetBuild": 16, - "dotnetTest": 18, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, "actionsUploadArtifact": 34, - "workflowsWithDotnetBuild": 13, - "workflowsWithDotnetTest": 12 + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 }, "interpretation": [ "The limits freeze CI sprawl; they are not performance targets and do not prove semantic necessity.", @@ -61,5 +61,43 @@ "workflowsWithDotnetTest": 1 }, "exitGate": "Only retire a legacy workflow or lower limits after shadow parity on matching source/engine SHAs, equivalent test verdicts, and explicit review of physical/release gate ownership. No release/promotion claim based on shadow results." + }, + "ciP2Consolidation": { + "issueNumber": 430, + "lane": "merge-execution-guard", + "approach": "Reuse completed canonical Build ARSAS exact-merge-SHA full-regression TRX artifact; no duplicate .NET compile for PR events. Keep original source-contract check and status name, read-only API, manual fallback via reusable full build/test.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 13, + "actionsSetupPython": 13, + "dotnetRestore": 16, + "dotnetBuild": 16, + "dotnetTest": 18, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 13, + "workflowsWithDotnetTest": 12 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "canonicalEvidenceArtifact": "ARSAS-test-evidence", + "candidateVerification": { + "source": "exact PR synthetic merge SHA", + "engine": "exact immutable engine lock SHA", + "canonicalRun": "successful latest matching PR head/branch run", + "tests": "signed-by-workflow-at-execution manifest plus verified TRX SHA-256 and passing counters", + "noProductionPromotion": true + }, + "rollback": "Restore original smart-discovery-merge-execution-guard.yml build/test steps if canonical evidence or cross-run matching causes flakiness; never skip P0-5h source-contract checks." } } diff --git a/scripts/test-ci-canonical-reuse.py b/scripts/test-ci-canonical-reuse.py new file mode 100644 index 000000000..2f3d733fe --- /dev/null +++ b/scripts/test-ci-canonical-reuse.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Synthetic fail-closed tests for canonical CI proof production and consumption.""" +from __future__ import annotations + +import importlib.util +import io +import json +import unittest +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def load(name: str, path: str): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +producer = load("arsas_canonical_producer", "write-ci-canonical-proof.py") +consumer = load("arsas_canonical_consumer", "verify-ci-canonical-reuse.py") +SOURCE = "a" * 40 +ENGINE = "b" * 40 +HEAD = "c" * 40 +BRANCH = "ci/p2a-canonical-regression-reuse" +RUN = 1234 + +TRX = b""" + + + + +""" + + +def fixture(): + return producer.create_manifest( + source_sha=SOURCE, + checkout_sha=SOURCE, + engine_expected=ENGINE, + engine_actual=ENGINE, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + trx=TRX, + trx_name="arsas-tests.trx", + ) + + +def zip_archive(manifest: dict, trx: bytes = TRX, extra: dict | None = None) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("ARSAS/TestResults/ci-canonical-authority.json", json.dumps(manifest)) + archive.writestr("ARSAS/TestResults/arsas-tests.trx", trx) + for name, content in (extra or {}).items(): + archive.writestr(name, content) + return buffer.getvalue() + + +class FakeApi: + def __init__(self, run_status: str = "completed", result: str = "success"): + self.status, self.result = run_status, result + self.blob = zip_archive(fixture()) + self.listing = [ + {"id": RUN, "run_attempt": 1, "name": "Build ARSAS", + "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request", + "status": self.status, "conclusion": self.result} + ] + + def get(self, url: str, binary: bool = False): + if binary: + return self.blob + if "/workflows/build.yml/runs?" in url: + return {"workflow_runs": self.listing} + if "/runs/" in url and "/artifacts" in url: + return {"artifacts": [ + {"id": 22, "name": "ARSAS-test-evidence", "expired": False, + "archive_download_url": "https://example.invalid/artifact.zip", + "workflow_run": {"id": RUN}} + ]} + raise RuntimeError("Unexpected test API request " + url) + + +def run_once(api: FakeApi): + return consumer.verify_canonical( + api, repository="masarray/arsas", branch=BRANCH, + head_sha=HEAD, merge_sha=SOURCE, engine_sha=ENGINE, + wait_seconds=0, poll_seconds=1, + ) + + +class CanonicalReuseTests(unittest.TestCase): + def test_producer_and_consumer_accept_verified_exact_evidence(self): + proof = run_once(FakeApi()) + self.assertEqual(proof["passed"], 1320) + self.assertEqual(proof["failed"], 0) + self.assertEqual(proof["mergeSha"], SOURCE) + self.assertEqual(proof["engineSha"], ENGINE) + + def test_manifest_never_claims_release_authority(self): + self.assertIs(fixture()["releasePromotionAuthority"], False) + self.assertTrue(fixture()["fullRegressionPassed"]) + + def test_reject_wrong_merge_sha(self): + api = FakeApi() + with self.assertRaisesRegex(consumer.ProofError, "stale/different PR merge tree|Timed out"): + consumer.validate_artifact_archive( + api.blob, merge_sha="f" * 40, engine_sha=ENGINE, + workflow_run_id=RUN, run_attempt=1, + ) + + def test_reject_wrong_engine_sha(self): + with self.assertRaisesRegex(consumer.ProofError, "engine SHA"): + consumer.validate_artifact_archive( + FakeApi().blob, merge_sha=SOURCE, engine_sha="f" * 40, + workflow_run_id=RUN, run_attempt=1, + ) + + def test_reject_manifest_run_attempt_mismatch(self): + with self.assertRaisesRegex(consumer.ProofError, "run attempt"): + consumer.validate_artifact_archive( + FakeApi().blob, merge_sha=SOURCE, engine_sha=ENGINE, + workflow_run_id=RUN, run_attempt=2, + ) + + def test_reject_changed_trx_bytes(self): + api = FakeApi() + api.blob = zip_archive(fixture(), TRX + b"malicious addition") + with self.assertRaisesRegex(consumer.ProofError, "TRX digest"): + run_once(api) + + def test_reject_nonpassing_trx_even_with_matching_hash(self): + bad = TRX.replace(b'failed="0"', b'failed="1"').replace(b'passed="1320"', b'passed="1319"') + with self.assertRaisesRegex(ValueError, "not entirely green"): + producer.counters_from_trx(bad) + + def test_reject_unsafe_archive_entry(self): + with self.assertRaisesRegex(consumer.ProofError, "unsafe"): + consumer.validate_artifact_archive( + zip_archive(fixture(), extra={"../../do-not-extract.txt": "unsafe"}), + merge_sha=SOURCE, engine_sha=ENGINE, + workflow_run_id=RUN, run_attempt=1, + ) + + def test_reject_duplicate_manifest(self): + blob = zip_archive( + fixture(), extra={"duplicate/ci-canonical-authority.json": json.dumps(fixture())} + ) + with self.assertRaisesRegex(consumer.ProofError, "duplicate"): + consumer.validate_artifact_archive( + blob, merge_sha=SOURCE, engine_sha=ENGINE, + workflow_run_id=RUN, run_attempt=1, + ) + + def test_latest_failed_canonical_run_must_reject_even_if_older_passed(self): + api = FakeApi(result="failure") + api.listing.append( + {"id": RUN - 1, "run_attempt": 1, "name": "Build ARSAS", + "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request", + "status": "completed", "conclusion": "success"} + ) + with self.assertRaisesRegex(consumer.ProofError, "Latest canonical.*failed"): + run_once(api) + + def test_pending_run_fails_closed_at_timeout(self): + with self.assertRaisesRegex(consumer.ProofError, "Timed out"): + run_once(FakeApi(run_status="in_progress", result=None)) + + def test_different_pr_head_or_branch_fails_closed(self): + api = FakeApi() + api.listing[0]["head_sha"] = "d" * 40 + with self.assertRaisesRegex(consumer.ProofError, "Timed out"): + run_once(api) + + def test_wrong_artifact_type_is_not_accepted(self): + api = FakeApi() + api.get = lambda url, binary=False: ( + {"workflow_runs": api.listing} if "/workflows/build.yml/runs?" in url + else {"artifacts": []} + ) + with self.assertRaisesRegex(consumer.ProofError, "exactly one"): + run_once(api) + + def test_cross_host_redirect_strips_bearer_token(self): + handler = consumer.StripCrossOriginAuthorization() + request = urllib.request.Request( + "https://api.github.com/repos/masarray/arsas/actions/artifacts/42/zip", + headers={"Authorization": "Bearer example-token"} + ) + redirected = handler.redirect_request( + request, None, 302, "Found", {}, + "https://artifact.blob.core.windows.net/archive" + ) + self.assertIsNotNone(redirected) + self.assertIsNone(redirected.get_header("Authorization")) + + def test_untrusted_repository_rejected(self): + with self.assertRaisesRegex(consumer.ProofError, "restricted"): + consumer.validate_identity( + "untrusted/arsas", HEAD, SOURCE, ENGINE, BRANCH + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-ci-canonical-reuse.py b/scripts/verify-ci-canonical-reuse.py new file mode 100644 index 000000000..2a4baaf06 --- /dev/null +++ b/scripts/verify-ci-canonical-reuse.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""CI-P2: reuse canonical Build ARSAS regression proof, fail closed. + +This proof reader runs on read-only GitHub token. The canonical Build ARSAS +must have completed successfully for the same PR HEAD and the exact synthetic +PR merge SHA; otherwise the Smart Discovery Merge Execution Guard fails. + +Downloaded artifacts are treated strictly as untrusted ZIP *data*. No file +from the artifact is extracted or executed. +""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from pathlib import PurePosixPath + +import importlib.util +from pathlib import Path + +_source = Path(__file__).with_name("write-ci-canonical-proof.py") +_spec = importlib.util.spec_from_file_location("arsas_ci_canonical_proof", _source) +assert _spec and _spec.loader +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +counters_from_trx = _module.counters_from_trx +sha = _module.sha + +MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 +MAX_EVIDENCE_BYTES = 12 * 1024 * 1024 +EXPECTED_ARTIFACT = "ARSAS-test-evidence" +EXPECTED_MANIFEST = "ci-canonical-authority.json" +EXPECTED_TRX = "arsas-tests.trx" + + +class ProofError(RuntimeError): + pass + + +def validate_identity(repo: str, head_sha: str, merge_sha: str, engine_sha: str, + branch: str) -> None: + if repo != "masarray/arsas": + raise ProofError("Canonical CI reuse is restricted to masarray/arsas") + for label, value in [ + ("PR head", head_sha), ("PR merge", merge_sha), ("engine", engine_sha) + ]: + try: + sha(value, label) + except ValueError as exc: + raise ProofError(str(exc)) from exc + if not branch or len(branch) > 200 or "\n" in branch: + raise ProofError("Invalid PR head branch identity") + + +def candidate_runs(payload: dict, head_sha: str, branch: str) -> list[dict]: + runs = payload.get("workflow_runs", []) + if not isinstance(runs, list): + raise ProofError("GitHub Actions run listing is malformed") + matches = [ + run for run in runs + if run.get("event") == "pull_request" + and run.get("name") == "Build ARSAS" + and run.get("head_sha") == head_sha + and run.get("head_branch") == branch + and isinstance(run.get("id"), int) + and isinstance(run.get("run_attempt"), int) + ] + return sorted(matches, key=lambda run: (run["id"], run["run_attempt"]), reverse=True) + + +def validate_artifact_archive( + payload: bytes, *, merge_sha: str, engine_sha: str, + workflow_run_id: int, run_attempt: int +) -> dict: + if not payload or len(payload) > MAX_ARCHIVE_BYTES: + raise ProofError("Canonical artifact is missing or exceeds bounded size") + + try: + archive = zipfile.ZipFile(io.BytesIO(payload)) + except (zipfile.BadZipFile, OSError) as exc: + raise ProofError("Canonical test evidence is not a valid ZIP archive") from exc + + with archive: + entries = {} + for info in archive.infolist(): + if info.is_dir(): + continue + name = info.filename.replace("\\", "/") + path = PurePosixPath(name) + if ( + path.is_absolute() + or ".." in path.parts + or ":" in name + or info.file_size < 0 + or info.file_size > MAX_EVIDENCE_BYTES + ): + raise ProofError("Canonical evidence archive has an unsafe or oversized entry") + basename = path.name + if basename in (EXPECTED_MANIFEST, EXPECTED_TRX): + if basename in entries: + raise ProofError(f"Canonical artifact contains duplicate {basename}") + entries[basename] = info + + if set(entries) != {EXPECTED_MANIFEST, EXPECTED_TRX}: + raise ProofError("Canonical manifest/TRX evidence is missing from artifact") + + try: + manifest = json.loads(archive.read(entries[EXPECTED_MANIFEST])) + trx = archive.read(entries[EXPECTED_TRX]) + except (ValueError, KeyError, RuntimeError, OSError) as exc: + raise ProofError("Canonical archive proof cannot be decoded safely") from exc + + if manifest.get("schemaVersion") != 1: + raise ProofError("Canonical proof schema version changed unexpectedly") + if manifest.get("kind") != "arsas-canonical-regression-evidence": + raise ProofError("Wrong canonical artifact provenance kind") + if manifest.get("canonicalWorkflow") != "Build ARSAS": + raise ProofError("Proof is not from canonical Build ARSAS") + if manifest.get("eventName") != "pull_request": + raise ProofError("Canonical proof was not produced by a PR run") + if manifest.get("workflowRunId") != workflow_run_id: + raise ProofError("Canonical workflow run ID differs from API run") + if manifest.get("runAttempt") != run_attempt: + raise ProofError("Canonical run attempt differs from API run attempt") + if manifest.get("exactSourceSha") != merge_sha: + raise ProofError("Canonical test proof is for a stale/different PR merge tree") + if manifest.get("exactEngineSha") != engine_sha: + raise ProofError("Canonical engine SHA differs from this PR's immutable lock") + if manifest.get("fullRegressionPassed") is not True: + raise ProofError("Canonical test evidence does not prove full test PASS") + if manifest.get("releasePromotionAuthority") is not False: + raise ProofError("Canonical regression proof misrepresents release authority") + if manifest.get("testFile") != EXPECTED_TRX: + raise ProofError("Canonical proof references unexpected TRX filename") + if manifest.get("testTrxSha256") != hashlib.sha256(trx).hexdigest(): + raise ProofError("Canonical TRX digest differs from recorded proof") + try: + actual = counters_from_trx(trx) + except (ValueError, ArithmeticError) as exc: + raise ProofError("TRX does not prove a fully passing test suite") from exc + if manifest.get("testCounters") != actual: + raise ProofError("Canonical TRX counters disagree with manifest") + return { + "canonicalRunId": workflow_run_id, + "canonicalRunAttempt": run_attempt, + "mergeSha": merge_sha, + "engineSha": engine_sha, + "passed": actual["passed"], + "failed": actual["failed"], + "notExecuted": actual["notExecuted"], + "artifactSha256": hashlib.sha256(payload).hexdigest(), + } + + +def choose_artifact(payload: dict, run_id: int) -> dict: + artifacts = payload.get("artifacts", []) + matches = [ + obj for obj in artifacts if obj.get("name") == EXPECTED_ARTIFACT + and obj.get("expired") is False and isinstance(obj.get("id"), int) + and obj.get("workflow_run", {}).get("id", run_id) == run_id + ] + if len(matches) != 1: + raise ProofError("Expected exactly one non-expired canonical test artifact") + return matches[0] + + +class StripCrossOriginAuthorization(urllib.request.HTTPRedirectHandler): + """Do not forward GitHub bearer credentials to artifact blob hosts.""" + + def redirect_request(self, request, fp, code, msg, headers, newurl): + redirected = super().redirect_request( + request, fp, code, msg, headers, newurl + ) + if redirected is not None: + before = urllib.parse.urlparse(request.full_url).netloc.lower() + after = urllib.parse.urlparse(newurl).netloc.lower() + if before != after: + redirected.remove_header("Authorization") + return redirected + + +class GitHubReadOnly: + def __init__(self, token: str): + if not token: + raise ProofError("A read-only GITHUB_TOKEN is required") + self.token = token + + def get(self, url: str, binary: bool = False): + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ARSAS-CI-P2-Canonical-Reuse", + }, + method="GET", + ) + try: + opener = urllib.request.build_opener(StripCrossOriginAuthorization()) + with opener.open(req, timeout=30) as reply: + data = reply.read(MAX_ARCHIVE_BYTES + 1 if binary else 3 * 1024 * 1024) + if binary: + return data + return json.loads(data) + except (urllib.error.HTTPError, urllib.error.URLError, ValueError) as exc: + raise ProofError(f"GitHub read-only evidence request failed: {exc}") from exc + + +def verify_canonical( + api: GitHubReadOnly, *, repository: str, branch: str, + head_sha: str, merge_sha: str, engine_sha: str, + wait_seconds: int = 960, poll_seconds: int = 10 +) -> dict: + validate_identity(repository, head_sha, merge_sha, engine_sha, branch) + if wait_seconds < 0 or poll_seconds < 1: + raise ProofError("Invalid bounded wait policy") + + base = "https://api.github.com/repos/" + repository + query = urllib.parse.urlencode( + {"event": "pull_request", "head_sha": head_sha, "per_page": 100} + ) + url = f"{base}/actions/workflows/build.yml/runs?{query}" + deadline = time.monotonic() + wait_seconds + previous = None + + while True: + runs = candidate_runs(api.get(url), head_sha, branch) + if runs: + run = runs[0] # Never use an older successful attempt over a newer failure. + previous = f"{run['id']}/{run['run_attempt']} {run.get('status')}/{run.get('conclusion')}" + if run.get("status") == "completed": + if run.get("conclusion") != "success": + raise ProofError( + f"Latest canonical Build ARSAS PR run failed: {previous}" + ) + run_id, attempt = run["id"], run["run_attempt"] + artifact = choose_artifact( + api.get(f"{base}/actions/runs/{run_id}/artifacts?per_page=100"), + run_id, + ) + blob = api.get(artifact["archive_download_url"], binary=True) + try: + return validate_artifact_archive( + blob, merge_sha=merge_sha, engine_sha=engine_sha, + workflow_run_id=run_id, run_attempt=attempt + ) + except ProofError as exc: + if "stale/different PR merge tree" not in str(exc): + raise + # A stale green run is not authoritative for the current base; + # a newly triggered canonical build can still arrive. + previous = f"{previous} (stale merge tree)" + if time.monotonic() >= deadline: + raise ProofError( + f"Timed out waiting for matching canonical Build ARSAS evidence " + f"for head={head_sha}, merge={merge_sha}, last={previous}" + ) + time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--head-branch", required=True) + parser.add_argument("--head-sha", required=True) + parser.add_argument("--merge-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--wait-seconds", type=int, default=960) + parser.add_argument("--output", default="") + args = parser.parse_args() + result = verify_canonical( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + repository=args.repository, + branch=args.head_branch, + head_sha=args.head_sha, + merge_sha=args.merge_sha, + engine_sha=args.engine_sha, + wait_seconds=args.wait_seconds, + ) + payload = json.dumps(result, sort_keys=True) + print("Canonical full-regression reuse PASS: " + payload) + if args.output: + with open(args.output, "w", encoding="utf-8") as stream: + stream.write(payload + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write-ci-canonical-proof.py b/scripts/write-ci-canonical-proof.py new file mode 100644 index 000000000..7ac652b78 --- /dev/null +++ b/scripts/write-ci-canonical-proof.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Produce exact-SHA canonical Build ARSAS regression proof from verified TRX. + +This script runs only after the canonical full test step succeeded. It writes +into the already-uploaded ARSAS-test-evidence artifact; there is no new artifact +publication surface and no release authority change. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path + + +def sha(value: str, label: str) -> str: + value = (value or "").lower() + if len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value): + raise ValueError(f"{label} must be an immutable lowercase 40-hex SHA") + return value + + +def git_head(path: Path) -> str: + return sha( + subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "HEAD"], stderr=subprocess.PIPE + ).decode("ascii").strip(), + "verified checkout", + ) + + +def counters_from_trx(data: bytes) -> dict[str, int]: + root = ET.fromstring(data) + counters = root.find(".//{*}Counters") + if counters is None: + raise ValueError("TRX has no test-result Counters element") + + result = {} + for name in ("total", "passed", "failed", "notExecuted", "executed"): + value = int(counters.attrib.get(name, -1)) + if value < 0: + raise ValueError(f"TRX Counters missing valid {name}") + result[name] = value + if ( + result["total"] <= 0 + or result["failed"] != 0 + or result["notExecuted"] != 0 + or result["passed"] != result["total"] + or result["executed"] != result["total"] + ): + raise ValueError( + f"Canonical full suite is not entirely green: {result}" + ) + return result + + +def create_manifest( + source_sha: str, + checkout_sha: str, + engine_expected: str, + engine_actual: str, + workflow_run_id: int, + run_attempt: int, + event_name: str, + trx: bytes, + trx_name: str, +) -> dict: + for label, value in ( + ("source", source_sha), + ("checkout", checkout_sha), + ("engine", engine_expected), + ("engine checkout", engine_actual), + ): + sha(value, label) + + if source_sha != checkout_sha or engine_expected != engine_actual: + raise ValueError("Source or engine checkout is not the exact expected SHA") + if workflow_run_id <= 0 or run_attempt <= 0: + raise ValueError("Invalid GitHub workflow run identity") + if event_name not in {"push", "pull_request", "workflow_dispatch"}: + raise ValueError("Unexpected canonical workflow event") + if Path(trx_name).name != trx_name or not trx_name.endswith(".trx"): + raise ValueError("TRX filename is invalid") + + test_counters = counters_from_trx(trx) + return { + "schemaVersion": 1, + "kind": "arsas-canonical-regression-evidence", + "canonicalWorkflow": "Build ARSAS", + "workflowRunId": workflow_run_id, + "runAttempt": run_attempt, + "eventName": event_name, + "exactSourceSha": checkout_sha, + "exactEngineSha": engine_actual, + "testFile": trx_name, + "testTrxSha256": hashlib.sha256(trx).hexdigest(), + "testCounters": test_counters, + "fullRegressionPassed": True, + "releasePromotionAuthority": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--app-dir", type=Path, required=True) + parser.add_argument("--engine-dir", type=Path, required=True) + parser.add_argument("--test-results-dir", type=Path, required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--run-id", type=int, required=True) + parser.add_argument("--attempt", type=int, required=True) + parser.add_argument("--event-name", required=True) + args = parser.parse_args() + + trx_file = args.test_results_dir / "arsas-tests.trx" + if not trx_file.is_file(): + raise FileNotFoundError(f"Canonical test evidence is missing: {trx_file}") + blob = trx_file.read_bytes() + manifest = create_manifest( + source_sha=sha(args.source_sha, "source"), + checkout_sha=git_head(args.app_dir), + engine_expected=sha(args.engine_sha, "expected engine"), + engine_actual=git_head(args.engine_dir), + workflow_run_id=args.run_id, + run_attempt=args.attempt, + event_name=args.event_name, + trx=blob, + trx_name=trx_file.name, + ) + output = args.test_results_dir / "ci-canonical-authority.json" + output.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") + print( + f"Canonical SHA proof: source={manifest['exactSourceSha']} " + f"engine={manifest['exactEngineSha']}, " + f"tests={manifest['testCounters']['passed']} PASS; " + f"run={manifest['workflowRunId']}/{manifest['runAttempt']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a70a26888a05689e72f68d2d84401b2869d5f29e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 10:21:45 +0700 Subject: [PATCH 04/99] ci(p2b): reuse canonical ARSAS tests while retaining R10 engine regression Eliminate duplicate ARSAS restore/build/full-test invocation from R10 Mainline Readiness; preserve independent 988/988 engine tests and physical convergence predicates. Require exact PR synthetic merge SHA/pinned engine/run-attested 1320/1320 canonical ARSAS TRX before readiness can pass. Manual reusable tests retained. All 12 PR checks passed. --- .../smart-discovery-mainline-readiness.yml | 60 +++++++++++++- .../audits/CI_P2B_MAINLINE_READINESS_REUSE.md | 78 +++++++++++++++++++ evidence/ci-workflow-budget.json | 49 ++++++++++-- ...overyProductionPromotionRegressionTests.cs | 22 +++++- 4 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 docs/audits/CI_P2B_MAINLINE_READINESS_REUSE.md diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index 9e4cb87bf..347323cb4 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -34,6 +34,10 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +permissions: + contents: read + actions: read + jobs: mainline-readiness: name: Require R10 physical authority before mainline review @@ -102,9 +106,57 @@ jobs: 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: Build and test ARSAS candidate + - name: Set up Python for canonical regression evidence verification + if: github.event_name == 'pull_request' + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Require successful canonical Build ARSAS for same PR merge and engine + if: github.event_name == 'pull_request' shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} 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 + python .\scripts\verify-ci-canonical-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch $env:PR_HEAD_BRANCH ` + --head-sha $env:PR_HEAD_SHA ` + --merge-sha $env:PR_MERGE_SHA ` + --engine-sha $env:ENGINE_COMMIT ` + --wait-seconds 960 ` + --output (Join-Path $env:RUNNER_TEMP 'ci-r10-canonical-reuse.json') + if ($LASTEXITCODE -ne 0) { + throw 'R10 Mainline Readiness cannot reuse canonical ARSAS regression evidence.' + } + + - name: Report independent engine test plus exact ARSAS regression reuse + if: github.event_name == 'pull_request' + shell: powershell + run: | + $proof = Get-Content (Join-Path $env:RUNNER_TEMP 'ci-r10-canonical-reuse.json') -Raw | ConvertFrom-Json + if ([int]$proof.failed -ne 0 -or [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1 -or $proof.engineSha -ne $env:ENGINE_COMMIT) { + throw 'R10 accepted engine/regression evidence is incomplete.' + } + @( + '## R10 Mainline Readiness — independent engine + canonical ARSAS tests', + '', + "- R10 engine integration SHA: $env:ENGINE_COMMIT", + "- canonical Build ARSAS run: $($proof.canonicalRunId)", + "- exact ARSAS PR merge SHA: $($proof.mergeSha)", + "- full ARSAS regression: $($proof.passed) passed, $($proof.failed) failed", + '- Historical physical authority validated independently before this reuse' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + manual-regression: + name: Manual exact-SHA ARSAS regression for R10 readiness + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} diff --git a/docs/audits/CI_P2B_MAINLINE_READINESS_REUSE.md b/docs/audits/CI_P2B_MAINLINE_READINESS_REUSE.md new file mode 100644 index 000000000..3840c957e --- /dev/null +++ b/docs/audits/CI_P2B_MAINLINE_READINESS_REUSE.md @@ -0,0 +1,78 @@ +# CI-P2B — Mainline Readiness without duplicate application compilation + +Companion: [CI-P2A canonical exact-SHA reuse](CI_P2A_CANONICAL_REGRESSION_REUSE.md). +Issue: [#432](https://github.com/masarray/arsas/issues/432). + +## What changed + +`Smart Discovery Mainline Readiness` already performs an R10 physical acceptance +audit and independent **ARIEC61850 engine** restore/build/full tests. Previously +it also repeated the entire ARSAS application restore/build/full tests, which +canonical `Build ARSAS` runs unconditionally on the same PR merge tree. + +CI-P2B preserves **all** original R10 physical convergence predicates and +the independently checked-out exact engine head and engine test suite. + +Only the duplicate *application* restore/build/test portion is replaced with +the reviewed CI-P2A `scripts/verify-ci-canonical-reuse.py` consumer. + +That consumer can pass only if the latest matching canonical Build ARSAS +`pull_request` run: + +1. completed successfully, including packaging and portable smoke; +2. has the same PR head SHA and branch; +3. produced all-pass canonical TRX evidence for **this exact synthetic merge SHA**; +4. used exactly the same immutable engine commit that passed R10 physical + convergence checks; +5. published a manifest matching run ID, attempt, TRX SHA-256 and test counts. + +Failures, missing evidence, stale merges and API/timeouts still fail closed. +Branch-protection settings are not assumed. Both check names remain visible. + +For `workflow_dispatch`, the independent engine checks still run and an +additional reusable exact-SHA test-only ARSAS job provides manual coverage. + +## Costs and bounds + +The P0 immutable historical baseline is unchanged. CI-P2B reduces the +static workflow command maxima by **one more** duplicate .NET +restore/build/test, in addition to CI-P2A: + +| Metric | CI-P1 | CI-P2A | CI-P2B | +| --- | ---: | ---: | ---: | +| `dotnet restore` occurrences | 16 | 15 | 14 | +| `dotnet build` occurrences | 16 | 15 | 14 | +| `dotnet test` occurrences | 18 | 17 | 16 | +| `actions/setup-dotnet` uses | 13 | 12 | 12 | +| `actions/setup-python` uses | 13 | 13 | 14 | + +The +1 Python action ensures the same audited verifier runs reproducibly +on the Windows R10 engine test runner. Unlike the removed application build, +it does not trigger another compilation. Actual wall-time still depends +on canonical Build ARSAS, engine tests, runner load, and restore caches. + +## Physical authority boundary + +This milestone does **not** modify: + +- SCL, Discovery, MMS, RCB or report runtime behavior; +- accepted R10 Ed1/Ed2 physical evidence; +- exact engine lock / historical physical tested head; +- ARIEC61850 engine tests or their post-merge authority; +- P141 SCL-assisted interoperability draft work; +- Golden Budget, Provenance, Repeat-run, Production or Field Capture guards; +- any release/installer workflow. + +## Acceptance + +Required: P0 integrity and budget, canonical Build ARSAS, Merge Execution +Guard, Mainline Readiness (engine full tests **plus** canonical ARSAS proof), +Field Capture smoke, Golden/Promotion guards — all on same exact PR head. + +Regression rollback is narrow: restore the old application full-test step in +Mainline Readiness, leave independently retained engine/R10 validation and +canonical Build ARSAS untouched. + +CI-P2C can later consolidate Field Capture packaging only with a separately +verified immutable binary artifact and all physical capture-specific checks. +No such retirement is authorized by P2B. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 8988a8343..2865ecc7e 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P2A canonical regression reuse — first verified duplicate removal", - "policy": "CI-P0 observed baseline is immutable. CI-P1 bounded shadow remains opt-in. CI-P2A removes the redundant Merge Execution Guard restore/build/full-test pipeline; that guard must consume a completed successful canonical Build ARSAS proof for identical PR merge source and engine SHAs, otherwise fail closed. Subsequent reductions require equivalent explicit evidence.", + "baselineName": "CI-P2B: canonical ARSAS regression reused by Mainline Readiness", + "policy": "CI-P0 historical observed baseline and CI-P1 opt-in shadow remain recorded; P2A+P2B now reuse canonical Build ARSAS SHA/TRX proof from Merge Execution and Mainline Readiness. Independent R10 engine tests, physical authority assertions and production gates remain unchanged. Small Python setup overhead is explicit and bounded.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -20,10 +20,10 @@ "workflowFiles": 31, "actionsCheckout": 35, "actionsSetupDotnet": 12, - "actionsSetupPython": 13, - "dotnetRestore": 15, - "dotnetBuild": 15, - "dotnetTest": 17, + "actionsSetupPython": 14, + "dotnetRestore": 14, + "dotnetBuild": 14, + "dotnetTest": 16, "actionsUploadArtifact": 34, "workflowsWithDotnetBuild": 12, "workflowsWithDotnetTest": 11 @@ -95,9 +95,44 @@ "source": "exact PR synthetic merge SHA", "engine": "exact immutable engine lock SHA", "canonicalRun": "successful latest matching PR head/branch run", - "tests": "signed-by-workflow-at-execution manifest plus verified TRX SHA-256 and passing counters", + "tests": "Workflow-produced, not cryptographically signed, exact-SHA manifest plus independently verified all-pass TRX SHA-256 and counters.", "noProductionPromotion": true }, "rollback": "Restore original smart-discovery-merge-execution-guard.yml build/test steps if canonical evidence or cross-run matching causes flakiness; never skip P0-5h source-contract checks." + }, + "ciP2MainlineReadiness": { + "issueNumber": 432, + "lane": "smart-discovery-mainline-readiness", + "approach": "After preserving R10 physical authority and running the engine's independent full restore/build/test, reuse immutable successful canonical Build ARSAS exact PR merge/engine SHA artifact instead of a second application restore/build/test. No cross-run proof bypass.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 12, + "actionsSetupPython": 14, + "dotnetRestore": 14, + "dotnetBuild": 14, + "dotnetTest": 16, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "existingR10EngineFullRegressionRetained": true, + "existingPhysicalAuthorityChecksRetained": true, + "exactCanonicalProofRequiredOnPullRequest": true, + "manualExecutionRunsReusableApplicationFullTests": true, + "actionsPermissionScope": "read-only contents/actions", + "rollback": "Restore direct application restore/build/test step if verifier fails legitimate parity; retain current R10 evidence and engine full tests without modification." } } diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index c91bb777c..2c142d4b2 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -82,7 +82,27 @@ public void R10_DedicatedMainlineGateRequiresPhysicalAuthorityAndExactMergedEngi Assert.Contains("physicalTestedCommit", workflow, StringComparison.Ordinal); Assert.Contains("mergedMainTree", workflow, StringComparison.Ordinal); Assert.Contains("Checkout exact merged ARIEC61850 authority", workflow, StringComparison.Ordinal); - Assert.Contains("Build and test ARSAS candidate", workflow, StringComparison.Ordinal); + Assert.Contains("Validate and test merged engine authority", workflow, StringComparison.Ordinal); + Assert.Contains(@"dotnet restore ..\ARIEC61850\ARIEC61850.sln", workflow, StringComparison.Ordinal); + Assert.Contains(@"dotnet build ..\ARIEC61850\ARIEC61850.sln", workflow, StringComparison.Ordinal); + Assert.Contains(@"dotnet test ..\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj", workflow, StringComparison.Ordinal); + + // The ARSAS suite is no longer compiled a second time here. Instead, + // this independent R10 gate fails closed unless the same PR synthetic + // merge tree and pinned engine have an all-pass canonical Build ARSAS + // run with matching TRX identity. Manual dispatch retains a reusable + // exact-SHA full regression job. + Assert.Contains("Require successful canonical Build ARSAS for same PR merge and engine", workflow, StringComparison.Ordinal); + Assert.Contains("scripts\\verify-ci-canonical-reuse.py", workflow, StringComparison.Ordinal); + Assert.Contains("--head-sha $env:PR_HEAD_SHA", workflow, StringComparison.Ordinal); + Assert.Contains("--merge-sha $env:PR_MERGE_SHA", workflow, StringComparison.Ordinal); + Assert.Contains("--engine-sha $env:ENGINE_COMMIT", workflow, StringComparison.Ordinal); + Assert.Contains("--wait-seconds 960", workflow, StringComparison.Ordinal); + Assert.Contains("manual-regression:", workflow, StringComparison.Ordinal); + Assert.Contains("_ci-p1-windows-build-test.yml", workflow, StringComparison.Ordinal); + Assert.DoesNotContain(@"dotnet restore .\ArIED61850Tester.sln", workflow, StringComparison.Ordinal); + Assert.DoesNotContain(@"dotnet build .\ArIED61850Tester.sln", workflow, StringComparison.Ordinal); + Assert.DoesNotContain(@"dotnet test .\tests\ARSAS.Tests\ARSAS.Tests.csproj", workflow, StringComparison.Ordinal); Assert.DoesNotContain("perf/smart-ied-discovery", workflow, StringComparison.Ordinal); Assert.DoesNotContain("EvidenceEngineBaselineCommit", workflow, StringComparison.Ordinal); } From e6b29e8a809149faf4e2d411854f95f3186476ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 10:41:10 +0700 Subject: [PATCH 05/99] ci(p2c): reuse canonical tests in production route guard Reuse exact merge-SHA and immutable engine canonical Build ARSAS all-pass evidence instead of a third independent ARSAS restore/build/test. Preserve source-route verification, SHA256 immutability, physical R10 authority, manual full tests and production protections. All ten PR workflow checks passed. --- .../smart-discovery-production-promotion.yml | 127 +++++++++++++----- docs/audits/CI_P2C_PRODUCTION_ROUTE_REUSE.md | 44 ++++++ evidence/ci-workflow-budget.json | 53 ++++++-- 3 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 docs/audits/CI_P2C_PRODUCTION_ROUTE_REUSE.md diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml index 8a221d4c1..46370de00 100644 --- a/.github/workflows/smart-discovery-production-promotion.yml +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -34,49 +34,58 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: smart-discovery-production-promotion-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: verify-production-route: name: Verify physical-proven Smart Discovery production parity runs-on: windows-latest - timeout-minutes: 30 + timeout-minutes: 25 steps: - name: Checkout exact ARSAS candidate uses: actions/checkout@v7 with: path: ArIED61850Tester + fetch-depth: 1 + persist-credentials: false - - name: Resolve immutable ARIEC61850 engine lock - shell: powershell + - name: Verify exact tested ARSAS candidate SHA + shell: pwsh + env: + EXPECTED_SOURCE_SHA: ${{ github.sha }} 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 production lock.' + $actual = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:EXPECTED_SOURCE_SHA) { + throw "Smart Discovery route guard checkout mismatch. Expected $env:EXPECTED_SOURCE_SHA, got $actual." } - "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - name: Checkout exact ARIEC61850 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().ToLowerInvariant() - if ($actual -ne $env:ARIEC61850_COMMIT.ToLowerInvariant()) { - throw "Engine pin mismatch: expected $env:ARIEC61850_COMMIT got $actual" - } + - name: Setup Python for bounded canonical evidence verification + uses: actions/setup-python@v6 + with: + python-version: '3.11' - - name: Setup .NET 8 - uses: actions/setup-dotnet@v6 + - name: Run offline canonical-proof fail-closed fixtures + run: python .\ArIED61850Tester\scripts\test-ci-canonical-reuse.py + + - id: engine + name: Resolve exact pinned ARIEC61850 integration authority + uses: ./ArIED61850Tester/.github/actions/resolve-ariec-engine with: - dotnet-version: 8.0.x + lock-path: ArIED61850Tester/engines/ARIEC61850.lock.json - - name: Verify tracked production route before build + - name: Verify tracked production route and preserve source hash shell: powershell run: | $native = '.\ArIED61850Tester\Services\NativeIec61850Client.cs' $before = (Get-FileHash $native -Algorithm SHA256).Hash + "SMART_ROUTE_SOURCE_SHA256=$before" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append & .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -VerifyOnly if ($LASTEXITCODE -ne 0) { throw 'Smart Discovery route verification failed.' } $after = (Get-FileHash $native -Algorithm SHA256).Hash @@ -95,25 +104,75 @@ jobs: } } - - name: Restore build and test the exact production route - shell: powershell + - name: Require matching canonical Build ARSAS full regression evidence + if: github.event_name == 'pull_request' + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + PINNED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} run: | - dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln - if ($LASTEXITCODE -ne 0) { throw 'ARSAS restore failed.' } - dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore - if ($LASTEXITCODE -ne 0) { throw 'ARSAS production-route build failed.' } - dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore - if ($LASTEXITCODE -ne 0) { throw 'ARSAS production-route regression tests failed.' } - - - name: Prove build did not select or synthesize another route + python .\ArIED61850Tester\scripts\verify-ci-canonical-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch $env:PR_HEAD_BRANCH ` + --head-sha $env:PR_HEAD_SHA ` + --merge-sha $env:PR_MERGE_SHA ` + --engine-sha $env:PINNED_ENGINE_SHA ` + --wait-seconds 960 ` + --output (Join-Path $env:RUNNER_TEMP 'ci-production-route-canonical-proof.json') + if ($LASTEXITCODE -ne 0) { + throw 'Production route guard requires matching exact-SHA canonical full-regression PASS.' + } + + - name: Summarize exact-source canonical regression parity + if: github.event_name == 'pull_request' + shell: pwsh + run: | + $proof = Get-Content (Join-Path $env:RUNNER_TEMP 'ci-production-route-canonical-proof.json') -Raw | ConvertFrom-Json + if ([int]$proof.failed -ne 0 -or [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1 -or + $proof.engineSha -ne '${{ steps.engine.outputs.commit }}') { + throw 'Production-route canonical full-regression proof is incomplete.' + } + @( + '## Smart Discovery production route — canonical full-regression reuse', + '', + "- exact PR merge SHA: $($proof.mergeSha)", + "- exact pinned engine SHA: $($proof.engineSha)", + "- canonical Build ARSAS run: $($proof.canonicalRunId)", + "- full regression: $($proof.passed) passed, $($proof.failed) failed", + '- Production route source verified independently without any mutation', + '- No promotion or release action was performed' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Prove canonical route remains immutable after evidence verification shell: powershell run: | $native = '.\ArIED61850Tester\Services\NativeIec61850Client.cs' + $actualHash = (Get-FileHash $native -Algorithm SHA256).Hash + if ($actualHash -cne $env:SMART_ROUTE_SOURCE_SHA256) { + throw 'Route source changed during canonical evidence verification.' + } & .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -VerifyOnly - if ($LASTEXITCODE -ne 0) { throw 'Post-build Smart Discovery route verification failed.' } + if ($LASTEXITCODE -ne 0) { throw 'Post-proof Smart Discovery route verification failed.' } + $finalHash = (Get-FileHash $native -Algorithm SHA256).Hash + if ($finalHash -cne $env:SMART_ROUTE_SOURCE_SHA256) { + throw 'Route verifier mutated production source.' + } $targets = Get-Content '.\ArIED61850Tester\Directory.Build.targets' -Raw if ($targets -match 'GITHUB_WORKFLOW' -or $targets -match 'SmartDiscoveryProductionPromoted') { throw 'Discovery runtime still depends on workflow name or promotion switch.' } - Write-Host 'Production, R7, installer and local builds now compile the same tracked Smart Discovery route.' + Write-Host 'Same tracked Smart Discovery route verified; exact-SHA canonical Build ARSAS supplies full regression proof.' + + manual-regression: + name: Manual exact-SHA full production-route regression + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} diff --git a/docs/audits/CI_P2C_PRODUCTION_ROUTE_REUSE.md b/docs/audits/CI_P2C_PRODUCTION_ROUTE_REUSE.md new file mode 100644 index 000000000..0983e78c1 --- /dev/null +++ b/docs/audits/CI_P2C_PRODUCTION_ROUTE_REUSE.md @@ -0,0 +1,44 @@ +# CI-P2C — Canonical regression reuse in production-route guard + +Issue #436. Existing accepted milestones: P2A #431 and P2B #433. + +## Scope + +On pull requests, Smart Discovery Production Promotion Guard independently verifies +the physical-proven Smart Discovery route before and after checking exact-SHA +canonical Build ARSAS full-regression evidence. This is a CI execution +optimization, not production release permission or a new field acceptance. + +## Independent checks retained + +- Checkout exact triggering synthetic PR merge SHA and assert git HEAD. +- Validate immutable engine lock without substituting historical physical SHA. +- Run enable-smart-discovery-capture.ps1 -VerifyOnly before proof consumption. +- Verify the route and critical reset/dispose source markers. +- Preserve NativeIec61850Client.cs SHA256 before/after evidence verification. +- Run -VerifyOnly again and reject workflow/promotion dependent targets. + +## Exact canonical proof required + +The unchanged scripts/verify-ci-canonical-reuse.py rejects: +- wrong PR branch/head SHA or synthetic merge SHA; +- wrong engine lock SHA, stale or failed newest canonical run; +- ambiguous/missing artifacts, malformed or unsafe archives; +- altered TRX bytes, bad test counters or nonzero failures/skips; +- invalid API provenance or unbounded evidence. + +The tool uses read-only Actions API and a bounded waiting policy. +Manual workflow_dispatch retains reusable full regression separately. +Packaging, field capture, R10 independent engine testing, release and +production promotion remain owned by existing workflows. + +## Inventory and rollback + +Static source cost from P2B to P2C: dotnet restore/build/test 14/14/16 +to 13/13/15; setup-dotnet 12 to 11; setup-python 14 to 15. +Historical P0 observations are unchanged. Lowered limits are recorded in +evidence/ci-workflow-budget.json. + +If exact cross-run matching is unreliable, revert the workflow migration +and restore direct dotnet regression execution; never drop route assertions +or rewrite historical IEC 61850 physical evidence. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 2865ecc7e..836eeabf5 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P2B: canonical ARSAS regression reused by Mainline Readiness", - "policy": "CI-P0 historical observed baseline and CI-P1 opt-in shadow remain recorded; P2A+P2B now reuse canonical Build ARSAS SHA/TRX proof from Merge Execution and Mainline Readiness. Independent R10 engine tests, physical authority assertions and production gates remain unchanged. Small Python setup overhead is explicit and bounded.", + "baselineName": "CI-P2C: reuse canonical regression in production route guard", + "policy": "P0 immutable historical baseline and P1 opt-in shadow remain recorded. P2A/P2B/P2C successively remove redundant ARSAS restore/build/test invocations by verifying the latest exact PR synthetic-merge-SHA and pinned-engine canonical Build ARSAS TRX, while preserving independent source-route and physical engine guards.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -19,14 +19,14 @@ "limits": { "workflowFiles": 31, "actionsCheckout": 35, - "actionsSetupDotnet": 12, - "actionsSetupPython": 14, - "dotnetRestore": 14, - "dotnetBuild": 14, - "dotnetTest": 16, + "actionsSetupDotnet": 11, + "actionsSetupPython": 15, + "dotnetRestore": 13, + "dotnetBuild": 13, + "dotnetTest": 15, "actionsUploadArtifact": 34, - "workflowsWithDotnetBuild": 12, - "workflowsWithDotnetTest": 11 + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 }, "interpretation": [ "The limits freeze CI sprawl; they are not performance targets and do not prove semantic necessity.", @@ -134,5 +134,40 @@ "manualExecutionRunsReusableApplicationFullTests": true, "actionsPermissionScope": "read-only contents/actions", "rollback": "Restore direct application restore/build/test step if verifier fails legitimate parity; retain current R10 evidence and engine full tests without modification." + }, + "ciP2ProductionRoute": { + "issueNumber": 436, + "lane": "smart-discovery-production-promotion", + "approach": "For PRs independently verify source route and its unchanged SHA256 before/after consuming exact-PR-merge + exact-engine-commit full-regression canonical Build ARSAS proof. Preserve manual full regression via reusable Windows workflow.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 12, + "actionsSetupPython": 14, + "dotnetRestore": 14, + "dotnetBuild": 14, + "dotnetTest": 16, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 15, + "dotnetRestore": 13, + "dotnetBuild": 13, + "dotnetTest": 15, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 + }, + "evidence": "Latest successful canonical Build ARSAS matching exact PR head/branch, merge SHA, immutable engine SHA, passing all-test TRX digest/counters", + "sourceRouteChecksIndependent": true, + "sourceSha256BeforeAfter": true, + "noPhysicalAuthorityRewrite": true, + "noPromotionActions": true, + "rollback": "Revert this workflow change to restore direct production-route .NET build/test if canonical proof or sequencing fails. Never omit source route checks." } } From 25c5d8daf2bebbe80c5b7ef5719993b66d8b721d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 11:26:15 +0700 Subject: [PATCH 06/99] ci(p2d): stop canonical consumers at exact regression artifact readiness Allow selected canonical regression consumers to finish as soon as the exact all-pass manifest/TRX artifact is available, while keeping strict verifier mode as default and Build ARSAS packaging/smoke as an independent check. Ten of ten PR workflows passed; observed savings were about five runner-minutes on the validation PR. --- .../smart-discovery-mainline-readiness.yml | 5 + .../smart-discovery-merge-execution-guard.yml | 5 + .../smart-discovery-production-promotion.yml | 5 + docs/audits/CI_P2D_ARTIFACT_READY_PROOF.md | 55 +++++++++++ evidence/ci-workflow-budget.json | 42 +++++++- scripts/test-ci-canonical-reuse.py | 38 ++++++- scripts/verify-ci-canonical-reuse.py | 99 ++++++++++++++----- 7 files changed, 219 insertions(+), 30 deletions(-) create mode 100644 docs/audits/CI_P2D_ARTIFACT_READY_PROOF.md diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index 347323cb4..2744b182a 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -128,6 +128,7 @@ jobs: --merge-sha $env:PR_MERGE_SHA ` --engine-sha $env:ENGINE_COMMIT ` --wait-seconds 960 ` + --allow-in-progress-artifact ` --output (Join-Path $env:RUNNER_TEMP 'ci-r10-canonical-reuse.json') if ($LASTEXITCODE -ne 0) { throw 'R10 Mainline Readiness cannot reuse canonical ARSAS regression evidence.' @@ -149,6 +150,10 @@ jobs: "- canonical Build ARSAS run: $($proof.canonicalRunId)", "- exact ARSAS PR merge SHA: $($proof.mergeSha)", "- full ARSAS regression: $($proof.passed) passed, $($proof.failed) failed", + "- canonical proof stage: $($proof.proofStage)", + "- canonical workflow completed: $($proof.workflowCompleted)", + "- packaging/smoke proven here: $($proof.packagingSmokeProven)", + '- Build ARSAS remains the independent packaging/smoke check', '- Historical physical authority validated independently before this reuse' ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml index 69c3fa982..b02c6835a 100644 --- a/.github/workflows/smart-discovery-merge-execution-guard.yml +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -121,6 +121,7 @@ jobs: --merge-sha "$PR_MERGE_SHA" \ --engine-sha "$PINNED_ENGINE_SHA" \ --wait-seconds 960 \ + --allow-in-progress-artifact \ --output "$RUNNER_TEMP/ci-canonical-reuse-proof.json" - name: Summarize exact canonical-regression reuse @@ -140,6 +141,10 @@ jobs: out.write(f"- exact PR merge SHA: {proof['mergeSha']}\\n") out.write(f"- exact engine SHA: {proof['engineSha']}\\n") out.write(f"- full regression: {proof['passed']} passed, {proof['failed']} failed\\n") + out.write(f"- proof stage: {proof['proofStage']}\\n") + out.write(f"- canonical workflow completed: {proof['workflowCompleted']}\\n") + out.write(f"- packaging/smoke proven by this guard: {proof['packagingSmokeProven']}\\n") + out.write("- Build ARSAS remains the independent packaging/smoke check\\n") out.write("- Source P0-5h ordered-merge contract: passed independently\\n") PY diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml index 46370de00..59f76e407 100644 --- a/.github/workflows/smart-discovery-production-promotion.yml +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -121,6 +121,7 @@ jobs: --merge-sha $env:PR_MERGE_SHA ` --engine-sha $env:PINNED_ENGINE_SHA ` --wait-seconds 960 ` + --allow-in-progress-artifact ` --output (Join-Path $env:RUNNER_TEMP 'ci-production-route-canonical-proof.json') if ($LASTEXITCODE -ne 0) { throw 'Production route guard requires matching exact-SHA canonical full-regression PASS.' @@ -143,6 +144,10 @@ jobs: "- exact pinned engine SHA: $($proof.engineSha)", "- canonical Build ARSAS run: $($proof.canonicalRunId)", "- full regression: $($proof.passed) passed, $($proof.failed) failed", + "- canonical proof stage: $($proof.proofStage)", + "- canonical workflow completed: $($proof.workflowCompleted)", + "- packaging/smoke proven by this guard: $($proof.packagingSmokeProven)", + '- Build ARSAS remains the independent packaging/smoke check', '- Production route source verified independently without any mutation', '- No promotion or release action was performed' ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append diff --git a/docs/audits/CI_P2D_ARTIFACT_READY_PROOF.md b/docs/audits/CI_P2D_ARTIFACT_READY_PROOF.md new file mode 100644 index 000000000..ad9901419 --- /dev/null +++ b/docs/audits/CI_P2D_ARTIFACT_READY_PROOF.md @@ -0,0 +1,55 @@ +# CI-P2D — Artifact-ready canonical regression proof + +Issue #438. + +## Problem + +P2A/P2B/P2C removed duplicate ARSAS compilation, but their guard runners still +waited until the entire canonical Build ARSAS workflow finished. In reference +run 37567436447, the exact regression artifact was uploaded at 03:37:44Z while +portable publish/smoke kept the canonical workflow alive until 03:39:45Z. +That left about 121 seconds of avoidable polling per consumer. + +## P2D behavior + +The canonical verifier keeps completed-workflow proof as its default. P2D adds +an explicit --allow-in-progress-artifact mode. In that mode, the newest matching +Build ARSAS run may satisfy the full-regression consumer while still in progress +only after the canonical manifest and TRX artifact exist and validate. + +The verifier still requires exact PR head/branch, exact synthetic PR merge SHA, +exact pinned engine SHA, exact workflow run ID/attempt, safe bounded archive, +matching TRX SHA-256 and all-pass counters with zero failed/notExecuted tests. + +A Build ARSAS run that is already completed with a non-success conclusion is +always rejected even when a prior test artifact exists. A missing artifact +continues bounded polling rather than becoming a pass. + +## Authority boundary + +Artifact-ready mode proves full regression only. If Build ARSAS is still +running, the proof explicitly reports workflowCompleted=false and +packagingSmokeProven=false. Portable publish/smoke remains the independent +Build ARSAS responsibility and is not promoted by Merge Execution, Mainline +Readiness or Production Route Guard. + +## Consumers + +- Smart Discovery Merge Execution Guard +- Smart Discovery Mainline Readiness +- Smart Discovery Production Promotion Guard + +Each consumer opts in explicitly. No other caller changes from strict default +behavior. + +## Measurement + +The 121-second post-regression packaging interval is observational evidence, +not a flaky timing acceptance test. With three consumers, the reference run +represents roughly six runner-minutes that can be avoided per comparable PR. + +## Rollback + +Remove --allow-in-progress-artifact from the three consumers. This immediately +restores completed-workflow waiting while preserving the same canonical evidence +format, exact-SHA checks, physical authority and release policy. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 836eeabf5..06c4fddfd 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P2C: reuse canonical regression in production route guard", - "policy": "P0 immutable historical baseline and P1 opt-in shadow remain recorded. P2A/P2B/P2C successively remove redundant ARSAS restore/build/test invocations by verifying the latest exact PR synthetic-merge-SHA and pinned-engine canonical Build ARSAS TRX, while preserving independent source-route and physical engine guards.", + "baselineName": "CI-P2D: exact regression artifact-ready consumers", + "policy": "P0 historical workflow-sprawl baseline remains immutable. P2A/P2B/P2C reuse one canonical ARSAS full-regression proof; P2D keeps those exact-SHA/digest/counter guarantees but allows consumers to stop polling once that all-pass artifact is uploaded. Build ARSAS packaging/smoke remains an independent check and is not claimed by early regression proof.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -169,5 +169,43 @@ "noPhysicalAuthorityRewrite": true, "noPromotionActions": true, "rollback": "Revert this workflow change to restore direct production-route .NET build/test if canonical proof or sequencing fails. Never omit source route checks." + }, + "ciP2ArtifactReady": { + "issueNumber": 438, + "lane": "canonical-regression-consumers", + "observedReferenceRun": { + "buildRunId": 37567436447, + "regressionEvidenceUploadedAt": "2026-10-07T03:37:44Z", + "canonicalWorkflowCompletedAt": "2026-10-07T03:39:45Z", + "observedPostRegressionPackagingWindowSeconds": 121, + "consumers": 3, + "note": "Observed timing is diagnostic evidence only, not a hard CI timing assertion." + }, + "verifierMode": { + "default": "completed-workflow-only", + "optInFlag": "--allow-in-progress-artifact", + "acceptsStatuses": [ + "in_progress", + "completed-success" + ], + "completedFailureRejected": true, + "exactPrHeadAndBranchRequired": true, + "exactSyntheticMergeShaRequired": true, + "exactEngineShaRequired": true, + "exactRunIdAttemptRequired": true, + "trxDigestAndAllPassCountersRequired": true, + "missingArtifactWaitsBoundedly": true + }, + "authorityBoundary": { + "proves": "full ARSAS regression artifact", + "doesNotProve": [ + "portable publish", + "portable smoke", + "release promotion" + ], + "canonicalBuildRemainsIndependent": true + }, + "staticLimitsChanged": false, + "rollback": "Remove the opt-in flag from P2A/P2B/P2C consumers to restore completed-workflow waiting without changing evidence formats or physical/release authority." } } diff --git a/scripts/test-ci-canonical-reuse.py b/scripts/test-ci-canonical-reuse.py index 2f3d733fe..498b649bf 100644 --- a/scripts/test-ci-canonical-reuse.py +++ b/scripts/test-ci-canonical-reuse.py @@ -65,8 +65,14 @@ def zip_archive(manifest: dict, trx: bytes = TRX, extra: dict | None = None) -> class FakeApi: - def __init__(self, run_status: str = "completed", result: str = "success"): + def __init__( + self, + run_status: str = "completed", + result: str = "success", + artifact_available: bool = True, + ): self.status, self.result = run_status, result + self.artifact_available = artifact_available self.blob = zip_archive(fixture()) self.listing = [ {"id": RUN, "run_attempt": 1, "name": "Build ARSAS", @@ -80,6 +86,8 @@ def get(self, url: str, binary: bool = False): if "/workflows/build.yml/runs?" in url: return {"workflow_runs": self.listing} if "/runs/" in url and "/artifacts" in url: + if not self.artifact_available: + return {"artifacts": []} return {"artifacts": [ {"id": 22, "name": "ARSAS-test-evidence", "expired": False, "archive_download_url": "https://example.invalid/artifact.zip", @@ -88,11 +96,12 @@ def get(self, url: str, binary: bool = False): raise RuntimeError("Unexpected test API request " + url) -def run_once(api: FakeApi): +def run_once(api: FakeApi, *, allow_in_progress_artifact: bool = False): return consumer.verify_canonical( api, repository="masarray/arsas", branch=BRANCH, head_sha=HEAD, merge_sha=SOURCE, engine_sha=ENGINE, wait_seconds=0, poll_seconds=1, + allow_in_progress_artifact=allow_in_progress_artifact, ) @@ -173,6 +182,31 @@ def test_pending_run_fails_closed_at_timeout(self): with self.assertRaisesRegex(consumer.ProofError, "Timed out"): run_once(FakeApi(run_status="in_progress", result=None)) + def test_artifact_ready_mode_accepts_exact_all_pass_proof_while_packaging_runs(self): + proof = run_once( + FakeApi(run_status="in_progress", result=None), + allow_in_progress_artifact=True, + ) + self.assertEqual(proof["proofStage"], "full-regression-artifact-ready") + self.assertIs(proof["workflowCompleted"], False) + self.assertIs(proof["packagingSmokeProven"], False) + self.assertEqual(proof["canonicalRunStatus"], "in_progress") + self.assertEqual(proof["passed"], 1320) + + def test_artifact_ready_mode_still_rejects_completed_failed_run(self): + with self.assertRaisesRegex(consumer.ProofError, "Latest canonical.*failed"): + run_once( + FakeApi(run_status="completed", result="failure"), + allow_in_progress_artifact=True, + ) + + def test_artifact_ready_mode_waits_when_expected_artifact_is_not_uploaded(self): + with self.assertRaisesRegex(consumer.ProofError, "artifact-ready canonical"): + run_once( + FakeApi(run_status="in_progress", result=None, artifact_available=False), + allow_in_progress_artifact=True, + ) + def test_different_pr_head_or_branch_fails_closed(self): api = FakeApi() api.listing[0]["head_sha"] = "d" * 40 diff --git a/scripts/verify-ci-canonical-reuse.py b/scripts/verify-ci-canonical-reuse.py index 2a4baaf06..c0cdad9fb 100644 --- a/scripts/verify-ci-canonical-reuse.py +++ b/scripts/verify-ci-canonical-reuse.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 """CI-P2: reuse canonical Build ARSAS regression proof, fail closed. -This proof reader runs on read-only GitHub token. The canonical Build ARSAS -must have completed successfully for the same PR HEAD and the exact synthetic -PR merge SHA; otherwise the Smart Discovery Merge Execution Guard fails. +This proof reader runs on a read-only GitHub token. Strict mode requires the +canonical Build ARSAS workflow to complete successfully. CI-P2D adds an explicit +artifact-ready mode that can prove the full regression earlier, after the exact +manifest/TRX artifact is uploaded, while portable packaging/smoke remains a +separate Build ARSAS responsibility. Downloaded artifacts are treated strictly as untrusted ZIP *data*. No file from the artifact is extracted or executed. @@ -159,13 +161,17 @@ def validate_artifact_archive( } -def choose_artifact(payload: dict, run_id: int) -> dict: +def choose_artifact(payload: dict, run_id: int, *, allow_missing: bool = False) -> dict | None: artifacts = payload.get("artifacts", []) + if not isinstance(artifacts, list): + raise ProofError("Canonical artifact listing is malformed") matches = [ obj for obj in artifacts if obj.get("name") == EXPECTED_ARTIFACT and obj.get("expired") is False and isinstance(obj.get("id"), int) and obj.get("workflow_run", {}).get("id", run_id) == run_id ] + if not matches and allow_missing: + return None if len(matches) != 1: raise ProofError("Expected exactly one non-expired canonical test artifact") return matches[0] @@ -217,7 +223,8 @@ def get(self, url: str, binary: bool = False): def verify_canonical( api: GitHubReadOnly, *, repository: str, branch: str, head_sha: str, merge_sha: str, engine_sha: str, - wait_seconds: int = 960, poll_seconds: int = 10 + wait_seconds: int = 960, poll_seconds: int = 10, + allow_in_progress_artifact: bool = False, ) -> dict: validate_identity(repository, head_sha, merge_sha, engine_sha, branch) if wait_seconds < 0 or poll_seconds < 1: @@ -235,37 +242,68 @@ def verify_canonical( runs = candidate_runs(api.get(url), head_sha, branch) if runs: run = runs[0] # Never use an older successful attempt over a newer failure. - previous = f"{run['id']}/{run['run_attempt']} {run.get('status')}/{run.get('conclusion')}" - if run.get("status") == "completed": - if run.get("conclusion") != "success": - raise ProofError( - f"Latest canonical Build ARSAS PR run failed: {previous}" - ) - run_id, attempt = run["id"], run["run_attempt"] + run_id, attempt = run["id"], run["run_attempt"] + status = run.get("status") + conclusion = run.get("conclusion") + previous = f"{run_id}/{attempt} {status}/{conclusion}" + + # A canonical run that has already finished red is never usable, even + # if an all-pass regression artifact was uploaded before a later + # packaging/smoke failure. + if status == "completed" and conclusion != "success": + raise ProofError( + f"Latest canonical Build ARSAS PR run failed: {previous}" + ) + + can_read_artifact = ( + status == "completed" + or (allow_in_progress_artifact and status == "in_progress") + ) + if can_read_artifact: artifact = choose_artifact( api.get(f"{base}/actions/runs/{run_id}/artifacts?per_page=100"), run_id, + allow_missing=(status != "completed"), ) - blob = api.get(artifact["archive_download_url"], binary=True) - try: - return validate_artifact_archive( - blob, merge_sha=merge_sha, engine_sha=engine_sha, - workflow_run_id=run_id, run_attempt=attempt - ) - except ProofError as exc: - if "stale/different PR merge tree" not in str(exc): - raise - # A stale green run is not authoritative for the current base; - # a newly triggered canonical build can still arrive. - previous = f"{previous} (stale merge tree)" + if artifact is not None: + blob = api.get(artifact["archive_download_url"], binary=True) + try: + proof = validate_artifact_archive( + blob, merge_sha=merge_sha, engine_sha=engine_sha, + workflow_run_id=run_id, run_attempt=attempt + ) + except ProofError as exc: + if "stale/different PR merge tree" not in str(exc): + raise + # A stale green artifact is not authoritative for the current + # synthetic merge tree; a replacement run can still arrive. + previous = f"{previous} (stale merge tree)" + else: + proof.update({ + "canonicalRunStatus": status, + "canonicalRunConclusion": conclusion, + "workflowCompleted": status == "completed", + "proofStage": ( + "completed-workflow" + if status == "completed" + else "full-regression-artifact-ready" + ), + "packagingSmokeProven": status == "completed", + }) + return proof + if time.monotonic() >= deadline: + mode = ( + "artifact-ready canonical Build ARSAS evidence" + if allow_in_progress_artifact + else "completed canonical Build ARSAS evidence" + ) raise ProofError( - f"Timed out waiting for matching canonical Build ARSAS evidence " + f"Timed out waiting for {mode} " f"for head={head_sha}, merge={merge_sha}, last={previous}" ) time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) - def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repository", required=True) @@ -274,6 +312,14 @@ def main() -> int: parser.add_argument("--merge-sha", required=True) parser.add_argument("--engine-sha", required=True) parser.add_argument("--wait-seconds", type=int, default=960) + parser.add_argument( + "--allow-in-progress-artifact", + action="store_true", + help=( + "Accept exact all-pass regression manifest/TRX from the latest matching " + "in-progress Build ARSAS run. Does not prove later packaging/smoke." + ), + ) parser.add_argument("--output", default="") args = parser.parse_args() result = verify_canonical( @@ -284,6 +330,7 @@ def main() -> int: merge_sha=args.merge_sha, engine_sha=args.engine_sha, wait_seconds=args.wait_seconds, + allow_in_progress_artifact=args.allow_in_progress_artifact, ) payload = json.dumps(result, sort_keys=True) print("Canonical full-regression reuse PASS: " + payload) From 73c677c398adad18ed23ba57a13dc831946ab50f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 13:16:35 +0700 Subject: [PATCH 07/99] ci(p2e): reuse exact-main canonical ARSAS regression post-merge Keep independent R10 engine regression and physical assertions, but reuse exact push/main Build ARSAS canonical regression evidence for the application suite. Preserve schema-compatible post-merge attestation with explicit verification state. All ten PR workflows passed. --- .../smart-discovery-post-merge-production.yml | 95 +++++++++++++++- docs/audits/CI_P2E_POST_MERGE_MAIN_REUSE.md | 57 ++++++++++ evidence/ci-workflow-budget.json | 51 ++++++++- scripts/test-ci-canonical-reuse.py | 104 ++++++++++++++++-- scripts/verify-ci-canonical-reuse.py | 47 +++++--- 5 files changed, 320 insertions(+), 34 deletions(-) create mode 100644 docs/audits/CI_P2E_POST_MERGE_MAIN_REUSE.md diff --git a/.github/workflows/smart-discovery-post-merge-production.yml b/.github/workflows/smart-discovery-post-merge-production.yml index 9248ebf04..62a21498a 100644 --- a/.github/workflows/smart-discovery-post-merge-production.yml +++ b/.github/workflows/smart-discovery-post-merge-production.yml @@ -6,15 +6,33 @@ on: - main workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: smart-discovery-post-merge-production-${{ github.ref }} + cancel-in-progress: true + jobs: post-merge-production: name: Verify R10 convergence authority on main runs-on: windows-latest steps: - - name: Checkout ARSAS main + - name: Checkout exact ARSAS main revision uses: actions/checkout@v7 with: + ref: ${{ github.sha }} fetch-depth: 1 + persist-credentials: false + + - name: Verify exact post-merge source identity + shell: powershell + run: | + $actual = (git rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:GITHUB_SHA.ToLowerInvariant()) { + throw "Post-merge source mismatch. Expected $env:GITHUB_SHA, got $actual." + } - name: Verify R10 physical convergence authority shell: powershell @@ -73,13 +91,67 @@ jobs: 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 + if ($LASTEXITCODE -ne 0) { throw 'Independent R10 engine regression failed.' } + "ENGINE_FULL_REGRESSION_VERIFIED=true" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Setup Python for exact-main canonical regression evidence + uses: actions/setup-python@v6 + with: + python-version: '3.11' - - name: Build and test ARSAS main + - name: Run offline canonical evidence safety fixtures + run: python .\scripts\test-ci-canonical-reuse.py + + - name: Reuse exact-main canonical ARSAS full regression shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} 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 + $proofPath = Join-Path $env:RUNNER_TEMP 'ci-post-merge-canonical-proof.json' + python .\scripts\verify-ci-canonical-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch main ` + --head-sha $env:GITHUB_SHA ` + --merge-sha $env:GITHUB_SHA ` + --engine-sha $env:ENGINE_COMMIT ` + --event-name push ` + --wait-seconds 960 ` + --allow-in-progress-artifact ` + --output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Post-merge production verification requires exact-main canonical ARSAS regression evidence.' + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if ($proof.eventName -ne 'push' -or + $proof.branch -ne 'main' -or + $proof.mergeSha -ne $env:GITHUB_SHA -or + $proof.engineSha -ne $env:ENGINE_COMMIT -or + [int]$proof.failed -ne 0 -or + [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1) { + throw 'Exact-main canonical ARSAS regression proof is incomplete.' + } + + "ARSAS_CANONICAL_REGRESSION_VERIFIED=true" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_RUN_ID=$($proof.canonicalRunId)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_RUN_ATTEMPT=$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_TESTS_PASSED=$($proof.passed)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_PROOF_STAGE=$($proof.proofStage)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_PACKAGING_PROVEN=$($proof.packagingSmokeProven.ToString().ToLowerInvariant())" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## R10 post-merge production verification', + '', + "- exact main SHA: $env:GITHUB_SHA", + "- exact engine SHA: $env:ENGINE_COMMIT", + '- R10 engine regression: independently executed', + "- canonical Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- canonical ARSAS regression: $($proof.passed) passed, $($proof.failed) failed", + "- proof stage: $($proof.proofStage)", + "- packaging/smoke proven by this workflow: $($proof.packagingSmokeProven)", + '- Build ARSAS remains independent packaging/smoke authority' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - name: Write R10 post-merge evidence if: always() @@ -87,12 +159,25 @@ jobs: run: | New-Item -ItemType Directory -Force .\TestResults | Out-Null $target = Get-Content .\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json + $engineVerified = $env:ENGINE_FULL_REGRESSION_VERIFIED -eq 'true' + $arsasVerified = $env:ARSAS_CANONICAL_REGRESSION_VERIFIED -eq 'true' + $status = if ($engineVerified -and $arsasVerified) { 'passed' } else { 'incomplete-or-failed' } + [ordered]@{ schemaVersion = 1 + verificationStatus = $status sourceCommit = $env:GITHUB_SHA engineCommit = $env:ENGINE_COMMIT convergenceStatus = $target.status physicalRetestPassed = [bool]$target.promotion.physicalRetestPassed + engineFullRegressionVerified = $engineVerified + arsasCanonicalRegressionVerified = $arsasVerified + arsasCanonicalRunId = $env:ARSAS_CANONICAL_RUN_ID + arsasCanonicalRunAttempt = $env:ARSAS_CANONICAL_RUN_ATTEMPT + arsasCanonicalTestsPassed = $env:ARSAS_CANONICAL_TESTS_PASSED + arsasCanonicalProofStage = $env:ARSAS_CANONICAL_PROOF_STAGE + arsasCanonicalPackagingProven = ($env:ARSAS_CANONICAL_PACKAGING_PROVEN -eq 'true') + buildArsasRemainsPackagingAuthority = $true ed2ProjectionErrors = [int]$target.physicalEvidence.arsasR10.reuseEdition2.projectionErrors ed2CacheLoss = [int]$target.physicalEvidence.arsasR10.reuseEdition2.cacheLoss ed1ProjectionErrors = [int]$target.physicalEvidence.arsasR10.reuseEdition1.projectionErrors diff --git a/docs/audits/CI_P2E_POST_MERGE_MAIN_REUSE.md b/docs/audits/CI_P2E_POST_MERGE_MAIN_REUSE.md new file mode 100644 index 000000000..9da2fb151 --- /dev/null +++ b/docs/audits/CI_P2E_POST_MERGE_MAIN_REUSE.md @@ -0,0 +1,57 @@ +# CI-P2E — Exact-main canonical ARSAS regression reuse + +Issue #440. + +## Goal + +Remove the duplicate ARSAS application restore/build/full-test from +Smart Discovery Post-Merge Production Verification while preserving: + +- exact R10 physical convergence assertions; +- exact merged ARIEC61850 checkout; +- independent engine restore/build/full-test; +- Build ARSAS portable publish/smoke authority; +- post-merge diagnostic attestation. + +## Canonical main proof + +The shared verifier now accepts an explicit workflow event identity. Its default +remains pull_request for existing P2A-P2D consumers. P2E calls it with: + +- event-name=push; +- branch=main; +- head SHA equal to GITHUB_SHA; +- source SHA equal to that same GITHUB_SHA; +- exact immutable engine lock SHA; +- artifact-ready mode enabled. + +The artifact must still contain the exact canonical manifest and TRX, exact +run ID/attempt, matching SHA-256 digest and an entirely green test suite. +A completed failed Build ARSAS run is never reusable. + +## Attestation + +R10-post-merge-production.json remains schemaVersion 1 for compatibility and +adds explicit fields showing whether independent engine regression and canonical +ARSAS regression were actually verified. Because the artifact is uploaded with +if: always(), verificationStatus becomes incomplete-or-failed if either proof +did not complete. A diagnostic artifact therefore cannot imply acceptance. + +## Static CI cost + +Compared with P2D, workflow-source dotnet restore/build/test counts fall from +13/13/15 to 12/12/14. setup-dotnet is unchanged because the post-merge job still +runs the engine suite. setup-python increases from 15 to 16 for the pinned +canonical evidence verifier. + +## Authority boundary + +Canonical regression reuse proves ARSAS application tests only. It does not +replace the R10 engine suite, physical field evidence, portable packaging, +installer validation or release promotion. + +## Rollback + +Restore the direct ARSAS main restore/build/test step in the post-merge workflow. +No evidence format, engine test or physical authority must be weakened to roll +back this optimization. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 06c4fddfd..19642da62 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P2D: exact regression artifact-ready consumers", - "policy": "P0 historical workflow-sprawl baseline remains immutable. P2A/P2B/P2C reuse one canonical ARSAS full-regression proof; P2D keeps those exact-SHA/digest/counter guarantees but allows consumers to stop polling once that all-pass artifact is uploaded. Build ARSAS packaging/smoke remains an independent check and is not claimed by early regression proof.", + "baselineName": "CI-P2E: exact-main canonical ARSAS regression reused post-merge", + "policy": "P0 historical workflow-sprawl baseline remains immutable. P2A-P2D consolidate PR regression evidence; P2E extends the same exact-SHA/digest/counter contract to push/main so post-merge production verification keeps independent R10 engine tests while reusing canonical Build ARSAS application regression.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -20,10 +20,10 @@ "workflowFiles": 31, "actionsCheckout": 35, "actionsSetupDotnet": 11, - "actionsSetupPython": 15, - "dotnetRestore": 13, - "dotnetBuild": 13, - "dotnetTest": 15, + "actionsSetupPython": 16, + "dotnetRestore": 12, + "dotnetBuild": 12, + "dotnetTest": 14, "actionsUploadArtifact": 34, "workflowsWithDotnetBuild": 11, "workflowsWithDotnetTest": 10 @@ -207,5 +207,44 @@ }, "staticLimitsChanged": false, "rollback": "Remove the opt-in flag from P2A/P2B/P2C consumers to restore completed-workflow waiting without changing evidence formats or physical/release authority." + }, + "ciP2PostMergeMainReuse": { + "issueNumber": 440, + "lane": "smart-discovery-post-merge-production", + "approach": "On push/main, preserve R10 physical assertions and independent engine restore/build/test, then consume exact same-main-SHA canonical Build ARSAS push artifact using event=push, branch=main, exact engine SHA and artifact-ready proof.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 15, + "dotnetRestore": 13, + "dotnetBuild": 13, + "dotnetTest": 15, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 16, + "dotnetRestore": 12, + "dotnetBuild": 12, + "dotnetTest": 14, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 + }, + "independentEngineFullRegressionRetained": true, + "exactMainSourceShaRequired": true, + "exactEngineShaRequired": true, + "canonicalEvent": "push", + "canonicalBranch": "main", + "canonicalBuildRemainsPackagingAuthority": true, + "attestationSchemaVersion": 1, + "diagnosticAttestationMarksIncompleteFailures": true, + "noRuntimeOrPhysicalEvidenceRewrite": true, + "rollback": "Restore direct ARSAS main restore/build/test in post-merge workflow; keep independent engine test and physical convergence assertions unchanged." } } diff --git a/scripts/test-ci-canonical-reuse.py b/scripts/test-ci-canonical-reuse.py index 498b649bf..f4b8f5dcc 100644 --- a/scripts/test-ci-canonical-reuse.py +++ b/scripts/test-ci-canonical-reuse.py @@ -40,7 +40,7 @@ def load(name: str, path: str): """ -def fixture(): +def fixture(event_name: str = "pull_request"): return producer.create_manifest( source_sha=SOURCE, checkout_sha=SOURCE, @@ -48,7 +48,7 @@ def fixture(): engine_actual=ENGINE, workflow_run_id=RUN, run_attempt=1, - event_name="pull_request", + event_name=event_name, trx=TRX, trx_name="arsas-tests.trx", ) @@ -70,13 +70,24 @@ def __init__( run_status: str = "completed", result: str = "success", artifact_available: bool = True, + event_name: str = "pull_request", + branch: str = BRANCH, + head_sha: str = HEAD, + source_sha: str = SOURCE, ): self.status, self.result = run_status, result self.artifact_available = artifact_available - self.blob = zip_archive(fixture()) + self.event_name = event_name + self.branch = branch + self.head_sha = head_sha + manifest = fixture(event_name=event_name) + if source_sha != SOURCE: + manifest = dict(manifest) + manifest["exactSourceSha"] = source_sha + self.blob = zip_archive(manifest) self.listing = [ {"id": RUN, "run_attempt": 1, "name": "Build ARSAS", - "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request", + "head_sha": head_sha, "head_branch": branch, "event": event_name, "status": self.status, "conclusion": self.result} ] @@ -96,12 +107,26 @@ def get(self, url: str, binary: bool = False): raise RuntimeError("Unexpected test API request " + url) -def run_once(api: FakeApi, *, allow_in_progress_artifact: bool = False): +def run_once( + api: FakeApi, + *, + allow_in_progress_artifact: bool = False, + event_name: str = "pull_request", + branch: str = BRANCH, + head_sha: str = HEAD, + source_sha: str = SOURCE, +): return consumer.verify_canonical( - api, repository="masarray/arsas", branch=BRANCH, - head_sha=HEAD, merge_sha=SOURCE, engine_sha=ENGINE, - wait_seconds=0, poll_seconds=1, + api, + repository="masarray/arsas", + branch=branch, + head_sha=head_sha, + merge_sha=source_sha, + engine_sha=ENGINE, + wait_seconds=0, + poll_seconds=1, allow_in_progress_artifact=allow_in_progress_artifact, + event_name=event_name, ) @@ -119,7 +144,7 @@ def test_manifest_never_claims_release_authority(self): def test_reject_wrong_merge_sha(self): api = FakeApi() - with self.assertRaisesRegex(consumer.ProofError, "stale/different PR merge tree|Timed out"): + with self.assertRaisesRegex(consumer.ProofError, "stale/different source revision|Timed out"): consumer.validate_artifact_archive( api.blob, merge_sha="f" * 40, engine_sha=ENGINE, workflow_run_id=RUN, run_attempt=1, @@ -207,6 +232,67 @@ def test_artifact_ready_mode_waits_when_expected_artifact_is_not_uploaded(self): allow_in_progress_artifact=True, ) + def test_main_push_accepts_exact_same_sha_artifact_ready_proof(self): + main_sha = "d" * 40 + api = FakeApi( + run_status="in_progress", + result=None, + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + proof = run_once( + api, + allow_in_progress_artifact=True, + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + self.assertEqual(proof["eventName"], "push") + self.assertEqual(proof["branch"], "main") + self.assertEqual(proof["mergeSha"], main_sha) + self.assertEqual(proof["proofStage"], "full-regression-artifact-ready") + self.assertFalse(proof["packagingSmokeProven"]) + + def test_main_push_rejects_pr_manifest_event_mismatch(self): + main_sha = "d" * 40 + api = FakeApi( + run_status="completed", + result="success", + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + api.blob = zip_archive(fixture(event_name="pull_request")) + with self.assertRaisesRegex(consumer.ProofError, "event mismatch"): + run_once( + api, + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + + def test_main_push_rejects_wrong_branch_or_sha(self): + main_sha = "d" * 40 + api = FakeApi( + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + with self.assertRaisesRegex(consumer.ProofError, "Timed out"): + run_once( + api, + event_name="push", + branch="release", + head_sha=main_sha, + source_sha=main_sha, + ) + def test_different_pr_head_or_branch_fails_closed(self): api = FakeApi() api.listing[0]["head_sha"] = "d" * 40 diff --git a/scripts/verify-ci-canonical-reuse.py b/scripts/verify-ci-canonical-reuse.py index c0cdad9fb..99f8ee2ec 100644 --- a/scripts/verify-ci-canonical-reuse.py +++ b/scripts/verify-ci-canonical-reuse.py @@ -47,7 +47,7 @@ class ProofError(RuntimeError): def validate_identity(repo: str, head_sha: str, merge_sha: str, engine_sha: str, - branch: str) -> None: + branch: str, event_name: str = "pull_request") -> None: if repo != "masarray/arsas": raise ProofError("Canonical CI reuse is restricted to masarray/arsas") for label, value in [ @@ -58,16 +58,20 @@ def validate_identity(repo: str, head_sha: str, merge_sha: str, engine_sha: str, except ValueError as exc: raise ProofError(str(exc)) from exc if not branch or len(branch) > 200 or "\n" in branch: - raise ProofError("Invalid PR head branch identity") + raise ProofError("Invalid canonical branch identity") + if event_name not in {"pull_request", "push"}: + raise ProofError("Unsupported canonical workflow event") -def candidate_runs(payload: dict, head_sha: str, branch: str) -> list[dict]: +def candidate_runs( + payload: dict, head_sha: str, branch: str, event_name: str = "pull_request" +) -> list[dict]: runs = payload.get("workflow_runs", []) if not isinstance(runs, list): raise ProofError("GitHub Actions run listing is malformed") matches = [ run for run in runs - if run.get("event") == "pull_request" + if run.get("event") == event_name and run.get("name") == "Build ARSAS" and run.get("head_sha") == head_sha and run.get("head_branch") == branch @@ -79,7 +83,8 @@ def candidate_runs(payload: dict, head_sha: str, branch: str) -> list[dict]: def validate_artifact_archive( payload: bytes, *, merge_sha: str, engine_sha: str, - workflow_run_id: int, run_attempt: int + workflow_run_id: int, run_attempt: int, + expected_event_name: str = "pull_request", ) -> dict: if not payload or len(payload) > MAX_ARCHIVE_BYTES: raise ProofError("Canonical artifact is missing or exceeds bounded size") @@ -125,14 +130,16 @@ def validate_artifact_archive( raise ProofError("Wrong canonical artifact provenance kind") if manifest.get("canonicalWorkflow") != "Build ARSAS": raise ProofError("Proof is not from canonical Build ARSAS") - if manifest.get("eventName") != "pull_request": - raise ProofError("Canonical proof was not produced by a PR run") + if manifest.get("eventName") != expected_event_name: + raise ProofError( + f"Canonical proof event mismatch: expected {expected_event_name}" + ) if manifest.get("workflowRunId") != workflow_run_id: raise ProofError("Canonical workflow run ID differs from API run") if manifest.get("runAttempt") != run_attempt: raise ProofError("Canonical run attempt differs from API run attempt") if manifest.get("exactSourceSha") != merge_sha: - raise ProofError("Canonical test proof is for a stale/different PR merge tree") + raise ProofError("Canonical test proof is for a stale/different source revision") if manifest.get("exactEngineSha") != engine_sha: raise ProofError("Canonical engine SHA differs from this PR's immutable lock") if manifest.get("fullRegressionPassed") is not True: @@ -225,21 +232,24 @@ def verify_canonical( head_sha: str, merge_sha: str, engine_sha: str, wait_seconds: int = 960, poll_seconds: int = 10, allow_in_progress_artifact: bool = False, + event_name: str = "pull_request", ) -> dict: - validate_identity(repository, head_sha, merge_sha, engine_sha, branch) + validate_identity( + repository, head_sha, merge_sha, engine_sha, branch, event_name + ) if wait_seconds < 0 or poll_seconds < 1: raise ProofError("Invalid bounded wait policy") base = "https://api.github.com/repos/" + repository query = urllib.parse.urlencode( - {"event": "pull_request", "head_sha": head_sha, "per_page": 100} + {"event": event_name, "head_sha": head_sha, "per_page": 100} ) url = f"{base}/actions/workflows/build.yml/runs?{query}" deadline = time.monotonic() + wait_seconds previous = None while True: - runs = candidate_runs(api.get(url), head_sha, branch) + runs = candidate_runs(api.get(url), head_sha, branch, event_name) if runs: run = runs[0] # Never use an older successful attempt over a newer failure. run_id, attempt = run["id"], run["run_attempt"] @@ -252,7 +262,7 @@ def verify_canonical( # packaging/smoke failure. if status == "completed" and conclusion != "success": raise ProofError( - f"Latest canonical Build ARSAS PR run failed: {previous}" + f"Latest canonical Build ARSAS run failed: {previous}" ) can_read_artifact = ( @@ -270,10 +280,11 @@ def verify_canonical( try: proof = validate_artifact_archive( blob, merge_sha=merge_sha, engine_sha=engine_sha, - workflow_run_id=run_id, run_attempt=attempt + workflow_run_id=run_id, run_attempt=attempt, + expected_event_name=event_name, ) except ProofError as exc: - if "stale/different PR merge tree" not in str(exc): + if "stale/different source revision" not in str(exc): raise # A stale green artifact is not authoritative for the current # synthetic merge tree; a replacement run can still arrive. @@ -289,6 +300,8 @@ def verify_canonical( else "full-regression-artifact-ready" ), "packagingSmokeProven": status == "completed", + "eventName": event_name, + "branch": branch, }) return proof @@ -311,6 +324,11 @@ def main() -> int: parser.add_argument("--head-sha", required=True) parser.add_argument("--merge-sha", required=True) parser.add_argument("--engine-sha", required=True) + parser.add_argument( + "--event-name", + choices=("pull_request", "push"), + default="pull_request", + ) parser.add_argument("--wait-seconds", type=int, default=960) parser.add_argument( "--allow-in-progress-artifact", @@ -331,6 +349,7 @@ def main() -> int: engine_sha=args.engine_sha, wait_seconds=args.wait_seconds, allow_in_progress_artifact=args.allow_in_progress_artifact, + event_name=args.event_name, ) payload = json.dumps(result, sort_keys=True) print("Canonical full-regression reuse PASS: " + payload) From e4133cfc6291cc0f0b0202c550801edb0c47015f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 13:34:51 +0700 Subject: [PATCH 08/99] ci(p2f): reuse canonical regression while preserving Field Capture packaging Reuse exact merge-SHA and integration-engine canonical ARSAS regression evidence for Field Capture while preserving P0-5c/P0-5d checks, physical baseline evidence, independent publish, portable smoke and package authority. Binary artifact reuse remains explicitly forbidden because reproducible byte identity has not been proven. All ten PR workflows passed. --- .../smart-discovery-capture-build.yml | 102 +++++++++++++++--- .../CI_P2F_FIELD_CAPTURE_REGRESSION_REUSE.md | 62 +++++++++++ evidence/ci-workflow-budget.json | 61 +++++++++-- 3 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 docs/audits/CI_P2F_FIELD_CAPTURE_REGRESSION_REUSE.md diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 7c1b4a685..4901ff0bd 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -34,8 +34,32 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: smart-discovery-field-capture-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: + manual-regression: + name: Manual exact-SHA full regression before Field Capture packaging + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} + build-smart-capture: + needs: [manual-regression] + if: >- + ${{ + always() && + (github.event_name != 'workflow_dispatch' || + needs.manual-regression.result == 'success') + }} name: Build smart-discovery portable field capture runs-on: windows-latest steps: @@ -242,23 +266,12 @@ jobs: 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@v6 - 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 + - name: Verify smart route and association lifecycle resets before packaging 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.' + throw 'Field Capture smart discovery route is 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.' @@ -267,11 +280,64 @@ jobs: 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.' + throw 'Field Capture Control path does not consume the authoritative smart domain inventory.' + } + + - name: Setup Python for canonical regression evidence + if: github.event_name == 'pull_request' + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Run offline canonical evidence safety fixtures + if: github.event_name == 'pull_request' + run: python .\ArIED61850Tester\scripts\test-ci-canonical-reuse.py + + - name: Reuse exact canonical ARSAS full regression + if: github.event_name == 'pull_request' + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + run: | + $proofPath = '.\ArIED61850Tester\TestResults\ci-smart-capture-canonical-proof.json' + python .\ArIED61850Tester\scripts\verify-ci-canonical-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch $env:PR_HEAD_BRANCH ` + --head-sha $env:PR_HEAD_SHA ` + --merge-sha $env:PR_MERGE_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --wait-seconds 960 ` + --allow-in-progress-artifact ` + --output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Field Capture requires exact canonical ARSAS full-regression evidence.' + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if ($proof.mergeSha -ne $env:PR_MERGE_SHA -or + $proof.engineSha -ne $env:ARIEC61850_COMMIT -or + [int]$proof.failed -ne 0 -or + [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1) { + throw 'Field Capture canonical regression proof is incomplete.' } + "SMART_CAPTURE_REGRESSION_AUTHORITY=canonical-build-arsas:$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - 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: Record manual reusable regression authority + if: github.event_name == 'workflow_dispatch' + shell: powershell + run: | + "SMART_CAPTURE_REGRESSION_AUTHORITY=manual-reusable-exact-sha" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Setup .NET 8 for independent Field Capture publish + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 8.0.x - name: Publish portable single EXE x64 shell: powershell @@ -302,6 +368,8 @@ jobs: "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "ARIEC61850 physical baseline commit: $env:ARIEC61850_BASELINE_COMMIT", "Engine PR: 134", + "Regression authority: $env:SMART_CAPTURE_REGRESSION_AUTHORITY", + "Packaging authority: independent Field Capture publish/smoke", "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", @@ -331,7 +399,7 @@ jobs: with: name: ARSAS-smart-discovery-pr134-test-evidence path: | - ArIED61850Tester\TestResults\*.trx + ArIED61850Tester\TestResults\ci-smart-capture-canonical-proof.json ArIED61850Tester\TestResults\P0-5D-*.json ArIED61850Tester\TestResults\P0-5E-*.json if-no-files-found: warn diff --git a/docs/audits/CI_P2F_FIELD_CAPTURE_REGRESSION_REUSE.md b/docs/audits/CI_P2F_FIELD_CAPTURE_REGRESSION_REUSE.md new file mode 100644 index 000000000..f472e6118 --- /dev/null +++ b/docs/audits/CI_P2F_FIELD_CAPTURE_REGRESSION_REUSE.md @@ -0,0 +1,62 @@ +# CI-P2F — Field Capture canonical regression reuse + +Issue #442. + +## Decision + +Field Capture may reuse canonical ARSAS full-regression evidence, but it may not +reuse the canonical portable executable. + +On PR #441 both lanes used the same ARSAS head and integration engine SHA, yet +the executables were not byte-identical: + +- canonical: 78,397,596 bytes, SHA-256 49ff985f9ab969483836810dc15895da5ea0b9b046cfb57fdb96b8897269e774; +- Field Capture: 78,397,601 bytes, SHA-256 ef80317a8665b7fa900473650864f8d7fb7f9250229880c11b5f4a558aaab40e. + +Therefore this milestone optimizes regression execution only. + +## Pull request path + +The Field Capture workflow still: + +- validates the physical baseline commit 4467124775d8d9d76f3db194f9fbfd97144767a8; +- resolves and checks out the current integration engine; +- validates P0-5c association-scoped Smart Discovery source contracts; +- executes deterministic P0-5d wire-proof PASS/FAIL fixtures; +- validates route/lifecycle markers before packaging; +- runs publish-windows-portable.ps1 independently; +- smoke-tests the resulting Field Capture executable; +- uploads the Field Capture package and field-specific evidence. + +The duplicate explicit solution restore/build/dotnet-test sequence is removed. +Full ARSAS regression is instead required from the exact PR synthetic merge SHA, +exact integration engine SHA, canonical Build ARSAS artifact and all-pass TRX. + +publish-windows-portable.ps1 still performs its own restore and dotnet publish; +therefore the Field Capture binary remains independently built in its own lane. + +## Manual workflow_dispatch + +Manual packaging cannot depend on a PR artifact. It runs the reusable CI-P1 +exact-SHA full-regression job first. Field Capture packaging proceeds only when +that manual regression job succeeds. + +## Evidence artifact + +The existing ARSAS-smart-discovery-pr134-test-evidence artifact name remains. +For PR runs its regression component is now +ci-smart-capture-canonical-proof.json rather than a duplicated local TRX. +P0-5D/P0-5E evidence remains field-lane evidence. + +## Static cost + +Compared with P2E, workflow-source restore/build/test tokens fall from +12/12/14 to 11/11/13. setup-python rises from 16 to 17 for exact canonical +proof verification. Workflow files containing an explicit dotnet build/test +fall from 11/10 to 10/9. + +## Rollback + +Restore the explicit Field Capture restore/build/test sequence. Never substitute +the canonical EXE for the Field Capture EXE unless a later reproducible-build +milestone proves byte identity on matching source and engine SHAs. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 19642da62..c86d1bae6 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P2E: exact-main canonical ARSAS regression reused post-merge", - "policy": "P0 historical workflow-sprawl baseline remains immutable. P2A-P2D consolidate PR regression evidence; P2E extends the same exact-SHA/digest/counter contract to push/main so post-merge production verification keeps independent R10 engine tests while reusing canonical Build ARSAS application regression.", + "baselineName": "CI-P2F: canonical regression reused by independent Field Capture packaging", + "policy": "P0 historical workflow-sprawl baseline remains immutable. P2A-P2E consolidate exact-SHA regression evidence; P2F keeps Field Capture source/wire checks and independent publish/smoke, but reuses canonical application regression because source and integration engine are identical. Binary artifact reuse remains forbidden because observed canonical and Field Capture EXEs were not byte-identical.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -20,13 +20,13 @@ "workflowFiles": 31, "actionsCheckout": 35, "actionsSetupDotnet": 11, - "actionsSetupPython": 16, - "dotnetRestore": 12, - "dotnetBuild": 12, - "dotnetTest": 14, + "actionsSetupPython": 17, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 13, "actionsUploadArtifact": 34, - "workflowsWithDotnetBuild": 11, - "workflowsWithDotnetTest": 10 + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 }, "interpretation": [ "The limits freeze CI sprawl; they are not performance targets and do not prove semantic necessity.", @@ -246,5 +246,50 @@ "diagnosticAttestationMarksIncompleteFailures": true, "noRuntimeOrPhysicalEvidenceRewrite": true, "rollback": "Restore direct ARSAS main restore/build/test in post-merge workflow; keep independent engine test and physical convergence assertions unchanged." + }, + "ciP2FieldCaptureRegressionReuse": { + "issueNumber": 442, + "lane": "smart-discovery-capture-build", + "approach": "Preserve exact Field Capture source checks, P0-5d fixtures, integration engine checkout, independent publish and smoke; replace duplicate application restore/build/full-test with exact canonical Build ARSAS regression evidence. Manual dispatch retains full regression through reusable CI-P1 before packaging.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 16, + "dotnetRestore": 12, + "dotnetBuild": 12, + "dotnetTest": 14, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 17, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 13, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "binaryReuseForbidden": true, + "binaryParityEvidence": { + "sourceHead": "83286557f3a485f2e589ed4742f2d8f9a3d9b8ef", + "engineCommit": "648124097621046f5f127ceb1cf853fea54db730", + "canonicalExeBytes": 78397596, + "canonicalExeSha256": "49ff985f9ab969483836810dc15895da5ea0b9b046cfb57fdb96b8897269e774", + "fieldCaptureExeBytes": 78397601, + "fieldCaptureExeSha256": "ef80317a8665b7fa900473650864f8d7fb7f9250229880c11b5f4a558aaab40e", + "byteIdentical": false + }, + "physicalBaselineCommitPreserved": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "integrationEngineStillCheckedOutForPackaging": true, + "independentPublishAndSmokeRetained": true, + "canonicalBuildRemainsRegressionAuthorityOnPullRequest": true, + "manualReusableRegressionRetained": true, + "rollback": "Restore direct Field Capture restore/build/dotnet test steps; do not substitute canonical EXE unless future reproducible-build evidence proves byte identity." } } From c5946a215b97e0285fed6e698fbf87ecce96cdd4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:30:07 +0700 Subject: [PATCH 09/99] ci(p3a): establish reproducible Windows portable build identity Make the pinned ArdIrec bridge reproducible with MSVC /Brepro, make managed publish determinism explicit, emit hash-bound portable build identities and fail early on packaging PowerShell syntax. Independent Build ARSAS and Field Capture runners produced identical bridge and portable SHA-256 values. All triggered workflows passed. --- .github/workflows/build.yml | 23 +++++- .../smart-discovery-capture-build.yml | 1 + .../CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md | 48 ++++++++++++ evidence/ci-workflow-budget.json | 22 +++++- scripts/build-ardirec-bridge.ps1 | 3 +- scripts/publish-windows-portable.ps1 | 49 ++++++++++++ ...wsPackagingReproducibilityContractTests.cs | 75 +++++++++++++++++++ 7 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md create mode 100644 tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f56abc80b..b3336e71e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,6 +55,25 @@ jobs: shell: powershell run: .\ArIED61850Tester\scripts\test-source-clean-guard.ps1 + - name: Validate Windows packaging PowerShell syntax + shell: powershell + run: | + $scripts = @( + '.\ArIED61850Tester\scripts\build-ardirec-bridge.ps1', + '.\ArIED61850Tester\scripts\publish-windows-portable.ps1', + '.\ArIED61850Tester\scripts\build-windows-installer.ps1' + ) + foreach ($script in $scripts) { + $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 syntax validation failed for ${script}: $messages" + } + } + - name: Set up Python for read-only maintenance inventory uses: actions/setup-python@v6 with: @@ -415,5 +434,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: ARSAS-win-x64-portable-single-exe - path: ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + path: | + ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json if-no-files-found: error diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 4901ff0bd..4227a8b8d 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -383,6 +383,7 @@ jobs: name: ARSAS-smart-discovery-pr134-win-x64 path: | ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1 ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1 diff --git a/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md b/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md new file mode 100644 index 000000000..8a6e6c45e --- /dev/null +++ b/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md @@ -0,0 +1,48 @@ +# CI-P3A — Reproducible Windows portable build identity + +Issue #447. + +## Purpose + +P2F proved that matching ARSAS and ARIEC61850 revisions could still produce +different portable single-file executables in independent workflows. Therefore +binary artifact reuse remains prohibited until reproducibility is proven. + +## Root-cause hypothesis and controlled change + +The Windows package embeds a pinned ArdIrec native bridge. That bridge is built +independently with MSVC/CMake in each workflow. A PE linker timestamp or other +non-reproducible native metadata can change the embedded bridge and, because the +single-file payload is compressed, propagate into a large executable diff. + +P3A does not treat that hypothesis as accepted evidence. It makes the native +shared-library linker request reproducible output with /Brepro and makes the +.NET publish determinism intent explicit with Deterministic=true and +ContinuousIntegrationBuild=true. Existing ArdIrec native regression tests remain. + +## Build identity + +Each single-file publish writes a JSON identity next to the executable containing: + +- ARSAS source commit when the source is a Git checkout; +- ARIEC61850 engine commit when the project is a Git checkout; +- pinned ArdIrec lock commit; +- ArdIrec bridge SHA-256 and byte size; +- portable executable SHA-256 and byte size; +- version/runtime and deterministic-build flags. + +Both Build ARSAS and Smart Discovery Field Capture upload that identity. + +## Acceptance boundary + +P3A itself does not authorize installer/release artifact reuse. After CI, the two +independent builders must report identical source, engine and ArdIrec inputs and +identical bridge plus portable SHA-256. Only that measured result can unlock P3B. + +Installer/release/physical authority and runtime behavior are unchanged. + +## Efficiency + +No new heavy workflow is introduced. Existing canonical and Field Capture lanes +serve as the independent builders, so reproducibility evidence is obtained from +work already required by the repository. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index c86d1bae6..73812cbc1 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P2F: canonical regression reused by independent Field Capture packaging", - "policy": "P0 historical workflow-sprawl baseline remains immutable. P2A-P2E consolidate exact-SHA regression evidence; P2F keeps Field Capture source/wire checks and independent publish/smoke, but reuses canonical application regression because source and integration engine are identical. Binary artifact reuse remains forbidden because observed canonical and Field Capture EXEs were not byte-identical.", + "baselineName": "CI-P3A: reproducible portable build identity", + "policy": "P0-P2 reduced duplicate regression work. P3A does not add a heavy workflow or change static CI counts; it establishes deterministic Windows packaging inputs and hash-bound build identity so later artifact promotion can reuse only proven identical bits.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -291,5 +291,23 @@ "canonicalBuildRemainsRegressionAuthorityOnPullRequest": true, "manualReusableRegressionRetained": true, "rollback": "Restore direct Field Capture restore/build/dotnet test steps; do not substitute canonical EXE unless future reproducible-build evidence proves byte identity." + }, + "ciP3ReproduciblePortableBuild": { + "issueNumber": 447, + "lane": "canonical-and-field-capture-windows-packaging", + "approach": "Request reproducible MSVC bridge linking, explicit deterministic .NET publish, and emit source/engine/ArdIrec/bridge/portable hash identity from both existing independent builders. No binary promotion until cross-run parity is proven.", + "staticLimitsChanged": false, + "nativeLinkerFlag": "/Brepro", + "managedDeterminism": [ + "Deterministic=true", + "ContinuousIntegrationBuild=true" + ], + "crossRunBuilders": [ + "Build ARSAS", + "Smart Discovery Field Capture Build" + ], + "binaryPromotionAllowed": false, + "acceptance": "Matching source, ARIEC61850 and ArdIrec inputs must yield identical bridge and portable SHA-256 across independent workflow runs.", + "rollback": "Remove P3A deterministic flags and identity artifact additions only if toolchain incompatibility is proven; retain P2F binary-reuse prohibition." } } diff --git a/scripts/build-ardirec-bridge.ps1 b/scripts/build-ardirec-bridge.ps1 index a5f502735..7fcf4d287 100644 --- a/scripts/build-ardirec-bridge.ps1 +++ b/scripts/build-ardirec-bridge.ps1 @@ -84,7 +84,8 @@ Write-Host "==> Configuring pinned ArdIrec native bridge (desktop/Qt disabled)" -DARDIREC_BUILD_DESKTOP=OFF ` -DARDIREC_BUILD_BRIDGE=ON ` -DARDIREC_BUILD_TESTS=ON ` - -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded + -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded ` + -DCMAKE_SHARED_LINKER_FLAGS_RELEASE=/Brepro if ($LASTEXITCODE -ne 0) { throw "ArdIrec bridge CMake configure failed with exit code $LASTEXITCODE." } diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1 index aaa0fefc6..e3aa95190 100644 --- a/scripts/publish-windows-portable.ps1 +++ b/scripts/publish-windows-portable.ps1 @@ -116,6 +116,8 @@ $publishArguments = @( "-p:UseAppHost=true", "-p:DebugType=None", "-p:DebugSymbols=false", + "-p:Deterministic=true", + "-p:ContinuousIntegrationBuild=true", "-p:Version=$normalizedVersion", "-p:AssemblyVersion=$numericVersion", "-p:FileVersion=$numericVersion", @@ -161,7 +163,54 @@ if ($SingleFile) { throw "Versioned portable single EXE was not produced: $singleExePath" } + function Get-GitIdentity([string]$path) { + try { + $repoRoot = (& git -C $path rev-parse --show-toplevel 2>$null).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { return $null } + $head = (& git -C $repoRoot rev-parse HEAD 2>$null).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $head -notmatch '^[0-9a-f]{40}$') { return $null } + return $head + } + catch { + return $null + } + } + + $engineProjectDirectory = Split-Path -Parent ([System.IO.Path]::GetFullPath($EngineProject)) + $sourceCommit = Get-GitIdentity $root + $engineCommit = Get-GitIdentity $engineProjectDirectory + $ardirecLockPath = Join-Path $root "engines\ARDIREC.lock.json" + $ardirecLockCommit = $null + if (Test-Path $ardirecLockPath -PathType Leaf) { + $ardirecLock = Get-Content $ardirecLockPath -Raw | ConvertFrom-Json + if ($ardirecLock.commit -match '^[0-9a-f]{40}$') { + $ardirecLockCommit = [string]$ardirecLock.commit + } + } + + $bridgeHash = (Get-FileHash $ArdIrecBridgePath -Algorithm SHA256).Hash.ToLowerInvariant() + $portableHash = (Get-FileHash $singleExePath -Algorithm SHA256).Hash.ToLowerInvariant() + $identityPath = Join-Path $outputRoot "ARSAS-$normalizedVersion-$Runtime-portable-build-identity.json" + [ordered]@{ + schemaVersion = 1 + kind = "arsas-portable-build-identity" + version = $normalizedVersion + runtime = $Runtime + sourceCommit = $sourceCommit + engineCommit = $engineCommit + ardIrecLockCommit = $ardirecLockCommit + ardIrecBridgeSha256 = $bridgeHash + ardIrecBridgeSizeBytes = (Get-Item $ArdIrecBridgePath).Length + portableSha256 = $portableHash + portableSizeBytes = (Get-Item $singleExePath).Length + deterministicManagedBuild = $true + reproducibleNativeLinkRequested = $true + } | ConvertTo-Json -Depth 4 | Set-Content $identityPath -Encoding utf8 + Write-Host "==> Real portable single EXE with embedded ArdIrec bridge: $singleExePath" + Write-Host "==> Portable build identity: $identityPath" + Write-Host "==> Portable SHA256: $portableHash" + Write-Host "==> ArdIrec bridge SHA256: $bridgeHash" Write-Output $singleExePath exit 0 } diff --git a/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs new file mode 100644 index 000000000..ec154884e --- /dev/null +++ b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs @@ -0,0 +1,75 @@ +namespace ARSAS.Tests; + +public sealed class WindowsPackagingReproducibilityContractTests +{ + [Fact] + public void NativeBridge_RequestsReproducibleMsvcLinkOutput() + { + var bridge = File.ReadAllText( + FindRepositoryFile("scripts/build-ardirec-bridge.ps1")); + + Assert.Contains( + "-DCMAKE_SHARED_LINKER_FLAGS_RELEASE=/Brepro", + bridge, + StringComparison.Ordinal); + Assert.Contains( + "-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded", + bridge, + StringComparison.Ordinal); + Assert.Contains( + "ctest --test-dir", + bridge, + StringComparison.Ordinal); + } + + [Fact] + public void PortablePublish_IsDeterministicAndEmitsHashBoundBuildIdentity() + { + var publish = File.ReadAllText( + FindRepositoryFile("scripts/publish-windows-portable.ps1")); + + Assert.Contains("-p:Deterministic=true", publish, StringComparison.Ordinal); + Assert.Contains("-p:ContinuousIntegrationBuild=true", publish, StringComparison.Ordinal); + Assert.Contains("arsas-portable-build-identity", publish, StringComparison.Ordinal); + Assert.Contains("ardIrecBridgeSha256", publish, StringComparison.Ordinal); + Assert.Contains("portableSha256", publish, StringComparison.Ordinal); + Assert.Contains("sourceCommit", publish, StringComparison.Ordinal); + Assert.Contains("engineCommit", publish, StringComparison.Ordinal); + Assert.Contains("ardIrecLockCommit", publish, StringComparison.Ordinal); + Assert.Contains("reproducibleNativeLinkRequested = $true", publish, StringComparison.Ordinal); + } + + [Fact] + public void CanonicalAndFieldCaptureArtifacts_BothCarryBuildIdentity() + { + var canonical = File.ReadAllText( + FindRepositoryFile(".github/workflows/build.yml")); + var capture = File.ReadAllText( + FindRepositoryFile(".github/workflows/smart-discovery-capture-build.yml")); + + const string identity = + "ArIED61850Tester\\dist\\ARSAS-*-win-x64-portable-build-identity.json"; + + Assert.Contains(identity, canonical, StringComparison.Ordinal); + Assert.Contains(identity, capture, StringComparison.Ordinal); + Assert.Contains( + "Packaging authority: independent Field Capture publish/smoke", + capture, + StringComparison.Ordinal); + } + + private static string FindRepositoryFile(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( + $"Repository file not found: {relativePath}"); + } +} From fd25836f309f2b1c879b83f74addf0c0d3cfd8bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:28:57 +0700 Subject: [PATCH 10/99] ci(p3): seal canonical Windows package and reuse exact tested bits Build and test the Windows application once, seal exact portable/installer-input/test-runtime bytes with source, engine, ArdIrec, run, TRX and per-file SHA-256 evidence, and make installer PR/main validate and consume those bytes instead of rebuilding the application. Preserve P3A reproducibility, physical Smart Discovery authority, independent installer/runtime smoke and manual fallback. Harden package materialization against Windows path aliases. Stable release promotion remains unchanged and the sealed artifact explicitly has no release authority. All eleven PR workflows passed. --- .github/workflows/build.yml | 180 ++++++- .../workflows/ci-p0-workflow-integrity.yml | 9 + .github/workflows/installer-windows.yml | 171 +++++- .../audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md | 130 +++++ evidence/ci-workflow-budget.json | 66 ++- scripts/build-windows-installer.ps1 | 51 +- scripts/test-ci-package-reuse.py | 379 +++++++++++++ scripts/verify-ci-package-reuse.py | 510 ++++++++++++++++++ scripts/write-ci-package-manifest.py | 282 ++++++++++ 9 files changed, 1724 insertions(+), 54 deletions(-) create mode 100644 docs/audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md create mode 100644 scripts/test-ci-package-reuse.py create mode 100644 scripts/verify-ci-package-reuse.py create mode 100644 scripts/write-ci-package-manifest.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3336e71e..e35cf016c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: "ARSAS_SOURCE_COMMIT=$actual" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "Testing and packaging exact ARSAS source $actual" - - name: Resolve immutable ARIEC61850 integration lock + - name: Resolve immutable engine and native bridge locks shell: powershell run: | $lockPath = ".\ArIED61850Tester\engines\ARIEC61850.lock.json" @@ -43,10 +43,28 @@ jobs: $lock.commit -notmatch '^[0-9a-f]{40}$') { throw "ARIEC61850 lock metadata is invalid." } + + $ardirecPath = ".\ArIED61850Tester\engines\ARDIREC.lock.json" + if (!(Test-Path $ardirecPath)) { + throw "ArdIrec lock file was not found." + } + $ardirec = Get-Content $ardirecPath -Raw | ConvertFrom-Json + if ($ardirec.schema -ne 3 -or + $ardirec.repository -notmatch '^[^/]+/[^/]+$' -or + $ardirec.ref -notmatch '^[A-Za-z0-9._/-]+$' -or + $ardirec.commit -notmatch '^[0-9a-f]{40}$' -or + $ardirec.bridge.abi -ne 1 -or + $ardirec.bridge.mode -ne 'native-only' -or + $ardirec.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { + throw "ArdIrec native bridge lock metadata is invalid." + } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARIEC61850_REF=$($lock.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - + "ARDIREC_REPOSITORY=$($ardirec.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_REF=$($ardirec.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_COMMIT=$($ardirec.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Verify source, website and license boundaries shell: powershell run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 @@ -295,6 +313,20 @@ jobs: throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." } + - name: Checkout immutable ArdIrec native bridge revision + shell: powershell + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec + if ($LASTEXITCODE -ne 0) { throw "Could not clone pinned ArdIrec repository." } + git -C .\ArdIrec fetch --quiet --depth 1 origin $env:ARDIREC_COMMIT + if ($LASTEXITCODE -ne 0) { throw "Could not fetch pinned ArdIrec revision." } + git -C .\ArdIrec checkout --quiet --detach $env:ARDIREC_COMMIT + if ($LASTEXITCODE -ne 0) { throw "Could not checkout pinned ArdIrec revision." } + $actual = (git -C .\ArdIrec rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -ne $env:ARDIREC_COMMIT) { + throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT, got $actual." + } + - name: Verify required ARIEC61850 APIs shell: powershell run: | @@ -401,7 +433,79 @@ jobs: ArIED61850Tester\TestResults\ci-canonical-authority.json if-no-files-found: error - - name: Publish real portable single EXE x64 + - name: Build and test exact pinned ArdIrec native bridge once + shell: powershell + run: | + $bridgeDir = Join-Path $env:RUNNER_TEMP 'arsas-canonical-ardirec-bridge' + $bridgeBuild = Join-Path $env:RUNNER_TEMP 'arsas-canonical-ardirec-build' + $bridge = [string]( + .\ArIED61850Tester\scripts\build-ardirec-bridge.ps1 ` + -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` + -OutputDirectory $bridgeDir ` + -BuildDirectory $bridgeBuild | + Select-Object -Last 1 + ) + $bridge = $bridge.Trim() + if (-not (Test-Path $bridge -PathType Leaf)) { + throw "Canonical ArdIrec bridge build did not produce a DLL: $bridge" + } + "ARDIREC_BRIDGE_PATH=$bridge" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_BRIDGE_SHA256=$((Get-FileHash $bridge -Algorithm SHA256).Hash.ToLowerInvariant())" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Publish canonical installer-input folder + shell: powershell + run: | + .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` + -Version $env:ARSAS_VERSION ` + -Runtime win-x64 ` + -SingleFile $false ` + -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" ` + -ArdIrecBridgePath $env:ARDIREC_BRIDGE_PATH + if ($LASTEXITCODE -ne 0) { throw "Canonical installer-input publish failed." } + + $publish = ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64" + $ardirecDir = Join-Path $publish 'Tools\ArdIrec' + New-Item -ItemType Directory -Force $ardirecDir | Out-Null + $license = ".\ArdIrec\LICENSE" + if (Test-Path $license -PathType Leaf) { + Copy-Item $license (Join-Path $ardirecDir 'LICENSE.txt') -Force + } + + $packagedBridge = Join-Path $ardirecDir 'ardirec_bridge.dll' + if (-not (Test-Path $packagedBridge -PathType Leaf)) { + throw "Canonical installer-input folder is missing the native bridge." + } + $packagedHash = (Get-FileHash $packagedBridge -Algorithm SHA256).Hash.ToLowerInvariant() + if ($packagedHash -ne $env:ARDIREC_BRIDGE_SHA256) { + throw "Installer-input bridge bytes differ from the tested canonical bridge." + } + + - name: Exercise exact packaged native bridge through managed tests + shell: powershell + run: | + $env:ARSAS_ARDIREC_BRIDGE_PATH = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64\Tools\ArdIrec\ardirec_bridge.dll" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" + foreach ($required in @( + $env:ARSAS_ARDIREC_BRIDGE_PATH, + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG, + $env:ARSAS_NATIVE_LOCUS_TEST_CFG + )) { + if (-not (Test-Path $required -PathType Leaf)) { + throw "Canonical native bridge verification prerequisite is missing: $required" + } + } + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` + -c Release --no-build --no-restore ` + --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { + throw "Canonical packaged ArdIrec bridge failed managed integration tests." + } + + - name: Publish real portable single EXE from the same native bridge shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -410,7 +514,9 @@ jobs: -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" + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecBridgePath $env:ARDIREC_BRIDGE_PATH + if ($LASTEXITCODE -ne 0) { throw "Canonical portable publish failed." } - name: Smoke-test real portable single EXE shell: powershell @@ -430,6 +536,72 @@ jobs: throw "Portable single EXE smoke test failed with exit code $($process.ExitCode)." } + - name: Stage package verification runtime and fixtures + shell: powershell + run: | + $verification = ".\ArIED61850Tester\TestResults\windows-package-verification" + if (Test-Path $verification) { Remove-Item $verification -Recurse -Force } + $tests = Join-Path $verification 'ARSAS.Tests' + $fixtures = Join-Path $verification 'fixtures' + New-Item -ItemType Directory -Force -Path $tests, $fixtures | Out-Null + + $testOutput = ".\ArIED61850Tester\tests\ARSAS.Tests\bin\Release\net8.0-windows" + if (-not (Test-Path (Join-Path $testOutput 'ARSAS.Tests.dll') -PathType Leaf)) { + throw "Canonical prebuilt ARSAS.Tests runtime is missing." + } + Copy-Item (Join-Path $testOutput '*') $tests -Recurse -Force + + foreach ($stem in @('minimal_1999', 'distance_p1')) { + $matches = @(Get-ChildItem ".\ArdIrec\tests\data\$stem.*" -File -ErrorAction SilentlyContinue) + if (-not ($matches | Where-Object Extension -eq '.cfg')) { + throw "Pinned ArdIrec fixture is missing: $stem.cfg" + } + foreach ($file in $matches) { + Copy-Item $file.FullName (Join-Path $fixtures $file.Name) -Force + } + } + + - name: Seal exact tested Windows package payload + shell: powershell + run: | + $artifactRoot = ".\ArIED61850Tester\TestResults\windows-package-artifact" + python .\ArIED61850Tester\scripts\write-ci-package-manifest.py ` + --artifact-root $artifactRoot ` + --portable ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" ` + --installer-input ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64" ` + --verification-dir ".\ArIED61850Tester\TestResults\windows-package-verification" ` + --evidence-dir ".\ArIED61850Tester\TestResults" ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --version $env:ARSAS_VERSION ` + --runtime win-x64 ` + --run-id $env:GITHUB_RUN_ID ` + --attempt $env:GITHUB_RUN_ATTEMPT ` + --event-name $env:GITHUB_EVENT_NAME + if ($LASTEXITCODE -ne 0) { throw "Canonical Windows package sealing failed." } + + $manifest = Get-Content (Join-Path $artifactRoot 'ci-windows-package-authority.json') -Raw | ConvertFrom-Json + if ($manifest.sourceSha -ne $env:GITHUB_SHA -or + $manifest.engineSha -ne $env:ARIEC61850_COMMIT -or + $manifest.ardirecSha -ne $env:ARDIREC_COMMIT -or + -not [bool]$manifest.fullRegressionPassed -or + -not [bool]$manifest.nativeBridgeIntegrationPassed -or + -not [bool]$manifest.portableSmokePassed -or + [bool]$manifest.installerBuilt -or + [bool]$manifest.releasePromotionAuthority -or + $manifest.nativeBridge.sha256 -ne $env:ARDIREC_BRIDGE_SHA256) { + throw "Canonical Windows package manifest does not match the tested build authority." + } + + - name: Upload sealed Windows package input + uses: actions/upload-artifact@v7 + with: + name: ARSAS-windows-package-input + path: ArIED61850Tester\TestResults\windows-package-artifact + if-no-files-found: error + retention-days: 30 + - name: Upload portable single EXE uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index bda091ed5..55b9a7f95 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -12,6 +12,9 @@ on: - "scripts/write-ci-canonical-proof.py" - "scripts/verify-ci-canonical-reuse.py" - "scripts/test-ci-canonical-reuse.py" + - "scripts/write-ci-package-manifest.py" + - "scripts/verify-ci-package-reuse.py" + - "scripts/test-ci-package-reuse.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -25,6 +28,9 @@ on: - "scripts/write-ci-canonical-proof.py" - "scripts/verify-ci-canonical-reuse.py" - "scripts/test-ci-canonical-reuse.py" + - "scripts/write-ci-package-manifest.py" + - "scripts/verify-ci-package-reuse.py" + - "scripts/test-ci-package-reuse.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -60,6 +66,9 @@ jobs: - name: Run canonical CI P2 evidence safety fixtures run: python3 scripts/test-ci-canonical-reuse.py + - name: Run canonical CI P3 package safety fixtures + run: python3 scripts/test-ci-package-reuse.py + - name: Validate tracked workflow topology and budget run: | python3 scripts/audit-ci-workflows.py \ diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index 16bac10f4..35395b0ed 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -9,6 +9,10 @@ on: - "scripts/create-windows-release-checksums.ps1" - "scripts/publish-windows-portable.ps1" - "scripts/stage-ardirec-viewer.ps1" + - "scripts/write-ci-package-manifest.py" + - "scripts/verify-ci-package-reuse.py" + - "scripts/test-ci-package-reuse.py" + - ".github/workflows/build.yml" - ".github/workflows/installer-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" - "ComtradeWorkspaceWindow*" @@ -35,6 +39,10 @@ on: - "scripts/create-windows-release-checksums.ps1" - "scripts/publish-windows-portable.ps1" - "scripts/stage-ardirec-viewer.ps1" + - "scripts/write-ci-package-manifest.py" + - "scripts/verify-ci-package-reuse.py" + - "scripts/test-ci-package-reuse.py" + - ".github/workflows/build.yml" - ".github/workflows/installer-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" - "ComtradeWorkspaceWindow*" @@ -58,6 +66,7 @@ on: permissions: contents: read + actions: read jobs: installer: @@ -66,10 +75,22 @@ jobs: timeout-minutes: 45 steps: - - name: Checkout ARSAS application + - name: Checkout exact ARSAS application revision uses: actions/checkout@v7 with: path: ArIED61850Tester + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Verify exact installer source identity + shell: powershell + run: | + $actual = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + $expected = $env:GITHUB_SHA.Trim().ToLowerInvariant() + if ($actual -ne $expected) { + throw "Installer source mismatch. Expected $expected, got $actual." + } - name: Resolve application version and immutable engine locks shell: powershell @@ -106,7 +127,97 @@ jobs: "ARDIREC_REPOSITORY=$($ardirecLock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_COMMIT=$($ardirecLock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Checkout immutable ARIEC61850 engine revision + - name: Setup Python for sealed package verification + if: github.event_name != 'workflow_dispatch' + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Run offline package verifier safety fixtures + if: github.event_name != 'workflow_dispatch' + run: python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + + - name: Reuse exact tested canonical Windows package + if: github.event_name != 'workflow_dispatch' + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + $eventName = 'pull_request' + $headBranch = $env:PR_HEAD_BRANCH + $headSha = $env:PR_HEAD_SHA + } + else { + $eventName = 'push' + $headBranch = 'main' + $headSha = $env:GITHUB_SHA + } + + $packageRoot = Join-Path $env:RUNNER_TEMP 'arsas-canonical-windows-package' + $proofPath = Join-Path $env:RUNNER_TEMP 'arsas-canonical-windows-package-proof.json' + python .\ArIED61850Tester\scripts\verify-ci-package-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch $headBranch ` + --head-sha $headSha ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --event-name $eventName ` + --wait-seconds 1200 ` + --allow-in-progress-artifact ` + --output-dir $packageRoot ` + --proof-output $proofPath + if ($LASTEXITCODE -ne 0) { + throw "Installer requires the exact sealed Build ARSAS package payload." + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if ($proof.sourceSha -ne $env:GITHUB_SHA -or + $proof.engineSha -ne $env:ARIEC61850_COMMIT -or + $proof.ardirecSha -ne $env:ARDIREC_COMMIT -or + $proof.version -ne $env:APP_VERSION -or + [int]$proof.failed -ne 0 -or + [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1 -or + [bool]$proof.releasePromotionAuthority) { + throw "Canonical Windows package proof does not match installer source authority." + } + + $installerInput = Join-Path $packageRoot ($proof.installerInputRoot -replace '/', '\') + $testAssembly = Join-Path $packageRoot ($proof.testAssemblyPath -replace '/', '\') + $comtradeFixture = Join-Path $packageRoot ($proof.comtradeFixturePath -replace '/', '\') + $locusFixture = Join-Path $packageRoot ($proof.locusFixturePath -replace '/', '\') + foreach ($required in @($installerInput, $testAssembly, $comtradeFixture, $locusFixture)) { + if (-not (Test-Path $required)) { + throw "Verified package materialization is incomplete: $required" + } + } + + "INSTALLER_INPUT_DIR=$installerInput" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_TEST_ASSEMBLY=$testAssembly" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_COMTRADE_FIXTURE=$comtradeFixture" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_LOCUS_FIXTURE=$locusFixture" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_PACKAGE_RUN=$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_PACKAGE_ARTIFACT_SHA256=$($proof.artifactSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## CI-P3 installer package reuse', + '', + "- exact source: $env:GITHUB_SHA", + "- engine: $env:ARIEC61850_COMMIT", + "- ArdIrec: $env:ARDIREC_COMMIT", + "- Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- full regression: $($proof.passed) passed, $($proof.failed) failed", + "- package files: $($proof.fileCount)", + "- proof stage: $($proof.proofStage)", + '- installer remains separate compile/install/runtime-smoke authority' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Checkout immutable ARIEC61850 engine revision for manual build + if: github.event_name == 'workflow_dispatch' shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 @@ -115,7 +226,8 @@ jobs: $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() if ($actual -ne $env:ARIEC61850_COMMIT) { throw "ARIEC61850 pin mismatch." } - - name: Checkout immutable ArdIrec engine revision + - name: Checkout immutable ArdIrec engine revision for manual build + if: github.event_name == 'workflow_dispatch' shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec @@ -124,7 +236,7 @@ jobs: $actual = (git -C .\ArdIrec rev-parse HEAD).Trim() if ($actual -ne $env:ARDIREC_COMMIT) { throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT got $actual." } - - name: Setup .NET 8 + - name: Setup .NET 8 for native verification uses: actions/setup-dotnet@v6 with: dotnet-version: 8.0.x @@ -139,7 +251,8 @@ jobs: $after = (Get-FileHash $native -Algorithm SHA256).Hash if ($before -ne $after) { throw 'Installer verification mutated discovery source.' } - - name: Restore build and test application solution + - name: Restore build and test application solution for manual packaging + if: github.event_name == 'workflow_dispatch' shell: powershell run: | dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln @@ -149,7 +262,8 @@ jobs: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore if ($LASTEXITCODE -ne 0) { throw "Application regression tests failed." } - - name: Publish installer source + - name: Publish installer source for manual packaging + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -159,41 +273,43 @@ jobs: -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" + if ($LASTEXITCODE -ne 0) { throw "Manual installer source publish failed." } - - name: Stage pinned ArdIrec native analysis bridge + - name: Stage pinned ArdIrec native analysis bridge for manual packaging + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\stage-ardirec-viewer.ps1 ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" ` -BuildDirectory "$env:RUNNER_TEMP\ardirec-installer-build" + if ($LASTEXITCODE -ne 0) { throw "Manual ArdIrec staging failed." } - - name: Exercise managed analysis bridge before packaging - shell: powershell - run: | - $publish = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" - $bridge = Join-Path $publish "Tools\ArdIrec\ardirec_bridge.dll" - if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not staged: $bridge" } - $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridge - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" - $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" - dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` - -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" - if ($LASTEXITCODE -ne 0) { throw "Managed ArdIrec bridge/locus integration test failed." } + "INSTALLER_INPUT_DIR=$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_TEST_ASSEMBLY=$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_COMTRADE_FIXTURE=$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_LOCUS_FIXTURE=$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_PACKAGE_RUN=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - name: Install Inno Setup compiler shell: powershell run: choco install innosetup --no-progress --yes - - name: Compile installer + - name: Compile installer from exact tested payload shell: powershell run: | .\ArIED61850Tester\scripts\build-windows-installer.ps1 ` -Version $env:APP_VERSION ` -Runtime win-x64 ` - -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" ` - -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" + -PublishedDirectory $env:INSTALLER_INPUT_DIR ` + -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" ` + -TestAssemblyPath $env:CANONICAL_TEST_ASSEMBLY ` + -FixtureCfg $env:CANONICAL_COMTRADE_FIXTURE ` + -LocusFixtureCfg $env:CANONICAL_LOCUS_FIXTURE - name: Silent install and field-runtime smoke test shell: powershell @@ -231,11 +347,10 @@ jobs: } $env:ARSAS_ARDIREC_BRIDGE_PATH = Join-Path $installRoot "Tools\ArdIrec\ardirec_bridge.dll" - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" - $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" - dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` - -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $env:CANONICAL_COMTRADE_FIXTURE + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $env:CANONICAL_LOCUS_FIXTURE + & dotnet vstest $env:CANONICAL_TEST_ASSEMBLY ` + "--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" if ($LASTEXITCODE -ne 0) { throw "Installed bridge/locus runtime validation failed." } $uninstaller = Join-Path $installRoot "unins000.exe" diff --git a/docs/audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md b/docs/audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md new file mode 100644 index 000000000..c4f09cdae --- /dev/null +++ b/docs/audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md @@ -0,0 +1,130 @@ +# CI-P3 — Canonical Windows package artifact + +Issue #444. + +## Objective + +Move Windows CI from repeated application rebuilds toward a build-once/package-many +model without weakening regression, native bridge, installer, physical or release +authority. + +The central rule is: + +> Test exact source once, seal exact package bytes, then package/promote those exact +> bytes after independently verifying their identity and hashes. + +Rebuilding the same source is not treated as proof that the same binary was produced. + +## Why exact-byte promotion + +CI-P2F observed that two Windows single-file publishes using the same ARSAS source +and ARIEC61850 integration SHA were not byte-identical. CI-P3 therefore does not +require reproducible builds before eliminating downstream rebuilds. + +Instead, Build ARSAS creates an immutable package artifact and downstream consumers +verify that artifact byte-for-byte before use. + +Reproducible-build work remains useful hardening, but it is not the trust boundary. + +## Canonical Build ARSAS responsibilities + +After the normal full regression suite succeeds, Build ARSAS: + +1. resolves the exact ARIEC61850 and ArdIrec lock SHAs; +2. checks out the exact ArdIrec revision; +3. builds and tests one native-only ArdIrec bridge; +4. publishes the installer-input folder using that exact bridge file; +5. runs managed native bridge and distance-locus integration tests against the + bridge in the installer-input folder; +6. publishes the portable single EXE using the same bridge file; +7. smoke-tests the portable EXE; +8. stages the already-built ARSAS.Tests runtime plus the exact COMTRADE fixtures + needed for package validation; +9. writes a hash-complete package manifest; +10. uploads ARSAS-windows-package-input. + +The old ARSAS-win-x64-portable-single-exe artifact remains available for normal +portable testing and download. + +## Sealed artifact contract + +The package artifact contains: + +- ci-windows-package-authority.json; +- canonical full-regression manifest and TRX; +- portable single EXE; +- complete self-contained installer-input folder; +- canonical prebuilt test runtime; +- pinned native integration fixtures. + +The manifest binds: + +- source SHA; +- ARIEC61850 SHA; +- ArdIrec SHA; +- Build ARSAS run ID, attempt and event; +- version/runtime; +- full-regression counters and TRX SHA-256; +- portable SHA-256; +- native bridge SHA-256; +- every payload file path, size and SHA-256. + +It explicitly states that the installer has not yet been built and that the +artifact has no stable-release promotion authority. + +## Cross-run verifier + +verify-ci-package-reuse.py treats workflow artifacts as untrusted ZIP data. + +Before extraction it verifies: + +- repository/event/head/branch/run identity; +- exact source, engine and ArdIrec SHAs; +- latest matching Build ARSAS run semantics; +- completed failures are never reusable; +- safe normalized ZIP paths, no symlinks or encrypted entries; +- bounded file/archive sizes; +- manifest-to-archive file equality; +- every file SHA-256 and size; +- canonical TRX SHA-256 and all-pass counters; +- portable/native bridge special records; +- verification runtime and fixture references. + +Only after all checks pass may it materialize files for installer use. + +## Installer workflow + +For pull_request and push/main, installer validation no longer restores, builds, +tests or publishes the ARSAS application. + +It consumes the exact sealed package, then independently: + +- verifies the physical-proven Smart Discovery source route without mutation; +- runs the packaged native bridge tests from the canonical prebuilt test runtime; +- compiles the Inno Setup installer from the verified installer-input directory; +- performs silent install/uninstall; +- rejects removed ArdIrec desktop/Qt runtime files; +- runs the same native bridge/locus tests against the installed bridge; +- writes checksums and uploads the installer artifact. + +workflow_dispatch retains the historical local-build fallback so manual engineering +validation is not coupled to the existence of a cross-run package artifact. + +## Authority boundaries + +Build ARSAS owns application regression and sealed package bytes. + +Installer workflow owns installer compilation plus installed-package smoke. + +Smart Discovery physical evidence remains unchanged. + +Stable release publication remains unchanged in this milestone. Existing +publish-verified-release.yml already demonstrates artifact-ID + SHA-256 publication +and is the preferred direction for the later release migration. + +## Rollback + +Restore installer PR/main source restore/build/test/publish/stage steps and stop +consuming ARSAS-windows-package-input. + +Do not relax package identity, TRX, ZIP safety or SHA-256 checks to make reuse pass. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 73812cbc1..a0c4fd84c 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3A: reproducible portable build identity", - "policy": "P0-P2 reduced duplicate regression work. P3A does not add a heavy workflow or change static CI counts; it establishes deterministic Windows packaging inputs and hash-bound build identity so later artifact promotion can reuse only proven identical bits.", + "baselineName": "CI-P3: sealed canonical Windows package reused by installer validation", + "policy": "P0 historical workflow-sprawl baseline remains immutable. CI-P3 establishes build-once/package-many for Windows installer validation: Build ARSAS seals exact tested portable + installer-input bytes with source/engine/ArdIrec/TRX/file digests; PR/main installer runs verify and consume those bytes instead of rebuilding the application. Manual workflow_dispatch retains a local fallback. Stable release mutation remains unchanged in this milestone.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -20,11 +20,11 @@ "workflowFiles": 31, "actionsCheckout": 35, "actionsSetupDotnet": 11, - "actionsSetupPython": 17, + "actionsSetupPython": 18, "dotnetRestore": 11, "dotnetBuild": 11, - "dotnetTest": 13, - "actionsUploadArtifact": 34, + "dotnetTest": 12, + "actionsUploadArtifact": 35, "workflowsWithDotnetBuild": 10, "workflowsWithDotnetTest": 9 }, @@ -292,6 +292,62 @@ "manualReusableRegressionRetained": true, "rollback": "Restore direct Field Capture restore/build/dotnet test steps; do not substitute canonical EXE unless future reproducible-build evidence proves byte identity." }, + "ciP3CanonicalWindowsPackage": { + "issueNumber": 444, + "lane": "canonical-windows-package-and-installer-reuse", + "approach": "Build ARSAS builds/tests once, builds one pinned ArdIrec bridge, uses those exact bridge bytes for installer-input and portable outputs, runs managed native integration tests, portable smoke, seals all package/test bytes into a hash-complete artifact, and installer PR/main consumes the exact package rather than restoring/building/publishing ARSAS again.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 17, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 13, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 18, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "staticBudgetNote": "Manual installer workflow_dispatch fallback intentionally keeps restore/build/full-test source commands in YAML, so static restore/build counts do not fall even though PR/main execution removes them.", + "artifact": "ARSAS-windows-package-input", + "exactIdentity": [ + "source SHA", + "ARIEC61850 SHA", + "ArdIrec SHA", + "Build ARSAS run ID/attempt/event" + ], + "proof": [ + "canonical all-pass TRX digest/counters", + "portable SHA-256", + "native bridge SHA-256", + "every installer-input and verification payload file SHA-256" + ], + "nativeBridgeSingleBuild": true, + "portableAndInstallerInputShareExactBridgeBytes": true, + "installerApplicationRebuildOnPullRequest": false, + "installerApplicationRebuildOnMainPush": false, + "installerManualFallbackRetained": true, + "installerStillCompilesInno": true, + "installerStillRunsSilentInstallUninstall": true, + "installerStillRunsInstalledNativeBridgeTests": true, + "releasePromotionAuthority": false, + "releaseWorkflowUnchanged": true, + "reproducibleBuildRequiredForReuse": false, + "exactBytePromotion": true, + "rollback": "Restore installer PR/main source restore/build/test/publish/stage steps and stop consuming ARSAS-windows-package-input. Keep sealed artifact producer/verifier available for diagnosis; never weaken hash or identity checks." + }, "ciP3ReproduciblePortableBuild": { "issueNumber": 447, "lane": "canonical-and-field-capture-windows-packaging", diff --git a/scripts/build-windows-installer.ps1 b/scripts/build-windows-installer.ps1 index 24925f7c8..605a7be8e 100644 --- a/scripts/build-windows-installer.ps1 +++ b/scripts/build-windows-installer.ps1 @@ -5,7 +5,10 @@ param( [string]$Runtime = "win-x64", [string]$PublishedDirectory = "", [string]$OutputDirectory = "", - [string]$InnoCompiler = "" + [string]$InnoCompiler = "", + [string]$TestAssemblyPath = "", + [string]$FixtureCfg = "", + [string]$LocusFixtureCfg = "" ) $ErrorActionPreference = "Stop" @@ -97,29 +100,42 @@ foreach ($file in $forbiddenRuntimeFiles) { } # Run the managed -> C ABI -> ardirec_core smoke on the exact bridge that is about to be -# packaged. The fixture belongs to ARSAS, so release validation does not depend on an external -# test-data path after the engine has been staged. -$testProject = Join-Path $root "tests\ARSAS.Tests\ARSAS.Tests.csproj" -$testAssembly = Join-Path $root "tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" -$fixtureCfg = Join-Path $root "tests\fixtures\comtrade\p1-release-smoke.cfg" +# packaged. CI-P3 can supply a prebuilt canonical test runtime so installer validation does +# not need to compile the application again. The defaults preserve the historical local/release path. +if ([string]::IsNullOrWhiteSpace($TestAssemblyPath)) { + $TestAssemblyPath = Join-Path $root "tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" +} +if ([string]::IsNullOrWhiteSpace($FixtureCfg)) { + $FixtureCfg = Join-Path $root "tests\fixtures\comtrade\p1-release-smoke.cfg" +} + +$TestAssemblyPath = [System.IO.Path]::GetFullPath($TestAssemblyPath) +$FixtureCfg = [System.IO.Path]::GetFullPath($FixtureCfg) +if (-not [string]::IsNullOrWhiteSpace($LocusFixtureCfg)) { + $LocusFixtureCfg = [System.IO.Path]::GetFullPath($LocusFixtureCfg) +} + $bridgePath = Join-Path $PublishedDirectory "Tools\ArdIrec\ardirec_bridge.dll" -if (-not (Test-Path $testProject -PathType Leaf) -or - -not (Test-Path $testAssembly -PathType Leaf) -or - -not (Test-Path $fixtureCfg -PathType Leaf)) { - throw "Native managed bridge smoke prerequisites are missing. Build the Release test project before packaging the installer." +if (-not (Test-Path $TestAssemblyPath -PathType Leaf) -or + -not (Test-Path $FixtureCfg -PathType Leaf) -or + -not (Test-Path $bridgePath -PathType Leaf)) { + throw "Native managed bridge smoke prerequisites are missing. Supply the tested assembly, fixture and packaged bridge." +} +if (-not [string]::IsNullOrWhiteSpace($LocusFixtureCfg) -and + -not (Test-Path $LocusFixtureCfg -PathType Leaf)) { + throw "Native locus fixture was not found: $LocusFixtureCfg" } $previousBridgePath = $env:ARSAS_ARDIREC_BRIDGE_PATH $previousFixtureCfg = $env:ARSAS_NATIVE_COMTRADE_TEST_CFG +$previousLocusCfg = $env:ARSAS_NATIVE_LOCUS_TEST_CFG try { $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridgePath - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $fixtureCfg - Write-Host "==> Validating native COMTRADE bridge and Locus session through ARSAS managed interop" - & dotnet test $testProject ` - -c Release ` - --no-build ` - --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $FixtureCfg + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $LocusFixtureCfg + Write-Host "==> Validating exact packaged native COMTRADE bridge through canonical prebuilt tests" + & dotnet vstest $TestAssemblyPath ` + "--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" if ($LASTEXITCODE -ne 0) { throw "Staged native COMTRADE bridge failed the managed bridge/Locus integration smoke test." } @@ -127,6 +143,7 @@ try { finally { $env:ARSAS_ARDIREC_BRIDGE_PATH = $previousBridgePath $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $previousFixtureCfg + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $previousLocusCfg } if ([string]::IsNullOrWhiteSpace($InnoCompiler)) { diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py new file mode 100644 index 000000000..61774ab11 --- /dev/null +++ b/scripts/test-ci-package-reuse.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +"""Offline fail-closed tests for CI-P3 canonical Windows package sealing/reuse.""" +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parent + + +def load(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +producer = load("arsas_package_producer", "write-ci-package-manifest.py") +verifier = load("arsas_package_verifier", "verify-ci-package-reuse.py") + +SOURCE = "a" * 40 +ENGINE = "b" * 40 +ARDIREC = "c" * 40 +HEAD = "d" * 40 +BRANCH = "ci/p3-canonical-windows-package" +RUN = 4321 +TRX = b""" + + + + +""" + + +def canonical_manifest(event_name: str = "pull_request") -> dict: + counters = producer.counters_from_trx(TRX) + return { + "schemaVersion": 1, + "kind": "arsas-canonical-regression-evidence", + "canonicalWorkflow": "Build ARSAS", + "workflowRunId": RUN, + "runAttempt": 1, + "eventName": event_name, + "exactSourceSha": SOURCE, + "exactEngineSha": ENGINE, + "testFile": "arsas-tests.trx", + "testTrxSha256": hashlib.sha256(TRX).hexdigest(), + "testCounters": counters, + "fullRegressionPassed": True, + "releasePromotionAuthority": False, + } + + +def make_payload(event_name: str = "pull_request") -> tuple[bytes, dict]: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + evidence = root / "evidence-source" + evidence.mkdir() + (evidence / "ci-canonical-authority.json").write_text( + json.dumps(canonical_manifest(event_name), sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + (evidence / "arsas-tests.trx").write_bytes(TRX) + + portable = root / "ARSAS-1.6.40-win-x64-portable.exe" + portable.write_bytes(b"portable-tested-bits") + + installer = root / "ARSAS-1.6.40-win-x64" + bridge = installer / "Tools" / "ArdIrec" / "ardirec_bridge.dll" + bridge.parent.mkdir(parents=True) + bridge.write_bytes(b"native-bridge") + (installer / "ARSAS.exe").write_bytes(b"folder-app") + (installer / "LICENSE").write_text("license", encoding="utf-8") + + verification = root / "verification-source" + tests = verification / "ARSAS.Tests" + fixtures = verification / "fixtures" + tests.mkdir(parents=True) + fixtures.mkdir(parents=True) + (tests / "ARSAS.Tests.dll").write_bytes(b"test-assembly") + (tests / ".coverage-transient").write_text("must-not-promote", encoding="utf-8") + (fixtures / "minimal_1999.cfg").write_text("cfg", encoding="utf-8") + (fixtures / "distance_p1.cfg").write_text("locus", encoding="utf-8") + + artifact = root / "artifact" + manifest = producer.stage_and_create_manifest( + artifact_root=artifact, + portable=portable, + installer_input=installer, + verification_dir=verification, + evidence_dir=evidence, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + version="1.6.40", + runtime="win-x64", + workflow_run_id=RUN, + run_attempt=1, + event_name=event_name, + test_assembly_relative="ARSAS.Tests/ARSAS.Tests.dll", + comtrade_fixture_relative="fixtures/minimal_1999.cfg", + locus_fixture_relative="fixtures/distance_p1.cfg", + ) + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(artifact.rglob("*")): + if path.is_file(): + archive.write(path, "payload/" + path.relative_to(artifact).as_posix()) + return buffer.getvalue(), manifest + + +class FakeApi: + def __init__( + self, + *, + event_name: str = "pull_request", + status: str = "completed", + conclusion: str | None = "success", + artifact_available: bool = True, + ): + self.blob, _ = make_payload(event_name) + self.artifact_available = artifact_available + self.runs = [{ + "id": RUN, + "run_attempt": 1, + "name": "Build ARSAS", + "head_sha": HEAD, + "head_branch": BRANCH, + "event": event_name, + "status": status, + "conclusion": conclusion, + }] + + def get(self, url: str, *, binary: bool = False): + if binary: + return self.blob + if "/workflows/build.yml/runs?" in url: + return {"workflow_runs": self.runs} + if "/runs/" in url and "/artifacts" in url: + if not self.artifact_available: + return {"artifacts": []} + return {"artifacts": [{ + "id": 99, + "name": "ARSAS-windows-package-input", + "expired": False, + "archive_download_url": "https://example.invalid/package.zip", + "workflow_run": {"id": RUN}, + }]} + raise RuntimeError("Unexpected fake API URL " + url) + + +class PackageReuseTests(unittest.TestCase): + def test_producer_and_verifier_accept_exact_payload(self): + blob, manifest = make_payload() + proof = verifier.validate_package_archive( + blob, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + self.assertEqual(proof["passed"], 1320) + self.assertEqual(proof["portableSha256"], manifest["portable"]["sha256"]) + self.assertEqual(proof["nativeBridgeSha256"], manifest["nativeBridge"]["sha256"]) + + def test_safe_materialization_occurs_after_verification(self): + blob, _ = make_payload() + with tempfile.TemporaryDirectory() as temp: + out = Path(temp) / "out" + verifier.validate_package_archive( + blob, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + output_dir=out, + ) + self.assertTrue((out / "ci-windows-package-authority.json").is_file()) + self.assertTrue((out / "installer-input/ARSAS-1.6.40-win-x64/ARSAS.exe").is_file()) + + def test_rejects_wrong_ardirec_identity(self): + blob, _ = make_payload() + with self.assertRaisesRegex(verifier.PackageProofError, "identity or authority"): + verifier.validate_package_archive( + blob, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha="f" * 40, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_rejects_tampered_payload_bytes(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + for info in src.infolist(): + data = src.read(info) + if info.filename.endswith("ARSAS-1.6.40-win-x64-portable.exe"): + data += b"tamper" + dst.writestr(info.filename, data) + with self.assertRaisesRegex(verifier.PackageProofError, "size differs|digest differs"): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_rejects_unsafe_archive_path(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + for info in src.infolist(): + dst.writestr(info.filename, src.read(info)) + dst.writestr("../escape.txt", b"bad") + with self.assertRaisesRegex(verifier.PackageProofError, "unsafe|outside"): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_rejects_windows_casefold_archive_collision(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + names = src.namelist() + for info in src.infolist(): + dst.writestr(info.filename, src.read(info)) + target = next( + name for name in names + if name.endswith("installer-input/ARSAS-1.6.40-win-x64/ARSAS.exe") + ) + dst.writestr(target[:-9] + "arsas.exe", b"collision") + with self.assertRaisesRegex( + verifier.PackageProofError, "Windows-normalized path collision" + ): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_rejects_windows_trailing_dot_archive_path(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + for info in src.infolist(): + dst.writestr(info.filename, src.read(info)) + prefix = PurePosixPath(src.namelist()[0]).parts[0] + dst.writestr(f"{prefix}/installer-input/bad./payload.txt", b"bad") + with self.assertRaisesRegex( + verifier.PackageProofError, "Windows-unsafe path component" + ): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_manifest_never_claims_installer_or_release_authority(self): + _, manifest = make_payload() + self.assertIs(manifest["installerBuilt"], False) + self.assertIs(manifest["releasePromotionAuthority"], False) + self.assertTrue(manifest["nativeBridgeIntegrationPassed"]) + self.assertTrue(manifest["portableSmokePassed"]) + + def test_hidden_build_transients_are_not_promoted(self): + blob, manifest = make_payload() + self.assertFalse(any("/." in "/" + entry["path"] for entry in manifest["files"])) + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + self.assertFalse(any("/." in "/" + name for name in archive.namelist())) + + def test_installer_script_and_workflow_share_prebuilt_test_contract(self): + installer_script = (ROOT / "build-windows-installer.ps1").read_text(encoding="utf-8") + workflow = (ROOT.parent / ".github/workflows/installer-windows.yml").read_text(encoding="utf-8") + for token in ( + "[string]$TestAssemblyPath", + "[string]$FixtureCfg", + "[string]$LocusFixtureCfg", + "& dotnet vstest $TestAssemblyPath", + ): + self.assertIn(token, installer_script) + for token in ( + "-TestAssemblyPath $env:CANONICAL_TEST_ASSEMBLY", + "-FixtureCfg $env:CANONICAL_COMTRADE_FIXTURE", + "-LocusFixtureCfg $env:CANONICAL_LOCUS_FIXTURE", + "& dotnet vstest $env:CANONICAL_TEST_ASSEMBLY", + ): + self.assertIn(token, workflow) + + def test_in_progress_exact_artifact_can_be_reused(self): + api = FakeApi(status="in_progress", conclusion=None) + proof = verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + self.assertEqual(proof["proofStage"], "sealed-package-artifact-ready") + self.assertFalse(proof["workflowCompleted"]) + + def test_completed_failed_latest_run_is_never_reused(self): + api = FakeApi(status="completed", conclusion="failure") + with self.assertRaisesRegex(verifier.PackageProofError, "Latest canonical Build ARSAS run failed"): + verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + + def test_missing_in_progress_artifact_fails_closed_at_bound(self): + api = FakeApi(status="in_progress", conclusion=None, artifact_available=False) + with self.assertRaisesRegex(verifier.PackageProofError, "Timed out"): + verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-ci-package-reuse.py b/scripts/verify-ci-package-reuse.py new file mode 100644 index 000000000..3848e8d97 --- /dev/null +++ b/scripts/verify-ci-package-reuse.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +"""Verify and safely materialize the exact canonical Windows package artifact.""" +from __future__ import annotations + +import argparse +import hashlib +import io +import importlib.util +import json +import os +import shutil +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path, PurePosixPath + +_here = Path(__file__).resolve().parent + +_spec = importlib.util.spec_from_file_location( + "arsas_ci_package_producer", _here / "write-ci-package-manifest.py" +) +assert _spec and _spec.loader +_producer = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_producer) +MANIFEST_NAME = _producer.MANIFEST_NAME +KIND = _producer.KIND +CANONICAL_MANIFEST = _producer.CANONICAL_MANIFEST +CANONICAL_TRX = _producer.CANONICAL_TRX +counters_from_trx = _producer.counters_from_trx +sha = _producer.sha + +EXPECTED_ARTIFACT = "ARSAS-windows-package-input" +MAX_ARCHIVE_BYTES = 768 * 1024 * 1024 +MAX_FILE_BYTES = 256 * 1024 * 1024 +MAX_TOTAL_BYTES = 1024 * 1024 * 1024 +MAX_FILES = 5000 + + +class PackageProofError(RuntimeError): + pass + + +def validate_identity( + repository: str, branch: str, head_sha: str, source_sha: str, + engine_sha: str, ardirec_sha: str, event_name: str +) -> None: + if repository != "masarray/arsas": + raise PackageProofError("Canonical package reuse is restricted to masarray/arsas") + for label, value in ( + ("head", head_sha), + ("source", source_sha), + ("engine", engine_sha), + ("ArdIrec", ardirec_sha), + ): + try: + sha(value, label) + except ValueError as exc: + raise PackageProofError(str(exc)) from exc + if not branch or len(branch) > 200 or "\n" in branch: + raise PackageProofError("Invalid canonical branch identity") + if event_name not in {"pull_request", "push"}: + raise PackageProofError("Unsupported canonical package event") + + +def candidate_runs(payload: dict, head_sha: str, branch: str, event_name: str) -> list[dict]: + runs = payload.get("workflow_runs", []) + if not isinstance(runs, list): + raise PackageProofError("Build ARSAS run listing is malformed") + matches = [ + run for run in runs + if run.get("event") == event_name + and run.get("name") == "Build ARSAS" + and run.get("head_sha") == head_sha + and run.get("head_branch") == branch + and isinstance(run.get("id"), int) + and isinstance(run.get("run_attempt"), int) + ] + return sorted(matches, key=lambda run: (run["id"], run["run_attempt"]), reverse=True) + + +def choose_artifact(payload: dict, run_id: int, *, allow_missing: bool) -> dict | None: + artifacts = payload.get("artifacts", []) + if not isinstance(artifacts, list): + raise PackageProofError("Canonical package artifact listing is malformed") + matches = [ + obj for obj in artifacts + if obj.get("name") == EXPECTED_ARTIFACT + and obj.get("expired") is False + and isinstance(obj.get("id"), int) + and obj.get("workflow_run", {}).get("id") == run_id + ] + if not matches and allow_missing: + return None + if len(matches) != 1: + raise PackageProofError("Expected exactly one non-expired canonical package artifact") + return matches[0] + + +def _zip_relative(info: zipfile.ZipInfo, prefix: PurePosixPath) -> str: + name = info.filename.replace("\\", "/") + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or ":" in name: + raise PackageProofError("Canonical package archive contains an unsafe path") + try: + rel = path.relative_to(prefix) if str(prefix) != "." else path + except ValueError as exc: + raise PackageProofError("Canonical package archive contains files outside its root") from exc + rel_text = rel.as_posix() + if not rel_text or rel_text == ".": + raise PackageProofError("Canonical package archive contains an invalid root entry") + return rel_text + + +WINDOWS_RESERVED_NAMES = { + "con", "prn", "aux", "nul", + *(f"com{i}" for i in range(1, 10)), + *(f"lpt{i}" for i in range(1, 10)), +} + + +def _windows_path_key(path_text: str) -> str: + """Return a Windows-canonical collision key or reject unsafe aliases.""" + path = PurePosixPath(path_text) + normalized = [] + for part in path.parts: + if ( + not part + or part in {".", ".."} + or part.endswith((" ", ".")) + or any(ord(ch) < 32 for ch in part) + ): + raise PackageProofError( + "Canonical package contains a Windows-unsafe path component" + ) + device = part.split(".", 1)[0].casefold() + if device in WINDOWS_RESERVED_NAMES: + raise PackageProofError( + "Canonical package contains a reserved Windows device path" + ) + normalized.append(part.casefold()) + if not normalized: + raise PackageProofError("Canonical package contains an empty Windows path") + return "/".join(normalized) + + +def _is_symlink(info: zipfile.ZipInfo) -> bool: + return ((info.external_attr >> 16) & 0o170000) == 0o120000 + + +def _hash_zip_entry(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> str: + h = hashlib.sha256() + with archive.open(info, "r") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def validate_package_archive( + payload: bytes, *, source_sha: str, engine_sha: str, ardirec_sha: str, + workflow_run_id: int, run_attempt: int, event_name: str, + output_dir: Path | None = None +) -> dict: + if not payload or len(payload) > MAX_ARCHIVE_BYTES: + raise PackageProofError("Canonical package artifact is missing or exceeds bounded size") + try: + archive = zipfile.ZipFile(io.BytesIO(payload)) + except (zipfile.BadZipFile, OSError) as exc: + raise PackageProofError("Canonical package artifact is not a valid ZIP") from exc + + with archive: + raw_files = [info for info in archive.infolist() if not info.is_dir()] + if not raw_files or len(raw_files) > MAX_FILES: + raise PackageProofError("Canonical package archive file count is invalid") + manifest_infos = [ + info for info in raw_files + if PurePosixPath(info.filename.replace("\\", "/")).name == MANIFEST_NAME + ] + if len(manifest_infos) != 1: + raise PackageProofError("Canonical package manifest is missing or duplicated") + manifest_info = manifest_infos[0] + prefix = PurePosixPath(manifest_info.filename.replace("\\", "/")).parent + + infos: dict[str, zipfile.ZipInfo] = {} + windows_archive_paths: dict[str, str] = {} + total_uncompressed = 0 + for info in raw_files: + if info.flag_bits & 0x1 or _is_symlink(info): + raise PackageProofError("Encrypted files and symlinks are not permitted") + if info.file_size < 0 or info.file_size > MAX_FILE_BYTES: + raise PackageProofError("Canonical package contains an oversized file") + total_uncompressed += info.file_size + if total_uncompressed > MAX_TOTAL_BYTES: + raise PackageProofError("Canonical package exceeds bounded uncompressed size") + rel = _zip_relative(info, prefix) + if rel in infos: + raise PackageProofError("Canonical package contains duplicate normalized paths") + windows_key = _windows_path_key(rel) + previous = windows_archive_paths.get(windows_key) + if previous is not None and previous != rel: + raise PackageProofError( + "Canonical package contains a Windows-normalized path collision" + ) + windows_archive_paths[windows_key] = rel + infos[rel] = info + + if MANIFEST_NAME not in infos: + raise PackageProofError("Canonical package manifest must be rooted in artifact") + + try: + manifest = json.loads(archive.read(infos[MANIFEST_NAME])) + except (ValueError, KeyError, OSError, RuntimeError) as exc: + raise PackageProofError("Canonical package manifest cannot be decoded") from exc + + if ( + manifest.get("schemaVersion") != 1 + or manifest.get("kind") != KIND + or manifest.get("canonicalWorkflow") != "Build ARSAS" + or manifest.get("workflowRunId") != workflow_run_id + or manifest.get("runAttempt") != run_attempt + or manifest.get("eventName") != event_name + or manifest.get("sourceSha") != source_sha + or manifest.get("engineSha") != engine_sha + or manifest.get("ardirecSha") != ardirec_sha + or manifest.get("runtime") != "win-x64" + or manifest.get("fullRegressionPassed") is not True + or manifest.get("nativeBridgeIntegrationPassed") is not True + or manifest.get("portableSmokePassed") is not True + or manifest.get("installerBuilt") is not False + or manifest.get("releasePromotionAuthority") is not False + ): + raise PackageProofError("Canonical package manifest identity or authority is invalid") + + records = manifest.get("files") + if not isinstance(records, list) or not records or len(records) > MAX_FILES: + raise PackageProofError("Canonical package file manifest is malformed") + recorded: dict[str, dict] = {} + windows_record_paths: dict[str, str] = {} + for record in records: + if not isinstance(record, dict): + raise PackageProofError("Canonical package file record is malformed") + path = record.get("path") + if not isinstance(path, str) or not path: + raise PackageProofError("Canonical package file path is malformed") + parsed = PurePosixPath(path) + if parsed.is_absolute() or ".." in parsed.parts or ":" in path or path in recorded: + raise PackageProofError("Canonical package file manifest has unsafe/duplicate paths") + windows_key = _windows_path_key(path) + previous = windows_record_paths.get(windows_key) + if previous is not None and previous != path: + raise PackageProofError( + "Canonical package file manifest has a Windows-normalized path collision" + ) + windows_record_paths[windows_key] = path + if not isinstance(record.get("size"), int) or record["size"] < 0: + raise PackageProofError("Canonical package file size is invalid") + digest = record.get("sha256") + if not isinstance(digest, str) or len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest): + raise PackageProofError("Canonical package file digest is invalid") + recorded[path] = record + + archive_payload_paths = set(infos) - {MANIFEST_NAME} + if set(recorded) != archive_payload_paths: + raise PackageProofError("Canonical package archive and file manifest disagree") + if manifest.get("fileCount") != len(recorded): + raise PackageProofError("Canonical package file count disagrees with manifest") + if manifest.get("totalBytes") != sum(r["size"] for r in records): + raise PackageProofError("Canonical package byte count disagrees with manifest") + + for path, record in recorded.items(): + info = infos[path] + if info.file_size != record["size"]: + raise PackageProofError(f"Canonical package file size differs: {path}") + if _hash_zip_entry(archive, info) != record["sha256"]: + raise PackageProofError(f"Canonical package file digest differs: {path}") + + canonical_manifest_path = f"evidence/{CANONICAL_MANIFEST}" + trx_path = f"evidence/{CANONICAL_TRX}" + if canonical_manifest_path not in infos or trx_path not in infos: + raise PackageProofError("Canonical regression evidence is absent from package") + canonical_bytes = archive.read(infos[canonical_manifest_path]) + trx = archive.read(infos[trx_path]) + try: + canonical = json.loads(canonical_bytes) + counters = counters_from_trx(trx) + except (ValueError, ArithmeticError) as exc: + raise PackageProofError("Canonical regression evidence is invalid") from exc + if ( + canonical.get("schemaVersion") != 1 + or canonical.get("kind") != "arsas-canonical-regression-evidence" + or canonical.get("canonicalWorkflow") != "Build ARSAS" + or canonical.get("workflowRunId") != workflow_run_id + or canonical.get("runAttempt") != run_attempt + or canonical.get("eventName") != event_name + or canonical.get("exactSourceSha") != source_sha + or canonical.get("exactEngineSha") != engine_sha + or canonical.get("fullRegressionPassed") is not True + or canonical.get("releasePromotionAuthority") is not False + or canonical.get("testTrxSha256") != hashlib.sha256(trx).hexdigest() + or canonical.get("testCounters") != counters + or manifest.get("testCounters") != counters + or manifest.get("testTrxSha256") != hashlib.sha256(trx).hexdigest() + or manifest.get("canonicalTestManifestSha256") != hashlib.sha256(canonical_bytes).hexdigest() + ): + raise PackageProofError("Canonical regression proof does not match package manifest") + + for key in ("portable", "nativeBridge"): + record = manifest.get(key) + if not isinstance(record, dict) or recorded.get(record.get("path")) != record: + raise PackageProofError(f"Canonical package {key} record is invalid") + verification = manifest.get("verification") + if not isinstance(verification, dict): + raise PackageProofError("Canonical package verification references are missing") + for key in ("testAssemblyPath", "comtradeFixturePath", "locusFixturePath"): + path = verification.get(key) + if not isinstance(path, str) or path not in recorded: + raise PackageProofError(f"Canonical package verification path is invalid: {key}") + + if output_dir is not None: + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True) + for rel, info in infos.items(): + target = output_dir / PurePosixPath(rel) + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info, "r") as src, target.open("wb") as dst: + shutil.copyfileobj(src, dst, 1024 * 1024) + + return { + "canonicalRunId": workflow_run_id, + "canonicalRunAttempt": run_attempt, + "sourceSha": source_sha, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "version": manifest["version"], + "passed": manifest["testCounters"]["passed"], + "failed": manifest["testCounters"]["failed"], + "notExecuted": manifest["testCounters"]["notExecuted"], + "fileCount": manifest["fileCount"], + "totalBytes": manifest["totalBytes"], + "portableSha256": manifest["portable"]["sha256"], + "nativeBridgeSha256": manifest["nativeBridge"]["sha256"], + "installerInputRoot": manifest["installerInputRoot"], + "testAssemblyPath": manifest["verification"]["testAssemblyPath"], + "comtradeFixturePath": manifest["verification"]["comtradeFixturePath"], + "locusFixturePath": manifest["verification"]["locusFixturePath"], + "artifactSha256": hashlib.sha256(payload).hexdigest(), + } + + +class StripCrossOriginAuthorization(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + redirected = super().redirect_request(request, fp, code, msg, headers, newurl) + if redirected is not None: + before = urllib.parse.urlparse(request.full_url).netloc.lower() + after = urllib.parse.urlparse(newurl).netloc.lower() + if before != after: + redirected.remove_header("Authorization") + return redirected + + +class GitHubReadOnly: + def __init__(self, token: str): + if not token: + raise PackageProofError("A read-only GITHUB_TOKEN is required") + self.token = token + + def get(self, url: str, *, binary: bool = False): + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ARSAS-CI-P3-Package-Reuse", + }, + method="GET", + ) + try: + opener = urllib.request.build_opener(StripCrossOriginAuthorization()) + with opener.open(req, timeout=60) as reply: + limit = MAX_ARCHIVE_BYTES + 1 if binary else 4 * 1024 * 1024 + data = reply.read(limit) + if len(data) >= limit: + raise PackageProofError("GitHub package response exceeds bounded size") + return data if binary else json.loads(data) + except PackageProofError: + raise + except (urllib.error.HTTPError, urllib.error.URLError, ValueError) as exc: + raise PackageProofError(f"GitHub package evidence request failed: {exc}") from exc + + +def verify_canonical_package( + api: GitHubReadOnly, *, repository: str, branch: str, + head_sha: str, source_sha: str, engine_sha: str, ardirec_sha: str, + event_name: str = "pull_request", wait_seconds: int = 1200, + poll_seconds: int = 10, allow_in_progress_artifact: bool = False, + output_dir: Path | None = None +) -> dict: + validate_identity( + repository, branch, head_sha, source_sha, engine_sha, ardirec_sha, event_name + ) + if wait_seconds < 0 or poll_seconds < 1: + raise PackageProofError("Invalid bounded wait policy") + + base = "https://api.github.com/repos/" + repository + query = urllib.parse.urlencode( + {"event": event_name, "head_sha": head_sha, "per_page": 100} + ) + url = f"{base}/actions/workflows/build.yml/runs?{query}" + deadline = time.monotonic() + wait_seconds + previous = None + + while True: + runs = candidate_runs(api.get(url), head_sha, branch, event_name) + if runs: + run = runs[0] + run_id, attempt = run["id"], run["run_attempt"] + status, conclusion = run.get("status"), run.get("conclusion") + previous = f"{run_id}/{attempt} {status}/{conclusion}" + if status == "completed" and conclusion != "success": + raise PackageProofError(f"Latest canonical Build ARSAS run failed: {previous}") + + can_read = status == "completed" or ( + allow_in_progress_artifact and status == "in_progress" + ) + if can_read: + artifact = choose_artifact( + api.get(f"{base}/actions/runs/{run_id}/artifacts?per_page=100"), + run_id, + allow_missing=(status != "completed"), + ) + if artifact is not None: + blob = api.get(artifact["archive_download_url"], binary=True) + try: + proof = validate_package_archive( + blob, + source_sha=source_sha, + engine_sha=engine_sha, + ardirec_sha=ardirec_sha, + workflow_run_id=run_id, + run_attempt=attempt, + event_name=event_name, + output_dir=output_dir, + ) + except PackageProofError as exc: + if "identity or authority is invalid" not in str(exc): + raise + previous = f"{previous} (stale/different package identity)" + else: + proof.update({ + "canonicalRunStatus": status, + "canonicalRunConclusion": conclusion, + "workflowCompleted": status == "completed", + "proofStage": ( + "completed-workflow" + if status == "completed" + else "sealed-package-artifact-ready" + ), + "releasePromotionAuthority": False, + }) + return proof + + if time.monotonic() >= deadline: + raise PackageProofError( + "Timed out waiting for exact canonical Windows package artifact " + f"for head={head_sha}, source={source_sha}, last={previous}" + ) + time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--head-branch", required=True) + parser.add_argument("--head-sha", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--ardirec-sha", required=True) + parser.add_argument("--event-name", choices=("pull_request", "push"), default="pull_request") + parser.add_argument("--wait-seconds", type=int, default=1200) + parser.add_argument("--allow-in-progress-artifact", action="store_true") + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--proof-output", type=Path) + args = parser.parse_args() + + proof = verify_canonical_package( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + repository=args.repository, + branch=args.head_branch, + head_sha=args.head_sha, + source_sha=args.source_sha, + engine_sha=args.engine_sha, + ardirec_sha=args.ardirec_sha, + event_name=args.event_name, + wait_seconds=args.wait_seconds, + allow_in_progress_artifact=args.allow_in_progress_artifact, + output_dir=args.output_dir, + ) + payload = json.dumps(proof, sort_keys=True) + print("Canonical Windows package reuse PASS: " + payload) + if args.proof_output: + args.proof_output.parent.mkdir(parents=True, exist_ok=True) + args.proof_output.write_text(payload + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write-ci-package-manifest.py b/scripts/write-ci-package-manifest.py new file mode 100644 index 000000000..f12314900 --- /dev/null +++ b/scripts/write-ci-package-manifest.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Seal the exact tested Windows package payload produced by Build ARSAS.""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import shutil +from pathlib import Path, PurePosixPath + +_here = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location( + "arsas_ci_canonical_proof", _here / "write-ci-canonical-proof.py" +) +assert _spec and _spec.loader +_canonical = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_canonical) +counters_from_trx = _canonical.counters_from_trx +sha = _canonical.sha + +MANIFEST_NAME = "ci-windows-package-authority.json" +CANONICAL_MANIFEST = "ci-canonical-authority.json" +CANONICAL_TRX = "arsas-tests.trx" +KIND = "arsas-canonical-windows-package" +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$") + + +def digest(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def safe_relative(path: Path, root: Path) -> str: + rel = path.relative_to(root).as_posix() + parsed = PurePosixPath(rel) + if parsed.is_absolute() or ".." in parsed.parts or ":" in rel: + raise ValueError(f"Unsafe staged path: {rel}") + return rel + + +def copy_tree(source: Path, destination: Path) -> None: + if not source.is_dir(): + raise FileNotFoundError(f"Directory not found: {source}") + for item in source.rglob("*"): + relative = item.relative_to(source) + # actions/upload-artifact excludes hidden files by default. Hidden build/ + # coverage transients are not runtime package inputs, so exclude them + # deliberately rather than widening artifact upload to hidden content. + if any(part.startswith(".") for part in relative.parts): + continue + if item.is_symlink(): + raise ValueError(f"Symlinks are not permitted in package input: {item}") + if not item.is_file(): + continue + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + + +def verify_canonical_evidence( + evidence_dir: Path, source_sha: str, engine_sha: str, + run_id: int, run_attempt: int, event_name: str +) -> tuple[dict, bytes]: + manifest_path = evidence_dir / CANONICAL_MANIFEST + trx_path = evidence_dir / CANONICAL_TRX + if not manifest_path.is_file() or not trx_path.is_file(): + raise FileNotFoundError("Canonical regression manifest/TRX is missing") + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + trx = trx_path.read_bytes() + counters = counters_from_trx(trx) + if ( + manifest.get("schemaVersion") != 1 + or manifest.get("kind") != "arsas-canonical-regression-evidence" + or manifest.get("canonicalWorkflow") != "Build ARSAS" + or manifest.get("workflowRunId") != run_id + or manifest.get("runAttempt") != run_attempt + or manifest.get("eventName") != event_name + or manifest.get("exactSourceSha") != source_sha + or manifest.get("exactEngineSha") != engine_sha + or manifest.get("fullRegressionPassed") is not True + or manifest.get("releasePromotionAuthority") is not False + or manifest.get("testFile") != CANONICAL_TRX + or manifest.get("testTrxSha256") != hashlib.sha256(trx).hexdigest() + or manifest.get("testCounters") != counters + ): + raise ValueError("Canonical regression evidence does not match package identity") + return manifest, trx + + +def file_record(path: Path, root: Path) -> dict: + return { + "path": safe_relative(path, root), + "size": path.stat().st_size, + "sha256": digest(path), + } + + +def stage_and_create_manifest( + *, + artifact_root: Path, + portable: Path, + installer_input: Path, + verification_dir: Path, + evidence_dir: Path, + source_sha: str, + engine_sha: str, + ardirec_sha: str, + version: str, + runtime: str, + workflow_run_id: int, + run_attempt: int, + event_name: str, + test_assembly_relative: str, + comtrade_fixture_relative: str, + locus_fixture_relative: str, +) -> dict: + source_sha = sha(source_sha, "source") + engine_sha = sha(engine_sha, "engine") + ardirec_sha = sha(ardirec_sha, "ArdIrec") + if not VERSION_RE.fullmatch(version): + raise ValueError("Invalid package version") + if runtime != "win-x64": + raise ValueError("Canonical Windows package runtime must be win-x64") + if workflow_run_id <= 0 or run_attempt <= 0: + raise ValueError("Invalid workflow run identity") + if event_name not in {"push", "pull_request", "workflow_dispatch"}: + raise ValueError("Unexpected workflow event") + if not portable.is_file() or not installer_input.is_dir(): + raise FileNotFoundError("Portable or installer-input payload is missing") + + canonical, trx = verify_canonical_evidence( + evidence_dir, source_sha, engine_sha, workflow_run_id, run_attempt, event_name + ) + + if artifact_root.exists(): + shutil.rmtree(artifact_root) + artifact_root.mkdir(parents=True) + + portable_target = artifact_root / "portable" / portable.name + portable_target.parent.mkdir(parents=True) + shutil.copy2(portable, portable_target) + + installer_target = artifact_root / "installer-input" / installer_input.name + copy_tree(installer_input, installer_target) + + evidence_target = artifact_root / "evidence" + evidence_target.mkdir() + shutil.copy2(evidence_dir / CANONICAL_MANIFEST, evidence_target / CANONICAL_MANIFEST) + (evidence_target / CANONICAL_TRX).write_bytes(trx) + + verification_target = artifact_root / "verification" + copy_tree(verification_dir, verification_target) + + bridge = installer_target / "Tools" / "ArdIrec" / "ardirec_bridge.dll" + test_assembly = verification_target / PurePosixPath(test_assembly_relative) + comtrade_fixture = verification_target / PurePosixPath(comtrade_fixture_relative) + locus_fixture = verification_target / PurePosixPath(locus_fixture_relative) + for label, path in ( + ("native bridge", bridge), + ("test assembly", test_assembly), + ("COMTRADE fixture", comtrade_fixture), + ("locus fixture", locus_fixture), + ): + if not path.is_file(): + raise FileNotFoundError(f"Required {label} missing from sealed payload: {path}") + + files = [] + for path in sorted(artifact_root.rglob("*"), key=lambda p: p.as_posix().lower()): + if path.is_symlink(): + raise ValueError(f"Symlink not allowed in sealed payload: {path}") + if path.is_file() and path.name != MANIFEST_NAME: + files.append(file_record(path, artifact_root)) + + by_path = {entry["path"]: entry for entry in files} + portable_rel = safe_relative(portable_target, artifact_root) + bridge_rel = safe_relative(bridge, artifact_root) + test_rel = safe_relative(test_assembly, artifact_root) + comtrade_rel = safe_relative(comtrade_fixture, artifact_root) + locus_rel = safe_relative(locus_fixture, artifact_root) + + manifest = { + "schemaVersion": 1, + "kind": KIND, + "canonicalWorkflow": "Build ARSAS", + "workflowRunId": workflow_run_id, + "runAttempt": run_attempt, + "eventName": event_name, + "sourceSha": source_sha, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "version": version, + "runtime": runtime, + "fullRegressionPassed": True, + "testCounters": canonical["testCounters"], + "canonicalTestManifestSha256": digest(evidence_target / CANONICAL_MANIFEST), + "testTrxSha256": hashlib.sha256(trx).hexdigest(), + "nativeBridgeIntegrationPassed": True, + "portableSmokePassed": True, + "installerBuilt": False, + "releasePromotionAuthority": False, + "installerInputRoot": safe_relative(installer_target, artifact_root), + "portable": by_path[portable_rel], + "nativeBridge": by_path[bridge_rel], + "verification": { + "testAssemblyPath": test_rel, + "comtradeFixturePath": comtrade_rel, + "locusFixturePath": locus_rel, + }, + "fileCount": len(files), + "totalBytes": sum(entry["size"] for entry in files), + "files": files, + } + (artifact_root / MANIFEST_NAME).write_text( + json.dumps(manifest, sort_keys=True, indent=2) + "\n", encoding="utf-8" + ) + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-root", type=Path, required=True) + parser.add_argument("--portable", type=Path, required=True) + parser.add_argument("--installer-input", type=Path, required=True) + parser.add_argument("--verification-dir", type=Path, required=True) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--ardirec-sha", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--runtime", default="win-x64") + parser.add_argument("--run-id", type=int, required=True) + parser.add_argument("--attempt", type=int, required=True) + parser.add_argument("--event-name", required=True) + parser.add_argument( + "--test-assembly-relative", + default="ARSAS.Tests/ARSAS.Tests.dll", + ) + parser.add_argument( + "--comtrade-fixture-relative", + default="fixtures/minimal_1999.cfg", + ) + parser.add_argument( + "--locus-fixture-relative", + default="fixtures/distance_p1.cfg", + ) + args = parser.parse_args() + + manifest = stage_and_create_manifest( + artifact_root=args.artifact_root, + portable=args.portable, + installer_input=args.installer_input, + verification_dir=args.verification_dir, + evidence_dir=args.evidence_dir, + source_sha=args.source_sha, + engine_sha=args.engine_sha, + ardirec_sha=args.ardirec_sha, + version=args.version, + runtime=args.runtime, + workflow_run_id=args.run_id, + run_attempt=args.attempt, + event_name=args.event_name, + test_assembly_relative=args.test_assembly_relative, + comtrade_fixture_relative=args.comtrade_fixture_relative, + locus_fixture_relative=args.locus_fixture_relative, + ) + print( + "Canonical Windows package sealed: " + f"source={manifest['sourceSha']} engine={manifest['engineSha']} " + f"ardirec={manifest['ardirecSha']} files={manifest['fileCount']} " + f"bytes={manifest['totalBytes']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From cd8d356dcdb6e6dfb78b6d4fb67afe7d797ca262 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:48:18 +0700 Subject: [PATCH 11/99] fix(ci-p3): retry transient sealed-artifact download readiness Retry only transient artifact archive HTTP 404 while preserving the existing bounded wait and all exact source/engine/ArdIrec/hash authority checks. Persistent 404 times out fail-closed and all other download failures remain hard errors. All ten PR workflows passed. --- scripts/test-ci-package-reuse.py | 84 ++++++++++++++++++++++++++++++ scripts/verify-ci-package-reuse.py | 82 +++++++++++++++++++---------- 2 files changed, 138 insertions(+), 28 deletions(-) diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py index 61774ab11..10bed0df6 100644 --- a/scripts/test-ci-package-reuse.py +++ b/scripts/test-ci-package-reuse.py @@ -128,9 +128,11 @@ def __init__( status: str = "completed", conclusion: str | None = "success", artifact_available: bool = True, + binary_404_count: int = 0, ): self.blob, _ = make_payload(event_name) self.artifact_available = artifact_available + self.binary_404_count = binary_404_count self.runs = [{ "id": RUN, "run_attempt": 1, @@ -144,6 +146,12 @@ def __init__( def get(self, url: str, *, binary: bool = False): if binary: + if self.binary_404_count > 0: + self.binary_404_count -= 1 + raise verifier.GitHubRequestError( + "GitHub package evidence request failed: HTTP 404 Not Found", + status=404, + ) return self.blob if "/workflows/build.yml/runs?" in url: return {"workflow_runs": self.runs} @@ -342,6 +350,82 @@ def test_in_progress_exact_artifact_can_be_reused(self): self.assertEqual(proof["proofStage"], "sealed-package-artifact-ready") self.assertFalse(proof["workflowCompleted"]) + def test_transient_artifact_archive_404_is_retried_without_fallback(self): + api = FakeApi( + status="in_progress", + conclusion=None, + binary_404_count=2, + ) + original_sleep = verifier.time.sleep + try: + verifier.time.sleep = lambda _: None + proof = verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=2, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + finally: + verifier.time.sleep = original_sleep + self.assertEqual(proof["proofStage"], "sealed-package-artifact-ready") + self.assertEqual(api.binary_404_count, 0) + + def test_persistent_artifact_archive_404_times_out_fail_closed(self): + api = FakeApi( + status="in_progress", + conclusion=None, + binary_404_count=100, + ) + with self.assertRaisesRegex( + verifier.PackageProofError, + "Timed out waiting for exact canonical Windows package artifact", + ): + verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + + def test_non_404_artifact_download_error_remains_hard_failure(self): + api = FakeApi(status="in_progress", conclusion=None) + original_get = api.get + + def failing_get(url: str, *, binary: bool = False): + if binary: + raise verifier.GitHubRequestError( + "GitHub package evidence request failed: HTTP 403 Forbidden", + status=403, + ) + return original_get(url, binary=binary) + + api.get = failing_get + with self.assertRaisesRegex(verifier.GitHubRequestError, "HTTP 403"): + verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + def test_completed_failed_latest_run_is_never_reused(self): api = FakeApi(status="completed", conclusion="failure") with self.assertRaisesRegex(verifier.PackageProofError, "Latest canonical Build ARSAS run failed"): diff --git a/scripts/verify-ci-package-reuse.py b/scripts/verify-ci-package-reuse.py index 3848e8d97..63338795b 100644 --- a/scripts/verify-ci-package-reuse.py +++ b/scripts/verify-ci-package-reuse.py @@ -42,6 +42,14 @@ class PackageProofError(RuntimeError): pass +class GitHubRequestError(PackageProofError): + """Typed read-only GitHub request failure used for bounded retry decisions.""" + + def __init__(self, message: str, *, status: int | None = None): + super().__init__(message) + self.status = status + + def validate_identity( repository: str, branch: str, head_sha: str, source_sha: str, engine_sha: str, ardirec_sha: str, event_name: str @@ -387,8 +395,15 @@ def get(self, url: str, *, binary: bool = False): return data if binary else json.loads(data) except PackageProofError: raise - except (urllib.error.HTTPError, urllib.error.URLError, ValueError) as exc: - raise PackageProofError(f"GitHub package evidence request failed: {exc}") from exc + except urllib.error.HTTPError as exc: + raise GitHubRequestError( + f"GitHub package evidence request failed: HTTP {exc.code} {exc.reason}", + status=exc.code, + ) from exc + except (urllib.error.URLError, ValueError) as exc: + raise GitHubRequestError( + f"GitHub package evidence request failed: {exc}" + ) from exc def verify_canonical_package( @@ -432,35 +447,46 @@ def verify_canonical_package( allow_missing=(status != "completed"), ) if artifact is not None: - blob = api.get(artifact["archive_download_url"], binary=True) try: - proof = validate_package_archive( - blob, - source_sha=source_sha, - engine_sha=engine_sha, - ardirec_sha=ardirec_sha, - workflow_run_id=run_id, - run_attempt=attempt, - event_name=event_name, - output_dir=output_dir, - ) - except PackageProofError as exc: - if "identity or authority is invalid" not in str(exc): + blob = api.get(artifact["archive_download_url"], binary=True) + except GitHubRequestError as exc: + # GitHub can expose artifact metadata before the archive + # redirect is readable. Retry only the observed transient + # 404, within the existing bounded deadline. + if exc.status != 404: raise - previous = f"{previous} (stale/different package identity)" + previous = ( + f"{previous} (artifact metadata ready; archive download pending)" + ) else: - proof.update({ - "canonicalRunStatus": status, - "canonicalRunConclusion": conclusion, - "workflowCompleted": status == "completed", - "proofStage": ( - "completed-workflow" - if status == "completed" - else "sealed-package-artifact-ready" - ), - "releasePromotionAuthority": False, - }) - return proof + try: + proof = validate_package_archive( + blob, + source_sha=source_sha, + engine_sha=engine_sha, + ardirec_sha=ardirec_sha, + workflow_run_id=run_id, + run_attempt=attempt, + event_name=event_name, + output_dir=output_dir, + ) + except PackageProofError as exc: + if "identity or authority is invalid" not in str(exc): + raise + previous = f"{previous} (stale/different package identity)" + else: + proof.update({ + "canonicalRunStatus": status, + "canonicalRunConclusion": conclusion, + "workflowCompleted": status == "completed", + "proofStage": ( + "completed-workflow" + if status == "completed" + else "sealed-package-artifact-ready" + ), + "releasePromotionAuthority": False, + }) + return proof if time.monotonic() >= deadline: raise PackageProofError( From 9fd31e9e77cbdc32cc6eb65cd6639db291163549 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 17:12:42 +0700 Subject: [PATCH 12/99] ci(p3b): promote sealed canonical portable into Field Capture Use the same hash-complete ARSAS-windows-package-input as installer and Field Capture promotion substrate. Seal and verify portable build identity, promote exact canonical portable bytes, retain field-specific checks/runtime smoke, and keep workflow_dispatch independent publish fallback. All twelve triggered workflows passed and exact-byte parity was verified. --- .github/workflows/build.yml | 5 +- .../smart-discovery-capture-build.yml | 80 ++++++++++++++---- ...P3B_SEALED_CANONICAL_PORTABLE_PROMOTION.md | 82 +++++++++++++++++++ evidence/ci-workflow-budget.json | 34 +++++++- scripts/test-ci-package-reuse.py | 56 +++++++++++++ scripts/verify-ci-package-reuse.py | 29 ++++++- scripts/write-ci-package-manifest.py | 31 ++++++- ...wsPackagingReproducibilityContractTests.cs | 38 ++++++++- 8 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 docs/audits/CI_P3B_SEALED_CANONICAL_PORTABLE_PROMOTION.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e35cf016c..01a572b60 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -568,6 +568,7 @@ jobs: python .\ArIED61850Tester\scripts\write-ci-package-manifest.py ` --artifact-root $artifactRoot ` --portable ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" ` + --portable-identity ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable-build-identity.json" ` --installer-input ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64" ` --verification-dir ".\ArIED61850Tester\TestResults\windows-package-verification" ` --evidence-dir ".\ArIED61850Tester\TestResults" ` @@ -590,7 +591,9 @@ jobs: -not [bool]$manifest.portableSmokePassed -or [bool]$manifest.installerBuilt -or [bool]$manifest.releasePromotionAuthority -or - $manifest.nativeBridge.sha256 -ne $env:ARDIREC_BRIDGE_SHA256) { + $manifest.nativeBridge.sha256 -ne $env:ARDIREC_BRIDGE_SHA256 -or + -not $manifest.portableIdentity -or + $manifest.portableIdentity.path -notmatch 'portable-build-identity\.json$') { throw "Canonical Windows package manifest does not match the tested build authority." } diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 4227a8b8d..365b1d1a0 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -94,6 +94,12 @@ jobs: throw "Unexpected engine commit (physical baseline): $baselineEngineCommit" } $integrationEngineCommit = $lock.commit + $ardirecLock = Get-Content .\ArIED61850Tester\engines\ARDIREC.lock.json -Raw | ConvertFrom-Json + if ($ardirecLock.commit -notmatch '^[0-9a-f]{40}$' -or + $ardirecLock.bridge.abi -ne 1 -or + $ardirecLock.bridge.mode -ne 'native-only') { + throw 'Invalid ArdIrec Field Capture pin.' + } $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() if ($arsasCommit -notmatch '^[0-9a-f]{40}$') { throw "Invalid cloned ARSAS commit: $arsasCommit" @@ -101,6 +107,7 @@ jobs: "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 + "ARDIREC_COMMIT=$($ardirecLock.commit)" | 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 @@ -283,49 +290,88 @@ jobs: throw 'Field Capture Control path does not consume the authoritative smart domain inventory.' } - - name: Setup Python for canonical regression evidence + - name: Setup Python for sealed canonical package verification if: github.event_name == 'pull_request' uses: actions/setup-python@v6 with: python-version: '3.11' - - name: Run offline canonical evidence safety fixtures + - name: Run offline sealed-package safety fixtures if: github.event_name == 'pull_request' - run: python .\ArIED61850Tester\scripts\test-ci-canonical-reuse.py + run: python .\ArIED61850Tester\scripts\test-ci-package-reuse.py - - name: Reuse exact canonical ARSAS full regression + - name: Promote exact sealed canonical portable if: github.event_name == 'pull_request' shell: powershell env: GITHUB_TOKEN: ${{ github.token }} PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_MERGE_SHA: ${{ github.sha }} run: | + $packageRoot = '.\ArIED61850Tester\TestResults\smart-capture-canonical-package' $proofPath = '.\ArIED61850Tester\TestResults\ci-smart-capture-canonical-proof.json' - python .\ArIED61850Tester\scripts\verify-ci-canonical-reuse.py ` + python .\ArIED61850Tester\scripts\verify-ci-package-reuse.py ` --repository $env:GITHUB_REPOSITORY ` --head-branch $env:PR_HEAD_BRANCH ` --head-sha $env:PR_HEAD_SHA ` - --merge-sha $env:PR_MERGE_SHA ` + --source-sha $env:GITHUB_SHA ` --engine-sha $env:ARIEC61850_COMMIT ` - --wait-seconds 960 ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --event-name pull_request ` + --wait-seconds 1200 ` --allow-in-progress-artifact ` - --output $proofPath + --output-dir $packageRoot ` + --proof-output $proofPath if ($LASTEXITCODE -ne 0) { - throw 'Field Capture requires exact canonical ARSAS full-regression evidence.' + throw 'Field Capture requires exact sealed canonical Windows package evidence.' } $proof = Get-Content $proofPath -Raw | ConvertFrom-Json - if ($proof.mergeSha -ne $env:PR_MERGE_SHA -or + if ($proof.sourceSha -ne $env:GITHUB_SHA -or $proof.engineSha -ne $env:ARIEC61850_COMMIT -or + $proof.ardirecSha -ne $env:ARDIREC_COMMIT -or + $proof.version -ne $env:ARSAS_VERSION -or [int]$proof.failed -ne 0 -or [int]$proof.notExecuted -ne 0 -or - [int]$proof.passed -lt 1) { - throw 'Field Capture canonical regression proof is incomplete.' + [int]$proof.passed -lt 1 -or + [bool]$proof.releasePromotionAuthority) { + throw 'Field Capture sealed canonical package proof is incomplete.' + } + + $portable = Join-Path $packageRoot ($proof.portablePath -replace '/', '\') + $identity = Join-Path $packageRoot ($proof.portableIdentityPath -replace '/', '\') + foreach ($required in @($portable, $identity)) { + if (-not (Test-Path $required -PathType Leaf)) { + throw "Verified canonical portable payload is incomplete: $required" + } } - "SMART_CAPTURE_REGRESSION_AUTHORITY=canonical-build-arsas:$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | + $portableSha = (Get-FileHash $portable -Algorithm SHA256).Hash.ToLowerInvariant() + if ($portableSha -ne $proof.portableSha256 -or + [long](Get-Item $portable).Length -ne [long]$proof.portableSizeBytes) { + throw 'Verified canonical portable bytes differ after materialization.' + } + + $dist = '.\ArIED61850Tester\dist' + New-Item -ItemType Directory -Force $dist | Out-Null + Copy-Item $portable (Join-Path $dist (Split-Path $portable -Leaf)) -Force + Copy-Item $identity (Join-Path $dist (Split-Path $identity -Leaf)) -Force + + "SMART_CAPTURE_REGRESSION_AUTHORITY=sealed-build-arsas:$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "SMART_CAPTURE_PACKAGING_AUTHORITY=sealed-build-arsas:$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## CI-P3B Field Capture exact-byte promotion', + '', + "- source: $env:GITHUB_SHA", + "- engine: $env:ARIEC61850_COMMIT", + "- ArdIrec: $env:ARDIREC_COMMIT", + "- Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- regression: $($proof.passed) passed, $($proof.failed) failed", + "- portable SHA-256: $($proof.portableSha256)", + '- Field Capture retains field checks and independent runtime smoke' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - name: Record manual reusable regression authority if: github.event_name == 'workflow_dispatch' @@ -333,13 +379,17 @@ jobs: run: | "SMART_CAPTURE_REGRESSION_AUTHORITY=manual-reusable-exact-sha" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "SMART_CAPTURE_PACKAGING_AUTHORITY=independent-manual-field-capture-publish" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append - name: Setup .NET 8 for independent Field Capture publish + if: github.event_name == 'workflow_dispatch' uses: actions/setup-dotnet@v6 with: dotnet-version: 8.0.x - name: Publish portable single EXE x64 + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -369,7 +419,7 @@ jobs: "ARIEC61850 physical baseline commit: $env:ARIEC61850_BASELINE_COMMIT", "Engine PR: 134", "Regression authority: $env:SMART_CAPTURE_REGRESSION_AUTHORITY", - "Packaging authority: independent Field Capture publish/smoke", + "Packaging authority: $env:SMART_CAPTURE_PACKAGING_AUTHORITY", "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", diff --git a/docs/audits/CI_P3B_SEALED_CANONICAL_PORTABLE_PROMOTION.md b/docs/audits/CI_P3B_SEALED_CANONICAL_PORTABLE_PROMOTION.md new file mode 100644 index 000000000..46a471af3 --- /dev/null +++ b/docs/audits/CI_P3B_SEALED_CANONICAL_PORTABLE_PROMOTION.md @@ -0,0 +1,82 @@ +# CI-P3B — Sealed canonical portable promotion into Field Capture + +Issue #449. + +## Basis + +P3A proved that two independent Windows builders using the same ARSAS source, +ARIEC61850 engine and ArdIrec revision produced byte-identical native bridge and +portable executable outputs. + +Accepted parity: + +- synthetic merge: `3794b29fa71695ec4ea099469ae2e55054d3d3f8`; +- engine: `648124097621046f5f127ceb1cf853fea54db730`; +- ArdIrec: `7dbc7149db668126a493ce338264363a6ec5ec00`; +- bridge SHA-256: `fa8efcaf26e8ebb0a29808a2e52ad5c9f466ec2df146945d29c5d662987c41ce`; +- portable SHA-256: `9fa7fac00c96acb24c98e64ff91ca9ee1709d9f1b89bf55030284ee3da4fc369`; +- portable size: 78,789,758 bytes. + +## Architecture + +P3B uses the existing `ARSAS-windows-package-input` as the sole canonical +promotion substrate. Field Capture does not use a second ad-hoc artifact +download implementation. + +Build ARSAS seals: + +- the tested portable executable; +- its portable build-identity JSON; +- installer input; +- canonical all-pass TRX and manifest; +- native bridge bytes; +- prebuilt verification runtime and fixtures; +- SHA-256 and size for every promoted file. + +The sealed package verifier independently checks exact source, engine, ArdIrec, +workflow run/attempt/event, full-regression counters, every file digest, the +portable build identity and Windows-safe archive paths before materialization. + +## Pull-request Field Capture path + +Field Capture still owns and executes: + +- exact candidate checkout; +- physical baseline engine evidence; +- P0-5c Smart Discovery source invariants; +- deterministic P0-5d wire-proof PASS/FAIL fixtures; +- integration-engine inspection; +- Smart Discovery route/lifecycle verification; +- portable runtime smoke; +- field artifact publication. + +The duplicate PR native bridge + self-contained .NET publish is removed. +After sealed package verification, the exact canonical portable executable and +its exact build identity are copied into the Field Capture dist directory, +smoke-tested again and uploaded under the existing +`ARSAS-smart-discovery-pr134-win-x64` contract. + +## Manual workflow_dispatch + +Manual Field Capture remains independent. It runs the reusable exact-SHA full +regression and then performs its own portable publish. This prevents a manual +field build from depending on a historical PR package. + +## Readiness and fail-closed behavior + +P3B inherits the shared sealed-package verifier's bounded artifact readiness +policy. A transient GitHub archive HTTP 404 may be retried only within the +existing deadline. Persistent 404 times out fail-closed; non-404 request +failures remain hard failures. Completed failed Build ARSAS runs are never +reused. + +## Authority boundary + +P3B changes only the normal PR Field Capture packaging source. It does not +change installer authority, release promotion, physical field evidence, or IEC +61850 runtime behavior. + +## Rollback + +Restore the PR Field Capture independent publish path. Do not add a separate +portable downloader or weaken the shared sealed-package identity/hash checks. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index a0c4fd84c..05a5e52d8 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3: sealed canonical Windows package reused by installer validation", - "policy": "P0 historical workflow-sprawl baseline remains immutable. CI-P3 establishes build-once/package-many for Windows installer validation: Build ARSAS seals exact tested portable + installer-input bytes with source/engine/ArdIrec/TRX/file digests; PR/main installer runs verify and consume those bytes instead of rebuilding the application. Manual workflow_dispatch retains a local fallback. Stable release mutation remains unchanged in this milestone.", + "baselineName": "CI-P3B: exact sealed canonical portable promoted into Field Capture", + "policy": "CI-P3 build-once/package-many now uses one sealed Windows package as the exact-byte promotion substrate for installer validation and PR Field Capture. P3B removes the normal PR Field Capture native/.NET republish after P3A proved matching independent builders are byte-reproducible; field-specific source/wire checks and runtime smoke remain independent, while workflow_dispatch retains an independent publish fallback.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -362,8 +362,34 @@ "Build ARSAS", "Smart Discovery Field Capture Build" ], - "binaryPromotionAllowed": false, + "binaryPromotionAllowed": true, "acceptance": "Matching source, ARIEC61850 and ArdIrec inputs must yield identical bridge and portable SHA-256 across independent workflow runs.", - "rollback": "Remove P3A deterministic flags and identity artifact additions only if toolchain incompatibility is proven; retain P2F binary-reuse prohibition." + "rollback": "Remove P3A deterministic flags and identity artifact additions only if toolchain incompatibility is proven; retain P2F binary-reuse prohibition.", + "acceptedParity": { + "syntheticMergeSha": "3794b29fa71695ec4ea099469ae2e55054d3d3f8", + "engineCommit": "648124097621046f5f127ceb1cf853fea54db730", + "ardIrecCommit": "7dbc7149db668126a493ce338264363a6ec5ec00", + "bridgeSha256": "fa8efcaf26e8ebb0a29808a2e52ad5c9f466ec2df146945d29c5d662987c41ce", + "portableSha256": "9fa7fac00c96acb24c98e64ff91ca9ee1709d9f1b89bf55030284ee3da4fc369", + "portableSizeBytes": 78789758, + "independentBuilders": 2 + } + }, + "ciP3CanonicalPortablePromotion": { + "issueNumber": 449, + "lane": "smart-discovery-capture-build", + "approach": "On pull_request consume the exact hash-complete ARSAS-windows-package-input already produced by Build ARSAS. The shared sealed-package verifier validates exact PR head/branch, synthetic merge/source SHA, ARIEC61850 SHA, ArdIrec SHA, workflow run/attempt, all-pass TRX counters, portable/native hashes and the sealed portable build-identity before materialization. Field Capture retains P0-5c/P0-5d/physical checks, then smoke-tests and republishes those exact canonical portable bytes under the stable field artifact contract. workflow_dispatch retains independent publish.", + "staticLimitsChanged": false, + "canonicalPromotionSubstrate": "ARSAS-windows-package-input", + "separatePortableDownloadMechanism": false, + "sealedPortableIdentityRequired": true, + "transientArchive404UsesSharedBoundedRetry": true, + "prIndependentDotnetPublishRemoved": true, + "fieldSourceAndWireChecksRetained": true, + "fieldPortableSmokeRetained": true, + "manualIndependentPublishRetained": true, + "artifactNameStable": "ARSAS-smart-discovery-pr134-win-x64", + "noInstallerOrReleaseAuthorityChange": true, + "rollback": "Restore PR Field Capture independent publish path if sealed package promotion becomes unavailable; never bypass exact package identity, digest, build-identity or runtime-smoke validation." } } diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py index 10bed0df6..74c79dfcd 100644 --- a/scripts/test-ci-package-reuse.py +++ b/scripts/test-ci-package-reuse.py @@ -82,6 +82,30 @@ def make_payload(event_name: str = "pull_request") -> tuple[bytes, dict]: (installer / "ARSAS.exe").write_bytes(b"folder-app") (installer / "LICENSE").write_text("license", encoding="utf-8") + portable_identity = root / "ARSAS-1.6.40-win-x64-portable-build-identity.json" + portable_identity.write_text( + json.dumps( + { + "schemaVersion": 1, + "kind": "arsas-portable-build-identity", + "version": "1.6.40", + "runtime": "win-x64", + "sourceCommit": SOURCE, + "engineCommit": ENGINE, + "ardIrecLockCommit": ARDIREC, + "ardIrecBridgeSha256": hashlib.sha256(bridge.read_bytes()).hexdigest(), + "ardIrecBridgeSizeBytes": bridge.stat().st_size, + "portableSha256": hashlib.sha256(portable.read_bytes()).hexdigest(), + "portableSizeBytes": portable.stat().st_size, + "deterministicManagedBuild": True, + "reproducibleNativeLinkRequested": True, + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + verification = root / "verification-source" tests = verification / "ARSAS.Tests" fixtures = verification / "fixtures" @@ -96,6 +120,7 @@ def make_payload(event_name: str = "pull_request") -> tuple[bytes, dict]: manifest = producer.stage_and_create_manifest( artifact_root=artifact, portable=portable, + portable_identity=portable_identity, installer_input=installer, verification_dir=verification, evidence_dir=evidence, @@ -182,6 +207,11 @@ def test_producer_and_verifier_accept_exact_payload(self): ) self.assertEqual(proof["passed"], 1320) self.assertEqual(proof["portableSha256"], manifest["portable"]["sha256"]) + self.assertEqual(proof["portablePath"], manifest["portable"]["path"]) + self.assertEqual( + proof["portableIdentityPath"], + manifest["portableIdentity"]["path"], + ) self.assertEqual(proof["nativeBridgeSha256"], manifest["nativeBridge"]["sha256"]) def test_safe_materialization_occurs_after_verification(self): @@ -201,6 +231,32 @@ def test_safe_materialization_occurs_after_verification(self): self.assertTrue((out / "ci-windows-package-authority.json").is_file()) self.assertTrue((out / "installer-input/ARSAS-1.6.40-win-x64/ARSAS.exe").is_file()) + def test_rejects_tampered_portable_build_identity(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + for info in src.infolist(): + data = src.read(info) + if info.filename.endswith("portable-build-identity.json"): + identity = json.loads(data) + identity["sourceCommit"] = "f" * 40 + data = json.dumps(identity, sort_keys=True).encode() + dst.writestr(info.filename, data) + with self.assertRaisesRegex( + verifier.PackageProofError, + "size differs|digest differs|Portable build identity", + ): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + def test_rejects_wrong_ardirec_identity(self): blob, _ = make_payload() with self.assertRaisesRegex(verifier.PackageProofError, "identity or authority"): diff --git a/scripts/verify-ci-package-reuse.py b/scripts/verify-ci-package-reuse.py index 63338795b..ed4a60e14 100644 --- a/scripts/verify-ci-package-reuse.py +++ b/scripts/verify-ci-package-reuse.py @@ -313,10 +313,34 @@ def validate_package_archive( ): raise PackageProofError("Canonical regression proof does not match package manifest") - for key in ("portable", "nativeBridge"): + for key in ("portable", "portableIdentity", "nativeBridge"): record = manifest.get(key) if not isinstance(record, dict) or recorded.get(record.get("path")) != record: raise PackageProofError(f"Canonical package {key} record is invalid") + + identity_record = manifest["portableIdentity"] + try: + build_identity = json.loads( + archive.read(infos[identity_record["path"]]).decode("utf-8-sig") + ) + except (ValueError, UnicodeDecodeError, KeyError, OSError, RuntimeError) as exc: + raise PackageProofError("Portable build identity cannot be decoded") from exc + if ( + build_identity.get("schemaVersion") != 1 + or build_identity.get("kind") != "arsas-portable-build-identity" + or build_identity.get("sourceCommit") != source_sha + or build_identity.get("engineCommit") != engine_sha + or build_identity.get("ardIrecLockCommit") != ardirec_sha + or build_identity.get("portableSha256") != manifest["portable"]["sha256"] + or build_identity.get("portableSizeBytes") != manifest["portable"]["size"] + or build_identity.get("ardIrecBridgeSha256") != manifest["nativeBridge"]["sha256"] + or build_identity.get("ardIrecBridgeSizeBytes") != manifest["nativeBridge"]["size"] + or build_identity.get("deterministicManagedBuild") is not True + or build_identity.get("reproducibleNativeLinkRequested") is not True + ): + raise PackageProofError( + "Portable build identity does not match canonical package authority" + ) verification = manifest.get("verification") if not isinstance(verification, dict): raise PackageProofError("Canonical package verification references are missing") @@ -348,6 +372,9 @@ def validate_package_archive( "fileCount": manifest["fileCount"], "totalBytes": manifest["totalBytes"], "portableSha256": manifest["portable"]["sha256"], + "portableSizeBytes": manifest["portable"]["size"], + "portablePath": manifest["portable"]["path"], + "portableIdentityPath": manifest["portableIdentity"]["path"], "nativeBridgeSha256": manifest["nativeBridge"]["sha256"], "installerInputRoot": manifest["installerInputRoot"], "testAssemblyPath": manifest["verification"]["testAssemblyPath"], diff --git a/scripts/write-ci-package-manifest.py b/scripts/write-ci-package-manifest.py index f12314900..ee8002f20 100644 --- a/scripts/write-ci-package-manifest.py +++ b/scripts/write-ci-package-manifest.py @@ -105,6 +105,7 @@ def stage_and_create_manifest( *, artifact_root: Path, portable: Path, + portable_identity: Path, installer_input: Path, verification_dir: Path, evidence_dir: Path, @@ -131,8 +132,8 @@ def stage_and_create_manifest( raise ValueError("Invalid workflow run identity") if event_name not in {"push", "pull_request", "workflow_dispatch"}: raise ValueError("Unexpected workflow event") - if not portable.is_file() or not installer_input.is_dir(): - raise FileNotFoundError("Portable or installer-input payload is missing") + if not portable.is_file() or not portable_identity.is_file() or not installer_input.is_dir(): + raise FileNotFoundError("Portable, build identity, or installer-input payload is missing") canonical, trx = verify_canonical_evidence( evidence_dir, source_sha, engine_sha, workflow_run_id, run_attempt, event_name @@ -145,6 +146,8 @@ def stage_and_create_manifest( portable_target = artifact_root / "portable" / portable.name portable_target.parent.mkdir(parents=True) shutil.copy2(portable, portable_target) + portable_identity_target = artifact_root / "portable" / portable_identity.name + shutil.copy2(portable_identity, portable_identity_target) installer_target = artifact_root / "installer-input" / installer_input.name copy_tree(installer_input, installer_target) @@ -179,11 +182,32 @@ def stage_and_create_manifest( by_path = {entry["path"]: entry for entry in files} portable_rel = safe_relative(portable_target, artifact_root) + portable_identity_rel = safe_relative(portable_identity_target, artifact_root) bridge_rel = safe_relative(bridge, artifact_root) test_rel = safe_relative(test_assembly, artifact_root) comtrade_rel = safe_relative(comtrade_fixture, artifact_root) locus_rel = safe_relative(locus_fixture, artifact_root) + try: + build_identity = json.loads(portable_identity_target.read_text(encoding="utf-8-sig")) + except (OSError, ValueError) as exc: + raise ValueError("Portable build identity cannot be decoded") from exc + + if ( + build_identity.get("schemaVersion") != 1 + or build_identity.get("kind") != "arsas-portable-build-identity" + or build_identity.get("sourceCommit") != source_sha + or build_identity.get("engineCommit") != engine_sha + or build_identity.get("ardIrecLockCommit") != ardirec_sha + or build_identity.get("portableSha256") != by_path[portable_rel]["sha256"] + or build_identity.get("portableSizeBytes") != by_path[portable_rel]["size"] + or build_identity.get("ardIrecBridgeSha256") != by_path[bridge_rel]["sha256"] + or build_identity.get("ardIrecBridgeSizeBytes") != by_path[bridge_rel]["size"] + or build_identity.get("deterministicManagedBuild") is not True + or build_identity.get("reproducibleNativeLinkRequested") is not True + ): + raise ValueError("Portable build identity does not match sealed package authority") + manifest = { "schemaVersion": 1, "kind": KIND, @@ -206,6 +230,7 @@ def stage_and_create_manifest( "releasePromotionAuthority": False, "installerInputRoot": safe_relative(installer_target, artifact_root), "portable": by_path[portable_rel], + "portableIdentity": by_path[portable_identity_rel], "nativeBridge": by_path[bridge_rel], "verification": { "testAssemblyPath": test_rel, @@ -226,6 +251,7 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--artifact-root", type=Path, required=True) parser.add_argument("--portable", type=Path, required=True) + parser.add_argument("--portable-identity", type=Path, required=True) parser.add_argument("--installer-input", type=Path, required=True) parser.add_argument("--verification-dir", type=Path, required=True) parser.add_argument("--evidence-dir", type=Path, required=True) @@ -254,6 +280,7 @@ def main() -> int: manifest = stage_and_create_manifest( artifact_root=args.artifact_root, portable=args.portable, + portable_identity=args.portable_identity, installer_input=args.installer_input, verification_dir=args.verification_dir, evidence_dir=args.evidence_dir, diff --git a/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs index ec154884e..926ee98e9 100644 --- a/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs +++ b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs @@ -53,7 +53,43 @@ public void CanonicalAndFieldCaptureArtifacts_BothCarryBuildIdentity() Assert.Contains(identity, canonical, StringComparison.Ordinal); Assert.Contains(identity, capture, StringComparison.Ordinal); Assert.Contains( - "Packaging authority: independent Field Capture publish/smoke", + "--portable-identity", + canonical, + StringComparison.Ordinal); + Assert.Contains( + "Promote exact sealed canonical portable", + capture, + StringComparison.Ordinal); + Assert.Contains( + "verify-ci-package-reuse.py", + capture, + StringComparison.Ordinal); + Assert.Contains( + "--allow-in-progress-artifact", + capture, + StringComparison.Ordinal); + Assert.Contains( + "proof.portablePath", + capture, + StringComparison.Ordinal); + Assert.Contains( + "proof.portableIdentityPath", + capture, + StringComparison.Ordinal); + Assert.Contains( + "SMART_CAPTURE_PACKAGING_AUTHORITY=sealed-build-arsas:", + capture, + StringComparison.Ordinal); + Assert.Contains( + "SMART_CAPTURE_PACKAGING_AUTHORITY=independent-manual-field-capture-publish", + capture, + StringComparison.Ordinal); + Assert.Contains( + "if: github.event_name == 'workflow_dispatch'", + capture, + StringComparison.Ordinal); + Assert.Contains( + "Packaging authority: $env:SMART_CAPTURE_PACKAGING_AUTHORITY", capture, StringComparison.Ordinal); } From 18a9ab12b96c9b715c46eb509ecb116390138b55 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 18:30:56 +0700 Subject: [PATCH 13/99] ci(p3c): promote exact sealed package through production release Complete CI-P3 build-once/package-many for Windows production release. Main/tag release paths now require the exact successful Build ARSAS push/main sealed package for the same source, ARIEC61850 and ArdIrec SHAs, promote exact portable and installer-input bytes, and retain release-owned native verification, installer compile/install smoke, checksum, SBOM, provenance, attestations and immutable publication. Manual workflow_dispatch keeps the local rebuild fallback. All twelve triggered PR workflows passed. --- .github/workflows/build.yml | 8 + .github/workflows/release-windows.yml | 238 +++++++++++++++--- docs/WINDOWS_RELEASES.md | 27 +- .../CI_P3C_RELEASE_EXACT_PACKAGE_PROMOTION.md | 83 ++++++ evidence/ci-workflow-budget.json | 64 ++++- scripts/test-ci-package-reuse.py | 74 +++++- scripts/validate-supply-chain-workflows.py | 5 + scripts/verify-ci-package-reuse.py | 8 +- scripts/write-ci-package-manifest.py | 10 + 9 files changed, 467 insertions(+), 50 deletions(-) create mode 100644 docs/audits/CI_P3C_RELEASE_EXACT_PACKAGE_PROMOTION.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 01a572b60..c53e5e95e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -561,6 +561,14 @@ jobs: } } + foreach ($extension in @('cfg', 'dat')) { + $releaseFixture = ".\ArIED61850Tester\tests\fixtures\comtrade\p1-release-smoke.$extension" + if (-not (Test-Path $releaseFixture -PathType Leaf)) { + throw "Canonical release native fixture is missing: $releaseFixture" + } + Copy-Item $releaseFixture (Join-Path $fixtures "p1-release-smoke.$extension") -Force + } + - name: Seal exact tested Windows package payload shell: powershell run: | diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 25c643863..b8b37446b 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -27,6 +27,7 @@ on: permissions: contents: write + actions: read id-token: write attestations: write @@ -36,11 +37,23 @@ jobs: runs-on: windows-latest steps: - - name: Checkout ARSAS application + - name: Checkout exact ARSAS release source uses: actions/checkout@v7 with: path: ArIED61850Tester + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false + + - name: Verify exact release source identity + shell: powershell + run: | + $actual = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + $expected = $env:GITHUB_SHA.Trim().ToLowerInvariant() + if ($actual -ne $expected) { + throw "Release source mismatch. Expected $expected, checked out $actual." + } + "RELEASE_SOURCE_COMMIT=$actual" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - name: Resolve release identity and immutable engine locks id: release @@ -128,7 +141,8 @@ jobs: "ARDIREC_BRIDGE_ABI=$($ardirecLock.bridge.abi)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_BRIDGE_RELATIVE_LIBRARY=$($ardirecLock.bridge.relativeLibrary)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Checkout immutable ARIEC61850 engine revision + - name: Checkout immutable ARIEC61850 engine revision for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 @@ -139,7 +153,8 @@ jobs: throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." } - - name: Checkout immutable ArdIrec native engine revision + - name: Checkout immutable ArdIrec native engine revision for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec @@ -185,7 +200,108 @@ jobs: } } - - name: Restore, build and test exact release source + - name: Run offline sealed-package safety fixtures + if: github.event_name != 'workflow_dispatch' + shell: powershell + run: python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + + - name: Promote exact sealed canonical Windows package for release + if: github.event_name != 'workflow_dispatch' + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $packageRoot = Join-Path $env:RUNNER_TEMP 'arsas-release-canonical-package' + $proofPath = Join-Path $env:RUNNER_TEMP 'arsas-release-canonical-package-proof.json' + python .\ArIED61850Tester\scripts\verify-ci-package-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch main ` + --head-sha $env:GITHUB_SHA ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --event-name push ` + --wait-seconds 1200 ` + --allow-in-progress-artifact ` + --output-dir $packageRoot ` + --proof-output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Production release requires the exact sealed Build ARSAS package for this main commit.' + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if ($proof.sourceSha -ne $env:GITHUB_SHA -or + $proof.engineSha -ne $env:ARIEC61850_COMMIT -or + $proof.ardirecSha -ne $env:ARDIREC_COMMIT -or + $proof.version -ne $env:RELEASE_VERSION -or + [int]$proof.failed -ne 0 -or + [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1 -or + [bool]$proof.releasePromotionAuthority) { + throw 'Canonical package identity does not match release authority.' + } + + $installerInput = Join-Path $packageRoot ($proof.installerInputRoot -replace '/', '\') + $portable = Join-Path $packageRoot ($proof.portablePath -replace '/', '\') + $testAssembly = Join-Path $packageRoot ($proof.testAssemblyPath -replace '/', '\') + $releaseFixture = Join-Path $packageRoot ($proof.releaseFixturePath -replace '/', '\') + $locusFixture = Join-Path $packageRoot ($proof.locusFixturePath -replace '/', '\') + foreach ($required in @($installerInput, $portable, $testAssembly, $releaseFixture, $locusFixture)) { + if (-not (Test-Path $required)) { + throw "Verified release package materialization is incomplete: $required" + } + } + + $dist = Join-Path $env:GITHUB_WORKSPACE 'ArIED61850Tester\dist' + New-Item -ItemType Directory -Force $dist | Out-Null + $releaseInput = Join-Path $dist "ARSAS-$env:RELEASE_VERSION-win-x64" + if (Test-Path $releaseInput) { Remove-Item $releaseInput -Recurse -Force } + Copy-Item $installerInput $releaseInput -Recurse -Force + + $releasePortable = Join-Path $dist "ARSAS-$env:RELEASE_VERSION-win-x64-portable.exe" + Copy-Item $portable $releasePortable -Force + $portableSha = (Get-FileHash $releasePortable -Algorithm SHA256).Hash.ToLowerInvariant() + if ($portableSha -ne $proof.portableSha256 -or + [long](Get-Item $releasePortable).Length -ne [long]$proof.portableSizeBytes) { + throw 'Promoted release portable bytes differ from the sealed package proof.' + } + + $bridge = Join-Path $releaseInput 'Tools\ArdIrec\ardirec_bridge.dll' + $bridgeSha = (Get-FileHash $bridge -Algorithm SHA256).Hash.ToLowerInvariant() + if ($bridgeSha -ne $proof.nativeBridgeSha256) { + throw 'Promoted release native bridge differs from the sealed package proof.' + } + + $evidenceDir = '.\ArIED61850Tester\TestResults\release-promoted-evidence' + New-Item -ItemType Directory -Force $evidenceDir | Out-Null + Copy-Item (Join-Path $packageRoot 'evidence\arsas-tests.trx') (Join-Path $evidenceDir 'arsas-tests.trx') -Force + Copy-Item $proofPath (Join-Path $evidenceDir 'ci-release-package-proof.json') -Force + + "RELEASE_INSTALLER_INPUT_DIR=$releaseInput" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PORTABLE_PATH=$releasePortable" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_TEST_ASSEMBLY=$testAssembly" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_COMTRADE_FIXTURE=$releaseFixture" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_LOCUS_FIXTURE=$locusFixture" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_AUTHORITY=sealed-build-arsas:$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_RUN=$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_ARTIFACT_SHA256=$($proof.artifactSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_CANONICAL_PORTABLE_SHA256=$($proof.portableSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## CI-P3C exact-byte release promotion', + '', + "- source: $env:GITHUB_SHA", + "- engine: $env:ARIEC61850_COMMIT", + "- ArdIrec: $env:ARDIREC_COMMIT", + "- Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- canonical regression: $($proof.passed) passed / $($proof.failed) failed", + "- portable SHA-256: $($proof.portableSha256)", + "- sealed artifact SHA-256: $($proof.artifactSha256)", + '- release does not rebuild application bytes' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Restore, build and test exact release source for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln @@ -200,15 +316,8 @@ jobs: --results-directory .\ArIED61850Tester\TestResults if ($LASTEXITCODE -ne 0) { throw "Release application regression tests failed." } - - name: Upload release test evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: ARSAS-${{ steps.release.outputs.version }}-release-tests - path: ArIED61850Tester/TestResults/*.trx - if-no-files-found: error - - - name: Publish installer source folder with pinned native bridge + - name: Publish installer source folder for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -220,26 +329,33 @@ jobs: -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" + "RELEASE_INSTALLER_INPUT_DIR=$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_TEST_ASSEMBLY=$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_COMTRADE_FIXTURE=$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\fixtures\comtrade\p1-release-smoke.cfg" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_LOCUS_FIXTURE=$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_AUTHORITY=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_RUN=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_ARTIFACT_SHA256=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + - name: Exercise managed native bridge before release packaging shell: powershell run: | - $publish = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" - $bridge = Join-Path $publish "Tools\ArdIrec\ardirec_bridge.dll" - $fixture = "$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\fixtures\comtrade\p1-release-smoke.cfg" - $locusFixture = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" - if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not published: $bridge" } - if (-not (Test-Path $fixture -PathType Leaf)) { throw "ARSAS native release fixture was not found: $fixture" } - if (-not (Test-Path $locusFixture -PathType Leaf)) { throw "Pinned ArdIrec locus fixture was not found: $locusFixture" } + $bridge = Join-Path $env:RELEASE_INSTALLER_INPUT_DIR "Tools\ArdIrec\ardirec_bridge.dll" + foreach ($required in @($bridge, $env:RELEASE_TEST_ASSEMBLY, $env:RELEASE_COMTRADE_FIXTURE, $env:RELEASE_LOCUS_FIXTURE)) { + if (-not (Test-Path $required -PathType Leaf)) { + throw "Release native verification prerequisite is missing: $required" + } + } $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridge - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $fixture - $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $locusFixture - dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` - -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $env:RELEASE_COMTRADE_FIXTURE + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $env:RELEASE_LOCUS_FIXTURE + & dotnet vstest $env:RELEASE_TEST_ASSEMBLY ` + "--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" if ($LASTEXITCODE -ne 0) { throw "Release managed ArdIrec bridge/locus integration test failed." } - - name: Publish real portable single EXE with embedded native bridge + - name: Publish real portable single EXE for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -250,12 +366,50 @@ jobs: -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" ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" + "RELEASE_PORTABLE_PATH=$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-portable.exe" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + $manualPortableSha = (Get-FileHash $env:RELEASE_PORTABLE_PATH -Algorithm SHA256).Hash.ToLowerInvariant() + "RELEASE_CANONICAL_PORTABLE_SHA256=$manualPortableSha" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - name: Smoke-test real portable single EXE + - name: Stage unified release regression evidence + if: always() shell: powershell run: | - $exe = ".\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-portable.exe" - if (-not (Test-Path $exe -PathType Leaf)) { throw "Portable single EXE was not produced: $exe" } + $evidenceDir = '.\ArIED61850Tester\TestResults\release-promoted-evidence' + New-Item -ItemType Directory -Force $evidenceDir | Out-Null + if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { + $trx = @(Get-ChildItem '.\ArIED61850Tester\TestResults\*.trx' -File -ErrorAction SilentlyContinue) + if ($trx.Count -lt 1) { + throw 'Manual release regression TRX is missing.' + } + foreach ($file in $trx) { + Copy-Item $file.FullName (Join-Path $evidenceDir $file.Name) -Force + } + [ordered]@{ + schemaVersion = 1 + authority = 'manual-local-build' + sourceCommit = $env:GITHUB_SHA + engineCommit = $env:ARIEC61850_COMMIT + ardIrecCommit = $env:ARDIREC_COMMIT + } | ConvertTo-Json | Set-Content (Join-Path $evidenceDir 'ci-release-package-proof.json') -Encoding utf8 + } + + - name: Upload release regression evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: ARSAS-${{ steps.release.outputs.version }}-release-tests + path: ArIED61850Tester/TestResults/release-promoted-evidence/* + if-no-files-found: error + + - name: Smoke-test exact release portable single EXE + shell: powershell + run: | + $exe = $env:RELEASE_PORTABLE_PATH + if (-not (Test-Path $exe -PathType Leaf)) { throw "Release portable single EXE is missing: $exe" } + $actualSha = (Get-FileHash $exe -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha -ne $env:RELEASE_CANONICAL_PORTABLE_SHA256) { + throw 'Release portable bytes changed after package promotion.' + } $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP "ARSAS-release-bundle-cache" $diagnostic = Join-Path ([System.IO.Path]::GetTempPath()) "ARSAS-portable-smoke-error.txt" Remove-Item $diagnostic -Force -ErrorAction SilentlyContinue @@ -279,8 +433,11 @@ jobs: .\ArIED61850Tester\scripts\build-windows-installer.ps1 ` -Version $env:RELEASE_VERSION ` -Runtime win-x64 ` - -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" ` - -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" + -PublishedDirectory $env:RELEASE_INSTALLER_INPUT_DIR ` + -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" ` + -TestAssemblyPath $env:RELEASE_TEST_ASSEMBLY ` + -FixtureCfg $env:RELEASE_COMTRADE_FIXTURE ` + -LocusFixtureCfg $env:RELEASE_LOCUS_FIXTURE - name: Smoke-test silent installer and uninstaller shell: powershell @@ -330,6 +487,15 @@ jobs: } } + $env:ARSAS_ARDIREC_BRIDGE_PATH = Join-Path $installRoot "Tools\ArdIrec\ardirec_bridge.dll" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $env:RELEASE_COMTRADE_FIXTURE + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $env:RELEASE_LOCUS_FIXTURE + & dotnet vstest $env:RELEASE_TEST_ASSEMBLY ` + "--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { + throw "Installed release bridge/locus runtime validation failed." + } + $uninstaller = Join-Path $installRoot "unins000.exe" if (-not (Test-Path $uninstaller -PathType Leaf)) { throw "Installer did not create an uninstaller." } $uninstall = Start-Process -FilePath $uninstaller -ArgumentList @("/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART") -Wait -PassThru @@ -388,6 +554,10 @@ jobs: comtradeViewerCommit = $env:ARDIREC_COMMIT comtradeBridgeAbi = [int]$env:ARDIREC_BRIDGE_ABI comtradeBridgeRelativeLibrary = $env:ARDIREC_BRIDGE_RELATIVE_LIBRARY + canonicalPackageAuthority = $env:RELEASE_PACKAGE_AUTHORITY + canonicalPackageRun = $env:RELEASE_PACKAGE_RUN + canonicalPackageArtifactSha256 = $env:RELEASE_PACKAGE_ARTIFACT_SHA256 + canonicalPortableSha256 = $env:RELEASE_CANONICAL_PORTABLE_SHA256 workflow = "release-windows.yml" } $evidence | ConvertTo-Json -Depth 4 | Set-Content ` @@ -416,7 +586,11 @@ jobs: $provenance.engineCommit -ne $env:ARIEC61850_COMMIT -or $provenance.comtradeViewerCommit -ne $env:ARDIREC_COMMIT -or $provenance.comtradeBridgeAbi -ne 1 -or - $provenance.comtradeBridgeRelativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { + $provenance.comtradeBridgeRelativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll' -or + $provenance.canonicalPackageAuthority -ne $env:RELEASE_PACKAGE_AUTHORITY -or + $provenance.canonicalPackageRun -ne $env:RELEASE_PACKAGE_RUN -or + $provenance.canonicalPackageArtifactSha256 -ne $env:RELEASE_PACKAGE_ARTIFACT_SHA256 -or + $provenance.canonicalPortableSha256 -ne $env:RELEASE_CANONICAL_PORTABLE_SHA256) { throw "Release provenance does not match the tested app, IEC engine and native COMTRADE bridge revisions." } diff --git a/docs/WINDOWS_RELEASES.md b/docs/WINDOWS_RELEASES.md index 9a3232cec..cb95c1107 100644 --- a/docs/WINDOWS_RELEASES.md +++ b/docs/WINDOWS_RELEASES.md @@ -11,20 +11,21 @@ The current stable application version is **1.6.40**. Public download metadata r The canonical release request is `.release/windows.json`. A reviewed change to that file on `main` triggers `.github/workflows/release-windows.yml`. -The release workflow: +The production release workflow: 1. resolves the semantic version from the release request or tag and verifies it against `VERSION`, `Directory.Build.props`, and `ArIED61850Tester.csproj`; -2. resolves and checks out the immutable ARIEC61850 engine revision from `engines/ARIEC61850.lock.json`; -3. resolves and checks out the immutable ArdIrec revision from `engines/ARDIREC.lock.json`; -4. verifies source and licensing boundaries; -5. restores, builds, and runs the ARSAS regression suite against the exact release source; -6. exercises the managed native COMTRADE bridge, including cursor, phasor, harmonics, and distance-locus integration; -7. publishes the multi-file self-contained source used by the installer; -8. publishes and smoke-tests the self-contained portable single EXE; -9. compiles the Windows installer and performs silent install/uninstall smoke validation; -10. creates SHA-256 checksums, SPDX 2.3 SBOM, and provenance evidence; -11. creates GitHub artifact attestations for the public Windows binaries; -12. creates a new stable GitHub Release with the public assets; existing tags/assets are immutable and must not be overwritten. +2. resolves the immutable ARIEC61850 and ArdIrec lock SHAs without rebuilding those dependencies; +3. verifies source, licensing and physical-proven Smart Discovery route boundaries; +4. requires the exact successful Build ARSAS `push/main` sealed Windows package for the same source, engine and ArdIrec SHAs; +5. validates the canonical all-pass TRX, portable build identity and SHA-256/size of every sealed payload file before materialization; +6. promotes the exact tested installer-input folder and portable EXE bytes instead of rebuilding the application; +7. reruns the release-specific managed native COMTRADE bridge/locus verification and portable runtime smoke; +8. compiles the Windows installer from the verified installer input and performs silent install/uninstall plus installed native bridge validation; +9. creates SHA-256 checksums, SPDX 2.3 SBOM, and provenance evidence that records the canonical package run/artifact identity; +10. creates GitHub artifact attestations for the public Windows binaries; +11. creates a new stable GitHub Release with the public assets; existing tags/assets are immutable and must not be overwritten. + +A production tag whose exact commit has no matching canonical Build ARSAS package fails closed rather than silently rebuilding. `workflow_dispatch` retains an explicit local rebuild fallback for controlled engineering recovery. The workflow explicitly rejects legacy `ardirec.exe` and Qt runtime files from official packaging. ARSAS uses the pinned in-process `ardirec_bridge.dll` contract instead. @@ -40,7 +41,7 @@ The active release automation has intentionally separate responsibilities. Maint | Workflow | Responsibility | Public release mutation | | --- | --- | --- | -| `.github/workflows/release-windows.yml` | Canonical Windows build, test, portable/installer packaging, checksums, SBOM, provenance and new stable publication from the reviewed release request. | May create a new release only; existing published tag/assets are treated as immutable. | +| `.github/workflows/release-windows.yml` | Exact-byte promotion from the canonical Build ARSAS package, release-native verification, installer compilation, checksums, SBOM, provenance and new stable publication from the reviewed release request. | May create a new release only; existing published tag/assets are treated as immutable. | | `.github/workflows/installer-windows.yml` | Installer/portable packaging and smoke validation for engineering verification. | No stable GitHub Release publication authority. | | `.github/workflows/publish-verified-release.yml` | Alternative publication from already-tested workflow artifacts and an explicit verified publication request. | Create-only; refuses an existing tag rather than replacing it. | | `.github/workflows/release-supply-chain.yml` | Verify an existing stable release and add missing supply-chain evidence/attestation. | Additive only; refuses replacement of an existing published SBOM. | diff --git a/docs/audits/CI_P3C_RELEASE_EXACT_PACKAGE_PROMOTION.md b/docs/audits/CI_P3C_RELEASE_EXACT_PACKAGE_PROMOTION.md new file mode 100644 index 000000000..74b277598 --- /dev/null +++ b/docs/audits/CI_P3C_RELEASE_EXACT_PACKAGE_PROMOTION.md @@ -0,0 +1,83 @@ +# CI-P3C — Exact sealed package promotion through Windows release + +Issue #455. + +## Objective + +Complete the Windows build-once/package-many chain. Build ARSAS is the canonical +application regression and package-byte authority; the production release workflow +must package and publish those exact bytes rather than rebuilding equivalent source. + +## Production path + +For a reviewed `.release/windows.json` push on `main` and for `v*.*.*` tag +pushes, `release-windows.yml`: + +1. checks out and verifies the exact release source SHA; +2. resolves release version, ARIEC61850 SHA and ArdIrec SHA from tracked locks; +3. queries the latest matching Build ARSAS **push/main** run for that exact SHA; +4. verifies the sealed `ARSAS-windows-package-input` before materialization; +5. requires exact source/engine/ArdIrec/run identity, all-pass canonical TRX, + portable build identity and every payload file SHA-256; +6. copies the verified installer-input directory and portable EXE into release + staging without rebuilding application bytes; +7. reruns the release-specific native bridge/locus verification; +8. reruns portable smoke; +9. compiles the Inno installer from the exact verified installer input; +10. performs silent install/uninstall and installed native bridge/locus tests; +11. generates checksums, SPDX SBOM, provenance and artifact attestations; +12. applies the existing immutable create-only stable publication contract. + +A tag that points at a commit without an exact successful Build ARSAS push/main +package fails closed. It does not fall back to a production rebuild. + +## Release-specific fixture + +P3C does not replace the historical release-native fixture with the installer +fixture. Build ARSAS now seals both `p1-release-smoke.cfg` and its DAT companion +inside the shared package payload. The package manifest exposes +`releaseFixturePath`; the verifier confirms that path is part of the +hash-complete payload before release can use it. + +## Manual fallback + +`workflow_dispatch` keeps the historical local restore/build/test/folder publish/ +portable publish path. This is an engineering recovery mechanism, not the normal +production promotion path. Existing `publish_release`, prerelease and immutable +published-tag behavior remain unchanged. + +## Authority boundaries + +- **Build ARSAS:** canonical application regression, native bridge build, + installer-input bytes, portable bytes and sealed package manifest. +- **Release workflow:** release version policy, native release re-verification, + Inno compilation, installed-package smoke, checksums, SBOM, provenance, + attestations and public release creation. +- **Physical Smart Discovery evidence:** unchanged. + +The sealed package itself still states `releasePromotionAuthority=false`. +Release authority is created only after the release-owned checks above succeed. + +## Provenance + +Release provenance remains schemaVersion 2 and adds: + +- `canonicalPackageAuthority`; +- `canonicalPackageRun`; +- `canonicalPackageArtifactSha256`; +- `canonicalPortableSha256`. + +Manual fallback records its authority as `manual-local-build`. + +## CI accounting + +Static restore/build/test counts do not fall because the manual fallback remains +tracked in YAML. Runtime work on production push/tag releases does fall: the +application restore/build/full regression, installer-input publish and portable +publish are no longer repeated. + +## Rollback + +Restore the non-manual release rebuild/publish steps and stop consuming the sealed +package. Do not weaken package identity, hashes, canonical TRX, build identity or +stable release immutability as a workaround. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 05a5e52d8..09d5a3d2e 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3B: exact sealed canonical portable promoted into Field Capture", - "policy": "CI-P3 build-once/package-many now uses one sealed Windows package as the exact-byte promotion substrate for installer validation and PR Field Capture. P3B removes the normal PR Field Capture native/.NET republish after P3A proved matching independent builders are byte-reproducible; field-specific source/wire checks and runtime smoke remain independent, while workflow_dispatch retains an independent publish fallback.", + "baselineName": "CI-P3C: exact sealed package promoted through production release", + "policy": "CI-P3 build-once/package-many now has one sealed Windows package as the exact-byte substrate for installer validation, Field Capture PR runtime smoke and production release. P3C removes non-manual release application rebuilds while preserving release-owned installer compile/install smoke, checksum, SBOM, provenance, attestations and immutable publication; workflow_dispatch retains a local rebuild recovery path.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -391,5 +391,65 @@ "artifactNameStable": "ARSAS-smart-discovery-pr134-win-x64", "noInstallerOrReleaseAuthorityChange": true, "rollback": "Restore PR Field Capture independent publish path if sealed package promotion becomes unavailable; never bypass exact package identity, digest, build-identity or runtime-smoke validation." + }, + "ciP3ReleaseExactPackagePromotion": { + "issueNumber": 455, + "lane": "release-windows", + "approach": "For push/main release requests and tags, resolve the reviewed release identity then require the exact successful Build ARSAS push/main sealed package for the same source, ARIEC61850 and ArdIrec SHAs. Materialize only after full hash/TRX/build-identity verification; promote exact portable and installer-input bytes into release-owned installer/smoke/SBOM/provenance/publication stages.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 18, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 18, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "staticLimitsChanged": false, + "staticBudgetNote": "workflow_dispatch intentionally retains restore/build/test/publish source commands in release-windows.yml, so static topology counts do not fall even though production push/tag execution removes those rebuilds.", + "productionEvents": [ + "push-main-release-request", + "push-tag" + ], + "canonicalBuildLookup": { + "event": "push", + "branch": "main", + "exactHeadSha": true + }, + "productionApplicationRebuild": false, + "tagWithoutCanonicalMainPackageFailsClosed": true, + "manualLocalRebuildFallbackRetained": true, + "releaseFixturePreserved": "p1-release-smoke.cfg", + "releaseFixtureAndDatSealedAndHashed": true, + "exactPortableBytesPromoted": true, + "exactInstallerInputBytesPromoted": true, + "releaseNativeBridgePrePackageTestRetained": true, + "installedNativeBridgeTestAdded": true, + "installerCompileRetained": true, + "silentInstallUninstallRetained": true, + "checksumSbomProvenanceAttestationRetained": true, + "stableReleaseImmutabilityRetained": true, + "provenanceAdds": [ + "canonicalPackageAuthority", + "canonicalPackageRun", + "canonicalPackageArtifactSha256", + "canonicalPortableSha256" + ], + "rollback": "Restore non-manual release restore/build/test/publish steps and stop requiring the sealed package. Never weaken exact source/engine/ArdIrec identity, package file hashes, canonical TRX, portable identity or stable-release immutability to make promotion pass." } } diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py index 74c79dfcd..140bfda0c 100644 --- a/scripts/test-ci-package-reuse.py +++ b/scripts/test-ci-package-reuse.py @@ -115,6 +115,8 @@ def make_payload(event_name: str = "pull_request") -> tuple[bytes, dict]: (tests / ".coverage-transient").write_text("must-not-promote", encoding="utf-8") (fixtures / "minimal_1999.cfg").write_text("cfg", encoding="utf-8") (fixtures / "distance_p1.cfg").write_text("locus", encoding="utf-8") + (fixtures / "p1-release-smoke.cfg").write_text("release-cfg", encoding="utf-8") + (fixtures / "p1-release-smoke.dat").write_bytes(b"release-dat") artifact = root / "artifact" manifest = producer.stage_and_create_manifest( @@ -135,6 +137,7 @@ def make_payload(event_name: str = "pull_request") -> tuple[bytes, dict]: test_assembly_relative="ARSAS.Tests/ARSAS.Tests.dll", comtrade_fixture_relative="fixtures/minimal_1999.cfg", locus_fixture_relative="fixtures/distance_p1.cfg", + release_fixture_relative="fixtures/p1-release-smoke.cfg", ) buffer = io.BytesIO() @@ -154,6 +157,8 @@ def __init__( conclusion: str | None = "success", artifact_available: bool = True, binary_404_count: int = 0, + head_sha: str = HEAD, + branch: str = BRANCH, ): self.blob, _ = make_payload(event_name) self.artifact_available = artifact_available @@ -162,8 +167,8 @@ def __init__( "id": RUN, "run_attempt": 1, "name": "Build ARSAS", - "head_sha": HEAD, - "head_branch": BRANCH, + "head_sha": head_sha, + "head_branch": branch, "event": event_name, "status": status, "conclusion": conclusion, @@ -213,6 +218,10 @@ def test_producer_and_verifier_accept_exact_payload(self): manifest["portableIdentity"]["path"], ) self.assertEqual(proof["nativeBridgeSha256"], manifest["nativeBridge"]["sha256"]) + self.assertEqual( + proof["releaseFixturePath"], + manifest["verification"]["releaseFixturePath"], + ) def test_safe_materialization_occurs_after_verification(self): blob, _ = make_payload() @@ -389,6 +398,42 @@ def test_installer_script_and_workflow_share_prebuilt_test_contract(self): ): self.assertIn(token, workflow) + def test_release_workflow_promotes_exact_sealed_package_and_keeps_manual_fallback(self): + workflow = (ROOT.parent / ".github/workflows/release-windows.yml").read_text( + encoding="utf-8" + ) + required = ( + "Promote exact sealed canonical Windows package for release", + "verify-ci-package-reuse.py", + "--head-branch main", + "--event-name push", + "--allow-in-progress-artifact", + "proof.releaseFixturePath", + "RELEASE_PACKAGE_AUTHORITY=sealed-build-arsas:", + "RELEASE_INSTALLER_INPUT_DIR", + "RELEASE_PORTABLE_PATH", + "-PublishedDirectory $env:RELEASE_INSTALLER_INPUT_DIR", + "-TestAssemblyPath $env:RELEASE_TEST_ASSEMBLY", + "& dotnet vstest $env:RELEASE_TEST_ASSEMBLY", + "canonicalPackageArtifactSha256", + "canonicalPortableSha256", + "Release source mismatch", + ) + for token in required: + self.assertIn(token, workflow) + + manual_only = ( + "Restore, build and test exact release source for manual fallback", + "Publish installer source folder for manual fallback", + "Publish real portable single EXE for manual fallback", + ) + for token in manual_only: + self.assertIn(token, workflow) + self.assertNotIn( + "- name: Restore, build and test exact release source\n shell:", + workflow, + ) + def test_in_progress_exact_artifact_can_be_reused(self): api = FakeApi(status="in_progress", conclusion=None) proof = verifier.verify_canonical_package( @@ -406,6 +451,31 @@ def test_in_progress_exact_artifact_can_be_reused(self): self.assertEqual(proof["proofStage"], "sealed-package-artifact-ready") self.assertFalse(proof["workflowCompleted"]) + def test_push_main_exact_source_package_can_be_reused_for_release(self): + api = FakeApi( + event_name="push", + status="completed", + conclusion="success", + head_sha=SOURCE, + branch="main", + ) + proof = verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch="main", + head_sha=SOURCE, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + event_name="push", + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + self.assertEqual(proof["sourceSha"], SOURCE) + self.assertEqual(proof["canonicalRunStatus"], "completed") + self.assertEqual(proof["proofStage"], "completed-workflow") + def test_transient_artifact_archive_404_is_retried_without_fallback(self): api = FakeApi( status="in_progress", diff --git a/scripts/validate-supply-chain-workflows.py b/scripts/validate-supply-chain-workflows.py index ae63b1f7e..1323d9590 100644 --- a/scripts/validate-supply-chain-workflows.py +++ b/scripts/validate-supply-chain-workflows.py @@ -76,6 +76,11 @@ def main() -> int: "actions/attest@v4", "subject-path: ArIED61850Tester/dist/ARSAS-Windows-x64-Setup.exe", "sbom-path: ArIED61850Tester/dist/ARSAS-Windows-x64-SBOM.spdx.json", "supplyChain = [ordered]@{", "attestationWorkflow = \"release-windows.yml\"", + "Promote exact sealed canonical Windows package for release", + "verify-ci-package-reuse.py", "--head-branch main", "--event-name push", + "RELEASE_PACKAGE_AUTHORITY=sealed-build-arsas:", + "-PublishedDirectory $env:RELEASE_INSTALLER_INPUT_DIR", + "canonicalPackageArtifactSha256", "canonicalPortableSha256", ) for value in primary_contract: if value not in primary: diff --git a/scripts/verify-ci-package-reuse.py b/scripts/verify-ci-package-reuse.py index ed4a60e14..0e207301d 100644 --- a/scripts/verify-ci-package-reuse.py +++ b/scripts/verify-ci-package-reuse.py @@ -344,7 +344,12 @@ def validate_package_archive( verification = manifest.get("verification") if not isinstance(verification, dict): raise PackageProofError("Canonical package verification references are missing") - for key in ("testAssemblyPath", "comtradeFixturePath", "locusFixturePath"): + for key in ( + "testAssemblyPath", + "comtradeFixturePath", + "locusFixturePath", + "releaseFixturePath", + ): path = verification.get(key) if not isinstance(path, str) or path not in recorded: raise PackageProofError(f"Canonical package verification path is invalid: {key}") @@ -380,6 +385,7 @@ def validate_package_archive( "testAssemblyPath": manifest["verification"]["testAssemblyPath"], "comtradeFixturePath": manifest["verification"]["comtradeFixturePath"], "locusFixturePath": manifest["verification"]["locusFixturePath"], + "releaseFixturePath": manifest["verification"]["releaseFixturePath"], "artifactSha256": hashlib.sha256(payload).hexdigest(), } diff --git a/scripts/write-ci-package-manifest.py b/scripts/write-ci-package-manifest.py index ee8002f20..b396defa9 100644 --- a/scripts/write-ci-package-manifest.py +++ b/scripts/write-ci-package-manifest.py @@ -120,6 +120,7 @@ def stage_and_create_manifest( test_assembly_relative: str, comtrade_fixture_relative: str, locus_fixture_relative: str, + release_fixture_relative: str, ) -> dict: source_sha = sha(source_sha, "source") engine_sha = sha(engine_sha, "engine") @@ -164,11 +165,13 @@ def stage_and_create_manifest( test_assembly = verification_target / PurePosixPath(test_assembly_relative) comtrade_fixture = verification_target / PurePosixPath(comtrade_fixture_relative) locus_fixture = verification_target / PurePosixPath(locus_fixture_relative) + release_fixture = verification_target / PurePosixPath(release_fixture_relative) for label, path in ( ("native bridge", bridge), ("test assembly", test_assembly), ("COMTRADE fixture", comtrade_fixture), ("locus fixture", locus_fixture), + ("release COMTRADE fixture", release_fixture), ): if not path.is_file(): raise FileNotFoundError(f"Required {label} missing from sealed payload: {path}") @@ -187,6 +190,7 @@ def stage_and_create_manifest( test_rel = safe_relative(test_assembly, artifact_root) comtrade_rel = safe_relative(comtrade_fixture, artifact_root) locus_rel = safe_relative(locus_fixture, artifact_root) + release_rel = safe_relative(release_fixture, artifact_root) try: build_identity = json.loads(portable_identity_target.read_text(encoding="utf-8-sig")) @@ -236,6 +240,7 @@ def stage_and_create_manifest( "testAssemblyPath": test_rel, "comtradeFixturePath": comtrade_rel, "locusFixturePath": locus_rel, + "releaseFixturePath": release_rel, }, "fileCount": len(files), "totalBytes": sum(entry["size"] for entry in files), @@ -275,6 +280,10 @@ def main() -> int: "--locus-fixture-relative", default="fixtures/distance_p1.cfg", ) + parser.add_argument( + "--release-fixture-relative", + default="fixtures/p1-release-smoke.cfg", + ) args = parser.parse_args() manifest = stage_and_create_manifest( @@ -295,6 +304,7 @@ def main() -> int: test_assembly_relative=args.test_assembly_relative, comtrade_fixture_relative=args.comtrade_fixture_relative, locus_fixture_relative=args.locus_fixture_relative, + release_fixture_relative=args.release_fixture_relative, ) print( "Canonical Windows package sealed: " From 0814793c7473dbae1c1c64da56df12cbeecf5df7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 05:18:32 +0700 Subject: [PATCH 14/99] ci(p3d): promote exact validated installer into production release Seal installed-smoke-tested installer authority and make production release promote those exact installer bytes from the same canonical package. Preserve release-specific installed runtime verification, supply-chain evidence, stable-release immutability, and manual local Inno fallback. All twelve PR workflows passed. --- .../workflows/ci-p0-workflow-integrity.yml | 9 + .github/workflows/installer-windows.yml | 37 ++- .github/workflows/release-windows.yml | 75 +++++- docs/WINDOWS_RELEASES.md | 2 +- .../CI_P3D_VALIDATED_INSTALLER_PROMOTION.md | 82 +++++++ evidence/ci-workflow-budget.json | 32 ++- scripts/test-ci-installer-reuse.py | 81 ++++++ scripts/validate-supply-chain-workflows.py | 4 + scripts/verify-ci-installer-reuse.py | 230 ++++++++++++++++++ scripts/write-ci-installer-manifest.py | 87 +++++++ 10 files changed, 631 insertions(+), 8 deletions(-) create mode 100644 docs/audits/CI_P3D_VALIDATED_INSTALLER_PROMOTION.md create mode 100644 scripts/test-ci-installer-reuse.py create mode 100644 scripts/verify-ci-installer-reuse.py create mode 100644 scripts/write-ci-installer-manifest.py diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index 55b9a7f95..45cb47db3 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -15,6 +15,9 @@ on: - "scripts/write-ci-package-manifest.py" - "scripts/verify-ci-package-reuse.py" - "scripts/test-ci-package-reuse.py" + - "scripts/write-ci-installer-manifest.py" + - "scripts/verify-ci-installer-reuse.py" + - "scripts/test-ci-installer-reuse.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -31,6 +34,9 @@ on: - "scripts/write-ci-package-manifest.py" - "scripts/verify-ci-package-reuse.py" - "scripts/test-ci-package-reuse.py" + - "scripts/write-ci-installer-manifest.py" + - "scripts/verify-ci-installer-reuse.py" + - "scripts/test-ci-installer-reuse.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -69,6 +75,9 @@ jobs: - name: Run canonical CI P3 package safety fixtures run: python3 scripts/test-ci-package-reuse.py + - name: Run validated installer CI P3D safety fixtures + run: python3 scripts/test-ci-installer-reuse.py + - name: Validate tracked workflow topology and budget run: | python3 scripts/audit-ci-workflows.py \ diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index 35395b0ed..eca98355f 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -12,6 +12,9 @@ on: - "scripts/write-ci-package-manifest.py" - "scripts/verify-ci-package-reuse.py" - "scripts/test-ci-package-reuse.py" + - "scripts/write-ci-installer-manifest.py" + - "scripts/verify-ci-installer-reuse.py" + - "scripts/test-ci-installer-reuse.py" - ".github/workflows/build.yml" - ".github/workflows/installer-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" @@ -30,6 +33,7 @@ on: - "scripts/enable-smart-discovery-capture.ps1" - "tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs" - "VERSION" + - ".release/windows.json" - "engines/ARIEC61850.lock.json" - "engines/ARDIREC.lock.json" pull_request: @@ -42,6 +46,9 @@ on: - "scripts/write-ci-package-manifest.py" - "scripts/verify-ci-package-reuse.py" - "scripts/test-ci-package-reuse.py" + - "scripts/write-ci-installer-manifest.py" + - "scripts/verify-ci-installer-reuse.py" + - "scripts/test-ci-installer-reuse.py" - ".github/workflows/build.yml" - ".github/workflows/installer-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" @@ -60,6 +67,7 @@ on: - "scripts/enable-smart-discovery-capture.ps1" - "tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs" - "VERSION" + - ".release/windows.json" - "engines/ARIEC61850.lock.json" - "engines/ARDIREC.lock.json" workflow_dispatch: @@ -133,9 +141,13 @@ jobs: with: python-version: '3.11' - - name: Run offline package verifier safety fixtures + - name: Run offline package and installer verifier safety fixtures if: github.event_name != 'workflow_dispatch' - run: python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + run: | + python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Canonical package verifier fixtures failed.' } + python .\ArIED61850Tester\scripts\test-ci-installer-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Validated installer verifier fixtures failed.' } - name: Reuse exact tested canonical Windows package if: github.event_name != 'workflow_dispatch' @@ -294,6 +306,7 @@ jobs: "CANONICAL_LOCUS_FIXTURE=$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "CANONICAL_PACKAGE_RUN=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_PACKAGE_ARTIFACT_SHA256=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - name: Install Inno Setup compiler shell: powershell @@ -368,6 +381,25 @@ jobs: -Runtime win-x64 ` -DistDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" + - name: Seal validated installer authority + shell: powershell + run: | + $installer = ".\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64-setup.exe" + $manifest = ".\ArIED61850Tester\dist\ci-installer-authority.json" + python .\ArIED61850Tester\scripts\write-ci-installer-manifest.py ` + --installer $installer ` + --output $manifest ` + --version $env:APP_VERSION ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --package-run $env:CANONICAL_PACKAGE_RUN ` + --package-artifact-sha256 $env:CANONICAL_PACKAGE_ARTIFACT_SHA256 ` + --workflow-run-id $env:GITHUB_RUN_ID ` + --run-attempt $env:GITHUB_RUN_ATTEMPT ` + --event-name $env:GITHUB_EVENT_NAME + if ($LASTEXITCODE -ne 0) { throw 'Validated installer authority manifest could not be sealed.' } + - name: Upload installer artifact uses: actions/upload-artifact@v7 with: @@ -376,3 +408,4 @@ jobs: path: | ArIED61850Tester/dist/ARSAS-${{ env.APP_VERSION }}-win-x64-setup.exe ArIED61850Tester/dist/SHA256SUMS.txt + ArIED61850Tester/dist/ci-installer-authority.json diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index b8b37446b..943fd535f 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -423,11 +423,69 @@ jobs: throw "Portable single EXE smoke test failed with exit code $($process.ExitCode)." } - - name: Install Inno Setup compiler + - name: Promote exact validated Windows installer for release + if: github.event_name != 'workflow_dispatch' + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $validated = Join-Path $env:RUNNER_TEMP 'arsas-validated-installer' + $proofPath = Join-Path $env:RUNNER_TEMP 'arsas-validated-installer-proof.json' + python .\ArIED61850Tester\scripts\verify-ci-installer-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --source-sha $env:GITHUB_SHA ` + --version $env:RELEASE_VERSION ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --package-artifact-sha256 $env:RELEASE_PACKAGE_ARTIFACT_SHA256 ` + --wait-seconds 1800 ` + --output-dir $validated ` + --proof-output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Production release requires the exact installed-smoke-tested installer for this main commit.' + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if ($proof.sourceSha -ne $env:GITHUB_SHA -or + $proof.engineSha -ne $env:ARIEC61850_COMMIT -or + $proof.ardirecSha -ne $env:ARDIREC_COMMIT -or + $proof.version -ne $env:RELEASE_VERSION -or + $proof.canonicalPackageArtifactSha256 -ne $env:RELEASE_PACKAGE_ARTIFACT_SHA256 -or + -not [bool]$proof.installedSmokePassed -or + [bool]$proof.releasePromotionAuthority) { + throw 'Validated installer authority does not match release identity.' + } + + $source = Join-Path $validated $proof.installerFile + $target = ".\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-setup.exe" + Copy-Item $source $target -Force + $actual = (Get-FileHash $target -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $proof.installerSha256 -or + [long](Get-Item $target).Length -ne [long]$proof.installerSizeBytes) { + throw 'Promoted installer bytes differ from validated installer proof.' + } + + "RELEASE_INSTALLER_AUTHORITY=validated-installer:$($proof.installerRunId)/$($proof.installerRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_INSTALLER_SHA256=$($proof.installerSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_INSTALLER_ARTIFACT_SHA256=$($proof.artifactSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## CI-P3D exact installer promotion', + '', + "- source: $env:GITHUB_SHA", + "- canonical package: $env:RELEASE_PACKAGE_RUN", + "- validated installer: $($proof.installerRunId)/$($proof.installerRunAttempt)", + "- installer SHA-256: $($proof.installerSha256)", + '- release-specific install/runtime smoke remains required' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Install Inno Setup compiler for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: choco install innosetup --no-progress --yes - - name: Build Windows installer + - name: Build Windows installer for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\build-windows-installer.ps1 ` @@ -438,6 +496,11 @@ jobs: -TestAssemblyPath $env:RELEASE_TEST_ASSEMBLY ` -FixtureCfg $env:RELEASE_COMTRADE_FIXTURE ` -LocusFixtureCfg $env:RELEASE_LOCUS_FIXTURE + $installer = ".\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-setup.exe" + $sha = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLowerInvariant() + "RELEASE_INSTALLER_AUTHORITY=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_INSTALLER_SHA256=$sha" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_INSTALLER_ARTIFACT_SHA256=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - name: Smoke-test silent installer and uninstaller shell: powershell @@ -558,6 +621,9 @@ jobs: canonicalPackageRun = $env:RELEASE_PACKAGE_RUN canonicalPackageArtifactSha256 = $env:RELEASE_PACKAGE_ARTIFACT_SHA256 canonicalPortableSha256 = $env:RELEASE_CANONICAL_PORTABLE_SHA256 + validatedInstallerAuthority = $env:RELEASE_INSTALLER_AUTHORITY + validatedInstallerArtifactSha256 = $env:RELEASE_INSTALLER_ARTIFACT_SHA256 + validatedInstallerSha256 = $env:RELEASE_INSTALLER_SHA256 workflow = "release-windows.yml" } $evidence | ConvertTo-Json -Depth 4 | Set-Content ` @@ -590,7 +656,10 @@ jobs: $provenance.canonicalPackageAuthority -ne $env:RELEASE_PACKAGE_AUTHORITY -or $provenance.canonicalPackageRun -ne $env:RELEASE_PACKAGE_RUN -or $provenance.canonicalPackageArtifactSha256 -ne $env:RELEASE_PACKAGE_ARTIFACT_SHA256 -or - $provenance.canonicalPortableSha256 -ne $env:RELEASE_CANONICAL_PORTABLE_SHA256) { + $provenance.canonicalPortableSha256 -ne $env:RELEASE_CANONICAL_PORTABLE_SHA256 -or + $provenance.validatedInstallerAuthority -ne $env:RELEASE_INSTALLER_AUTHORITY -or + $provenance.validatedInstallerArtifactSha256 -ne $env:RELEASE_INSTALLER_ARTIFACT_SHA256 -or + $provenance.validatedInstallerSha256 -ne $env:RELEASE_INSTALLER_SHA256) { throw "Release provenance does not match the tested app, IEC engine and native COMTRADE bridge revisions." } diff --git a/docs/WINDOWS_RELEASES.md b/docs/WINDOWS_RELEASES.md index cb95c1107..4019a607c 100644 --- a/docs/WINDOWS_RELEASES.md +++ b/docs/WINDOWS_RELEASES.md @@ -41,7 +41,7 @@ The active release automation has intentionally separate responsibilities. Maint | Workflow | Responsibility | Public release mutation | | --- | --- | --- | -| `.github/workflows/release-windows.yml` | Exact-byte promotion from the canonical Build ARSAS package, release-native verification, installer compilation, checksums, SBOM, provenance and new stable publication from the reviewed release request. | May create a new release only; existing published tag/assets are treated as immutable. | +| `.github/workflows/release-windows.yml` | Exact-byte promotion from the canonical Build ARSAS package and the matching installed-smoke-tested installer, release-specific runtime re-verification, checksums, SBOM, provenance and new stable publication from the reviewed release request. Manual dispatch retains local installer compilation fallback. | May create a new release only; existing published tag/assets are treated as immutable. | | `.github/workflows/installer-windows.yml` | Installer/portable packaging and smoke validation for engineering verification. | No stable GitHub Release publication authority. | | `.github/workflows/publish-verified-release.yml` | Alternative publication from already-tested workflow artifacts and an explicit verified publication request. | Create-only; refuses an existing tag rather than replacing it. | | `.github/workflows/release-supply-chain.yml` | Verify an existing stable release and add missing supply-chain evidence/attestation. | Additive only; refuses replacement of an existing published SBOM. | diff --git a/docs/audits/CI_P3D_VALIDATED_INSTALLER_PROMOTION.md b/docs/audits/CI_P3D_VALIDATED_INSTALLER_PROMOTION.md new file mode 100644 index 000000000..ffe25aa08 --- /dev/null +++ b/docs/audits/CI_P3D_VALIDATED_INSTALLER_PROMOTION.md @@ -0,0 +1,82 @@ +# CI-P3D — Exact validated installer promotion + +Issue #457. + +## Objective + +Complete the Windows build-once/package-many chain at the installer layer. +The normal production release path must publish the exact installer bytes that +were already compiled, silently installed, runtime-tested and uninstalled by the +installer validation workflow for the same source and canonical package. + +## Installer authority + +After installer compilation and installed runtime smoke pass, +`.github/workflows/installer-windows.yml` writes +`ci-installer-authority.json` into the existing versioned installer artifact. + +The manifest binds: + +- release version; +- exact ARSAS source SHA; +- exact ARIEC61850 SHA; +- exact ArdIrec SHA; +- canonical Build ARSAS package run; +- canonical package artifact SHA-256; +- installer workflow run and attempt; +- installer filename, SHA-256 and size; +- `installedSmokePassed=true`; +- `releasePromotionAuthority=false`. + +The installer validator remains validation authority only. It cannot publish a +stable release by itself. + +## Production release + +For reviewed main release requests and tag releases, +`release-windows.yml`: + +1. verifies and materializes the exact sealed Build ARSAS package as in P3C; +2. waits boundedly for the matching successful installer validation + `push/main` run for the same source SHA; +3. validates the installer manifest and exact installer bytes; +4. requires the installer to reference the same canonical package artifact + already accepted by the release; +5. promotes those exact installer bytes without installing Inno Setup or + recompiling the installer; +6. still performs release-specific silent install/uninstall and installed + native bridge/locus tests with the release fixture; +7. generates release checksums, SPDX SBOM, provenance and attestations; +8. preserves the existing create-only/immutable stable release contract. + +A tag without an exact validated main installer fails closed. + +## Manual fallback + +`workflow_dispatch` retains local Inno Setup installation and installer +compilation. It records `manual-local-build` installer authority. + +## Provenance + +Release provenance adds: + +- `validatedInstallerAuthority`; +- `validatedInstallerArtifactSha256`; +- `validatedInstallerSha256`. + +These fields supplement the P3C canonical package provenance. They do not +replace source, engine, ArdIrec or portable identity. + +## Authority boundaries + +- **Build ARSAS:** tested application and canonical package bytes. +- **Installer validation:** installer compilation plus installed smoke authority. +- **Release workflow:** release-specific installed runtime re-verification, + checksums, SBOM, provenance, attestations and public release creation. +- **Physical IEC 61850 evidence:** unchanged. + +## Rollback + +Restore production Inno Setup installation and installer compilation in the +release workflow. Do not weaken source/package/installer identity checks to make +promotion pass. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 09d5a3d2e..319315fb1 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3C: exact sealed package promoted through production release", - "policy": "CI-P3 build-once/package-many now has one sealed Windows package as the exact-byte substrate for installer validation, Field Capture PR runtime smoke and production release. P3C removes non-manual release application rebuilds while preserving release-owned installer compile/install smoke, checksum, SBOM, provenance, attestations and immutable publication; workflow_dispatch retains a local rebuild recovery path.", + "baselineName": "CI-P3D: exact validated installer promotion", + "policy": "P0 workflow-sprawl baseline remains immutable. P3A-P3C establish reproducible, sealed exact-byte application/package promotion. P3D seals the installed-smoke-tested installer and makes production release promote those exact installer bytes while retaining release-specific install/runtime smoke, supply-chain evidence and publication authority.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -451,5 +451,33 @@ "canonicalPortableSha256" ], "rollback": "Restore non-manual release restore/build/test/publish steps and stop requiring the sealed package. Never weaken exact source/engine/ArdIrec identity, package file hashes, canonical TRX, portable identity or stable-release immutability to make promotion pass." + }, + "ciP3ValidatedInstallerPromotion": { + "issueNumber": 457, + "lane": "installer-windows-to-release-windows", + "approach": "Installer validation compiles once from the exact sealed Build ARSAS package, performs silent install/uninstall plus installed native tests, then seals installer SHA/size and exact source/engine/ArdIrec/canonical-package provenance. Production release waits for the matching successful push/main installer validation, verifies that authority, promotes the exact installer bytes and keeps release-specific installed runtime smoke before checksums/SBOM/provenance/attestation/publication.", + "staticLimitsChanged": false, + "installerArtifactName": "ARSAS--win-x64-installer", + "installerManifest": "ci-installer-authority.json", + "exactIdentity": [ + "source SHA", + "ARIEC61850 SHA", + "ArdIrec SHA", + "canonical package artifact SHA-256", + "installer workflow run/attempt", + "installer SHA-256/size" + ], + "installerValidationReleasePromotionAuthority": false, + "productionInnoCompileRemoved": true, + "releaseSpecificSilentInstallUninstallRetained": true, + "releaseSpecificInstalledNativeTestRetained": true, + "manualInnoCompileFallbackRetained": true, + "tagRequiresExactMainInstaller": true, + "provenanceAdds": [ + "validatedInstallerAuthority", + "validatedInstallerArtifactSha256", + "validatedInstallerSha256" + ], + "rollback": "Restore production Inno install/compile in release-windows.yml and stop consuming validated installer authority. Never weaken exact source/package/installer identity or stable-release immutability." } } diff --git a/scripts/test-ci-installer-reuse.py b/scripts/test-ci-installer-reuse.py new file mode 100644 index 000000000..0baf5cd69 --- /dev/null +++ b/scripts/test-ci-installer-reuse.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Offline fail-closed fixtures for CI-P3D validated installer reuse.""" +from __future__ import annotations +import hashlib, importlib.util, io, json, tempfile, unittest, zipfile +from pathlib import Path + +ROOT=Path(__file__).resolve().parent + +def load(name, filename): + s=importlib.util.spec_from_file_location(name, ROOT/filename); assert s and s.loader + m=importlib.util.module_from_spec(s); s.loader.exec_module(m); return m +producer=load("installer_producer","write-ci-installer-manifest.py") +verifier=load("installer_verifier","verify-ci-installer-reuse.py") +S="a"*40; E="b"*40; A="c"*40; P="d"*64; RUN=777 + +def manifest(installer=b"installer"): + return { + "schemaVersion":1,"kind":"arsas-validated-windows-installer","version":"1.6.40", + "sourceSha":S,"engineSha":E,"ardirecSha":A,"canonicalPackageRun":"4321/1", + "canonicalPackageArtifactSha256":P,"installerWorkflow":"Validate ARSAS Windows installer", + "workflowRunId":RUN,"runAttempt":1,"eventName":"push", + "installerFile":"ARSAS-1.6.40-win-x64-setup.exe", + "installerSha256":hashlib.sha256(installer).hexdigest(),"installerSizeBytes":len(installer), + "installedSmokePassed":True,"releasePromotionAuthority":False} + +def archive(m=None, installer=b"installer", extra=None): + b=io.BytesIO() + with zipfile.ZipFile(b,"w") as z: + z.writestr("ARSAS-1.6.40-win-x64-setup.exe",installer) + z.writestr("ci-installer-authority.json",json.dumps(m or manifest(installer))) + z.writestr("SHA256SUMS.txt",b"fixture") + for k,v in (extra or {}).items(): z.writestr(k,v) + return b.getvalue() + +class FakeApi: + def __init__(self,status="completed",conclusion="success"): + self.status=status; self.conclusion=conclusion; self.blob=archive() + def get(self,url,*,binary=False): + if binary:return self.blob + if "installer-windows.yml/runs?" in url: + return {"workflow_runs":[{"id":RUN,"run_attempt":1,"name":"Validate ARSAS Windows installer", + "event":"push","head_branch":"main","head_sha":S,"status":self.status,"conclusion":self.conclusion}]} + if "/artifacts" in url: + return {"artifacts":[{"id":9,"name":"ARSAS-1.6.40-win-x64-installer","expired":False, + "archive_download_url":"https://example.invalid/installer.zip"}]} + raise RuntimeError(url) + +class InstallerReuseTests(unittest.TestCase): + def validate(self, blob=None, **kw): + return verifier.validate_archive(blob or archive(),version="1.6.40",source_sha=S,engine_sha=E, + ardirec_sha=A,package_artifact_sha256=P,workflow_run_id=RUN,run_attempt=1,**kw) + def test_accepts_exact_installer(self): + with tempfile.TemporaryDirectory() as t: + p=self.validate(output_dir=Path(t)) + self.assertTrue((Path(t)/p["installerFile"]).is_file()); self.assertTrue(p["installedSmokePassed"]) + def test_rejects_tampered_installer(self): + with self.assertRaisesRegex(verifier.InstallerProofError,"SHA-256|size"): + self.validate(blob=archive(manifest(),b"tampered")) + def test_rejects_wrong_package_digest(self): + with self.assertRaisesRegex(verifier.InstallerProofError,"canonicalPackageArtifactSha256"): + verifier.validate_archive(archive(),version="1.6.40",source_sha=S,engine_sha=E,ardirec_sha=A, + package_artifact_sha256="e"*64,workflow_run_id=RUN,run_attempt=1) + def test_rejects_release_authority_claim(self): + m=manifest(); m["releasePromotionAuthority"]=True + with self.assertRaisesRegex(verifier.InstallerProofError,"releasePromotionAuthority"): self.validate(blob=archive(m)) + def test_rejects_unsafe_path(self): + with self.assertRaisesRegex(verifier.InstallerProofError,"unsafe"): + self.validate(blob=archive(extra={"../escape":b"x"})) + def test_latest_failed_run_is_hard_failure(self): + with self.assertRaisesRegex(verifier.InstallerProofError,"failed"): + verifier.verify(FakeApi(conclusion="failure"),repository="masarray/arsas",source_sha=S,version="1.6.40", + engine_sha=E,ardirec_sha=A,package_artifact_sha256=P,wait_seconds=0,poll_seconds=1,output_dir=None) + def test_release_workflow_promotes_validated_installer_and_keeps_manual_compile(self): + w=(ROOT.parent/".github/workflows/release-windows.yml").read_text(encoding="utf-8") + for token in ("Promote exact validated Windows installer for release","verify-ci-installer-reuse.py", + "RELEASE_INSTALLER_AUTHORITY=validated-installer:","RELEASE_INSTALLER_SHA256", + "if: github.event_name == 'workflow_dispatch'","build-windows-installer.ps1", + "Smoke-test silent installer and uninstaller"): + self.assertIn(token,w) + +if __name__=="__main__": unittest.main() diff --git a/scripts/validate-supply-chain-workflows.py b/scripts/validate-supply-chain-workflows.py index 1323d9590..79ec4863c 100644 --- a/scripts/validate-supply-chain-workflows.py +++ b/scripts/validate-supply-chain-workflows.py @@ -79,6 +79,10 @@ def main() -> int: "Promote exact sealed canonical Windows package for release", "verify-ci-package-reuse.py", "--head-branch main", "--event-name push", "RELEASE_PACKAGE_AUTHORITY=sealed-build-arsas:", + "Promote exact validated Windows installer for release", + "verify-ci-installer-reuse.py", + "RELEASE_INSTALLER_AUTHORITY=validated-installer:", + "validatedInstallerArtifactSha256", "validatedInstallerSha256", "-PublishedDirectory $env:RELEASE_INSTALLER_INPUT_DIR", "canonicalPackageArtifactSha256", "canonicalPortableSha256", ) diff --git a/scripts/verify-ci-installer-reuse.py b/scripts/verify-ci-installer-reuse.py new file mode 100644 index 000000000..179796a91 --- /dev/null +++ b/scripts/verify-ci-installer-reuse.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Verify and safely materialize the exact installed-smoke-tested ARSAS installer.""" +from __future__ import annotations + +import argparse, hashlib, io, json, os, re, time, urllib.error, urllib.parse, urllib.request, zipfile +from pathlib import Path, PurePosixPath + +EXPECTED_MANIFEST = "ci-installer-authority.json" +MAX_ARCHIVE_BYTES = 256 * 1024 * 1024 +MAX_FILE_BYTES = 192 * 1024 * 1024 +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +class InstallerProofError(RuntimeError): + pass + + +class GitHubRequestError(InstallerProofError): + def __init__(self, message: str, *, status: int | None = None): + super().__init__(message) + self.status = status + + +class StripCrossOriginAuthorization(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + redirected = super().redirect_request(request, fp, code, msg, headers, newurl) + if redirected is not None: + if urllib.parse.urlparse(request.full_url).netloc.lower() != urllib.parse.urlparse(newurl).netloc.lower(): + redirected.remove_header("Authorization") + return redirected + + +class GitHubReadOnly: + def __init__(self, token: str): + if not token: + raise InstallerProofError("A read-only GITHUB_TOKEN is required") + self.token = token + + def get(self, url: str, *, binary: bool = False): + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ARSAS-CI-P3D-Installer-Reuse", + }) + try: + with urllib.request.build_opener(StripCrossOriginAuthorization()).open(req, timeout=30) as reply: + return reply.read(MAX_ARCHIVE_BYTES + 1) if binary else json.loads(reply.read(3 * 1024 * 1024)) + except urllib.error.HTTPError as exc: + raise GitHubRequestError(f"GitHub installer evidence request failed: HTTP {exc.code}", status=exc.code) from exc + except (urllib.error.URLError, ValueError) as exc: + raise GitHubRequestError(f"GitHub installer evidence request failed: {exc}") from exc + + +def _sha(value: str, label: str) -> str: + value = (value or "").strip().lower() + if not SHA_RE.fullmatch(value): + raise InstallerProofError(f"{label} must be a lowercase 40-hex SHA") + return value + + +def _safe_name(name: str) -> PurePosixPath: + norm = name.replace("\\", "/") + p = PurePosixPath(norm) + if p.is_absolute() or ".." in p.parts or ":" in norm: + raise InstallerProofError("Installer artifact contains an unsafe path") + return p + + +def validate_archive(blob: bytes, *, version: str, source_sha: str, engine_sha: str, + ardirec_sha: str, package_artifact_sha256: str, + workflow_run_id: int, run_attempt: int, + output_dir: Path | None = None) -> dict: + if not blob or len(blob) > MAX_ARCHIVE_BYTES: + raise InstallerProofError("Installer artifact is missing or oversized") + try: + z = zipfile.ZipFile(io.BytesIO(blob)) + except zipfile.BadZipFile as exc: + raise InstallerProofError("Installer artifact is not a valid ZIP") from exc + + files: dict[str, tuple[zipfile.ZipInfo, bytes]] = {} + seen = set() + with z: + for info in z.infolist(): + if info.is_dir(): + continue + p = _safe_name(info.filename) + key = "/".join(part.rstrip(" .").casefold() for part in p.parts) + if key in seen: + raise InstallerProofError("Installer artifact has a Windows-normalized path collision") + seen.add(key) + if info.file_size < 0 or info.file_size > MAX_FILE_BYTES: + raise InstallerProofError("Installer artifact contains an oversized file") + data = z.read(info) + files[p.name] = (info, data) + + if EXPECTED_MANIFEST not in files: + raise InstallerProofError("Installer authority manifest is missing") + try: + manifest = json.loads(files[EXPECTED_MANIFEST][1]) + except ValueError as exc: + raise InstallerProofError("Installer authority manifest is invalid JSON") from exc + + expected = { + "schemaVersion": 1, + "kind": "arsas-validated-windows-installer", + "version": version, + "sourceSha": _sha(source_sha, "source"), + "engineSha": _sha(engine_sha, "engine"), + "ardirecSha": _sha(ardirec_sha, "ArdIrec"), + "canonicalPackageArtifactSha256": package_artifact_sha256.lower(), + "installerWorkflow": "Validate ARSAS Windows installer", + "workflowRunId": workflow_run_id, + "runAttempt": run_attempt, + "eventName": "push", + "installedSmokePassed": True, + "releasePromotionAuthority": False, + } + for key, value in expected.items(): + if manifest.get(key) != value: + raise InstallerProofError(f"Installer authority mismatch for {key}") + + installer_name = manifest.get("installerFile") + if not isinstance(installer_name, str) or installer_name not in files: + raise InstallerProofError("Validated installer file is missing") + installer = files[installer_name][1] + actual_sha = hashlib.sha256(installer).hexdigest() + if manifest.get("installerSha256") != actual_sha: + raise InstallerProofError("Installer SHA-256 differs from authority manifest") + if manifest.get("installerSizeBytes") != len(installer): + raise InstallerProofError("Installer size differs from authority manifest") + if not SHA256_RE.fullmatch(package_artifact_sha256.lower()): + raise InstallerProofError("Canonical package artifact SHA-256 is invalid") + + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + target = output_dir / installer_name + target.write_bytes(installer) + (output_dir / EXPECTED_MANIFEST).write_bytes(files[EXPECTED_MANIFEST][1]) + + return { + "installerFile": installer_name, + "installerSha256": actual_sha, + "installerSizeBytes": len(installer), + "installerRunId": workflow_run_id, + "installerRunAttempt": run_attempt, + "canonicalPackageRun": manifest.get("canonicalPackageRun"), + "canonicalPackageArtifactSha256": manifest.get("canonicalPackageArtifactSha256"), + "artifactSha256": hashlib.sha256(blob).hexdigest(), + "sourceSha": manifest["sourceSha"], + "engineSha": manifest["engineSha"], + "ardirecSha": manifest["ardirecSha"], + "version": manifest["version"], + "installedSmokePassed": True, + "releasePromotionAuthority": False, + } + + +def verify(api: GitHubReadOnly, *, repository: str, source_sha: str, version: str, + engine_sha: str, ardirec_sha: str, package_artifact_sha256: str, + wait_seconds: int, poll_seconds: int, output_dir: Path | None) -> dict: + if repository != "masarray/arsas": + raise InstallerProofError("Installer reuse is restricted to masarray/arsas") + _sha(source_sha, "source"); _sha(engine_sha, "engine"); _sha(ardirec_sha, "ArdIrec") + base = f"https://api.github.com/repos/{repository}" + query = urllib.parse.urlencode({"event": "push", "head_sha": source_sha, "per_page": 100}) + runs_url = f"{base}/actions/workflows/installer-windows.yml/runs?{query}" + deadline = time.monotonic() + wait_seconds + last = None + while True: + payload = api.get(runs_url) + runs = [r for r in payload.get("workflow_runs", []) + if r.get("name") == "Validate ARSAS Windows installer" + and r.get("event") == "push" and r.get("head_branch") == "main" + and r.get("head_sha") == source_sha + and isinstance(r.get("id"), int) and isinstance(r.get("run_attempt"), int)] + runs.sort(key=lambda r: (r["id"], r["run_attempt"]), reverse=True) + if runs: + run = runs[0] + last = f"{run['id']}/{run['run_attempt']} {run.get('status')}/{run.get('conclusion')}" + if run.get("status") == "completed": + if run.get("conclusion") != "success": + raise InstallerProofError(f"Latest exact installer validation failed: {last}") + arts = api.get(f"{base}/actions/runs/{run['id']}/artifacts?per_page=100").get("artifacts", []) + name = f"ARSAS-{version}-win-x64-installer" + matches = [a for a in arts if a.get("name") == name and a.get("expired") is False] + if len(matches) != 1: + raise InstallerProofError("Expected exactly one non-expired validated installer artifact") + blob = api.get(matches[0]["archive_download_url"], binary=True) + proof = validate_archive( + blob, version=version, source_sha=source_sha, engine_sha=engine_sha, + ardirec_sha=ardirec_sha, package_artifact_sha256=package_artifact_sha256, + workflow_run_id=run["id"], run_attempt=run["run_attempt"], output_dir=output_dir) + proof["installerRunStatus"] = "completed" + proof["installerRunConclusion"] = "success" + return proof + if time.monotonic() >= deadline: + raise InstallerProofError(f"Timed out waiting for exact validated installer, last={last}") + time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--repository", required=True) + p.add_argument("--source-sha", required=True) + p.add_argument("--version", required=True) + p.add_argument("--engine-sha", required=True) + p.add_argument("--ardirec-sha", required=True) + p.add_argument("--package-artifact-sha256", required=True) + p.add_argument("--wait-seconds", type=int, default=1800) + p.add_argument("--poll-seconds", type=int, default=10) + p.add_argument("--output-dir", type=Path, required=True) + p.add_argument("--proof-output", type=Path, default=None) + args = p.parse_args() + proof = verify( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + repository=args.repository, source_sha=args.source_sha, version=args.version, + engine_sha=args.engine_sha, ardirec_sha=args.ardirec_sha, + package_artifact_sha256=args.package_artifact_sha256, + wait_seconds=args.wait_seconds, poll_seconds=args.poll_seconds, output_dir=args.output_dir) + payload = json.dumps(proof, sort_keys=True) + print("Validated installer reuse PASS: " + payload) + if args.proof_output: + args.proof_output.write_text(payload + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write-ci-installer-manifest.py b/scripts/write-ci-installer-manifest.py new file mode 100644 index 000000000..d3e64dd42 --- /dev/null +++ b/scripts/write-ci-installer-manifest.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Write a fail-closed authority manifest for an installed-smoke-tested Windows installer.""" +from __future__ import annotations + +import argparse, hashlib, json, re +from pathlib import Path + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +def sha(value: str, label: str) -> str: + value = (value or "").strip().lower() + if not SHA_RE.fullmatch(value): + raise ValueError(f"{label} must be a lowercase 40-hex SHA") + return value + + +def file_sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--installer", type=Path, required=True) + p.add_argument("--output", type=Path, required=True) + p.add_argument("--version", required=True) + p.add_argument("--source-sha", required=True) + p.add_argument("--engine-sha", required=True) + p.add_argument("--ardirec-sha", required=True) + p.add_argument("--package-run", required=True) + p.add_argument("--package-artifact-sha256", required=True) + p.add_argument("--workflow-run-id", type=int, required=True) + p.add_argument("--run-attempt", type=int, required=True) + p.add_argument("--event-name", required=True) + args = p.parse_args() + + if not args.installer.is_file(): + raise FileNotFoundError(args.installer) + if args.workflow_run_id <= 0 or args.run_attempt <= 0: + raise ValueError("Invalid installer workflow identity") + if args.event_name not in {"push", "pull_request", "workflow_dispatch"}: + raise ValueError("Unexpected installer workflow event") + if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?", args.version): + raise ValueError("Invalid installer version") + + package_digest = args.package_artifact_sha256.strip().lower() + if args.event_name == "workflow_dispatch": + if package_digest != "manual-local-build": + raise ValueError("Manual installer must record manual-local-build package authority") + elif not SHA256_RE.fullmatch(package_digest): + raise ValueError("Canonical package artifact SHA-256 is invalid") + + size = args.installer.stat().st_size + if size <= 0: + raise ValueError("Installer is empty") + + payload = { + "schemaVersion": 1, + "kind": "arsas-validated-windows-installer", + "version": args.version, + "sourceSha": sha(args.source_sha, "source"), + "engineSha": sha(args.engine_sha, "engine"), + "ardirecSha": sha(args.ardirec_sha, "ArdIrec"), + "canonicalPackageRun": args.package_run, + "canonicalPackageArtifactSha256": package_digest, + "installerWorkflow": "Validate ARSAS Windows installer", + "workflowRunId": args.workflow_run_id, + "runAttempt": args.run_attempt, + "eventName": args.event_name, + "installerFile": args.installer.name, + "installerSha256": file_sha256(args.installer), + "installerSizeBytes": size, + "installedSmokePassed": True, + "releasePromotionAuthority": False, + } + args.output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(payload, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f7e063e1add0779c74a07854173e5a74ec692245 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 07:49:14 +0700 Subject: [PATCH 15/99] ci(p3f): defer Windows installer until canonical package is ready Use a metadata-only ubuntu gate to wait for the exact Build ARSAS canonical package before allocating the Windows installer runner. Full sealed-package verification, installer compile, silent install/runtime smoke, validated-installer sealing and manual fallback remain intact. Latest PR validation reduced Windows installer occupancy from about 500s to 103s while all 11 workflows passed. --- .github/workflows/installer-windows.yml | 106 ++++++++++++++++++ .../CI_P3F_INSTALLER_PACKAGE_READY_GATE.md | 63 +++++++++++ evidence/ci-workflow-budget.json | 36 +++++- scripts/test-ci-package-reuse.py | 33 ++++++ 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 docs/audits/CI_P3F_INSTALLER_PACKAGE_READY_GATE.md diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index eca98355f..580bdeb4f 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -77,8 +77,113 @@ permissions: actions: read jobs: + package-ready: + name: Wait for exact canonical package metadata + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + canonical_run_id: ${{ steps.wait.outputs.canonical_run_id }} + canonical_run_attempt: ${{ steps.wait.outputs.canonical_run_attempt }} + package_artifact_id: ${{ steps.wait.outputs.package_artifact_id }} + steps: + - name: Wait for exact Build ARSAS package artifact metadata + id: wait + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + $ErrorActionPreference = 'Stop' + if (-not $env:GITHUB_TOKEN) { throw 'Read-only GitHub token is required for package-ready gating.' } + + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + $eventName = 'pull_request' + $headBranch = $env:PR_HEAD_BRANCH + $headSha = $env:PR_HEAD_SHA + } + elseif ($env:GITHUB_EVENT_NAME -eq 'push') { + $eventName = 'push' + $headBranch = 'main' + $headSha = $env:GITHUB_SHA + } + else { throw "Unsupported package-ready event: $env:GITHUB_EVENT_NAME" } + + if ($headSha.Length -ne 40 -or $headSha -match '[^0-9a-f]' -or + [string]::IsNullOrWhiteSpace($headBranch) -or + $headBranch.Contains([Environment]::NewLine)) { + throw 'Invalid exact Build ARSAS identity for package-ready gate.' + } + + $headers = @{ + Authorization = "Bearer $env:GITHUB_TOKEN" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + 'User-Agent' = 'ARSAS-CI-P3F-Package-Ready-Gate' + } + $base = "https://api.github.com/repos/$env:GITHUB_REPOSITORY" + $escapedHead = [uri]::EscapeDataString($headSha) + $escapedEvent = [uri]::EscapeDataString($eventName) + $runsUrl = "$base/actions/workflows/build.yml/runs?event=$escapedEvent&head_sha=$escapedHead&per_page=100" + $deadline = [DateTimeOffset]::UtcNow.AddMinutes(20) + $last = 'no matching Build ARSAS run yet' + $ready = $false + + while ([DateTimeOffset]::UtcNow -lt $deadline) { + $payload = Invoke-RestMethod -Uri $runsUrl -Headers $headers -Method Get + $matches = @($payload.workflow_runs | Where-Object { + $_.name -eq 'Build ARSAS' -and $_.event -eq $eventName -and + $_.head_sha -eq $headSha -and $_.head_branch -eq $headBranch -and + [long]$_.id -gt 0 -and [int]$_.run_attempt -gt 0 + } | Sort-Object @{Expression='id';Descending=$true}, @{Expression='run_attempt';Descending=$true}) + + if ($matches.Count -gt 0) { + $run = $matches[0] + $last = "$($run.id)/$($run.run_attempt) $($run.status)/$($run.conclusion)" + if ($run.status -eq 'completed' -and $run.conclusion -ne 'success') { + throw "Latest exact Build ARSAS run failed before package readiness: $last" + } + if ($run.status -in @('in_progress', 'completed')) { + $artifactUrl = "$base/actions/runs/$($run.id)/artifacts?per_page=100" + $artifactPayload = Invoke-RestMethod -Uri $artifactUrl -Headers $headers -Method Get + $artifacts = @($artifactPayload.artifacts | Where-Object { + $_.name -eq 'ARSAS-windows-package-input' -and + -not [bool]$_.expired -and $_.workflow_run.id -eq $run.id + }) + if ($artifacts.Count -gt 1) { throw 'Package-ready gate found duplicate canonical package artifacts.' } + if ($artifacts.Count -eq 1) { + $artifact = $artifacts[0] + "canonical_run_id=$($run.id)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "canonical_run_attempt=$($run.run_attempt)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "package_artifact_id=$($artifact.id)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + @( + '## CI-P3F canonical package-ready gate', + '', + "- event/head: $eventName / $headBranch @ $headSha", + "- Build ARSAS run: $($run.id)/$($run.run_attempt)", + "- package artifact id: $($artifact.id)", + '- authority: scheduling metadata only; package bytes remain untrusted until Windows verifier completes' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + $ready = $true + break + } + $last = "$last (canonical package metadata not uploaded yet)" + } + } + Start-Sleep -Seconds 10 + } + if (-not $ready) { throw "Timed out waiting for exact canonical package metadata. Last=$last" } + installer: name: Build install and field-runtime smoke test + needs: [package-ready] + if: >- + ${{ + always() && + (github.event_name == 'workflow_dispatch' || + needs.package-ready.result == 'success') + }} runs-on: windows-latest timeout-minutes: 45 @@ -222,6 +327,7 @@ jobs: "- engine: $env:ARIEC61850_COMMIT", "- ArdIrec: $env:ARDIREC_COMMIT", "- Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- package-ready gate: ${{ needs.package-ready.outputs.canonical_run_id }}/${{ needs.package-ready.outputs.canonical_run_attempt }}", "- full regression: $($proof.passed) passed, $($proof.failed) failed", "- package files: $($proof.fileCount)", "- proof stage: $($proof.proofStage)", diff --git a/docs/audits/CI_P3F_INSTALLER_PACKAGE_READY_GATE.md b/docs/audits/CI_P3F_INSTALLER_PACKAGE_READY_GATE.md new file mode 100644 index 000000000..f77c04aea --- /dev/null +++ b/docs/audits/CI_P3F_INSTALLER_PACKAGE_READY_GATE.md @@ -0,0 +1,63 @@ +# CI-P3F — Defer Windows installer allocation until canonical package readiness + +Issue #460. + +## Problem + +On main commit 0814793c7473dbae1c1c64da56df12cbeecf5df7, the installer Windows runner +started at 22:18:39Z while the canonical Build ARSAS package was not uploaded until +22:24:11Z. The installer package-reuse step occupied the Windows runner from +22:19:01Z to 22:24:27Z: 326 seconds of mostly waiting before installer-specific work. + +Installer-specific compile/install/runtime validation after package readiness took +about 152 seconds. + +## Design + +For pull_request and push events, installer-windows.yml now starts a small +ubuntu-latest package-ready job first. It only checks GitHub Actions metadata: + +- exact Build ARSAS workflow name; +- exact event, head SHA and head branch; +- latest matching run ID/attempt; +- latest completed failure is rejected; +- exactly one non-expired ARSAS-windows-package-input artifact must exist. + +The gate does not download the artifact archive and never validates or trusts package +bytes. Its sole purpose is scheduling: the Windows installer job is not allocated until +the canonical artifact metadata exists. + +## Authority boundary + +The Windows installer job still runs verify-ci-package-reuse.py. That verifier remains +the package authority and still validates: + +- exact source, ARIEC61850 and ArdIrec SHAs; +- Build ARSAS run ID/attempt/event; +- canonical TRX digest and all-pass counters; +- every sealed payload file size and SHA-256; +- portable build identity and native bridge identity; +- package authority flags. + +Installer compilation, silent install/uninstall, installed native runtime tests, +checksums and validated-installer sealing are unchanged. + +Metadata presence is therefore not equivalent to package validity. + +## Manual workflow_dispatch + +Manual dispatch bypasses package-ready because it retains the existing local-build +fallback. The Windows job is allowed to start when either the metadata gate succeeds +or the event is workflow_dispatch. + +## Static topology + +No workflow file, checkout action, setup-python action, dotnet restore/build/test or +artifact-upload count is added. The change targets runtime allocation efficiency, not +textual CI primitive counts. + +## Rollback + +Remove package-ready and the installer needs/if dependency to return to direct Windows +polling. Do not remove or weaken verify-ci-package-reuse.py or installer/runtime smoke +as part of rollback. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 319315fb1..e0a389c1a 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3D: exact validated installer promotion", - "policy": "P0 workflow-sprawl baseline remains immutable. P3A-P3C establish reproducible, sealed exact-byte application/package promotion. P3D seals the installed-smoke-tested installer and makes production release promote those exact installer bytes while retaining release-specific install/runtime smoke, supply-chain evidence and publication authority.", + "baselineName": "CI-P3F: package-ready gate defers Windows installer allocation", + "policy": "P0 workflow-sprawl baseline remains immutable. P3A-P3D establish reproducible sealed exact-byte promotion through installer/release. P3F changes orchestration only: a non-authoritative Ubuntu metadata gate waits for the exact canonical package artifact before allocating the Windows installer runner; full package-byte verification and installer validation remain unchanged.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -479,5 +479,37 @@ "validatedInstallerSha256" ], "rollback": "Restore production Inno install/compile in release-windows.yml and stop consuming validated installer authority. Never weaken exact source/package/installer identity or stable-release immutability." + }, + "ciP3InstallerPackageReadyGate": { + "issueNumber": 460, + "lane": "installer-windows", + "approach": "For PR/push events, wait on ubuntu-latest for the latest exact Build ARSAS head/branch/event run to expose exactly one non-expired ARSAS-windows-package-input artifact. Only then allocate the Windows installer job. Metadata gate never downloads package bytes or claims package/installer authority; Windows verifier remains the fail-closed authority.", + "observedBaseline": { + "mainCommit": "0814793c7473dbae1c1c64da56df12cbeecf5df7", + "buildRunId": 37695338673, + "installerRunId": 37695338631, + "installerStartedAt": "2026-10-07T22:18:39Z", + "packageWaitStartedAt": "2026-10-07T22:19:01Z", + "packageArtifactCreatedAt": "2026-10-07T22:24:11Z", + "packageReuseCompletedAt": "2026-10-07T22:24:27Z", + "observedWindowsPackageWaitSeconds": 326, + "installerSpecificWorkAfterPackageSeconds": 152 + }, + "staticLimitsChanged": false, + "addedWorkflowFiles": 0, + "addedCheckoutActions": 0, + "addedSetupPythonActions": 0, + "metadataGateRunner": "ubuntu-latest", + "windowsRunnerDeferred": true, + "latestCompletedCanonicalFailureRejected": true, + "duplicateCanonicalPackageMetadataRejected": true, + "packageBytesTrustedByGate": false, + "fullPackageVerifierRetained": true, + "installerCompileRetained": true, + "silentInstallUninstallRetained": true, + "installedNativeRuntimeTestsRetained": true, + "manualWorkflowDispatchBypassesGate": true, + "releaseAuthorityChanged": false, + "rollback": "Remove package-ready job and installer needs/if dependency to restore direct Windows polling. Never remove the full sealed-package verifier or installer/runtime smoke to reduce wait time." } } diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py index 140bfda0c..cbdabad4d 100644 --- a/scripts/test-ci-package-reuse.py +++ b/scripts/test-ci-package-reuse.py @@ -585,5 +585,38 @@ def test_missing_in_progress_artifact_fails_closed_at_bound(self): ) + def test_installer_package_ready_gate_only_schedules_windows_authority(self): + workflow = (ROOT.parent / ".github/workflows/installer-windows.yml").read_text( + encoding="utf-8" + ) + self.assertIn(" package-ready:", workflow) + self.assertIn(" needs: [package-ready]", workflow) + self.assertIn("runs-on: ubuntu-latest", workflow) + self.assertIn("Wait for exact Build ARSAS package artifact metadata", workflow) + self.assertIn("ARSAS-windows-package-input", workflow) + self.assertIn( + "Latest exact Build ARSAS run failed before package readiness", + workflow, + ) + + gate = workflow.split(" package-ready:", 1)[1].split("\n installer:", 1)[0] + self.assertNotIn("archive_download_url", gate) + self.assertNotIn("verify-ci-package-reuse.py", gate) + self.assertIn( + "package bytes remain untrusted until Windows verifier completes", + gate, + ) + + installer = workflow.split("\n installer:", 1)[1] + self.assertIn("verify-ci-package-reuse.py", installer) + self.assertIn("--allow-in-progress-artifact", installer) + self.assertIn("Compile installer from exact tested payload", installer) + self.assertIn("Silent install and field-runtime smoke test", installer) + self.assertIn( + "github.event_name == 'workflow_dispatch' ||", + installer, + ) + + if __name__ == "__main__": unittest.main() From 75e3218d6c99cd6d50ccb316764ca42229a69809 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 08:11:44 +0700 Subject: [PATCH 16/99] ci(p3g): defer Windows release until immutable dependencies are ready Move production release dependency polling to a metadata-only Ubuntu gate and allocate the Windows release runner only after the exact canonical package and completed-success validated installer metadata exist. Keep all byte-level package/installer verification, runtime smoke, SBOM, provenance, attestations, publication policy and manual fallback unchanged. Retry only transient validated-installer archive HTTP 404 within the existing bounded verifier deadline. All twelve triggered PR workflows passed. --- .../workflows/ci-p0-workflow-integrity.yml | 7 + .github/workflows/release-windows.yml | 114 ++++++- docs/audits/CI_P3G_RELEASE_READY_GATE.md | 92 ++++++ evidence/ci-workflow-budget.json | 65 +++- scripts/test-ci-installer-reuse.py | 34 +- scripts/test-ci-release-ready.py | 304 ++++++++++++++++++ scripts/verify-ci-installer-reuse.py | 27 +- scripts/wait-ci-release-ready.py | 296 +++++++++++++++++ 8 files changed, 924 insertions(+), 15 deletions(-) create mode 100644 docs/audits/CI_P3G_RELEASE_READY_GATE.md create mode 100644 scripts/test-ci-release-ready.py create mode 100644 scripts/wait-ci-release-ready.py diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index 45cb47db3..2508f4b70 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -18,6 +18,8 @@ on: - "scripts/write-ci-installer-manifest.py" - "scripts/verify-ci-installer-reuse.py" - "scripts/test-ci-installer-reuse.py" + - "scripts/wait-ci-release-ready.py" + - "scripts/test-ci-release-ready.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -37,6 +39,8 @@ on: - "scripts/write-ci-installer-manifest.py" - "scripts/verify-ci-installer-reuse.py" - "scripts/test-ci-installer-reuse.py" + - "scripts/wait-ci-release-ready.py" + - "scripts/test-ci-release-ready.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -78,6 +82,9 @@ jobs: - name: Run validated installer CI P3D safety fixtures run: python3 scripts/test-ci-installer-reuse.py + - name: Run release-ready CI P3G safety fixtures + run: python3 scripts/test-ci-release-ready.py + - name: Validate tracked workflow topology and budget run: | python3 scripts/audit-ci-workflows.py \ diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 943fd535f..cf981206a 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -32,8 +32,100 @@ permissions: attestations: write jobs: + release-ready: + name: Wait for exact package and validated installer metadata + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 50 + permissions: + contents: read + actions: read + outputs: + version: ${{ steps.ready.outputs.version }} + engine_sha: ${{ steps.ready.outputs.engine_sha }} + ardirec_sha: ${{ steps.ready.outputs.ardirec_sha }} + build_run_id: ${{ steps.ready.outputs.build_run_id }} + package_artifact_id: ${{ steps.ready.outputs.package_artifact_id }} + installer_run_id: ${{ steps.ready.outputs.installer_run_id }} + installer_artifact_id: ${{ steps.ready.outputs.installer_artifact_id }} + steps: + - name: Checkout exact release source for metadata gating + uses: actions/checkout@v7 + with: + path: ArIED61850Tester + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Setup Python for release-ready gate + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Run offline release-ready and installer-verifier fixtures + run: | + python .\ArIED61850Tester\scripts\test-ci-release-ready.py + if ($LASTEXITCODE -ne 0) { throw 'Release-ready metadata gate fixtures failed.' } + python .\ArIED61850Tester\scripts\test-ci-installer-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Validated-installer verifier fixtures failed.' } + + - name: Wait for exact immutable release dependencies + id: ready + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $proofPath = Join-Path $env:RUNNER_TEMP 'arsas-release-ready.json' + python .\ArIED61850Tester\scripts\wait-ci-release-ready.py ` + --repository $env:GITHUB_REPOSITORY ` + --source-dir .\ArIED61850Tester ` + --source-sha $env:GITHUB_SHA ` + --ref-type $env:GITHUB_REF_TYPE ` + --ref-name $env:GITHUB_REF_NAME ` + --wait-seconds 2700 ` + --output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Release dependencies did not become metadata-ready within the bounded gate.' + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if (-not [bool]$proof.metadataReady -or + [bool]$proof.releasePromotionAuthority -or + $proof.sourceSha -ne $env:GITHUB_SHA) { + throw 'Release-ready metadata proof is incomplete or overclaims authority.' + } + + "version=$($proof.version)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "engine_sha=$($proof.engineSha)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "ardirec_sha=$($proof.ardirecSha)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "build_run_id=$($proof.buildRunId)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "package_artifact_id=$($proof.packageArtifactId)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "installer_run_id=$($proof.installerRunId)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "installer_artifact_id=$($proof.installerArtifactId)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + + @( + '## CI-P3G release-ready scheduling gate', + '', + "- source: $($proof.sourceSha)", + "- version: $($proof.version)", + "- engine: $($proof.engineSha)", + "- ArdIrec: $($proof.ardirecSha)", + "- canonical Build ARSAS: $($proof.buildRunId)/$($proof.buildRunAttempt)", + "- package artifact id: $($proof.packageArtifactId)", + "- validated installer: $($proof.installerRunId)/$($proof.installerRunAttempt)", + "- installer artifact id: $($proof.installerArtifactId)", + '- authority: scheduling metadata only; Windows release revalidates all artifact bytes' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + windows-release: name: Build, test and release ARSAS Windows packages + needs: [release-ready] + if: >- + ${{ + always() && + (github.event_name == 'workflow_dispatch' || + needs.release-ready.result == 'success') + }} runs-on: windows-latest steps: @@ -55,6 +147,22 @@ jobs: } "RELEASE_SOURCE_COMMIT=$actual" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + - name: Record release-ready scheduling metadata + if: github.event_name != 'workflow_dispatch' + shell: powershell + run: | + @( + '## CI-P3G Windows runner allocation', + '', + '- Windows release runner started only after metadata-ready gate', + "- gate version: ${{ needs.release-ready.outputs.version }}", + "- gate engine: ${{ needs.release-ready.outputs.engine_sha }}", + "- gate ArdIrec: ${{ needs.release-ready.outputs.ardirec_sha }}", + "- canonical Build run: ${{ needs.release-ready.outputs.build_run_id }}", + "- validated installer run: ${{ needs.release-ready.outputs.installer_run_id }}", + '- authority: diagnostic scheduling metadata only' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + - name: Resolve release identity and immutable engine locks id: release shell: powershell @@ -203,7 +311,11 @@ jobs: - name: Run offline sealed-package safety fixtures if: github.event_name != 'workflow_dispatch' shell: powershell - run: python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + run: | + python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Canonical package verifier fixtures failed.' } + python .\ArIED61850Tester\scripts\test-ci-installer-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Validated installer verifier fixtures failed.' } - name: Promote exact sealed canonical Windows package for release if: github.event_name != 'workflow_dispatch' diff --git a/docs/audits/CI_P3G_RELEASE_READY_GATE.md b/docs/audits/CI_P3G_RELEASE_READY_GATE.md new file mode 100644 index 000000000..0420cfbc8 --- /dev/null +++ b/docs/audits/CI_P3G_RELEASE_READY_GATE.md @@ -0,0 +1,92 @@ +# CI-P3G — Release-ready metadata gate + +Issue #462. + +## Goal + +Keep expensive Windows release capacity out of dependency polling. + +Before P3G, the production release workflow allocated `windows-latest` +immediately and could then spend up to 1200 seconds waiting for the exact +canonical Build ARSAS package and another 1800 seconds waiting for the exact +validated installer. + +P3F already moved canonical-package waiting out of the installer Windows job. +P3G applies the same orchestration principle to the release lane. + +## Production release path + +For non-manual release events, a lightweight `ubuntu-latest` `release-ready` +job: + +1. checks out the exact release source SHA; +2. resolves and validates release version, ARIEC61850 SHA and ArdIrec SHA; +3. requires the latest exact Build ARSAS `push/main` run for the same source; +4. requires exactly one non-expired `ARSAS-windows-package-input` artifact; +5. requires the latest exact installer `push/main` run for the same source to + be completed successfully; +6. requires exactly one non-expired versioned installer artifact; +7. emits only run/artifact identifiers and scheduling diagnostics. + +The metadata gate never downloads artifact bytes and has +`releasePromotionAuthority=false`. + +Only after the gate succeeds is the `windows-release` job allocated. + +## Authority remains on Windows + +The Windows release job still independently: + +- validates/materializes the sealed Build ARSAS package; +- revalidates source, engine, ArdIrec, run/attempt, TRX and every payload hash; +- validates/materializes the installed-smoke-tested installer; +- rechecks portable and installer SHA-256 values; +- runs portable smoke; +- runs release-specific silent install/uninstall and installed native runtime + verification; +- creates checksums and SPDX SBOM; +- writes provenance; +- produces attestations; +- enforces stable-release publication policy. + +Metadata readiness cannot substitute for any of those checks. + +## Tag behavior + +A tag release still requires package and installer evidence from an exact +`push/main` run of the same commit. A tag on a commit that never completed the +main packaging/installer path fails closed instead of rebuilding silently. + +## Manual recovery + +`workflow_dispatch` bypasses `release-ready` and retains the historical local +restore/build/test/publish/Inno fallback. This preserves an explicit engineering +recovery path without weakening production exact-byte promotion. + +## Artifact archive readiness hardening + +Because the release Windows runner now starts close to installer artifact +publication, `verify-ci-installer-reuse.py` treats only archive-download HTTP +404 as transient within its existing bounded deadline. + +- transient 404: retry; +- persistent 404: timeout and fail closed; +- any other HTTP failure: immediate hard failure. + +The package verifier already follows the same observed GitHub artifact-readiness +rule. + +## Static CI cost + +P3G adds one checkout and one Python setup action on `ubuntu-latest`. It adds +no restore, build or test invocation. + +This is an intentional scheduling trade: cheap metadata work is added to avoid +holding a Windows release runner while upstream immutable artifacts are still +being produced. + +## Rollback + +Remove the `release-ready` job and the `windows-release` dependency/condition. +Do not remove or weaken the package verifier, installer verifier, release smoke, +SBOM, provenance, attestations or publication policy. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index e0a389c1a..fc11a3087 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3F: package-ready gate defers Windows installer allocation", - "policy": "P0 workflow-sprawl baseline remains immutable. P3A-P3D establish reproducible sealed exact-byte promotion through installer/release. P3F changes orchestration only: a non-authoritative Ubuntu metadata gate waits for the exact canonical package artifact before allocating the Windows installer runner; full package-byte verification and installer validation remain unchanged.", + "baselineName": "CI-P3G: release-ready gate defers Windows release allocation", + "policy": "P0 workflow-sprawl baseline remains immutable. P3A-P3F establish reproducible exact-byte promotion and defer installer Windows allocation. P3G adds a metadata-only Ubuntu release gate so production Windows release capacity is allocated only after the exact canonical package and exact completed-success validated installer artifacts exist; all byte/hash/TRX/runtime/release verification remains in the Windows release authority.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -18,9 +18,9 @@ }, "limits": { "workflowFiles": 31, - "actionsCheckout": 35, + "actionsCheckout": 36, "actionsSetupDotnet": 11, - "actionsSetupPython": 18, + "actionsSetupPython": 19, "dotnetRestore": 11, "dotnetBuild": 11, "dotnetTest": 12, @@ -511,5 +511,62 @@ "manualWorkflowDispatchBypassesGate": true, "releaseAuthorityChanged": false, "rollback": "Remove package-ready job and installer needs/if dependency to restore direct Windows polling. Never remove the full sealed-package verifier or installer/runtime smoke to reduce wait time." + }, + "ciP3ReleaseReadyGate": { + "issueNumber": 462, + "lane": "release-windows", + "approach": "For non-manual production release events, checkout the exact release source on ubuntu-latest, resolve version/ARIEC61850/ArdIrec identity, and wait for exact Build ARSAS package metadata plus exact completed-success validated installer metadata before allocating windows-latest. Windows release still revalidates and materializes both artifacts byte-for-byte.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 18, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 36, + "actionsSetupDotnet": 11, + "actionsSetupPython": 19, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "staticLimitsChanged": true, + "addedWorkflowFiles": 0, + "addedCheckoutActions": 1, + "addedSetupPythonActions": 1, + "addedDotnetRestoreBuildTest": 0, + "metadataGateRunner": "ubuntu-latest", + "boundedWaitSeconds": 2700, + "canonicalPackageMetadataRequired": true, + "completedSuccessfulInstallerMetadataRequired": true, + "exactSourceShaRequired": true, + "exactMainPushDependencyRunsRequired": true, + "gateResolvesVersionEngineAndArdIrecIdentity": true, + "packageBytesTrustedByGate": false, + "installerBytesTrustedByGate": false, + "releasePromotionAuthority": false, + "fullCanonicalPackageVerifierRetained": true, + "fullValidatedInstallerVerifierRetained": true, + "releasePortableSmokeRetained": true, + "releaseInstalledRuntimeSmokeRetained": true, + "checksumsSbomProvenanceAttestationsRetained": true, + "manualWorkflowDispatchBypassesGate": true, + "installerArchiveTransient404Retry": { + "status": 404, + "boundedByExistingVerifierDeadline": true, + "otherHttpErrorsHardFail": true, + "persistent404TimesOut": true + }, + "rollback": "Remove release-ready job and windows-release needs/if dependency to restore direct Windows polling. Keep exact package and installer verifiers, runtime smoke and release authority unchanged." } } diff --git a/scripts/test-ci-installer-reuse.py b/scripts/test-ci-installer-reuse.py index 0baf5cd69..ca03ec984 100644 --- a/scripts/test-ci-installer-reuse.py +++ b/scripts/test-ci-installer-reuse.py @@ -33,10 +33,16 @@ def archive(m=None, installer=b"installer", extra=None): return b.getvalue() class FakeApi: - def __init__(self,status="completed",conclusion="success"): + def __init__(self,status="completed",conclusion="success",binary_failures=0,binary_status=404): self.status=status; self.conclusion=conclusion; self.blob=archive() + self.binary_failures=binary_failures; self.binary_status=binary_status def get(self,url,*,binary=False): - if binary:return self.blob + if binary: + if self.binary_failures > 0: + self.binary_failures -= 1 + raise verifier.GitHubRequestError( + f"fixture HTTP {self.binary_status}", status=self.binary_status) + return self.blob if "installer-windows.yml/runs?" in url: return {"workflow_runs":[{"id":RUN,"run_attempt":1,"name":"Validate ARSAS Windows installer", "event":"push","head_branch":"main","head_sha":S,"status":self.status,"conclusion":self.conclusion}]} @@ -70,6 +76,30 @@ def test_latest_failed_run_is_hard_failure(self): with self.assertRaisesRegex(verifier.InstallerProofError,"failed"): verifier.verify(FakeApi(conclusion="failure"),repository="masarray/arsas",source_sha=S,version="1.6.40", engine_sha=E,ardirec_sha=A,package_artifact_sha256=P,wait_seconds=0,poll_seconds=1,output_dir=None) + + def test_transient_archive_404_recovers_within_existing_deadline(self): + proof = verifier.verify( + FakeApi(binary_failures=1, binary_status=404), + repository="masarray/arsas", source_sha=S, version="1.6.40", + engine_sha=E, ardirec_sha=A, package_artifact_sha256=P, + wait_seconds=1, poll_seconds=1, output_dir=None) + self.assertTrue(proof["installedSmokePassed"]) + + def test_persistent_archive_404_times_out_fail_closed(self): + with self.assertRaisesRegex(verifier.InstallerProofError, "Timed out"): + verifier.verify( + FakeApi(binary_failures=100, binary_status=404), + repository="masarray/arsas", source_sha=S, version="1.6.40", + engine_sha=E, ardirec_sha=A, package_artifact_sha256=P, + wait_seconds=0, poll_seconds=1, output_dir=None) + + def test_non_transient_archive_error_is_hard_failure(self): + with self.assertRaises(verifier.GitHubRequestError): + verifier.verify( + FakeApi(binary_failures=1, binary_status=403), + repository="masarray/arsas", source_sha=S, version="1.6.40", + engine_sha=E, ardirec_sha=A, package_artifact_sha256=P, + wait_seconds=1, poll_seconds=1, output_dir=None) def test_release_workflow_promotes_validated_installer_and_keeps_manual_compile(self): w=(ROOT.parent/".github/workflows/release-windows.yml").read_text(encoding="utf-8") for token in ("Promote exact validated Windows installer for release","verify-ci-installer-reuse.py", diff --git a/scripts/test-ci-release-ready.py b/scripts/test-ci-release-ready.py new file mode 100644 index 000000000..9e9567b60 --- /dev/null +++ b/scripts/test-ci-release-ready.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Offline fail-closed fixtures for CI-P3G release readiness.""" +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def load(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +gate = load("release_ready_gate", "wait-ci-release-ready.py") +S = "a" * 40 +E = "b" * 40 +A = "c" * 40 +BUILD = 111 +INSTALLER = 222 +VERSION = "1.6.40" + + +def run_obj(run_id: int, name: str, status: str = "completed", conclusion: str | None = "success"): + return { + "id": run_id, + "run_attempt": 1, + "name": name, + "event": "push", + "head_branch": "main", + "head_sha": S, + "status": status, + "conclusion": conclusion, + } + + +class FakeApi: + def __init__( + self, + *, + build_status="completed", + build_conclusion="success", + installer_status="completed", + installer_conclusion="success", + package_count=1, + installer_count=1, + ): + self.build = run_obj(BUILD, "Build ARSAS", build_status, build_conclusion) + self.installer = run_obj( + INSTALLER, + "Validate ARSAS Windows installer", + installer_status, + installer_conclusion, + ) + self.package_count = package_count + self.installer_count = installer_count + + def get(self, url: str): + if "workflows/build.yml/runs?" in url: + return {"workflow_runs": [self.build]} + if "workflows/installer-windows.yml/runs?" in url: + return {"workflow_runs": [self.installer]} + if f"/runs/{BUILD}/artifacts" in url: + return { + "artifacts": [ + { + "id": 300 + i, + "name": "ARSAS-windows-package-input", + "expired": False, + "workflow_run": {"id": BUILD}, + } + for i in range(self.package_count) + ] + } + if f"/runs/{INSTALLER}/artifacts" in url: + return { + "artifacts": [ + { + "id": 400 + i, + "name": f"ARSAS-{VERSION}-win-x64-installer", + "expired": False, + "workflow_run": {"id": INSTALLER}, + } + for i in range(self.installer_count) + ] + } + raise RuntimeError(url) + + +def identity(): + return { + "sourceSha": S, + "version": VERSION, + "engineSha": E, + "ardirecSha": A, + "refType": "branch", + "refName": "main", + } + + +class ReleaseReadyTests(unittest.TestCase): + def test_accepts_only_when_package_and_completed_installer_metadata_are_ready(self): + proof = gate.wait_release_ready( + FakeApi(), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + self.assertTrue(proof["metadataReady"]) + self.assertFalse(proof["releasePromotionAuthority"]) + self.assertEqual(proof["buildRunId"], BUILD) + self.assertEqual(proof["installerRunId"], INSTALLER) + self.assertEqual(proof["packageArtifactId"], 300) + self.assertEqual(proof["installerArtifactId"], 400) + + def test_completed_failed_build_is_immediate_hard_failure(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "Build ARSAS run failed"): + gate.wait_release_ready( + FakeApi(build_conclusion="failure"), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_completed_failed_installer_is_immediate_hard_failure(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "installer validation failed"): + gate.wait_release_ready( + FakeApi(installer_conclusion="failure"), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_in_progress_installer_is_not_ready(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "Timed out"): + gate.wait_release_ready( + FakeApi(installer_status="in_progress", installer_conclusion=None), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_missing_package_is_not_ready_while_build_runs(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "Timed out"): + gate.wait_release_ready( + FakeApi( + build_status="in_progress", + build_conclusion=None, + package_count=0, + installer_status="in_progress", + installer_conclusion=None, + ), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_duplicate_package_or_installer_metadata_fails_closed(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "exactly one"): + gate.wait_release_ready( + FakeApi(package_count=2), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + with self.assertRaisesRegex(gate.ReleaseReadyError, "exactly one"): + gate.wait_release_ready( + FakeApi(installer_count=2), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_wrong_head_branch_or_event_never_matches(self): + api = FakeApi() + api.build["head_branch"] = "release" + api.installer["event"] = "workflow_dispatch" + with self.assertRaisesRegex(gate.ReleaseReadyError, "Timed out"): + gate.wait_release_ready( + api, + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_identity_resolution_binds_main_manifest_version_and_locks(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / ".release").mkdir() + (root / "engines").mkdir() + (root / "VERSION").write_text(VERSION + "\n", encoding="utf-8") + (root / "Directory.Build.props").write_text( + f"{VERSION}", + encoding="utf-8", + ) + (root / "ArIED61850Tester.csproj").write_text( + f"{VERSION}", + encoding="utf-8", + ) + (root / ".release/windows.json").write_text( + json.dumps({"version": VERSION}), encoding="utf-8" + ) + (root / "engines/ARIEC61850.lock.json").write_text( + json.dumps( + { + "repository": "masarray/ARIEC61850", + "ref": "main", + "commit": E, + } + ), + encoding="utf-8", + ) + (root / "engines/ARDIREC.lock.json").write_text( + json.dumps( + { + "schema": 3, + "repository": "masarray/ardirec", + "ref": "feature/test", + "commit": A, + "bridge": {"abi": 1, "mode": "native-only"}, + } + ), + encoding="utf-8", + ) + resolved = gate.resolve_identity( + root, source_sha=S, ref_type="branch", ref_name="main" + ) + self.assertEqual(resolved["version"], VERSION) + self.assertEqual(resolved["engineSha"], E) + self.assertEqual(resolved["ardirecSha"], A) + + def test_release_workflow_defers_windows_but_retains_full_authority_verifiers(self): + workflow = (ROOT.parent / ".github/workflows/release-windows.yml").read_text( + encoding="utf-8" + ) + for token in ( + "release-ready:", + "runs-on: ubuntu-latest", + "wait-ci-release-ready.py", + "needs: [release-ready]", + "github.event_name == 'workflow_dispatch'", + "verify-ci-package-reuse.py", + "verify-ci-installer-reuse.py", + "Smoke-test exact release portable single EXE", + "Smoke-test silent installer and uninstaller", + "Generate SPDX 2.3 package SBOM", + "Create or update GitHub Release", + ): + self.assertIn(token, workflow) + self.assertIn( + "authority: scheduling metadata only; Windows release revalidates all artifact bytes", + workflow, + ) + + def test_tag_identity_must_match_canonical_version(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / ".release").mkdir() + (root / "engines").mkdir() + (root / "VERSION").write_text(VERSION, encoding="utf-8") + xml = f"{VERSION}" + (root / "Directory.Build.props").write_text(xml, encoding="utf-8") + (root / "ArIED61850Tester.csproj").write_text(xml, encoding="utf-8") + (root / ".release/windows.json").write_text( + json.dumps({"version": VERSION}), encoding="utf-8" + ) + (root / "engines/ARIEC61850.lock.json").write_text( + json.dumps({"repository": "masarray/ARIEC61850", "ref": "main", "commit": E}), + encoding="utf-8", + ) + (root / "engines/ARDIREC.lock.json").write_text( + json.dumps( + { + "schema": 3, + "repository": "masarray/ardirec", + "commit": A, + "bridge": {"abi": 1, "mode": "native-only"}, + } + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.ReleaseReadyError, "version identity mismatch"): + gate.resolve_identity( + root, source_sha=S, ref_type="tag", ref_name="v9.9.9" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-ci-installer-reuse.py b/scripts/verify-ci-installer-reuse.py index 179796a91..b8683e213 100644 --- a/scripts/verify-ci-installer-reuse.py +++ b/scripts/verify-ci-installer-reuse.py @@ -187,14 +187,25 @@ def verify(api: GitHubReadOnly, *, repository: str, source_sha: str, version: st matches = [a for a in arts if a.get("name") == name and a.get("expired") is False] if len(matches) != 1: raise InstallerProofError("Expected exactly one non-expired validated installer artifact") - blob = api.get(matches[0]["archive_download_url"], binary=True) - proof = validate_archive( - blob, version=version, source_sha=source_sha, engine_sha=engine_sha, - ardirec_sha=ardirec_sha, package_artifact_sha256=package_artifact_sha256, - workflow_run_id=run["id"], run_attempt=run["run_attempt"], output_dir=output_dir) - proof["installerRunStatus"] = "completed" - proof["installerRunConclusion"] = "success" - return proof + try: + blob = api.get(matches[0]["archive_download_url"], binary=True) + except GitHubRequestError as exc: + # Artifact metadata can become visible shortly before GitHub's + # archive redirect is readable. Retry only this transient 404 + # within the existing bounded deadline; all other errors fail. + if exc.status != 404: + raise + last = ( + f"{last} (installer metadata ready; archive download pending)" + ) + else: + proof = validate_archive( + blob, version=version, source_sha=source_sha, engine_sha=engine_sha, + ardirec_sha=ardirec_sha, package_artifact_sha256=package_artifact_sha256, + workflow_run_id=run["id"], run_attempt=run["run_attempt"], output_dir=output_dir) + proof["installerRunStatus"] = "completed" + proof["installerRunConclusion"] = "success" + return proof if time.monotonic() >= deadline: raise InstallerProofError(f"Timed out waiting for exact validated installer, last={last}") time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) diff --git a/scripts/wait-ci-release-ready.py b/scripts/wait-ci-release-ready.py new file mode 100644 index 000000000..0c9ceb899 --- /dev/null +++ b/scripts/wait-ci-release-ready.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""CI-P3G metadata-only release readiness gate. + +Wait until the exact Build ARSAS canonical Windows package metadata exists and +the exact main-push Windows installer validation has completed successfully with +one non-expired installer artifact. This script never downloads artifact bytes; +the Windows release job remains responsible for full package/installer proof. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import time +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$") +PACKAGE_ARTIFACT = "ARSAS-windows-package-input" + + +class ReleaseReadyError(RuntimeError): + pass + + +class GitHubReadOnly: + def __init__(self, token: str): + if not token: + raise ReleaseReadyError("A read-only GITHUB_TOKEN is required") + self.token = token + + def get(self, url: str) -> dict: + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ARSAS-CI-P3G-Release-Ready", + }, + method="GET", + ) + try: + with urllib.request.urlopen(req, timeout=30) as reply: + return json.loads(reply.read(4 * 1024 * 1024)) + except (urllib.error.HTTPError, urllib.error.URLError, ValueError) as exc: + raise ReleaseReadyError(f"GitHub release-readiness request failed: {exc}") from exc + + +def _sha(value: str, label: str) -> str: + value = (value or "").strip().lower() + if not SHA_RE.fullmatch(value): + raise ReleaseReadyError(f"{label} must be a lowercase 40-hex SHA") + return value + + +def _version(value: str) -> str: + value = (value or "").strip() + if value.lower().startswith("v"): + value = value[1:] + if not VERSION_RE.fullmatch(value): + raise ReleaseReadyError("Release version is invalid") + return value + + +def _xml_version(path: Path) -> str: + try: + root = ET.fromstring(path.read_text(encoding="utf-8-sig")) + except (OSError, ET.ParseError) as exc: + raise ReleaseReadyError(f"Cannot parse release version metadata: {path}") from exc + for group in root.findall("PropertyGroup"): + node = group.find("Version") + if node is not None and node.text and node.text.strip(): + return node.text.strip() + raise ReleaseReadyError(f"Version metadata is missing from {path}") + + +def resolve_identity(source_dir: Path, *, source_sha: str, ref_type: str, ref_name: str) -> dict: + source_sha = _sha(source_sha, "source") + if ref_type not in {"branch", "tag"}: + raise ReleaseReadyError("Release gate supports only branch or tag push refs") + + version_file = (source_dir / "VERSION").read_text(encoding="utf-8-sig").strip() + props_version = _xml_version(source_dir / "Directory.Build.props") + project_version = _xml_version(source_dir / "ArIED61850Tester.csproj") + + if ref_type == "tag": + requested = _version(ref_name) + else: + if ref_name != "main": + raise ReleaseReadyError("Branch release readiness is restricted to main") + try: + release = json.loads((source_dir / ".release/windows.json").read_text(encoding="utf-8-sig")) + requested = _version(str(release["version"])) + except (OSError, ValueError, KeyError, TypeError) as exc: + raise ReleaseReadyError("Release manifest version is missing or invalid") from exc + + canonical = _version(version_file) + if requested != canonical or props_version != canonical or project_version != canonical: + raise ReleaseReadyError( + f"Release version identity mismatch: requested={requested}, VERSION={canonical}, " + f"props={props_version}, project={project_version}" + ) + + try: + iec = json.loads((source_dir / "engines/ARIEC61850.lock.json").read_text(encoding="utf-8-sig")) + ard = json.loads((source_dir / "engines/ARDIREC.lock.json").read_text(encoding="utf-8-sig")) + except (OSError, ValueError) as exc: + raise ReleaseReadyError("Release engine lock metadata cannot be decoded") from exc + + engine_sha = _sha(str(iec.get("commit", "")), "ARIEC61850") + ardirec_sha = _sha(str(ard.get("commit", "")), "ArdIrec") + if iec.get("repository") != "masarray/ARIEC61850" or iec.get("ref") != "main": + raise ReleaseReadyError("ARIEC61850 release lock identity is invalid") + if str(ard.get("repository", "")).lower() != "masarray/ardirec": + raise ReleaseReadyError("ArdIrec release lock identity is invalid") + bridge = ard.get("bridge") if isinstance(ard.get("bridge"), dict) else {} + if ard.get("schema") != 3 or bridge.get("abi") != 1 or bridge.get("mode") != "native-only": + raise ReleaseReadyError("ArdIrec native bridge release lock is invalid") + + return { + "sourceSha": source_sha, + "version": canonical, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "refType": ref_type, + "refName": ref_name, + } + + +def _candidate_runs(payload: dict, *, workflow_name: str, source_sha: str) -> list[dict]: + runs = payload.get("workflow_runs", []) + if not isinstance(runs, list): + raise ReleaseReadyError("GitHub workflow run listing is malformed") + matches = [ + run for run in runs + if run.get("name") == workflow_name + and run.get("event") == "push" + and run.get("head_branch") == "main" + and run.get("head_sha") == source_sha + and isinstance(run.get("id"), int) + and isinstance(run.get("run_attempt"), int) + ] + return sorted(matches, key=lambda r: (r["id"], r["run_attempt"]), reverse=True) + + +def _one_artifact(payload: dict, *, name: str, run_id: int, allow_missing: bool) -> dict | None: + artifacts = payload.get("artifacts", []) + if not isinstance(artifacts, list): + raise ReleaseReadyError("GitHub artifact listing is malformed") + matches = [ + a for a in artifacts + if a.get("name") == name + and a.get("expired") is False + and isinstance(a.get("id"), int) + and a.get("workflow_run", {}).get("id", run_id) == run_id + ] + if not matches and allow_missing: + return None + if len(matches) != 1: + raise ReleaseReadyError(f"Expected exactly one non-expired artifact named {name}") + return matches[0] + + +def wait_release_ready( + api: GitHubReadOnly, + *, + repository: str, + identity: dict, + wait_seconds: int = 2400, + poll_seconds: int = 10, +) -> dict: + if repository != "masarray/arsas": + raise ReleaseReadyError("Release readiness is restricted to masarray/arsas") + source_sha = _sha(identity.get("sourceSha", ""), "source") + version = _version(identity.get("version", "")) + if wait_seconds < 0 or poll_seconds < 1: + raise ReleaseReadyError("Invalid bounded wait policy") + + base = f"https://api.github.com/repos/{repository}" + query = urllib.parse.urlencode({"event": "push", "head_sha": source_sha, "per_page": 100}) + build_url = f"{base}/actions/workflows/build.yml/runs?{query}" + installer_url = f"{base}/actions/workflows/installer-windows.yml/runs?{query}" + deadline = time.monotonic() + wait_seconds + last_build = "not found" + last_installer = "not found" + + while True: + package = None + installer_artifact = None + + builds = _candidate_runs( + api.get(build_url), workflow_name="Build ARSAS", source_sha=source_sha + ) + build_run = builds[0] if builds else None + if build_run is not None: + last_build = ( + f"{build_run['id']}/{build_run['run_attempt']} " + f"{build_run.get('status')}/{build_run.get('conclusion')}" + ) + if build_run.get("status") == "completed" and build_run.get("conclusion") != "success": + raise ReleaseReadyError(f"Latest exact Build ARSAS run failed: {last_build}") + if build_run.get("status") in {"in_progress", "completed"}: + package = _one_artifact( + api.get(f"{base}/actions/runs/{build_run['id']}/artifacts?per_page=100"), + name=PACKAGE_ARTIFACT, + run_id=build_run["id"], + allow_missing=(build_run.get("status") != "completed"), + ) + + installers = _candidate_runs( + api.get(installer_url), + workflow_name="Validate ARSAS Windows installer", + source_sha=source_sha, + ) + installer_run = installers[0] if installers else None + if installer_run is not None: + last_installer = ( + f"{installer_run['id']}/{installer_run['run_attempt']} " + f"{installer_run.get('status')}/{installer_run.get('conclusion')}" + ) + if installer_run.get("status") == "completed": + if installer_run.get("conclusion") != "success": + raise ReleaseReadyError( + f"Latest exact installer validation failed: {last_installer}" + ) + installer_artifact = _one_artifact( + api.get( + f"{base}/actions/runs/{installer_run['id']}/artifacts?per_page=100" + ), + name=f"ARSAS-{version}-win-x64-installer", + run_id=installer_run["id"], + allow_missing=False, + ) + + if package is not None and installer_artifact is not None: + return { + **identity, + "buildRunId": build_run["id"], + "buildRunAttempt": build_run["run_attempt"], + "packageArtifactId": package["id"], + "installerRunId": installer_run["id"], + "installerRunAttempt": installer_run["run_attempt"], + "installerArtifactId": installer_artifact["id"], + "metadataReady": True, + "releasePromotionAuthority": False, + } + + if time.monotonic() >= deadline: + raise ReleaseReadyError( + "Timed out waiting for exact release dependencies: " + f"Build={last_build}, Installer={last_installer}" + ) + time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--repository", required=True) + p.add_argument("--source-dir", type=Path, required=True) + p.add_argument("--source-sha", required=True) + p.add_argument("--ref-type", choices=("branch", "tag"), required=True) + p.add_argument("--ref-name", required=True) + p.add_argument("--wait-seconds", type=int, default=2400) + p.add_argument("--poll-seconds", type=int, default=10) + p.add_argument("--output", type=Path) + args = p.parse_args() + + identity = resolve_identity( + args.source_dir, + source_sha=args.source_sha, + ref_type=args.ref_type, + ref_name=args.ref_name, + ) + proof = wait_release_ready( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + repository=args.repository, + identity=identity, + wait_seconds=args.wait_seconds, + poll_seconds=args.poll_seconds, + ) + payload = json.dumps(proof, sort_keys=True) + print("Release dependency metadata ready: " + payload) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 979b1ca189016dcf6b674ae486b4de677b25ad15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:38:59 +0700 Subject: [PATCH 17/99] ci(p3h): cancel superseded Windows PR runs Cancel stale Windows-heavy pull-request runs when a newer PR revision arrives while keeping every push/main, tag and workflow_dispatch run isolated by unique run_id. Real synchronize validation proved older PR heads are cancelled and the latest head completes all checks successfully. No build, test, package, installer or release authority is weakened. --- .github/workflows/build.yml | 4 + .../workflows/comtrade-viewer-integration.yml | 4 + .github/workflows/installer-windows.yml | 4 + .github/workflows/rcb-export-guard.yml | 4 + .../smart-discovery-golden-budget-lock.yml | 4 + .../smart-discovery-golden-provenance.yml | 4 + .../smart-discovery-mainline-readiness.yml | 4 + .../smart-discovery-repeat-run-stability.yml | 4 + .github/workflows/validate-io-testing.yml | 4 + .github/workflows/validate-sv-evidence.yml | 4 + .../CI_P3H_SUPERSEDED_PR_CANCELLATION.md | 83 +++++++++++++++++++ evidence/ci-workflow-budget.json | 33 +++++++- scripts/test-ci-workflows.py | 30 +++++++ 13 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 docs/audits/CI_P3H_SUPERSEDED_PR_CANCELLATION.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c53e5e95e..e56d85819 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: build-windows: name: Build, test, validate and package Windows application diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index 06b070ed6..ef0274e18 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -27,6 +27,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: windows-viewer-integration: name: Build pinned ArdIrec native bridge and enforce in-process routing diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index 580bdeb4f..358580911 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -76,6 +76,10 @@ permissions: contents: read actions: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: package-ready: name: Wait for exact canonical package metadata diff --git a/.github/workflows/rcb-export-guard.yml b/.github/workflows/rcb-export-guard.yml index 6adca0000..b4349d2e8 100644 --- a/.github/workflows/rcb-export-guard.yml +++ b/.github/workflows/rcb-export-guard.yml @@ -32,6 +32,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify: name: Verify selected-RCB CID workflow diff --git a/.github/workflows/smart-discovery-golden-budget-lock.yml b/.github/workflows/smart-discovery-golden-budget-lock.yml index 15a1aa4cb..083a05b26 100644 --- a/.github/workflows/smart-discovery-golden-budget-lock.yml +++ b/.github/workflows/smart-discovery-golden-budget-lock.yml @@ -34,6 +34,10 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify-golden-lock: name: Verify P0-5e evidence-derived hard budget diff --git a/.github/workflows/smart-discovery-golden-provenance.yml b/.github/workflows/smart-discovery-golden-provenance.yml index 01dd2bc0c..0b5d62cd0 100644 --- a/.github/workflows/smart-discovery-golden-provenance.yml +++ b/.github/workflows/smart-discovery-golden-provenance.yml @@ -34,6 +34,10 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify-provenance: name: Verify P0-5e capture build and target provenance diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index 2744b182a..37d1c1d27 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -38,6 +38,10 @@ permissions: contents: read actions: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: mainline-readiness: name: Require R10 physical authority before mainline review diff --git a/.github/workflows/smart-discovery-repeat-run-stability.yml b/.github/workflows/smart-discovery-repeat-run-stability.yml index 48ad5a0ce..3f38fcb41 100644 --- a/.github/workflows/smart-discovery-repeat-run-stability.yml +++ b/.github/workflows/smart-discovery-repeat-run-stability.yml @@ -34,6 +34,10 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify-repeat-run-stability: name: Verify P0-5f repeat-run stability contract diff --git a/.github/workflows/validate-io-testing.yml b/.github/workflows/validate-io-testing.yml index 0bcd315ce..da327cd8c 100644 --- a/.github/workflows/validate-io-testing.yml +++ b/.github/workflows/validate-io-testing.yml @@ -20,6 +20,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: validate-io-testing: name: Build and test IO List Testing diff --git a/.github/workflows/validate-sv-evidence.yml b/.github/workflows/validate-sv-evidence.yml index 7d5989b93..34dd0650a 100644 --- a/.github/workflows/validate-sv-evidence.yml +++ b/.github/workflows/validate-sv-evidence.yml @@ -15,6 +15,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: validate-evidence: name: Build and test deterministic SV evidence diff --git a/docs/audits/CI_P3H_SUPERSEDED_PR_CANCELLATION.md b/docs/audits/CI_P3H_SUPERSEDED_PR_CANCELLATION.md new file mode 100644 index 000000000..269f02c8a --- /dev/null +++ b/docs/audits/CI_P3H_SUPERSEDED_PR_CANCELLATION.md @@ -0,0 +1,83 @@ +# CI-P3H — Superseded PR cancellation + +Issue #464. + +## Goal + +Stop obsolete Windows CI for stale pull-request revisions without weakening +main, tag, manual, installer, package, or release authority. + +## Scheduling contract + +Ten Windows-heavy pull-request workflows use: + +- group: workflow name + pull-request number only for `pull_request`; +- fallback group identity: unique `github.run_id` for every non-PR event; +- `cancel-in-progress: true`. + +This means a synchronize event for the same PR cancels the older revision, +while push/main, tags, release production paths, and workflow_dispatch runs +cannot cancel one another because their group identity is unique per run. + +## Why this matters + +P2/P3 consumers bind to exact source, engine, run and artifact identity. Keeping +obsolete PR Windows runs alive wastes capacity and can later produce irrelevant +red statuses after a newer canonical Build ARSAS run becomes authoritative. +Cancellation makes the newest PR head the sole active revision while preserving +fail-closed exact-SHA verification. + +## Affected workflows + +- Build ARSAS; +- Windows installer validation; +- Smart Discovery Mainline Readiness; +- RCB export guard; +- SV evidence validation; +- IO testing validation; +- COMTRADE integration; +- Smart Discovery Golden Provenance; +- Smart Discovery Golden Budget; +- Smart Discovery Repeat-Run Stability. + +Existing workflows that already had an intentional concurrency contract remain +unchanged. + +## Authority boundary + +P3H changes scheduling only. It does not change: + +- canonical Build ARSAS regression/package evidence; +- sealed package or portable hashes; +- physical R10 authority; +- installer compile/install/runtime smoke; +- validated-installer authority; +- release checksums, SBOM, provenance, attestations, or publication; +- manual recovery paths. + +## Regression guard + +`scripts/test-ci-workflows.py` asserts the exact PR-only concurrency expressions +for all ten workflows and confirms the production release workflow does not gain +the PR cancellation policy. + +## Corrected concurrency shape + +An initial implementation used an event-scoped boolean for `cancel-in-progress`. +Real synchronize validation showed the newer runs waiting behind older runs instead +of cancelling them. P3H therefore uses the safer concurrency invariant: only PR +runs share a group; all other events use `github.run_id`; cancellation is always +true inside a group. This preserves the intended PR-only effect without relying +on a separate boolean expression. + +## Validation protocol + +The PR acceptance intentionally performs one synchronize event after the first +Windows-heavy run set starts. The previous head must become stale and the newest +head must become the sole active PR authority for P3H-managed workflows. + +## Rollback + +Remove the P3H concurrency blocks. `cancel-in-progress: true` is safe here only +because every non-PR event falls back to its unique `github.run_id`; never change +the group expression so main, tags, release, or manual recovery runs can collide. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index fc11a3087..cfb140cd3 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3G: release-ready gate defers Windows release allocation", - "policy": "P0 workflow-sprawl baseline remains immutable. P3A-P3F establish reproducible exact-byte promotion and defer installer Windows allocation. P3G adds a metadata-only Ubuntu release gate so production Windows release capacity is allocated only after the exact canonical package and exact completed-success validated installer artifacts exist; all byte/hash/TRX/runtime/release verification remains in the Windows release authority.", + "baselineName": "CI-P3H: cancel superseded Windows PR runs", + "policy": "P0 historical workflow-sprawl baseline remains immutable. P3H changes scheduling only: Windows-heavy pull_request workflows cancel stale revisions when a newer commit arrives, while push/main, tag, release and workflow_dispatch runs remain non-cancellable. Exact artifact, regression, installer and release authority contracts are unchanged.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -568,5 +568,34 @@ "persistent404TimesOut": true }, "rollback": "Remove release-ready job and windows-release needs/if dependency to restore direct Windows polling. Keep exact package and installer verifiers, runtime smoke and release authority unchanged." + }, + "ciP3SupersededPrCancellation": { + "issueNumber": 464, + "lane": "windows-heavy-pull-request-workflows", + "approach": "Share one concurrency group only for the same pull request; all non-PR events fall back to unique github.run_id. cancel-in-progress is unconditional inside the group, so only stale PR revisions can collide and be cancelled.", + "staticLimitsChanged": false, + "affectedWorkflows": [ + "build.yml", + "installer-windows.yml", + "smart-discovery-mainline-readiness.yml", + "rcb-export-guard.yml", + "validate-sv-evidence.yml", + "validate-io-testing.yml", + "comtrade-viewer-integration.yml", + "smart-discovery-golden-provenance.yml", + "smart-discovery-golden-budget-lock.yml", + "smart-discovery-repeat-run-stability.yml" + ], + "groupExpression": "${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}", + "cancelExpression": "true", + "pullRequestSupersession": true, + "pushMainCancellation": false, + "tagCancellation": false, + "manualCancellation": false, + "releaseAuthorityChanged": false, + "canonicalPackageAuthorityChanged": false, + "installerAuthorityChanged": false, + "rollback": "Remove the P3H concurrency blocks from affected workflows. Never broaden cancel-in-progress to main/tag/manual runs.", + "observedFirstAttempt": "Event-scoped cancel expression left newer runs pending behind older runs; corrected to unique non-PR grouping plus unconditional cancellation." } } diff --git a/scripts/test-ci-workflows.py b/scripts/test-ci-workflows.py index 47fa8554d..584e446ab 100644 --- a/scripts/test-ci-workflows.py +++ b/scripts/test-ci-workflows.py @@ -163,6 +163,36 @@ def test_duplicate_job_id_is_rejected(self): any("duplicate job id 'validate'" in item for item in report["structureErrors"]) ) + def test_p3h_windows_pr_workflows_cancel_only_superseded_pr_runs(self): + repo = Path(__file__).resolve().parents[1] + workflows = [ + ".github/workflows/build.yml", + ".github/workflows/installer-windows.yml", + ".github/workflows/smart-discovery-mainline-readiness.yml", + ".github/workflows/rcb-export-guard.yml", + ".github/workflows/validate-sv-evidence.yml", + ".github/workflows/validate-io-testing.yml", + ".github/workflows/comtrade-viewer-integration.yml", + ".github/workflows/smart-discovery-golden-provenance.yml", + ".github/workflows/smart-discovery-golden-budget-lock.yml", + ".github/workflows/smart-discovery-repeat-run-stability.yml", + ] + expected_group = ( + "group: ${{ github.workflow }}-" + "${{ github.event_name == 'pull_request' && " + "github.event.pull_request.number || github.run_id }}" + ) + expected_cancel = "cancel-in-progress: true" + for relative in workflows: + source = (repo / relative).read_text(encoding="utf-8") + self.assertIn(expected_group, source, relative) + self.assertIn(expected_cancel, source, relative) + + release = (repo / ".github/workflows/release-windows.yml").read_text( + encoding="utf-8" + ) + self.assertNotIn(expected_group, release) + def test_budget_rejects_new_duplicate_build_cost(self): self.write( ".github/workflows/build.yml", From 11ffc6a37944470b8efe6d5648b59fdad27d853e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 13:49:33 +0700 Subject: [PATCH 18/99] ci(p3i): preserve exact main and manual verification runs --- .../workflows/ci-p0-workflow-integrity.yml | 2 +- .../interoperability-reference-guard.yml | 2 +- .github/workflows/scl-interoperability-r7.yml | 2 +- .../smart-discovery-capture-build.yml | 2 +- .../smart-discovery-merge-execution-guard.yml | 2 +- .../smart-discovery-post-merge-production.yml | 4 +- .../smart-discovery-production-promotion.yml | 2 +- docs/audits/CI_P3I_MAIN_MANUAL_AUTHORITY.md | 25 ++++++++++++ scripts/test-ci-workflows.py | 38 +++++++++++++++++++ 9 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 docs/audits/CI_P3I_MAIN_MANUAL_AUTHORITY.md diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index 2508f4b70..656f5ffb4 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -50,7 +50,7 @@ permissions: contents: read concurrency: - group: ci-p0-workflow-integrity-${{ github.event.pull_request.number || github.ref }} + group: ci-p0-workflow-integrity-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: diff --git a/.github/workflows/interoperability-reference-guard.yml b/.github/workflows/interoperability-reference-guard.yml index dd5a9d0bc..4dcc877f6 100644 --- a/.github/workflows/interoperability-reference-guard.yml +++ b/.github/workflows/interoperability-reference-guard.yml @@ -18,7 +18,7 @@ on: workflow_dispatch: concurrency: - group: interoperability-reference-${{ github.event.pull_request.number || github.ref }} + group: interoperability-reference-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 1434dbc91..f4919f4ed 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -22,7 +22,7 @@ on: workflow_dispatch: concurrency: - group: scl-interoperability-r7-${{ github.event.pull_request.number || github.ref }} + group: scl-interoperability-r7-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 365b1d1a0..60b99ef2b 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -39,7 +39,7 @@ permissions: actions: read concurrency: - group: smart-discovery-field-capture-${{ github.event.pull_request.number || github.ref }} + group: smart-discovery-field-capture-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml index b02c6835a..70948c723 100644 --- a/.github/workflows/smart-discovery-merge-execution-guard.yml +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -39,7 +39,7 @@ permissions: actions: read concurrency: - group: smart-discovery-merge-execution-${{ github.event.pull_request.number || github.ref }} + group: smart-discovery-merge-execution-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: diff --git a/.github/workflows/smart-discovery-post-merge-production.yml b/.github/workflows/smart-discovery-post-merge-production.yml index 62a21498a..4c7b5e2f0 100644 --- a/.github/workflows/smart-discovery-post-merge-production.yml +++ b/.github/workflows/smart-discovery-post-merge-production.yml @@ -11,8 +11,8 @@ permissions: actions: read concurrency: - group: smart-discovery-post-merge-production-${{ github.ref }} - cancel-in-progress: true + group: smart-discovery-post-merge-production-${{ github.run_id }} + cancel-in-progress: false jobs: post-merge-production: diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml index 59f76e407..fc6defb4b 100644 --- a/.github/workflows/smart-discovery-production-promotion.yml +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -39,7 +39,7 @@ permissions: actions: read concurrency: - group: smart-discovery-production-promotion-${{ github.event.pull_request.number || github.ref }} + group: smart-discovery-production-promotion-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: diff --git a/docs/audits/CI_P3I_MAIN_MANUAL_AUTHORITY.md b/docs/audits/CI_P3I_MAIN_MANUAL_AUTHORITY.md new file mode 100644 index 000000000..731d06574 --- /dev/null +++ b/docs/audits/CI_P3I_MAIN_MANUAL_AUTHORITY.md @@ -0,0 +1,25 @@ +# CI-P3I — Preserve main and manual verification evidence + +CI-P3H (PR #465) cancels superseded Windows-heavy pull request runs without cancelling `main` or manual runs. This follow-up closes the equivalent gap in inherited workflows which grouped by `github.ref` or used a shared `pull_request.number || github.ref` fallback. + +## Why + +- `smart-discovery-post-merge-production.yml` runs an independent R10 engine regression and consumes **exact main SHA** Build ARSAS evidence. A second push to `main` must not cancel the previous SHA's physical/provenance verification. +- `ci-p0-workflow-integrity.yml` audits workflow safety and budget. A new main push must not erase the previous commit's guard result. +- Five manual-capable interoperability and proof workflows previously shared a concurrency group by ref; two dispatches on the same branch could cancel one another. +- On `pull_request`, stale revisions of the **same PR** should still cancel to save resources. + +## Policy + +| Event | Concurrency identity | Cancellation | +| --- | --- | --- | +| Pull request | stable workflow-specific prefix + PR number | stale same-PR runs cancelled | +| Main push | stable workflow-specific prefix + unique `github.run_id` | no cross-run cancellation | +| Workflow dispatch | stable workflow-specific prefix + unique `github.run_id` | no cross-run cancellation | +| R10 post-merge push/dispatch | `smart-discovery-post-merge-production-${{ github.run_id }}` | explicitly disabled | + +The top-level group is isolated per workflow. These changes affect only scheduling, not job conditions, R10 evidence, exact engine/source SHA matching, reusable regression, installer validation, portable hashes or release publication. Website deployment concurrency is intentionally outside scope. + +## Validation and rollback + +`scripts/test-ci-workflows.py` now locks each affected workflow's group and cancellation behavior. CI-P0 inventory/budget and the full PR workflow suite must pass before merge. For production acceptance, confirm a manual dispatch does not cancel a running post-merge verification. Roll back by restoring the pre-P3I concurrency blocks, not by changing evidence or package verifiers. diff --git a/scripts/test-ci-workflows.py b/scripts/test-ci-workflows.py index 584e446ab..1cb6bf9d9 100644 --- a/scripts/test-ci-workflows.py +++ b/scripts/test-ci-workflows.py @@ -193,6 +193,44 @@ def test_p3h_windows_pr_workflows_cancel_only_superseded_pr_runs(self): ) self.assertNotIn(expected_group, release) + def test_p3i_main_and_manual_verification_runs_are_not_superseded(self): + """PR updates may cancel old PR work; main and manual runs must keep their proof.""" + repo = Path(__file__).resolve().parents[1] + isolated = { + "ci-p0-workflow-integrity.yml": "ci-p0-workflow-integrity", + "smart-discovery-capture-build.yml": "smart-discovery-field-capture", + "scl-interoperability-r7.yml": "scl-interoperability-r7", + "smart-discovery-merge-execution-guard.yml": "smart-discovery-merge-execution", + "smart-discovery-production-promotion.yml": "smart-discovery-production-promotion", + "interoperability-reference-guard.yml": "interoperability-reference", + } + pr_or_unique_run = ( + "${{ github.event_name == 'pull_request' && " + "github.event.pull_request.number || github.run_id }}" + ) + for filename, prefix in isolated.items(): + source = (repo / ".github/workflows" / filename).read_text( + encoding="utf-8" + ) + self.assertEqual(source.count("\nconcurrency:\n"), 1, filename) + block = source.split("\nconcurrency:\n", 1)[1].split("\njobs:\n", 1)[0] + self.assertIn(f"group: {prefix}-{pr_or_unique_run}", block, filename) + self.assertIn("cancel-in-progress: true", block, filename) + self.assertNotIn("|| github.ref", block, filename) + + post_merge = ( + repo / ".github/workflows/smart-discovery-post-merge-production.yml" + ).read_text(encoding="utf-8") + block = post_merge.split("\nconcurrency:\n", 1)[1].split( + "\njobs:\n", 1 + )[0] + self.assertIn( + "group: smart-discovery-post-merge-production-${{ github.run_id }}", + block, + ) + self.assertIn("cancel-in-progress: false", block) + self.assertNotIn("github.ref", block) + def test_budget_rejects_new_duplicate_build_cost(self): self.write( ".github/workflows/build.yml", From 17a18d241b261084cb596262231a17b0b5ad0cd6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 14:07:31 +0700 Subject: [PATCH 19/99] ci(p3i): promote canonical domain-test proof into SV and IO lanes Reuse exact canonical Build ARSAS TRX evidence for specialized SV and IO PR checks, require explicit domain test-family presence/pass, keep stable check names, and retain focused Windows diagnostics for manual dispatch. Consolidate domain evidence uploads and lower the P0 artifact ceiling. All twelve triggered PR workflows passed. --- .github/workflows/validate-io-testing.yml | 97 +++++++++++++------ .github/workflows/validate-sv-evidence.yml | 93 ++++++++++++------ .../CI_P3I_SPECIALIZED_TEST_PROMOTION.md | 71 ++++++++++++++ evidence/ci-workflow-budget.json | 77 ++++++++++++++- scripts/test-ci-canonical-reuse.py | 54 +++++++++++ scripts/verify-ci-canonical-reuse.py | 56 +++++++++++ 6 files changed, 387 insertions(+), 61 deletions(-) create mode 100644 docs/audits/CI_P3I_SPECIALIZED_TEST_PROMOTION.md diff --git a/.github/workflows/validate-io-testing.yml b/.github/workflows/validate-io-testing.yml index da327cd8c..c63609462 100644 --- a/.github/workflows/validate-io-testing.yml +++ b/.github/workflows/validate-io-testing.yml @@ -15,10 +15,13 @@ on: - "tests/ARSAS.Tests/FatScl*" - "engines/ARIEC61850.lock.json" - ".github/workflows/validate-io-testing.yml" + - "scripts/verify-ci-canonical-reuse.py" + - "scripts/test-ci-canonical-reuse.py" workflow_dispatch: permissions: contents: read + actions: read concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} @@ -27,44 +30,84 @@ concurrency: jobs: validate-io-testing: name: Build and test IO List Testing - runs-on: windows-latest + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'windows-latest' }} steps: - - name: Checkout ARSAS application + - name: Checkout exact ARSAS application revision uses: actions/checkout@v7 with: path: ARSAS + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 1 - - name: Resolve immutable ARIEC61850 engine lock - shell: powershell + - id: engine + name: Resolve exact immutable ARIEC61850 engine identity + uses: ./ARSAS/.github/actions/resolve-ariec-engine + with: + lock-path: ARSAS/engines/ARIEC61850.lock.json + + - name: Run offline canonical evidence safety fixtures + if: github.event_name == 'pull_request' + run: python3 ARSAS/scripts/test-ci-canonical-reuse.py + + - name: Prove exact canonical IO domain tests passed + if: github.event_name == 'pull_request' + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + ENGINE_SHA: ${{ steps.engine.outputs.commit }} run: | - $lock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json - if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { - throw "ARIEC61850 lock metadata is invalid." - } - "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + set -euo pipefail + mkdir -p ARSAS/TestResults + python3 ARSAS/scripts/verify-ci-canonical-reuse.py \ + --repository "$GITHUB_REPOSITORY" \ + --head-branch "$PR_HEAD_BRANCH" \ + --head-sha "$PR_HEAD_SHA" \ + --merge-sha "$PR_MERGE_SHA" \ + --engine-sha "$ENGINE_SHA" \ + --wait-seconds 960 \ + --allow-in-progress-artifact \ + --require-test-substring IoTest \ + --require-test-substring IoList \ + --require-test-substring IoFat \ + --require-test-substring FatDataSet \ + --require-test-substring FatScl \ + --output ARSAS/TestResults/io-canonical-domain-proof.json - - name: Checkout immutable ARIEC61850 engine revision + - name: Checkout immutable ARIEC61850 engine revision for manual diagnostics + if: github.event_name == 'workflow_dispatch' shell: powershell + env: + ENGINE_REPOSITORY: ${{ steps.engine.outputs.repository }} + ENGINE_COMMIT: ${{ steps.engine.outputs.commit }} 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 "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ENGINE_REPOSITORY.git" ARIEC61850 + if ($LASTEXITCODE -ne 0) { throw 'Engine clone failed.' } + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ENGINE_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Pinned engine fetch failed.' } + git -C .\ARIEC61850 checkout --quiet --detach $env:ENGINE_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Pinned engine checkout failed.' } + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:ENGINE_COMMIT) { + throw "ARIEC61850 pin mismatch. Expected $env:ENGINE_COMMIT, got $actual." } - - name: Setup .NET 8 + - name: Setup .NET 8 for manual diagnostics + if: github.event_name == 'workflow_dispatch' uses: actions/setup-dotnet@v6 with: dotnet-version: 8.0.x - name: Restore focused test project + if: github.event_name == 'workflow_dispatch' run: dotnet restore .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj - name: Build focused IO testing project + if: github.event_name == 'workflow_dispatch' shell: powershell run: | $log = ".\ARSAS\io-testing-build.log" @@ -83,21 +126,17 @@ jobs: } Get-Content $log -Tail 12 - - name: Upload IO testing compiler diagnostics - if: always() - uses: actions/upload-artifact@v7 - with: - name: ARSAS-io-testing-compiler-log - path: ARSAS/io-testing-build.log - if-no-files-found: error - - name: Run IO testing regression tests + if: github.event_name == 'workflow_dispatch' run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~IoTest|FullyQualifiedName~IoList|FullyQualifiedName~IoFat|FullyQualifiedName~FatDataSet|FullyQualifiedName~FatScl" --logger "trx;LogFileName=io-testing-tests.trx" --results-directory .\ARSAS\TestResults - - name: Upload IO testing test results + - name: Upload IO validation evidence if: always() uses: actions/upload-artifact@v7 with: name: ARSAS-io-testing-test-results - path: ARSAS/TestResults/*.trx - if-no-files-found: ignore + path: | + ARSAS/TestResults/io-canonical-domain-proof.json + ARSAS/TestResults/*.trx + ARSAS/io-testing-build.log + if-no-files-found: warn diff --git a/.github/workflows/validate-sv-evidence.yml b/.github/workflows/validate-sv-evidence.yml index 34dd0650a..4e4311c44 100644 --- a/.github/workflows/validate-sv-evidence.yml +++ b/.github/workflows/validate-sv-evidence.yml @@ -10,10 +10,13 @@ on: - "tests/ARSAS.Tests/**" - "engines/ARIEC61850.lock.json" - ".github/workflows/validate-sv-evidence.yml" + - "scripts/verify-ci-canonical-reuse.py" + - "scripts/test-ci-canonical-reuse.py" workflow_dispatch: permissions: contents: read + actions: read concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} @@ -22,44 +25,80 @@ concurrency: jobs: validate-evidence: name: Build and test deterministic SV evidence - runs-on: windows-latest + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'windows-latest' }} steps: - - name: Checkout ARSAS application + - name: Checkout exact ARSAS application revision uses: actions/checkout@v7 with: path: ARSAS + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 1 - - name: Resolve immutable ARIEC61850 engine lock - shell: powershell + - id: engine + name: Resolve exact immutable ARIEC61850 engine identity + uses: ./ARSAS/.github/actions/resolve-ariec-engine + with: + lock-path: ARSAS/engines/ARIEC61850.lock.json + + - name: Run offline canonical evidence safety fixtures + if: github.event_name == 'pull_request' + run: python3 ARSAS/scripts/test-ci-canonical-reuse.py + + - name: Prove exact canonical SV evidence tests passed + if: github.event_name == 'pull_request' + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + ENGINE_SHA: ${{ steps.engine.outputs.commit }} run: | - $lock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json - if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { - throw "ARIEC61850 lock metadata is invalid." - } - "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + set -euo pipefail + mkdir -p ARSAS/TestResults + python3 ARSAS/scripts/verify-ci-canonical-reuse.py \ + --repository "$GITHUB_REPOSITORY" \ + --head-branch "$PR_HEAD_BRANCH" \ + --head-sha "$PR_HEAD_SHA" \ + --merge-sha "$PR_MERGE_SHA" \ + --engine-sha "$ENGINE_SHA" \ + --wait-seconds 960 \ + --allow-in-progress-artifact \ + --require-test-substring SmvSnapshotEvidenceExporterTests \ + --output ARSAS/TestResults/sv-canonical-domain-proof.json - - name: Checkout immutable ARIEC61850 engine revision + - name: Checkout immutable ARIEC61850 engine revision for manual diagnostics + if: github.event_name == 'workflow_dispatch' shell: powershell + env: + ENGINE_REPOSITORY: ${{ steps.engine.outputs.repository }} + ENGINE_COMMIT: ${{ steps.engine.outputs.commit }} 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 "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ENGINE_REPOSITORY.git" ARIEC61850 + if ($LASTEXITCODE -ne 0) { throw 'Engine clone failed.' } + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ENGINE_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Pinned engine fetch failed.' } + git -C .\ARIEC61850 checkout --quiet --detach $env:ENGINE_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Pinned engine checkout failed.' } + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:ENGINE_COMMIT) { + throw "ARIEC61850 pin mismatch. Expected $env:ENGINE_COMMIT, got $actual." } - - name: Setup .NET 8 + - name: Setup .NET 8 for manual diagnostics + if: github.event_name == 'workflow_dispatch' uses: actions/setup-dotnet@v6 with: dotnet-version: 8.0.x - name: Restore focused test project + if: github.event_name == 'workflow_dispatch' run: dotnet restore .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj - name: Build focused test project + if: github.event_name == 'workflow_dispatch' shell: powershell run: | $log = ".\ARSAS\sv-evidence-build.log" @@ -78,21 +117,17 @@ jobs: } Get-Content $log -Tail 12 - - name: Upload focused compiler diagnostics - if: always() - uses: actions/upload-artifact@v7 - with: - name: ARSAS-sv-evidence-compiler-log - path: ARSAS/sv-evidence-build.log - if-no-files-found: error - - name: Run SV evidence regression tests + if: github.event_name == 'workflow_dispatch' run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~SmvSnapshotEvidenceExporterTests" --logger "trx;LogFileName=sv-evidence-tests.trx" --results-directory .\ARSAS\TestResults - - name: Upload focused evidence test results + - name: Upload SV validation evidence if: always() uses: actions/upload-artifact@v7 with: name: ARSAS-sv-evidence-test-results - path: ARSAS/TestResults/*.trx - if-no-files-found: ignore + path: | + ARSAS/TestResults/sv-canonical-domain-proof.json + ARSAS/TestResults/*.trx + ARSAS/sv-evidence-build.log + if-no-files-found: warn diff --git a/docs/audits/CI_P3I_SPECIALIZED_TEST_PROMOTION.md b/docs/audits/CI_P3I_SPECIALIZED_TEST_PROMOTION.md new file mode 100644 index 000000000..b37b4bc94 --- /dev/null +++ b/docs/audits/CI_P3I_SPECIALIZED_TEST_PROMOTION.md @@ -0,0 +1,71 @@ +# CI-P3I — Specialized canonical domain-test promotion + +Issue #467. + +## Decision + +SV Evidence and IO Testing remain distinct review checks, but pull requests no +longer compile and test the same ARSAS application a second time on Windows. +They consume the exact canonical Build ARSAS regression artifact instead. + +## Exact authority + +The shared canonical verifier continues to require: + +- exact PR head and branch; +- exact synthetic merge/source SHA; +- exact ARIEC61850 integration SHA; +- latest matching Build ARSAS run ID and attempt; +- safe, bounded, non-expired ARSAS-test-evidence artifact; +- exact canonical manifest and TRX SHA-256; +- globally all-pass TRX counters. + +P3I adds an optional required-test-family proof. Every requested substring must +match at least one UnitTestResult with outcome Passed. A missing family or any +matching non-Passed result fails closed. + +SV requires SmvSnapshotEvidenceExporterTests. + +IO requires IoTest, IoList, IoFat, FatDataSet and FatScl. + +The production main TRX at the P3I start point contained 6 SV evidence tests +and, respectively, 110 / 12 / 169 / 2 / 12 matching IO-family results. + +## Runner routing + +For pull_request, both existing workflow/job check names are preserved but the +job runs on ubuntu-latest and only performs exact identity + canonical TRX +verification. No .NET restore/build/test is executed in those PR lanes. + +For workflow_dispatch, the same workflows continue to run on windows-latest +and retain the focused restore/build/filter-test diagnostic path. + +## Artifact contract + +The existing ARSAS-sv-evidence-test-results and +ARSAS-io-testing-test-results artifact names remain stable. Their PR payload is +an inspectable canonical domain-proof JSON rather than a second TRX generated +by duplicate execution. Manual diagnostics publish the focused TRX and compiler +log together in the same domain evidence artifact. The redundant standalone +compiler-log artifact names are retired; repository search found no consumers. + +## Static topology + +No workflow file, checkout action, setup-python action, setup-dotnet action, +or static dotnet restore/build/test count is added. Manual fallback commands +stay in YAML intentionally. Consolidating each domain's proof/TRX/compiler log +into one evidence artifact reduces static upload-artifact occurrences from +35 to 33, and P0 freezes that lower ceiling. PR execution no longer allocates +a Windows runner. + +## Authority boundary + +This milestone changes only specialized PR verification execution. It does not +change canonical Build ARSAS, Windows package authority, installer authority, +release promotion, R10/P141 physical evidence, or IEC 61850 runtime behavior. + +## Rollback + +Restore pull_request execution to the focused Windows restore/build/test path. +Never remove exact canonical identity or required-domain-test checks merely to +make the specialized workflow green. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index cfb140cd3..6dbe1a7d1 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3H: cancel superseded Windows PR runs", - "policy": "P0 historical workflow-sprawl baseline remains immutable. P3H changes scheduling only: Windows-heavy pull_request workflows cancel stale revisions when a newer commit arrives, while push/main, tag, release and workflow_dispatch runs remain non-cancellable. Exact artifact, regression, installer and release authority contracts are unchanged.", + "baselineName": "CI-P3I: canonical domain-test proof replaces duplicate specialized PR compilation", + "policy": "P0 historical workflow-sprawl baseline remains immutable. P3I preserves static topology and manual focused diagnostics, but SV Evidence and IO Testing PR runs consume exact canonical Build ARSAS TRX proof for their required test families on ubuntu-latest instead of compiling/testing the same application again on windows-latest.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -24,7 +24,7 @@ "dotnetRestore": 11, "dotnetBuild": 11, "dotnetTest": 12, - "actionsUploadArtifact": 35, + "actionsUploadArtifact": 33, "workflowsWithDotnetBuild": 10, "workflowsWithDotnetTest": 9 }, @@ -597,5 +597,76 @@ "installerAuthorityChanged": false, "rollback": "Remove the P3H concurrency blocks from affected workflows. Never broaden cancel-in-progress to main/tag/manual runs.", "observedFirstAttempt": "Event-scoped cancel expression left newer runs pending behind older runs; corrected to unique non-PR grouping plus unconditional cancellation." + }, + "ciP3SpecializedTestPromotion": { + "issueNumber": 467, + "lane": "validate-sv-evidence + validate-io-testing", + "approach": "On pull_request, preserve workflow/job check names but run on ubuntu-latest, resolve exact engine lock, and require exact canonical Build ARSAS all-pass TRX plus explicit domain test-family presence. workflow_dispatch retains existing focused Windows restore/build/test diagnostics.", + "staticLimitsChanged": true, + "addedWorkflowFiles": 0, + "addedCheckoutActions": 0, + "addedSetupPythonActions": 0, + "addedSetupDotnetActions": 0, + "addedUploadArtifactActions": -2, + "prWindowsRunnerRequired": false, + "canonicalIdentity": [ + "PR head/branch", + "synthetic merge/source SHA", + "ARIEC61850 SHA", + "Build ARSAS run ID/attempt", + "canonical TRX SHA-256 and all-pass counters" + ], + "requiredDomainTests": { + "sv": [ + "SmvSnapshotEvidenceExporterTests" + ], + "io": [ + "IoTest", + "IoList", + "IoFat", + "FatDataSet", + "FatScl" + ] + }, + "missingDomainTestFailsClosed": true, + "nonPassingDomainMatchFailsClosed": true, + "manualFocusedWindowsDiagnosticsRetained": true, + "releaseAuthorityChanged": false, + "packageAuthorityChanged": false, + "physicalAuthorityChanged": false, + "rollback": "Restore pull_request execution to the focused Windows restore/build/test path. Never waive exact canonical identity or required domain-test assertions to make a specialized check pass.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 36, + "actionsSetupDotnet": 11, + "actionsSetupPython": 19, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 36, + "actionsSetupDotnet": 11, + "actionsSetupPython": 19, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "manualCompilerLogFoldedIntoDomainEvidenceArtifact": true, + "artifactNamesRetained": [ + "ARSAS-sv-evidence-test-results", + "ARSAS-io-testing-test-results" + ], + "retiredRedundantManualArtifactNames": [ + "ARSAS-sv-evidence-compiler-log", + "ARSAS-io-testing-compiler-log" + ] } } diff --git a/scripts/test-ci-canonical-reuse.py b/scripts/test-ci-canonical-reuse.py index f4b8f5dcc..3513bc9f7 100644 --- a/scripts/test-ci-canonical-reuse.py +++ b/scripts/test-ci-canonical-reuse.py @@ -37,6 +37,14 @@ def load(name: str, path: str): notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="1320" inProgress="0" pending="0" /> + + + + + + + + """ @@ -115,6 +123,7 @@ def run_once( branch: str = BRANCH, head_sha: str = HEAD, source_sha: str = SOURCE, + required_test_substrings: list[str] | None = None, ): return consumer.verify_canonical( api, @@ -127,6 +136,7 @@ def run_once( poll_seconds=1, allow_in_progress_artifact=allow_in_progress_artifact, event_name=event_name, + required_test_substrings=required_test_substrings, ) @@ -142,6 +152,50 @@ def test_manifest_never_claims_release_authority(self): self.assertIs(fixture()["releasePromotionAuthority"], False) self.assertTrue(fixture()["fullRegressionPassed"]) + def test_required_domain_test_family_is_proven_from_canonical_trx(self): + proof = run_once( + FakeApi(), + required_test_substrings=[ + "SmvSnapshotEvidenceExporterTests", + "IoTest", + "IoList", + "IoFat", + "FatDataSet", + "FatScl", + ], + ) + self.assertEqual(proof["requiredTests"]["SmvSnapshotEvidenceExporterTests"], 1) + self.assertEqual(proof["requiredTests"]["IoTest"], 1) + self.assertEqual(proof["requiredTests"]["FatScl"], 1) + + def test_missing_required_domain_test_family_fails_closed(self): + with self.assertRaisesRegex(consumer.ProofError, "missing required test family"): + run_once( + FakeApi(), + required_test_substrings=["DefinitelyMissingTests"], + ) + + def test_required_domain_test_family_rejects_nonpassing_match(self): + bad = TRX.replace( + b'testName="ARSAS.Tests.IoTestWorkflowTests.BuildsPlan" outcome="Passed"', + b'testName="ARSAS.Tests.IoTestWorkflowTests.BuildsPlan" outcome="Failed"', + ) + manifest = producer.create_manifest( + source_sha=SOURCE, + checkout_sha=SOURCE, + engine_expected=ENGINE, + engine_actual=ENGINE, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + trx=bad, + trx_name="arsas-tests.trx", + ) + api = FakeApi() + api.blob = zip_archive(manifest, bad) + with self.assertRaisesRegex(consumer.ProofError, "not entirely Passed"): + run_once(api, required_test_substrings=["IoTest"]) + def test_reject_wrong_merge_sha(self): api = FakeApi() with self.assertRaisesRegex(consumer.ProofError, "stale/different source revision|Timed out"): diff --git a/scripts/verify-ci-canonical-reuse.py b/scripts/verify-ci-canonical-reuse.py index 99f8ee2ec..bca4a9dda 100644 --- a/scripts/verify-ci-canonical-reuse.py +++ b/scripts/verify-ci-canonical-reuse.py @@ -22,6 +22,7 @@ import urllib.parse import urllib.request import zipfile +import xml.etree.ElementTree as ET from pathlib import PurePosixPath import importlib.util @@ -46,6 +47,46 @@ class ProofError(RuntimeError): pass +def required_test_matches(trx: bytes, required_substrings: list[str]) -> dict[str, int]: + if not required_substrings: + return {} + + normalized: list[str] = [] + for value in required_substrings: + item = (value or "").strip() + if not item or len(item) > 200 or "\n" in item or "\r" in item: + raise ProofError("Required canonical test substring is invalid") + if item not in normalized: + normalized.append(item) + + try: + root = ET.fromstring(trx) + except ET.ParseError as exc: + raise ProofError("Canonical TRX cannot be parsed for required domain tests") from exc + + results: list[tuple[str, str]] = [] + for element in root.findall(".//{*}UnitTestResult"): + name = element.attrib.get("testName", "") + outcome = element.attrib.get("outcome", "") + if name: + results.append((name, outcome)) + + proof: dict[str, int] = {} + for required in normalized: + matches = [(name, outcome) for name, outcome in results if required in name] + if not matches: + raise ProofError( + f"Canonical TRX is missing required test family: {required}" + ) + nonpassing = [name for name, outcome in matches if outcome != "Passed"] + if nonpassing: + raise ProofError( + f"Canonical TRX required test family is not entirely Passed: {required}" + ) + proof[required] = len(matches) + return proof + + def validate_identity(repo: str, head_sha: str, merge_sha: str, engine_sha: str, branch: str, event_name: str = "pull_request") -> None: if repo != "masarray/arsas": @@ -85,6 +126,7 @@ def validate_artifact_archive( payload: bytes, *, merge_sha: str, engine_sha: str, workflow_run_id: int, run_attempt: int, expected_event_name: str = "pull_request", + required_test_substrings: list[str] | None = None, ) -> dict: if not payload or len(payload) > MAX_ARCHIVE_BYTES: raise ProofError("Canonical artifact is missing or exceeds bounded size") @@ -156,6 +198,7 @@ def validate_artifact_archive( raise ProofError("TRX does not prove a fully passing test suite") from exc if manifest.get("testCounters") != actual: raise ProofError("Canonical TRX counters disagree with manifest") + required_tests = required_test_matches(trx, required_test_substrings or []) return { "canonicalRunId": workflow_run_id, "canonicalRunAttempt": run_attempt, @@ -165,6 +208,7 @@ def validate_artifact_archive( "failed": actual["failed"], "notExecuted": actual["notExecuted"], "artifactSha256": hashlib.sha256(payload).hexdigest(), + "requiredTests": required_tests, } @@ -233,6 +277,7 @@ def verify_canonical( wait_seconds: int = 960, poll_seconds: int = 10, allow_in_progress_artifact: bool = False, event_name: str = "pull_request", + required_test_substrings: list[str] | None = None, ) -> dict: validate_identity( repository, head_sha, merge_sha, engine_sha, branch, event_name @@ -282,6 +327,7 @@ def verify_canonical( blob, merge_sha=merge_sha, engine_sha=engine_sha, workflow_run_id=run_id, run_attempt=attempt, expected_event_name=event_name, + required_test_substrings=required_test_substrings, ) except ProofError as exc: if "stale/different source revision" not in str(exc): @@ -330,6 +376,15 @@ def main() -> int: default="pull_request", ) parser.add_argument("--wait-seconds", type=int, default=960) + parser.add_argument( + "--require-test-substring", + action="append", + default=[], + help=( + "Require at least one Passed UnitTestResult whose testName contains " + "this substring. Repeat for multiple domain test families." + ), + ) parser.add_argument( "--allow-in-progress-artifact", action="store_true", @@ -350,6 +405,7 @@ def main() -> int: wait_seconds=args.wait_seconds, allow_in_progress_artifact=args.allow_in_progress_artifact, event_name=args.event_name, + required_test_substrings=args.require_test_substring, ) payload = json.dumps(result, sort_keys=True) print("Canonical full-regression reuse PASS: " + payload) From c81f79004010c4ce54040e725f12acedaaf44049 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 14:48:34 +0700 Subject: [PATCH 20/99] ci(p3j-a): capture exact native CTest evidence in sealed Windows package --- .github/workflows/build.yml | 37 ++++- .../workflows/ci-p0-workflow-integrity.yml | 7 + docs/audits/CI_P3J_NATIVE_CTEST_EVIDENCE.md | 30 ++++ scripts/build-ardirec-bridge.ps1 | 21 ++- scripts/test-ci-native-ctest-proof.py | 129 ++++++++++++++ scripts/write-ci-native-ctest-proof.py | 157 ++++++++++++++++++ 6 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 docs/audits/CI_P3J_NATIVE_CTEST_EVIDENCE.md create mode 100644 scripts/test-ci-native-ctest-proof.py create mode 100644 scripts/write-ci-native-ctest-proof.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e56d85819..8dba25d55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -442,17 +442,33 @@ jobs: run: | $bridgeDir = Join-Path $env:RUNNER_TEMP 'arsas-canonical-ardirec-bridge' $bridgeBuild = Join-Path $env:RUNNER_TEMP 'arsas-canonical-ardirec-build' + $ctestJunit = Join-Path $env:RUNNER_TEMP 'arsas-canonical-native-ctest.xml' $bridge = [string]( .\ArIED61850Tester\scripts\build-ardirec-bridge.ps1 ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` -OutputDirectory $bridgeDir ` - -BuildDirectory $bridgeBuild | + -BuildDirectory $bridgeBuild ` + -CTestJunitPath $ctestJunit | Select-Object -Last 1 ) $bridge = $bridge.Trim() if (-not (Test-Path $bridge -PathType Leaf)) { throw "Canonical ArdIrec bridge build did not produce a DLL: $bridge" } + $ctestProof = Join-Path $env:RUNNER_TEMP 'ci-native-ctest-authority.json' + python .\ArIED61850Tester\scripts\write-ci-native-ctest-proof.py ` + --junit $ctestJunit ` + --bridge $bridge ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --run-id $env:GITHUB_RUN_ID ` + --attempt $env:GITHUB_RUN_ATTEMPT ` + --event $env:GITHUB_EVENT_NAME ` + --output $ctestProof + if ($LASTEXITCODE -ne 0) { throw "Canonical native CTest proof sealing failed." } + "NATIVE_CTEST_JUNIT=$ctestJunit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "NATIVE_CTEST_PROOF=$ctestProof" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_BRIDGE_PATH=$bridge" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_BRIDGE_SHA256=$((Get-FileHash $bridge -Algorithm SHA256).Hash.ToLowerInvariant())" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append @@ -547,7 +563,24 @@ jobs: if (Test-Path $verification) { Remove-Item $verification -Recurse -Force } $tests = Join-Path $verification 'ARSAS.Tests' $fixtures = Join-Path $verification 'fixtures' - New-Item -ItemType Directory -Force -Path $tests, $fixtures | Out-Null + $native = Join-Path $verification 'native' + New-Item -ItemType Directory -Force -Path $tests, $fixtures, $native | Out-Null + $stagedCtest = Join-Path $native 'ctest.xml' + $stagedNativeProof = Join-Path $native 'ci-native-ctest-authority.json' + Copy-Item $env:NATIVE_CTEST_JUNIT $stagedCtest -Force + Copy-Item $env:NATIVE_CTEST_PROOF $stagedNativeProof -Force + python .\ArIED61850Tester\scripts\write-ci-native-ctest-proof.py ` + --junit $stagedCtest ` + --bridge $env:ARDIREC_BRIDGE_PATH ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --run-id $env:GITHUB_RUN_ID ` + --attempt $env:GITHUB_RUN_ATTEMPT ` + --event $env:GITHUB_EVENT_NAME ` + --output $stagedNativeProof ` + --verify-only + if ($LASTEXITCODE -ne 0) { throw 'Staged native CTest proof verification failed.' } $testOutput = ".\ArIED61850Tester\tests\ARSAS.Tests\bin\Release\net8.0-windows" if (-not (Test-Path (Join-Path $testOutput 'ARSAS.Tests.dll') -PathType Leaf)) { diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index 656f5ffb4..e68526361 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -15,6 +15,8 @@ on: - "scripts/write-ci-package-manifest.py" - "scripts/verify-ci-package-reuse.py" - "scripts/test-ci-package-reuse.py" + - "scripts/write-ci-native-ctest-proof.py" + - "scripts/test-ci-native-ctest-proof.py" - "scripts/write-ci-installer-manifest.py" - "scripts/verify-ci-installer-reuse.py" - "scripts/test-ci-installer-reuse.py" @@ -36,6 +38,8 @@ on: - "scripts/write-ci-package-manifest.py" - "scripts/verify-ci-package-reuse.py" - "scripts/test-ci-package-reuse.py" + - "scripts/write-ci-native-ctest-proof.py" + - "scripts/test-ci-native-ctest-proof.py" - "scripts/write-ci-installer-manifest.py" - "scripts/verify-ci-installer-reuse.py" - "scripts/test-ci-installer-reuse.py" @@ -79,6 +83,9 @@ jobs: - name: Run canonical CI P3 package safety fixtures run: python3 scripts/test-ci-package-reuse.py + - name: Run CI-P3J native CTest authority safety fixtures + run: python3 scripts/test-ci-native-ctest-proof.py + - name: Run validated installer CI P3D safety fixtures run: python3 scripts/test-ci-installer-reuse.py diff --git a/docs/audits/CI_P3J_NATIVE_CTEST_EVIDENCE.md b/docs/audits/CI_P3J_NATIVE_CTEST_EVIDENCE.md new file mode 100644 index 000000000..a457705e7 --- /dev/null +++ b/docs/audits/CI_P3J_NATIVE_CTEST_EVIDENCE.md @@ -0,0 +1,30 @@ +# CI-P3J-A — Canonical native ArdIrec CTest evidence + +Issue: #470. This is an **evidence instrumentation milestone**, not authorization to delete the independent COMTRADE native test workflow. + +## Baseline and discovery + +Both `Build ARSAS` and `Validate COMTRADE viewer integration` call the same locked `scripts/build-ardirec-bridge.ps1`, which configures Windows x64 Release ArdIrec with native-only / Qt desktop disabled, builds the bridge, then runs CTest. The canonical build also performs managed packaged-bridge/locus integration tests and seals a file-by-file hashed Windows package. Previously, the manifest's `nativeBridgeIntegrationPassed` boolean was not independent machine-readable proof of the individual native CTest cases. + +## CI-P3J-A change + +- Add an **optional** `-CTestJunitPath` parameter to the native build script. With the option set, CTest emits JUnit using the existing x64/Release build. Without the option, existing COMTRADE/manual paths keep their original invocation and test behavior. +- Require native test cases to be present, individually passing, non-skipped, nonduplicated and consistent with suite counters. Reject malformed/unbounded/DTD/entity XML. Do not infer native success from an exit code alone. +- Seal `ci-native-ctest-authority.json` against exact ARSAS, ARIEC61850 and ArdIrec SHA; canonical workflow run/attempt/event; CTest JUnit SHA-256; native bridge SHA-256/size and discovered case names. +- Stage `verification/native/ctest.xml` and `verification/native/ci-native-ctest-authority.json` in the existing immutable Windows package input. Its existing archive file list, sizes and SHA-256 seal binds their exact bytes without adding a workflow/artifact. +- Reverify staged native evidence before canonical package sealing. Add Python offline positive and fail-closed fixtures in the CI-P0 gate. + +## Non-goals and authority limits + +No changes to the ARSAS UI/runtime, ARIEC61850 source/lock, native CMake flags, CTest selection, field tests, COMTRADE independent Windows lane, installer/release promotion rules or existing package verifier schema. + +The new proof has `independentComtradeLaneReplaced=false` and `releasePromotionAuthority=false`. Do **not** treat these files as an independent signed attestation; their authenticity follows the exact successfully verified canonical package run/artifact and its file digests. + +## Follow-up: CI-P3J-B (separately gated) + +1. Confirm actual canonical JUnit test inventory and CTest names from Windows CI. +2. Add a read-only verifier that rejects missing CTest proof, wrong source/engine/ArdIrec/run ID, modified bridge, absent managed COMTRADE/bridge/locus domain tests and failed latest canonical runs. +3. Compare the independent COMTRADE lane against the canonical lane on the same SHA, including native CTest coverage, compiler flags and binary identity. +4. **Only after real parity is green**, consider running pull-request COMTRADE static routing checks on Ubuntu while retaining Windows CMake/CTest for manual diagnosis. Otherwise retain independent Windows CI permanently. + +Rollback: revert the optional CTest JUnit output and canonical verification staging. The native build script default CTest path and all existing release/physical authority remain intact. diff --git a/scripts/build-ardirec-bridge.ps1 b/scripts/build-ardirec-bridge.ps1 index 7fcf4d287..c9c08f869 100644 --- a/scripts/build-ardirec-bridge.ps1 +++ b/scripts/build-ardirec-bridge.ps1 @@ -4,7 +4,9 @@ param( [Parameter(Mandatory = $true)] [string]$OutputDirectory, - [string]$BuildDirectory = "" + [string]$BuildDirectory = "", + + [string]$CTestJunitPath = "" ) $ErrorActionPreference = "Stop" @@ -33,6 +35,10 @@ New-Item -ItemType Directory -Path $output -Force | Out-Null $destinationBridge = Join-Path $output "ardirec_bridge.dll" if (Test-Path $destinationBridge -PathType Leaf) { + if (-not [string]::IsNullOrWhiteSpace($CTestJunitPath) -and + -not (Test-Path $CTestJunitPath -PathType Leaf)) { + throw "Cannot reuse pinned bridge without its requested CTest JUnit evidence." + } Write-Host "==> Reusing pinned ArdIrec bridge: $destinationBridge" Write-Output $destinationBridge exit 0 @@ -97,10 +103,21 @@ if ($LASTEXITCODE -ne 0) { } Write-Host "==> Running ArdIrec core/bridge regression tests" -& ctest --test-dir $build -C Release --output-on-failure +if (-not [string]::IsNullOrWhiteSpace($CTestJunitPath)) { + $ctestOutput = [System.IO.Path]::GetFullPath($CTestJunitPath) + New-Item -ItemType Directory -Force (Split-Path -Parent $ctestOutput) | Out-Null + if (Test-Path $ctestOutput) { Remove-Item $ctestOutput -Force } + & ctest --test-dir $build -C Release --output-on-failure --output-junit $ctestOutput +} else { + & ctest --test-dir $build -C Release --output-on-failure +} if ($LASTEXITCODE -ne 0) { throw "ArdIrec native regression tests failed with exit code $LASTEXITCODE." } +if (-not [string]::IsNullOrWhiteSpace($CTestJunitPath) -and + -not (Test-Path $ctestOutput -PathType Leaf)) { + throw "CTest completed but the requested JUnit evidence was not produced." +} $builtBridge = Join-Path $build "bridge\Release\ardirec_bridge.dll" if (-not (Test-Path $builtBridge -PathType Leaf)) { diff --git a/scripts/test-ci-native-ctest-proof.py b/scripts/test-ci-native-ctest-proof.py new file mode 100644 index 000000000..7f8b50970 --- /dev/null +++ b/scripts/test-ci-native-ctest-proof.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Offline CI-P3J-A native CTest evidence fail-closed fixtures.""" +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +SRC = Path(__file__).with_name("write-ci-native-ctest-proof.py") +spec = importlib.util.spec_from_file_location("native_ctest_proof", SRC) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +GOOD_JUNIT = """ + + + + +""" + +class NativeCTestProofTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.junit = self.root / "ctest.xml" + self.bridge = self.root / "ardirec_bridge.dll" + self.output = self.root / "ci-native-ctest-authority.json" + self.junit.write_text(GOOD_JUNIT, encoding="utf-8") + self.bridge.write_bytes(b"pinned-native-test-bridge") + self.args = dict( + junit=self.junit, bridge=self.bridge, source_sha="a" * 40, + engine_sha="b" * 40, ardirec_sha="c" * 40, run_id=123, + attempt=1, event="pull_request", + ) + + def tearDown(self): + self.tmp.cleanup() + + def seal(self): + return module.write_or_verify(self.output, verify_only=False, **self.args) + + def verify(self): + return module.write_or_verify(self.output, verify_only=True, **self.args) + + def test_exact_native_proof_passes_and_is_nonpromotional(self): + proof = self.seal() + self.assertEqual(proof["nativeTestsPassed"], 2) + self.assertEqual(proof["testNames"], ["BridgeAbi", "LocusGeometry"]) + self.assertFalse(proof["independentComtradeLaneReplaced"]) + self.assertFalse(proof["releasePromotionAuthority"]) + self.assertEqual(self.verify(), proof) + + def test_rejects_missing_native_tests(self): + self.junit.write_text('', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "no test suites|empty"): + self.seal() + + def test_rejects_failed_or_skipped_ctest(self): + for snippet in ( + GOOD_JUNIT.replace('failures="0"', 'failures="1"'), + GOOD_JUNIT.replace('', + ''), + GOOD_JUNIT.replace('skipped="0"', 'skipped="1"'), + GOOD_JUNIT.replace('', + ''), + ): + with self.subTest(snippet=snippet[:80]): + self.junit.write_text(snippet, encoding="utf-8") + with self.assertRaisesRegex(ValueError, "nonzero|failing"): + self.seal() + + def test_rejects_counter_mismatch(self): + self.junit.write_text(GOOD_JUNIT.replace('tests="2"', 'tests="3"'), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "counter disagrees"): + self.seal() + + def test_rejects_duplicate_tests(self): + self.junit.write_text(GOOD_JUNIT.replace("LocusGeometry", "BridgeAbi"), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "duplicate"): + self.seal() + + def test_rejects_tampered_ctest_xml_and_bridge(self): + self.seal() + self.junit.write_text(GOOD_JUNIT.replace("BridgeAbi", "OtherTest"), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "does not match"): + self.verify() + self.junit.write_text(GOOD_JUNIT, encoding="utf-8") + self.bridge.write_bytes(b"tampered-bridge") + with self.assertRaisesRegex(ValueError, "does not match"): + self.verify() + + def test_rejects_stale_identity_or_tampered_manifest(self): + self.seal() + self.args["source_sha"] = "d" * 40 + with self.assertRaisesRegex(ValueError, "does not match"): + self.verify() + self.args["source_sha"] = "a" * 40 + proof = json.loads(self.output.read_text(encoding="utf-8")) + proof["nativeTestsPassed"] = 999 + self.output.write_text(json.dumps(proof), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "does not match"): + self.verify() + + def test_rejects_broken_or_unsafe_xml(self): + for value in ('not xml', ''): + with self.subTest(value=value): + self.junit.write_text(value, encoding="utf-8") + with self.assertRaisesRegex(ValueError, "valid XML|prohibited"): + self.seal() + + def test_canonical_workflow_captures_native_junit_without_replacing_comtrade(self): + root = Path(__file__).resolve().parents[1] + native = (root / "scripts/build-ardirec-bridge.ps1").read_text(encoding="utf-8") + build = (root / ".github/workflows/build.yml").read_text(encoding="utf-8") + comtrade = (root / ".github/workflows/comtrade-viewer-integration.yml").read_text(encoding="utf-8") + for token in ("CTestJunitPath", "--output-junit", "ctest --test-dir"): + self.assertIn(token, native) + for token in ("-CTestJunitPath", "write-ci-native-ctest-proof.py", + "ci-native-ctest-authority.json", "ctest.xml"): + self.assertIn(token, build) + for token in ("Build and test pinned native bridge", "build-ardirec-bridge.ps1", + "Enforce field-tested native-only ARSAS COMTRADE routing"): + self.assertIn(token, comtrade) + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/write-ci-native-ctest-proof.py b/scripts/write-ci-native-ctest-proof.py new file mode 100644 index 000000000..fc46791ad --- /dev/null +++ b/scripts/write-ci-native-ctest-proof.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Seal and reverify canonical ArdIrec CTest/JUnit evidence (CI-P3J-A). + +Evidence capture is NOT permission to skip the independent COMTRADE lane. +The existing Windows package remains the authority for exact tested bytes. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +KIND = "arsas-canonical-ardirec-native-ctest" +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +MAX_JUNIT_BYTES = 2 * 1024 * 1024 +MAX_TESTS = 5000 +PROFILE = "windows-x64-release-native-only-cmake-ctest" + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def parse_ctest_junit(path: Path) -> list[str]: + payload = path.read_bytes() + if not payload or len(payload) > MAX_JUNIT_BYTES: + raise ValueError("CTest JUnit evidence missing or exceeds bounded size") + if b" MAX_TESTS or len(names) != len(set(names)): + raise ValueError("CTest JUnit has empty, duplicate, or excessive test cases") + return sorted(names) + + +def expected_proof( + *, junit: Path, bridge: Path, source_sha: str, engine_sha: str, + ardirec_sha: str, run_id: int, attempt: int, event: str, +) -> dict: + for label, value in ( + ("source", source_sha), ("engine", engine_sha), ("ArdIrec", ardirec_sha) + ): + if not SHA_RE.fullmatch(value): + raise ValueError(f"Invalid {label} SHA") + if run_id < 1 or attempt < 1 or event not in { + "push", "pull_request", "workflow_dispatch" + }: + raise ValueError("Invalid canonical GitHub Actions run identity") + if not bridge.is_file() or bridge.stat().st_size < 1: + raise ValueError("Native bridge is missing or empty") + names = parse_ctest_junit(junit) + return { + "schemaVersion": 1, + "kind": KIND, + "canonicalWorkflow": "Build ARSAS", + "workflowRunId": run_id, + "runAttempt": attempt, + "eventName": event, + "sourceSha": source_sha, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "buildProfile": PROFILE, + "ctestJunitSha256": digest(junit), + "nativeBridgeSha256": digest(bridge), + "nativeBridgeSizeBytes": bridge.stat().st_size, + "testNames": names, + "nativeTestsPassed": len(names), + "nativeTestsFailed": 0, + "nativeTestsSkipped": 0, + "nativeCTestPassed": True, + "independentComtradeLaneReplaced": False, + "releasePromotionAuthority": False, + } + + +def write_or_verify(proof_path: Path, *, verify_only: bool, **kwargs) -> dict: + expected = expected_proof(**kwargs) + if verify_only: + try: + actual = json.loads(proof_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, ValueError) as exc: + raise ValueError("Native CTest proof is missing or invalid") from exc + if actual != expected: + raise ValueError("Native CTest proof does not match exact tested bytes/identity") + else: + proof_path.parent.mkdir(parents=True, exist_ok=True) + proof_path.write_text( + json.dumps(expected, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return expected + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--junit", type=Path, required=True) + parser.add_argument("--bridge", type=Path, required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--ardirec-sha", required=True) + parser.add_argument("--run-id", type=int, required=True) + parser.add_argument("--attempt", type=int, required=True) + parser.add_argument("--event", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--verify-only", action="store_true") + args = parser.parse_args() + proof = write_or_verify( + args.output, verify_only=args.verify_only, junit=args.junit, + bridge=args.bridge, source_sha=args.source_sha, + engine_sha=args.engine_sha, ardirec_sha=args.ardirec_sha, + run_id=args.run_id, attempt=args.attempt, event=args.event, + ) + print( + f"Native CTest proof PASS: {proof['nativeTestsPassed']} tests; " + f"bridge={proof['nativeBridgeSha256']}; " + "independent COMTRADE authority unchanged" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 827bc9aa264fcb16bae0b6503891dcd3d725dd18 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 15:26:28 +0700 Subject: [PATCH 21/99] ci(p3j-b): verify canonical native CTest proof fail-closed in package consumers --- .github/workflows/installer-windows.yml | 1 + .../smart-discovery-capture-build.yml | 1 + .../CI_P3J_B_NATIVE_ARCHIVE_VERIFICATION.md | 34 ++++++++++ scripts/test-ci-package-reuse.py | 18 ++++++ scripts/verify-ci-package-reuse.py | 46 ++++++++++++- scripts/write-ci-native-ctest-proof.py | 64 +++++++++++++++---- 6 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 docs/audits/CI_P3J_B_NATIVE_ARCHIVE_VERIFICATION.md diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index 358580911..c0701d288 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -289,6 +289,7 @@ jobs: --event-name $eventName ` --wait-seconds 1200 ` --allow-in-progress-artifact ` + --require-native-ctest-proof ` --output-dir $packageRoot ` --proof-output $proofPath if ($LASTEXITCODE -ne 0) { diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 60b99ef2b..95fe9fae7 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -320,6 +320,7 @@ jobs: --event-name pull_request ` --wait-seconds 1200 ` --allow-in-progress-artifact ` + --require-native-ctest-proof ` --output-dir $packageRoot ` --proof-output $proofPath if ($LASTEXITCODE -ne 0) { diff --git a/docs/audits/CI_P3J_B_NATIVE_ARCHIVE_VERIFICATION.md b/docs/audits/CI_P3J_B_NATIVE_ARCHIVE_VERIFICATION.md new file mode 100644 index 000000000..79fa2b528 --- /dev/null +++ b/docs/audits/CI_P3J_B_NATIVE_ARCHIVE_VERIFICATION.md @@ -0,0 +1,34 @@ +# CI-P3J-B — Fail-closed verification of sealed native CTest evidence + +Issue: #470. Depends on merged CI-P3J-A PR #472 (canonical CTest JUnit + source/engine/ArdIrec/DLL identity proof). + +## Scope + +Verify the two evidence files staged inside the already sealed \`ARSAS-windows-package-input\` artifact: + +- \`verification/native/ctest.xml\` — executed Windows x64 Release native CTest inventory. +- \`verification/native/ci-native-ctest-authority.json\` — SHA-256 of JUnit and native DLL, source/engine/ArdIrec SHA, run ID/attempt/event, exact test names and no-skips/no-failures verdict. + +After existing archive-level path safety, file count, and per-file SHA-256 checks, the **independent consumer** recomputes the native proof and rejects contradictions. Both files must be present together. New \`--require-native-ctest-proof\` forces their presence for current PR installer and Field Capture package consumers. A missing pair remains readable in default mode solely for historical/manual compatibility. A present but invalid pair always fails, including default-mode release consumption. + +## Safety boundary + +- No app/engine/runtime changes, no removal of the independent Windows COMTRADE CMake/CTest gate. +- No change to the canonical native compiler flags or test set. +- No new workflow, artifact upload, expensive build or runner required; the verifier inspects bytes it already downloads and checks. +- Native evidence has no standalone release promotion authority. The existing exact canonical Build ARSAS artifact and successful run checks remain required. +- Release workflow's regular canonical path verifies native proof **when present**, without adding a new explicit required-presence policy to historical/manual release paths. +- Installer and Field Capture demand the new proof on PR/push canonical paths; manual fallback remains an independent native build. +- A verified CTest proof is **not yet proof that independent COMTRADE and canonical native builds produce identical bytes**. No deduplication authorized. + +## Fail-closed offline fixtures + +- Successful source/engine/ArdIrec/run/bridge/test-inventory agreement. +- Missing native pair rejected in strict mode; partial pair rejected in all modes. +- Rehashed/rewritten proof with wrong source/engine/ArdIrec/run/bridge/authority values rejected. +- Rehashed JUnit containing native failures rejected. +- Byte-level tampering already rejected by the package manifest digests. + +## Next: CI-P3J-C shadow parity + +Capture independently produced COMTRADE native CTest test inventory and native bridge digest on **exact same PR head**, compare to canonical (without conflating source head SHA with GitHub's synthetic merge SHA), measure runner occupancy, and retain independent Windows tests unless proven equivalent. A read-only lightweight proof transport is preferable to re-downloading the 160+ MB sealed Windows package for each comparison. diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py index cbdabad4d..685e07c20 100644 --- a/scripts/test-ci-package-reuse.py +++ b/scripts/test-ci-package-reuse.py @@ -223,6 +223,24 @@ def test_producer_and_verifier_accept_exact_payload(self): manifest["verification"]["releaseFixturePath"], ) + def test_strict_current_consumers_keep_manual_fallback_intact(self): + root = ROOT.parent + installer = (root / ".github/workflows/installer-windows.yml").read_text( + encoding="utf-8" + ) + capture = (root / ".github/workflows/smart-discovery-capture-build.yml").read_text( + encoding="utf-8" + ) + release = (root / ".github/workflows/release-windows.yml").read_text( + encoding="utf-8" + ) + for workflow in (installer, capture): + self.assertIn("--require-native-ctest-proof", workflow) + self.assertIn("--allow-in-progress-artifact", workflow) + self.assertIn("workflow_dispatch", installer) + self.assertIn("workflow_dispatch", capture) + self.assertNotIn("--require-native-ctest-proof", release) + def test_safe_materialization_occurs_after_verification(self): blob, _ = make_payload() with tempfile.TemporaryDirectory() as temp: diff --git a/scripts/verify-ci-package-reuse.py b/scripts/verify-ci-package-reuse.py index 0e207301d..336739d28 100644 --- a/scripts/verify-ci-package-reuse.py +++ b/scripts/verify-ci-package-reuse.py @@ -31,6 +31,13 @@ counters_from_trx = _producer.counters_from_trx sha = _producer.sha +_native_spec = importlib.util.spec_from_file_location( + "arsas_ci_native_ctest", _here / "write-ci-native-ctest-proof.py" +) +assert _native_spec and _native_spec.loader +_native = importlib.util.module_from_spec(_native_spec) +_native_spec.loader.exec_module(_native) + EXPECTED_ARTIFACT = "ARSAS-windows-package-input" MAX_ARCHIVE_BYTES = 768 * 1024 * 1024 MAX_FILE_BYTES = 256 * 1024 * 1024 @@ -168,7 +175,7 @@ def _hash_zip_entry(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> str: def validate_package_archive( payload: bytes, *, source_sha: str, engine_sha: str, ardirec_sha: str, workflow_run_id: int, run_attempt: int, event_name: str, - output_dir: Path | None = None + output_dir: Path | None = None, require_native_ctest_proof: bool = False ) -> dict: if not payload or len(payload) > MAX_ARCHIVE_BYTES: raise PackageProofError("Canonical package artifact is missing or exceeds bounded size") @@ -354,6 +361,34 @@ def validate_package_archive( if not isinstance(path, str) or path not in recorded: raise PackageProofError(f"Canonical package verification path is invalid: {key}") + native_proof = None + junit_path = "verification/native/ctest.xml" + proof_path = "verification/native/ci-native-ctest-authority.json" + has_junit = junit_path in infos + has_proof = proof_path in infos + if has_junit != has_proof: + raise PackageProofError("Native CTest JUnit/proof pair is incomplete") + if require_native_ctest_proof and not has_proof: + raise PackageProofError("Required native CTest archive proof is missing") + if has_proof: + try: + native_proof = _native.verify_sealed_native_proof( + proof_bytes=archive.read(infos[proof_path]), + junit_bytes=archive.read(infos[junit_path]), + bridge_sha256=manifest["nativeBridge"]["sha256"], + bridge_size=manifest["nativeBridge"]["size"], + source_sha=source_sha, + engine_sha=engine_sha, + ardirec_sha=ardirec_sha, + run_id=workflow_run_id, + attempt=run_attempt, + event=event_name, + ) + except (ValueError, KeyError, TypeError) as exc: + raise PackageProofError( + f"Native CTest archive proof is invalid: {exc}" + ) from exc + if output_dir is not None: if output_dir.exists(): shutil.rmtree(output_dir) @@ -365,6 +400,10 @@ def validate_package_archive( shutil.copyfileobj(src, dst, 1024 * 1024) return { + "nativeCtestProofAvailable": native_proof is not None, + "nativeCtestPassed": native_proof is not None and native_proof["nativeCTestPassed"], + "nativeTestNames": native_proof["testNames"] if native_proof else [], + "nativeCtestJunitSha256": native_proof["ctestJunitSha256"] if native_proof else None, "canonicalRunId": workflow_run_id, "canonicalRunAttempt": run_attempt, "sourceSha": source_sha, @@ -444,7 +483,7 @@ def verify_canonical_package( head_sha: str, source_sha: str, engine_sha: str, ardirec_sha: str, event_name: str = "pull_request", wait_seconds: int = 1200, poll_seconds: int = 10, allow_in_progress_artifact: bool = False, - output_dir: Path | None = None + output_dir: Path | None = None, require_native_ctest_proof: bool = False ) -> dict: validate_identity( repository, branch, head_sha, source_sha, engine_sha, ardirec_sha, event_name @@ -502,6 +541,7 @@ def verify_canonical_package( run_attempt=attempt, event_name=event_name, output_dir=output_dir, + require_native_ctest_proof=require_native_ctest_proof, ) except PackageProofError as exc: if "identity or authority is invalid" not in str(exc): @@ -542,6 +582,7 @@ def main() -> int: parser.add_argument("--allow-in-progress-artifact", action="store_true") parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--proof-output", type=Path) + parser.add_argument("--require-native-ctest-proof", action="store_true") args = parser.parse_args() proof = verify_canonical_package( @@ -556,6 +597,7 @@ def main() -> int: wait_seconds=args.wait_seconds, allow_in_progress_artifact=args.allow_in_progress_artifact, output_dir=args.output_dir, + require_native_ctest_proof=args.require_native_ctest_proof, ) payload = json.dumps(proof, sort_keys=True) print("Canonical Windows package reuse PASS: " + payload) diff --git a/scripts/write-ci-native-ctest-proof.py b/scripts/write-ci-native-ctest-proof.py index fc46791ad..5fa2e61b2 100644 --- a/scripts/write-ci-native-ctest-proof.py +++ b/scripts/write-ci-native-ctest-proof.py @@ -28,8 +28,7 @@ def local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] -def parse_ctest_junit(path: Path) -> list[str]: - payload = path.read_bytes() +def parse_ctest_junit_bytes(payload: bytes) -> list[str]: if not payload or len(payload) > MAX_JUNIT_BYTES: raise ValueError("CTest JUnit evidence missing or exceeds bounded size") if b" list[str]: return sorted(names) -def expected_proof( - *, junit: Path, bridge: Path, source_sha: str, engine_sha: str, - ardirec_sha: str, run_id: int, attempt: int, event: str, +def parse_ctest_junit(path: Path) -> list[str]: + return parse_ctest_junit_bytes(path.read_bytes()) + + +def record_from_bytes( + *, junit_bytes: bytes, bridge_sha256: str, bridge_size: int, + source_sha: str, engine_sha: str, ardirec_sha: str, + run_id: int, attempt: int, event: str, ) -> dict: for label, value in ( ("source", source_sha), ("engine", engine_sha), ("ArdIrec", ardirec_sha) ): if not SHA_RE.fullmatch(value): raise ValueError(f"Invalid {label} SHA") + if not isinstance(bridge_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", bridge_sha256): + raise ValueError("Invalid native bridge digest") + if not isinstance(bridge_size, int) or bridge_size <= 0: + raise ValueError("Invalid native bridge size") if run_id < 1 or attempt < 1 or event not in { "push", "pull_request", "workflow_dispatch" }: raise ValueError("Invalid canonical GitHub Actions run identity") - if not bridge.is_file() or bridge.stat().st_size < 1: - raise ValueError("Native bridge is missing or empty") - names = parse_ctest_junit(junit) + names = parse_ctest_junit_bytes(junit_bytes) return { "schemaVersion": 1, "kind": KIND, @@ -95,9 +101,9 @@ def expected_proof( "engineSha": engine_sha, "ardirecSha": ardirec_sha, "buildProfile": PROFILE, - "ctestJunitSha256": digest(junit), - "nativeBridgeSha256": digest(bridge), - "nativeBridgeSizeBytes": bridge.stat().st_size, + "ctestJunitSha256": hashlib.sha256(junit_bytes).hexdigest(), + "nativeBridgeSha256": bridge_sha256, + "nativeBridgeSizeBytes": bridge_size, "testNames": names, "nativeTestsPassed": len(names), "nativeTestsFailed": 0, @@ -108,6 +114,42 @@ def expected_proof( } +def verify_sealed_native_proof( + *, proof_bytes: bytes, junit_bytes: bytes, bridge_sha256: str, bridge_size: int, + source_sha: str, engine_sha: str, ardirec_sha: str, + run_id: int, attempt: int, event: str, +) -> dict: + """Verify sealed archive native evidence independently of its producer.""" + if len(proof_bytes) > 64 * 1024: + raise ValueError("Native CTest proof JSON exceeds bounded size") + try: + actual = json.loads(proof_bytes.decode("utf-8-sig")) + except (UnicodeError, ValueError) as exc: + raise ValueError("Native CTest proof JSON is not valid") from exc + expected = record_from_bytes( + junit_bytes=junit_bytes, bridge_sha256=bridge_sha256, + bridge_size=bridge_size, source_sha=source_sha, engine_sha=engine_sha, + ardirec_sha=ardirec_sha, run_id=run_id, attempt=attempt, event=event, + ) + if actual != expected: + raise ValueError("Native CTest archive proof identity/coverage/digests disagree") + return expected + + +def expected_proof( + *, junit: Path, bridge: Path, source_sha: str, engine_sha: str, + ardirec_sha: str, run_id: int, attempt: int, event: str, +) -> dict: + if not bridge.is_file() or bridge.stat().st_size < 1: + raise ValueError("Native bridge is missing or empty") + return record_from_bytes( + junit_bytes=junit.read_bytes(), bridge_sha256=digest(bridge), + bridge_size=bridge.stat().st_size, source_sha=source_sha, + engine_sha=engine_sha, ardirec_sha=ardirec_sha, + run_id=run_id, attempt=attempt, event=event, + ) + + def write_or_verify(proof_path: Path, *, verify_only: bool, **kwargs) -> dict: expected = expected_proof(**kwargs) if verify_only: From 2ff7848b05471b89c699660703cd4b5daaa3da6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 15:27:20 +0700 Subject: [PATCH 22/99] test(ci-p3j-b): exercise malformed native archive proof and locked consumers --- scripts/test-ci-package-reuse.py | 150 +++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py index 685e07c20..a4e632b96 100644 --- a/scripts/test-ci-package-reuse.py +++ b/scripts/test-ci-package-reuse.py @@ -24,6 +24,7 @@ def load(name: str, filename: str): producer = load("arsas_package_producer", "write-ci-package-manifest.py") verifier = load("arsas_package_verifier", "verify-ci-package-reuse.py") +native = load("arsas_native_ctest", "write-ci-native-ctest-proof.py") SOURCE = "a" * 40 ENGINE = "b" * 40 @@ -118,6 +119,26 @@ def make_payload(event_name: str = "pull_request") -> tuple[bytes, dict]: (fixtures / "p1-release-smoke.cfg").write_text("release-cfg", encoding="utf-8") (fixtures / "p1-release-smoke.dat").write_bytes(b"release-dat") + # CI-P3J-B: native evidence must be bound to the tested bridge and run. + native_dir = verification / "native" + native_dir.mkdir() + native_junit = native_dir / "ctest.xml" + native_junit.write_text( + '' + '' + '' + '', + encoding="utf-8", + ) + native_record = native.expected_proof( + junit=native_junit, bridge=bridge, source_sha=SOURCE, engine_sha=ENGINE, + ardirec_sha=ARDIREC, run_id=RUN, attempt=1, event=event_name, + ) + (native_dir / "ci-native-ctest-authority.json").write_text( + json.dumps(native_record, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + artifact = root / "artifact" manifest = producer.stage_and_create_manifest( artifact_root=artifact, @@ -148,6 +169,37 @@ def make_payload(event_name: str = "pull_request") -> tuple[bytes, dict]: return buffer.getvalue(), manifest +def update_sealed_fixture( + blob: bytes, *, updates: dict[str, bytes | None] +) -> bytes: + """Mutate a test archive AND recalculate its file manifest digests.""" + with zipfile.ZipFile(io.BytesIO(blob)) as source: + names = source.namelist() + raw = {info.filename: source.read(info) for info in source.infolist()} + manifest_name = next(name for name in names if name.endswith("/ci-windows-package-authority.json")) + manifest = json.loads(raw[manifest_name]) + for name, data in updates.items(): + archive_name = next(item for item in names if item.endswith(name)) + if data is None: + raw.pop(archive_name) + manifest["files"] = [ + f for f in manifest["files"] if not archive_name.endswith(f["path"]) + ] + else: + raw[archive_name] = data + entry = next(f for f in manifest["files"] if archive_name.endswith(f["path"])) + entry["size"] = len(data) + entry["sha256"] = hashlib.sha256(data).hexdigest() + manifest["fileCount"] = len(manifest["files"]) + manifest["totalBytes"] = sum(x["size"] for x in manifest["files"]) + raw[manifest_name] = (json.dumps(manifest, sort_keys=True) + "\n").encode() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as dest: + for name, data in raw.items(): + dest.writestr(name, data) + return buffer.getvalue() + + class FakeApi: def __init__( self, @@ -223,6 +275,104 @@ def test_producer_and_verifier_accept_exact_payload(self): manifest["verification"]["releaseFixturePath"], ) + def test_native_archive_verifier_requires_real_ctest_evidence(self): + blob, manifest = make_payload() + proof = verifier.validate_package_archive( + blob, source_sha=SOURCE, engine_sha=ENGINE, ardirec_sha=ARDIREC, + workflow_run_id=RUN, run_attempt=1, event_name="pull_request", + require_native_ctest_proof=True, + ) + self.assertTrue(proof["nativeCtestProofAvailable"]) + self.assertTrue(proof["nativeCtestPassed"]) + self.assertEqual(proof["nativeTestNames"], ["NativeBridgeAbi", "NativeLocus"]) + self.assertEqual(proof["nativeBridgeSha256"], manifest["nativeBridge"]["sha256"]) + + def test_missing_ctest_pair_is_legacy_only_not_authoritative(self): + blob, _ = make_payload() + blob = update_sealed_fixture( + blob, updates={ + "verification/native/ctest.xml": None, + "verification/native/ci-native-ctest-authority.json": None, + }, + ) + legacy = verifier.validate_package_archive( + blob, source_sha=SOURCE, engine_sha=ENGINE, ardirec_sha=ARDIREC, + workflow_run_id=RUN, run_attempt=1, event_name="pull_request", + ) + self.assertFalse(legacy["nativeCtestProofAvailable"]) + with self.assertRaisesRegex(verifier.PackageProofError, "Required native CTest"): + verifier.validate_package_archive( + blob, source_sha=SOURCE, engine_sha=ENGINE, ardirec_sha=ARDIREC, + workflow_run_id=RUN, run_attempt=1, event_name="pull_request", + require_native_ctest_proof=True, + ) + + def test_half_present_native_proof_rejected_even_in_legacy_mode(self): + blob, _ = make_payload() + blob = update_sealed_fixture( + blob, updates={"verification/native/ctest.xml": None}, + ) + with self.assertRaisesRegex(verifier.PackageProofError, "pair is incomplete"): + verifier.validate_package_archive( + blob, source_sha=SOURCE, engine_sha=ENGINE, ardirec_sha=ARDIREC, + workflow_run_id=RUN, run_attempt=1, event_name="pull_request", + ) + + def test_tampered_native_identity_is_rejected_even_with_resealed_file_hash(self): + blob, _ = make_payload() + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + raw = next( + archive.read(x) for x in archive.namelist() + if x.endswith("verification/native/ci-native-ctest-authority.json") + ) + for key, value in ( + ("sourceSha", "f" * 40), + ("engineSha", "f" * 40), + ("ardirecSha", "f" * 40), + ("workflowRunId", 999), + ("nativeBridgeSha256", "0" * 64), + ("nativeCTestPassed", False), + ("releasePromotionAuthority", True), + ("independentComtradeLaneReplaced", True), + ): + with self.subTest(key=key): + value_json = json.loads(raw) + value_json[key] = value + edited = update_sealed_fixture( + blob, + updates={ + "verification/native/ci-native-ctest-authority.json": + json.dumps(value_json).encode(), + }, + ) + with self.assertRaisesRegex(verifier.PackageProofError, "Native CTest archive proof"): + verifier.validate_package_archive( + edited, source_sha=SOURCE, engine_sha=ENGINE, + ardirec_sha=ARDIREC, workflow_run_id=RUN, + run_attempt=1, event_name="pull_request", + ) + + def test_failing_native_junit_rejected_even_when_file_digest_resealed(self): + blob, _ = make_payload() + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + raw = next( + archive.read(x) for x in archive.namelist() + if x.endswith("verification/native/ctest.xml") + ) + edited = update_sealed_fixture( + blob, + updates={ + "verification/native/ctest.xml": + raw.replace(b'failures="0"', b'failures="1"'), + }, + ) + with self.assertRaisesRegex(verifier.PackageProofError, "Native CTest archive proof"): + verifier.validate_package_archive( + edited, source_sha=SOURCE, engine_sha=ENGINE, + ardirec_sha=ARDIREC, workflow_run_id=RUN, + run_attempt=1, event_name="pull_request", + ) + def test_strict_current_consumers_keep_manual_fallback_intact(self): root = ROOT.parent installer = (root / ".github/workflows/installer-windows.yml").read_text( From 4f1d94daa31732924903da20c0e66a1bdb219544 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:16:43 +0700 Subject: [PATCH 23/99] ci(p3j-c): observe exact-source independent native CTest/bridge parity without waiving COMTRADE --- .github/workflows/build.yml | 15 + .../workflows/ci-p0-workflow-integrity.yml | 7 + .../workflows/comtrade-viewer-integration.yml | 47 ++- docs/audits/CI_P3J_C_NATIVE_SHADOW_PARITY.md | 22 ++ evidence/ci-workflow-budget.json | 25 +- scripts/compare-ci-native-shadow.py | 270 ++++++++++++++++++ scripts/test-ci-native-shadow.py | 206 +++++++++++++ 7 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 docs/audits/CI_P3J_C_NATIVE_SHADOW_PARITY.md create mode 100644 scripts/compare-ci-native-shadow.py create mode 100644 scripts/test-ci-native-shadow.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8dba25d55..084aabadf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -467,12 +467,27 @@ jobs: --event $env:GITHUB_EVENT_NAME ` --output $ctestProof if ($LASTEXITCODE -ne 0) { throw "Canonical native CTest proof sealing failed." } + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + $shadowDir = '.\ArIED61850Tester\TestResults\native-shadow' + New-Item -ItemType Directory -Path $shadowDir -Force | Out-Null + Copy-Item $ctestJunit (Join-Path $shadowDir 'ctest.xml') -Force + Copy-Item $ctestProof (Join-Path $shadowDir 'ci-native-ctest-authority.json') -Force + } "NATIVE_CTEST_JUNIT=$ctestJunit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "NATIVE_CTEST_PROOF=$ctestProof" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_BRIDGE_PATH=$bridge" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_BRIDGE_SHA256=$((Get-FileHash $bridge -Algorithm SHA256).Hash.ToLowerInvariant())" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + - name: Upload canonical native CTest shadow evidence + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: ARSAS-native-ctest-shadow + path: ArIED61850Tester/TestResults/native-shadow/ + if-no-files-found: error + retention-days: 14 + - name: Publish canonical installer-input folder shell: powershell run: | diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index e68526361..f12f055ae 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -17,6 +17,8 @@ on: - "scripts/test-ci-package-reuse.py" - "scripts/write-ci-native-ctest-proof.py" - "scripts/test-ci-native-ctest-proof.py" + - "scripts/compare-ci-native-shadow.py" + - "scripts/test-ci-native-shadow.py" - "scripts/write-ci-installer-manifest.py" - "scripts/verify-ci-installer-reuse.py" - "scripts/test-ci-installer-reuse.py" @@ -40,6 +42,8 @@ on: - "scripts/test-ci-package-reuse.py" - "scripts/write-ci-native-ctest-proof.py" - "scripts/test-ci-native-ctest-proof.py" + - "scripts/compare-ci-native-shadow.py" + - "scripts/test-ci-native-shadow.py" - "scripts/write-ci-installer-manifest.py" - "scripts/verify-ci-installer-reuse.py" - "scripts/test-ci-installer-reuse.py" @@ -86,6 +90,9 @@ jobs: - name: Run CI-P3J native CTest authority safety fixtures run: python3 scripts/test-ci-native-ctest-proof.py + - name: Run CI-P3J-C native shadow comparison fixtures + run: python3 scripts/test-ci-native-shadow.py + - name: Run validated installer CI P3D safety fixtures run: python3 scripts/test-ci-installer-reuse.py diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index ef0274e18..ba93a3ea1 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -18,6 +18,10 @@ on: - "tests/ARSAS.Tests/ArdIrecLocusNativeSessionIntegrationTests.cs" - "tests/ARSAS.Tests/Comtrade*.cs" - "scripts/build-ardirec-bridge.ps1" + - "scripts/write-ci-native-ctest-proof.py" + - "scripts/compare-ci-native-shadow.py" + - "scripts/test-ci-native-shadow.py" + - ".github/workflows/build.yml" - "scripts/publish-windows-portable.ps1" - "engines/ARDIREC.lock.json" - "docs/COMTRADE_VIEWER_INTEGRATION.md" @@ -26,6 +30,7 @@ on: permissions: contents: read + actions: read concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} @@ -91,16 +96,19 @@ jobs: shell: powershell run: | $stage = Join-Path $env:RUNNER_TEMP "arsas-native-comtrade" + $nativeJunit = Join-Path $env:RUNNER_TEMP "comtrade-independent-ctest.xml" .\ARSAS\scripts\build-ardirec-bridge.ps1 ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` -OutputDirectory $stage ` - -BuildDirectory "$env:RUNNER_TEMP\ardirec-native-build" + -BuildDirectory "$env:RUNNER_TEMP\ardirec-native-build" ` + -CTestJunitPath $nativeJunit $bridge = Join-Path $stage "ardirec_bridge.dll" if (-not (Test-Path $bridge -PathType Leaf)) { throw "Pinned ArdIrec native bridge was not staged: $bridge" } "NATIVE_BRIDGE_PATH=$bridge" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "INDEPENDENT_CTEST_JUNIT=$nativeJunit" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Enforce field-tested native-only ARSAS COMTRADE routing shell: powershell @@ -148,3 +156,40 @@ jobs: } Write-Host "COMTRADE Open uses the field-tested in-process workstation with Phasor, Harmonics and Locus; no Qt/external ArdIrec process launch remains." + + - name: Advisory same-SHA native CTest and bridge byte shadow comparison + if: github.event_name == 'pull_request' + continue-on-error: true + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + $out = Join-Path $env:RUNNER_TEMP 'ci-p3j-c-native-shadow-parity.json' + python .\ARSAS\scripts\compare-ci-native-shadow.py ` + --head-sha $env:PR_HEAD_SHA ` + --head-branch $env:PR_HEAD_BRANCH ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --independent-junit $env:INDEPENDENT_CTEST_JUNIT ` + --independent-bridge $env:NATIVE_BRIDGE_PATH ` + --wait-seconds 600 ` + --output $out + $verdict = $LASTEXITCODE + "### CI-P3J-C native shadow parity (advisory)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if (Test-Path $out -PathType Leaf) { + $proof = Get-Content $out -Raw | ConvertFrom-Json + "- canonical run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt), status: $($proof.canonicalRunStatus)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + "- CTest case inventory equal: $($proof.ctestInventoryEqual)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + "- Independent vs canonical bridge bytes equal: $($proof.bridgeBytesEqual)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + "- Canonical build completed-success: $($proof.fullCanonicalBuildPassed)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } else { + "- canonical comparison unavailable; no native equivalence may be claimed" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + "- COMTRADE Windows CMake/CTest is retained; this is not release or deduplication authority" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($verdict -ne 0) { + Write-Warning "Advisory CI-P3J-C comparison failed/unavailable; independent native CTest remains mandatory." + exit $verdict + } diff --git a/docs/audits/CI_P3J_C_NATIVE_SHADOW_PARITY.md b/docs/audits/CI_P3J_C_NATIVE_SHADOW_PARITY.md new file mode 100644 index 000000000..3e4a0d2e7 --- /dev/null +++ b/docs/audits/CI_P3J_C_NATIVE_SHADOW_PARITY.md @@ -0,0 +1,22 @@ +# CI-P3J-C — Shadow independent ArdIrec native test/byte parity + +Issue #470. Follows merged PR #472 (native CTest evidence producer) and PR #474 (archive verifier, current installer/Field Capture required proof). + +## Intent +Do not assume running the same CMake command is equivalent to running the same native tests and producing the same DLL. Compare *both* the CTest case inventory and SHA-256 of the independently built native bridge against canonical proof **for one exact source/engine/ArdIrec identity**. A mismatch stays visible; the independent Windows COMTRADE lane is still a required gate. + +## Data flow +1. Canonical Build ARSAS still tests the exact native bridge and seals `verification/native/ctest.xml` plus `ci-native-ctest-authority.json` in the full package. For PRs only, it also uploads a **tiny two-file copy** as `ARSAS-native-ctest-shadow` after CTest, without waiting for package/installer completion. +2. COMTRADE's **existing independent Windows job** now runs CTest with optional JUnit output; CMake flags and exact locked native source stay unchanged. Its existing native-only source routing assertions execute before comparison. +3. The read-only shadow comparer discovers the latest Build ARSAS run for the exact PR head/branch, requires the exact PR synthetic merge SHA and ARIEC61850/ArdIrec SHA in its proof, validates the GitHub artifact ZIP metadata digest and a bounded two-file ZIP, independently checks the JUnit/proof contents, then compares test names and independently built DLL size/hash. +4. A canonical run still in progress is marked **provisional**, not completed-success. No binary reuse, independent-native retirement, installer, release, or field-physical authority follows from provisional or advisory parity. +5. Comparison runs on the **existing COMTRADE Windows job** without new jobs. Its outcome is `continue-on-error` only for the **new advisory comparison step**. Any core native CTest failure and existing native-only routing failure are still hard CI failures. +6. Diagnostics are recorded in the workflow job's step summary. A digest difference or unavailable canonical proof must never yield a "pass" parity claim. + +## CI budget and rollback +One additional tiny artifact upload is deliberately budgeted (`actionsUploadArtifact: 33 -> 34`), with no new workflow, Windows job, restore/build/test, checkout, or runtime dependency. It avoids a second transfer of the existing ~165 MB canonical Windows package on every COMTRADE comparison. The artifact is scoped to PRs with 14-day retention. + +Rollback removes the small canonical native-shadow upload and advisory COMTRADE comparison only. Preserve the independently passing native CMake/CTest, canonical package/installer identity, physical R10 checks and source pinning. + +## Promotion gate after observations +Do not delete the independent COMTRADE Windows build just because an isolated shadow comparison returns equivalent hashes. Inspect several **completed-success** exact-source independent comparisons and runtime/runner efficiency data, reject flaky/unavailable or mismatched cases, and get explicit review before replacing its regression authority. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 6dbe1a7d1..675936cb5 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -24,7 +24,7 @@ "dotnetRestore": 11, "dotnetBuild": 11, "dotnetTest": 12, - "actionsUploadArtifact": 33, + "actionsUploadArtifact": 34, "workflowsWithDotnetBuild": 10, "workflowsWithDotnetTest": 9 }, @@ -668,5 +668,28 @@ "ARSAS-sv-evidence-compiler-log", "ARSAS-io-testing-compiler-log" ] + }, + "ciP3JNativeShadowParity": { + "issueNumber": 470, + "lane": "canonical Build ARSAS / independent COMTRADE Windows", + "approach": "Record two small, SHA-bound CTest files after canonical native build, compare exact PR synthetic merge SHA, PR head/branch, ARIEC61850/ArdIrec pin, test inventory and independently built native bridge SHA-256; keep independent COMTRADE CMake/CTest and routing guards mandatory.", + "staticLimitsChanged": true, + "addedWorkflowFiles": 0, + "addedCheckoutActions": 0, + "addedUploadArtifactActions": 1, + "artifactName": "ARSAS-native-ctest-shadow", + "artifactContents": ["ctest.xml", "ci-native-ctest-authority.json"], + "artifactRetentionDays": 14, + "boundedShadowWaitSeconds": 600, + "crossRunRouting": ["exact PR head SHA", "exact PR branch", "exact synthetic merge source SHA", "engine lock SHA", "ArdIrec lock SHA", "canonical run ID/attempt"], + "comparisonStatus": "advisory", + "provisionalEvidenceDoesNotImplyCompletedCanonicalBuild": true, + "nativeBridgeIdentityDifferenceIsVisibleNotWaived": true, + "independentComtradeWindowsCTestRetained": true, + "sourceRoutingContractRetained": true, + "releaseAuthorityChanged": false, + "physicalAuthorityChanged": false, + "binaryReuseAllowed": false, + "rollback": "Remove the tiny shadow upload and advisory comparison only. Keep canonical package native CTest proof, independent Windows CMake/CTest and all release/physical authority intact." } } diff --git a/scripts/compare-ci-native-shadow.py b/scripts/compare-ci-native-shadow.py new file mode 100644 index 000000000..63a4d62fe --- /dev/null +++ b/scripts/compare-ci-native-shadow.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""CI-P3J-C read-only PR-native COMTRADE/canonical shadow comparison. + +Advisory only: this tool never authorizes dropping independent native CTest. +""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import io +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location( + "arsas_native_shadow_ctest", _ROOT / "write-ci-native-ctest-proof.py" +) +assert _spec and _spec.loader +_native = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_native) + +ARTIFACT = "ARSAS-native-ctest-shadow" +MAX_ZIP = 4 * 1024 * 1024 +JUNIT = "ctest.xml" +PROOF = "ci-native-ctest-authority.json" +NAMES = frozenset((JUNIT, PROOF)) +REPO = "masarray/arsas" + + +class ShadowError(RuntimeError): + pass + + +class SafeRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + redirected = super().redirect_request(request, fp, code, msg, headers, newurl) + if redirected is not None: + before = urllib.parse.urlparse(request.full_url).netloc.lower() + after = urllib.parse.urlparse(newurl).netloc.lower() + if before != after: + redirected.remove_header("Authorization") + return redirected + + +class GitHubReadOnly: + def __init__(self, token: str): + if not token: + raise ShadowError("GITHUB_TOKEN with read-only actions scope is required") + self.token = token + + def get(self, url: str, *, binary: bool = False): + request = urllib.request.Request( + url, headers={ + "Authorization": "Bearer " + self.token, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ARSAS-CI-P3J-C-Native-Shadow", + }, method="GET", + ) + try: + with urllib.request.build_opener(SafeRedirect()).open(request, timeout=30) as result: + limit = MAX_ZIP if binary else 1024 * 1024 + payload = result.read(limit + 1) + if len(payload) > limit: + raise ShadowError("Shadow evidence download exceeds bounded size") + return payload if binary else json.loads(payload) + except (urllib.error.URLError, ValueError) as exc: + raise ShadowError(f"Read-only GitHub shadow evidence request failed: {exc}") from exc + + +def choose_exact_run(payload: dict, *, head_sha: str, head_branch: str) -> dict | None: + runs = payload.get("workflow_runs") + if not isinstance(runs, list): + raise ShadowError("Canonical run list is malformed") + chosen = [ + run for run in runs + if run.get("name") == "Build ARSAS" + and run.get("event") == "pull_request" + and run.get("head_sha") == head_sha + and run.get("head_branch") == head_branch + and isinstance(run.get("id"), int) + and isinstance(run.get("run_attempt"), int) + ] + if not chosen: + return None + # Reruns and retries supersede prior proof on the same PR/head. + return max(chosen, key=lambda run: (run["id"], run["run_attempt"])) + + +def one_artifact(payload: dict, run_id: int) -> dict | None: + entries = payload.get("artifacts") + if not isinstance(entries, list): + raise ShadowError("Canonical artifact list is malformed") + matches = [ + a for a in entries + if a.get("name") == ARTIFACT + and a.get("expired") is False + and a.get("workflow_run", {}).get("id") == run_id + and isinstance(a.get("id"), int) + ] + if len(matches) > 1: + raise ShadowError("Duplicate canonical native CTest shadow artifacts") + return matches[0] if matches else None + + +def parse_small_artifact(blob: bytes, expected_digest: str) -> tuple[bytes, bytes]: + if not blob or len(blob) > MAX_ZIP: + raise ShadowError("Native CTest artifact ZIP missing or oversized") + if not isinstance(expected_digest, str) or not expected_digest.startswith("sha256:"): + raise ShadowError("Canonical artifact SHA-256 metadata missing") + if hashlib.sha256(blob).hexdigest() != expected_digest.removeprefix("sha256:"): + raise ShadowError("Canonical artifact ZIP SHA-256 does not match API metadata") + try: + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + files = [i for i in archive.infolist() if not i.is_dir()] + if {i.filename for i in files} != NAMES or len(files) != 2: + raise ShadowError("Canonical native artifact has missing/extra evidence files") + if any(i.file_size > MAX_ZIP or i.flag_bits & 0x1 for i in files): + raise ShadowError("Canonical native artifact contains unsafe entries") + return archive.read(JUNIT), archive.read(PROOF) + except (zipfile.BadZipFile, RuntimeError, KeyError) as exc: + raise ShadowError("Canonical native shadow artifact is not a readable ZIP") from exc + + +def compare_proofs( + *, junit: bytes, canonical_json: bytes, + independent_junit: Path, independent_bridge: Path, + source_sha: str, engine_sha: str, ardirec_sha: str, + run_id: int, run_attempt: int, event: str, +) -> dict: + try: + claimed = json.loads(canonical_json) + if not isinstance(claimed, dict): + raise ValueError("Native proof JSON must be an object") + canonical = _native.verify_sealed_native_proof( + proof_bytes=canonical_json, junit_bytes=junit, + bridge_sha256=claimed.get("nativeBridgeSha256"), + bridge_size=claimed.get("nativeBridgeSizeBytes"), + source_sha=source_sha, engine_sha=engine_sha, ardirec_sha=ardirec_sha, + run_id=run_id, attempt=run_attempt, event=event, + ) + local_names = _native.parse_ctest_junit(independent_junit) + local_sha = _native.digest(independent_bridge) + local_size = independent_bridge.stat().st_size + except (OSError, ValueError, KeyError, TypeError) as exc: + raise ShadowError(f"Invalid canonical/independent native evidence: {exc}") from exc + inventory_match = local_names == canonical["testNames"] + bytes_match = (local_sha == canonical["nativeBridgeSha256"] + and local_size == canonical["nativeBridgeSizeBytes"]) + return { + "sourceSha": source_sha, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "canonicalRunId": run_id, + "canonicalRunAttempt": run_attempt, + "canonicalTestNames": canonical["testNames"], + "independentTestNames": local_names, + "canonicalBridgeSha256": canonical["nativeBridgeSha256"], + "independentBridgeSha256": local_sha, + "ctestInventoryEqual": inventory_match, + "bridgeBytesEqual": bytes_match, + "shadowParityObserved": inventory_match and bytes_match, + "independentWindowsCTestRetained": True, + "releasePromotionAuthority": False, + "deduplicationAuthorized": False, + } + + +def compare_online( + api, *, head_sha: str, head_branch: str, source_sha: str, + engine_sha: str, ardirec_sha: str, independent_junit: Path, + independent_bridge: Path, wait_seconds: int = 600, poll_seconds: int = 15, +) -> dict: + for label, sha in (("head", head_sha), ("source", source_sha), + ("engine", engine_sha), ("ArdIrec", ardirec_sha)): + if not _native.SHA_RE.fullmatch(sha): + raise ShadowError(f"Invalid exact {label} SHA") + if not head_branch or len(head_branch) > 180 or "\n" in head_branch: + raise ShadowError("Invalid exact PR branch") + if wait_seconds < 0 or wait_seconds > 1200 or poll_seconds < 1: + raise ShadowError("Invalid bounded shadow wait") + base = "https://api.github.com/repos/" + REPO + query = urllib.parse.urlencode({ + "event": "pull_request", "head_sha": head_sha, "per_page": 100, + }) + deadline = time.monotonic() + wait_seconds + last = "canonical run/artifact not ready" + while True: + run = choose_exact_run( + api.get(f"{base}/actions/workflows/build.yml/runs?{query}"), + head_sha=head_sha, head_branch=head_branch, + ) + if run is not None: + status, conclusion = run.get("status"), run.get("conclusion") + last = f"run {run['id']}/{run['run_attempt']} {status}/{conclusion}" + if status == "completed" and conclusion != "success": + raise ShadowError("Latest matching canonical build failed: " + last) + if status in {"in_progress", "completed"}: + art = one_artifact( + api.get(f"{base}/actions/runs/{run['id']}/artifacts?per_page=100"), + run["id"], + ) + if art is not None: + try: + blob = api.get(art["archive_download_url"], binary=True) + except ShadowError as exc: + if "404" not in str(exc): + raise + else: + j, proof = parse_small_artifact(blob, art.get("digest")) + result = compare_proofs( + junit=j, canonical_json=proof, + independent_junit=independent_junit, + independent_bridge=independent_bridge, + source_sha=source_sha, engine_sha=engine_sha, + ardirec_sha=ardirec_sha, + run_id=run["id"], run_attempt=run["run_attempt"], + event="pull_request", + ) + result["canonicalRunStatus"] = status + result["canonicalRunConclusion"] = conclusion + result["fullCanonicalBuildPassed"] = ( + status == "completed" and conclusion == "success" + ) + result["provisional"] = not result["fullCanonicalBuildPassed"] + return result + if time.monotonic() >= deadline: + raise ShadowError(f"Bounded shadow comparison timeout; last={last}") + time.sleep(min(poll_seconds, deadline - time.monotonic())) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--head-sha", required=True) + parser.add_argument("--head-branch", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--ardirec-sha", required=True) + parser.add_argument("--independent-junit", type=Path, required=True) + parser.add_argument("--independent-bridge", type=Path, required=True) + parser.add_argument("--wait-seconds", type=int, default=600) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = compare_online( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + head_sha=args.head_sha, head_branch=args.head_branch, + source_sha=args.source_sha, engine_sha=args.engine_sha, + ardirec_sha=args.ardirec_sha, + independent_junit=args.independent_junit, + independent_bridge=args.independent_bridge, + wait_seconds=args.wait_seconds, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, sort_keys=True, indent=2) + "\n", + encoding="utf-8") + print("CI-P3J-C shadow result: " + json.dumps(result, sort_keys=True)) + if not result["shadowParityObserved"]: + raise ShadowError("Independent native build differs; keep Windows COMTRADE CTest") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-ci-native-shadow.py b/scripts/test-ci-native-shadow.py new file mode 100644 index 000000000..ac0d5c604 --- /dev/null +++ b/scripts/test-ci-native-shadow.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Offline fail-closed and advisory CI-P3J-C native shadow fixtures.""" +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location( + "arsas_shadow", ROOT / "compare-ci-native-shadow.py" +) +assert spec and spec.loader +shadow = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shadow) +spec2 = importlib.util.spec_from_file_location( + "arsas_native_proof_test", ROOT / "write-ci-native-ctest-proof.py" +) +assert spec2 and spec2.loader +native = importlib.util.module_from_spec(spec2) +spec2.loader.exec_module(native) + +HEAD, MERGE, ENGINE, ARDIREC = "d" * 40, "a" * 40, "b" * 40, "c" * 40 +BRANCH = "ci/p3j-c-native-shadow" +RUN = 1234 +XML = b'' + + +def artifact_bytes(*, source=MERGE, bridge=b"canonical-tested-binary", names=XML, + run_id=RUN, event="pull_request") -> tuple[bytes, str]: + proof = native.record_from_bytes( + junit_bytes=names, + bridge_sha256=hashlib.sha256(bridge).hexdigest(), + bridge_size=len(bridge), + source_sha=source, engine_sha=ENGINE, ardirec_sha=ARDIREC, + run_id=run_id, attempt=1, event=event, + ) + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as f: + f.writestr(shadow.JUNIT, names) + f.writestr(shadow.PROOF, json.dumps(proof).encode()) + return output.getvalue(), hashlib.sha256(output.getvalue()).hexdigest() + + +class FakeApi: + def __init__(self, *, blob, digest, run_status="completed", run_conclusion="success", + head=HEAD, source_branch=BRANCH, artifact=True, duplicate=False): + self.blob = blob + self.digest = digest + self.run_status = run_status + self.run_conclusion = run_conclusion + self.head = head + self.branch = source_branch + self.artifact = artifact + self.duplicate = duplicate + + def get(self, url: str, *, binary=False): + if binary: + return self.blob + if "/workflows/build.yml/runs?" in url: + return {"workflow_runs": [{ + "id": RUN, "run_attempt": 1, "name": "Build ARSAS", + "head_sha": self.head, "head_branch": self.branch, + "event": "pull_request", "status": self.run_status, + "conclusion": self.run_conclusion, + }]} + if "/artifacts?" in url: + entries = [] + if self.artifact: + entries = [{ + "id": 77, "name": shadow.ARTIFACT, + "expired": False, + "workflow_run": {"id": RUN}, + "digest": "sha256:" + self.digest, + "archive_download_url": "https://example.invalid/artifact.zip", + }] + if self.duplicate: + entries.append(dict(entries[0], id=78)) + return {"artifacts": entries} + raise RuntimeError("Unexpected fake API request: " + url) + + +class NativeShadowTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.local = self.root / "local-ctest.xml" + self.bridge = self.root / "native.dll" + self.local.write_bytes(XML) + self.bridge.write_bytes(b"canonical-tested-binary") + self.blob, self.digest = artifact_bytes() + self.kwargs = { + "head_sha": HEAD, "head_branch": BRANCH, + "source_sha": MERGE, "engine_sha": ENGINE, "ardirec_sha": ARDIREC, + "independent_junit": self.local, "independent_bridge": self.bridge, + "wait_seconds": 0, + } + + def tearDown(self): + self.tmp.cleanup() + + def test_same_exact_merge_and_native_bits_parity(self): + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **self.kwargs + ) + self.assertTrue(result["shadowParityObserved"]) + self.assertTrue(result["fullCanonicalBuildPassed"]) + self.assertFalse(result["deduplicationAuthorized"]) + self.assertFalse(result["releasePromotionAuthority"]) + self.assertEqual(result["canonicalRunId"], RUN) + + def test_native_dll_mismatch_is_visible_not_approved(self): + self.bridge.write_bytes(b"non-identical-native-dll") + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **self.kwargs + ) + self.assertFalse(result["shadowParityObserved"]) + self.assertFalse(result["bridgeBytesEqual"]) + + def test_native_test_inventory_mismatch_is_visible(self): + self.local.write_bytes(XML.replace(b"Locus", b"Other")) + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **self.kwargs + ) + self.assertFalse(result["shadowParityObserved"]) + self.assertFalse(result["ctestInventoryEqual"]) + + def test_wrong_exact_source_or_engine_is_rejected(self): + for edits in ({"source_sha": "f" * 40}, + {"engine_sha": "f" * 40}, + {"ardirec_sha": "f" * 40}): + with self.subTest(edits=edits): + kw = dict(self.kwargs, **edits) + with self.assertRaisesRegex(shadow.ShadowError, "evidence"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **kw + ) + + def test_wrong_head_sha_is_not_matched_to_old_run(self): + kw = dict(self.kwargs, head_sha="f" * 40) + with self.assertRaisesRegex(shadow.ShadowError, "timeout"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **kw + ) + + def test_rejected_failed_latest_canonical_run(self): + with self.assertRaisesRegex(shadow.ShadowError, "Latest matching"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, + run_status="completed", run_conclusion="failure"), + **self.kwargs, + ) + + def test_in_progress_artifact_is_provisional_not_promotion(self): + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, + run_status="in_progress", run_conclusion=None), + **self.kwargs, + ) + self.assertTrue(result["provisional"]) + self.assertFalse(result["fullCanonicalBuildPassed"]) + self.assertFalse(result["deduplicationAuthorized"]) + + def test_missing_digest_and_modified_archive_are_rejected(self): + with self.assertRaisesRegex(shadow.ShadowError, "metadata missing"): + shadow.parse_small_artifact(self.blob, "") + with self.assertRaisesRegex(shadow.ShadowError, "SHA-256"): + shadow.parse_small_artifact(self.blob, "sha256:" + "0" * 64) + + def test_extra_file_or_duplicate_artifact_is_rejected(self): + with zipfile.ZipFile(io.BytesIO(self.blob)) as source: + data = {x: source.read(x) for x in source.namelist()} + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as dest: + for name, value in data.items(): + dest.writestr(name, value) + dest.writestr("payload.exe", b"unexpected") + payload = buffer.getvalue() + with self.assertRaisesRegex(shadow.ShadowError, "extra evidence"): + shadow.parse_small_artifact( + payload, "sha256:" + hashlib.sha256(payload).hexdigest() + ) + with self.assertRaisesRegex(shadow.ShadowError, "Duplicate"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, duplicate=True), + **self.kwargs, + ) + + def test_ci_preserves_native_independent_ctest_and_advisory_only(self): + comtrade = (ROOT.parent / ".github/workflows/comtrade-viewer-integration.yml").read_text() + canonical = (ROOT.parent / ".github/workflows/build.yml").read_text() + for token in ("-CTestJunitPath", "compare-ci-native-shadow.py", + "continue-on-error: true", "Enforce field-tested native-only"): + self.assertIn(token, comtrade) + self.assertIn("ARSAS-native-ctest-shadow", canonical) + self.assertIn("actions/upload-artifact@v7", canonical) + self.assertIn("Build and test pinned native bridge", comtrade) + + +if __name__ == "__main__": + unittest.main() From d852f28bfc50c45b54de6574f07e527bb5bf685d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:17:39 +0700 Subject: [PATCH 24/99] fix(ci-p3j-c): keep explicit shadow ZIP safety failures intact --- scripts/compare-ci-native-shadow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/compare-ci-native-shadow.py b/scripts/compare-ci-native-shadow.py index 63a4d62fe..87b4a2e02 100644 --- a/scripts/compare-ci-native-shadow.py +++ b/scripts/compare-ci-native-shadow.py @@ -125,7 +125,7 @@ def parse_small_artifact(blob: bytes, expected_digest: str) -> tuple[bytes, byte if any(i.file_size > MAX_ZIP or i.flag_bits & 0x1 for i in files): raise ShadowError("Canonical native artifact contains unsafe entries") return archive.read(JUNIT), archive.read(PROOF) - except (zipfile.BadZipFile, RuntimeError, KeyError) as exc: + except (zipfile.BadZipFile, OSError, KeyError, EOFError) as exc: raise ShadowError("Canonical native shadow artifact is not a readable ZIP") from exc From 4fbe914eadf00cbdacb360d1e07dfef720a4c748 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:19:06 +0700 Subject: [PATCH 25/99] fix(ci-p3j-c): resolve exact engine lock SHA for independent native comparison --- .../workflows/comtrade-viewer-integration.yml | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index ba93a3ea1..503ed863f 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -77,6 +77,128 @@ jobs: } } + $engineLock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json + if ($engineLock.commit -notmatch '^[0-9a-f]{40} + "ARDIREC_REF=$($lock.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout immutable ArdIrec revision + shell: powershell + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec + git -C .\ArdIrec fetch --quiet --depth 1 origin $env:ARDIREC_COMMIT + git -C .\ArdIrec checkout --quiet --detach $env:ARDIREC_COMMIT + $actual = (git -C .\ArdIrec rev-parse HEAD).Trim() + if ($actual -ne $env:ARDIREC_COMMIT) { + throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT, got $actual." + } + + - name: Build and test pinned native bridge + shell: powershell + run: | + $stage = Join-Path $env:RUNNER_TEMP "arsas-native-comtrade" + $nativeJunit = Join-Path $env:RUNNER_TEMP "comtrade-independent-ctest.xml" + .\ARSAS\scripts\build-ardirec-bridge.ps1 ` + -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` + -OutputDirectory $stage ` + -BuildDirectory "$env:RUNNER_TEMP\ardirec-native-build" ` + -CTestJunitPath $nativeJunit + + $bridge = Join-Path $stage "ardirec_bridge.dll" + if (-not (Test-Path $bridge -PathType Leaf)) { + throw "Pinned ArdIrec native bridge was not staged: $bridge" + } + "NATIVE_BRIDGE_PATH=$bridge" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "INDEPENDENT_CTEST_JUNIT=$nativeJunit" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Enforce field-tested native-only ARSAS COMTRADE routing + shell: powershell + run: | + $openPath = ".\ARSAS\FaultRecordWindow.ComtradeOpen.cs" + $launcherPath = ".\ARSAS\Services\ArdIrecViewerLauncher.cs" + $bridgePath = ".\ARSAS\Services\ArdIrecNativeBridge.cs" + $bootstrapPath = ".\ARSAS\Services\ArdIrecEmbeddedBridgeBootstrap.cs" + $projectPath = ".\ARSAS\ArIED61850Tester.csproj" + $workspacePath = ".\ARSAS\ComtradeWorkspaceWindow.xaml" + $locusViewPath = ".\ARSAS\Controls\ComtradeLocusView.cs" + + $open = Get-Content $openPath -Raw + $launcher = Get-Content $launcherPath -Raw + $bridge = Get-Content $bridgePath -Raw + $bootstrap = Get-Content $bootstrapPath -Raw + $project = Get-Content $projectPath -Raw + $workspace = Get-Content $workspacePath -Raw + $locusView = Get-Content $locusViewPath -Raw + + if ($open -notmatch 'ArdIrecNativeBridge\.TryOpen' -or + $open -notmatch 'ComtradeWorkspaceWindow' -or + $open -match 'Process\.Start|TryLaunch\(') { + throw "Fault-record Open must route only to the in-process native COMTRADE workspace." + } + + if ($launcher -match 'Process\.Start|ProcessStartInfo|ardirec\.exe|ARSAS_ARDIREC_PATH|ARDIREC_VIEWER_PATH|TryLaunch\(') { + throw "External ArdIrec process-launch compatibility code is not allowed." + } + + if ($bridge -notmatch 'ardirec_bridge\.dll' -or + $bridge -notmatch 'ExpectedAbiVersion = 1' -or + $bridge -notmatch 'Harmonic' -or + $bridge -notmatch 'Distance' -or + $bootstrap -notmatch 'ArIED61850Tester\.Native\.ardirec_bridge\.dll' -or + $project -notmatch 'ArIED61850Tester\.Native\.ardirec_bridge\.dll' -or + $project -notmatch 'Tools\\ArdIrec\\ardirec_bridge\.dll') { + throw "Field-tested native ArdIrec analysis/packaging contract is incomplete." + } + + if ($workspace -notmatch 'ComtradePhasorView' -or + $workspace -notmatch 'ComtradeHarmonicsWorkstationView' -or + $locusView -notmatch 'class\s+ComtradeLocusView') { + throw "Production COMTRADE Phasor, Harmonics or Locus analysis surface is missing." + } + + Write-Host "COMTRADE Open uses the field-tested in-process workstation with Phasor, Harmonics and Locus; no Qt/external ArdIrec process launch remains." + + - name: Advisory same-SHA native CTest and bridge byte shadow comparison + if: github.event_name == 'pull_request' + continue-on-error: true + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + $out = Join-Path $env:RUNNER_TEMP 'ci-p3j-c-native-shadow-parity.json' + python .\ARSAS\scripts\compare-ci-native-shadow.py ` + --head-sha $env:PR_HEAD_SHA ` + --head-branch $env:PR_HEAD_BRANCH ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --independent-junit $env:INDEPENDENT_CTEST_JUNIT ` + --independent-bridge $env:NATIVE_BRIDGE_PATH ` + --wait-seconds 600 ` + --output $out + $verdict = $LASTEXITCODE + "### CI-P3J-C native shadow parity (advisory)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if (Test-Path $out -PathType Leaf) { + $proof = Get-Content $out -Raw | ConvertFrom-Json + "- canonical run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt), status: $($proof.canonicalRunStatus)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + "- CTest case inventory equal: $($proof.ctestInventoryEqual)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + "- Independent vs canonical bridge bytes equal: $($proof.bridgeBytesEqual)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + "- Canonical build completed-success: $($proof.fullCanonicalBuildPassed)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } else { + "- canonical comparison unavailable; no native equivalence may be claimed" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + "- COMTRADE Windows CMake/CTest is retained; this is not release or deduplication authority" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($verdict -ne 0) { + Write-Warning "Advisory CI-P3J-C comparison failed/unavailable; independent native CTest remains mandatory." + exit $verdict + } +) { + throw "Canonical shadow comparison needs an exact ARIEC61850 engine SHA." + } + "ARIEC61850_COMMIT=$($engineLock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_REF=$($lock.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append From 903ed4cd13351eacb20142a2a54d0ed4669f08e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:19:23 +0700 Subject: [PATCH 26/99] test(ci-p3j-c): lock exact cross-lane engine identity in independent shadow --- scripts/test-ci-native-shadow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/test-ci-native-shadow.py b/scripts/test-ci-native-shadow.py index ac0d5c604..94e5170e1 100644 --- a/scripts/test-ci-native-shadow.py +++ b/scripts/test-ci-native-shadow.py @@ -200,6 +200,8 @@ def test_ci_preserves_native_independent_ctest_and_advisory_only(self): self.assertIn("ARSAS-native-ctest-shadow", canonical) self.assertIn("actions/upload-artifact@v7", canonical) self.assertIn("Build and test pinned native bridge", comtrade) + self.assertIn("ARIEC61850_COMMIT=$($engineLock.commit)", comtrade) + self.assertIn("engines\\ARIEC61850.lock.json", comtrade) if __name__ == "__main__": From 8e978a93cbacf63e0148ff5ea18b2cab26d94bcc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:20:13 +0700 Subject: [PATCH 27/99] fix(ci-p3j-c): restore single valid COMTRADE workflow with exact engine lock --- .../workflows/comtrade-viewer-integration.yml | 118 +----------------- 1 file changed, 1 insertion(+), 117 deletions(-) diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index 503ed863f..f004cd900 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -78,123 +78,7 @@ jobs: } $engineLock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json - if ($engineLock.commit -notmatch '^[0-9a-f]{40} - "ARDIREC_REF=$($lock.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "ARDIREC_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Checkout immutable ArdIrec revision - shell: powershell - run: | - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec - git -C .\ArdIrec fetch --quiet --depth 1 origin $env:ARDIREC_COMMIT - git -C .\ArdIrec checkout --quiet --detach $env:ARDIREC_COMMIT - $actual = (git -C .\ArdIrec rev-parse HEAD).Trim() - if ($actual -ne $env:ARDIREC_COMMIT) { - throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT, got $actual." - } - - - name: Build and test pinned native bridge - shell: powershell - run: | - $stage = Join-Path $env:RUNNER_TEMP "arsas-native-comtrade" - $nativeJunit = Join-Path $env:RUNNER_TEMP "comtrade-independent-ctest.xml" - .\ARSAS\scripts\build-ardirec-bridge.ps1 ` - -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` - -OutputDirectory $stage ` - -BuildDirectory "$env:RUNNER_TEMP\ardirec-native-build" ` - -CTestJunitPath $nativeJunit - - $bridge = Join-Path $stage "ardirec_bridge.dll" - if (-not (Test-Path $bridge -PathType Leaf)) { - throw "Pinned ArdIrec native bridge was not staged: $bridge" - } - "NATIVE_BRIDGE_PATH=$bridge" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "INDEPENDENT_CTEST_JUNIT=$nativeJunit" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Enforce field-tested native-only ARSAS COMTRADE routing - shell: powershell - run: | - $openPath = ".\ARSAS\FaultRecordWindow.ComtradeOpen.cs" - $launcherPath = ".\ARSAS\Services\ArdIrecViewerLauncher.cs" - $bridgePath = ".\ARSAS\Services\ArdIrecNativeBridge.cs" - $bootstrapPath = ".\ARSAS\Services\ArdIrecEmbeddedBridgeBootstrap.cs" - $projectPath = ".\ARSAS\ArIED61850Tester.csproj" - $workspacePath = ".\ARSAS\ComtradeWorkspaceWindow.xaml" - $locusViewPath = ".\ARSAS\Controls\ComtradeLocusView.cs" - - $open = Get-Content $openPath -Raw - $launcher = Get-Content $launcherPath -Raw - $bridge = Get-Content $bridgePath -Raw - $bootstrap = Get-Content $bootstrapPath -Raw - $project = Get-Content $projectPath -Raw - $workspace = Get-Content $workspacePath -Raw - $locusView = Get-Content $locusViewPath -Raw - - if ($open -notmatch 'ArdIrecNativeBridge\.TryOpen' -or - $open -notmatch 'ComtradeWorkspaceWindow' -or - $open -match 'Process\.Start|TryLaunch\(') { - throw "Fault-record Open must route only to the in-process native COMTRADE workspace." - } - - if ($launcher -match 'Process\.Start|ProcessStartInfo|ardirec\.exe|ARSAS_ARDIREC_PATH|ARDIREC_VIEWER_PATH|TryLaunch\(') { - throw "External ArdIrec process-launch compatibility code is not allowed." - } - - if ($bridge -notmatch 'ardirec_bridge\.dll' -or - $bridge -notmatch 'ExpectedAbiVersion = 1' -or - $bridge -notmatch 'Harmonic' -or - $bridge -notmatch 'Distance' -or - $bootstrap -notmatch 'ArIED61850Tester\.Native\.ardirec_bridge\.dll' -or - $project -notmatch 'ArIED61850Tester\.Native\.ardirec_bridge\.dll' -or - $project -notmatch 'Tools\\ArdIrec\\ardirec_bridge\.dll') { - throw "Field-tested native ArdIrec analysis/packaging contract is incomplete." - } - - if ($workspace -notmatch 'ComtradePhasorView' -or - $workspace -notmatch 'ComtradeHarmonicsWorkstationView' -or - $locusView -notmatch 'class\s+ComtradeLocusView') { - throw "Production COMTRADE Phasor, Harmonics or Locus analysis surface is missing." - } - - Write-Host "COMTRADE Open uses the field-tested in-process workstation with Phasor, Harmonics and Locus; no Qt/external ArdIrec process launch remains." - - - name: Advisory same-SHA native CTest and bridge byte shadow comparison - if: github.event_name == 'pull_request' - continue-on-error: true - shell: powershell - env: - GITHUB_TOKEN: ${{ github.token }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} - run: | - $out = Join-Path $env:RUNNER_TEMP 'ci-p3j-c-native-shadow-parity.json' - python .\ARSAS\scripts\compare-ci-native-shadow.py ` - --head-sha $env:PR_HEAD_SHA ` - --head-branch $env:PR_HEAD_BRANCH ` - --source-sha $env:GITHUB_SHA ` - --engine-sha $env:ARIEC61850_COMMIT ` - --ardirec-sha $env:ARDIREC_COMMIT ` - --independent-junit $env:INDEPENDENT_CTEST_JUNIT ` - --independent-bridge $env:NATIVE_BRIDGE_PATH ` - --wait-seconds 600 ` - --output $out - $verdict = $LASTEXITCODE - "### CI-P3J-C native shadow parity (advisory)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - if (Test-Path $out -PathType Leaf) { - $proof = Get-Content $out -Raw | ConvertFrom-Json - "- canonical run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt), status: $($proof.canonicalRunStatus)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - "- CTest case inventory equal: $($proof.ctestInventoryEqual)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - "- Independent vs canonical bridge bytes equal: $($proof.bridgeBytesEqual)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - "- Canonical build completed-success: $($proof.fullCanonicalBuildPassed)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } else { - "- canonical comparison unavailable; no native equivalence may be claimed" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - "- COMTRADE Windows CMake/CTest is retained; this is not release or deduplication authority" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - if ($verdict -ne 0) { - Write-Warning "Advisory CI-P3J-C comparison failed/unavailable; independent native CTest remains mandatory." - exit $verdict - } -) { + if ($engineLock.commit -notmatch '^[0-9a-f]{40}$') { throw "Canonical shadow comparison needs an exact ARIEC61850 engine SHA." } "ARIEC61850_COMMIT=$($engineLock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append From ced417918cb44b1f6058ba8370566a1d4f6116f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:20:29 +0700 Subject: [PATCH 28/99] test(ci-p3j-c): reject duplicated COMTRADE workflow blocks --- scripts/test-ci-native-shadow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/test-ci-native-shadow.py b/scripts/test-ci-native-shadow.py index 94e5170e1..872d19d8b 100644 --- a/scripts/test-ci-native-shadow.py +++ b/scripts/test-ci-native-shadow.py @@ -200,6 +200,9 @@ def test_ci_preserves_native_independent_ctest_and_advisory_only(self): self.assertIn("ARSAS-native-ctest-shadow", canonical) self.assertIn("actions/upload-artifact@v7", canonical) self.assertIn("Build and test pinned native bridge", comtrade) + self.assertEqual(comtrade.count("name: Validate COMTRADE viewer integration"), 1) + self.assertEqual(comtrade.count("name: Advisory same-SHA native"), 1) + self.assertEqual(comtrade.count("name: Checkout immutable ArdIrec revision"), 1) self.assertIn("ARIEC61850_COMMIT=$($engineLock.commit)", comtrade) self.assertIn("engines\\ARIEC61850.lock.json", comtrade) From 62479064ea0fb248abf2675c7990b71967fbfc89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:43:59 +0700 Subject: [PATCH 29/99] ci(p3j-d): move native parity waiting off required Windows COMTRADE runner --- .../workflows/comtrade-viewer-integration.yml | 122 +++++++++++++----- .../audits/CI_P3J_D_UBUNTU_SHADOW_OBSERVER.md | 33 +++++ evidence/ci-workflow-budget.json | 50 ++++++- scripts/test-ci-native-shadow.py | 21 +++ 4 files changed, 193 insertions(+), 33 deletions(-) create mode 100644 docs/audits/CI_P3J_D_UBUNTU_SHADOW_OBSERVER.md diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index f004cd900..c6789265d 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -163,39 +163,103 @@ jobs: Write-Host "COMTRADE Open uses the field-tested in-process workstation with Phasor, Harmonics and Locus; no Qt/external ArdIrec process launch remains." - - name: Advisory same-SHA native CTest and bridge byte shadow comparison + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + $nativeInputs = ".\ARSAS\TestResults\native-shadow-independent" + New-Item -ItemType Directory -Force -Path $nativeInputs | Out-Null + Copy-Item $env:INDEPENDENT_CTEST_JUNIT (Join-Path $nativeInputs "ctest.xml") -Force + Copy-Item $env:NATIVE_BRIDGE_PATH (Join-Path $nativeInputs "ardirec_bridge.dll") -Force + if (-not (Test-Path (Join-Path $nativeInputs "ctest.xml") -PathType Leaf) -or + -not (Test-Path (Join-Path $nativeInputs "ardirec_bridge.dll") -PathType Leaf)) { + throw "Independently tested COMTRADE native evidence could not be staged." + } + } + + + + - name: Upload independent tested COMTRADE native inputs for shadow comparison if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: ARSAS-independent-native-ctest-shadow + path: ARSAS/TestResults/native-shadow-independent/ + if-no-files-found: error + retention-days: 14 + + native-shadow-observer: + name: Advisory native byte and CTest parity (Ubuntu, no native rebuild) + if: github.event_name == 'pull_request' + needs: windows-viewer-integration + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout exact PR synthetic merge source + uses: actions/checkout@v7 + with: + path: ARSAS + persist-credentials: false + + - name: Download independently tested COMTRADE DLL and JUnit + uses: actions/download-artifact@v8 + with: + name: ARSAS-independent-native-ctest-shadow + path: native-input + + - name: Verify independently built native inputs and source locks + shell: bash + run: | + test -s native-input/ardirec_bridge.dll + test -s native-input/ctest.xml + python3 - <<'PY' + import json + from pathlib import Path + for name in ('ARIEC61850', 'ARDIREC'): + lock = json.loads(Path(f'ARSAS/engines/{name}.lock.json').read_text()) + value = lock.get('commit') + if not isinstance(value, str) or len(value) != 40 or any( + c not in '0123456789abcdef' for c in value + ): + raise SystemExit(f'Invalid exact {name} lock SHA') + PY + + - name: Advisory exact-SHA native comparison (never deduplication authority) continue-on-error: true - shell: powershell + shell: bash env: GITHUB_TOKEN: ${{ github.token }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} run: | - $out = Join-Path $env:RUNNER_TEMP 'ci-p3j-c-native-shadow-parity.json' - python .\ARSAS\scripts\compare-ci-native-shadow.py ` - --head-sha $env:PR_HEAD_SHA ` - --head-branch $env:PR_HEAD_BRANCH ` - --source-sha $env:GITHUB_SHA ` - --engine-sha $env:ARIEC61850_COMMIT ` - --ardirec-sha $env:ARDIREC_COMMIT ` - --independent-junit $env:INDEPENDENT_CTEST_JUNIT ` - --independent-bridge $env:NATIVE_BRIDGE_PATH ` - --wait-seconds 600 ` - --output $out - $verdict = $LASTEXITCODE - "### CI-P3J-C native shadow parity (advisory)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - if (Test-Path $out -PathType Leaf) { - $proof = Get-Content $out -Raw | ConvertFrom-Json - "- canonical run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt), status: $($proof.canonicalRunStatus)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - "- CTest case inventory equal: $($proof.ctestInventoryEqual)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - "- Independent vs canonical bridge bytes equal: $($proof.bridgeBytesEqual)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - "- Canonical build completed-success: $($proof.fullCanonicalBuildPassed)" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } else { - "- canonical comparison unavailable; no native equivalence may be claimed" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - "- COMTRADE Windows CMake/CTest is retained; this is not release or deduplication authority" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - if ($verdict -ne 0) { - Write-Warning "Advisory CI-P3J-C comparison failed/unavailable; independent native CTest remains mandatory." - exit $verdict - } + ENGINE_SHA="$(python3 -c 'import json;print(json.load(open("ARSAS/engines/ARIEC61850.lock.json"))["commit"])')" + ARDIREC_SHA="$(python3 -c 'import json;print(json.load(open("ARSAS/engines/ARDIREC.lock.json"))["commit"])')" + set +e + python3 ARSAS/scripts/compare-ci-native-shadow.py \ + --head-sha "$PR_HEAD_SHA" \ + --head-branch "$PR_HEAD_BRANCH" \ + --source-sha "$GITHUB_SHA" \ + --engine-sha "$ENGINE_SHA" \ + --ardirec-sha "$ARDIREC_SHA" \ + --independent-junit native-input/ctest.xml \ + --independent-bridge native-input/ardirec_bridge.dll \ + --wait-seconds 600 \ + --output native-shadow-parity.json + verdict=$? + set -e + { + echo '### CI-P3J-D native parity observer (advisory only)' + if [ -f native-shadow-parity.json ]; then + python3 - <<'PY' + import json + p = json.load(open('native-shadow-parity.json')) + print(f"- Canonical run: {p['canonicalRunId']}/{p['canonicalRunAttempt']} ({p['canonicalRunStatus']})") + print(f"- Native CTest case inventory identical: {p['ctestInventoryEqual']}") + print(f"- Independently tested native DLL SHA-256 identical: {p['bridgeBytesEqual']}") + print(f"- Canonical completed-success: {p['fullCanonicalBuildPassed']}") + print(f"- Provisional: {p['provisional']}") + PY + else + echo '- Canonical parity unavailable; no native equivalence may be claimed' + fi + echo '- Independent Windows CMake/CTest and native-only route guards remain mandatory' + echo '- This observer does not authorize binary reuse or release promotion' + } >> "$GITHUB_STEP_SUMMARY" + exit "$verdict" diff --git a/docs/audits/CI_P3J_D_UBUNTU_SHADOW_OBSERVER.md b/docs/audits/CI_P3J_D_UBUNTU_SHADOW_OBSERVER.md new file mode 100644 index 000000000..2c3b41f73 --- /dev/null +++ b/docs/audits/CI_P3J_D_UBUNTU_SHADOW_OBSERVER.md @@ -0,0 +1,33 @@ +# CI-P3J-D — Free Windows runner after independent COMTRADE CTest + +Issue: #470. Prerequisite: merged PR #475 proved a real same-PR native JUnit inventory and SHA-256 bridge parity while COMTRADE's independent Windows build and source guards stayed mandatory. + +## Observed cost (not modeled savings) + +GitHub Actions **run 37755975584** (October 8, 2026) on exact PR #475: +- Windows job started 09:21:44 UTC and completed 09:25:41 UTC (**237 seconds**). +- Native CMake configure + compile + CTest: 09:21:58–09:23:05 (**67 seconds**). +- The advisory wait/compare step: 09:23:05–09:25:37 (**152 seconds**), before the canonical build fully completed. +- Canonical **Build ARSAS** run 37755975609 ultimately passed; independent/native CTest inventory was **9/9 equal** and bridge SHA-256 was **fa8efcaf26e8ebb0a29808a2e52ad5c9f466ec2df146945d29c5d662987c41ce**. +- The 152-second wait monopolized a Windows runner only to poll and compare small read-only evidence. Optimization targets this wait **not the required independent native tests**. + +## Changes + +1. On PR, the required Windows COMTRADE job performs the same pinned CMake build, independent JUnit-emitting CTest, and native-only routing guard. It stages **the actual independently tested** `ctest.xml` plus `ardirec_bridge.dll`, then uploads one small artifact `ARSAS-independent-native-ctest-shadow`. On `workflow_dispatch`, it still independently compiles/tests and enforces routing; no shadow upload. +2. A new **Ubuntu-only observer job** depends on the required Windows job and runs only on PR events. It checks out the exact candidate, downloads those two independent native inputs from **its own workflow run**, verifies they are present, reads the locked engine and ArdIrec SHAs, and calls the unchanged read-only GitHub canonical shadow comparer (bounded 600 seconds). +3. Its only comparison step is marked `continue-on-error: true`; a mismatch or unavailable canonical proof is never treated as established parity and is called out in the job summary. Missing independent inputs are a **hard observer job failure**. The required Windows job never becomes advisory. +4. No new native compilation, no release/installer authority, no runtime/engine code, no changes to physical R10, and no permission to retire native CTest. + +## CI budget and multi-thread safety + +Explicit tracked topology delta: **one lightweight Ubuntu job**, one checkout, one small artifact upload (`actionsCheckout 36→37`, `actionsUploadArtifact 34→35`). The code does not add Windows jobs, workflow files, CMake calls, or .NET restore/build/test. Observer uses the same PR-specific concurrency group and is cancelled on superseding PR revisions; push/main and manual workflows retain unique-run groups. The GitHub API comparer requires exact PR head/branch, synthetic source merge SHA, engine/ArdIrec SHA, canonical run ID/attempt and tested native hashes. It cannot consume cross-PR or older branch evidence as matching current SHA. + +This is expected to release the Windows runner sooner, but **actual after-change timings must be measured**. Total elapsed PR wall-clock may remain dominated by canonical Build ARSAS; reducing Windows occupancy does not imply a faster overall result. + +## Rollback + +Reinstate the advisory comparison in the same Windows job, remove the Ubuntu observer and independent native shadow input upload, restore topology budget counts. Independent native Windows CMake/CTest and source guards must remain unchanged in either direction. + +## Later promotion gate + +Multiple completed-success native proof parity observations and explicit COMTRADE managed native test-family evidence are prerequisites before eliminating independent CMake/CTest on PR. The present milestone **does not** authorize that change. \ No newline at end of file diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 675936cb5..eb1522cd3 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -18,13 +18,13 @@ }, "limits": { "workflowFiles": 31, - "actionsCheckout": 36, + "actionsCheckout": 37, "actionsSetupDotnet": 11, "actionsSetupPython": 19, "dotnetRestore": 11, "dotnetBuild": 11, "dotnetTest": 12, - "actionsUploadArtifact": 34, + "actionsUploadArtifact": 35, "workflowsWithDotnetBuild": 10, "workflowsWithDotnetTest": 9 }, @@ -678,10 +678,20 @@ "addedCheckoutActions": 0, "addedUploadArtifactActions": 1, "artifactName": "ARSAS-native-ctest-shadow", - "artifactContents": ["ctest.xml", "ci-native-ctest-authority.json"], + "artifactContents": [ + "ctest.xml", + "ci-native-ctest-authority.json" + ], "artifactRetentionDays": 14, "boundedShadowWaitSeconds": 600, - "crossRunRouting": ["exact PR head SHA", "exact PR branch", "exact synthetic merge source SHA", "engine lock SHA", "ArdIrec lock SHA", "canonical run ID/attempt"], + "crossRunRouting": [ + "exact PR head SHA", + "exact PR branch", + "exact synthetic merge source SHA", + "engine lock SHA", + "ArdIrec lock SHA", + "canonical run ID/attempt" + ], "comparisonStatus": "advisory", "provisionalEvidenceDoesNotImplyCompletedCanonicalBuild": true, "nativeBridgeIdentityDifferenceIsVisibleNotWaived": true, @@ -691,5 +701,37 @@ "physicalAuthorityChanged": false, "binaryReuseAllowed": false, "rollback": "Remove the tiny shadow upload and advisory comparison only. Keep canonical package native CTest proof, independent Windows CMake/CTest and all release/physical authority intact." + }, + "ciP3JNativeShadowObserver": { + "issueNumber": 470, + "lane": "independent COMTRADE native Windows and read-only Ubuntu observer", + "approach": "Keep independent pinned Windows CMake/CTest and native-only route gate mandatory; stage its actual DLL/JUnit only on PR, then move the existing wait-for-canonical-native-shadow and hash/test-inventory comparison to lightweight Ubuntu after Windows job succeeds.", + "measurement": { + "referenceRun": "37755975584", + "nativeBuildTestSeconds": 67, + "shadowComparisonWaitSeconds": 152, + "independentWindowsJobSeconds": 237, + "observedUtc": "2026-10-08T09:21:44Z", + "basis": "GitHub Actions jobs/steps recorded timestamps; a baseline, not a performance guarantee." + }, + "staticBudgetChanged": true, + "addedWorkflowFiles": 0, + "addedWindowsJobs": 0, + "addedUbuntuJobs": 1, + "addedCheckoutActions": 1, + "addedUploadArtifactActions": 1, + "artifactName": "ARSAS-independent-native-ctest-shadow", + "artifactFiles": [ + "ctest.xml", + "ardirec_bridge.dll" + ], + "artifactRetentionDays": 14, + "manualWindowsCTestRetained": true, + "independentNativeRouteGuardRetained": true, + "comparisonAdvisory": true, + "releaseAuthorityChanged": false, + "physicalAuthorityChanged": false, + "nativeDeduplicationAuthorized": false, + "rollback": "Remove Ubuntu observer and independent evidence upload, restore existing Windows advisory comparison; never remove mandatory Windows CMake/CTest or native-only route checks." } } diff --git a/scripts/test-ci-native-shadow.py b/scripts/test-ci-native-shadow.py index 872d19d8b..aff3ac352 100644 --- a/scripts/test-ci-native-shadow.py +++ b/scripts/test-ci-native-shadow.py @@ -203,6 +203,27 @@ def test_ci_preserves_native_independent_ctest_and_advisory_only(self): self.assertEqual(comtrade.count("name: Validate COMTRADE viewer integration"), 1) self.assertEqual(comtrade.count("name: Advisory same-SHA native"), 1) self.assertEqual(comtrade.count("name: Checkout immutable ArdIrec revision"), 1) + + self.assertIn("native-shadow-observer:", comtrade) + self.assertIn("needs: windows-viewer-integration", comtrade) + self.assertIn("runs-on: ubuntu-latest", comtrade) + self.assertIn("ARSAS-independent-native-ctest-shadow", comtrade) + self.assertIn("actions/upload-artifact@v7", comtrade) + self.assertIn("actions/download-artifact@v8", comtrade) + self.assertIn("if: github.event_name == 'pull_request'", comtrade) + self.assertIn("name: Build pinned ArdIrec native bridge", comtrade) + self.assertIn("name: Enforce field-tested native-only ARSAS COMTRADE routing", comtrade) + self.assertIn("name: Advisory exact-SHA native comparison", comtrade) + # There must be no second CMake/CTest outside the required Windows job. + before, observer = comtrade.split(" native-shadow-observer:", 1) + self.assertIn("build-ardirec-bridge.ps1", before) + self.assertNotIn("build-ardirec-bridge.ps1", observer) + self.assertNotIn("ctest --test-dir", observer) + self.assertIn("compare-ci-native-shadow.py", observer) + self.assertIn("continue-on-error: true", observer) + self.assertEqual(comtrade.count("native-shadow-observer:"), 1) + self.assertIn("workflow_dispatch", before) + self.assertIn("ARIEC61850_COMMIT=$($engineLock.commit)", comtrade) self.assertIn("engines\\ARIEC61850.lock.json", comtrade) From 5ae5cd921dc05b39f9cc73dab3935ac5d8303506 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:44:50 +0700 Subject: [PATCH 30/99] test(ci-p3j-d): track renamed Ubuntu shadow observer instead of removed Windows wait --- scripts/test-ci-native-shadow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-ci-native-shadow.py b/scripts/test-ci-native-shadow.py index aff3ac352..5b3a1f7a5 100644 --- a/scripts/test-ci-native-shadow.py +++ b/scripts/test-ci-native-shadow.py @@ -201,7 +201,7 @@ def test_ci_preserves_native_independent_ctest_and_advisory_only(self): self.assertIn("actions/upload-artifact@v7", canonical) self.assertIn("Build and test pinned native bridge", comtrade) self.assertEqual(comtrade.count("name: Validate COMTRADE viewer integration"), 1) - self.assertEqual(comtrade.count("name: Advisory same-SHA native"), 1) + self.assertEqual(comtrade.count("name: Advisory exact-SHA native comparison"), 1) self.assertEqual(comtrade.count("name: Checkout immutable ArdIrec revision"), 1) self.assertIn("native-shadow-observer:", comtrade) From 502d97f439b469dcd50237552a654f32df2eebdd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:57:46 +0700 Subject: [PATCH 31/99] ci(p3j-e): only accept completed canonical success for final native shadow parity --- .../workflows/comtrade-viewer-integration.yml | 1 + .../audits/CI_P3J_E_NATIVE_SHADOW_FINALITY.md | 27 ++++++++ scripts/compare-ci-native-shadow.py | 6 +- scripts/test-ci-native-shadow.py | 61 +++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 docs/audits/CI_P3J_E_NATIVE_SHADOW_FINALITY.md diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index c6789265d..61a635ed0 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -241,6 +241,7 @@ jobs: --independent-junit native-input/ctest.xml \ --independent-bridge native-input/ardirec_bridge.dll \ --wait-seconds 600 \ + --require-completed-canonical \ --output native-shadow-parity.json verdict=$? set -e diff --git a/docs/audits/CI_P3J_E_NATIVE_SHADOW_FINALITY.md b/docs/audits/CI_P3J_E_NATIVE_SHADOW_FINALITY.md new file mode 100644 index 000000000..2900cdee1 --- /dev/null +++ b/docs/audits/CI_P3J_E_NATIVE_SHADOW_FINALITY.md @@ -0,0 +1,27 @@ +# CI-P3J-E — Completed-canonical native parity finality gate + +Issue #470. Builds on **merged** #475 (real SHA-256 + native 9/9 CTest parity) and **merged** #477 (required COMTRADE native build/CTest and native-only route guard complete on Windows; advisory-only comparison runs on Ubuntu). + +## Problem proven in real GitHub Actions + +PR #477 run 37758793077 independently built/tested its COMTRADE DLL on Windows in 87 seconds (previous run 37755975584 held the Windows runner for 237 seconds). The Ubuntu observer subsequently compared the exact source/engine/ArdIrec and all nine CTest names plus the binary SHA-256 successfully, but the canonical Build ARSAS run 37758793021 was still **in_progress**. The verifier explicitly marked "provisional: true", "fullCanonicalBuildPassed: false", "deduplicationAuthorized: false". + +Although the canonical build ultimately passed, a provisional observation is not evidence that its complete package/integration tests passed. Nor does a green advisory workflow job imply that this was final parity. + +## Minimal fail-closed change + +- Reuse the existing \`scripts/compare-ci-native-shadow.py\` and read-only \`actions:read\` permission. Add opt-in \`--require-completed-canonical\` mode on the existing Ubuntu observer only. +- Keep the wait bounded to 600 seconds. Require **the latest exact PR-head, branch, synthetic merge-source SHA and locked engine/ArdIrec** Build ARSAS run to be **completed/success** before fetching and verifying its two-file tiny JUnit/proof artifact. +- Any failed/cancelled completed canonical run errors immediately; in-progress/queued runs cannot produce a final verdict; timeout/missing artifact and malformed/foreign proof fail closed. The advisory step continues to flag an inability to establish parity as a **visible warning**, not a promotion/pass claim. Native DLL mismatch still yields no parity. +- Preserve default/provisional verifier behavior for explicit earlier diagnostic consumers; the added finality contract is opt-in and tested, so no unrelated callers change meaning. +- New offline fixtures prove completed success, in-progress timeout, completed failure, missing artifact, and transition from in-progress to completed with no pre-completion artifact lookup. + +## Thread-safe scope and cost + +No new workflow or runner, no new Windows build, no new evidence transport, no changed engine pin, COMTRADE runtime code, release authority, R10 field baseline, or P7 branch. The **mandatory Windows CMake/CTest and native-only route guard** still run independently before any observer. No topology-budget limit changes. + +Wait time increases only on the existing lightweight Ubuntu observer when canonical build has not yet completed. A matching, completed canonical and independently passed native DLL/CTest can now be recorded as **final observation of parity for that one exact revision**. This does not itself authorize retiring the independent Windows native lane or reusing bytes in a release. + +## Rollback + +Remove the opt-in flag from the Ubuntu job to restore provisional early advisory comparison, or revert this PR entirely; do not change the mandatory native tests or sealed package verifiers. Review multiple independent completed-success parity observations and named managed COMTRADE domain tests before any future deduplication consideration. diff --git a/scripts/compare-ci-native-shadow.py b/scripts/compare-ci-native-shadow.py index 87b4a2e02..9b876bb80 100644 --- a/scripts/compare-ci-native-shadow.py +++ b/scripts/compare-ci-native-shadow.py @@ -177,6 +177,7 @@ def compare_online( api, *, head_sha: str, head_branch: str, source_sha: str, engine_sha: str, ardirec_sha: str, independent_junit: Path, independent_bridge: Path, wait_seconds: int = 600, poll_seconds: int = 15, + require_completed: bool = False, ) -> dict: for label, sha in (("head", head_sha), ("source", source_sha), ("engine", engine_sha), ("ArdIrec", ardirec_sha)): @@ -202,7 +203,7 @@ def compare_online( last = f"run {run['id']}/{run['run_attempt']} {status}/{conclusion}" if status == "completed" and conclusion != "success": raise ShadowError("Latest matching canonical build failed: " + last) - if status in {"in_progress", "completed"}: + if status == "completed" or (status == "in_progress" and not require_completed): art = one_artifact( api.get(f"{base}/actions/runs/{run['id']}/artifacts?per_page=100"), run["id"], @@ -230,6 +231,7 @@ def compare_online( status == "completed" and conclusion == "success" ) result["provisional"] = not result["fullCanonicalBuildPassed"] + result["completedCanonicalRequired"] = require_completed return result if time.monotonic() >= deadline: raise ShadowError(f"Bounded shadow comparison timeout; last={last}") @@ -246,6 +248,7 @@ def main() -> int: parser.add_argument("--independent-junit", type=Path, required=True) parser.add_argument("--independent-bridge", type=Path, required=True) parser.add_argument("--wait-seconds", type=int, default=600) + parser.add_argument("--require-completed-canonical", action="store_true") parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() result = compare_online( @@ -256,6 +259,7 @@ def main() -> int: independent_junit=args.independent_junit, independent_bridge=args.independent_bridge, wait_seconds=args.wait_seconds, + require_completed=args.require_completed_canonical, ) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(result, sort_keys=True, indent=2) + "\n", diff --git a/scripts/test-ci-native-shadow.py b/scripts/test-ci-native-shadow.py index 5b3a1f7a5..2fb9c2efc 100644 --- a/scripts/test-ci-native-shadow.py +++ b/scripts/test-ci-native-shadow.py @@ -166,6 +166,66 @@ def test_in_progress_artifact_is_provisional_not_promotion(self): self.assertFalse(result["fullCanonicalBuildPassed"]) self.assertFalse(result["deduplicationAuthorized"]) + def test_completed_success_is_required_for_final_shadow(self): + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), + **dict(self.kwargs, require_completed=True), + ) + self.assertTrue(result["fullCanonicalBuildPassed"]) + self.assertTrue(result["completedCanonicalRequired"]) + self.assertFalse(result["provisional"]) + self.assertTrue(result["shadowParityObserved"]) + + def test_in_progress_cannot_pass_final_shadow_even_if_bits_equal(self): + with self.assertRaisesRegex(shadow.ShadowError, "timeout"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, + run_status="in_progress", run_conclusion=None), + **dict(self.kwargs, require_completed=True), + ) + + def test_final_shadow_rejects_failed_canonical_run(self): + with self.assertRaisesRegex(shadow.ShadowError, "Latest matching"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, + run_status="completed", run_conclusion="failure"), + **dict(self.kwargs, require_completed=True), + ) + + def test_final_shadow_blocks_missing_artifact_even_after_build_success(self): + with self.assertRaisesRegex(shadow.ShadowError, "timeout"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, artifact=False), + **dict(self.kwargs, require_completed=True), + ) + + def test_final_shadow_ignores_in_progress_then_checks_completed_run(self): + class CompletingApi(FakeApi): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.polls = 0 + self.artifact_before_completion = False + + def get(self, url: str, *, binary=False): + if "/workflows/build.yml/runs?" in url: + self.polls += 1 + self.run_status = "in_progress" if self.polls == 1 else "completed" + self.run_conclusion = None if self.polls == 1 else "success" + if "/artifacts?" in url and self.run_status != "completed": + self.artifact_before_completion = True + return super().get(url, binary=binary) + + api = CompletingApi(blob=self.blob, digest=self.digest) + result = shadow.compare_online( + api, + **dict(self.kwargs, require_completed=True, + wait_seconds=3, poll_seconds=1), + ) + self.assertGreaterEqual(api.polls, 2) + self.assertFalse(api.artifact_before_completion) + self.assertTrue(result["fullCanonicalBuildPassed"]) + self.assertFalse(result["provisional"]) + def test_missing_digest_and_modified_archive_are_rejected(self): with self.assertRaisesRegex(shadow.ShadowError, "metadata missing"): shadow.parse_small_artifact(self.blob, "") @@ -220,6 +280,7 @@ def test_ci_preserves_native_independent_ctest_and_advisory_only(self): self.assertNotIn("build-ardirec-bridge.ps1", observer) self.assertNotIn("ctest --test-dir", observer) self.assertIn("compare-ci-native-shadow.py", observer) + self.assertIn("--require-completed-canonical", observer) self.assertIn("continue-on-error: true", observer) self.assertEqual(comtrade.count("native-shadow-observer:"), 1) self.assertIn("workflow_dispatch", before) From 09aff650b7d5477cb21b5a14ba13f7a760c41e70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 06:04:02 +0700 Subject: [PATCH 32/99] fix(goose-ui): align compact event header, fixed count badge and scope controls --- Views/GooseSubscriberLiteView.xaml | 53 ++++++++------ .../GooseEventHeaderLayoutTests.cs | 69 +++++++++++++++++++ 2 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 tests/ARSAS.Tests/GooseEventHeaderLayoutTests.cs diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index e646a6234..3ea4030cb 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -281,39 +281,52 @@ - + + - + + - - - - + + + + + - - + + VerticalAlignment="Center" FontSize="10.5"> - - + + FontSize="10.5" Foreground="{StaticResource Muted}" + ToolTip="Show every received GOOSE frame including retransmissions. Unchecked: show changes only; raw capture is preserved."/> diff --git a/tests/ARSAS.Tests/GooseEventHeaderLayoutTests.cs b/tests/ARSAS.Tests/GooseEventHeaderLayoutTests.cs new file mode 100644 index 000000000..d01900043 --- /dev/null +++ b/tests/ARSAS.Tests/GooseEventHeaderLayoutTests.cs @@ -0,0 +1,69 @@ +using System.Xml.Linq; + +namespace ARSAS.Tests; + +public sealed class GooseEventHeaderLayoutTests +{ + [Fact] + public void ActiveGooseEventToolbarUsesOneAlignedCompactRow() + { + var xaml = XDocument.Parse(Read("Views/GooseSubscriberLiteView.xaml")); + XNamespace w = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var header = xaml.Descendants(w + "Grid") + .Single(e => (string?)e.Attribute(x + "Name") == "GooseEventHeader"); + + Assert.Equal("30", (string?)header.Attribute("MinHeight")); + var columns = header.Element(w + "Grid.ColumnDefinitions")!.Elements(w + "ColumnDefinition").ToArray(); + Assert.Equal(new[] { "Auto", "*", "Auto" }, + columns.Select(c => (string?)c.Attribute("Width")).ToArray()); + var count = header.Descendants(w + "Border").Single(e => + (string?)e.Attribute(x + "Name") == "GooseEventCountBadge"); + Assert.Equal("21", (string?)count.Attribute("Height")); + Assert.Equal("Center", (string?)count.Attribute("VerticalAlignment")); + Assert.Equal("NoWrap", (string?)count.Descendants(w + "TextBlock").Single() + .Attribute("TextWrapping")); + Assert.Contains(header.Descendants(w + "TextBlock"), + e => (string?)e.Attribute("Text") == "GOOSE Events"); + } + + [Fact] + public void EventScopeFollowAndRetransmissionControlsRetainExistingContracts() + { + var xaml = XDocument.Parse(Read("Views/GooseSubscriberLiteView.xaml")); + XNamespace w = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var header = xaml.Descendants(w + "Grid").Single(e => + (string?)e.Attribute(x + "Name") == "GooseEventHeader"); + var scope = header.Descendants(w + "ComboBox").Single(); + Assert.Equal("GooseEventScope", (string?)scope.Attribute(x + "Name")); + Assert.Equal("{Binding GooseIedScopeIndex, Mode=TwoWay}", + (string?)scope.Attribute("SelectedIndex")); + Assert.Equal("{StaticResource ModernComboBox}", (string?)scope.Attribute("Style")); + Assert.Equal(new[] { "Selected IED only", "All IED GOOSE signals" }, + scope.Elements(w + "ComboBoxItem").Select(i => (string?)i.Attribute("Content")).ToArray()); + var boxes = header.Descendants(w + "CheckBox").ToArray(); + Assert.Equal(2,boxes.Length); + Assert.Equal(new[] { + "{Binding FollowLatestGooseEvents, Mode=TwoWay}", + "{Binding ShowGooseRetransmissions, Mode=TwoWay}" + },boxes.Select(b => (string?)b.Attribute("IsChecked")).ToArray()); + Assert.All(boxes,b=>Assert.Equal("28",(string?)b.Attribute("Height"))); + var events = xaml.Descendants(w + "DataGrid").Single(g => + (string?)g.Attribute("ItemsSource") == "{Binding GooseVisibleEvents}"); + Assert.Equal("{Binding SelectedGooseEvent, Mode=TwoWay}", + (string?)events.Attribute("SelectedItem")); + } + + private static string Read(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var file = Path.Combine(dir.FullName,path.Replace('/',Path.DirectorySeparatorChar)); + if (File.Exists(file)) return File.ReadAllText(file); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +} From 9add501ce774188e749d0bf5942e6daff002871d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 07:04:18 +0700 Subject: [PATCH 33/99] feat(identity): verify SCL-backed IED/LD before Discovery display and canonical export --- .github/workflows/build.yml | 3 + MainWindow.RcbExport.cs | 5 +- MainWindow.xaml.cs | 25 +++- Services/CanonicalSclReloadValidator.cs | 22 ++-- Services/Iec61850MonitorRuntime.cs | 33 +++++ Services/TrustedSclIdentityAuthority.cs | 56 +++++++++ engines/ARIEC61850.lock.json | 12 +- scripts/verify-rcb-export.ps1 | 3 +- .../TrustedSclIdentityConsumerTests.cs | 113 ++++++++++++++++++ 9 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 Services/TrustedSclIdentityAuthority.cs create mode 100644 tests/ARSAS.Tests/TrustedSclIdentityConsumerTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d11d32785..0919191ef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -353,6 +353,9 @@ jobs: - name: Build solution run: dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore + - name: Run pinned engine P10 identity and canonical SCL regression + run: dotnet test .\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --filter "FullyQualifiedName~TrustedSclIedIdentityMatcherTests|FullyQualifiedName~CanonicalLiveIedSclExporterTests" --logger "trx;LogFileName=engine-p10-identity.trx" --results-directory .\ArIED61850Tester\TestResults + - name: Run ARSAS application regression tests run: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-tests.trx" --results-directory .\ArIED61850Tester\TestResults diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index fbe25ad7d..bed12b760 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -499,6 +499,8 @@ private async Task ExportLegacySasRcbAsync( } var filteredModel = SclReportControlFilter.FilterLiveModel(exportModel, row.Reference); + var verifiedIdentity = await TrustedSclIdentityAuthority.TryMatchAsync( + device,filteredModel,cancellationToken); var liveResult = await Task.Run(() => AuthoritativeLiveIedSclExporter.WriteFiles( filteredModel, outputPath, @@ -507,7 +509,8 @@ private async Task ExportLegacySasRcbAsync( Profile = "full-model", SchemaProfile = schema, IpAddress = device.IpAddress, - IedNameOverride = device.Name, + IedNameOverride = verifiedIdentity?.IedName ?? device.Name, + VerifiedIdentity = verifiedIdentity, IncludeLowConfidenceTypes = true, IncludeRuntimeStateComment = false }), cancellationToken); diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index c2629ba5a..2c55ee333 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1821,7 +1821,21 @@ private async void IedSaveScl_Click(object sender, RoutedEventArgs e) return; } - SaveTypedModelAsScl(device, model, sourceDescription, schema, dialog.FileName); + AR.Iec61850.Discovery.LiveIedIdentity? provenIdentity = null; + if (device.SclWorkspace == null) + { + provenIdentity = await TrustedSclIdentityAuthority.TryMatchAsync( + device,model,_applicationCancellation.Token); + if (provenIdentity is not null) + AddLog("INFO","SCL Export", + $"{device.Name}: verified trusted SCL identity {provenIdentity.IedName} " + + $"and {provenIdentity.LogicalDeviceAliases.Count} LD mappings; MMS domains unchanged."); + else if (device.IdentitySource.Contains("(Low)",StringComparison.OrdinalIgnoreCase)) + AddLog("WARN","SCL Export", + $"{device.Name}: IED/LD naming is provisional (MMS domains alone do not prove the boundary). " + + "Open and verify the original engineering SCL on this IED to generate an authoritative identity."); + } + SaveTypedModelAsScl(device, model, sourceDescription, schema, dialog.FileName, provenIdentity); } catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentException) { @@ -1884,7 +1898,8 @@ private void SaveTypedModelAsScl( AR.Iec61850.Discovery.LiveIedModelDiscoveryDocument model, string sourceDescription, SclSchemaProfileDescriptor schema, - string outputPath) + string outputPath, + AR.Iec61850.Discovery.LiveIedIdentity? verifiedIdentity = null) { LiveIedSclExportResult result; AR.Iec61850.Discovery.LiveIedCanonicalModel? canonicalExportEvidence = null; @@ -1905,7 +1920,8 @@ private void SaveTypedModelAsScl( canonical, outputPath, schema.Profile, - profile: "full-model"); + profile: "full-model", + verifiedIdentity: verifiedIdentity); try { var semanticPatch = SclExportSemanticParityPatch.ApplyForLiveModel( @@ -1914,7 +1930,8 @@ private void SaveTypedModelAsScl( canonicalReloadWorkspace = CanonicalSclReloadValidator.Validate( _sclWorkspaceService, canonical, - result); + result, + verifiedIdentity?.IedName); if (semanticPatch.Changed) { diff --git a/Services/CanonicalSclReloadValidator.cs b/Services/CanonicalSclReloadValidator.cs index d47e072a2..c3c5d26d8 100644 --- a/Services/CanonicalSclReloadValidator.cs +++ b/Services/CanonicalSclReloadValidator.cs @@ -9,12 +9,18 @@ public static class CanonicalSclReloadValidator public static SclIedWorkspace Validate( SclWorkspaceService workspaceService, LiveIedCanonicalModel canonical, - LiveIedSclExportResult result) + LiveIedSclExportResult result, + string? verifiedIedName = null) { ArgumentNullException.ThrowIfNull(workspaceService); ArgumentNullException.ThrowIfNull(canonical); ArgumentNullException.ThrowIfNull(result); + // Only the engineering IED identity changes; verified MMS/ACSE endpoint + // and accepted association selectors remain owned by the live snapshot. + var effectiveIedName = string.IsNullOrWhiteSpace(verifiedIedName) + ? canonical.IedName : verifiedIedName.Trim(); + if (!string.Equals(result.Profile, "full-model", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( @@ -31,19 +37,19 @@ public static SclIedWorkspace Validate( result.SclPath, new SclWorkspaceOpenOptions { - IedName = canonical.IedName, + IedName = effectiveIedName, 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}'."); + $"Generated SCL could not be reopened as exactly one ARSAS workspace for '{effectiveIedName}/{canonical.AccessPointName}'."); - if (!string.Equals(workspace.IedName, canonical.IedName, StringComparison.Ordinal) || + if (!string.Equals(workspace.IedName, effectiveIedName, StringComparison.Ordinal) || !string.Equals(workspace.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal)) { throw new InvalidOperationException( - $"Generated SCL reload identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}', " + + $"Generated SCL reload identity drifted. Expected '{effectiveIedName}/{canonical.AccessPointName}', " + $"reloaded '{workspace.IedName}/{workspace.AccessPointName}'."); } @@ -80,7 +86,7 @@ public static SclIedWorkspace Validate( var preparation = SclAssistedConnectionPreparationBuilder.Build( File.ReadAllText(result.SclPath), - canonical.IedName, + effectiveIedName, canonical.AccessPointName, canonical.Communication.Host, canonical.Communication.Port); @@ -94,13 +100,13 @@ public static SclIedWorkspace Validate( } var plan = preparation.AssociationPlan; - if (!string.Equals(plan.IedName, canonical.IedName, StringComparison.Ordinal) || + if (!string.Equals(plan.IedName, effectiveIedName, 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}' " + + $"Generated SCL reconnect plan identity drifted. Expected '{effectiveIedName}/{canonical.AccessPointName}' " + $"at {canonical.Communication.Host}:{canonical.Communication.Port}, rebuilt " + $"'{plan.IedName}/{plan.AccessPointName}' at {plan.Host}:{plan.Port}."); } diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 1d1577c31..f6f89e91e 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -195,6 +195,39 @@ public async Task> ConnectAndDiscoverAsync( progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.PreparingWorkspace, "Filtering and ordering the discovered signal workspace…", 98d, 14, 15)); var identity = session.Client.DetectedIdentity; + if (device.LiveDiscoveryModel is { } liveModel && + !string.IsNullOrWhiteSpace(device.SclSourcePath) && + !string.IsNullOrWhiteSpace(device.SclSourceSha256)) + { + try + { + var proof = await TrustedSclIdentityAuthority.TryMatchAsync( + device,liveModel,cancellationToken).ConfigureAwait(false); + if (proof is not null) + { + identity = TrustedSclIdentityAuthority.ToConsumerIdentity( + proof,liveModel.LogicalDevices.Select(ld => ld.MmsDomain)); + Log("INFO",device.Name, + $"Trusted SCL topology verified: IED={proof.IedName}; " + + $"LD={string.Join(",",proof.LogicalDeviceAliases.Values)}; " + + $"MMS domains preserved; source={proof.Source}."); + } + else + { + Log("WARN",device.Name, + "Opened SCL did not uniquely match this endpoint's full MMS domain set. " + + "Discovery identity remains provisional; no vendor-based rename."); + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) when (ex is IOException or InvalidDataException or System.Xml.XmlException + or ArgumentException or UnauthorizedAccessException) + { + Log("WARN",device.Name, + $"Trusted SCL identity verification was rejected: {ex.Message}. " + + "Discovery can continue, but identity is provisional until SCL is re-opened."); + } + } if (!string.IsNullOrWhiteSpace(identity.IedName)) { var previousName = device.Name; diff --git a/Services/TrustedSclIdentityAuthority.cs b/Services/TrustedSclIdentityAuthority.cs new file mode 100644 index 000000000..dca7a5c66 --- /dev/null +++ b/Services/TrustedSclIdentityAuthority.cs @@ -0,0 +1,56 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using System.Xml.Linq; + +namespace ArIED61850Tester.Services; + +/// +/// SCL identity is authoritative only if it is the same SHA-256 pinned source +/// already opened by the operator AND its complete IED/AP/LD topology matches +/// the observed MMS domains. This is deliberately outside the hot report path. +/// +public static class TrustedSclIdentityAuthority +{ + public static async Task TryMatchAsync( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument liveModel, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(liveModel); + if (string.IsNullOrWhiteSpace(device.SclSourcePath) || + string.IsNullOrWhiteSpace(device.SclSourceSha256)) + return null; + + var verified = await VerifiedSclSourceLoader.LoadAsync( + device.SclSourcePath,device.SclSourceSha256,cancellationToken).ConfigureAwait(false); + var source = XDocument.Parse(verified.Xml, LoadOptions.PreserveWhitespace); + return TrustedSclIedIdentityMatcher.TryMatch( + source, + liveModel.LogicalDevices.Select(ld => ld.MmsDomain), + device.IpAddress, + device.SclIedName); + } + + public static Iec61850DeviceIdentity ToConsumerIdentity( + LiveIedIdentity verified, IEnumerable domains) + { + ArgumentNullException.ThrowIfNull(verified); + if (verified.Source != "TrustedSclExactDomainMatch" || + verified.Confidence != LiveIedDiscoveryConfidenceLevel.High) + throw new InvalidDataException("SCL model identity is not verified."); + + return new Iec61850DeviceIdentity + { + IedName=verified.IedName, + Source=$"ARIEC61850 {verified.Source} ({verified.Confidence})", + LogicalDevices=domains.Distinct(StringComparer.OrdinalIgnoreCase).Select(domain => + new Iec61850LogicalDeviceIdentity + { + Domain=domain, + Instance=verified.LogicalDeviceAliases.TryGetValue(domain,out var alias) + ? alias : throw new InvalidDataException($"Verified LD mapping missing '{domain}'.") + }).ToArray() + }; + } +} diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 221ce6ead..edb6297c8 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,13 +2,13 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "8b0c0f7e695b016834e40a6f068994dcc945cb1a", - "sourcePullRequest": 157, - "purpose": "EXPERIMENTAL DISCOVERY DATASET TYPE CLOSURE, code qualification pending: exact engine PR #157, extends immutable P6.2 baseline without touching canonical command/RCB or cyclic polling invariants. Physical F650 parity required before promotion.", + "commit": "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1", + "sourcePullRequest": 161, + "purpose": "P10 VERIFIED-SCL IDENTITY CODE TRIAL on exact PR #157 dataset-first Discovery baseline. Includes canonical live Save SCL propagation with original accepted association evidence and LD instance values, after PR #161 correction commit; physical BCUGE test pending. No change to existing MMS report/control/cyclic scheduler.", "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." + "commit": "8b0c0f7e695b016834e40a6f068994dcc945cb1a", + "sourcePullRequest": 157, + "purpose": "Direct previous P9 pinned Smart Discovery DataSet type-closure baseline. P10 is a single ancestry-continuation identity-only patch." }, "priorGoldenWirePin": { "commit": "0023ef9a4373855497464ed3979e359c4041c95d", diff --git a/scripts/verify-rcb-export.ps1 b/scripts/verify-rcb-export.ps1 index a237cda25..91a57a063 100644 --- a/scripts/verify-rcb-export.ps1 +++ b/scripts/verify-rcb-export.ps1 @@ -43,7 +43,8 @@ Require-Text $mainCode 'SclReportControlFilter.FilterLiveModel' "Live-discovery Require-Text $mainCode 'latestAvailability' "The latest read-only availability evidence is not retained for export." Require-Text $mainCode 'LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory' "Live FCDA directory evidence is not merged before selected-RCB export." Require-Text $mainCode 'AuthoritativeLiveIedSclExporter.WriteFiles' "Live CID export must normalize the physical IED identity separately from MMS Logical Device domains." -Require-Text $mainCode 'IedNameOverride = device.Name' "The IED card identity must be passed as the authoritative SCL IED name." +Require-Text $mainCode 'IedNameOverride = verifiedIdentity?.IedName ?? device.Name' "Verified SCL identity must take precedence over provisional display name at live RCB export." +Require-Text $mainCode 'VerifiedIdentity = verifiedIdentity' "Verified LD mapping must be forwarded to authoritative SCL exporter." Reject-Text $mainCode 'Task.Run(() => LiveIedSclExporter.WriteFiles(' "Live selected-RCB export must not bypass authoritative IED identity normalization." Require-Text $mainXaml 'Adaptive five-slot action bar' "IED-card source action row is no longer protected from clipping." Require-Text $mainXaml '(async () => + await TrustedSclIdentityAuthority.TryMatchAsync(device,Model("BCUGEF650"))); + } + + [Fact] + public async Task Missing_Trusted_SCL_And_Wrong_MMS_Endpoint_Does_Not_Guess() + { + var plain=new Iec61850MonitorDevice{IpAddress="192.16.1.33"}; + Assert.Null(await TrustedSclIdentityAuthority.TryMatchAsync(plain,Model("BCUGEF650"))); + using var temp=TempScl.Create("BCUGE","F650","192.16.1.33"); + temp.Device.IpAddress="192.16.1.34"; + Assert.Null(await TrustedSclIdentityAuthority.TryMatchAsync( + temp.Device,Model("BCUGEF650"))); + } + + [Fact] + public void Canonical_Save_And_RCB_Export_Must_Consume_Verified_Identity() + { + var save=Read("MainWindow.xaml.cs"); + Assert.Contains("verifiedIdentity: verifiedIdentity",save); + Assert.Contains("verifiedIdentity?.IedName",save); + Assert.Contains("TrustedSclIdentityAuthority.TryMatchAsync",save); + var validator=Read("Services/CanonicalSclReloadValidator.cs"); + Assert.Contains("effectiveIedName",validator); + var export=Read("MainWindow.RcbExport.cs"); + Assert.Contains("VerifiedIdentity = verifiedIdentity",export); + Assert.Contains("IedNameOverride = verifiedIdentity?.IedName ?? device.Name",export); + var runtime=Read("Services/Iec61850MonitorRuntime.cs"); + Assert.Contains("TrustedSclIdentityAuthority.ToConsumerIdentity",runtime); + } + + private static LiveIedModelDiscoveryDocument Model(string domain) => new() + { + Host="192.16.1.33",IedName="IED_192_16_1_33", + LogicalDevices=[new LiveIedLogicalDeviceModel{MmsDomain=domain,Inst=domain}] + }; + + private sealed class TempScl : IDisposable + { + private readonly string _folder; + public Iec61850MonitorDevice Device { get; } + private TempScl(string folder,Iec61850MonitorDevice device) + { + _folder=folder;Device=device; + } + public static TempScl Create(string ied,string ld,string ip) + { + var dir=Path.Combine(Path.GetTempPath(),"arsas-ied-p10-"+Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var path=Path.Combine(dir,"engineering.iid"); + var xml=$""" + +

{ip}

+ +
+ """; + File.WriteAllText(path,xml); + var hash=Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); + return new TempScl(dir,new Iec61850MonitorDevice + { + IpAddress=ip, + SclSourcePath=path, + SclSourceSha256=hash, + SclIedName=ied, + SclAccessPointName="S1" + }); + } + public void Dispose()=>Directory.Delete(_folder,recursive:true); + } + + private static string Read(string path) + { + DirectoryInfo? dir=new(AppContext.BaseDirectory); + while(dir is not null) + { + var file=Path.Combine(dir.FullName,path.Replace('/',Path.DirectorySeparatorChar)); + if(File.Exists(file))return File.ReadAllText(file); + dir=dir.Parent; + } + throw new FileNotFoundException(path); + } +} From 439e06717f606647012cd2b75d298f39adaf142c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 07:07:28 +0700 Subject: [PATCH 34/99] test(p10): preserve old physical baseline and qualify exact code-trial engine provenance --- .github/workflows/scl-interoperability-r7.yml | 19 +++++++++++++++++++ engines/ARIEC61850.lock.json | 16 +++++++++++++--- .../CanonicalLiveSclExportRegressionTests.cs | 10 ++++++++-- ...teroperabilityTrialGuardRegressionTests.cs | 9 ++++++++- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 45117d35c..284b34188 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -127,6 +127,25 @@ jobs: } Write-Host 'DataSet-first closure trial qualified for CI only; no physical promotion.' } + elseif ($null -ne $lock.p10IdentityTrial -and + $lock.commit -eq $lock.p10IdentityTrial.exactCommit) { + $p10 = $lock.p10IdentityTrial + $closure = $lock.datasetFirstDiscoveryTrial + if ($lock.ref -ne 'main' -or + [int]$lock.sourcePullRequest -ne 161 -or + [int]$p10.enginePullRequest -ne 161 -or + $p10.baseEngineCommit -ne $closure.exactCommit -or + $p10.exactCommit -ne '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -or + $p10.status -ne 'code-trial-not-physical' -or + -not [bool]$p10.physicalQualificationRequired -or + -not [bool]$p10.fullNameListFallbackForbidden -or + -not [bool]$p10.cyclicMmsProcessPollingForbidden -or + $p10.identitySource -ne 'TrustedSclExactDomainMatch' -or + $closure.status -ne 'code-trial-not-physical') { + throw 'P10 identity trial is not pinned to exact qualified DataSet-first ancestry.' + } + Write-Host 'P10 trusted-SCL identity trial accepted for code CI ONLY; R7/R10 physical gate unchanged.' + } elseif ($lock.commit -eq $convergence.activeStack.engineModelAndScl.head) { if ([int]$lock.sourcePullRequest -notin @(134, 135) -or $lock.purpose -notmatch 'R7 physical SCL repair pin') { diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index edb6297c8..7ff7d95c0 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -6,9 +6,9 @@ "sourcePullRequest": 161, "purpose": "P10 VERIFIED-SCL IDENTITY CODE TRIAL on exact PR #157 dataset-first Discovery baseline. Includes canonical live Save SCL propagation with original accepted association evidence and LD instance values, after PR #161 correction commit; physical BCUGE test pending. No change to existing MMS report/control/cyclic scheduler.", "previousTrialPin": { - "commit": "8b0c0f7e695b016834e40a6f068994dcc945cb1a", - "sourcePullRequest": 157, - "purpose": "Direct previous P9 pinned Smart Discovery DataSet type-closure baseline. P10 is a single ancestry-continuation identity-only patch." + "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", @@ -83,5 +83,15 @@ "fullNameListFallbackForbidden": true, "cyclicMmsProcessPollingForbidden": true, "physicalQualificationRequired": true + }, + "p10IdentityTrial": { + "status": "code-trial-not-physical", + "enginePullRequest": 161, + "baseEngineCommit": "8b0c0f7e695b016834e40a6f068994dcc945cb1a", + "exactCommit": "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1", + "physicalQualificationRequired": true, + "fullNameListFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true, + "identitySource": "TrustedSclExactDomainMatch" } } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index ce96afb75..d1a5e739d 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -126,7 +126,8 @@ public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() 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("IedName = effectiveIedName", validator, StringComparison.Ordinal); + Assert.Contains("verifiedIedName",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); @@ -416,9 +417,14 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter Assert.Equal(495, closure.GetProperty("issue").GetInt32()); Assert.Equal("352c81e6a798635c6addcee0683235ca87ad416d", closure.GetProperty("baseEngineCommit").GetString()); + var p10 = root.GetProperty("p10IdentityTrial"); Assert.Equal(closure.GetProperty("exactCommit").GetString(), + p10.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(p10.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); - Assert.Equal(157, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(161, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); + Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("fullNameListFallbackForbidden").GetBoolean()); Assert.True(closure.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index bbf37e854..c3e996a84 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -67,8 +67,15 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP Assert.Equal(157, closure.GetProperty("enginePullRequest").GetInt32()); Assert.Equal(serverAt.GetProperty("exactCommit").GetString(), closure.GetProperty("baseEngineCommit").GetString()); + var p10 = root.GetProperty("p10IdentityTrial"); + Assert.Equal(closure.GetProperty("exactCommit").GetString(), + p10.GetProperty("baseEngineCommit").GetString()); Assert.Equal(root.GetProperty("commit").GetString(), - closure.GetProperty("exactCommit").GetString()); + p10.GetProperty("exactCommit").GetString()); + Assert.Equal(161,root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); + Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(p10.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); Assert.True(serverAt.GetProperty("physicalQualificationRequired").GetBoolean()); From 424a108d654a389429151a925ce6105c40bfa3cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 07:16:40 +0700 Subject: [PATCH 35/99] ci(p10): recognize exact code-only identity pin without weakening reference gates --- .../interoperability-reference-guard.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/interoperability-reference-guard.yml b/.github/workflows/interoperability-reference-guard.yml index ed1313fb5..32e2615c2 100644 --- a/.github/workflows/interoperability-reference-guard.yml +++ b/.github/workflows/interoperability-reference-guard.yml @@ -101,6 +101,22 @@ jobs: [bool]$closure.physicalQualificationRequired -and [bool]$closure.fullNameListFallbackForbidden -and [bool]$closure.cyclicMmsProcessPollingForbidden + $p10 = $lock.p10IdentityTrial + $p10Trial = $null -ne $p10 -and + $p10.status -eq 'code-trial-not-physical' -and + [int]$p10.enginePullRequest -eq 161 -and + [int]$lock.sourcePullRequest -eq 161 -and + $lock.commit -eq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + $p10.exactCommit -eq $lock.commit -and + $p10.baseEngineCommit -eq $closure.exactCommit -and + $p10.identitySource -eq 'TrustedSclExactDomainMatch' -and + $lock.ref -eq 'main' -and + [bool]$p10.physicalQualificationRequired -and + [bool]$p10.fullNameListFallbackForbidden -and + [bool]$p10.cyclicMmsProcessPollingForbidden -and + [bool]$closure.physicalQualificationRequired -and + [bool]$closure.fullNameListFallbackForbidden -and + [bool]$closure.cyclicMmsProcessPollingForbidden $serverAtTrial = $null -ne $serverAt -and $lock.commit -eq $serverAt.exactCommit -and $serverAt.exactCommit -eq '352c81e6a798635c6addcee0683235ca87ad416d' -and @@ -115,7 +131,7 @@ jobs: if ($trial.status -ne 'code-verified-not-physical' -or [int]$trial.issue -ne 144 -or [int]$trial.integrationPullRequest -ne 148 -or - -not ($originalTrial -or $serverAtTrial -or $datasetTrial) -or + -not ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial) -or -not [bool]$trial.fullDiscoveryFallbackForbidden -or -not [bool]$trial.vendorBranchingForbidden -or -not [bool]$trial.physicalPromotionRequired) { From 6179df3253f2f319c10d2e9a75b77ed250b09b03 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 09:23:35 +0700 Subject: [PATCH 36/99] fix(ci): keep P10 engine TRX separate from exact canonical ARSAS proof --- .github/workflows/build.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1d072226..c87ca988c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -409,7 +409,7 @@ jobs: run: dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore - name: Run pinned engine P10 identity and canonical SCL regression - run: dotnet test .\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --filter "FullyQualifiedName~TrustedSclIedIdentityMatcherTests|FullyQualifiedName~CanonicalLiveIedSclExporterTests" --logger "trx;LogFileName=engine-p10-identity.trx" --results-directory .\ArIED61850Tester\TestResults + run: dotnet test .\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --filter "FullyQualifiedName~TrustedSclIedIdentityMatcherTests|FullyQualifiedName~CanonicalLiveIedSclExporterTests" --logger "trx;LogFileName=engine-p10-identity.trx" --results-directory .\ArIED61850Tester\EngineTestResults - name: Run ARSAS application regression tests run: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-tests.trx" --results-directory .\ArIED61850Tester\TestResults @@ -440,6 +440,14 @@ jobs: ArIED61850Tester\TestResults\ci-canonical-authority.json if-no-files-found: error + - name: Upload P10 engine identity regression evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: ARSAS-p10-engine-identity-evidence + path: ArIED61850Tester\EngineTestResults\engine-p10-identity.trx + if-no-files-found: error + - name: Build and test exact pinned ArdIrec native bridge once shell: powershell run: | From 6d19727443ffb3277d28a10586b3ee558136b179 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 09:26:07 +0700 Subject: [PATCH 37/99] fix(ci): admit only exact P10 engine trial in shared resolver, prove gate failures Preserve physical tested SCL authority and all immutable SHA lineage constraints; extend offline resolver mutation regression. Correct the R10 workflow step labels and repo-root resolution for Windows execution tests. No engine, MMS, reporting, control, GOOSE, or source pin changes. --- .../smart-discovery-mainline-readiness.yml | 4 +- scripts/resolve-ariec-lock.ps1 | 60 ++++++++++++++++++- scripts/test-resolve-ariec-lock.ps1 | 33 ++++++++++ .../P10MainlinePolicyExecutionTests.cs | 4 +- 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index 0509a8306..22655d396 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -178,7 +178,7 @@ jobs: "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 @@ -194,7 +194,7 @@ jobs: 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 diff --git a/scripts/resolve-ariec-lock.ps1 b/scripts/resolve-ariec-lock.ps1 index 9d4ccd5de..ad525f606 100644 --- a/scripts/resolve-ariec-lock.ps1 +++ b/scripts/resolve-ariec-lock.ps1 @@ -68,7 +68,65 @@ if ($null -ne $lock.sclAssociationInteroperability) { if ($null -ne $association.testedEngineCommit) { Assert-Sha -Value ([string]$association.testedEngineCommit) -Name 'sclAssociationInteroperability.testedEngineCommit' if ([string]$association.testedEngineCommit -cne [string]$lock.commit) { - throw 'SCL association physical-tested engine head differs from integration lock.' + # Historical SCL physical acceptance is immutable. The active + # runtime SHA may differ ONLY for an exact, ancestry-bound trial. + $trial = $lock.smartInteroperabilityCodeTrial + $serverAt = $lock.sclServerAtTrial + $closure = $lock.datasetFirstDiscoveryTrial + $p10 = $lock.p10IdentityTrial + $historicalAnchorValid = ( + [string]$association.testedEngineCommit -ceq '84e9820e5a32690475960e49d5ef74e6637847fd' -and + [string]$association.mergedEngineCommit -ceq 'e5deed1d8aa11d97991695c6e390baafea7ab797' -and + [string]$lock.mergedMainCommit -ceq '648124097621046f5f127ceb1cf853fea54db730' -and + [string]$lock.previousStablePin.commit -ceq [string]$lock.mergedMainCommit -and + [string]$association.contractId -ceq 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -and + [bool]$association.preservesR10DiscoveryModelBaseline -and + [bool]$association.fullDiscoveryFallbackForbidden -and + [bool]$association.cyclicMmsProcessPollingForbidden + ) + $trialValid = $null -ne $trial -and + [string]$trial.exactCommit -ceq '9c5292570f55dd81be1b3a6b56f937e5ba1ed276' -and + [string]$trial.status -ceq 'code-verified-not-physical' -and + [int]$trial.integrationPullRequest -eq 148 -and + [bool]$trial.physicalPromotionRequired -and + [bool]$trial.fullDiscoveryFallbackForbidden -and + [bool]$trial.vendorBranchingForbidden + $serverAtValid = $trialValid -and $null -ne $serverAt -and + [string]$serverAt.exactCommit -ceq '352c81e6a798635c6addcee0683235ca87ad416d' -and + [string]$serverAt.baseEngineCommit -ceq [string]$trial.exactCommit -and + [int]$serverAt.sourcePullRequest -eq 153 -and + [string]$serverAt.status -ceq 'code-verified-candidate-not-physical' -and + [bool]$serverAt.physicalQualificationRequired -and + [bool]$serverAt.noAutomaticMmsFailover + $closureValid = $serverAtValid -and $null -ne $closure -and + [string]$closure.exactCommit -ceq '8b0c0f7e695b016834e40a6f068994dcc945cb1a' -and + [string]$closure.baseEngineCommit -ceq [string]$serverAt.exactCommit -and + [int]$closure.enginePullRequest -eq 157 -and + [int]$closure.consumerPullRequest -eq 497 -and + [string]$closure.status -ceq 'code-trial-not-physical' -and + [bool]$closure.physicalQualificationRequired -and + [bool]$closure.fullNameListFallbackForbidden -and + [bool]$closure.cyclicMmsProcessPollingForbidden + $p10Valid = $closureValid -and $null -ne $p10 -and + [string]$p10.exactCommit -ceq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + [string]$p10.baseEngineCommit -ceq [string]$closure.exactCommit -and + [int]$p10.enginePullRequest -eq 161 -and + [string]$p10.status -ceq 'code-trial-not-physical' -and + [string]$p10.identitySource -ceq 'TrustedSclExactDomainMatch' -and + [bool]$p10.physicalQualificationRequired -and + [bool]$p10.fullNameListFallbackForbidden -and + [bool]$p10.cyclicMmsProcessPollingForbidden + + $acceptedTrial = ( + ($trialValid -and [string]$lock.commit -ceq [string]$trial.exactCommit -and [int]$lock.sourcePullRequest -eq 148) -or + ($serverAtValid -and [string]$lock.commit -ceq [string]$serverAt.exactCommit -and [int]$lock.sourcePullRequest -eq 153) -or + ($closureValid -and [string]$lock.commit -ceq [string]$closure.exactCommit -and [int]$lock.sourcePullRequest -eq 157) -or + ($p10Valid -and [string]$lock.commit -ceq [string]$p10.exactCommit -and [int]$lock.sourcePullRequest -eq 161) + ) + if (-not $historicalAnchorValid -or -not $acceptedTrial) { + throw 'SCL association physical-tested engine head differs from integration lock.' + } + Write-Host 'Exact stacked code trial recognized; physical SCL-association/R10 authority remains historical and promotion-blocked.' } } if ($null -ne $association.mergedEngineCommit) { diff --git a/scripts/test-resolve-ariec-lock.ps1 b/scripts/test-resolve-ariec-lock.ps1 index 4f1afc5f2..d1b0f53cc 100644 --- a/scripts/test-resolve-ariec-lock.ps1 +++ b/scripts/test-resolve-ariec-lock.ps1 @@ -79,6 +79,39 @@ try { } Assert-Rejects $wrongAssociation 'physical-tested' + # Exact P10 lineage can be resolved as CODE TRIAL without converting + # the older physically accepted association head into a false runtime pin. + function New-TrackedP10Fixture { + $tracked = Join-Path $PSScriptRoot '..\engines\ARIEC61850.lock.json' + $source = Get-Content -LiteralPath $tracked -Raw -Encoding UTF8 | + ConvertFrom-Json -AsHashtable -Depth 40 + $copy = @{} + foreach ($key in $source.Keys) { $copy[$key] = $source[$key] } + return $copy + } + + Assert-Resolves (New-TrackedP10Fixture) + + $wrongP10Parent = New-TrackedP10Fixture + $wrongP10Parent.p10IdentityTrial.baseEngineCommit = ('f' * 40) + Assert-Rejects $wrongP10Parent 'physical-tested' + + $wrongP10Source = New-TrackedP10Fixture + $wrongP10Source.sourcePullRequest = 160 + Assert-Rejects $wrongP10Source 'physical-tested' + + $wrongP10Polling = New-TrackedP10Fixture + $wrongP10Polling.p10IdentityTrial.cyclicMmsProcessPollingForbidden = $false + Assert-Rejects $wrongP10Polling 'physical-tested' + + $wrongP10History = New-TrackedP10Fixture + $wrongP10History.sclAssociationInteroperability.testedEngineCommit = ('f' * 40) + Assert-Rejects $wrongP10History 'physical-tested' + + $wrongP10Pin = New-TrackedP10Fixture + $wrongP10Pin.commit = ('f' * 40) + Assert-Rejects $wrongP10Pin 'physical-tested' + Write-Host "CI-P1 immutable engine resolver: $checks synthetic cases PASS." } finally { diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs index f01050ef3..85b956267 100644 --- a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs +++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs @@ -138,7 +138,9 @@ private static string FindRepositoryRoot() while (current is not null) { if (File.Exists(Path.Combine(current.FullName, - "engines", "ARIEC61850.lock.json"))) + "engines", "ARIEC61850.lock.json")) && + File.Exists(Path.Combine(current.FullName, ".github", + "workflows", "smart-discovery-mainline-readiness.yml"))) return current.FullName; current = current.Parent; } From 2369c286da6d14dda2470a2854daf2d46d588dfe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 09:27:31 +0700 Subject: [PATCH 38/99] fix(ci): honor 35-artifact budget and retain isolated P10 engine test step --- .github/workflows/build.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c87ca988c..bb7aa2d90 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -440,14 +440,6 @@ jobs: ArIED61850Tester\TestResults\ci-canonical-authority.json if-no-files-found: error - - name: Upload P10 engine identity regression evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: ARSAS-p10-engine-identity-evidence - path: ArIED61850Tester\EngineTestResults\engine-p10-identity.trx - if-no-files-found: error - - name: Build and test exact pinned ArdIrec native bridge once shell: powershell run: | From 9b19abcb8ebfb0c5af6da5825ec530c622c79abb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:03:55 +0700 Subject: [PATCH 39/99] feat(p10.4): offline SHA-pinned GE Edition 1 discovery SCL structural evidence gate --- scripts/verify-p10-ge-field-scl.py | 294 +++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 scripts/verify-p10-ge-field-scl.py diff --git a/scripts/verify-p10-ge-field-scl.py b/scripts/verify-p10-ge-field-scl.py new file mode 100644 index 000000000..d77a64983 --- /dev/null +++ b/scripts/verify-p10-ge-field-scl.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""P10.4 GE Edition 1 SCL structural evidence gate (offline, read-only). + +Not a physical IEC 61850 qualification: an operator must separately provide +association/PCAP, InformationReport, GI, continuity and semantic evidence. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from collections import Counter +from pathlib import Path +from xml.etree import ElementTree as ET + +NS = "http://www.iec.ch/61850/2003/SCL" +TAG = lambda local: f"{{{NS}}}{local}" +MAX_BYTES = 16 * 1024 * 1024 +HEX_SHA = re.compile(r"^[0-9a-fA-F]{64}$") + + +class RejectedEvidence(ValueError): + """A malformed or unauthoritative field input.""" + + +def read_bounded(path: Path) -> bytes: + size = path.stat().st_size + if size <= 0 or size > MAX_BYTES: + raise RejectedEvidence(f"{path.name}: file size outside (0, {MAX_BYTES}] bytes") + data = path.read_bytes() + if len(data) != size: + raise RejectedEvidence(f"{path.name}: file changed during read") + return data + + +def scl_tree(data: bytes, label: str) -> ET.Element: + # Do not allow entities/DTD or silently accept namespace-free pseudo SCL. + if re.search(rb" str: + return (element.get(name) or "").strip() + + +def ied(root: ET.Element, name: str, label: str) -> ET.Element: + candidates = [x for x in root.findall(TAG("IED")) if attr(x, "name") == name] + return only(candidates, f"{label}: IED '{name}'") + + +def access_point(ied_node: ET.Element, name: str, label: str) -> ET.Element: + return only([x for x in ied_node.findall(TAG("AccessPoint")) + if attr(x, "name") == name], f"{label}: AccessPoint '{name}'") + + +def endpoint(root: ET.Element, name: str, ap: str, label: str) -> str | None: + comm = root.find(TAG("Communication")) + if comm is None: + return None + matches = [node for node in comm.iter(TAG("ConnectedAP")) + if attr(node, "iedName") == name and attr(node, "apName") == ap] + if len(matches) > 1: + raise RejectedEvidence(f"{label}: ambiguous ConnectedAP '{name}/{ap}'") + if not matches: + return None + addr = matches[0].find(TAG("Address")) + if addr is None: + return None + ips = [str(p.text or "").strip() for p in addr.findall(TAG("P")) + if attr(p, "type").upper() == "IP"] + if len(ips) > 1: + raise RejectedEvidence(f"{label}: multiple IP entries") + return ips[0] if ips else None + + +def snapshot(root: ET.Element, name: str, ap_name: str, label: str) -> dict: + own_ied = ied(root, name, label) + own_ap = access_point(own_ied, ap_name, label) + server = only(own_ap.findall(TAG("Server")), f"{label}: Server") + ld_by_inst = {} + domains = {} + logical_nodes = set() + datasets = {} + reports = [] + goose = [] + + for ld in server.findall(TAG("LDevice")): + inst = attr(ld, "inst") + if not inst or inst in ld_by_inst: + raise RejectedEvidence(f"{label}: missing/duplicate LDevice.inst '{inst}'") + ld_by_inst[inst] = ld + domain = attr(ld, "ldName") or name + inst + if domain in domains: + raise RejectedEvidence(f"{label}: duplicate resolved MMS domain '{domain}'") + domains[domain] = inst + for ln in list(ld): + if ln.tag not in (TAG("LN0"), TAG("LN")): + continue + ln_class = attr(ln, "lnClass") or ("LLN0" if ln.tag == TAG("LN0") else "") + prefix, ln_inst = attr(ln, "prefix"), attr(ln, "inst") + node_key = (inst, prefix, ln_class, ln_inst) + if not ln_class or node_key in logical_nodes: + raise RejectedEvidence(f"{label}: invalid/duplicate logical node {node_key}") + logical_nodes.add(node_key) + for ds in ln.findall(TAG("DataSet")): + ds_name = attr(ds, "name") + key = (inst, prefix, ln_class, ln_inst, ds_name) + if not ds_name or key in datasets: + raise RejectedEvidence(f"{label}: duplicate/empty DataSet {key}") + members = [] + for fcda in ds.findall(TAG("FCDA")): + target_ld = attr(fcda, "ldInst") or inst + fc = attr(fcda, "fc") + do = attr(fcda, "doName") + ln_cl = attr(fcda, "lnClass") + if not target_ld or not ln_cl or not do or not fc: + raise RejectedEvidence(f"{label}: incomplete FCDA in {key}") + members.append((target_ld, attr(fcda, "prefix"), ln_cl, + attr(fcda, "lnInst"), do, + attr(fcda, "daName"), fc, attr(fcda, "ix"))) + if not members: + raise RejectedEvidence(f"{label}: empty DataSet {key}") + datasets[key] = members + for rc in ln.findall(TAG("ReportControl")): + rc_name = attr(rc, "name") + ds = attr(rc, "datSet") + if not rc_name or not ds: + raise RejectedEvidence(f"{label}: ReportControl missing name/datSet") + key = (inst, prefix, ln_class, ln_inst, ds) + if key not in datasets: + raise RejectedEvidence(f"{label}: RCB '{rc_name}' binds missing DataSet {key}") + reports.append((inst, prefix, ln_class, ln_inst, rc_name, ds, + attr(rc, "buffered"), attr(rc, "indexed"))) + for cb in ln.findall(TAG("GSEControl")): + goose.append((inst, prefix, ln_class, ln_inst, + attr(cb, "name"), attr(cb, "datSet"))) + + if not ld_by_inst: + raise RejectedEvidence(f"{label}: missing LDevice") + for key, members in datasets.items(): + for member in members: + if member[0] not in ld_by_inst: + raise RejectedEvidence(f"{label}: FCDA targets unknown LD '{member[0]}'") + if member[:4] not in logical_nodes: + raise RejectedEvidence(f"{label}: FCDA targets unknown LN {member[:4]}") + return { + "domains": domains, + "logicalNodes": logical_nodes, + "datasets": datasets, + "reports": reports, + "goose": goose, + "endpoint": endpoint(root, name, ap_name, label), + } + + +def readable_key(key: tuple) -> str: + return "/".join(str(x) for x in key) + + +def compare(trusted: dict, saved: dict, domain: str, ip: str, + expected_datasets: int, expected_members: int) -> dict: + failures, review = [], [] + for label, snap in (("trusted", trusted), ("discovery export", saved)): + if snap["domains"] != {domain: "F650"}: + failures.append(f"{label}: MMS wire-domain→LD mapping differs from BCUGEF650→F650") + if snap["endpoint"] is not None and snap["endpoint"] != ip: + failures.append(f"{label}: SCL declared IP '{snap['endpoint']}' differs from '{ip}'") + if snap["endpoint"] is None: + review.append(f"{label}: no authoritative ConnectedAP IP; endpoint must be checked from field capture") + if len(snap["datasets"]) != expected_datasets: + failures.append(f"{label}: DataSets={len(snap['datasets'])}; expected {expected_datasets}") + count = sum(len(m) for m in snap["datasets"].values()) + if count != expected_members: + failures.append(f"{label}: FCDA members={count}; expected {expected_members}") + + if trusted["datasets"] != saved["datasets"]: + missing = trusted["datasets"].keys() - saved["datasets"].keys() + extra = saved["datasets"].keys() - trusted["datasets"].keys() + changed = [key for key in trusted["datasets"].keys() & saved["datasets"].keys() + if trusted["datasets"][key] != saved["datasets"][key]] + failures.append(f"DataSet FCDA ordered semantic mismatch: missing={len(missing)}, " + f"extra={len(extra)}, changed={len(changed)}") + review.extend("DataSet mismatch: " + readable_key(key) + for key in list(sorted(missing | extra | set(changed)))[:12]) + + # Different design/offline vs discoverable live scope is NOT made invisible; + # this is a separate engineering review, not a DataSet/reporting pass. + missing_ln = trusted["logicalNodes"] - saved["logicalNodes"] + added_ln = saved["logicalNodes"] - trusted["logicalNodes"] + if missing_ln or added_ln: + review.append(f"LN design/live scope differs: trusted-only={len(missing_ln)}, " + f"export-only={len(added_ln)}; DO/DA semantic parity NOT VERIFIED") + if Counter(trusted["reports"]) != Counter(saved["reports"]): + review.append("Logical ReportControl configuration differs from trusted IID; " + "RCB semantic parity and buffer continuity NOT VERIFIED") + if Counter(trusted["goose"]) != Counter(saved["goose"]): + review.append("GOOSE control set differs; GOOSE address/cb reference parity NOT VERIFIED") + + return {"structuralVerdict": "FAIL" if failures else "STRUCTURE_PASS_ONLY", + "failures": failures, "review": review, + "dataSets": len(saved["datasets"]), + "staticMembers": sum(len(m) for m in saved["datasets"].values()), + "logicalNodes": {"trusted": len(trusted["logicalNodes"]), + "discoveryExport": len(saved["logicalNodes"])}, + "logicalReportControls": {"trusted": len(trusted["reports"]), + "discoveryExport": len(saved["reports"])}} + + +def assess(trusted_file: Path, generated_file: Path, source_sha: str, + domain: str, ip: str, ap_name: str, datasets: int = 8, + members: int = 124) -> dict: + if not HEX_SHA.fullmatch(source_sha): + raise RejectedEvidence("trusted source SHA-256 must be exactly 64 hex characters") + if trusted_file.resolve() == generated_file.resolve(): + raise RejectedEvidence("source IID and Discovery-export SCL cannot be the same file") + origin = read_bounded(trusted_file) + actual = hashlib.sha256(origin).hexdigest() + if actual.lower() != source_sha.lower(): + raise RejectedEvidence("trusted engineering IID SHA-256 mismatch; fail closed") + output = read_bounded(generated_file) + t = snapshot(scl_tree(origin, "trusted IID"), "BCUGE", ap_name, "trusted IID") + g = snapshot(scl_tree(output, "Discovery SCL"), "BCUGE", ap_name, "Discovery SCL") + result = compare(t, g, domain, ip, datasets, members) + return { + "schema": "arsas-p10-ge-field-check-v1", + "source": {"sha256": actual, "bytes": len(origin)}, + "discoveryExport": {"sha256": hashlib.sha256(output).hexdigest(), "bytes": len(output)}, + "expectedEndpoint": f"{ip}:102", + "observedDomainClaim": domain, + "identity": "BCUGE", + "logicalDevice": "F650", + **result, + "unverifiedPhysicalGates": [ + "actual MMS association/device provenance and raw domain name-list", + "67/67 runtime points and six concrete static RCBs with InformationReport traffic", + "zero cyclic MMS process-value polling and valid GI causality", + "LN/DO/DA semantic closure, 6400 name-list cap and incomplete chains", + "BRCB BufOvfl/sequence/EntryID replay continuity across sessions", + "GOOSE address and wire subscriber semantics", + ], + "physicalQualification": "NOT_VERIFIED", + "r10PromotionAllowed": False, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--trusted-iid", type=Path, required=True) + parser.add_argument("--trusted-sha256", required=True) + parser.add_argument("--discovery-scl", type=Path, required=True) + parser.add_argument("--observed-mms-domain", required=True) + parser.add_argument("--endpoint-ip", default="192.16.1.33") + parser.add_argument("--access-point", required=True) + parser.add_argument("--out", type=Path, help="Optional JSON report path") + args = parser.parse_args(argv) + try: + result = assess(args.trusted_iid, args.discovery_scl, + args.trusted_sha256, args.observed_mms_domain, + args.endpoint_ip, args.access_point) + code = 2 if result["structuralVerdict"] == "FAIL" else 0 + except (OSError, RejectedEvidence) as exc: + result = {"schema": "arsas-p10-ge-field-check-v1", + "structuralVerdict": "REJECTED_INPUT", + "reason": str(exc), "physicalQualification": "NOT_VERIFIED", + "r10PromotionAllowed": False} + code = 2 + report = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + print(report) + if args.out is not None: + try: + args.out.write_text(report + "\n", encoding="utf-8") + except OSError as exc: + print(f"Could not write report: {exc}", file=sys.stderr) + return 2 + return code + + +if __name__ == "__main__": + sys.exit(main()) From 92460e90a52f71ceabff36c0b3fb68ad7ecd215b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:04:37 +0700 Subject: [PATCH 40/99] test(p10.4): verify GE SCL gate fail-closed and never promotes synthetic physical evidence --- scripts/test-verify-p10-ge-field-scl.py | 168 ++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 scripts/test-verify-p10-ge-field-scl.py diff --git a/scripts/test-verify-p10-ge-field-scl.py b/scripts/test-verify-p10-ge-field-scl.py new file mode 100644 index 000000000..5c417842b --- /dev/null +++ b/scripts/test-verify-p10-ge-field-scl.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Offline P10.4 GE field SCL gate regression: no hardware or network required.""" +import hashlib +import importlib.util +import tempfile +import unittest +from pathlib import Path +from xml.etree import ElementTree as ET + +SCRIPT = Path(__file__).with_name("verify-p10-ge-field-scl.py") +spec = importlib.util.spec_from_file_location("p10_ge_gate", SCRIPT) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) +N = gate.NS +T = gate.TAG + + +def example(*, ip="192.16.1.33", ied="BCUGE", inst="F650", + ap="S1", wire_ld_name=None, members=124, remove_ln=False): + ET.register_namespace("", N) + root = ET.Element(T("SCL")) + comm = ET.SubElement(root, T("Communication")) + subnet = ET.SubElement(comm, T("SubNetwork")) + conn = ET.SubElement(subnet, T("ConnectedAP"), + {"iedName": ied, "apName": ap}) + address = ET.SubElement(conn, T("Address")) + ET.SubElement(address, T("P"), {"type": "IP"}).text = ip + node = ET.SubElement(root, T("IED"), {"name": ied}) + access = ET.SubElement(node, T("AccessPoint"), {"name": ap}) + server = ET.SubElement(access, T("Server")) + attrs = {"inst": inst} + if wire_ld_name: + attrs["ldName"] = wire_ld_name + ld = ET.SubElement(server, T("LDevice"), attrs) + ln = ET.SubElement(ld, T("LN0"), {"lnClass": "LLN0"}) + if not remove_ln: + ET.SubElement(ld, T("LN"), {"lnClass": "CSWI", "inst": "7"}) + for index in range(8): + ds = ET.SubElement(ln, T("DataSet"), {"name": f"DS{index + 1}"}) + # 4*16+4*15=124: non-overlapping ordered FCDA evidence. + count = (16 if index < 4 else 15) + if members != 124 and index == 0: + count += members - 124 + for subindex in range(max(count, 0)): + ET.SubElement(ds, T("FCDA"), + {"ldInst": inst, "lnClass": "CSWI", + "lnInst": "7", "doName": f"Pos{subindex}", + "daName": "stVal", "fc": "ST"}) + if index < 6: + ET.SubElement(ln, T("ReportControl"), + {"name": f"RCB{index + 1}", "datSet": f"DS{index + 1}", + "buffered": "true"}) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +class GeFieldEvidenceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.src = Path(self.temp.name, "BCU_GE.iid") + self.out = Path(self.temp.name, "BCUGE-discovery.icd") + self.src.write_bytes(example()) + self.out.write_bytes(example()) + + def check(self, sha=None, domain="BCUGEF650"): + return gate.assess( + self.src, self.out, + sha or hashlib.sha256(self.src.read_bytes()).hexdigest(), + domain, "192.16.1.33", "S1") + + def test_valid_structure_is_explicitly_not_physical_qualification(self): + result = self.check() + self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) + self.assertEqual(8, result["dataSets"]) + self.assertEqual(124, result["staticMembers"]) + self.assertEqual("NOT_VERIFIED", result["physicalQualification"]) + self.assertFalse(result["r10PromotionAllowed"]) + self.assertTrue(any("BRCB" in s for s in result["unverifiedPhysicalGates"])) + + def test_source_sha_drift_fails_closed(self): + with self.assertRaisesRegex(gate.RejectedEvidence, "SHA-256 mismatch"): + self.check("0" * 64) + + def test_same_scl_file_fails_closed(self): + with self.assertRaisesRegex(gate.RejectedEvidence, "same file"): + gate.assess(self.src, self.src, + hashlib.sha256(self.src.read_bytes()).hexdigest(), + "BCUGEF650", "192.16.1.33", "S1") + + def test_wrong_observed_wire_domain_fails_even_if_scl_graph_equal(self): + self.assertEqual("FAIL", self.check(domain="BCUGECTRL")["structuralVerdict"]) + + def test_wrong_endpoint_rejected(self): + self.out.write_bytes(example(ip="192.16.1.34")) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_ipless_scl_is_explicit_review_not_invented_endpoint(self): + root = ET.fromstring(example()) + root.find(f".//{T('Address')}").clear() + self.out.write_bytes(ET.tostring(root)) + result = self.check() + self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) + self.assertTrue(any("no authoritative ConnectedAP IP" in s for s in result["review"])) + + def test_f650_must_not_become_mms_domain_inst(self): + self.out.write_bytes(example(inst="BCUGEF650")) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_extra_explicit_ldname_is_detected(self): + self.out.write_bytes(example(wire_ld_name="OTHER_WIRE_DOMAIN")) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_missing_static_member_fails(self): + self.out.write_bytes(example(members=123)) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_member_order_change_fails_not_just_count(self): + root = ET.fromstring(example()) + ds = root.find(f".//{T('DataSet')}") + ds[0], ds[1] = ds[1], ds[0] + self.out.write_bytes(ET.tostring(root)) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_fcda_unknown_ln_rejected(self): + self.out.write_bytes(example(remove_ln=True)) + with self.assertRaisesRegex(gate.RejectedEvidence, "unknown LN"): + self.check() + + def test_no_false_rcb_continuity_from_matching_logical_rcbs(self): + result = self.check() + self.assertEqual(6, result["logicalReportControls"]["discoveryExport"]) + self.assertEqual("NOT_VERIFIED", result["physicalQualification"]) + + def test_different_rcb_configuration_is_review_not_physical_pass(self): + root = ET.fromstring(example()) + root.find(f".//{T('ReportControl')}").set("buffered", "false") + self.out.write_bytes(ET.tostring(root)) + result = self.check() + self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) + self.assertTrue(any("Logical ReportControl" in s for s in result["review"])) + self.assertFalse(result["r10PromotionAllowed"]) + + def test_source_extra_design_ln_is_review_not_dataset_failure(self): + root = ET.fromstring(example()) + ld = root.find(f".//{T('LDevice')}") + ET.SubElement(ld, T("LN"), {"lnClass": "MMXU", "inst": "1"}) + self.src.write_bytes(ET.tostring(root)) + result = self.check() + self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) + self.assertTrue(any("LN design/live scope differs" in s for s in result["review"])) + + def test_dtd_and_entities_rejected(self): + evil = b']>' + self.out.write_bytes(evil) + with self.assertRaisesRegex(gate.RejectedEvidence, "DTD/ENTITY"): + self.check() + + def test_ambiguous_accesspoint_rejected(self): + root = ET.fromstring(example()) + access = root.find(f".//{T('AccessPoint')}") + root.find(f".//{T('IED')}").append(ET.fromstring(ET.tostring(access))) + self.out.write_bytes(ET.tostring(root)) + with self.assertRaisesRegex(gate.RejectedEvidence, "AccessPoint"): + self.check() + + +if __name__ == "__main__": + unittest.main() From 620b04a144ea7ff81e7fa1d5007d25c6eb768e51 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:05:09 +0700 Subject: [PATCH 41/99] docs(p10.4): GE Edition1 field acceptance and SHA-pinned offline SCL comparison --- docs/P10_GE_ED1_FIELD_ACCEPTANCE.md | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/P10_GE_ED1_FIELD_ACCEPTANCE.md diff --git a/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md b/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md new file mode 100644 index 000000000..1081e75ee --- /dev/null +++ b/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md @@ -0,0 +1,46 @@ +# P10.3/P10.4 — GE F650 Edition 1 physical-evidence procedure + +**Code authority:** ARSAS staging P10.2 commit \`2369c286da6d14dda2470a2854daf2d46d588dfe\` and engine lock \`00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1\`. No rebasing to old main and no substitution of engine PR #160. The artifact built by the exact staging [Build ARSAS #38105708427](https://github.com/masarray/arsas/actions/runs/38105708427) is the P10.3 regression-qualified **candidate**, not field-qualified release. + +## Trust and safety gates + +- Use **an independently supplied original** \`BCU_GE.iid\`, not an SCL generated by ARSAS and later renamed as engineering authority. Record its **actual file SHA-256** before opening, and preserve the source unchanged. The source and generated SCL must be separate files. +- Observe real MMS session **192.16.1.33:102** on the authorized substation network, from a qualified operator computer. Do not use direct device control operations or write data to create test traffic. +- In ARSAS, **Open SCL** original IID and select the actual \`BCUGE\` / AccessPoint. This establishes SHA-bound trusted identity. Then Discovery on the same endpoint, without clearing the source-association context. If live workflow creates a new card, ensure the trusted SCL remains bound to that card before deciding identity is high-confidence. +- Discovery must show IED \`BCUGE\`, \`LDevice.inst=F650\`, and observed *raw* MMS domain \`BCUGEF650\`. Without trusted exact SCL evidence, provisional \`IED_192_16_1_33\` is safer than heuristically guessing a device name. +- Select **Static DataSet** (not dynamic/polling), confirm 8 static DataSets, 124 ordered members, 67 selected runtime points and six concrete RCB streams with actual InformationReport traffic. GI and report-backed causality must be observed. **No cyclic MMS process-value reads** and no auto fallback. +- **Save SCL** in full-model Edition 1, then **Open SCL** the saved file and reconnect using the reloaded model; collect separate before/after diagnostics, SCL report files and PCAP (where permitted). The existing \`CanonicalSclReloadValidator\` checks internal reload and association structure; our offline check below independently compares to the original SHA-pinned IID. +- Test BRCB continuity in its **own** session evidence: timestamp, RCB, BufOvfl, SqNum/SubSqNum, EntryID, GI reason and replay after reconnect. Treat missing/ambiguous fields as incomplete, not zero faults. +- Evaluate design-only LN/DO/DA structures, observed GetNameList cap 6400 and \`incompleteChains\` separately. 8/8 DataSets is not proof of full model closure. GOOSE bindings/addresses also require separate verification. + +## Read-only offline SCL verification + +On the authorized local computer, use Python 3 already used by ARSAS CI (no additional libraries). In PowerShell: + +\`\`\`powershell +$iid = 'C:\field\BCU_GE.iid' +$export = 'C:\field\BCUGE-discovery.icd' +$sha = (Get-FileHash -Algorithm SHA256 $iid).Hash.ToLowerInvariant() +python .\scripts\verify-p10-ge-field-scl.py \` + --trusted-iid $iid \` + --trusted-sha256 $sha \` + --discovery-scl $export \` + --observed-mms-domain BCUGEF650 \` + --endpoint-ip 192.16.1.33 \` + --access-point S1 \` + --out 'C:\field\p10-ge-structural-evidence.json' +\`\`\` + +Replace \`S1\` with the **actual trusted** AccessPoint if different. The observed-domain argument is an operator-supplied claim; save independent device logs/PCAP proving that claim. The tool hashes and checks both input files, is strictly offline, does not connect to a relay, and has no write path other than an explicitly requested JSON report. It fails closed on missing source SHA, ambiguous AP/IED, wrong IP, changed MMS wire-domain, missing LD/LN, or drift in ordered DataSet/FCDA members. It compares engineering-vs-export logical RCB and GOOSE definitions and reports them separately for review. It cannot infer unobserved MMS values, packet continuity, or physical certification. + +Possible structural outcomes: \`STRUCTURE_PASS_ONLY\`, \`FAIL\`, or \`REJECTED_INPUT\`. Even the passing case **always** has \`physicalQualification=NOT_VERIFIED\` and \`r10PromotionAllowed=false\`. It is **never** a substitute for actual IEC 61850 field evidence. + +The JSON records input SHA, structural discrepancies and explicitly lists all unverified physical gates. Do **not** modify \`evidence/interoperability-reference-target.json\` or change CI approval flags from this output. + +## Field package to attach for P10.4 review + +Preserve original IID plus original SHA256, exported ICD/IID, generated export report, full ARSAS Diagnostics from Discovery and Open SCL, packet capture (if allowed), and RCB continuity evidence with timestamps for both sessions. If PCAP contains sensitive operational data, share a redacted engineering-safe version but retain original hash/provenance in the field record. + +### Exit criteria + +Only mark P10.4 physically accepted after: verified field provenance; correct BCUGE/F650/BCUGEF650 mapping; 8/124 member match; report-only 67 runtime / 6 active RCB with real traffic; no cyclic fallback; Save→Open SCL endpoint/association round-trip; independently reviewed LN/DO/DA/name-list completeness; and independently reviewed BRCB replay/GI continuity. No claim that successful CI or this offline tool satisfies those conditions. From cf2fb06ed857fc2a0a0f39aa9b5f0504fe554d17 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:05:45 +0700 Subject: [PATCH 42/99] ci(p10.4): run 16 bounded offline GE field SCL acceptance gate regressions --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb7aa2d90..8f0769fef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -106,6 +106,8 @@ jobs: run: | python .\ArIED61850Tester\scripts\test-maintenance-dependencies.py if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory tests failed." } + python .\ArIED61850Tester\scripts\test-verify-p10-ge-field-scl.py + if ($LASTEXITCODE -ne 0) { throw "P10 GE trusted-IID SCL field gate fixture regressions failed." } python .\ArIED61850Tester\scripts\audit-maintenance-dependencies.py --root .\ArIED61850Tester --output "$env:RUNNER_TEMP\arsas-maintenance-dependencies.json" if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory generation failed." } From 2db58bfd1c39c25503e737feb239e8fe583d30c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:06:05 +0700 Subject: [PATCH 43/99] docs(p10.4): insist on independently pinned IID intake hash, not self-derived hash --- docs/P10_GE_ED1_FIELD_ACCEPTANCE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md b/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md index 1081e75ee..28001f6e5 100644 --- a/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md +++ b/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md @@ -20,7 +20,7 @@ On the authorized local computer, use Python 3 already used by ARSAS CI (no addi \`\`\`powershell $iid = 'C:\field\BCU_GE.iid' $export = 'C:\field\BCUGE-discovery.icd' -$sha = (Get-FileHash -Algorithm SHA256 $iid).Hash.ToLowerInvariant() +$sha = '<64-character SHA256 copied from original independent intake record>' python .\scripts\verify-p10-ge-field-scl.py \` --trusted-iid $iid \` --trusted-sha256 $sha \` @@ -31,7 +31,7 @@ python .\scripts\verify-p10-ge-field-scl.py \` --out 'C:\field\p10-ge-structural-evidence.json' \`\`\` -Replace \`S1\` with the **actual trusted** AccessPoint if different. The observed-domain argument is an operator-supplied claim; save independent device logs/PCAP proving that claim. The tool hashes and checks both input files, is strictly offline, does not connect to a relay, and has no write path other than an explicitly requested JSON report. It fails closed on missing source SHA, ambiguous AP/IED, wrong IP, changed MMS wire-domain, missing LD/LN, or drift in ordered DataSet/FCDA members. It compares engineering-vs-export logical RCB and GOOSE definitions and reports them separately for review. It cannot infer unobserved MMS values, packet continuity, or physical certification. +Replace \`S1\` with the **actual trusted** AccessPoint if different. Do not derive the expected trusted hash from the candidate file during the same verification run; compare with the independently recorded original intake hash. The observed-domain argument is an operator-supplied claim; save independent device logs/PCAP proving that claim. The tool hashes and checks both input files, is strictly offline, does not connect to a relay, and has no write path other than an explicitly requested JSON report. It fails closed on missing source SHA, ambiguous AP/IED, wrong IP, changed MMS wire-domain, missing LD/LN, or drift in ordered DataSet/FCDA members. It compares engineering-vs-export logical RCB and GOOSE definitions and reports them separately for review. It cannot infer unobserved MMS values, packet continuity, or physical certification. Possible structural outcomes: \`STRUCTURE_PASS_ONLY\`, \`FAIL\`, or \`REJECTED_INPUT\`. Even the passing case **always** has \`physicalQualification=NOT_VERIFIED\` and \`r10PromotionAllowed=false\`. It is **never** a substitute for actual IEC 61850 field evidence. From 4c469e608493a55ba2ae225113a6191edd778ba4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:07:04 +0700 Subject: [PATCH 44/99] fix(p10.4): reject cross-IED FCDA rather than assuming local MMS reference --- scripts/verify-p10-ge-field-scl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/verify-p10-ge-field-scl.py b/scripts/verify-p10-ge-field-scl.py index d77a64983..91ba07f3a 100644 --- a/scripts/verify-p10-ge-field-scl.py +++ b/scripts/verify-p10-ge-field-scl.py @@ -125,6 +125,9 @@ def snapshot(root: ET.Element, name: str, ap_name: str, label: str) -> dict: members = [] for fcda in ds.findall(TAG("FCDA")): target_ld = attr(fcda, "ldInst") or inst + fcda_ied = attr(fcda, "iedName") + if fcda_ied and fcda_ied != name: + raise RejectedEvidence(f"{label}: FCDA targets external IED '{fcda_ied}'") fc = attr(fcda, "fc") do = attr(fcda, "doName") ln_cl = attr(fcda, "lnClass") From 280f294c5809954636cad593e8a84d60110ad197 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:07:20 +0700 Subject: [PATCH 45/99] test(p10.4): prove cross-IED FCDA rejection in local-wire identity verifier --- scripts/test-verify-p10-ge-field-scl.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/test-verify-p10-ge-field-scl.py b/scripts/test-verify-p10-ge-field-scl.py index 5c417842b..894dbbee3 100644 --- a/scripts/test-verify-p10-ge-field-scl.py +++ b/scripts/test-verify-p10-ge-field-scl.py @@ -149,6 +149,13 @@ def test_source_extra_design_ln_is_review_not_dataset_failure(self): self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) self.assertTrue(any("LN design/live scope differs" in s for s in result["review"])) + def test_external_ied_fcda_is_not_silently_treated_as_local(self): + root = ET.fromstring(example()) + root.find(f".//{T('FCDA')}").set("iedName", "DIFFERENT_IED") + self.out.write_bytes(ET.tostring(root)) + with self.assertRaisesRegex(gate.RejectedEvidence, "external IED"): + self.check() + def test_dtd_and_entities_rejected(self): evil = b']>' self.out.write_bytes(evil) From 34f6f1691d2eff54de668b20239cb322947f0c35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:35:37 +0700 Subject: [PATCH 46/99] fix(p10.5): compute static semantic coverage as unique IEC reference+FC, not overlapping RCB bindings --- Services/StaticAcquisitionParityTracker.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Services/StaticAcquisitionParityTracker.cs b/Services/StaticAcquisitionParityTracker.cs index 76d0a9138..d8f4bd81a 100644 --- a/Services/StaticAcquisitionParityTracker.cs +++ b/Services/StaticAcquisitionParityTracker.cs @@ -136,8 +136,12 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence( if (brcbBindings != planning.StaticBrcbSignalCount || urcbBindings != planning.StaticUrcbSignalCount) evidenceIssues.Add("static plan bindings do not match engine coverage counters"); - if (brcbBindings + urcbBindings + planning.UncoveredSignalCount != planning.RequestedPointCount) - evidenceIssues.Add("static plan coverage does not account for all requested runtime points"); + // Binding counters measure report membership incidences. One IEC point can + // legitimately appear in several configured DataSets / RCBs (GE F650: + // 166 binding incidences across 6 RCBs represent 67 unique runtime points). + // Do not compare incidence sum against distinct requested signal count. + // Check unique exact IEC reference + FC below, after collecting all plans. + var uniqueSelectedPoints = new HashSet(StringComparer.Ordinal); var semanticLines = new List { @@ -217,6 +221,7 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence( .OrderBy(value => value, StringComparer.Ordinal) .ToArray(); + uniqueSelectedPoints.UnionWith(selected); if (selected.Length == 0) evidenceIssues.Add($"DataSet {dataSetReference} has no selected point bindings"); if (plan.Bindings.Any(point => @@ -231,6 +236,13 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence( runtimeTargets.Add(BuildRuntimeTarget(plan)); } + // Fail closed on genuinely incomplete coverage, while preserving the + // existing per-family raw binding counter consistency guards above. + if ((long)uniqueSelectedPoints.Count + planning.UncoveredSignalCount != planning.RequestedPointCount) + evidenceIssues.Add( + $"unique static plan coverage does not account for requested runtime points " + + $"(unique={uniqueSelectedPoints.Count}, uncovered={planning.UncoveredSignalCount}, requested={planning.RequestedPointCount})"); + var canonicalText = string.Join("\n", semanticLines); var fingerprint = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(canonicalText))) From c45cc7ca1064e4bad8de16816cf36e364e0042ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:36:10 +0700 Subject: [PATCH 47/99] test(p10.5): exercise overlapping GE BRCB/URCB references and fail-closed missing unique point --- .../P105StaticParityUniqueCoverageTests.cs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs diff --git a/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs b/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs new file mode 100644 index 000000000..bf10946e6 --- /dev/null +++ b/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs @@ -0,0 +1,132 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// GE F650 field capture: 6 configured RCB plans had 166 binding incidences, +/// but only 67 unique requested runtime signals. A duplicate RCB binding must +/// neither be counted as an uncovered signal nor mask a genuinely missing point. +/// +public sealed class P105StaticParityUniqueCoverageTests +{ + [Theory] + [InlineData(3, true)] + [InlineData(4, false)] + public void OverlappingBrcbAndUrcb_UsesUniqueReferencesAndFc(int requested, bool expectedComparable) + { + var device = new Iec61850MonitorDevice + { + Name = "BCUGE", + IpAddress = "192.16.1.33", + LiveDiscoveryModel = new LiveIedModelDiscoveryDocument + { + IedName = "BCUGE", + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "BCUGEF650/LLN0.Events", + Domain = "BCUGEF650", + LogicalNode = "LLN0", + Name = "Events", + MemberCount = 3, + Members = + [ + new LiveIedDataSetMemberModel { Index = 0, Reference = "BCUGEF650/XCBR1.Pos.stVal", FunctionalConstraint = "ST" }, + new LiveIedDataSetMemberModel { Index = 1, Reference = "BCUGEF650/XCBR2.Pos.stVal", FunctionalConstraint = "ST" }, + new LiveIedDataSetMemberModel { Index = 2, Reference = "BCUGEF650/XCBR3.Pos.stVal", FunctionalConstraint = "ST" } + ] + } + ] + } + }; + static Iec61850MonitorPoint Point(string reference, string fc = "ST") => new() + { + DeviceId = "ge-f650", + IecReference = reference, + FunctionalConstraint = fc + }; + var first = "BCUGEF650/XCBR1.Pos.stVal"; + var second = "BCUGEF650/XCBR2.Pos.stVal"; + var third = "BCUGEF650/XCBR3.Pos.stVal"; + + var planning = new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + RequestedPointCount = requested, + CatalogMappedPointCount = requested, + StaticBrcbSignalCount = 2, + StaticUrcbSignalCount = 2, + UncoveredSignalCount = 0, + ReportPlans = + [ + new ReportControlPlan + { + IsEngineAuthoritative = true, + EngineAcquisitionKind = "StaticBrcb", + Buffered = true, + DataSetReference = "BCUGEF650/LLN0.Events", + ReportControlReference = "BCUGEF650/LLN0.BR.Rpt01", + Bindings = [Point(first), Point(second)] + }, + new ReportControlPlan + { + IsEngineAuthoritative = true, + EngineAcquisitionKind = "StaticUrcb", + Buffered = false, + DataSetReference = "BCUGEF650/LLN0.Events", + ReportControlReference = "BCUGEF650/LLN0.RP.Rpt01", + Bindings = [Point(first), Point(third)] + } + ] + }; + + var evidence = StaticAcquisitionParityTracker.BuildEvidence( + device, planning, StaticAcquisitionIngressKind.LiveDiscovery); + + Assert.Equal(expectedComparable, evidence.IsComparable); + if (expectedComparable) + { + Assert.DoesNotContain("coverage", evidence.IncomparableReason, StringComparison.Ordinal); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, + StaticAcquisitionParityTracker.Compare(evidence, evidence).Status); + } + else + { + // Raw 2+2 binding counters superficially equal requested=4, but + // unique referenced points are only three; never award a false MATCH. + Assert.Contains("unique=3", evidence.IncomparableReason, StringComparison.Ordinal); + Assert.Equal(StaticAcquisitionParityStatus.InsufficientEvidence, + StaticAcquisitionParityTracker.Compare(evidence, evidence).Status); + } + } + + [Fact] + public void MalformedBindingFc_RemainsIncomparableDespiteExactIncidenceCounters() + { + var source = File.ReadAllText(FindRepoFile("Services/StaticAcquisitionParityTracker.cs")); + Assert.Contains("uniqueSelectedPoints.UnionWith(selected)", source, StringComparison.Ordinal); + Assert.Contains("incomplete selected signal identity or FC evidence", source, StringComparison.Ordinal); + Assert.Contains("static plan bindings do not match engine coverage counters", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var candidate = Path.Combine(current.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(candidate)) + return candidate; + var nested = Path.Combine(current.FullName, "ArIED61850Tester", + relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(nested)) + return nested; + current = current.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} From 60d09f79f706a26a857595aed2bae82a53abc42d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:37:16 +0700 Subject: [PATCH 48/99] fix(p10.5 ux): qualify duplicate signal captions by exact LN instance without touching wire references --- Models/MonitorModels.cs | 376 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 11e25bd1b..08aef1709 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -670,6 +670,382 @@ public sealed class Iec61850MonitorPoint : ObservableObject public string PointKey => $"{DeviceId}|{NormalizeReference(IecReference)}"; public string IecTelegram => StripIedNamePrefix(IecReference, DeviceName); + + // Explorer presentation only: a short label must still identify the actual LN + // instance. E.g. 16 distinct CSWI.Pos rows must not all read "CSWI Pos". + // Keep SignalName and the original wire IEC reference untouched for reports, + // commands, search, persistence and engineering comparisons. + public string ExplorerSignalName + { + get + { + var signal = SignalName ?? string.Empty; + var reference = IecReference ?? string.Empty; + var slash = reference.IndexOf('/'); + if (slash < 0) + return signal; + + var start = slash + 1; + var end = reference.IndexOfAny(['.', ' + public string Value + { + get => _value; + set + { + if (Set(ref _value, string.IsNullOrWhiteSpace(value) ? "-" : value)) + { + Raise(nameof(DisplayValue)); + Raise(nameof(ValueTone)); + Raise(nameof(ValueVisualKind)); + } + } + } + public string DisplayValue => Value; + public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); + public string ValueTone => Iec61850ValueStatePresentation.Classify(Value, IecDataType); + public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( + Value, + IecDataType, + Category, + IecReference); + public string Quality { get => _quality; set => Set(ref _quality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + public string DeviceTimestamp { get => _deviceTimestamp; set => Set(ref _deviceTimestamp, string.IsNullOrWhiteSpace(value) ? "-" : value); } + public string SourceMode { get => _sourceMode; set => Set(ref _sourceMode, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + public string Reason { get => _reason; set => Set(ref _reason, string.IsNullOrWhiteSpace(value) ? "-" : value); } + public string Status { get => _status; set => Set(ref _status, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + // Runtime ordering metadata is not rendered. Avoid an unnecessary WPF property-change + // notification for every poll/report sample; the value still guards stale snapshots. + public long Sequence { get => _sequence; set => _sequence = value; } + public bool IsRecentlyChanged { get => _isRecentlyChanged; set => Set(ref _isRecentlyChanged, value); } + public bool IsAnnunciatorSelected { get => _isAnnunciatorSelected; set => Set(ref _isAnnunciatorSelected, value); } + public bool CanUseAsAnnunciator => FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase); + public string AnnunciatorSelectionToolTip => CanUseAsAnnunciator + ? "Latch this IEC 61850 ST point in Alarm Annunciator using SOE/Event Log edges." + : "Alarm Annunciator selection is limited to IEC 61850 ST status points."; + + /// + /// Applies a process value and returns true only for a real semantic transition. + /// Initial population and formatting-only differences are deliberately ignored. + /// + public bool ApplyProcessValue(string? value) + { + var next = string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + var previous = Value; + Value = next; + return IsInitializedProcessValue(previous) && + IsInitializedProcessValue(next) && + !AreSemanticallyEquivalent(previous, next); + } + + private static bool IsInitializedProcessValue(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length > 0 && text != "-" && + !text.Equals("Pending", StringComparison.OrdinalIgnoreCase) && + !text.Equals("Unknown", StringComparison.OrdinalIgnoreCase); + } + + internal static bool AreSemanticallyEquivalent(string? left, string? right) + { + var a = NormalizeSemanticValue(left); + var b = NormalizeSemanticValue(right); + if (a == b) return true; + + if (TryParseSemanticNumber(a, out var an) && TryParseSemanticNumber(b, out var bn)) + return an == bn; + + return false; + } + + private static bool TryParseSemanticNumber(string value, out decimal number) + { + if (value == "binary:1") + { + number = 1m; + return true; + } + if (value == "binary:0") + { + number = 0m; + return true; + } + + return decimal.TryParse( + value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out number); + } + + private static string NormalizeSemanticValue(string? value) + { + var text = (value ?? string.Empty).Trim().ToLowerInvariant(); + if (text is "true" or "on" or "active" or "asserted") return "binary:1"; + if (text is "false" or "off" or "inactive" or "deasserted") return "binary:0"; + if (text.Contains("open") && text.Contains("01")) return "dbpos:01"; + if (text.Contains("closed") && text.Contains("10")) return "dbpos:10"; + if (text.Contains("intermediate") && text.Contains("00")) return "dbpos:00"; + if (text.Contains("bad") && text.Contains("11")) return "dbpos:11"; + return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.').Replace("..", ".").ToLowerInvariant(); + + internal static string StripIedNamePrefix(string? reference, string? iedName) + { + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + var name = (iedName ?? string.Empty).Trim(); + var slash = text.IndexOf('/'); + if (slash <= 0 || string.IsNullOrWhiteSpace(name)) + return text; + + var domain = text[..slash]; + if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) || domain.Length <= name.Length) + return text; + + return domain[name.Length..] + text[slash..]; + } +} + +public sealed class Iec61850PointSnapshot +{ + public required Iec61850MonitorPoint Point { get; init; } + public string PreviousValue { get; init; } = "-"; + public string Value { get; init; } = "-"; + public string Quality { get; init; } = "Unknown"; + public string DeviceTimestamp { get; init; } = "-"; + public string SourceMode { get; init; } = "MMS Polling"; + public string Reason { get; init; } = "cyclic"; + public string Status { get; init; } = "Live"; + /// + /// True only when the runtime observed a real semantic process-value transition. + /// This flag survives UI batching so a fast report/poll sequence cannot erase the + /// three-second commissioning highlight before WPF renders it. + /// + public bool IsValueEdge { get; init; } + public bool IsReportTraffic { get; init; } + public long Sequence { get; init; } +} + +public sealed class Iec61850EventEntry +{ + public long Sequence { get; init; } + public string DeviceId { get; init; } = string.Empty; + public string PointKey { get; init; } = string.Empty; + public string DeviceTimestamp { get; init; } = "-"; + public string DeviceName { get; init; } = string.Empty; + public string IpAddress { get; init; } = string.Empty; + public string SignalName { get; init; } = string.Empty; + public string IecReference { get; init; } = string.Empty; + public string IecDataType { get; init; } = string.Empty; + public string OldValue { get; init; } = "-"; + public string NewValue { get; init; } = "-"; + public string Quality { get; init; } = "Unknown"; + public string SourceMode { get; init; } = string.Empty; + public string Reason { get; init; } = string.Empty; + public string EdgeType + { + get + { + if (TryParseBinaryState(OldValue, out var oldState) && + TryParseBinaryState(NewValue, out var newState) && + oldState != newState) + { + return newState ? "Rising" : "Falling"; + } + + return "State change"; + } + } + + public string ChangeText => $"{EdgeType} · {OldValue} → {NewValue}"; + public string EventValue => string.IsNullOrWhiteSpace(NewValue) ? "-" : NewValue; + public string DisplayValue => EventValue; + public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); + public string ValueTone => Iec61850ValueStatePresentation.Classify(EventValue, IecDataType); + public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( + EventValue, + IecDataType, + string.Empty, + IecReference); + public string QualityTone => Iec61850QualityPresentation.Classify(Quality); + + public string IecTelegram => Iec61850MonitorPoint.StripIedNamePrefix(IecReference, DeviceName); + + private static bool TryParseBinaryState(string? value, out bool state) + { + state = false; + var text = (value ?? string.Empty).Trim(); + if (bool.TryParse(text, out state)) + return true; + + if (text.Equals("1", StringComparison.OrdinalIgnoreCase) || + text.Equals("1.0", StringComparison.OrdinalIgnoreCase) || + text.Equals("on", StringComparison.OrdinalIgnoreCase) || + text.Equals("active", StringComparison.OrdinalIgnoreCase) || + text.Equals("asserted", StringComparison.OrdinalIgnoreCase)) + { + state = true; + return true; + } + + if (text.Equals("0", StringComparison.OrdinalIgnoreCase) || + text.Equals("0.0", StringComparison.OrdinalIgnoreCase) || + text.Equals("off", StringComparison.OrdinalIgnoreCase) || + text.Equals("inactive", StringComparison.OrdinalIgnoreCase) || + text.Equals("deasserted", StringComparison.OrdinalIgnoreCase)) + { + state = false; + return true; + } + + return false; + } +} + +public sealed class Iec61850TesterProject +{ + public int SchemaVersion { get; set; } = 3; + public string ProjectName { get; set; } = "ARSAS Session"; + public int DefaultPollingIntervalMs { get; set; } = 1000; + public List Devices { get; set; } = new(); +} + +public sealed class Iec61850TesterDeviceProfile +{ + public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); + public string Name { get; set; } = "IED"; + public string IdentitySource { get; set; } = string.Empty; + public string LogicalDeviceSummary { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public int Port { get; set; } = 102; + public bool AllowDynamicDataSetWrites { get; set; } = true; + public bool DiscoverySucceeded { get; set; } + public string SclSourcePath { get; set; } = string.Empty; + public string SclSourceSha256 { get; set; } = string.Empty; + public string SclIedName { get; set; } = string.Empty; + public string SclAccessPointName { get; set; } = string.Empty; + public List SelectedReferences { get; set; } = new(); + public List AnnunciatorReferences { get; set; } = new(); + public List CachedSignals { get; set; } = new(); +} + +/// +/// Stable, JSON-friendly discovery snapshot. It deliberately stores model metadata, +/// not live values, so an opened project can reconnect immediately without presenting +/// stale process data as current. +/// +public sealed class Iec61850CachedSignalProfile +{ + public string Name { get; set; } = string.Empty; + public string ObjectReference { get; set; } = string.Empty; + public string DisplayReference { get; set; } = string.Empty; + public string FunctionalConstraint { get; set; } = string.Empty; + public string DataType { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Unit { get; set; } = string.Empty; + public string Confidence { get; set; } = "Medium"; + public string DataSetReference { get; set; } = string.Empty; + public string ReportControlReference { get; set; } = string.Empty; + public string ReportCoverageReason { get; set; } = string.Empty; + public string QualityReference { get; set; } = string.Empty; + public string TimestampReference { get; set; } = string.Empty; + public string Source { get; set; } = "Project cache"; + public bool IsReportCapable { get; set; } + public string ReportCoverage { get; set; } = "Polling fallback"; + public bool IsControlSignal { get; set; } + public string ControlCdc { get; set; } = string.Empty; + public string ControlModelReference { get; set; } = string.Empty; + public string ControlStatusReference { get; set; } = string.Empty; + public string ControlModelText { get; set; } = "Auto-detect"; + public string ControlValueType { get; set; } = string.Empty; + public bool IsSelected { get; set; } + + public static Iec61850CachedSignalProfile FromSignal(SignalDefinition signal) + { + ArgumentNullException.ThrowIfNull(signal); + return new Iec61850CachedSignalProfile + { + Name = signal.Name, + ObjectReference = signal.ObjectReference, + DisplayReference = signal.DisplayReference, + FunctionalConstraint = signal.FunctionalConstraint, + DataType = signal.DataType, + Category = signal.Category, + Unit = signal.Unit, + Confidence = signal.Confidence, + DataSetReference = signal.DataSetReference, + ReportControlReference = signal.ReportControlReference, + ReportCoverageReason = signal.ReportCoverageReason, + QualityReference = signal.QualityReference, + TimestampReference = signal.TimestampReference, + Source = signal.Source, + IsReportCapable = signal.IsReportCapable, + ReportCoverage = signal.ReportCoverage, + IsControlSignal = signal.IsControlSignal, + ControlCdc = signal.ControlCdc, + ControlModelReference = signal.ControlModelReference, + ControlStatusReference = signal.ControlStatusReference, + ControlModelText = signal.ControlModelText, + ControlValueType = signal.ControlValueType, + IsSelected = signal.IsSelected + }; + } + + public SignalDefinition ToSignal() + => new() + { + Name = Name, + ObjectReference = ObjectReference, + DisplayReference = DisplayReference, + FunctionalConstraint = FunctionalConstraint, + DataType = DataType, + Category = Category, + Unit = Unit, + Confidence = Confidence, + DataSetReference = DataSetReference, + ReportControlReference = ReportControlReference, + ReportCoverageReason = ReportCoverageReason, + QualityReference = QualityReference, + TimestampReference = TimestampReference, + Source = string.IsNullOrWhiteSpace(Source) ? "Project cache" : Source, + IsReportCapable = IsReportCapable, + ReportCoverage = ReportCoverage, + IsControlSignal = IsControlSignal, + ControlCdc = ControlCdc, + ControlModelReference = ControlModelReference, + ControlStatusReference = ControlStatusReference, + ControlModelText = ControlModelText, + ControlValueType = ControlValueType, + IsSelected = IsSelected, + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-", + ProbeStatus = "Restored from saved discovery model" + }; +} +], start); + if (end <= start) + return signal; + + var logicalNode = reference[start..end]; + var digitAt = 0; + while (digitAt < logicalNode.Length && char.IsLetter(logicalNode[digitAt])) + digitAt++; + if (digitAt == 0 || digitAt == logicalNode.Length || + !logicalNode.AsSpan(digitAt).ToString().All(char.IsDigit)) + return signal; + + if (signal.StartsWith(logicalNode, StringComparison.OrdinalIgnoreCase)) + return signal; + + var logicalNodeClass = logicalNode[..digitAt]; + return signal.StartsWith(logicalNodeClass + " ", StringComparison.OrdinalIgnoreCase) + ? logicalNode + signal[logicalNodeClass.Length..] + : $"{logicalNode} · {signal}"; + } + } public string Value { get => _value; From 8ff2ba722ebe19cd5af3ed833d849fdf419228f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:37:31 +0700 Subject: [PATCH 49/99] perf(p10.5): avoid transient LINQ/string allocation in virtualized signal-name formatter --- Models/MonitorModels.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 08aef1709..8942f7ca3 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -1033,11 +1033,17 @@ public SignalDefinition ToSignal() var digitAt = 0; while (digitAt < logicalNode.Length && char.IsLetter(logicalNode[digitAt])) digitAt++; - if (digitAt == 0 || digitAt == logicalNode.Length || - !logicalNode.AsSpan(digitAt).ToString().All(char.IsDigit)) + if (digitAt == 0 || digitAt == logicalNode.Length) return signal; + for (var index = digitAt; index < logicalNode.Length; index++) + { + if (!char.IsDigit(logicalNode[index])) + return signal; + } - if (signal.StartsWith(logicalNode, StringComparison.OrdinalIgnoreCase)) + if (signal.StartsWith(logicalNode, StringComparison.OrdinalIgnoreCase) && + (signal.Length == logicalNode.Length || + !char.IsDigit(signal[logicalNode.Length]))) return signal; var logicalNodeClass = logicalNode[..digitAt]; From af4cd109181bef290c763071b7b2e8b154953d92 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:37:42 +0700 Subject: [PATCH 50/99] fix(p10.5 ux): show exact logical-node instance in Live Signal Values Signal column --- MainWindow.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index a8e0a084a..8ee6d221f 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -572,7 +572,7 @@ - + From 87788ab8240156d27e5091d5d32e563a6d184281 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:37:55 +0700 Subject: [PATCH 51/99] =?UTF-8?q?test(p10.5=20ux):=20preserve=20canonical?= =?UTF-8?q?=20reference=20and=20uniquely=20show=20CSWI1=E2=80=93CSWI16=20l?= =?UTF-8?q?ive=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../P105StaticParityUniqueCoverageTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs b/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs index bf10946e6..96c66b03e 100644 --- a/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs +++ b/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs @@ -103,6 +103,34 @@ public void OverlappingBrcbAndUrcb_UsesUniqueReferencesAndFc(int requested, bool } } + [Theory] + [InlineData("BCUGEF650/CSWI1.Pos.stVal", "CSWI Pos", "CSWI1 Pos")] + [InlineData("BCUGEF650/CSWI16.Pos.stVal", "CSWI Pos", "CSWI16 Pos")] + [InlineData("BCUGEF650/MMXU1.A.phsA.cVal.mag.f", "MMXU A", "MMXU1 A")] + [InlineData("BCUGEF650/CSWI1.Pos.stVal", "CSWI1 Pos", "CSWI1 Pos")] + [InlineData("not-a-logical-node", "Equipment status", "Equipment status")] + public void ExplorerSignalLabel_UsesExactLnInstanceWithoutChangingMmsReference( + string reference, string signalName, string expected) + { + var point = new Iec61850MonitorPoint + { + DeviceId = "ge", + DeviceName = "BCUGE", + IecReference = reference, + SignalName = signalName + }; + Assert.Equal(expected, point.ExplorerSignalName); + Assert.Equal(reference, point.IecReference); + Assert.Equal(signalName, point.SignalName); + } + + [Fact] + public void ExplorerGrid_BindsQualifiedLabelNotBareSignalName() + { + var xaml = File.ReadAllText(FindRepoFile("MainWindow.xaml")); + Assert.Contains("Header=\"Signal\" Binding=\"{Binding ExplorerSignalName}\"", xaml, StringComparison.Ordinal); + } + [Fact] public void MalformedBindingFc_RemainsIncomparableDespiteExactIncidenceCounters() { From 6f717890849e3e33c7e107440c60dbb351f55fc2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 10:41:35 +0700 Subject: [PATCH 52/99] fix(p10.5): reconstruct valid literal LN-label formatter from exact P10.4 baseline; remove accidental JS replacement expansion --- Models/MonitorModels.cs | 353 +--------------------------------------- 1 file changed, 6 insertions(+), 347 deletions(-) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 8942f7ca3..be2ba7bc0 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -671,10 +671,8 @@ public sealed class Iec61850MonitorPoint : ObservableObject public string PointKey => $"{DeviceId}|{NormalizeReference(IecReference)}"; public string IecTelegram => StripIedNamePrefix(IecReference, DeviceName); - // Explorer presentation only: a short label must still identify the actual LN - // instance. E.g. 16 distinct CSWI.Pos rows must not all read "CSWI Pos". - // Keep SignalName and the original wire IEC reference untouched for reports, - // commands, search, persistence and engineering comparisons. + // Explorer-only label: preserve every original IEC/MMS reference and signal name. + // "CSWI Pos" is ambiguous across CSWI1..CSWI16; wire references are not. public string ExplorerSignalName { get @@ -686,350 +684,11 @@ public string ExplorerSignalName return signal; var start = slash + 1; - var end = reference.IndexOfAny(['.', ' - public string Value - { - get => _value; - set - { - if (Set(ref _value, string.IsNullOrWhiteSpace(value) ? "-" : value)) - { - Raise(nameof(DisplayValue)); - Raise(nameof(ValueTone)); - Raise(nameof(ValueVisualKind)); - } - } - } - public string DisplayValue => Value; - public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); - public string ValueTone => Iec61850ValueStatePresentation.Classify(Value, IecDataType); - public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( - Value, - IecDataType, - Category, - IecReference); - public string Quality { get => _quality; set => Set(ref _quality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - public string DeviceTimestamp { get => _deviceTimestamp; set => Set(ref _deviceTimestamp, string.IsNullOrWhiteSpace(value) ? "-" : value); } - public string SourceMode { get => _sourceMode; set => Set(ref _sourceMode, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - public string Reason { get => _reason; set => Set(ref _reason, string.IsNullOrWhiteSpace(value) ? "-" : value); } - public string Status { get => _status; set => Set(ref _status, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - // Runtime ordering metadata is not rendered. Avoid an unnecessary WPF property-change - // notification for every poll/report sample; the value still guards stale snapshots. - public long Sequence { get => _sequence; set => _sequence = value; } - public bool IsRecentlyChanged { get => _isRecentlyChanged; set => Set(ref _isRecentlyChanged, value); } - public bool IsAnnunciatorSelected { get => _isAnnunciatorSelected; set => Set(ref _isAnnunciatorSelected, value); } - public bool CanUseAsAnnunciator => FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase); - public string AnnunciatorSelectionToolTip => CanUseAsAnnunciator - ? "Latch this IEC 61850 ST point in Alarm Annunciator using SOE/Event Log edges." - : "Alarm Annunciator selection is limited to IEC 61850 ST status points."; - - /// - /// Applies a process value and returns true only for a real semantic transition. - /// Initial population and formatting-only differences are deliberately ignored. - /// - public bool ApplyProcessValue(string? value) - { - var next = string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); - var previous = Value; - Value = next; - return IsInitializedProcessValue(previous) && - IsInitializedProcessValue(next) && - !AreSemanticallyEquivalent(previous, next); - } - - private static bool IsInitializedProcessValue(string? value) - { - var text = (value ?? string.Empty).Trim(); - return text.Length > 0 && text != "-" && - !text.Equals("Pending", StringComparison.OrdinalIgnoreCase) && - !text.Equals("Unknown", StringComparison.OrdinalIgnoreCase); - } - - internal static bool AreSemanticallyEquivalent(string? left, string? right) - { - var a = NormalizeSemanticValue(left); - var b = NormalizeSemanticValue(right); - if (a == b) return true; - - if (TryParseSemanticNumber(a, out var an) && TryParseSemanticNumber(b, out var bn)) - return an == bn; - - return false; - } - - private static bool TryParseSemanticNumber(string value, out decimal number) - { - if (value == "binary:1") - { - number = 1m; - return true; - } - if (value == "binary:0") - { - number = 0m; - return true; - } - - return decimal.TryParse( - value, - System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, - out number); - } - - private static string NormalizeSemanticValue(string? value) - { - var text = (value ?? string.Empty).Trim().ToLowerInvariant(); - if (text is "true" or "on" or "active" or "asserted") return "binary:1"; - if (text is "false" or "off" or "inactive" or "deasserted") return "binary:0"; - if (text.Contains("open") && text.Contains("01")) return "dbpos:01"; - if (text.Contains("closed") && text.Contains("10")) return "dbpos:10"; - if (text.Contains("intermediate") && text.Contains("00")) return "dbpos:00"; - if (text.Contains("bad") && text.Contains("11")) return "dbpos:11"; - return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); - } - - private static string NormalizeReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.').Replace("..", ".").ToLowerInvariant(); - - internal static string StripIedNamePrefix(string? reference, string? iedName) - { - var text = (reference ?? string.Empty).Trim().Replace('$', '.'); - var name = (iedName ?? string.Empty).Trim(); - var slash = text.IndexOf('/'); - if (slash <= 0 || string.IsNullOrWhiteSpace(name)) - return text; - - var domain = text[..slash]; - if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) || domain.Length <= name.Length) - return text; - - return domain[name.Length..] + text[slash..]; - } -} - -public sealed class Iec61850PointSnapshot -{ - public required Iec61850MonitorPoint Point { get; init; } - public string PreviousValue { get; init; } = "-"; - public string Value { get; init; } = "-"; - public string Quality { get; init; } = "Unknown"; - public string DeviceTimestamp { get; init; } = "-"; - public string SourceMode { get; init; } = "MMS Polling"; - public string Reason { get; init; } = "cyclic"; - public string Status { get; init; } = "Live"; - /// - /// True only when the runtime observed a real semantic process-value transition. - /// This flag survives UI batching so a fast report/poll sequence cannot erase the - /// three-second commissioning highlight before WPF renders it. - /// - public bool IsValueEdge { get; init; } - public bool IsReportTraffic { get; init; } - public long Sequence { get; init; } -} - -public sealed class Iec61850EventEntry -{ - public long Sequence { get; init; } - public string DeviceId { get; init; } = string.Empty; - public string PointKey { get; init; } = string.Empty; - public string DeviceTimestamp { get; init; } = "-"; - public string DeviceName { get; init; } = string.Empty; - public string IpAddress { get; init; } = string.Empty; - public string SignalName { get; init; } = string.Empty; - public string IecReference { get; init; } = string.Empty; - public string IecDataType { get; init; } = string.Empty; - public string OldValue { get; init; } = "-"; - public string NewValue { get; init; } = "-"; - public string Quality { get; init; } = "Unknown"; - public string SourceMode { get; init; } = string.Empty; - public string Reason { get; init; } = string.Empty; - public string EdgeType - { - get - { - if (TryParseBinaryState(OldValue, out var oldState) && - TryParseBinaryState(NewValue, out var newState) && - oldState != newState) - { - return newState ? "Rising" : "Falling"; - } - - return "State change"; - } - } - - public string ChangeText => $"{EdgeType} · {OldValue} → {NewValue}"; - public string EventValue => string.IsNullOrWhiteSpace(NewValue) ? "-" : NewValue; - public string DisplayValue => EventValue; - public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); - public string ValueTone => Iec61850ValueStatePresentation.Classify(EventValue, IecDataType); - public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( - EventValue, - IecDataType, - string.Empty, - IecReference); - public string QualityTone => Iec61850QualityPresentation.Classify(Quality); - - public string IecTelegram => Iec61850MonitorPoint.StripIedNamePrefix(IecReference, DeviceName); - - private static bool TryParseBinaryState(string? value, out bool state) - { - state = false; - var text = (value ?? string.Empty).Trim(); - if (bool.TryParse(text, out state)) - return true; - - if (text.Equals("1", StringComparison.OrdinalIgnoreCase) || - text.Equals("1.0", StringComparison.OrdinalIgnoreCase) || - text.Equals("on", StringComparison.OrdinalIgnoreCase) || - text.Equals("active", StringComparison.OrdinalIgnoreCase) || - text.Equals("asserted", StringComparison.OrdinalIgnoreCase)) - { - state = true; - return true; - } - - if (text.Equals("0", StringComparison.OrdinalIgnoreCase) || - text.Equals("0.0", StringComparison.OrdinalIgnoreCase) || - text.Equals("off", StringComparison.OrdinalIgnoreCase) || - text.Equals("inactive", StringComparison.OrdinalIgnoreCase) || - text.Equals("deasserted", StringComparison.OrdinalIgnoreCase)) - { - state = false; - return true; - } - - return false; - } -} - -public sealed class Iec61850TesterProject -{ - public int SchemaVersion { get; set; } = 3; - public string ProjectName { get; set; } = "ARSAS Session"; - public int DefaultPollingIntervalMs { get; set; } = 1000; - public List Devices { get; set; } = new(); -} - -public sealed class Iec61850TesterDeviceProfile -{ - public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); - public string Name { get; set; } = "IED"; - public string IdentitySource { get; set; } = string.Empty; - public string LogicalDeviceSummary { get; set; } = string.Empty; - public string IpAddress { get; set; } = string.Empty; - public int Port { get; set; } = 102; - public bool AllowDynamicDataSetWrites { get; set; } = true; - public bool DiscoverySucceeded { get; set; } - public string SclSourcePath { get; set; } = string.Empty; - public string SclSourceSha256 { get; set; } = string.Empty; - public string SclIedName { get; set; } = string.Empty; - public string SclAccessPointName { get; set; } = string.Empty; - public List SelectedReferences { get; set; } = new(); - public List AnnunciatorReferences { get; set; } = new(); - public List CachedSignals { get; set; } = new(); -} - -/// -/// Stable, JSON-friendly discovery snapshot. It deliberately stores model metadata, -/// not live values, so an opened project can reconnect immediately without presenting -/// stale process data as current. -/// -public sealed class Iec61850CachedSignalProfile -{ - public string Name { get; set; } = string.Empty; - public string ObjectReference { get; set; } = string.Empty; - public string DisplayReference { get; set; } = string.Empty; - public string FunctionalConstraint { get; set; } = string.Empty; - public string DataType { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public string Unit { get; set; } = string.Empty; - public string Confidence { get; set; } = "Medium"; - public string DataSetReference { get; set; } = string.Empty; - public string ReportControlReference { get; set; } = string.Empty; - public string ReportCoverageReason { get; set; } = string.Empty; - public string QualityReference { get; set; } = string.Empty; - public string TimestampReference { get; set; } = string.Empty; - public string Source { get; set; } = "Project cache"; - public bool IsReportCapable { get; set; } - public string ReportCoverage { get; set; } = "Polling fallback"; - public bool IsControlSignal { get; set; } - public string ControlCdc { get; set; } = string.Empty; - public string ControlModelReference { get; set; } = string.Empty; - public string ControlStatusReference { get; set; } = string.Empty; - public string ControlModelText { get; set; } = "Auto-detect"; - public string ControlValueType { get; set; } = string.Empty; - public bool IsSelected { get; set; } - - public static Iec61850CachedSignalProfile FromSignal(SignalDefinition signal) - { - ArgumentNullException.ThrowIfNull(signal); - return new Iec61850CachedSignalProfile - { - Name = signal.Name, - ObjectReference = signal.ObjectReference, - DisplayReference = signal.DisplayReference, - FunctionalConstraint = signal.FunctionalConstraint, - DataType = signal.DataType, - Category = signal.Category, - Unit = signal.Unit, - Confidence = signal.Confidence, - DataSetReference = signal.DataSetReference, - ReportControlReference = signal.ReportControlReference, - ReportCoverageReason = signal.ReportCoverageReason, - QualityReference = signal.QualityReference, - TimestampReference = signal.TimestampReference, - Source = signal.Source, - IsReportCapable = signal.IsReportCapable, - ReportCoverage = signal.ReportCoverage, - IsControlSignal = signal.IsControlSignal, - ControlCdc = signal.ControlCdc, - ControlModelReference = signal.ControlModelReference, - ControlStatusReference = signal.ControlStatusReference, - ControlModelText = signal.ControlModelText, - ControlValueType = signal.ControlValueType, - IsSelected = signal.IsSelected - }; - } - - public SignalDefinition ToSignal() - => new() - { - Name = Name, - ObjectReference = ObjectReference, - DisplayReference = DisplayReference, - FunctionalConstraint = FunctionalConstraint, - DataType = DataType, - Category = Category, - Unit = Unit, - Confidence = Confidence, - DataSetReference = DataSetReference, - ReportControlReference = ReportControlReference, - ReportCoverageReason = ReportCoverageReason, - QualityReference = QualityReference, - TimestampReference = TimestampReference, - Source = string.IsNullOrWhiteSpace(Source) ? "Project cache" : Source, - IsReportCapable = IsReportCapable, - ReportCoverage = ReportCoverage, - IsControlSignal = IsControlSignal, - ControlCdc = ControlCdc, - ControlModelReference = ControlModelReference, - ControlStatusReference = ControlStatusReference, - ControlModelText = ControlModelText, - ControlValueType = ControlValueType, - IsSelected = IsSelected, - Value = "-", - Quality = "Unknown", - DeviceTimestamp = "-", - ProbeStatus = "Restored from saved discovery model" - }; -} -], start); - if (end <= start) + var relativeEnd = reference.AsSpan(start).IndexOfAny('.', '$'); + if (relativeEnd <= 0) return signal; - var logicalNode = reference[start..end]; + var logicalNode = reference.Substring(start, relativeEnd); var digitAt = 0; while (digitAt < logicalNode.Length && char.IsLetter(logicalNode[digitAt])) digitAt++; @@ -1049,7 +708,7 @@ public SignalDefinition ToSignal() var logicalNodeClass = logicalNode[..digitAt]; return signal.StartsWith(logicalNodeClass + " ", StringComparison.OrdinalIgnoreCase) ? logicalNode + signal[logicalNodeClass.Length..] - : $"{logicalNode} · {signal}"; + : logicalNode + " · " + signal; } } public string Value From 2ecf3dd767dc8973a2ddda67c10c17c5c50565e4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 11:14:43 +0700 Subject: [PATCH 53/99] feat(p10.6): persist exact successfully connected SCL source path and expose endpoint-scoped trusted candidates --- Services/SclEndpointBindingStore.cs | 33 ++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/Services/SclEndpointBindingStore.cs b/Services/SclEndpointBindingStore.cs index d8b87fa22..f5fbf4540 100644 --- a/Services/SclEndpointBindingStore.cs +++ b/Services/SclEndpointBindingStore.cs @@ -25,6 +25,8 @@ internal sealed class Binding public string SourceSha256 { get; set; } = string.Empty; public string IedName { get; set; } = string.Empty; public string AccessPointName { get; set; } = string.Empty; + // Original operator-opened, SHA-pinned SCL source; absent in legacy records. + public string SourcePath { get; set; } = string.Empty; public string IpAddress { get; set; } = string.Empty; public int Port { get; set; } = 102; public DateTime LastVerifiedUtc { get; set; } @@ -57,12 +59,12 @@ internal static bool TryGetAtPath( public static void RecordVerified( string sourceSha256, string iedName, string accessPointName, - string ipAddress, int port) - => RecordVerifiedAtPath(DefaultPath, sourceSha256, iedName, accessPointName, ipAddress, port); + string ipAddress, int port, string? sourcePath = null) + => RecordVerifiedAtPath(DefaultPath, sourceSha256, iedName, accessPointName, ipAddress, port, sourcePath); internal static void RecordVerifiedAtPath( string path, string sourceSha256, string iedName, string accessPointName, - string ipAddress, int port) + string ipAddress, int port, string? sourcePath = null) { if (!ValidIdentity(sourceSha256, iedName, accessPointName) || !ValidAddress(ipAddress, port)) throw new ArgumentException("An exact SCL source, IED, AP and valid MMS IP:port are required."); @@ -76,6 +78,7 @@ internal static void RecordVerifiedAtPath( SourceSha256 = sourceSha256.Trim(), IedName = iedName.Trim(), AccessPointName = accessPointName.Trim(), + SourcePath = string.IsNullOrWhiteSpace(sourcePath) ? string.Empty : Path.GetFullPath(sourcePath), IpAddress = ipAddress.Trim(), Port = port, LastVerifiedUtc = DateTime.UtcNow @@ -102,6 +105,30 @@ internal static void RecordVerifiedAtPath( } } + // A previously successful SCL-assisted MMS association is an operator-authorized + // identity candidate, not proof by itself. Every source is re-hashed and matched + // against the current full live MMS domain topology before name promotion. + internal static IReadOnlyList FindVerifiedSourcesForEndpoint( + string ipAddress, int port) + => FindVerifiedSourcesForEndpointAtPath(DefaultPath, ipAddress, port); + + internal static IReadOnlyList FindVerifiedSourcesForEndpointAtPath( + string path, string ipAddress, int port) + { + if (!ValidAddress(ipAddress, port)) return Array.Empty(); + lock (Gate) + { + return Load(path) + .Where(binding => + ValidIdentity(binding.SourceSha256, binding.IedName, binding.AccessPointName) && + ValidAddress(binding.IpAddress, binding.Port) && + string.Equals(binding.IpAddress, ipAddress, StringComparison.OrdinalIgnoreCase) && + binding.Port == port && + !string.IsNullOrWhiteSpace(binding.SourcePath)) + .ToArray(); + } + } + private static List Load(string path) { try From 1a77e885e3ae9cb866bb5791104a0c3569566c40 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 11:15:12 +0700 Subject: [PATCH 54/99] feat(p10.6): revalidate prior SHA-pinned operator IID and unique AP/domain topology for IP-only Discovery --- Services/TrustedSclIdentityAuthority.cs | 82 +++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/Services/TrustedSclIdentityAuthority.cs b/Services/TrustedSclIdentityAuthority.cs index dca7a5c66..2e894f5c0 100644 --- a/Services/TrustedSclIdentityAuthority.cs +++ b/Services/TrustedSclIdentityAuthority.cs @@ -32,6 +32,84 @@ public static class TrustedSclIdentityAuthority device.SclIedName); } + /// + /// IP-only discovery may reuse a prior user-opened IID only after its SCL-assisted + /// MMS association was successful, its original bytes still match the pinned + /// SHA-256, and its IED/AP/LD domain topology uniquely matches this live session. + /// No IP-only guess, vendor heuristic or SCL-assisted data acquisition is enabled. + /// + public static async Task TryMatchRememberedAsync( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument liveModel, + CancellationToken cancellationToken = default, + string? bindingStorePath = null) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(liveModel); + + var candidates = bindingStorePath is null + ? SclEndpointBindingStore.FindVerifiedSourcesForEndpoint(device.IpAddress, device.Port) + : SclEndpointBindingStore.FindVerifiedSourcesForEndpointAtPath( + bindingStorePath, device.IpAddress, device.Port); + // Never pick the "most recent" plausible model from an ambiguous or + // pathological endpoint history. Capped work protects discovery latency. + if (candidates.Count == 0 || candidates.Count > 16) + return null; + + var domains = liveModel.LogicalDevices.Select(ld => ld.MmsDomain).ToArray(); + RememberedTrustedSclMatch? unique = null; + var ns = (XNamespace)"http://www.iec.ch/61850/2003/SCL"; + + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var source = await VerifiedSclSourceLoader.LoadAsync( + candidate.SourcePath, candidate.SourceSha256, cancellationToken) + .ConfigureAwait(false); + var document = XDocument.Parse(source.Xml, LoadOptions.PreserveWhitespace); + + // Engine matcher guards global IED/AP ambiguity. Additionally, only + // a single explicit AP for the remembered IED is eligible until an + // independently authenticated AP identifier is observable from MMS. + var matchingIeds = document.Root?.Elements(ns + "IED") + .Where(ied => string.Equals( + (string?)ied.Attribute("name"), candidate.IedName, + StringComparison.OrdinalIgnoreCase)).ToArray() + ?? Array.Empty(); + if (matchingIeds.Length != 1) + continue; + var accessPoints = matchingIeds[0].Elements(ns + "AccessPoint").ToArray(); + if (accessPoints.Length != 1 || !string.Equals( + (string?)accessPoints[0].Attribute("name"), + candidate.AccessPointName, StringComparison.OrdinalIgnoreCase)) + continue; + + var identity = TrustedSclIedIdentityMatcher.TryMatch( + document, domains, device.IpAddress, candidate.IedName); + if (identity is null || + identity.Source != "TrustedSclExactDomainMatch" || + identity.Confidence != LiveIedDiscoveryConfidenceLevel.High) + continue; + + if (unique is not null) + return null; // competing qualified IID sources: fail closed + unique = new RememberedTrustedSclMatch( + identity, source.Sha256, candidate.AccessPointName); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException or InvalidDataException or + System.Xml.XmlException or ArgumentException) + { + // Missing/edited/malformed remembered files cannot rename a live IED. + continue; + } + } + return unique; + } + public static Iec61850DeviceIdentity ToConsumerIdentity( LiveIedIdentity verified, IEnumerable domains) { @@ -54,3 +132,7 @@ public static Iec61850DeviceIdentity ToConsumerIdentity( }; } } + +/// Association-local proof, not a persisted or inferred IEC wire name. +public sealed record RememberedTrustedSclMatch( + LiveIedIdentity Identity, string SourceSha256, string AccessPointName); From 1222b8af587da79b77fd70608faa0b19871a9004 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 11:15:29 +0700 Subject: [PATCH 55/99] feat(p10.6): retain only fresh association-scoped high-confidence identity proof --- Models/MonitorModels.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index be2ba7bc0..eeb30e767 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -54,6 +54,12 @@ public sealed class Iec61850MonitorDevice : ObservableObject [JsonIgnore] public StaticAcquisitionParitySnapshot StaticAcquisitionParity { get; set; } = new(); + // Association-scoped only. A fresh Discovery must re-verify the original IID + // SHA and full domain topology; no persisted IP or filename is identity proof. + [JsonIgnore] + public LiveIedIdentity? TrustedLiveIdentity { get; set; } + + // Snapshot publication is atomic across runtime/diagnostic threads, never a // mutable live ReportStreams dictionary. Association reset clears its scope. [JsonIgnore] From 4474e490389bd7e4ee521cebfc61fe23fb12f645 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 11:15:50 +0700 Subject: [PATCH 56/99] fix(p10.6): promote IP Discovery IEDName only from SHA-rechecked prior verified SCL evidence --- Services/Iec61850MonitorRuntime.cs | 78 +++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index f6f89e91e..61fc6d56f 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -142,6 +142,8 @@ public async Task> ConnectAndDiscoverAsync( progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.PreparingSession, "Preparing an independent IEC 61850 session…", 2d, 1, 15)); await StopDeviceAsync(device.DeviceId).ConfigureAwait(false); + // Previous sessions cannot authorize another endpoint or association. + device.TrustedLiveIdentity = null; var session = new DeviceSession { @@ -195,37 +197,67 @@ public async Task> ConnectAndDiscoverAsync( progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.PreparingWorkspace, "Filtering and ordering the discovered signal workspace…", 98d, 14, 15)); var identity = session.Client.DetectedIdentity; - if (device.LiveDiscoveryModel is { } liveModel && - !string.IsNullOrWhiteSpace(device.SclSourcePath) && - !string.IsNullOrWhiteSpace(device.SclSourceSha256)) + if (device.LiveDiscoveryModel is { } liveModel) { - try + if (!string.IsNullOrWhiteSpace(device.SclSourcePath) && + !string.IsNullOrWhiteSpace(device.SclSourceSha256)) { - var proof = await TrustedSclIdentityAuthority.TryMatchAsync( - device,liveModel,cancellationToken).ConfigureAwait(false); - if (proof is not null) + try { - identity = TrustedSclIdentityAuthority.ToConsumerIdentity( - proof,liveModel.LogicalDevices.Select(ld => ld.MmsDomain)); - Log("INFO",device.Name, - $"Trusted SCL topology verified: IED={proof.IedName}; " + - $"LD={string.Join(",",proof.LogicalDeviceAliases.Values)}; " + - $"MMS domains preserved; source={proof.Source}."); + var proof = await TrustedSclIdentityAuthority.TryMatchAsync( + device, liveModel, cancellationToken).ConfigureAwait(false); + if (proof is not null) + { + identity = TrustedSclIdentityAuthority.ToConsumerIdentity( + proof, liveModel.LogicalDevices.Select(ld => ld.MmsDomain)); + device.TrustedLiveIdentity = proof; + Log("INFO", device.Name, + $"Trusted SCL topology verified: IED={proof.IedName}; " + + $"LD={string.Join(",", proof.LogicalDeviceAliases.Values)}; " + + $"MMS domains preserved; source={proof.Source}."); + } + else + { + Log("WARN", device.Name, + "Opened SCL did not uniquely match this endpoint's full MMS domain set. " + + "Discovery identity remains provisional; no vendor-based rename."); + } } - else + catch (OperationCanceledException) { throw; } + catch (Exception ex) when (ex is IOException or InvalidDataException or System.Xml.XmlException + or ArgumentException or UnauthorizedAccessException) { - Log("WARN",device.Name, - "Opened SCL did not uniquely match this endpoint's full MMS domain set. " + - "Discovery identity remains provisional; no vendor-based rename."); + Log("WARN", device.Name, + $"Trusted SCL identity verification was rejected: {ex.Message}. " + + "Discovery can continue, but identity is provisional until SCL is re-opened."); } } - catch (OperationCanceledException) { throw; } - catch (Exception ex) when (ex is IOException or InvalidDataException or System.Xml.XmlException - or ArgumentException or UnauthorizedAccessException) + else { - Log("WARN",device.Name, - $"Trusted SCL identity verification was rejected: {ex.Message}. " + - "Discovery can continue, but identity is provisional until SCL is re-opened."); + // Discovery by IP only: reuse only a *previously successful* + // SCL-assisted endpoint whose unchanged IID bytes and full live + // MMS domain topology are freshly matched. Never attach the + // SCL workspace or change report/GOOSE acquisition authority. + var remembered = await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + device, liveModel, cancellationToken).ConfigureAwait(false); + if (remembered is not null) + { + identity = TrustedSclIdentityAuthority.ToConsumerIdentity( + remembered.Identity, liveModel.LogicalDevices.Select(ld => ld.MmsDomain)); + device.TrustedLiveIdentity = remembered.Identity; + Log("INFO", device.Name, + $"IP Discovery identity independently verified: " + + $"IED={remembered.Identity.IedName}; AP={remembered.AccessPointName}; " + + $"IID SHA-256={remembered.SourceSha256}; source=prior verified SCL-assisted association. " + + "Live MMS discovery and static DataSet authority remain unchanged."); + } + else + { + Log("INFO", device.Name, + "IP Discovery IEDName remains provisional: no unique, unchanged " + + "previously verified SCL identity matched this endpoint and full MMS domain set. " + + "Open the original engineering IID and connect once to establish a reusable identity proof."); + } } } if (!string.IsNullOrWhiteSpace(identity.IedName)) From e6ba4ed09c104ed5c5102d9bc70f07e3c029ebfa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 11:16:03 +0700 Subject: [PATCH 57/99] feat(p10.6): remember the actual SHA-verified operator IID source after successful SCL/MMs association --- MainWindow.xaml.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 2c55ee333..5fce0ce01 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1047,7 +1047,8 @@ await _runtime.ConnectUsingCachedModelAsync( { SclEndpointBindingStore.RecordVerified( device.SclSourceSha256, device.SclIedName, - device.SclAccessPointName, device.IpAddress, device.Port); + device.SclAccessPointName, device.IpAddress, device.Port, + device.SclSourcePath); if (device.SclEndpointOrigin != "SCD-declared") device.SclEndpointOrigin = "Verified exact SCD/IED/AP"; } From d9ae5e4f41f6b34bf37a5ba72abf10e278f1fa53 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 11:16:15 +0700 Subject: [PATCH 58/99] fix(p10.6): recognize fresh high-confidence SCL identity proof in Discovery parity without mutating MMS model --- Services/StaticAcquisitionParityTracker.cs | 30 ++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Services/StaticAcquisitionParityTracker.cs b/Services/StaticAcquisitionParityTracker.cs index d8f4bd81a..7d2bac3dd 100644 --- a/Services/StaticAcquisitionParityTracker.cs +++ b/Services/StaticAcquisitionParityTracker.cs @@ -120,8 +120,8 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence( evidenceIssues.Add("no engine-authoritative static report plans"); if (model is null) evidenceIssues.Add("ingress model missing"); - else if (string.IsNullOrWhiteSpace(model.IedName) || - !string.Equals(model.IedName.Trim(), device.Name.Trim(), StringComparison.OrdinalIgnoreCase)) + else if (!string.Equals(model.IedName?.Trim(), device.Name.Trim(), StringComparison.OrdinalIgnoreCase) && + !HasVerifiedIngressIdentity(device, model, ingress)) evidenceIssues.Add($"ingress model IED identity '{model.IedName}' does not match selected IED '{device.Name}'"); // Comparing equal hashes is not meaningful if two independently malformed or @@ -331,6 +331,32 @@ internal static StaticAcquisitionParitySnapshot Compare( }; } + // The live MMS document may retain its original IP-based name as immutable + // wire provenance. A fresh, association-scoped, full-domain SHA-verified SCL + // identity is sufficient to compare semantic evidence under the real IED name. + // Never allow an arbitrary manual card rename to satisfy this parity gate. + private static bool HasVerifiedIngressIdentity( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument model, + StaticAcquisitionIngressKind ingress) + { + if (ingress != StaticAcquisitionIngressKind.LiveDiscovery || + device.TrustedLiveIdentity is not { } proof || + proof.Source != "TrustedSclExactDomainMatch" || + proof.Confidence != LiveIedDiscoveryConfidenceLevel.High || + !string.Equals(proof.IedName, device.Name, StringComparison.OrdinalIgnoreCase)) + return false; + + var domains = model.LogicalDevices + .Select(ld => ld.MmsDomain) + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return domains.Length > 0 && + domains.Length == proof.LogicalDeviceAliases.Count && + domains.All(domain => proof.LogicalDeviceAliases.ContainsKey(domain)); + } + private static string BuildRuntimeTarget(ReportControlPlan plan) => $"{NormalizeKind(plan.EngineAcquisitionKind)}|dataset={NormalizeReference(plan.DataSetReference)}|" + $"rcb={NormalizeReference(plan.ReportControlReference)}|bindings={plan.Bindings.Count}"; From cb19ce4a01556034000d4a9e313e0a18a3e9c173 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 11:17:06 +0700 Subject: [PATCH 59/99] test(p10.6): verify remembered GE IID identity and fail closed for untrusted, stale, divergent and ambiguous sources --- ...RememberedTrustedDiscoveryIdentityTests.cs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs diff --git a/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs b/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs new file mode 100644 index 000000000..6a901f386 --- /dev/null +++ b/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs @@ -0,0 +1,206 @@ +using System.Security.Cryptography; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// IP-only Discovery identity MUST be proved by a live, exact-domain, +/// SHA-verified engineering IID previously accepted on a successful SCL/MMS association. +public sealed class P106RememberedTrustedDiscoveryIdentityTests +{ + [Fact] + public async Task GE_F650_PriorSuccessfulSclAssociation_PromotesIpDiscoveryToBCUGE() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102, source.Path); + + var device = Device("192.16.1.33"); + var original = Model("BCUGEF650"); + var result = await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + device, original, bindingStorePath: fixture.Store); + + Assert.NotNull(result); + Assert.Equal("BCUGE", result.Identity.IedName); + Assert.Equal("S1", result.AccessPointName); + Assert.Equal(source.Sha, result.SourceSha256, ignoreCase: true); + var consumer = TrustedSclIdentityAuthority.ToConsumerIdentity( + result.Identity, ["BCUGEF650"]); + Assert.Equal("BCUGE", consumer.IedName); + Assert.Equal("F650", Assert.Single(consumer.LogicalDevices).Instance); + Assert.Equal("BCUGEF650", Assert.Single(consumer.LogicalDevices).Domain); + Assert.Equal("IED_192_16_1_33", original.IedName); // engine model not rewritten + Assert.Null(device.SclWorkspace); // Discovery reporting remains MMS-owned + } + + [Fact] + public async Task NameIsNeverGuessedFromIpOrLoneMmsDomain() + { + using var fixture = new Fixture(); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Fact] + public async Task LegacySuccessfulEndpointWithoutSourcePath_IsNotNameAuthority() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Theory] + [InlineData("192.16.1.34", "BCUGEF650")] + [InlineData("192.16.1.33", "ANOTHERDOMAIN")] + public async Task WrongEndpointOrDomain_FailsClosed(string ip, string domain) + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102, source.Path); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device(ip), Model(domain), bindingStorePath: fixture.Store)); + } + + [Fact] + public async Task ModifiedTrustedIid_RejectsSourceEvenIfXmlTopologyStillMatches() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102, source.Path); + File.AppendAllText(source.Path, " "); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Fact] + public async Task MultipleMatchingEngineeringSources_CannotSilentlySelectMostRecent() + { + using var fixture = new Fixture(); + var first = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + var other = fixture.NewIid("OTHER", "S1", "F650", "192.16.1.33", + explicitDomain: "BCUGEF650"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, first.Sha, + "BCUGE", "S1", "192.16.1.33", 102, first.Path); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, other.Sha, + "OTHER", "S1", "192.16.1.33", 102, other.Path); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Fact] + public async Task MultiAccessPointIid_IsNotGuessedFromTheRememberedAp() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33", + extraAccessPoint: true); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102, source.Path); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Fact] + public void VerifiedIdentityAllowsCanonicalParityWithoutMutatingIpFallbackModel() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + var device = Device("192.16.1.33"); + device.Name = "BCUGE"; + device.LiveDiscoveryModel = new LiveIedModelDiscoveryDocument + { + IedName = "IED_192_16_1_33", + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "BCUGEF650" }], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "BCUGEF650/LLN0.Events", + Domain = "BCUGEF650", + LogicalNode = "LLN0", + Name = "Events", + MemberCount = 1, + Members = [new LiveIedDataSetMemberModel { + Index = 0, Reference = "BCUGEF650/XCBR1.Pos.stVal", FunctionalConstraint = "ST" }] + } + ] + }; + var plan = new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + RequestedPointCount = 1, + StaticBrcbSignalCount = 1, + ReportPlans = [new ReportControlPlan + { + IsEngineAuthoritative = true, EngineAcquisitionKind = "StaticBrcb", + Buffered = true, DataSetReference = "BCUGEF650/LLN0.Events", + ReportControlReference = "BCUGEF650/LLN0.BR.Events1", + Bindings = [new Iec61850MonitorPoint { + DeviceId = "ge", IecReference = "BCUGEF650/XCBR1.Pos.stVal", + FunctionalConstraint = "ST" }] + }] + }; + + var noProof = StaticAcquisitionParityTracker.BuildEvidence( + device, plan, StaticAcquisitionIngressKind.LiveDiscovery); + Assert.False(noProof.IsComparable); + Assert.Contains("IED identity", noProof.IncomparableReason, StringComparison.Ordinal); + + // Identity evidence is set only by the trusted matcher at successful Discovery. + var xml = System.Xml.Linq.XDocument.Load(source.Path); + var proof = TrustedSclIedIdentityMatcher.TryMatch( + xml, ["BCUGEF650"], "192.16.1.33", "BCUGE"); + Assert.NotNull(proof); + device.TrustedLiveIdentity = proof; + var verified = StaticAcquisitionParityTracker.BuildEvidence( + device, plan, StaticAcquisitionIngressKind.LiveDiscovery); + Assert.True(verified.IsComparable, verified.IncomparableReason); + Assert.Equal("IED_192_16_1_33", device.LiveDiscoveryModel.IedName); + } + + private static Iec61850MonitorDevice Device(string ip) => new() + { + Name = "IED_192_16_1_33", IpAddress = ip, Port = 102 + }; + + private static LiveIedModelDiscoveryDocument Model(string domain) => new() + { + Host = "192.16.1.33", + IedName = "IED_192_16_1_33", + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = domain, Inst = domain }] + }; + + private sealed class Fixture : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), + "arsas-p106-" + Guid.NewGuid().ToString("N")); + public Fixture() => Directory.CreateDirectory(_directory); + public string Store => Path.Combine(_directory, "bindings.json"); + + public (string Path, string Sha) NewIid( + string ied, string ap, string ld, string ip, + string? explicitDomain = null, bool extraAccessPoint = false) + { + var path = Path.Combine(_directory, Guid.NewGuid().ToString("N") + ".iid"); + var ldName = explicitDomain is null ? "" : " ldName=\"" + explicitDomain + "\""; + var extra = extraAccessPoint + ? "" + : ""; + var xml = $""" + +

{ip}

+ {extra} +
+ """; + File.WriteAllText(path, xml); + return (path, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant()); + } + public void Dispose() => Directory.Delete(_directory, recursive: true); + } +} From a883510218c62ac02096de37e831feede389f4a1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 11:19:06 +0700 Subject: [PATCH 60/99] test(p10.6): prove trusted IP Discovery semantic fingerprint equals equivalent Open SCL fingerprint --- ...RememberedTrustedDiscoveryIdentityTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs b/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs index 6a901f386..681c24ee2 100644 --- a/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs +++ b/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; using ArIED61850Tester.Models; using ArIED61850Tester.Services; @@ -162,6 +163,26 @@ public void VerifiedIdentityAllowsCanonicalParityWithoutMutatingIpFallbackModel( device, plan, StaticAcquisitionIngressKind.LiveDiscovery); Assert.True(verified.IsComparable, verified.IncomparableReason); Assert.Equal("IED_192_16_1_33", device.LiveDiscoveryModel.IedName); + + // A verified IED name is semantic metadata only; the exact same selected + // DataSet plan must now fingerprint identically to Open SCL. + device.SclWorkspace = new SclIedWorkspace + { + IedName = "BCUGE", + AccessPointName = "S1", + DesignModel = new LiveIedModelDiscoveryDocument + { + IedName = "BCUGE", + LogicalDevices = device.LiveDiscoveryModel.LogicalDevices, + DataSets = device.LiveDiscoveryModel.DataSets + } + }; + var openScl = StaticAcquisitionParityTracker.BuildEvidence( + device, plan, StaticAcquisitionIngressKind.OpenScl); + Assert.True(openScl.IsComparable, openScl.IncomparableReason); + Assert.Equal(verified.SemanticFingerprint, openScl.SemanticFingerprint); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, + StaticAcquisitionParityTracker.Compare(verified, openScl).Status); } private static Iec61850MonitorDevice Device(string ip) => new() From aa3aaf9fe3c4343d827d82fd762cca056ce24cee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 12:20:24 +0700 Subject: [PATCH 61/99] feat(p10.7): classify per-RCB SqNum findings by EntryID, GI and BufOvfl without mutating reporting --- Services/Iec61850ReportContinuityInspector.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index af561f6c8..f31dceef8 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -28,6 +28,14 @@ internal sealed class Iec61850ReportContinuityState public long MissingSequenceFrames { get; set; } public long SegmentedFrames { get; set; } public long AnomalyFindings { get; set; } + // Frame counts stay independent of findings: one frame may carry multiple anomalies. + public long AnomalousFrames { get; set; } + public long SequenceDiscontinuities { get; set; } + public long SequenceWithChangedEntryId { get; set; } + public long SequenceWithRepeatedEntryId { get; set; } + public long SequenceWithoutComparableEntryId { get; set; } + public long SequenceWithGiReason { get; set; } + public long SequenceWithOverflow { get; set; } public long BufferOverflows { get; set; } public long ConfRevChanges { get; set; } public long EntryIdPresentFrames { get; set; } @@ -68,6 +76,13 @@ public sealed record Iec61850ReportContinuityStreamSnapshot public long SequenceMissing { get; init; } public long Segmented { get; init; } public long Findings { get; init; } + public long AnomalousFrames { get; init; } + public long SequenceDiscontinuities { get; init; } + public long SequenceWithChangedEntryId { get; init; } + public long SequenceWithRepeatedEntryId { get; init; } + public long SequenceWithoutComparableEntryId { get; init; } + public long SequenceWithGiReason { get; init; } + public long SequenceWithOverflow { get; init; } public long Overflow { get; init; } public long ConfRevChanges { get; init; } public long EntryIdPresent { get; init; } @@ -104,6 +119,8 @@ public sealed record Iec61850ReportContinuitySnapshot public long Frames { get; init; } public long Sequenced { get; init; } public long Findings { get; init; } + public long AnomalousFrames { get; init; } + public long SequenceDiscontinuities { get; init; } public long Overflows { get; init; } public long OptFldsDecoded { get; init; } public long OptFldsUnknown { get; init; } @@ -176,6 +193,32 @@ string CausalContext() => $"startReturnUtc={state.ActivationReturnedAtUtc?.ToString("O") ?? "unknown"}, " + $"frameReceivedUtc={frame.ReceivedAt:O}; GI correlation is not proof of a harmless reset"; + // One causal classification per actually observed SqNum discontinuity. + // EntryID is an opaque octet string, never a numeric continuity proof. + void RecordSequenceCorrelation() + { + if (state.SequenceDiscontinuities < long.MaxValue) + state.SequenceDiscontinuities++; + if (buffered && priorEntryIdPresent && entryIdPresent) + { + if (entryIdChanged) + { + if (state.SequenceWithChangedEntryId < long.MaxValue) + state.SequenceWithChangedEntryId++; + } + else if (state.SequenceWithRepeatedEntryId < long.MaxValue) + state.SequenceWithRepeatedEntryId++; + } + else if (state.SequenceWithoutComparableEntryId < long.MaxValue) + state.SequenceWithoutComparableEntryId++; + + if (frame.GeneralInterrogationReasonSeen == true && + state.SequenceWithGiReason < long.MaxValue) + state.SequenceWithGiReason++; + if (frame.BufferOverflow == true && state.SequenceWithOverflow < long.MaxValue) + state.SequenceWithOverflow++; + } + // Raw OptFlds is authoritative decoder evidence. Unknown stays UNKNOWN; // a missing value cannot be interpreted as an omitted wire field. var hasOptFlds = frame.OptFldsSequenceNumber.HasValue && @@ -278,6 +321,7 @@ string CausalContext() => if (discontinuity is not null) { CaptureAnomaly(state, frame); + RecordSequenceCorrelation(); Warn(discontinuity + EntryContext() + CausalContext()); } } @@ -308,6 +352,7 @@ string CausalContext() => if (discontinuity is not null) { CaptureAnomaly(state, frame); + RecordSequenceCorrelation(); Warn(discontinuity + EntryContext() + CausalContext()); } state.LastSequenceNumber = current; @@ -321,6 +366,8 @@ string CausalContext() => if (entryIdPresent) state.LastEntryIdHex = frame.EntryIdHex; + if (findings is not null && state.AnomalousFrames < long.MaxValue) + state.AnomalousFrames++; return findings is null ? Array.Empty() : findings; } @@ -348,6 +395,13 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( SequenceMissing = state.MissingSequenceFrames, Segmented = state.SegmentedFrames, Findings = state.AnomalyFindings, + AnomalousFrames = state.AnomalousFrames, + SequenceDiscontinuities = state.SequenceDiscontinuities, + SequenceWithChangedEntryId = state.SequenceWithChangedEntryId, + SequenceWithRepeatedEntryId = state.SequenceWithRepeatedEntryId, + SequenceWithoutComparableEntryId = state.SequenceWithoutComparableEntryId, + SequenceWithGiReason = state.SequenceWithGiReason, + SequenceWithOverflow = state.SequenceWithOverflow, Overflow = state.BufferOverflows, ConfRevChanges = state.ConfRevChanges, EntryIdPresent = state.EntryIdPresentFrames, @@ -381,6 +435,8 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( Frames = ordered.Sum(state => state.FramesSeen), Sequenced = ordered.Sum(state => state.SequencedFrames), Findings = ordered.Sum(state => state.AnomalyFindings), + AnomalousFrames = ordered.Sum(state => state.AnomalousFrames), + SequenceDiscontinuities = ordered.Sum(state => state.SequenceDiscontinuities), Overflows = ordered.Sum(state => state.BufferOverflows), OptFldsDecoded = ordered.Sum(state => state.OptFldsDecodedFrames), OptFldsUnknown = ordered.Sum(state => state.OptFldsUnknownFrames), From 355de3d7d6a9c2d35a229743f93bc3e61b0dc91c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 12:20:39 +0700 Subject: [PATCH 62/99] =?UTF-8?q?feat(p10.7):=20include=20bounded=20BRCB?= =?UTF-8?q?=20SqNum=E2=80=93EntryID/GI/BufOvfl=20correlation=20counts=20in?= =?UTF-8?q?=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Services/DiagnosticReportBuilder.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index ddf92a589..1a2e6c27d 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -291,6 +291,9 @@ internal static void AppendReportContinuity( $"streams={snapshot.StreamCount}, findings={snapshot.Findings}, " + $"BufOvfl={snapshot.Overflows}, untracked={snapshot.UntrackedStreamCount}, " + $"OptFldsDecoded={snapshot.OptFldsDecoded}, OptFldsUnknown={snapshot.OptFldsUnknown}"); + builder.AppendLine($" Frame triage : framesWithFindings={snapshot.AnomalousFrames}, " + + $"sequenceDiscontinuities={snapshot.SequenceDiscontinuities}; " + + "these are observational counts, not confirmed lost SOE events."); builder.AppendLine(" Qualification : ARIEC decoder-sourced wire OptFlds evidence; " + "OptFldsUnknown is not proof of absent wire fields. No alerts does not prove SOE/event continuity, " + "GI causality or reconnect/replay completeness."); @@ -311,6 +314,15 @@ static string Safe(string value) $"BufOvfl={stream.Overflow}, ConfRevChanges={stream.ConfRevChanges}, " + $"EntryIDPresent={stream.EntryIdPresent}, firstSqNum={stream.FirstSqNum?.ToString() ?? "-"}, " + $"lastSqNum={stream.LastSqNum?.ToString() ?? "-"}"); + if (stream.Findings > 0) + builder.AppendLine($" BRCB triage : anomalousFrames={stream.AnomalousFrames}, " + + $"sqDiscontinuities={stream.SequenceDiscontinuities}, " + + $"sqChangedEntryID={stream.SequenceWithChangedEntryId}, " + + $"sqRepeatedEntryID={stream.SequenceWithRepeatedEntryId}, " + + $"sqEntryIDUnknown={stream.SequenceWithoutComparableEntryId}, " + + $"sqWithGIreason={stream.SequenceWithGiReason}, " + + $"sqWithBufOvfl={stream.SequenceWithOverflow}; " + + "EntryID difference/identical is not an event-loss or harmless-replay conclusion."); builder.AppendLine($" OptFlds : decoded={stream.OptFldsDecoded}/{stream.Frames}, " + $"unknown={stream.OptFldsUnknown}, lastMask={Safe(stream.LastOptFldsHex)}, " + $"SqNum requested={stream.SqNumAdvertised}, omitted={stream.SqNumOmitted}, " + From 52ca1e742cabced137ec860e73f78642b1521713 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 12:21:09 +0700 Subject: [PATCH 63/99] test(p10.7): preserve BufOvfl warnings and classify overlapping BRCB SqNum/EntryID/GI evidence --- .../P107BrcbEvidenceTriageTests.cs | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs diff --git a/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs b/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs new file mode 100644 index 000000000..68988b6e0 --- /dev/null +++ b/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs @@ -0,0 +1,166 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Deterministic, lab-origin protocol-fact fixtures derived from GE simulator +/// observations. Counts are evidence only; they do not certify lossless SOE. +/// +public sealed class P107BrcbEvidenceTriageTests +{ + private static NativeReportFrameMetadata Frame( + ulong? sq, string entry = "", bool? giReason = null, + bool? overflow = null, ulong? sub = null, bool? more = null) + => new() + { + SequenceNumber = sq, + EntryIdHex = entry, + GeneralInterrogationReasonSeen = giReason, + BufferOverflow = overflow, + SubSequenceNumber = sub, + MoreSegmentsFollow = more, + ReceivedAt = DateTimeOffset.Parse("2026-10-11T04:45:43+00:00") + }; + + [Fact] + public void Brcb_BackwardSqWithNewEntryIdAndGIAndOverflow_StaysTwoFindingsOneBadFrame() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, Frame(12, "AABB"), true)); + var findings = Iec61850ReportContinuityInspector.Observe( + state, Frame(1, "CCDD", giReason: true, overflow: true), true); + + Assert.Equal(2, findings.Count); // BufOvfl and backward/reset/replay remain visible + Assert.Contains(findings, f => f.Contains("BufOvfl=true", StringComparison.Ordinal)); + Assert.Contains(findings, f => f.Contains("no event-loss conclusion", StringComparison.Ordinal)); + Assert.Equal(1, state.AnomalousFrames); + Assert.Equal(2, state.AnomalyFindings); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(1, state.SequenceWithChangedEntryId); + Assert.Equal(0, state.SequenceWithRepeatedEntryId); + Assert.Equal(0, state.SequenceWithoutComparableEntryId); + Assert.Equal(1, state.SequenceWithGiReason); + Assert.Equal(1, state.SequenceWithOverflow); + Assert.Equal(1, state.BufferOverflows); + } + + [Fact] + public void RepeatedSqAndIdenticalOpaqueEntryID_RemainsAmbiguousNotCertifiedReplay() + { + var state = new Iec61850ReportContinuityState(); + Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, Frame(0, "CAFE"), true)); + var findings = Iec61850ReportContinuityInspector.Observe(state, Frame(0, "CAFE"), true); + Assert.Single(findings); + Assert.Contains("duplicate/replay", findings[0], StringComparison.Ordinal); + Assert.Contains("changed=False", findings[0], StringComparison.Ordinal); + Assert.Equal(1, state.SequenceWithRepeatedEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + Assert.Equal(0, state.SequenceWithGiReason); + } + + [Fact] + public void MissingEntryID_IsCountedSeparatelyWithoutInferringMissingEvents() + { + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, Frame(4, "CAFE"), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(1), true); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(1, state.SequenceWithoutComparableEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + } + + [Fact] + public void GiWriteWithoutDecodedGiReason_DoesNotCountAsFrameGiCorrelation() + { + var state = new Iec61850ReportContinuityState { GiWriteAccepted = true }; + Iec61850ReportContinuityInspector.Observe(state, Frame(4, "AA"), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(0, "BB"), true); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(0, state.SequenceWithGiReason); + } + + [Fact] + public void HealthyIncrementAndRollover_AreNotManufacturedAsSequenceAnomalies() + { + var state = new Iec61850ReportContinuityState(); + foreach (var sq in new ulong[] { 65535, 0, 1 }) + Assert.Empty(Iec61850ReportContinuityInspector.Observe( + state, Frame(sq, sq.ToString("X4")), true)); + Assert.Equal(0, state.AnomalousFrames); + Assert.Equal(0, state.SequenceDiscontinuities); + } + + [Fact] + public void SegmentMetadataFault_IncrementsBadFrameButNotSqDiscontinuity() + { + var state = new Iec61850ReportContinuityState(); + var warnings = Iec61850ReportContinuityInspector.Observe( + state, Frame(12, sub: 2, more: false), true); + Assert.Contains(warnings, w => w.Contains("starts at SubSqNum=2", StringComparison.Ordinal)); + Assert.Equal(1, state.AnomalousFrames); + Assert.Equal(0, state.SequenceDiscontinuities); + } + + [Fact] + public void SnapshotAndDiagnostics_SeparateFindingsFromFramesAndOpaqueEntryIdKinds() + { + var state = new Iec61850ReportContinuityState + { + ReportControlReference = "BCUGEF650/LLN0.BR.rpt01", + DataSetReference = "BCUGEF650/LLN0.CSWI", + Buffered = true + }; + Iec61850ReportContinuityInspector.Observe(state, Frame(3, "A0"), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(1, "B0", giReason: true, overflow: true), true); + var report = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["rpt"] = state }, 67, 0); + Assert.Equal(2, report.Findings); + Assert.Equal(1, report.AnomalousFrames); + Assert.Equal(1, report.SequenceDiscontinuities); + Assert.Equal(1, report.Streams[0].SequenceWithChangedEntryId); + Assert.Equal(1, report.Streams[0].SequenceWithGiReason); + Assert.Equal(1, report.Streams[0].SequenceWithOverflow); + + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, report); + var text = sb.ToString(); + Assert.Contains("sequenceDiscontinuities=1", text, StringComparison.Ordinal); + Assert.Contains("sqChangedEntryID=1", text, StringComparison.Ordinal); + Assert.Contains("sqWithGIreason=1", text, StringComparison.Ordinal); + Assert.Contains("sqWithBufOvfl=1", text, StringComparison.Ordinal); + Assert.Contains("not an event-loss", text, StringComparison.Ordinal); + + // A published copy must not follow later mutation of the live association. + Iec61850ReportContinuityInspector.Observe(state, Frame(1, "B0"), true); + Assert.Equal(1, report.SequenceDiscontinuities); + Assert.Equal(2, report.Findings); + } + + [Fact] + public void DiagnosticOnly_LeavesMmsReportAndControlWireContractsUntouched() + { + var source = ReadRepoFile("Services/Iec61850ReportContinuityInspector.cs"); + Assert.DoesNotContain("ReadMms", source, StringComparison.Ordinal); + Assert.DoesNotContain("WriteMms", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartReport", source, StringComparison.Ordinal); + Assert.Contains("RecordSequenceCorrelation();", source, StringComparison.Ordinal); + var runtime = ReadRepoFile("Services/Iec61850MonitorRuntime.cs"); + Assert.Contains("session.ReportStreams.Clear()", runtime, StringComparison.Ordinal); + Assert.Contains("maxTrackedStreams = 64", runtime, StringComparison.Ordinal); + } + + private static string ReadRepoFile(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var found = Path.Combine(dir.FullName, path); + if (File.Exists(found)) return File.ReadAllText(found); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +} From 96c0b9b7921496fbc6a5c30d18c13c176e9b49b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 12:24:51 +0700 Subject: [PATCH 64/99] fix(p10.7): require raw OptFlds provenance on both BRCB EntryIDs before classifying changed/repeated cursor --- Services/Iec61850ReportContinuityInspector.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index f31dceef8..950a8a88d 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -16,6 +16,9 @@ internal sealed class Iec61850ReportContinuityState public bool AwaitingMoreSegments { get; set; } public ulong? ConfigurationRevision { get; set; } public string LastEntryIdHex { get; set; } = string.Empty; + // A decoded value without raw OptionFields provenance cannot certify that + // the prior wire report advertised an EntryID. Do not treat it as comparable. + public bool LastEntryIdProvenByOptFlds { get; set; } // All counts belong to this exact report stream within the current // association. No static state, no process/control-plane side effects. @@ -199,7 +202,9 @@ void RecordSequenceCorrelation() { if (state.SequenceDiscontinuities < long.MaxValue) state.SequenceDiscontinuities++; - if (buffered && priorEntryIdPresent && entryIdPresent) + if (buffered && priorEntryIdPresent && entryIdPresent && + state.LastEntryIdProvenByOptFlds && frame.OptFldsEntryId == true && + IsBoundedHex(frame.OptFldsRawHex)) { if (entryIdChanged) { @@ -364,7 +369,17 @@ void RecordSequenceCorrelation() } if (entryIdPresent) + { state.LastEntryIdHex = frame.EntryIdHex; + state.LastEntryIdProvenByOptFlds = hasOptFlds && frame.OptFldsEntryId == true; + } + else + { + // Missing current EntryID invalidates comparisons with future frames; + // never compare across an opaque, unobserved resume cursor. + state.LastEntryIdHex = string.Empty; + state.LastEntryIdProvenByOptFlds = false; + } if (findings is not null && state.AnomalousFrames < long.MaxValue) state.AnomalousFrames++; From 36a68adc12c8a447ba7a1ccdb5d6a9423c4408d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 12:25:16 +0700 Subject: [PATCH 65/99] test(p10.7): classify opaque BRCB cursors only with two consecutive raw OptFlds proofs --- .../P107BrcbEvidenceTriageTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs b/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs index 68988b6e0..5fd64c9a5 100644 --- a/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs +++ b/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs @@ -17,6 +17,13 @@ private static NativeReportFrameMetadata Frame( { SequenceNumber = sq, EntryIdHex = entry, + // Synthetic wire-grounded metadata: both decoded OptionFields and + // reported values must be present to compare EntryID cursors. + OptFldsRawHex = "7F80", + OptFldsSequenceNumber = true, + OptFldsEntryId = true, + OptFldsBufferOverflow = true, + OptFldsConfRev = true, GeneralInterrogationReasonSeen = giReason, BufferOverflow = overflow, SubSequenceNumber = sub, @@ -71,6 +78,43 @@ public void MissingEntryID_IsCountedSeparatelyWithoutInferringMissingEvents() Assert.Equal(0, state.SequenceWithChangedEntryId); } + [Fact] + public void DecodedEntryIdWithoutRawOptionFields_StaysUncomparable() + { + var noWireProvenance = new NativeReportFrameMetadata + { + SequenceNumber = 4, + EntryIdHex = "CAFE" + }; + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, noWireProvenance, true); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, "BEEF"), true); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(1, state.SequenceWithoutComparableEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + + var next = new NativeReportFrameMetadata + { + SequenceNumber = 0, + EntryIdHex = "DEAD" + }; + Iec61850ReportContinuityInspector.Observe(state, next, true); + Assert.Equal(2, state.SequenceWithoutComparableEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + } + + [Fact] + public void MissingIntermediateEntryId_CannotBridgeNonAdjacentResumeCursors() + { + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, Frame(5, "AAAA"), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(6), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, "BBBB"), true); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(1, state.SequenceWithoutComparableEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + } + [Fact] public void GiWriteWithoutDecodedGiReason_DoesNotCountAsFrameGiCorrelation() { From 415d1db74c0f69637063601da037edf78142cb89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 12:25:33 +0700 Subject: [PATCH 66/99] fix(p10.7): label shared RCB diagnostics correctly for both BRCB and URCB --- Services/DiagnosticReportBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 1a2e6c27d..681688c82 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -315,7 +315,7 @@ static string Safe(string value) $"EntryIDPresent={stream.EntryIdPresent}, firstSqNum={stream.FirstSqNum?.ToString() ?? "-"}, " + $"lastSqNum={stream.LastSqNum?.ToString() ?? "-"}"); if (stream.Findings > 0) - builder.AppendLine($" BRCB triage : anomalousFrames={stream.AnomalousFrames}, " + + builder.AppendLine($" RCB triage : anomalousFrames={stream.AnomalousFrames}, " + $"sqDiscontinuities={stream.SequenceDiscontinuities}, " + $"sqChangedEntryID={stream.SequenceWithChangedEntryId}, " + $"sqRepeatedEntryID={stream.SequenceWithRepeatedEntryId}, " + From d8b51ee1494405bf536520f9a10d0ee4f66aa5a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 13:31:44 +0700 Subject: [PATCH 67/99] feat(p10.8): bounded post-finding per-RCB receive-order evidence with no MMS changes --- Services/Iec61850ReportContinuityInspector.cs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index 950a8a88d..da27a2246 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -33,6 +33,11 @@ internal sealed class Iec61850ReportContinuityState public long AnomalyFindings { get; set; } // Frame counts stay independent of findings: one frame may carry multiple anomalies. public long AnomalousFrames { get; set; } + // Bounded per-association receive-order evidence. No raw event history or timers. + public long FramesAfterLastFinding { get; set; } + public DateTimeOffset? FirstFrameReceivedAtUtc { get; set; } + public DateTimeOffset? LastFrameReceivedAtUtc { get; set; } + public DateTimeOffset? LastFindingReceivedAtUtc { get; set; } public long SequenceDiscontinuities { get; set; } public long SequenceWithChangedEntryId { get; set; } public long SequenceWithRepeatedEntryId { get; set; } @@ -80,6 +85,10 @@ public sealed record Iec61850ReportContinuityStreamSnapshot public long Segmented { get; init; } public long Findings { get; init; } public long AnomalousFrames { get; init; } + public long FramesAfterLastFinding { get; init; } + public DateTimeOffset? FirstFrameReceivedAtUtc { get; init; } + public DateTimeOffset? LastFrameReceivedAtUtc { get; init; } + public DateTimeOffset? LastFindingReceivedAtUtc { get; init; } public long SequenceDiscontinuities { get; init; } public long SequenceWithChangedEntryId { get; init; } public long SequenceWithRepeatedEntryId { get; init; } @@ -150,6 +159,11 @@ internal static IReadOnlyList Observe( ArgumentNullException.ThrowIfNull(frame); if (state.FramesSeen < long.MaxValue) state.FramesSeen++; + if (frame.ReceivedAt != default) + { + state.FirstFrameReceivedAtUtc ??= frame.ReceivedAt; + state.LastFrameReceivedAtUtc = frame.ReceivedAt; + } if (frame.SequenceNumber.HasValue) { if (state.SequencedFrames < long.MaxValue) state.SequencedFrames++; @@ -381,8 +395,19 @@ void RecordSequenceCorrelation() state.LastEntryIdProvenByOptFlds = false; } - if (findings is not null && state.AnomalousFrames < long.MaxValue) - state.AnomalousFrames++; + if (findings is not null) + { + if (state.AnomalousFrames < long.MaxValue) + state.AnomalousFrames++; + state.FramesAfterLastFinding = 0; + // Missing decoder timestamps remain unknown; do not reuse older time. + state.LastFindingReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; + } + else if (state.AnomalousFrames > 0 && state.FramesAfterLastFinding < long.MaxValue) + { + // Receive-order observation only; no claim of event delivery completeness. + state.FramesAfterLastFinding++; + } return findings is null ? Array.Empty() : findings; } @@ -411,6 +436,10 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( Segmented = state.SegmentedFrames, Findings = state.AnomalyFindings, AnomalousFrames = state.AnomalousFrames, + FramesAfterLastFinding = state.FramesAfterLastFinding, + FirstFrameReceivedAtUtc = state.FirstFrameReceivedAtUtc, + LastFrameReceivedAtUtc = state.LastFrameReceivedAtUtc, + LastFindingReceivedAtUtc = state.LastFindingReceivedAtUtc, SequenceDiscontinuities = state.SequenceDiscontinuities, SequenceWithChangedEntryId = state.SequenceWithChangedEntryId, SequenceWithRepeatedEntryId = state.SequenceWithRepeatedEntryId, From c27da842124d34d0102c6f7d015ddd51675a3e0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 13:32:01 +0700 Subject: [PATCH 68/99] feat(p10.8): report real post-anomaly frame count and first/last received times --- Services/DiagnosticReportBuilder.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 681688c82..1206cb6a0 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -323,6 +323,12 @@ static string Safe(string value) $"sqWithGIreason={stream.SequenceWithGiReason}, " + $"sqWithBufOvfl={stream.SequenceWithOverflow}; " + "EntryID difference/identical is not an event-loss or harmless-replay conclusion."); + if (stream.AnomalousFrames > 0) + builder.AppendLine($" RCB follow-up : receivedAfterLastFinding={stream.FramesAfterLastFinding}, " + + $"firstFrameUtc={stream.FirstFrameReceivedAtUtc?.ToString("O") ?? "-"}, " + + $"lastFindingUtc={stream.LastFindingReceivedAtUtc?.ToString("O") ?? "-"}, " + + $"lastFrameUtc={stream.LastFrameReceivedAtUtc?.ToString("O") ?? "-"}; " + + "later clean frames are observed report traffic, not proof of complete buffered SOE."); builder.AppendLine($" OptFlds : decoded={stream.OptFldsDecoded}/{stream.Frames}, " + $"unknown={stream.OptFldsUnknown}, lastMask={Safe(stream.LastOptFldsHex)}, " + $"SqNum requested={stream.SqNumAdvertised}, omitted={stream.SqNumOmitted}, " + From a7f4edbf5cb972eacb9baaf0c25e4475ee5f10c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 13:32:39 +0700 Subject: [PATCH 69/99] test(p10.8): verify per-RCB post-finding observed traffic, timestamp provenance and bounded snapshots --- .../P108PostAnomalyFollowUpEvidenceTests.cs | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/ARSAS.Tests/P108PostAnomalyFollowUpEvidenceTests.cs diff --git a/tests/ARSAS.Tests/P108PostAnomalyFollowUpEvidenceTests.cs b/tests/ARSAS.Tests/P108PostAnomalyFollowUpEvidenceTests.cs new file mode 100644 index 000000000..121e0996e --- /dev/null +++ b/tests/ARSAS.Tests/P108PostAnomalyFollowUpEvidenceTests.cs @@ -0,0 +1,163 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// P10.8 bounded receive-order evidence. A clean suffix does not clear historical +/// BufOvfl or prove lossless SOE. No network operation is performed here. +/// +public sealed class P108PostAnomalyFollowUpEvidenceTests +{ + private static readonly DateTimeOffset Start = + DateTimeOffset.Parse("2026-10-11T06:25:51.000+00:00"); + + private static NativeReportFrameMetadata Frame( + ulong sequence, int elapsedMilliseconds, bool overflow = false, + bool missingTime = false) => new() + { + ReceivedAt = missingTime ? default : Start.AddMilliseconds(elapsedMilliseconds), + SequenceNumber = sequence, + EntryIdHex = sequence.ToString("X4"), + OptFldsRawHex = "7F80", + OptFldsSequenceNumber = true, + OptFldsEntryId = true, + OptFldsBufferOverflow = true, + OptFldsConfRev = true, + GeneralInterrogationReasonSeen = false, + BufferOverflow = overflow, + ConfRev = 1 + }; + + [Fact] + public void StartupFindingsRemainVisibleWhileCleanFollowUpFramesAccumulate() + { + var state = new Iec61850ReportContinuityState + { + ReportControlReference = "BCUGEF650/LLN0.BR.BRB_CSWI_GW010101", + Buffered = true + }; + Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, Frame(4, 0), true)); + var anomaly = Iec61850ReportContinuityInspector.Observe( + state, Frame(1, 120, overflow: true), true); + Assert.Equal(2, anomaly.Count); + + foreach (var (sq, elapsed) in new[] { (2UL, 2000), (3UL, 3000), (4UL, 4000) }) + Assert.Empty(Iec61850ReportContinuityInspector.Observe( + state, Frame(sq, elapsed), true)); + + Assert.Equal(5, state.FramesSeen); + Assert.Equal(2, state.AnomalyFindings); + Assert.Equal(1, state.AnomalousFrames); + Assert.Equal(1, state.BufferOverflows); + Assert.Equal(3, state.FramesAfterLastFinding); + Assert.Equal(Start, state.FirstFrameReceivedAtUtc); + Assert.Equal(Start.AddMilliseconds(120), state.LastFindingReceivedAtUtc); + Assert.Equal(Start.AddMilliseconds(4000), state.LastFrameReceivedAtUtc); + + var snap = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["CSWI"] = state }, 5, 0); + Assert.Equal(3, Assert.Single(snap.Streams).FramesAfterLastFinding); + + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, snap); + Assert.Contains("receivedAfterLastFinding=3", sb.ToString(), StringComparison.Ordinal); + Assert.Contains("later clean frames", sb.ToString(), StringComparison.Ordinal); + Assert.Contains("BufOvfl=1", sb.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void NewLateFindingResetsFollowUpCounterButKeepsAllHistoricalWarnings() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Iec61850ReportContinuityInspector.Observe(state, Frame(4, 0), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, 200, overflow: true), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(2, 2000), true); + Assert.Equal(1, state.FramesAfterLastFinding); + + var late = Iec61850ReportContinuityInspector.Observe(state, Frame(1, 5000), true); + Assert.Single(late); + Assert.Equal(0, state.FramesAfterLastFinding); + Assert.Equal(Start.AddMilliseconds(5000), state.LastFindingReceivedAtUtc); + Assert.Equal(2, state.AnomalousFrames); + Assert.Equal(3, state.AnomalyFindings); + Assert.Equal(1, state.BufferOverflows); + } + + [Fact] + public void NoFindingNeverClaimsAFollowUpRecovery() + { + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, Frame(0, 0), false); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, 60000), false); + Assert.Equal(0, state.FramesAfterLastFinding); + Assert.Null(state.LastFindingReceivedAtUtc); + Assert.Equal(Start.AddMinutes(1), state.LastFrameReceivedAtUtc); + var snap = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["URCB"] = state }, 2, 0); + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, snap); + Assert.DoesNotContain("RCB follow-up", sb.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void MissingDecoderReceiveTimestampRemainsUnknownAndNeverInventsAge() + { + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, Frame(10, 0), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(0, 100, missingTime: true), true); + Assert.Equal(0, state.FramesAfterLastFinding); + Assert.Null(state.LastFindingReceivedAtUtc); + Assert.Equal(Start, state.LastFrameReceivedAtUtc); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, 5000), true); + Assert.Equal(1, state.FramesAfterLastFinding); + Assert.Null(state.LastFindingReceivedAtUtc); + } + + [Fact] + public void PostFindingCounterSaturatesAndSnapshotDoesNotFollowMutableState() + { + var state = new Iec61850ReportContinuityState + { + AnomalousFrames = 1, + FramesAfterLastFinding = long.MaxValue - 1 + }; + Iec61850ReportContinuityInspector.Observe(state, Frame(2, 100), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(3, 200), true); + Assert.Equal(long.MaxValue, state.FramesAfterLastFinding); + var snap = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["R"] = state }, 1, 0); + Assert.Equal(long.MaxValue, snap.Streams[0].FramesAfterLastFinding); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, 300), true); + Assert.Equal(0, state.FramesAfterLastFinding); + Assert.Equal(long.MaxValue, snap.Streams[0].FramesAfterLastFinding); + } + + [Fact] + public void MultipleStreamsHaveIndependentFollowUpEvidenceAndBoundedSnapshots() + { + var br = new Iec61850ReportContinuityState + { + Buffered = true, + ReportControlReference = "BCUGEF650/LLN0.BR.CSWI" + }; + var ur = new Iec61850ReportContinuityState + { + ReportControlReference = "BCUGEF650/LLN0.RP.MMXU" + }; + Iec61850ReportContinuityInspector.Observe(br, Frame(1, 0, overflow: true), true); + Iec61850ReportContinuityInspector.Observe(br, Frame(2, 5000), true); + Iec61850ReportContinuityInspector.Observe(ur, Frame(0, 0), false); + Iec61850ReportContinuityInspector.Observe(ur, Frame(1, 5000), false); + Assert.Equal(1, br.FramesAfterLastFinding); + Assert.Equal(0, ur.FramesAfterLastFinding); + var snapshot = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["a"] = br, ["b"] = ur }, + 5, 0, maxShown: 1); + Assert.Equal(2, snapshot.StreamCount); + Assert.Single(snapshot.Streams); + Assert.Equal("BCUGEF650/LLN0.BR.CSWI", snapshot.Streams[0].Rcb); + } +} From dfa65b8e9b3e7590edc90659199f82f5ea2434eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 13:59:34 +0700 Subject: [PATCH 70/99] feat(p10.8b): bounded first-32/last-8 BRCB metadata chronology with immutable snapshots --- Services/Iec61850ReportContinuityInspector.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index da27a2246..f67902c41 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -38,6 +38,12 @@ internal sealed class Iec61850ReportContinuityState public DateTimeOffset? FirstFrameReceivedAtUtc { get; set; } public DateTimeOffset? LastFrameReceivedAtUtc { get; set; } public DateTimeOffset? LastFindingReceivedAtUtc { get; set; } + // Head + tail chronology: fixed-size per BRCB and allocated only on demand. + public Iec61850BrcbChronologyFrame[]? InitialChronology { get; set; } + public int InitialChronologyCount { get; set; } + public Iec61850BrcbChronologyFrame[]? RecentChronology { get; set; } + public int RecentChronologyCount { get; set; } + public int RecentChronologyNext { get; set; } public long SequenceDiscontinuities { get; set; } public long SequenceWithChangedEntryId { get; set; } public long SequenceWithRepeatedEntryId { get; set; } @@ -69,6 +75,21 @@ internal sealed class Iec61850ReportContinuityState public DateTimeOffset? LastAnomalyReceivedAtUtc { get; set; } } +/// +/// Decoder-metadata-only evidence: no raw EntryID, process values, or packet bodies. +/// FrameOrdinal is receive order, not a server event sequence or TCP packet index. +/// +public sealed record Iec61850BrcbChronologyFrame +{ + public long FrameOrdinal { get; init; } + public DateTimeOffset? ReceivedAtUtc { get; init; } + public ulong? SequenceNumber { get; init; } + public bool? GiReason { get; init; } + public bool? BufferOverflow { get; init; } + public string EntryIdRelation { get; init; } = "unverified"; + public int FindingCount { get; init; } +} + /// /// Immutable copyable diagnostic evidence. Counts describe decoded metadata, /// NOT completeness of SOE delivery or absence of lost events. @@ -89,6 +110,11 @@ public sealed record Iec61850ReportContinuityStreamSnapshot public DateTimeOffset? FirstFrameReceivedAtUtc { get; init; } public DateTimeOffset? LastFrameReceivedAtUtc { get; init; } public DateTimeOffset? LastFindingReceivedAtUtc { get; init; } + public IReadOnlyList Chronology { get; init; } = + Array.Empty(); + public int ChronologyInitialCount { get; init; } + public int ChronologyRecentCount { get; init; } + public long ChronologyOmittedFrames { get; init; } public long SequenceDiscontinuities { get; init; } public long SequenceWithChangedEntryId { get; init; } public long SequenceWithRepeatedEntryId { get; init; } @@ -144,6 +170,8 @@ public sealed record Iec61850ReportContinuitySnapshot internal static class Iec61850ReportContinuityInspector { private const ulong MaxSequenceNumber = ushort.MaxValue; + private const int InitialChronologyLimit = 32; + private const int RecentChronologyLimit = 8; /// /// A gap/backward move is an OBSERVED discontinuity, not proof of a specific @@ -395,6 +423,26 @@ void RecordSequenceCorrelation() state.LastEntryIdProvenByOptFlds = false; } + if (buffered) + { + // Observational classification only. Both OptFlds must prove the + // opaque cursor to label successive values as changed or repeated. + var comparableEntryId = priorEntryIdPresent && entryIdPresent && + state.LastEntryIdProvenByOptFlds && hasOptFlds && frame.OptFldsEntryId == true; + var relation = !entryIdPresent ? "absent" : + !comparableEntryId ? "unverified" : entryIdChanged ? "changed" : "repeated"; + RecordChronology(state, new Iec61850BrcbChronologyFrame + { + FrameOrdinal = state.FramesSeen, + ReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt, + SequenceNumber = frame.SequenceNumber, + GiReason = frame.GeneralInterrogationReasonSeen, + BufferOverflow = frame.BufferOverflow, + EntryIdRelation = relation, + FindingCount = findings?.Count ?? 0 + }); + } + if (findings is not null) { if (state.AnomalousFrames < long.MaxValue) @@ -440,6 +488,12 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( FirstFrameReceivedAtUtc = state.FirstFrameReceivedAtUtc, LastFrameReceivedAtUtc = state.LastFrameReceivedAtUtc, LastFindingReceivedAtUtc = state.LastFindingReceivedAtUtc, + Chronology = CopyChronology(state), + ChronologyInitialCount = state.InitialChronologyCount, + ChronologyRecentCount = state.RecentChronologyCount, + ChronologyOmittedFrames = state.Buffered + ? Math.Max(0, state.FramesSeen - state.InitialChronologyCount - state.RecentChronologyCount) + : 0, SequenceDiscontinuities = state.SequenceDiscontinuities, SequenceWithChangedEntryId = state.SequenceWithChangedEntryId, SequenceWithRepeatedEntryId = state.SequenceWithRepeatedEntryId, @@ -488,6 +542,44 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( }; } + private static void RecordChronology( + Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) + { + if (state.InitialChronologyCount < InitialChronologyLimit) + { + var initial = state.InitialChronology ??= new Iec61850BrcbChronologyFrame[InitialChronologyLimit]; + initial[state.InitialChronologyCount++] = item; + } + else + { + var recent = state.RecentChronology ??= new Iec61850BrcbChronologyFrame[RecentChronologyLimit]; + recent[state.RecentChronologyNext] = item; + state.RecentChronologyNext = (state.RecentChronologyNext + 1) % RecentChronologyLimit; + if (state.RecentChronologyCount < RecentChronologyLimit) + state.RecentChronologyCount++; + } + } + + private static IReadOnlyList CopyChronology( + Iec61850ReportContinuityState state) + { + var initialCount = state.InitialChronologyCount; + var recentCount = state.RecentChronologyCount; + if (initialCount + recentCount == 0) + return Array.Empty(); + + var copy = new Iec61850BrcbChronologyFrame[initialCount + recentCount]; + if (initialCount > 0) + Array.Copy(state.InitialChronology!, 0, copy, 0, initialCount); + for (var index = 0; index < recentCount; index++) + { + var source = (state.RecentChronologyNext - recentCount + index + RecentChronologyLimit) + % RecentChronologyLimit; + copy[initialCount + index] = state.RecentChronology![source]; + } + return copy; + } + private static string WriteVerdict(bool? value) => value is true ? "accepted/yes" : value is false ? "rejected/no" : "UNKNOWN"; From 789bff22437a355f264aef763319921ae6772410 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 13:59:51 +0700 Subject: [PATCH 71/99] fix(p10.8b): compare EntryID provenance against previous frame before association state mutation --- Services/Iec61850ReportContinuityInspector.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index f67902c41..981257368 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -220,6 +220,7 @@ void Warn(string message) } var priorEntryIdPresent = !string.IsNullOrEmpty(state.LastEntryIdHex); + var priorEntryIdProven = state.LastEntryIdProvenByOptFlds; var entryIdPresent = !string.IsNullOrEmpty(frame.EntryIdHex); var entryIdChanged = priorEntryIdPresent && entryIdPresent && !string.Equals(state.LastEntryIdHex, frame.EntryIdHex, StringComparison.OrdinalIgnoreCase); @@ -428,7 +429,7 @@ void RecordSequenceCorrelation() // Observational classification only. Both OptFlds must prove the // opaque cursor to label successive values as changed or repeated. var comparableEntryId = priorEntryIdPresent && entryIdPresent && - state.LastEntryIdProvenByOptFlds && hasOptFlds && frame.OptFldsEntryId == true; + priorEntryIdProven && hasOptFlds && frame.OptFldsEntryId == true; var relation = !entryIdPresent ? "absent" : !comparableEntryId ? "unverified" : entryIdChanged ? "changed" : "repeated"; RecordChronology(state, new Iec61850BrcbChronologyFrame From f05a9921988151ca6703b7181bd9ae201c225fd7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 14:00:07 +0700 Subject: [PATCH 72/99] feat(p10.8b): surface bounded, raw-payload-free BRCB chronology in diagnostic report --- Services/DiagnosticReportBuilder.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 1206cb6a0..53e1be576 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -329,6 +329,21 @@ static string Safe(string value) $"lastFindingUtc={stream.LastFindingReceivedAtUtc?.ToString("O") ?? "-"}, " + $"lastFrameUtc={stream.LastFrameReceivedAtUtc?.ToString("O") ?? "-"}; " + "later clean frames are observed report traffic, not proof of complete buffered SOE."); + // Chronology is a small immutable copy of decoder metadata only: + // first 32 frames plus 8 most recent frames; no raw EntryID or values. + if (stream.Buffered && stream.Chronology.Count > 0) + { + builder.AppendLine($" BRCB chronology: first={stream.ChronologyInitialCount}, " + + $"recent={stream.ChronologyRecentCount}, omittedMiddle={stream.ChronologyOmittedFrames}; " + + "receive-order metadata only, not TCP/wire-order or SOE completeness proof."); + foreach (var item in stream.Chronology) + builder.AppendLine($" frame#{item.FrameOrdinal}: " + + $"receivedUtc={item.ReceivedAtUtc?.ToString("O") ?? "unknown"}, " + + $"SqNum={item.SequenceNumber?.ToString() ?? "unknown"}, " + + $"GIreason={Verdict(item.GiReason)}, " + + $"BufOvfl={Verdict(item.BufferOverflow)}, " + + $"EntryIDrelation={item.EntryIdRelation}, findings={item.FindingCount}"); + } builder.AppendLine($" OptFlds : decoded={stream.OptFldsDecoded}/{stream.Frames}, " + $"unknown={stream.OptFldsUnknown}, lastMask={Safe(stream.LastOptFldsHex)}, " + $"SqNum requested={stream.SqNumAdvertised}, omitted={stream.SqNumOmitted}, " + From f04a189834acb67fbb0c9d449a11c98f8bdfaddb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 14:00:33 +0700 Subject: [PATCH 73/99] perf(p10.8b): use allocation-free readonly struct for bounded per-report chronology ring --- Services/Iec61850ReportContinuityInspector.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index 981257368..03fd84d97 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -79,14 +79,14 @@ internal sealed class Iec61850ReportContinuityState /// Decoder-metadata-only evidence: no raw EntryID, process values, or packet bodies. /// FrameOrdinal is receive order, not a server event sequence or TCP packet index. /// -public sealed record Iec61850BrcbChronologyFrame +public readonly record struct Iec61850BrcbChronologyFrame { public long FrameOrdinal { get; init; } public DateTimeOffset? ReceivedAtUtc { get; init; } public ulong? SequenceNumber { get; init; } public bool? GiReason { get; init; } public bool? BufferOverflow { get; init; } - public string EntryIdRelation { get; init; } = "unverified"; + public string? EntryIdRelation { get; init; } public int FindingCount { get; init; } } From f45849413418bc9b7e33081201d602a8bc0e5e70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 14:01:12 +0700 Subject: [PATCH 74/99] test(p10.8b): first/head and tail chronology rollover, wire-proven cursor relation, security, immutability --- .../P108BBoundedReportChronologyTests.cs | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs diff --git a/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs b/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs new file mode 100644 index 000000000..e086ad506 --- /dev/null +++ b/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs @@ -0,0 +1,214 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// P10.8B: fixed-capacity BRCB receive-order metadata for offline correlation. +/// This is not a packet sniffer or proof that the IED delivered every SOE event. +/// +public sealed class P108BBoundedReportChronologyTests +{ + private static readonly DateTimeOffset Epoch = + DateTimeOffset.Parse("2026-10-11T06:25:51.000+00:00"); + + private static NativeReportFrameMetadata Frame( + ulong sq, string entryId, int ms, + bool? gi = false, bool? overflow = false, bool rawProven = true) => new() + { + SequenceNumber = sq, + EntryIdHex = entryId, + ReceivedAt = Epoch.AddMilliseconds(ms), + OptFldsRawHex = rawProven ? "7F80" : "", + OptFldsSequenceNumber = rawProven ? true : null, + OptFldsEntryId = rawProven ? true : null, + OptFldsBufferOverflow = rawProven ? true : null, + OptFldsConfRev = rawProven ? true : null, + BufferOverflow = overflow, + GeneralInterrogationReasonSeen = gi, + ConfRev = 5 + }; + + private static Iec61850ReportContinuityStreamSnapshot Only( + Iec61850ReportContinuityState state) + => Assert.Single(Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["r"] = state }, 0, 0).Streams); + + [Fact] + public void SixtyReports_PreservesFirst32AndLast8InReceiveOrderOnly() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var index = 0; index < 60; index++) + Assert.Empty(Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, index.ToString("X4"), index * 100), true)); + var evidence = Only(state); + Assert.Equal(32, evidence.ChronologyInitialCount); + Assert.Equal(8, evidence.ChronologyRecentCount); + Assert.Equal(20, evidence.ChronologyOmittedFrames); + Assert.Equal(40, evidence.Chronology.Count); + Assert.Equal(Enumerable.Range(1, 32).Concat(Enumerable.Range(53, 8)) + .Select(index => (long)index), + evidence.Chronology.Select(entry => entry.FrameOrdinal)); + Assert.Equal("unverified", evidence.Chronology[0].EntryIdRelation); + Assert.Equal("changed", evidence.Chronology[1].EntryIdRelation); + Assert.Equal("changed", evidence.Chronology[^1].EntryIdRelation); + Assert.Equal(Epoch.AddMilliseconds(5900), evidence.Chronology[^1].ReceivedAtUtc); + + var text = Format(state); + Assert.Contains("first=32, recent=8, omittedMiddle=20", text, StringComparison.Ordinal); + Assert.Contains("frame#60:", text, StringComparison.Ordinal); + Assert.DoesNotContain("frame#40:", text, StringComparison.Ordinal); + Assert.Contains("receive-order metadata only", text, StringComparison.Ordinal); + } + + [Fact] + public void FirstAndLastRangesDoNotDuplicateFramesAtCapacityBoundary() + { + foreach (var count in new[] { 1, 31, 32, 33, 39, 40, 41 }) + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var index = 0; index < count; index++) + Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, index.ToString("X4"), index), true); + + var evidence = Only(state); + Assert.Equal(Math.Min(32, count), evidence.ChronologyInitialCount); + Assert.Equal(Math.Min(8, Math.Max(0, count - 32)), evidence.ChronologyRecentCount); + Assert.Equal(Math.Max(0, count - 40), evidence.ChronologyOmittedFrames); + Assert.Equal(Math.Min(count, 40), evidence.Chronology.Count); + Assert.Equal(evidence.Chronology.Count, + evidence.Chronology.Select(f => f.FrameOrdinal).Distinct().Count()); + } + } + + [Fact] + public void DuplicateSqWithRepeatedEntryId_StillRaisesWarningAndRecordsOneFrame() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Iec61850ReportContinuityInspector.Observe(state, Frame(4, "AABB", 0), true); + var findings = Iec61850ReportContinuityInspector.Observe( + state, Frame(4, "AABB", 100, gi: true, overflow: true), true); + Assert.Equal(2, findings.Count); + var evidence = Only(state); + Assert.Equal(2, evidence.Chronology.Count); + Assert.Equal("repeated", evidence.Chronology[1].EntryIdRelation); + Assert.Equal(2, evidence.Chronology[1].FindingCount); + Assert.True(evidence.Chronology[1].GiReason); + Assert.True(evidence.Chronology[1].BufferOverflow); + Assert.Equal(1, evidence.Overflow); + Assert.Equal(1, evidence.SequenceDiscontinuities); + } + + [Fact] + public void MissingRawOptionFieldsCannotInventComparableEntryId() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Iec61850ReportContinuityInspector.Observe( + state, Frame(4, "ABCD", 0, rawProven: false), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(1, "1234", 100), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(2, "1234", 200), true); + var entries = Only(state).Chronology; + Assert.Equal("unverified", entries[0].EntryIdRelation); + Assert.Equal("unverified", entries[1].EntryIdRelation); + Assert.Equal("repeated", entries[2].EntryIdRelation); + Assert.Equal(1, Only(state).SequenceWithoutComparableEntryId); + } + + [Fact] + public void ChronologyNeverContainsTheRawBrcbEntryIdOrProcessValues() + { + var state = new Iec61850ReportContinuityState + { + Buffered = true, + ReportControlReference = "BCUGEF650/LLN0.BR.BRB_CSWI_GW010101" + }; + Iec61850ReportContinuityInspector.Observe( + state, Frame(4, "DEADBEEF0123456789", 0), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(1, "CAFED00DFEED000011", 100), true); + var text = Format(state); + Assert.Contains("EntryIDrelation=unverified", text, StringComparison.Ordinal); + Assert.DoesNotContain("DEADBEEF0123456789", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("CAFED00DFEED000011", text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void UnbufferedReportingDoesNotAllocateChronologyHistory() + { + var state = new Iec61850ReportContinuityState { Buffered = false }; + for (var index = 0; index < 50; index++) + Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, "", index), false); + var snap = Only(state); + Assert.Empty(snap.Chronology); + Assert.Null(state.InitialChronology); + Assert.Null(state.RecentChronology); + Assert.DoesNotContain("BRCB chronology", Format(state), StringComparison.Ordinal); + } + + [Fact] + public void SnapshotIsAnImmutableCopyDespiteLaterRingWrap() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var index = 0; index < 50; index++) + Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, index.ToString("X4"), index * 100), true); + var frozen = Only(state); + Assert.Equal(18, frozen.ChronologyOmittedFrames); + var tail = frozen.Chronology[^1]; + for (var index = 50; index < 90; index++) + Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, index.ToString("X4"), index * 100), true); + Assert.Equal(tail, frozen.Chronology[^1]); + Assert.Equal(50, frozen.Chronology[^1].FrameOrdinal); + Assert.Equal(90, Only(state).Chronology[^1].FrameOrdinal); + Assert.Equal(50, frozen.Frames); + } + + [Fact] + public void UnknownDecoderTimestampIsNotSubstitutedByWallClock() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata + { + SequenceNumber = 10, + EntryIdHex = "000A", + ReceivedAt = default + }, true); + var evidence = Only(state); + Assert.Null(evidence.Chronology[0].ReceivedAtUtc); + Assert.Contains("receivedUtc=unknown", Format(state), StringComparison.Ordinal); + } + + [Fact] + public void DistinctBrcbStreamsRetainIndependentBoundedChronologies() + { + var a = new Iec61850ReportContinuityState { Buffered = true, ReportControlReference = "A" }; + var b = new Iec61850ReportContinuityState { Buffered = true, ReportControlReference = "B" }; + Iec61850ReportContinuityInspector.Observe(a, Frame(0, "00", 0), true); + for (var index = 0; index < 60; index++) + Iec61850ReportContinuityInspector.Observe( + b, Frame((ulong)index, index.ToString("X4"), index), true); + var snapshot = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["a"] = a, ["b"] = b }, + 0, 0, maxShown: 1); + Assert.Equal(2, snapshot.StreamCount); + Assert.Single(snapshot.Streams); + Assert.Single(snapshot.Streams[0].Chronology); + Assert.Equal("A", snapshot.Streams[0].Rcb); + } + + private static string Format(Iec61850ReportContinuityState state) + { + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, + Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["one"] = state }, + 0, 0)); + return sb.ToString(); + } +} From 3c58a476e25289a1a59f55992dbf70c81f73fffd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 14:01:31 +0700 Subject: [PATCH 75/99] test(p10.8b): correct 50-frame head-plus-tail omitted count to 10 --- tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs b/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs index e086ad506..37f228a45 100644 --- a/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs +++ b/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs @@ -157,7 +157,7 @@ public void SnapshotIsAnImmutableCopyDespiteLaterRingWrap() Iec61850ReportContinuityInspector.Observe( state, Frame((ulong)index, index.ToString("X4"), index * 100), true); var frozen = Only(state); - Assert.Equal(18, frozen.ChronologyOmittedFrames); + Assert.Equal(10, frozen.ChronologyOmittedFrames); var tail = frozen.Chronology[^1]; for (var index = 50; index < 90; index++) Iec61850ReportContinuityInspector.Observe( From 6efd0fc7cc333795d78302314f05b223be060d9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 14:40:42 +0700 Subject: [PATCH 76/99] feat(p10.8c): preserve bounded last-eight anomalous BRCB metadata across omitted chronology middle --- Services/Iec61850ReportContinuityInspector.cs | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index 03fd84d97..4f7d44c14 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -44,6 +44,11 @@ internal sealed class Iec61850ReportContinuityState public Iec61850BrcbChronologyFrame[]? RecentChronology { get; set; } public int RecentChronologyCount { get; set; } public int RecentChronologyNext { get; set; } + // Retain the last eight anomalous BRCB frames even if they fall in the + // omitted middle of a long healthy stream. O(1) memory per buffered RCB. + public Iec61850BrcbChronologyFrame[]? AnomalyChronology { get; set; } + public int AnomalyChronologyCount { get; set; } + public int AnomalyChronologyNext { get; set; } public long SequenceDiscontinuities { get; set; } public long SequenceWithChangedEntryId { get; set; } public long SequenceWithRepeatedEntryId { get; set; } @@ -115,6 +120,8 @@ public sealed record Iec61850ReportContinuityStreamSnapshot public int ChronologyInitialCount { get; init; } public int ChronologyRecentCount { get; init; } public long ChronologyOmittedFrames { get; init; } + public IReadOnlyList RecentAnomalies { get; init; } = + Array.Empty(); public long SequenceDiscontinuities { get; init; } public long SequenceWithChangedEntryId { get; init; } public long SequenceWithRepeatedEntryId { get; init; } @@ -172,6 +179,7 @@ internal static class Iec61850ReportContinuityInspector private const ulong MaxSequenceNumber = ushort.MaxValue; private const int InitialChronologyLimit = 32; private const int RecentChronologyLimit = 8; + private const int AnomalyChronologyLimit = 8; /// /// A gap/backward move is an OBSERVED discontinuity, not proof of a specific @@ -432,7 +440,7 @@ void RecordSequenceCorrelation() priorEntryIdProven && hasOptFlds && frame.OptFldsEntryId == true; var relation = !entryIdPresent ? "absent" : !comparableEntryId ? "unverified" : entryIdChanged ? "changed" : "repeated"; - RecordChronology(state, new Iec61850BrcbChronologyFrame + var evidence = new Iec61850BrcbChronologyFrame { FrameOrdinal = state.FramesSeen, ReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt, @@ -441,7 +449,10 @@ void RecordSequenceCorrelation() BufferOverflow = frame.BufferOverflow, EntryIdRelation = relation, FindingCount = findings?.Count ?? 0 - }); + }; + RecordChronology(state, evidence); + if (evidence.FindingCount > 0) + RecordAnomalyChronology(state, evidence); } if (findings is not null) @@ -490,6 +501,7 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( LastFrameReceivedAtUtc = state.LastFrameReceivedAtUtc, LastFindingReceivedAtUtc = state.LastFindingReceivedAtUtc, Chronology = CopyChronology(state), + RecentAnomalies = CopyAnomalyChronology(state), ChronologyInitialCount = state.InitialChronologyCount, ChronologyRecentCount = state.RecentChronologyCount, ChronologyOmittedFrames = state.Buffered @@ -561,6 +573,34 @@ private static void RecordChronology( } } + private static void RecordAnomalyChronology( + Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) + { + var ring = state.AnomalyChronology ??= + new Iec61850BrcbChronologyFrame[AnomalyChronologyLimit]; + ring[state.AnomalyChronologyNext] = item; + state.AnomalyChronologyNext = + (state.AnomalyChronologyNext + 1) % AnomalyChronologyLimit; + if (state.AnomalyChronologyCount < AnomalyChronologyLimit) + state.AnomalyChronologyCount++; + } + + private static IReadOnlyList CopyAnomalyChronology( + Iec61850ReportContinuityState state) + { + var count = state.AnomalyChronologyCount; + if (count == 0) + return Array.Empty(); + var copy = new Iec61850BrcbChronologyFrame[count]; + for (var index = 0; index < count; index++) + { + var source = (state.AnomalyChronologyNext - count + index + + AnomalyChronologyLimit) % AnomalyChronologyLimit; + copy[index] = state.AnomalyChronology![source]; + } + return copy; + } + private static IReadOnlyList CopyChronology( Iec61850ReportContinuityState state) { From f5ffc43741c081fc8798e48be09ea76c3bbfaaa7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 14:41:05 +0700 Subject: [PATCH 77/99] feat(p10.8c): expose last anomalous RCB frames even when omitted from head/tail chronology --- Services/DiagnosticReportBuilder.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 53e1be576..9d687470a 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -344,6 +344,19 @@ static string Safe(string value) $"BufOvfl={Verdict(item.BufferOverflow)}, " + $"EntryIDrelation={item.EntryIdRelation}, findings={item.FindingCount}"); } + if (stream.Buffered && stream.RecentAnomalies.Count > 0) + { + builder.AppendLine($" BRCB anomaly history: last={stream.RecentAnomalies.Count} " + + $"of {stream.AnomalousFrames} anomalous frames; bounded decoder receive-order evidence. " + + "Some entries may also appear in the first/recent chronology."); + foreach (var item in stream.RecentAnomalies) + builder.AppendLine($" anomalyFrame#{item.FrameOrdinal}: " + + $"receivedUtc={item.ReceivedAtUtc?.ToString("O") ?? "unknown"}, " + + $"SqNum={item.SequenceNumber?.ToString() ?? "unknown"}, " + + $"GIreason={Verdict(item.GiReason)}, " + + $"BufOvfl={Verdict(item.BufferOverflow)}, " + + $"EntryIDrelation={item.EntryIdRelation}, findings={item.FindingCount}"); + } builder.AppendLine($" OptFlds : decoded={stream.OptFldsDecoded}/{stream.Frames}, " + $"unknown={stream.OptFldsUnknown}, lastMask={Safe(stream.LastOptFldsHex)}, " + $"SqNum requested={stream.SqNumAdvertised}, omitted={stream.SqNumOmitted}, " + From b013dfc19af46f27a8986397f23e29e874e39bbc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 14:41:37 +0700 Subject: [PATCH 78/99] test(p10.8c): retain anomalies omitted by bounded first/last chronology and preserve immutable snapshots --- .../P108CAnomalyChronologyTests.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs diff --git a/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs b/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs new file mode 100644 index 000000000..889132f5c --- /dev/null +++ b/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs @@ -0,0 +1,107 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P108CAnomalyChronologyTests +{ + private static NativeReportFrameMetadata Frame(int sq, int ordinal, bool overflow = false) => new() + { + SequenceNumber = (ulong)sq, + EntryIdHex = ordinal.ToString("X8"), + ReceivedAt = DateTimeOffset.Parse("2026-10-11T06:25:51Z").AddMilliseconds(ordinal), + OptFldsRawHex = "7F80", + OptFldsSequenceNumber = true, + OptFldsEntryId = true, + OptFldsBufferOverflow = true, + BufferOverflow = overflow + }; + + private static Iec61850ReportContinuityStreamSnapshot Snapshot(Iec61850ReportContinuityState state) + => Assert.Single(Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["r"] = state }, 0, 0).Streams); + + [Fact] + public void AnomalyInOmittedMiddleSurvivesHeadTailTruncation() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var i = 0; i < 70; i++) + Iec61850ReportContinuityInspector.Observe(state, Frame(i, i + 1, overflow: i == 45), true); + + var s = Snapshot(state); + Assert.Equal(32, s.ChronologyInitialCount); + Assert.Equal(8, s.ChronologyRecentCount); + Assert.Equal(30, s.ChronologyOmittedFrames); + Assert.DoesNotContain(s.Chronology, f => f.FrameOrdinal == 46); + Assert.Single(s.RecentAnomalies); + Assert.Equal(46, s.RecentAnomalies[0].FrameOrdinal); + Assert.True(s.RecentAnomalies[0].BufferOverflow); + Assert.Equal(1, s.Overflow); + Assert.Equal(1, s.AnomalousFrames); + var report = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(report, + Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["r"] = state }, 0, 0)); + Assert.Contains("anomalyFrame#46:", report.ToString(), StringComparison.Ordinal); + Assert.Contains("BRCB anomaly history", report.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void LastEightAnomaliesStayInReceiveOrderAcrossRingWrap() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var i = 0; i < 52; i++) + Iec61850ReportContinuityInspector.Observe(state, Frame(i, i + 1, overflow: i is >= 35 and <= 46), true); + var s = Snapshot(state); + Assert.Equal(12, s.AnomalousFrames); + Assert.Equal(8, s.RecentAnomalies.Count); + Assert.Equal(Enumerable.Range(40, 8).Select(x => (long)x), s.RecentAnomalies.Select(x => x.FrameOrdinal)); + Assert.Equal(12, s.Overflow); + } + + [Fact] + public void SnapshotDoesNotMutateWhenLaterFramesOverwriteAnomalyRing() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var i = 0; i < 45; i++) + Iec61850ReportContinuityInspector.Observe(state, Frame(i, i + 1, overflow: i is >= 34 and <= 41), true); + var snap = Snapshot(state); + var original = snap.RecentAnomalies.Select(x => x.FrameOrdinal).ToArray(); + for (var i = 45; i < 75; i++) + Iec61850ReportContinuityInspector.Observe(state, Frame(i, i + 1, overflow: true), true); + Assert.Equal(original, snap.RecentAnomalies.Select(x => x.FrameOrdinal)); + Assert.Equal(8, snap.RecentAnomalies.Count); + Assert.Equal(75, Snapshot(state).RecentAnomalies[^1].FrameOrdinal); + } + + [Fact] + public void NoWarningsOrUnbufferedReportsDoNotAllocateAnomalyHistory() + { + var healthy = new Iec61850ReportContinuityState { Buffered = true }; + var unbuffered = new Iec61850ReportContinuityState(); + for (var i = 0; i < 50; i++) + { + Iec61850ReportContinuityInspector.Observe(healthy, Frame(i, i + 1), true); + Iec61850ReportContinuityInspector.Observe(unbuffered, Frame(i, i + 1, overflow: true), false); + } + Assert.Null(healthy.AnomalyChronology); + Assert.Null(unbuffered.AnomalyChronology); + Assert.Empty(Snapshot(healthy).RecentAnomalies); + Assert.Empty(Snapshot(unbuffered).RecentAnomalies); + } + + [Fact] + public void OnlyBoundedMetadataIsSavedNeverRawEntryIdOrProcessValues() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + var frame = Frame(0, 1, true) with { EntryIdHex = "DEADBEEF12345678" }; + Iec61850ReportContinuityInspector.Observe(state, frame, true); + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, + Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["r"] = state }, 0, 0)); + Assert.DoesNotContain("DEADBEEF12345678", sb.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("anomalyFrame#1:", sb.ToString(), StringComparison.Ordinal); + } +} From a10eae2c3a5be11ba63e0b94b78d4f24c7fc73f2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 14:42:03 +0700 Subject: [PATCH 79/99] test(p10.8c): use explicit immutable decoder metadata fixture without record assumption --- tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs b/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs index 889132f5c..a8cdb8dc3 100644 --- a/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs +++ b/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs @@ -95,7 +95,17 @@ public void NoWarningsOrUnbufferedReportsDoNotAllocateAnomalyHistory() public void OnlyBoundedMetadataIsSavedNeverRawEntryIdOrProcessValues() { var state = new Iec61850ReportContinuityState { Buffered = true }; - var frame = Frame(0, 1, true) with { EntryIdHex = "DEADBEEF12345678" }; + var frame = new NativeReportFrameMetadata + { + SequenceNumber = 0, + EntryIdHex = "DEADBEEF12345678", + ReceivedAt = DateTimeOffset.Parse("2026-10-11T06:25:51Z"), + OptFldsRawHex = "7F80", + OptFldsSequenceNumber = true, + OptFldsEntryId = true, + OptFldsBufferOverflow = true, + BufferOverflow = true + }; Iec61850ReportContinuityInspector.Observe(state, frame, true); var sb = new StringBuilder(); DiagnosticReportBuilder.AppendReportContinuity(sb, From ed6d7cffcc4348d6c8d18807054eb3d75136a0d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 15:38:11 +0700 Subject: [PATCH 80/99] feat(p10.8d): fail-closed offline comparator for exact BRCB slots and semantic source parity --- scripts/compare-p10-brcb-sessions.py | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 scripts/compare-p10-brcb-sessions.py diff --git a/scripts/compare-p10-brcb-sessions.py b/scripts/compare-p10-brcb-sessions.py new file mode 100644 index 000000000..e49b0bfa9 --- /dev/null +++ b/scripts/compare-p10-brcb-sessions.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Offline diagnostic comparison for exact BRCB slots. No IEC/MMS I/O. + +Observational scope only. Even matching SqNum/EntryID-relation chronologies +cannot prove SOE continuity, event delivery or original wire PDU ordering. +""" +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path + +STREAM = re.compile(r"^\s*stream\[\d+\]\s*:\s*(BRCB|URCB),\s*rcb=([^,\r\n]+),\s*dataset=([^,\r\n]+),.*\bframes=(\d+),") +TRIAGE = re.compile(r"^\s*RCB triage\s*:\s*anomalousFrames=(\d+),\s*sqDiscontinuities=(\d+),") +FOLLOW = re.compile(r"^\s*RCB follow-up\s*:\s*receivedAfterLastFinding=(\d+),") +FRAME = re.compile(r"^\s*(?:frame|anomalyFrame)#(\d+):\s*receivedUtc=([^,\s]+),\s*SqNum=([^,\s]+),\s*GIreason=([^,\s]+),\s*BufOvfl=([^,\s]+),\s*EntryIDrelation=([^,\s]+),\s*findings=(\d+)") +FINGERPRINT = re.compile(r"\bfingerprint=([a-fA-F0-9]{64})\b") +IED = re.compile(r"^\s*IED\s*:\s*([^\r\n]+)$") +ENGINE = re.compile(r"^Engine version\s*:\s*[^+\r\n]+\+([0-9a-f]{40})\s*$") +DATASET = re.compile(r"^\s*Static DataSets\s*:\s*(\d+)") + +@dataclass(frozen=True) +class Stream: + kind: str + rcb: str + dataset: str + frames: int + anomalous_frames: int = 0 + discontinuities: int = 0 + after_last_finding: int | None = None + chronology: tuple[tuple[str, ...], ...] = () + recent_anomalies: tuple[tuple[str, ...], ...] = () + +def parse_report(report: str) -> dict: + streams: dict[str, dict] = {} + active: str | None = None + fingerprint: str | None = None + ied: str | None = None + engine: str | None = None + for line in report.splitlines(): + m = FINGERPRINT.search(line) + if m and line.lstrip().startswith(("Discovery", "Open SCL", "LiveDiscovery:")): + fingerprint = m.group(1).lower() + m = IED.match(line) + if m: + ied = m.group(1).strip() + m = ENGINE.match(line) + if m: + engine = m.group(1) + m = STREAM.match(line) + if m: + kind, rcb, dataset, count = m.groups() + active = rcb.strip().casefold() + if active in streams: + raise ValueError(f"duplicate RCB in diagnostic: {active}") + streams[active] = dict(kind=kind, rcb=rcb.strip(), dataset=dataset.strip(), + frames=int(count), anomalous_frames=0, discontinuities=0, + after_last_finding=None, chronology=[], recent_anomalies=[]) + continue + if active is None or active not in streams: + continue + m = TRIAGE.match(line) + if m: + streams[active]["anomalous_frames"] = int(m.group(1)) + streams[active]["discontinuities"] = int(m.group(2)) + m = FOLLOW.match(line) + if m: + streams[active]["after_last_finding"] = int(m.group(1)) + m = FRAME.match(line) + if m: + values = m.groups() + key = "recent_anomalies" if "anomalyFrame#" in line else "chronology" + streams[active][key].append(values) + if not ied or not fingerprint or not engine or not streams: + raise ValueError("missing IED, engine SHA, static fingerprint, or RCB streams") + return dict(ied=ied, fingerprint=fingerprint, engine=engine, + streams={k: Stream(**{**v, "chronology": tuple(v["chronology"]), + "recent_anomalies": tuple(v["recent_anomalies"])}) + for k, v in streams.items()}) + +def compare(left: dict, right: dict) -> dict: + if left["ied"].casefold() != right["ied"].casefold(): + raise ValueError("different IED identities; comparison blocked") + if left["fingerprint"] != right["fingerprint"]: + raise ValueError("different static semantic fingerprints; comparison blocked") + if left["engine"] != right["engine"]: + raise ValueError("different engine commits; comparison blocked") + la = {k: s for k, s in left["streams"].items() if s.kind == "BRCB"} + ra = {k: s for k, s in right["streams"].items() if s.kind == "BRCB"} + if not la or not ra: + raise ValueError("no BRCB streams") + if set(la) != set(ra): + raise ValueError("different BRCB slots: no cross-slot reliability comparison") + results = [] + for ref in sorted(la): + a, b = la[ref], ra[ref] + if a.dataset.casefold() != b.dataset.casefold(): + raise ValueError(f"different DataSet on BRCB {ref}; comparison blocked") + results.append(dict(rcb=a.rcb, dataset=a.dataset, + left_frames=a.frames, right_frames=b.frames, + left_findings_frames=a.anomalous_frames, + right_findings_frames=b.anomalous_frames, + left_sq_discontinuities=a.discontinuities, + right_sq_discontinuities=b.discontinuities, + left_clean_followup=a.after_last_finding, + right_clean_followup=b.after_last_finding, + left_chronology_frames=len(a.chronology), + right_chronology_frames=len(b.chronology), + left_anomaly_history_frames=len(a.recent_anomalies), + right_anomaly_history_frames=len(b.recent_anomalies))) + return dict(status="SAME_SLOT_OBSERVATION_ONLY", ied=left["ied"], + fingerprint=left["fingerprint"], engine_commit=left["engine"], + slots=results, + qualification="NOT_WIRE_ORDER_PROOF; NOT_SOE_LOSSLESS; NOT_R10_PHYSICAL_ACCEPTANCE") + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("open_scl", type=Path, help="Open SCL ARSAS Diagnostic Report") + ap.add_argument("discovery", type=Path, help="Discovery IP ARSAS Diagnostic Report") + ap.add_argument("--output", type=Path, help="optional JSON output path") + args = ap.parse_args() + try: + a = parse_report(args.open_scl.read_text(encoding="utf-8-sig")) + b = parse_report(args.discovery.read_text(encoding="utf-8-sig")) + result = compare(a, b) + except (OSError, UnicodeError, ValueError) as exc: + ap.exit(2, f"INCOMPARABLE: {exc}\n") + formatted = json.dumps(result, indent=2, sort_keys=True) + if args.output: + args.output.write_text(formatted + "\n", encoding="utf-8") + print(formatted) + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) From ed5ae6ab19276e51ee8ca3dcbd3874b35ec18e86 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 15:38:45 +0700 Subject: [PATCH 81/99] test(p10.8d): nine fixture gates reject false cross-slot, model, engine and identity equivalence --- scripts/test-compare-p10-brcb-sessions.py | 83 +++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 scripts/test-compare-p10-brcb-sessions.py diff --git a/scripts/test-compare-p10-brcb-sessions.py b/scripts/test-compare-p10-brcb-sessions.py new file mode 100644 index 000000000..b7fbd45e4 --- /dev/null +++ b/scripts/test-compare-p10-brcb-sessions.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Regress fail-closed cross-session BRCB evidence comparisons.""" +import importlib.util +import unittest +from pathlib import Path + +PATH = Path(__file__).with_name("compare-p10-brcb-sessions.py") +spec = importlib.util.spec_from_file_location("p108d", PATH) +mod = importlib.util.module_from_spec(spec) +import sys +sys.modules["p108d"] = mod +spec.loader.exec_module(mod) + +FP = "439d4dc0507019533b154ed68f7f4007f39f6be92b5cd3f413ff3b84f619727e" +ENGINE = "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1" + +def report(ied="BCUGE", fp=FP, engine=ENGINE, slot="GW01", + dataset="BCUGEF650/LLN0.CSWI", frames=25, with_chronology=True): + return f"""ARSAS Diagnostic Report +Engine version : 1.6.40+{engine} + IED : {ied} + Discovery : LiveDiscovery: fingerprint={fp}, requested=67 + stream[0] : BRCB, rcb=BCUGEF650/LLN0.BR.{slot}, dataset={dataset}, rptId=BRB01, frames={frames}, SqNum=25, findings=2 + RCB triage : anomalousFrames=1, sqDiscontinuities=1, sqChangedEntryID=1 + RCB follow-up : receivedAfterLastFinding=3, firstFrameUtc=2026-10-11T06:25:51Z + BRCB chronology: first=2, recent=0, omittedMiddle=0 +{(" frame#1: receivedUtc=unknown, SqNum=4, GIreason=NO, BufOvfl=NO, EntryIDrelation=unverified, findings=0" + chr(10)) if with_chronology else ""} OptFlds : decoded=25/25 + stream[1] : URCB, rcb=BCUGEF650/LLN0.RP.URB01, dataset=MMXU, rptId=URB01, frames=1, SqNum=1, findings=0 +""" + +class TestComparator(unittest.TestCase): + def test_exact_same_slot_only_maintains_not_soe_qualified(self): + result = mod.compare(mod.parse_report(report()), mod.parse_report(report(frames=43))) + self.assertEqual("SAME_SLOT_OBSERVATION_ONLY", result["status"]) + self.assertEqual("NOT_WIRE_ORDER_PROOF; NOT_SOE_LOSSLESS; NOT_R10_PHYSICAL_ACCEPTANCE", + result["qualification"]) + self.assertEqual(25, result["slots"][0]["left_frames"]) + self.assertEqual(43, result["slots"][0]["right_frames"]) + self.assertEqual(1, result["slots"][0]["left_chronology_frames"]) + + def test_different_slots_do_not_allow_false_comparison(self): + with self.assertRaisesRegex(ValueError, "different BRCB slots"): + mod.compare(mod.parse_report(report(slot="GW01")), + mod.parse_report(report(slot="SOGI101"))) + + def test_different_semantics_fail_closed(self): + with self.assertRaisesRegex(ValueError, "fingerprints"): + mod.compare(mod.parse_report(report()), + mod.parse_report(report(fp="f" * 64))) + + def test_engine_mismatch_fail_closed(self): + with self.assertRaisesRegex(ValueError, "engine commits"): + mod.compare(mod.parse_report(report()), + mod.parse_report(report(engine="f" * 40))) + + def test_ied_mismatch_fail_closed(self): + with self.assertRaisesRegex(ValueError, "IED identities"): + mod.compare(mod.parse_report(report()), + mod.parse_report(report(ied="WRONG"))) + + def test_different_datasets_fail_closed(self): + with self.assertRaisesRegex(ValueError, "different DataSet"): + mod.compare(mod.parse_report(report()), + mod.parse_report(report(dataset="BCUGEF650/LLN0.EVEGGIO"))) + + def test_missing_provenance_rejected(self): + with self.assertRaisesRegex(ValueError, "missing IED"): + mod.parse_report("stream[0] : BRCB, rcb=a, dataset=b, frames=1") + + def test_duplicate_stream_rejected(self): + data = report() + line = next(l for l in data.splitlines() if "stream[0]" in l) + with self.assertRaisesRegex(ValueError, "duplicate RCB"): + mod.parse_report(data + line.replace("stream[0]", "stream[9]") + "\n") + + def test_empty_chronology_allowed_but_does_not_invent_events(self): + result = mod.compare(mod.parse_report(report(with_chronology=False)), + mod.parse_report(report(with_chronology=False))) + self.assertEqual(0, result["slots"][0]["left_chronology_frames"]) + self.assertIn("NOT_WIRE_ORDER_PROOF", result["qualification"]) + +if __name__ == "__main__": + unittest.main() From ce73df3a47e163416c232b083eba1e4915f33e17 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 15:39:19 +0700 Subject: [PATCH 82/99] ci(p10.8d): exercise offline same-BRCB-slot comparison negative fixtures in canonical Windows build --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8f0769fef..dd6aa6470 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -108,6 +108,8 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory tests failed." } python .\ArIED61850Tester\scripts\test-verify-p10-ge-field-scl.py if ($LASTEXITCODE -ne 0) { throw "P10 GE trusted-IID SCL field gate fixture regressions failed." } + python .\ArIED61850Tester\scripts\test-compare-p10-brcb-sessions.py + if ($LASTEXITCODE -ne 0) { throw "P10.8D exact-slot BRCB comparator regressions failed." } python .\ArIED61850Tester\scripts\audit-maintenance-dependencies.py --root .\ArIED61850Tester --output "$env:RUNNER_TEMP\arsas-maintenance-dependencies.json" if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory generation failed." } From b129e7da32d804effff195497359f5ff0eb56a07 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 17:13:59 +0700 Subject: [PATCH 83/99] test(p10.8e): guard and expose same-slot BRCB BufOvfl evidence --- scripts/compare-p10-brcb-sessions.py | 13 ++++++++++- scripts/test-compare-p10-brcb-sessions.py | 27 +++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/scripts/compare-p10-brcb-sessions.py b/scripts/compare-p10-brcb-sessions.py index e49b0bfa9..f75bb7f75 100644 --- a/scripts/compare-p10-brcb-sessions.py +++ b/scripts/compare-p10-brcb-sessions.py @@ -20,6 +20,7 @@ IED = re.compile(r"^\s*IED\s*:\s*([^\r\n]+)$") ENGINE = re.compile(r"^Engine version\s*:\s*[^+\r\n]+\+([0-9a-f]{40})\s*$") DATASET = re.compile(r"^\s*Static DataSets\s*:\s*(\d+)") +BUFOVFL = re.compile(r"(?:^|,)\s*BufOvfl=(\d+)(?:,|$)") @dataclass(frozen=True) class Stream: @@ -27,6 +28,7 @@ class Stream: rcb: str dataset: str frames: int + overflow_frames: int = 0 anomalous_frames: int = 0 discontinuities: int = 0 after_last_finding: int | None = None @@ -52,11 +54,18 @@ def parse_report(report: str) -> dict: m = STREAM.match(line) if m: kind, rcb, dataset, count = m.groups() + overflow_match = BUFOVFL.search(line) + if kind == "BRCB" and overflow_match is None: + raise ValueError(f"missing BRCB BufOvfl count for {rcb.strip()}") + overflow_frames = int(overflow_match.group(1)) if overflow_match else 0 + if overflow_frames > int(count): + raise ValueError(f"BRCB BufOvfl count exceeds received frames for {rcb.strip()}") active = rcb.strip().casefold() if active in streams: raise ValueError(f"duplicate RCB in diagnostic: {active}") streams[active] = dict(kind=kind, rcb=rcb.strip(), dataset=dataset.strip(), - frames=int(count), anomalous_frames=0, discontinuities=0, + frames=int(count), overflow_frames=overflow_frames, + anomalous_frames=0, discontinuities=0, after_last_finding=None, chronology=[], recent_anomalies=[]) continue if active is None or active not in streams: @@ -100,6 +109,8 @@ def compare(left: dict, right: dict) -> dict: raise ValueError(f"different DataSet on BRCB {ref}; comparison blocked") results.append(dict(rcb=a.rcb, dataset=a.dataset, left_frames=a.frames, right_frames=b.frames, + left_bufovfl_frames=a.overflow_frames, + right_bufovfl_frames=b.overflow_frames, left_findings_frames=a.anomalous_frames, right_findings_frames=b.anomalous_frames, left_sq_discontinuities=a.discontinuities, diff --git a/scripts/test-compare-p10-brcb-sessions.py b/scripts/test-compare-p10-brcb-sessions.py index b7fbd45e4..90ec07c14 100644 --- a/scripts/test-compare-p10-brcb-sessions.py +++ b/scripts/test-compare-p10-brcb-sessions.py @@ -15,12 +15,12 @@ ENGINE = "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1" def report(ied="BCUGE", fp=FP, engine=ENGINE, slot="GW01", - dataset="BCUGEF650/LLN0.CSWI", frames=25, with_chronology=True): + dataset="BCUGEF650/LLN0.CSWI", frames=25, overflow=1, with_chronology=True): return f"""ARSAS Diagnostic Report Engine version : 1.6.40+{engine} IED : {ied} Discovery : LiveDiscovery: fingerprint={fp}, requested=67 - stream[0] : BRCB, rcb=BCUGEF650/LLN0.BR.{slot}, dataset={dataset}, rptId=BRB01, frames={frames}, SqNum=25, findings=2 + stream[0] : BRCB, rcb=BCUGEF650/LLN0.BR.{slot}, dataset={dataset}, rptId=BRB01, frames={frames}, SqNum=25, findings=2, BufOvfl={overflow} RCB triage : anomalousFrames=1, sqDiscontinuities=1, sqChangedEntryID=1 RCB follow-up : receivedAfterLastFinding=3, firstFrameUtc=2026-10-11T06:25:51Z BRCB chronology: first=2, recent=0, omittedMiddle=0 @@ -73,6 +73,29 @@ def test_duplicate_stream_rejected(self): with self.assertRaisesRegex(ValueError, "duplicate RCB"): mod.parse_report(data + line.replace("stream[0]", "stream[9]") + "\n") + def test_same_slot_overflow_counts_are_observable_not_loss_estimates(self): + result = mod.compare(mod.parse_report(report(overflow=1)), + mod.parse_report(report(frames=43, overflow=3))) + slot = result["slots"][0] + self.assertEqual(1, slot["left_bufovfl_frames"]) + self.assertEqual(3, slot["right_bufovfl_frames"]) + self.assertIn("NOT_SOE_LOSSLESS", result["qualification"]) + + def test_brcb_missing_overflow_evidence_fails_closed(self): + truncated = report().replace(", BufOvfl=1", "") + with self.assertRaisesRegex(ValueError, "missing BRCB BufOvfl count"): + mod.parse_report(truncated) + + def test_brcb_overflow_count_cannot_exceed_decoded_frames(self): + with self.assertRaisesRegex(ValueError, "exceeds received frames"): + mod.parse_report(report(frames=2, overflow=3)) + + def test_clean_brcb_zero_overflow_remains_comparable(self): + result = mod.compare(mod.parse_report(report(overflow=0)), + mod.parse_report(report(overflow=0))) + self.assertEqual(0, result["slots"][0]["left_bufovfl_frames"]) + self.assertEqual(0, result["slots"][0]["right_bufovfl_frames"]) + def test_empty_chronology_allowed_but_does_not_invent_events(self): result = mod.compare(mod.parse_report(report(with_chronology=False)), mod.parse_report(report(with_chronology=False))) From a27d108b765cb725f042c85b584b254969a56519 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 17:37:46 +0700 Subject: [PATCH 84/99] feat(p10.9a): independent cross-slot BRCB evidence triage without reliability comparison --- scripts/compare-p10-brcb-sessions.py | 80 +++++++++++++++++++++-- scripts/test-compare-p10-brcb-sessions.py | 45 ++++++++++++- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/scripts/compare-p10-brcb-sessions.py b/scripts/compare-p10-brcb-sessions.py index f75bb7f75..4b8560ca6 100644 --- a/scripts/compare-p10-brcb-sessions.py +++ b/scripts/compare-p10-brcb-sessions.py @@ -9,7 +9,8 @@ import argparse import json import re -from dataclasses import asdict, dataclass +from dataclasses import dataclass +from datetime import datetime from pathlib import Path STREAM = re.compile(r"^\s*stream\[\d+\]\s*:\s*(BRCB|URCB),\s*rcb=([^,\r\n]+),\s*dataset=([^,\r\n]+),.*\bframes=(\d+),") @@ -21,6 +22,13 @@ ENGINE = re.compile(r"^Engine version\s*:\s*[^+\r\n]+\+([0-9a-f]{40})\s*$") DATASET = re.compile(r"^\s*Static DataSets\s*:\s*(\d+)") BUFOVFL = re.compile(r"(?:^|,)\s*BufOvfl=(\d+)(?:,|$)") +SQ_GI = re.compile(r"\bsqWithGIreason=(\d+)\b") +LIFECYCLE = re.compile(r"^\s*RCB lifecycle\s*:") + +def _field(line: str, key: str) -> str | None: + match = re.search(rf"(?:^|,\s*){re.escape(key)}=([^,;\s]+)", line) + value = match.group(1) if match else None + return value if value and value != "-" else None @dataclass(frozen=True) class Stream: @@ -32,6 +40,10 @@ class Stream: anomalous_frames: int = 0 discontinuities: int = 0 after_last_finding: int | None = None + sq_with_gi_reason: int | None = None + gi_reason_frames: int | None = None + start_return_utc: str | None = None + last_anomaly_utc: str | None = None chronology: tuple[tuple[str, ...], ...] = () recent_anomalies: tuple[tuple[str, ...], ...] = () @@ -66,7 +78,9 @@ def parse_report(report: str) -> dict: streams[active] = dict(kind=kind, rcb=rcb.strip(), dataset=dataset.strip(), frames=int(count), overflow_frames=overflow_frames, anomalous_frames=0, discontinuities=0, - after_last_finding=None, chronology=[], recent_anomalies=[]) + after_last_finding=None, sq_with_gi_reason=None, + gi_reason_frames=None, start_return_utc=None, + last_anomaly_utc=None, chronology=[], recent_anomalies=[]) continue if active is None or active not in streams: continue @@ -74,9 +88,16 @@ def parse_report(report: str) -> dict: if m: streams[active]["anomalous_frames"] = int(m.group(1)) streams[active]["discontinuities"] = int(m.group(2)) + gi = SQ_GI.search(line) + streams[active]["sq_with_gi_reason"] = int(gi.group(1)) if gi else None m = FOLLOW.match(line) if m: streams[active]["after_last_finding"] = int(m.group(1)) + if LIFECYCLE.match(line): + count = _field(line, "GIreasonFrames") + streams[active]["gi_reason_frames"] = int(count) if count is not None and count.isdecimal() else None + streams[active]["start_return_utc"] = _field(line, "startReturnUtc") + streams[active]["last_anomaly_utc"] = _field(line, "lastAnomalyUtc") m = FRAME.match(line) if m: values = m.groups() @@ -89,13 +110,62 @@ def parse_report(report: str) -> dict: "recent_anomalies": tuple(v["recent_anomalies"])}) for k, v in streams.items()}) -def compare(left: dict, right: dict) -> dict: +def _require_matching_authority(left: dict, right: dict) -> None: if left["ied"].casefold() != right["ied"].casefold(): raise ValueError("different IED identities; comparison blocked") if left["fingerprint"] != right["fingerprint"]: raise ValueError("different static semantic fingerprints; comparison blocked") if left["engine"] != right["engine"]: raise ValueError("different engine commits; comparison blocked") + +def _elapsed_ms(start: str | None, end: str | None) -> float | None: + if not start or not end: + return None + try: + a, b = datetime.fromisoformat(start.replace("Z", "+00:00")), datetime.fromisoformat(end.replace("Z", "+00:00")) + delta = (b - a).total_seconds() * 1000 + return round(delta, 3) if 0 <= delta <= 86400000 else None + except (TypeError, ValueError): + return None + +def independent_triage(left: dict, right: dict) -> dict: + """Evidence for *separate* sessions, never a paired BRCB reliability comparison.""" + _require_matching_authority(left, right) + la = {k: s for k, s in left["streams"].items() if s.kind == "BRCB"} + ra = {k: s for k, s in right["streams"].items() if s.kind == "BRCB"} + if not la or not ra: + raise ValueError("no BRCB streams") + matched = set(la) == set(ra) and all( + la[key].dataset.casefold() == ra[key].dataset.casefold() for key in la) + def session_rows(streams: dict[str, Stream]) -> list[dict]: + rows = [] + for stream in sorted(streams.values(), key=lambda s: s.rcb.casefold()): + gi = stream.sq_with_gi_reason + if stream.anomalous_frames > stream.frames or ( + gi is not None and gi > stream.discontinuities): + raise ValueError(f"impossible BRCB diagnostic counts for {stream.rcb}") + rows.append(dict(rcb=stream.rcb, dataset=stream.dataset, + received_frames=stream.frames, bufovfl_true_frames=stream.overflow_frames, + anomalous_frames=stream.anomalous_frames, + sq_discontinuities=stream.discontinuities, + sq_with_gi_reason=gi, + sq_without_gi_reason=(stream.discontinuities - gi) if gi is not None else None, + received_after_last_finding=stream.after_last_finding, + gi_reason_frames=stream.gi_reason_frames, + last_anomaly_ms_after_start_return=_elapsed_ms( + stream.start_return_utc, stream.last_anomaly_utc), + retained_chronology_frames=len(stream.chronology), + retained_anomaly_frames=len(stream.recent_anomalies))) + return rows + return dict(status="INDEPENDENT_SESSIONS_OBSERVATION_ONLY", + same_slot_dataset_scope=matched, + paired_comparison_blocked_reason=None if matched else "different BRCB slots or DataSets", + ied=left["ied"], fingerprint=left["fingerprint"], engine_commit=left["engine"], + sessions=dict(open_scl=session_rows(la), discovery=session_rows(ra)), + qualification="NOT_PAIRED_RELIABILITY_COMPARISON; NOT_WIRE_ORDER_PROOF; NOT_SOE_LOSSLESS; NOT_R10_PHYSICAL_ACCEPTANCE") + +def compare(left: dict, right: dict) -> dict: + _require_matching_authority(left, right) la = {k: s for k, s in left["streams"].items() if s.kind == "BRCB"} ra = {k: s for k, s in right["streams"].items() if s.kind == "BRCB"} if not la or not ra: @@ -131,11 +201,13 @@ def main() -> int: ap.add_argument("open_scl", type=Path, help="Open SCL ARSAS Diagnostic Report") ap.add_argument("discovery", type=Path, help="Discovery IP ARSAS Diagnostic Report") ap.add_argument("--output", type=Path, help="optional JSON output path") + ap.add_argument("--independent-triage", action="store_true", + help="separate per-session evidence when real BRCB slots differ; never paired reliability") args = ap.parse_args() try: a = parse_report(args.open_scl.read_text(encoding="utf-8-sig")) b = parse_report(args.discovery.read_text(encoding="utf-8-sig")) - result = compare(a, b) + result = independent_triage(a, b) if args.independent_triage else compare(a, b) except (OSError, UnicodeError, ValueError) as exc: ap.exit(2, f"INCOMPARABLE: {exc}\n") formatted = json.dumps(result, indent=2, sort_keys=True) diff --git a/scripts/test-compare-p10-brcb-sessions.py b/scripts/test-compare-p10-brcb-sessions.py index 90ec07c14..847e8fef2 100644 --- a/scripts/test-compare-p10-brcb-sessions.py +++ b/scripts/test-compare-p10-brcb-sessions.py @@ -21,8 +21,9 @@ def report(ied="BCUGE", fp=FP, engine=ENGINE, slot="GW01", IED : {ied} Discovery : LiveDiscovery: fingerprint={fp}, requested=67 stream[0] : BRCB, rcb=BCUGEF650/LLN0.BR.{slot}, dataset={dataset}, rptId=BRB01, frames={frames}, SqNum=25, findings=2, BufOvfl={overflow} - RCB triage : anomalousFrames=1, sqDiscontinuities=1, sqChangedEntryID=1 + RCB triage : anomalousFrames=1, sqDiscontinuities=1, sqChangedEntryID=1, sqWithGIreason=0 RCB follow-up : receivedAfterLastFinding=3, firstFrameUtc=2026-10-11T06:25:51Z + RCB lifecycle : RptEnaWrite=YES, GIWrite=YES, startReturnUtc=2026-10-11T06:25:50Z, GIreasonFrames=1, lastAnomalyUtc=2026-10-11T06:25:51Z BRCB chronology: first=2, recent=0, omittedMiddle=0 {(" frame#1: receivedUtc=unknown, SqNum=4, GIreason=NO, BufOvfl=NO, EntryIDrelation=unverified, findings=0" + chr(10)) if with_chronology else ""} OptFlds : decoded=25/25 stream[1] : URCB, rcb=BCUGEF650/LLN0.RP.URB01, dataset=MMXU, rptId=URB01, frames=1, SqNum=1, findings=0 @@ -96,6 +97,48 @@ def test_clean_brcb_zero_overflow_remains_comparable(self): self.assertEqual(0, result["slots"][0]["left_bufovfl_frames"]) self.assertEqual(0, result["slots"][0]["right_bufovfl_frames"]) + def test_independent_triage_different_real_slots_is_explicitly_unpaired(self): + data = mod.independent_triage(mod.parse_report(report(slot="GW01")), + mod.parse_report(report(slot="SOGI101", frames=43, overflow=3))) + self.assertEqual("INDEPENDENT_SESSIONS_OBSERVATION_ONLY", data["status"]) + self.assertFalse(data["same_slot_dataset_scope"]) + self.assertEqual("different BRCB slots or DataSets", data["paired_comparison_blocked_reason"]) + self.assertNotIn("slots", data) + self.assertEqual(1, data["sessions"]["open_scl"][0]["bufovfl_true_frames"]) + self.assertEqual(3, data["sessions"]["discovery"][0]["bufovfl_true_frames"]) + self.assertIn("NOT_PAIRED_RELIABILITY_COMPARISON", data["qualification"]) + + def test_independent_triage_same_slots_does_not_establish_losslessness(self): + data = mod.independent_triage(mod.parse_report(report()), mod.parse_report(report())) + self.assertTrue(data["same_slot_dataset_scope"]) + self.assertIn("NOT_SOE_LOSSLESS", data["qualification"]) + + def test_gi_context_and_monotonic_startup_delay_are_observational(self): + one = mod.independent_triage(mod.parse_report(report()), mod.parse_report(report())) + stream = one["sessions"]["open_scl"][0] + self.assertEqual(1, stream["sq_without_gi_reason"]) + self.assertEqual(1, stream["gi_reason_frames"]) + self.assertEqual(1000.0, stream["last_anomaly_ms_after_start_return"]) + self.assertEqual(3, stream["received_after_last_finding"]) + + def test_missing_gi_and_lifecycle_are_unknown_not_zero(self): + data = report().replace(", sqWithGIreason=0", "") + data = "\n".join(x for x in data.splitlines() if "RCB lifecycle" not in x) + row = mod.independent_triage(mod.parse_report(data), mod.parse_report(data))["sessions"]["open_scl"][0] + self.assertIsNone(row["sq_with_gi_reason"]) + self.assertIsNone(row["sq_without_gi_reason"]) + self.assertIsNone(row["last_anomaly_ms_after_start_return"]) + + def test_impossible_gi_discontinuity_counts_are_rejected(self): + data = report().replace("sqWithGIreason=0", "sqWithGIreason=2") + with self.assertRaisesRegex(ValueError, "impossible BRCB diagnostic counts"): + mod.independent_triage(mod.parse_report(data), mod.parse_report(report())) + + def test_independent_triage_mismatched_engine_still_fails_closed(self): + with self.assertRaisesRegex(ValueError, "different engine commits"): + mod.independent_triage(mod.parse_report(report()), + mod.parse_report(report(engine="f" * 40))) + def test_empty_chronology_allowed_but_does_not_invent_events(self): result = mod.compare(mod.parse_report(report(with_chronology=False)), mod.parse_report(report(with_chronology=False))) From 38dc422dad49b4bc4433577dc3ef65240e323ea1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 18:28:35 +0700 Subject: [PATCH 85/99] feat(p10.9b): qualify complete BRCB decoder receive chronology and post-idle non-GI findings --- scripts/compare-p10-brcb-sessions.py | 73 ++++++++++++++++++++++- scripts/test-compare-p10-brcb-sessions.py | 62 +++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/scripts/compare-p10-brcb-sessions.py b/scripts/compare-p10-brcb-sessions.py index 4b8560ca6..fb40ab890 100644 --- a/scripts/compare-p10-brcb-sessions.py +++ b/scripts/compare-p10-brcb-sessions.py @@ -24,6 +24,7 @@ BUFOVFL = re.compile(r"(?:^|,)\s*BufOvfl=(\d+)(?:,|$)") SQ_GI = re.compile(r"\bsqWithGIreason=(\d+)\b") LIFECYCLE = re.compile(r"^\s*RCB lifecycle\s*:") +CHRONOLOGY = re.compile(r"^\s*BRCB chronology:\s*first=(\d+),\s*recent=(\d+),\s*omittedMiddle=(\d+)") def _field(line: str, key: str) -> str | None: match = re.search(rf"(?:^|,\s*){re.escape(key)}=([^,;\s]+)", line) @@ -45,6 +46,7 @@ class Stream: start_return_utc: str | None = None last_anomaly_utc: str | None = None chronology: tuple[tuple[str, ...], ...] = () + chronology_declared: tuple[int, int, int] | None = None recent_anomalies: tuple[tuple[str, ...], ...] = () def parse_report(report: str) -> dict: @@ -80,7 +82,8 @@ def parse_report(report: str) -> dict: anomalous_frames=0, discontinuities=0, after_last_finding=None, sq_with_gi_reason=None, gi_reason_frames=None, start_return_utc=None, - last_anomaly_utc=None, chronology=[], recent_anomalies=[]) + last_anomaly_utc=None, chronology=[], + chronology_declared=None, recent_anomalies=[]) continue if active is None or active not in streams: continue @@ -98,6 +101,9 @@ def parse_report(report: str) -> dict: streams[active]["gi_reason_frames"] = int(count) if count is not None and count.isdecimal() else None streams[active]["start_return_utc"] = _field(line, "startReturnUtc") streams[active]["last_anomaly_utc"] = _field(line, "lastAnomalyUtc") + m = CHRONOLOGY.match(line) + if m and streams[active]["kind"] == "BRCB": + streams[active]["chronology_declared"] = tuple(map(int, m.groups())) m = FRAME.match(line) if m: values = m.groups() @@ -128,6 +134,66 @@ def _elapsed_ms(start: str | None, end: str | None) -> float | None: except (TypeError, ValueError): return None +def _receive_order_gap_evidence(stream: Stream) -> dict: + """Only a fully retained decoder receive chronology authorizes temporal inferences. + + Neither callback timestamps nor SqNum/EntryID establish MMS packet order or SOE delivery. + An invalid, bounded, omitted or untimed chronology returns unknown, not zero. + """ + rows = stream.chronology + declared = stream.chronology_declared + reason = None + if declared is None: + reason = "MISSING_CHRONOLOGY_DECLARATION" + elif declared[2] != 0 or sum(declared[:2]) != stream.frames: + reason = "CHRONOLOGY_HAS_OMITTED_OR_UNACCOUNTED_FRAMES" + elif len(rows) != stream.frames or len(rows) != sum(declared[:2]): + reason = "RETAINED_CHRONOLOGY_INCOMPLETE" + elif [int(row[0]) for row in rows] != list(range(1, stream.frames + 1)): + reason = "RECEIVE_FRAME_INDICES_NOT_CONTIGUOUS" + times = [] + if reason is None: + for row in rows: + try: + moment = datetime.fromisoformat(row[1].replace("Z", "+00:00")) + if moment.tzinfo is None or moment.utcoffset() is None: + raise ValueError("no UTC offset") + except (TypeError, ValueError, OverflowError): + reason = "MISSING_OR_INVALID_RECEIVE_TIMESTAMPS" + break + times.append(moment) + if reason is None and any(b < a for a, b in zip(times, times[1:])): + reason = "NONMONOTONIC_DECODER_RECEIVE_TIMESTAMPS" + if reason is not None: + return dict(coverage="UNVERIFIABLE", reason=reason, + recorded_frames=len(rows), expected_frames=stream.frames, + longest_receive_gap_ms=None, + non_gi_backward_finding_after_gap=None) + gaps = [(b - a).total_seconds() * 1000 for a, b in zip(times, times[1:])] + candidates = [] + for index, gap in enumerate(gaps, start=1): + before, after = rows[index - 1], rows[index] + try: + prev_sq, curr_sq = int(before[2]), int(after[2]) + except ValueError: + continue + # A genuine decoded finding that recurs without GI or BufOvfl on this + # received frame; this is NOT evidence of harmless replay or lost SOE. + if (after[3] == "NO" and after[4] == "NO" and + int(after[6]) > 0 and curr_sq <= prev_sq): + candidates.append(dict(previous_frame=int(before[0]), frame=int(after[0]), + previous_sqnum=prev_sq, sqnum=curr_sq, + gap_ms=round(gap, 3), + elapsed_since_start_return_ms=_elapsed_ms( + stream.start_return_utc, after[1]), + received_utc=after[1], + entry_id_relation=after[5])) + strongest = max(candidates, key=lambda x: x["gap_ms"]) if candidates else None + return dict(coverage="COMPLETE_BOUNDED_DECODER_RECEIVE_ORDER", reason=None, + recorded_frames=len(rows), expected_frames=stream.frames, + longest_receive_gap_ms=round(max(gaps), 3) if gaps else None, + non_gi_backward_finding_after_gap=strongest) + def independent_triage(left: dict, right: dict) -> dict: """Evidence for *separate* sessions, never a paired BRCB reliability comparison.""" _require_matching_authority(left, right) @@ -155,14 +221,15 @@ def session_rows(streams: dict[str, Stream]) -> list[dict]: last_anomaly_ms_after_start_return=_elapsed_ms( stream.start_return_utc, stream.last_anomaly_utc), retained_chronology_frames=len(stream.chronology), - retained_anomaly_frames=len(stream.recent_anomalies))) + retained_anomaly_frames=len(stream.recent_anomalies), + receive_order_gap_evidence=_receive_order_gap_evidence(stream))) return rows return dict(status="INDEPENDENT_SESSIONS_OBSERVATION_ONLY", same_slot_dataset_scope=matched, paired_comparison_blocked_reason=None if matched else "different BRCB slots or DataSets", ied=left["ied"], fingerprint=left["fingerprint"], engine_commit=left["engine"], sessions=dict(open_scl=session_rows(la), discovery=session_rows(ra)), - qualification="NOT_PAIRED_RELIABILITY_COMPARISON; NOT_WIRE_ORDER_PROOF; NOT_SOE_LOSSLESS; NOT_R10_PHYSICAL_ACCEPTANCE") + qualification="NOT_PAIRED_RELIABILITY_COMPARISON; DECODER_RECEIVE_TIMESTAMPS_NOT_WIRE_ORDER; NOT_SOE_LOSSLESS; NOT_R10_PHYSICAL_ACCEPTANCE") def compare(left: dict, right: dict) -> dict: _require_matching_authority(left, right) diff --git a/scripts/test-compare-p10-brcb-sessions.py b/scripts/test-compare-p10-brcb-sessions.py index 847e8fef2..f45deb89b 100644 --- a/scripts/test-compare-p10-brcb-sessions.py +++ b/scripts/test-compare-p10-brcb-sessions.py @@ -139,6 +139,68 @@ def test_independent_triage_mismatched_engine_still_fails_closed(self): mod.independent_triage(mod.parse_report(report()), mod.parse_report(report(engine="f" * 40))) + def test_complete_bounded_chronology_exposes_20s_non_gi_recurrence(self): + data = report(frames=4, overflow=1, with_chronology=False) + data = data.replace("BRCB chronology: first=2, recent=0, omittedMiddle=0", + "BRCB chronology: first=4, recent=0, omittedMiddle=0") + data = data.replace(" OptFlds : decoded=25/25", """ frame#1: receivedUtc=2026-10-11T06:25:51Z, SqNum=10, GIreason=NO, BufOvfl=YES, EntryIDrelation=unverified, findings=1 + frame#2: receivedUtc=2026-10-11T06:25:51.100Z, SqNum=11, GIreason=NO, BufOvfl=NO, EntryIDrelation=changed, findings=0 + frame#3: receivedUtc=2026-10-11T06:26:11Z, SqNum=1, GIreason=NO, BufOvfl=NO, EntryIDrelation=changed, findings=1 + frame#4: receivedUtc=2026-10-11T06:26:17Z, SqNum=2, GIreason=NO, BufOvfl=NO, EntryIDrelation=changed, findings=0 + OptFlds : decoded=25/25""") + return data + + def test_late_non_gi_recurrence_after_full_receive_gap(self): + sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + triage = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample)) + evidence = triage["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("COMPLETE_BOUNDED_DECODER_RECEIVE_ORDER", evidence["coverage"]) + self.assertEqual(19900.0, evidence["longest_receive_gap_ms"]) + anomaly = evidence["non_gi_backward_finding_after_gap"] + self.assertEqual(3, anomaly["frame"]) + self.assertEqual((11, 1), (anomaly["previous_sqnum"], anomaly["sqnum"])) + self.assertEqual(19900.0, anomaly["gap_ms"]) + self.assertEqual(21000.0, anomaly["elapsed_since_start_return_ms"]) + self.assertIn("NOT_SOE_LOSSLESS", triage["qualification"]) + + def test_truncated_chronology_never_invents_receive_gap(self): + sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = "\\n".join(line for line in sample.splitlines() if "frame#2:" not in line) + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("UNVERIFIABLE", evidence["coverage"]) + self.assertEqual("RETAINED_CHRONOLOGY_INCOMPLETE", evidence["reason"]) + self.assertIsNone(evidence["longest_receive_gap_ms"]) + self.assertIsNone(evidence["non_gi_backward_finding_after_gap"]) + + def test_omitted_middle_never_certifies_gaps(self): + sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = sample.replace("omittedMiddle=0", "omittedMiddle=1") + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("CHRONOLOGY_HAS_OMITTED_OR_UNACCOUNTED_FRAMES", evidence["reason"]) + + def test_out_of_order_or_duplicate_frame_ids_do_not_certify_receive_order(self): + sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = sample.replace("frame#3:", "frame#2:") + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("RECEIVE_FRAME_INDICES_NOT_CONTIGUOUS", evidence["reason"]) + + def test_nonmonotonic_timestamps_do_not_certify_receive_order(self): + sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = sample.replace("frame#3: receivedUtc=2026-10-11T06:26:11Z", + "frame#3: receivedUtc=2026-10-11T06:25:50Z") + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("NONMONOTONIC_DECODER_RECEIVE_TIMESTAMPS", evidence["reason"]) + + def test_gi_reason_only_and_unverified_fixture_do_not_claim_non_gi_recurrence(self): + sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = sample.replace("frame#3: receivedUtc=2026-10-11T06:26:11Z, SqNum=1, GIreason=NO", + "frame#3: receivedUtc=2026-10-11T06:26:11Z, SqNum=1, GIreason=YES") + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("COMPLETE_BOUNDED_DECODER_RECEIVE_ORDER", evidence["coverage"]) + self.assertIsNone(evidence["non_gi_backward_finding_after_gap"]) + old = mod.independent_triage(mod.parse_report(report()), mod.parse_report(report())) + self.assertEqual("UNVERIFIABLE", old["sessions"]["open_scl"][0]["receive_order_gap_evidence"]["coverage"]) + def test_empty_chronology_allowed_but_does_not_invent_events(self): result = mod.compare(mod.parse_report(report(with_chronology=False)), mod.parse_report(report(with_chronology=False))) From 6bc21ff4787ad10a53a70ec33fe26268620c100d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 18:29:26 +0700 Subject: [PATCH 86/99] test(p10.9b): reuse non-test chronology fixture and trim correctly --- scripts/test-compare-p10-brcb-sessions.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/test-compare-p10-brcb-sessions.py b/scripts/test-compare-p10-brcb-sessions.py index f45deb89b..50661570e 100644 --- a/scripts/test-compare-p10-brcb-sessions.py +++ b/scripts/test-compare-p10-brcb-sessions.py @@ -139,7 +139,7 @@ def test_independent_triage_mismatched_engine_still_fails_closed(self): mod.independent_triage(mod.parse_report(report()), mod.parse_report(report(engine="f" * 40))) - def test_complete_bounded_chronology_exposes_20s_non_gi_recurrence(self): + def _full_chronology_report(self): data = report(frames=4, overflow=1, with_chronology=False) data = data.replace("BRCB chronology: first=2, recent=0, omittedMiddle=0", "BRCB chronology: first=4, recent=0, omittedMiddle=0") @@ -151,7 +151,7 @@ def test_complete_bounded_chronology_exposes_20s_non_gi_recurrence(self): return data def test_late_non_gi_recurrence_after_full_receive_gap(self): - sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = self._full_chronology_report() triage = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample)) evidence = triage["sessions"]["open_scl"][0]["receive_order_gap_evidence"] self.assertEqual("COMPLETE_BOUNDED_DECODER_RECEIVE_ORDER", evidence["coverage"]) @@ -164,8 +164,8 @@ def test_late_non_gi_recurrence_after_full_receive_gap(self): self.assertIn("NOT_SOE_LOSSLESS", triage["qualification"]) def test_truncated_chronology_never_invents_receive_gap(self): - sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() - sample = "\\n".join(line for line in sample.splitlines() if "frame#2:" not in line) + sample = self._full_chronology_report() + sample = "\n".join(line for line in sample.splitlines() if "frame#2:" not in line) evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] self.assertEqual("UNVERIFIABLE", evidence["coverage"]) self.assertEqual("RETAINED_CHRONOLOGY_INCOMPLETE", evidence["reason"]) @@ -173,26 +173,26 @@ def test_truncated_chronology_never_invents_receive_gap(self): self.assertIsNone(evidence["non_gi_backward_finding_after_gap"]) def test_omitted_middle_never_certifies_gaps(self): - sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = self._full_chronology_report() sample = sample.replace("omittedMiddle=0", "omittedMiddle=1") evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] self.assertEqual("CHRONOLOGY_HAS_OMITTED_OR_UNACCOUNTED_FRAMES", evidence["reason"]) def test_out_of_order_or_duplicate_frame_ids_do_not_certify_receive_order(self): - sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = self._full_chronology_report() sample = sample.replace("frame#3:", "frame#2:") evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] self.assertEqual("RECEIVE_FRAME_INDICES_NOT_CONTIGUOUS", evidence["reason"]) def test_nonmonotonic_timestamps_do_not_certify_receive_order(self): - sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = self._full_chronology_report() sample = sample.replace("frame#3: receivedUtc=2026-10-11T06:26:11Z", "frame#3: receivedUtc=2026-10-11T06:25:50Z") evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] self.assertEqual("NONMONOTONIC_DECODER_RECEIVE_TIMESTAMPS", evidence["reason"]) def test_gi_reason_only_and_unverified_fixture_do_not_claim_non_gi_recurrence(self): - sample = self.test_complete_bounded_chronology_exposes_20s_non_gi_recurrence() + sample = self._full_chronology_report() sample = sample.replace("frame#3: receivedUtc=2026-10-11T06:26:11Z, SqNum=1, GIreason=NO", "frame#3: receivedUtc=2026-10-11T06:26:11Z, SqNum=1, GIreason=YES") evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] From 83e3ac4d4437f60b73b3a1328c1188dc36c3d4a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 19:06:02 +0700 Subject: [PATCH 87/99] fix(discovery-ux): remember last submitted IED IP and port without changing SCL bindings --- IpConnectWizardWindow.xaml.cs | 43 +++++- MainWindow.xaml.cs | 4 +- Services/UserPreferenceStore.cs | 51 ++++++- ...coveryEndpointPreferenceRegressionTests.cs | 144 ++++++++++++++++++ 4 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 tests/ARSAS.Tests/DiscoveryEndpointPreferenceRegressionTests.cs diff --git a/IpConnectWizardWindow.xaml.cs b/IpConnectWizardWindow.xaml.cs index 01728720b..da52f2cae 100644 --- a/IpConnectWizardWindow.xaml.cs +++ b/IpConnectWizardWindow.xaml.cs @@ -26,16 +26,33 @@ public string MmsPortText set => SetValue(MmsPortTextProperty, value); } + private readonly bool _rememberDiscoveryEndpoint; public int MmsPort { get; private set; } = 102; public ObservableCollection RecentRelayIps { get; } = new(); - public IpConnectWizardWindow(string initialIp = "", int initialPort = 102) + public IpConnectWizardWindow(string initialIp = "", int initialPort = 102, + bool preferLastDiscoveryEndpoint = false) { + _rememberDiscoveryEndpoint = preferLastDiscoveryEndpoint; InitializeComponent(); - RelayIpAddress = initialIp?.Trim() ?? string.Empty; - MmsPortText = (initialPort <= 0 ? 102 : initialPort).ToString(CultureInfo.InvariantCulture); foreach (var endpoint in UserPreferenceStore.LoadRecentEndpoints()) RecentRelayIps.Add(endpoint); + + // This dialog is reused from several workspace actions. The last + // explicitly confirmed address, not a selected card or hardcoded NIC, + // is the primary suggestion for the next IP Discovery action. + if (_rememberDiscoveryEndpoint && + UserPreferenceStore.TryLoadLastDiscoveryEndpoint(out var lastIp, out var lastPort)) + { + RelayIpAddress = lastIp; + MmsPortText = lastPort.ToString(CultureInfo.InvariantCulture); + } + else + { + RelayIpAddress = initialIp?.Trim() ?? string.Empty; + MmsPortText = (initialPort is > 0 and <= 65535 ? initialPort : 102) + .ToString(CultureInfo.InvariantCulture); + } Loaded += (_, _) => RelayIpBox.Focus(); } @@ -54,7 +71,9 @@ private void RelayIpBox_Loaded(object sender, RoutedEventArgs e) private void Connect_Click(object sender, RoutedEventArgs e) { - RelayIpAddress = (RelayIpAddress ?? string.Empty).Trim(); + // Editable ComboBox commits its binding on LostFocus. Enter can submit + // while the editor still owns focus: read the live text to avoid stale IPs. + RelayIpAddress = RelayIpBox.Text.Trim(); if (!System.Net.IPAddress.TryParse(RelayIpAddress, out _)) { WizardStatusText.Text = "Enter a valid IPv4 or IPv6 address."; @@ -69,6 +88,22 @@ private void Connect_Click(object sender, RoutedEventArgs e) } MmsPort = port; + + // Persist the operator's valid *submission*, not only a successful + // MMS association. Cancel/invalid inputs never overwrite the last IP. + if (_rememberDiscoveryEndpoint) + { + try + { + UserPreferenceStore.RecordLastDiscoveryEndpoint(RelayIpAddress, MmsPort); + } + catch (Exception ex) when (ex is System.IO.IOException or UnauthorizedAccessException) + { + // Preference storage must never prevent an intentional connection. + System.Diagnostics.Debug.WriteLine($"Discovery address could not be saved: {ex.Message}"); + } + } + DialogResult = true; Close(); } diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 5fce0ce01..8b5553833 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -42,7 +42,7 @@ public partial class MainWindow : Window, INotifyPropertyChanged Interval = TimeSpan.FromSeconds(8) }; private Iec61850MonitorDevice? _selectedDevice; - private string _newDeviceIp = "192.168.1.10"; + private string _newDeviceIp = string.Empty; private string _newDevicePort = "102"; private int _pollingIntervalMs = 1000; private string _lastStatusText = "Ready. Add an IEC 61850 IED or open a saved ARSAS project."; @@ -157,7 +157,7 @@ private async void AddRelay_Click(object sender, RoutedEventArgs e) { var initialIp = SelectedDevice?.IpAddress ?? NewDeviceIp; var initialPort = SelectedDevice?.Port ?? 102; - var wizard = new IpConnectWizardWindow(initialIp, initialPort) { Owner = this }; + var wizard = new IpConnectWizardWindow(initialIp, initialPort, preferLastDiscoveryEndpoint: true) { Owner = this }; if (wizard.ShowDialog() != true) return; diff --git a/Services/UserPreferenceStore.cs b/Services/UserPreferenceStore.cs index 9be74665e..e874da83f 100644 --- a/Services/UserPreferenceStore.cs +++ b/Services/UserPreferenceStore.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Net; using System.Text.Json; namespace ArIED61850Tester.Services; @@ -26,14 +27,58 @@ public static class UserPreferenceStore "user-preferences.json"); + /// + /// Remembers the operator's last valid Discovery submission independently + /// of connection success and the per-IED signal selection history. + /// + public static bool TryLoadLastDiscoveryEndpoint(out string ipAddress, out int port) + => TryLoadLastDiscoveryEndpoint(PreferencesPath, out ipAddress, out port); + + // Tests use a temp path; no test writes to the real Windows profile. + internal static bool TryLoadLastDiscoveryEndpoint(string path, out string ipAddress, out int port) + { + ipAddress = string.Empty; + port = 102; + var prefs = ReadPreferencesFile(path); + if (prefs is null || !IPAddress.TryParse(prefs.LastDiscoveryIpAddress, out _) || + prefs.LastDiscoveryMmsPort is <= 0 or > 65535) + return false; + ipAddress = prefs.LastDiscoveryIpAddress.Trim(); + port = prefs.LastDiscoveryMmsPort; + return true; + } + + public static void RecordLastDiscoveryEndpoint(string ipAddress, int port) + => RecordLastDiscoveryEndpoint(PreferencesPath, ipAddress, port); + + internal static void RecordLastDiscoveryEndpoint(string path, string ipAddress, int port) + { + var ip = ipAddress?.Trim() ?? string.Empty; + if (!IPAddress.TryParse(ip, out _) || port is <= 0 or > 65535) + throw new ArgumentException("Discovery requires a valid IP address and MMS port."); + + // Keep legacy migration on the production path and preserve every + // existing saved signal profile and successful IED endpoint. + var prefs = string.Equals(path, PreferencesPath, StringComparison.Ordinal) + ? LoadPreferences() + : ReadPreferencesFile(path) ?? new UserPreferences(); + prefs.LastDiscoveryIpAddress = ip; + prefs.LastDiscoveryMmsPort = port; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(prefs, Options)); + } + public static IReadOnlyList LoadRecentEndpoints(int maximum = 20) { maximum = Math.Clamp(maximum, 1, 100); var prefs = LoadPreferences(); - return prefs.SuccessfulRelays + return new[] { prefs.LastDiscoveryIpAddress } + .Where(ip => !string.IsNullOrWhiteSpace(ip) && IPAddress.TryParse(ip.Trim(), out _)) + .Select(ip => ip.Trim()) + .Concat(prefs.SuccessfulRelays .Where(item => !string.IsNullOrWhiteSpace(item.IpAddress)) .OrderByDescending(item => item.LastConnectedUtc) - .Select(item => item.IpAddress.Trim()) + .Select(item => item.IpAddress.Trim())) .Concat(prefs.SignalSelectionProfiles .Where(item => !string.IsNullOrWhiteSpace(item.IpAddress)) .OrderByDescending(item => item.LastSavedUtc) @@ -285,6 +330,8 @@ private sealed class UserPreferences { // Keep the original ArServer fields so successful endpoints migrate without // requiring the user to type the IP address again after upgrading. + public string LastDiscoveryIpAddress { get; set; } = string.Empty; + public int LastDiscoveryMmsPort { get; set; } = 102; public List RecentRelayIps { get; set; } = new(); public List SuccessfulRelays { get; set; } = new(); public List SignalSelectionProfiles { get; set; } = new(); diff --git a/tests/ARSAS.Tests/DiscoveryEndpointPreferenceRegressionTests.cs b/tests/ARSAS.Tests/DiscoveryEndpointPreferenceRegressionTests.cs new file mode 100644 index 000000000..0b079d1d2 --- /dev/null +++ b/tests/ARSAS.Tests/DiscoveryEndpointPreferenceRegressionTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Test the actual persistence code using isolated temporary files, not the +/// operator's AppData. No MMS sockets, report controls, or process polling. +/// +public sealed class DiscoveryEndpointPreferenceRegressionTests +{ + [Fact] + public void LastSubmittedIpAndCustomPort_SurviveReload_AndPreserveExistingProfiles() + { + WithTemporaryPreferences(path => + { + File.WriteAllText(path, """ + {"SuccessfulRelays":[{"IpAddress":"10.0.0.44","MmsPort":102,"IedName":"OLD"}], + "SignalSelectionProfiles":[{"IedName":"IED1","IpAddress":"10.0.0.44", + "Port":102,"SelectedObjectReferences":["LD0/LLN0.Mod.stVal"]}], + "RecentRelayIps":["10.0.0.50"]} + """); + + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, " 192.16.1.33 ", 1102); + + Assert.True(UserPreferenceStore.TryLoadLastDiscoveryEndpoint( + path, out var storedIp, out var storedPort)); + Assert.Equal("192.16.1.33", storedIp); + Assert.Equal(1102, storedPort); + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var prefs = document.RootElement; + Assert.Equal("OLD", prefs.GetProperty("SuccessfulRelays")[0].GetProperty("IedName").GetString()); + Assert.Equal("LD0/LLN0.Mod.stVal", + prefs.GetProperty("SignalSelectionProfiles")[0] + .GetProperty("SelectedObjectReferences")[0].GetString()); + Assert.Equal("10.0.0.50", prefs.GetProperty("RecentRelayIps")[0].GetString()); + }); + } + + [Fact] + public void SecondValidSubmissionWins_EvenIfNoSuccessfulConnectionWasRecorded() + { + WithTemporaryPreferences(path => + { + Assert.False(UserPreferenceStore.TryLoadLastDiscoveryEndpoint(path, out _, out _)); + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.168.81.17", 102); + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.16.1.33", 102); + + Assert.True(UserPreferenceStore.TryLoadLastDiscoveryEndpoint( + path, out var ip, out var port)); + Assert.Equal("192.16.1.33", ip); + Assert.Equal(102, port); + }); + } + + [Fact] + public void InvalidIpOrPort_DoesNotOverwriteLastValidEndpoint() + { + WithTemporaryPreferences(path => + { + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.16.1.33", 102); + var expectedJson = File.ReadAllText(path); + + Assert.Throws(() => + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "not-an-ip", 102)); + Assert.Throws(() => + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.16.1.99", 0)); + Assert.Throws(() => + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.16.1.99", 65536)); + Assert.Equal(expectedJson, File.ReadAllText(path)); + }); + } + + [Fact] + public void CorruptOrInvalidPreferenceData_FailsClosedWithoutFabricatingIp() + { + WithTemporaryPreferences(path => + { + File.WriteAllText(path, "not-json"); + Assert.False(UserPreferenceStore.TryLoadLastDiscoveryEndpoint(path, out _, out _)); + + File.WriteAllText(path, """{"LastDiscoveryIpAddress":"not-an-ip","LastDiscoveryMmsPort":102}"""); + Assert.False(UserPreferenceStore.TryLoadLastDiscoveryEndpoint(path, out _, out _)); + + File.WriteAllText(path, """{"LastDiscoveryIpAddress":"192.16.1.33","LastDiscoveryMmsPort":65536}"""); + Assert.False(UserPreferenceStore.TryLoadLastDiscoveryEndpoint(path, out _, out _)); + }); + } + + [Fact] + public void Dialog_UsesPersistedEndpointAheadOfCallerSelection_AndCommitsOnlyValidSubmit() + { + var wizard = File.ReadAllText(Find("IpConnectWizardWindow.xaml.cs")); + var xaml = File.ReadAllText(Find("IpConnectWizardWindow.xaml")); + var mainWindow = File.ReadAllText(Find("MainWindow.xaml.cs")); + Assert.Contains("new IpConnectWizardWindow(initialIp, initialPort, preferLastDiscoveryEndpoint: true)", mainWindow); + Assert.Contains("new IpConnectWizardWindow(string.Empty, device.Port <= 0 ? 102 : device.Port)", mainWindow); + Assert.DoesNotContain("private string _newDeviceIp = \"192.168.1.10\";", mainWindow); + Assert.Contains("private string _newDeviceIp = string.Empty;", mainWindow); + Assert.Contains("bool preferLastDiscoveryEndpoint = false", wizard); + Assert.Contains("_rememberDiscoveryEndpoint = preferLastDiscoveryEndpoint;", wizard); + Assert.Contains("if (_rememberDiscoveryEndpoint &&", wizard); + Assert.Contains("if (_rememberDiscoveryEndpoint)", wizard); + Assert.Contains("TryLoadLastDiscoveryEndpoint(out var lastIp, out var lastPort)", wizard); + Assert.Contains("RelayIpAddress = lastIp;", wizard); + Assert.Contains("MmsPortText = lastPort.ToString(CultureInfo.InvariantCulture);", wizard); + Assert.Contains("RelayIpAddress = RelayIpBox.Text.Trim();", wizard); + Assert.Contains("RecordLastDiscoveryEndpoint(RelayIpAddress, MmsPort)", wizard); + var validateIp = wizard.IndexOf("System.Net.IPAddress.TryParse", StringComparison.Ordinal); + var validatePort = wizard.IndexOf("port is <= 0 or > 65535", StringComparison.Ordinal); + var commit = wizard.IndexOf("RecordLastDiscoveryEndpoint(RelayIpAddress, MmsPort)", StringComparison.Ordinal); + Assert.True(validateIp >= 0 && validateIp < validatePort && validatePort < commit); + Assert.Contains("IsEditable=\"True\" IsTextSearchEnabled=\"False\"", xaml); + Assert.Contains("UpdateSourceTrigger=LostFocus", xaml); + } + + private static void WithTemporaryPreferences(Action action) + { + var folder = Path.Combine(Path.GetTempPath(), "arsas-last-discovery-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + action(Path.Combine(folder, "preferences.json")); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + private static string Find(string relative) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var file = Path.Combine(directory.FullName, relative); + if (File.Exists(file)) + return file; + directory = directory.Parent; + } + throw new FileNotFoundException(relative); + } +} From 3e84d6951fa405f8ceb2505664f91e57827e3060 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 20:13:46 +0700 Subject: [PATCH 88/99] chore(p10.9e): pin validated ARIEC core RptID/DataSet routing consistency guard --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 7ff7d95c0..d6da551e2 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1", - "sourcePullRequest": 161, - "purpose": "P10 VERIFIED-SCL IDENTITY CODE TRIAL on exact PR #157 dataset-first Discovery baseline. Includes canonical live Save SCL propagation with original accepted association evidence and LD instance values, after PR #161 correction commit; physical BCUGE test pending. No change to existing MMS report/control/cyclic scheduler.", + "commit": "fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7", + "sourcePullRequest": 164, + "purpose": "P10.9E client MMS report-routing integrity lock: quarantine contradictory full DatSet/RptID before value projection (PR #164). Also includes P10.9D simulator-only FIFO emission and CI staging PR trigger (PR #163). Previous BCUGE static acquisition remains authority: zero cyclic polling and unchanged decoder, GI, controls and report activation. The simulator FIFO fix is not ARSAS client runtime and does not establish real BCUGE SOE continuity.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, From e62b42bfbc7d1a9db0ec1ae55bb46789f5fec347 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 04:37:22 +0700 Subject: [PATCH 89/99] ci(p10.9f): qualify exact ARIEC core routing SHA as non-physical staging-only trial --- .../interoperability-reference-guard.yml | 4 +- .github/workflows/scl-interoperability-r7.yml | 5 ++ .../smart-discovery-mainline-readiness.yml | 4 +- engines/ARIEC61850.lock.json | 15 +++++ scripts/resolve-ariec-lock.ps1 | 5 +- scripts/test-p10-9f-core-routing-trial.ps1 | 64 +++++++++++++++++++ .../CanonicalLiveSclExportRegressionTests.cs | 6 +- .../P10MainlinePolicyExecutionTests.cs | 15 +++++ ...teroperabilityTrialGuardRegressionTests.cs | 10 ++- 9 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 scripts/test-p10-9f-core-routing-trial.ps1 diff --git a/.github/workflows/interoperability-reference-guard.yml b/.github/workflows/interoperability-reference-guard.yml index 8922b09d7..709c3f377 100644 --- a/.github/workflows/interoperability-reference-guard.yml +++ b/.github/workflows/interoperability-reference-guard.yml @@ -84,6 +84,8 @@ jobs: -not [bool]$association.physicalAcceptance.actualInformationReportObserved) { throw 'Accepted post-baseline SCL association authority drifted or lost physical evidence.' } + . .\ARSAS\scripts\test-p10-9f-core-routing-trial.ps1 + $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial $serverAt = $lock.sclServerAtTrial $originalTrial = $lock.commit -eq $trial.exactCommit -and @@ -131,7 +133,7 @@ jobs: if ($trial.status -ne 'code-verified-not-physical' -or [int]$trial.issue -ne 144 -or [int]$trial.integrationPullRequest -ne 148 -or - -not ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial) -or + -not ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid) -or -not [bool]$trial.fullDiscoveryFallbackForbidden -or -not [bool]$trial.vendorBranchingForbidden -or -not [bool]$trial.physicalPromotionRequired) { diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index aa5faf586..9b69feac8 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -81,6 +81,8 @@ jobs: throw 'Physical SCL-association/R10 provenance is not intact.' } + . .\ARSAS\scripts\test-p10-9f-core-routing-trial.ps1 + $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial if ($null -ne $trial -and $lock.commit -eq $trial.exactCommit) { if ([int]$lock.sourcePullRequest -ne 148 -or @@ -146,6 +148,9 @@ jobs: } Write-Host 'P10 trusted-SCL identity trial accepted for code CI ONLY; R7/R10 physical gate unchanged.' } + elseif ($coreRoutingTrialValid) { + Write-Host 'P10.9F routing core accepted for CodeVerified staging CI ONLY; R7/R10 physical authority unchanged.' + } elseif ($lock.commit -eq $convergence.activeStack.engineModelAndScl.head) { if ([int]$lock.sourcePullRequest -notin @(134, 135) -or $lock.purpose -notmatch 'R7 physical SCL repair pin') { diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index 22655d396..f5735b4a4 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -109,6 +109,8 @@ jobs: # P10.1: exact, bounded and ancestry-checked code-trial allowlist. # This never grants field acceptance or mainline promotion. + . .\scripts\test-p10-9f-core-routing-trial.ps1 + $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial $serverAt = $lock.sclServerAtTrial $closure = $lock.datasetFirstDiscoveryTrial @@ -160,7 +162,7 @@ jobs: $lock.commit -eq $p10.exactCommit -and [int]$lock.sourcePullRequest -eq 161 - if ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial) { + if ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid) { if (-not $historicalTrialValid -or $lock.ref -ne 'main') { throw 'Stacked trial identity or ancestor authority is inconsistent.' diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index d6da551e2..5c67895cc 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -84,6 +84,21 @@ "cyclicMmsProcessPollingForbidden": true, "physicalQualificationRequired": true }, + "coreReportRoutingTrial": { + "status": "code-verified-not-physical", + "issue": 531, + "simulatorPullRequest": 163, + "corePullRequest": 164, + "baseEngineCommit": "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1", + "simulatorFifoCommit": "ef0fe44f3b40e47dc18b1aac4476b635e67d531e", + "exactCommit": "fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7", + "simulatorCiRun": 38141582981, + "coreCiRun": 38142115574, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + }, "p10IdentityTrial": { "status": "code-trial-not-physical", "enginePullRequest": 161, diff --git a/scripts/resolve-ariec-lock.ps1 b/scripts/resolve-ariec-lock.ps1 index ad525f606..00f4172c4 100644 --- a/scripts/resolve-ariec-lock.ps1 +++ b/scripts/resolve-ariec-lock.ps1 @@ -117,11 +117,14 @@ if ($null -ne $lock.sclAssociationInteroperability) { [bool]$p10.fullNameListFallbackForbidden -and [bool]$p10.cyclicMmsProcessPollingForbidden + . (Join-Path $PSScriptRoot 'test-p10-9f-core-routing-trial.ps1') + $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock $acceptedTrial = ( ($trialValid -and [string]$lock.commit -ceq [string]$trial.exactCommit -and [int]$lock.sourcePullRequest -eq 148) -or ($serverAtValid -and [string]$lock.commit -ceq [string]$serverAt.exactCommit -and [int]$lock.sourcePullRequest -eq 153) -or ($closureValid -and [string]$lock.commit -ceq [string]$closure.exactCommit -and [int]$lock.sourcePullRequest -eq 157) -or - ($p10Valid -and [string]$lock.commit -ceq [string]$p10.exactCommit -and [int]$lock.sourcePullRequest -eq 161) + ($p10Valid -and [string]$lock.commit -ceq [string]$p10.exactCommit -and [int]$lock.sourcePullRequest -eq 161) -or + ($p10Valid -and $coreRoutingTrialValid) ) if (-not $historicalAnchorValid -or -not $acceptedTrial) { throw 'SCL association physical-tested engine head differs from integration lock.' diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 new file mode 100644 index 000000000..7827aa251 --- /dev/null +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -0,0 +1,64 @@ +# PowerShell 5.1/7 compatible. Code-verification does NOT grant physical acceptance. +# Exactly one audited core routing candidate; no wildcard SHA, no runtime overrides. +function Test-P109FCoreRoutingTrial { + param([Parameter(Mandatory = $true)] [object] $Lock) + $candidate = $Lock.coreReportRoutingTrial + if ([string]$Lock.commit -cne 'fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7' -or + [int]$Lock.sourcePullRequest -ne 164 -or + [string]$Lock.repository -cne 'masarray/ARIEC61850' -or + [string]$Lock.ref -cne 'main' -or $null -eq $candidate) { return $false } + + # A code-only candidate cannot be interpreted as physical authority, + # including CI builds targeting or running on main. + foreach ($ref in @($env:PR_BASE_REF, $env:SOURCE_REF_NAME, + $env:GITHUB_BASE_REF, $env:GITHUB_REF_NAME)) { + if ([string]$ref -ceq 'main' -or [string]$ref -ceq 'refs/heads/main') { + return $false + } + } + $p10 = $Lock.p10IdentityTrial + $closure = $Lock.datasetFirstDiscoveryTrial + $association = $Lock.sclAssociationInteroperability + $historical = $Lock.smartInteroperabilityCodeTrial + $serverAt = $Lock.sclServerAtTrial + if ($null -eq $p10 -or $null -eq $closure -or $null -eq $association -or + $null -eq $historical -or $null -eq $serverAt) { return $false } + + return ( + [string]$candidate.status -ceq 'code-verified-not-physical' -and + [int]$candidate.issue -eq 531 -and + [int]$candidate.simulatorPullRequest -eq 163 -and + [int]$candidate.corePullRequest -eq 164 -and + [string]$candidate.baseEngineCommit -ceq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + [string]$candidate.simulatorFifoCommit -ceq 'ef0fe44f3b40e47dc18b1aac4476b635e67d531e' -and + [string]$candidate.exactCommit -ceq 'fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7' -and + [long]$candidate.simulatorCiRun -eq 38141582981 -and + [long]$candidate.coreCiRun -eq 38142115574 -and + [bool]$candidate.physicalQualificationRequired -and + [bool]$candidate.mainlinePromotionForbidden -and + [bool]$candidate.fullDiscoveryFallbackForbidden -and + [bool]$candidate.cyclicMmsProcessPollingForbidden -and + [string]$p10.exactCommit -ceq [string]$candidate.baseEngineCommit -and + [string]$p10.exactCommit -ceq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + [int]$p10.enginePullRequest -eq 161 -and + [string]$p10.baseEngineCommit -ceq [string]$closure.exactCommit -and + [string]$p10.identitySource -ceq 'TrustedSclExactDomainMatch' -and + [string]$p10.status -ceq 'code-trial-not-physical' -and + [bool]$p10.physicalQualificationRequired -and + [bool]$p10.fullNameListFallbackForbidden -and + [bool]$p10.cyclicMmsProcessPollingForbidden -and + [string]$closure.exactCommit -ceq '8b0c0f7e695b016834e40a6f068994dcc945cb1a' -and + [string]$closure.baseEngineCommit -ceq [string]$serverAt.exactCommit -and + [string]$serverAt.exactCommit -ceq '352c81e6a798635c6addcee0683235ca87ad416d' -and + [string]$serverAt.baseEngineCommit -ceq [string]$historical.exactCommit -and + [string]$historical.exactCommit -ceq '9c5292570f55dd81be1b3a6b56f937e5ba1ed276' -and + [string]$association.testedEngineCommit -ceq '84e9820e5a32690475960e49d5ef74e6637847fd' -and + [string]$association.mergedEngineCommit -ceq 'e5deed1d8aa11d97991695c6e390baafea7ab797' -and + [string]$association.contractId -ceq 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -and + [bool]$association.preservesR10DiscoveryModelBaseline -and + [bool]$association.fullDiscoveryFallbackForbidden -and + [bool]$association.cyclicMmsProcessPollingForbidden -and + [string]$Lock.mergedMainCommit -ceq '648124097621046f5f127ceb1cf853fea54db730' -and + [string]$Lock.previousStablePin.commit -ceq [string]$Lock.mergedMainCommit + ) +} diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index d1a5e739d..4756cc748 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -420,9 +420,13 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter var p10 = root.GetProperty("p10IdentityTrial"); Assert.Equal(closure.GetProperty("exactCommit").GetString(), p10.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1", p10.GetProperty("exactCommit").GetString()); + var coreTrial = root.GetProperty("coreReportRoutingTrial"); Assert.Equal(p10.GetProperty("exactCommit").GetString(), + coreTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(coreTrial.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); - Assert.Equal(161, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(164, root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs index 85b956267..8f2ef8779 100644 --- a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs +++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs @@ -11,6 +11,10 @@ public sealed class P10MainlinePolicyExecutionTests { [Theory] [InlineData("p10-stacked-valid", true)] + [InlineData("core-trial-wrong-exact-sha-denied", false)] + [InlineData("core-trial-wrong-base-denied", false)] + [InlineData("core-trial-wrong-ci-denied", false)] + [InlineData("core-trial-physical-flag-denied", false)] [InlineData("p10-pr-main-denied", false)] [InlineData("p10-source-main-denied", false)] [InlineData("p10-wrong-sha-denied", false)] @@ -51,6 +55,9 @@ public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion( { Directory.CreateDirectory(Path.Combine(folder, "engines")); Directory.CreateDirectory(Path.Combine(folder, "evidence")); + Directory.CreateDirectory(Path.Combine(folder, "scripts")); + File.Copy(Path.Combine(root, "scripts", "test-p10-9f-core-routing-trial.ps1"), + Path.Combine(folder, "scripts", "test-p10-9f-core-routing-trial.ps1")); var lockDoc = JsonNode.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json")))!.AsObject(); @@ -59,6 +66,14 @@ public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion( switch (scenario) { case "p10-pr-main-denied": baseRef = "main"; break; + case "core-trial-wrong-exact-sha-denied": + lockDoc["coreReportRoutingTrial"]!["exactCommit"] = new string('0', 40); break; + case "core-trial-wrong-base-denied": + lockDoc["coreReportRoutingTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "core-trial-wrong-ci-denied": + lockDoc["coreReportRoutingTrial"]!["coreCiRun"] = 0; break; + case "core-trial-physical-flag-denied": + lockDoc["coreReportRoutingTrial"]!["physicalQualificationRequired"] = false; break; case "p10-source-main-denied": sourceRef = "main"; break; case "p10-wrong-sha-denied": lockDoc["p10IdentityTrial"]!["exactCommit"] = new string('0', 40); break; diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index c3e996a84..5cbe62eb9 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -70,9 +70,15 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP var p10 = root.GetProperty("p10IdentityTrial"); Assert.Equal(closure.GetProperty("exactCommit").GetString(), p10.GetProperty("baseEngineCommit").GetString()); + var codeTrial = root.GetProperty("coreReportRoutingTrial"); + Assert.Equal(p10.GetProperty("exactCommit").GetString(), + codeTrial.GetProperty("baseEngineCommit").GetString()); Assert.Equal(root.GetProperty("commit").GetString(), - p10.GetProperty("exactCommit").GetString()); - Assert.Equal(161,root.GetProperty("sourcePullRequest").GetInt32()); + codeTrial.GetProperty("exactCommit").GetString()); + Assert.Equal("code-verified-not-physical", codeTrial.GetProperty("status").GetString()); + Assert.True(codeTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(codeTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.Equal(164,root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(p10.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); From 80d1a894b1c909b61ccbe36440528c5ef32ee388 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 05:34:34 +0700 Subject: [PATCH 90/99] ci(p10.9g): integrate loss-detecting ARIEC report subscriber via exact code-only provenance --- engines/ARIEC61850.lock.json | 18 ++++++++++--- scripts/test-p10-9f-core-routing-trial.ps1 | 27 ++++++++++++++++--- .../CanonicalLiveSclExportRegressionTests.cs | 8 +++++- .../P10MainlinePolicyExecutionTests.cs | 15 +++++++++++ ...teroperabilityTrialGuardRegressionTests.cs | 10 +++++-- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5c67895cc..811e91def 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7", - "sourcePullRequest": 164, - "purpose": "P10.9E client MMS report-routing integrity lock: quarantine contradictory full DatSet/RptID before value projection (PR #164). Also includes P10.9D simulator-only FIFO emission and CI staging PR trigger (PR #163). Previous BCUGE static acquisition remains authority: zero cyclic polling and unchanged decoder, GI, controls and report activation. The simulator FIFO fix is not ARSAS client runtime and does not establish real BCUGE SOE continuity.", + "commit": "d700b0df741b466afb8abe6cee1d70b1619d7b4d", + "sourcePullRequest": 165, + "purpose": "P10.9G secondary MMS InformationReport subscriber overload is now fail-closed instead of silently DropOldest; bounded subscriber error leaves canonical static FIFO untouched (ARIEC PR #165). P10.9E client MMS report-routing integrity lock: quarantine contradictory full DatSet/RptID before value projection (PR #164). Also includes P10.9D simulator-only FIFO emission and CI staging PR trigger (PR #163). Previous BCUGE static acquisition remains authority: zero cyclic polling and unchanged decoder, GI, controls and report activation. The simulator FIFO fix is not ARSAS client runtime and does not establish real BCUGE SOE continuity.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -84,6 +84,18 @@ "cyclicMmsProcessPollingForbidden": true, "physicalQualificationRequired": true }, + "reportSubscriberIntegrityTrial": { + "status": "code-verified-not-physical", + "issue": 532, + "enginePullRequest": 165, + "baseEngineCommit": "fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7", + "exactCommit": "d700b0df741b466afb8abe6cee1d70b1619d7b4d", + "engineCiRun": 38180732078, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + }, "coreReportRoutingTrial": { "status": "code-verified-not-physical", "issue": 531, diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 index 7827aa251..feb19e196 100644 --- a/scripts/test-p10-9f-core-routing-trial.ps1 +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -1,10 +1,31 @@ # PowerShell 5.1/7 compatible. Code-verification does NOT grant physical acceptance. -# Exactly one audited core routing candidate; no wildcard SHA, no runtime overrides. +# Exact audited code-only candidates (P10.9F and P10.9G); no wildcard SHA, no runtime overrides. function Test-P109FCoreRoutingTrial { param([Parameter(Mandatory = $true)] [object] $Lock) $candidate = $Lock.coreReportRoutingTrial - if ([string]$Lock.commit -cne 'fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7' -or - [int]$Lock.sourcePullRequest -ne 164 -or + $subscriber = $Lock.reportSubscriberIntegrityTrial + $previousHead = ( + [string]$Lock.commit -ceq 'fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7' -and + [int]$Lock.sourcePullRequest -eq 164 + ) + # P10.9G is a precisely scoped successor to the P10.9F code-only pin, + # NOT a promotion to physical or mainline acceptance. + $subscriberHead = ( + [string]$Lock.commit -ceq 'd700b0df741b466afb8abe6cee1d70b1619d7b4d' -and + [int]$Lock.sourcePullRequest -eq 165 -and + $null -ne $subscriber -and + [string]$subscriber.status -ceq 'code-verified-not-physical' -and + [int]$subscriber.issue -eq 532 -and + [int]$subscriber.enginePullRequest -eq 165 -and + [string]$subscriber.baseEngineCommit -ceq 'fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7' -and + [string]$subscriber.exactCommit -ceq 'd700b0df741b466afb8abe6cee1d70b1619d7b4d' -and + [long]$subscriber.engineCiRun -eq 38180732078 -and + [bool]$subscriber.physicalQualificationRequired -and + [bool]$subscriber.mainlinePromotionForbidden -and + [bool]$subscriber.fullDiscoveryFallbackForbidden -and + [bool]$subscriber.cyclicMmsProcessPollingForbidden + ) + if (-not ($previousHead -or $subscriberHead) -or [string]$Lock.repository -cne 'masarray/ARIEC61850' -or [string]$Lock.ref -cne 'main' -or $null -eq $candidate) { return $false } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 4756cc748..2f2511709 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -424,9 +424,15 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter var coreTrial = root.GetProperty("coreReportRoutingTrial"); Assert.Equal(p10.GetProperty("exactCommit").GetString(), coreTrial.GetProperty("baseEngineCommit").GetString()); + var subscriptionTrial = root.GetProperty("reportSubscriberIntegrityTrial"); Assert.Equal(coreTrial.GetProperty("exactCommit").GetString(), + subscriptionTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(subscriptionTrial.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); - Assert.Equal(164, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("code-verified-not-physical", subscriptionTrial.GetProperty("status").GetString()); + Assert.True(subscriptionTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(subscriptionTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.Equal(165, root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs index 8f2ef8779..bef1f273a 100644 --- a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs +++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs @@ -15,6 +15,11 @@ public sealed class P10MainlinePolicyExecutionTests [InlineData("core-trial-wrong-base-denied", false)] [InlineData("core-trial-wrong-ci-denied", false)] [InlineData("core-trial-physical-flag-denied", false)] + [InlineData("subscriber-trial-sha-denied", false)] + [InlineData("subscriber-trial-parent-denied", false)] + [InlineData("subscriber-trial-ci-denied", false)] + [InlineData("subscriber-trial-physical-flag-denied", false)] + [InlineData("subscriber-trial-mainline-flag-denied", false)] [InlineData("p10-pr-main-denied", false)] [InlineData("p10-source-main-denied", false)] [InlineData("p10-wrong-sha-denied", false)] @@ -74,6 +79,16 @@ public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion( lockDoc["coreReportRoutingTrial"]!["coreCiRun"] = 0; break; case "core-trial-physical-flag-denied": lockDoc["coreReportRoutingTrial"]!["physicalQualificationRequired"] = false; break; + case "subscriber-trial-sha-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["exactCommit"] = new string('0', 40); break; + case "subscriber-trial-parent-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "subscriber-trial-ci-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["engineCiRun"] = 0; break; + case "subscriber-trial-physical-flag-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["physicalQualificationRequired"] = false; break; + case "subscriber-trial-mainline-flag-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["mainlinePromotionForbidden"] = false; break; case "p10-source-main-denied": sourceRef = "main"; break; case "p10-wrong-sha-denied": lockDoc["p10IdentityTrial"]!["exactCommit"] = new string('0', 40); break; diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index 5cbe62eb9..196defff3 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -73,12 +73,18 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP var codeTrial = root.GetProperty("coreReportRoutingTrial"); Assert.Equal(p10.GetProperty("exactCommit").GetString(), codeTrial.GetProperty("baseEngineCommit").GetString()); + var subscriberTrial = root.GetProperty("reportSubscriberIntegrityTrial"); + Assert.Equal(codeTrial.GetProperty("exactCommit").GetString(), + subscriberTrial.GetProperty("baseEngineCommit").GetString()); Assert.Equal(root.GetProperty("commit").GetString(), - codeTrial.GetProperty("exactCommit").GetString()); + subscriberTrial.GetProperty("exactCommit").GetString()); Assert.Equal("code-verified-not-physical", codeTrial.GetProperty("status").GetString()); + Assert.Equal("code-verified-not-physical", subscriberTrial.GetProperty("status").GetString()); + Assert.True(subscriberTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(subscriberTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); Assert.True(codeTrial.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(codeTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); - Assert.Equal(164,root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(165,root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(p10.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); From 9a52679f867b7f6e0fd846e2a0e386e20b333dfe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 07:36:02 +0700 Subject: [PATCH 91/99] fix(diagnostics): distinguish same-NIC IPv4 aliases and contextualize late BRCB anomalies --- Services/DiagnosticReportBuilder.cs | 96 ++++++++++-- .../ContextAwareIec61850DiagnosisTests.cs | 143 ++++++++++++++++++ 2 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 9d687470a..bcd9e6d94 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -12,7 +12,7 @@ namespace ArIED61850Tester.Services; internal static class DiagnosticReportBuilder { private sealed record TcpProbeResult(string DeviceId, string Endpoint, string Result, string Detail); - private sealed record AdapterIpv4(string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength); + private sealed record AdapterIpv4(string InterfaceId, string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength); private sealed record RouteAnalysis(string Source, string MatchingAdapters, string AdapterMatrix, string Note); public static string BuildEmergency( @@ -323,6 +323,8 @@ static string Safe(string value) $"sqWithGIreason={stream.SequenceWithGiReason}, " + $"sqWithBufOvfl={stream.SequenceWithOverflow}; " + "EntryID difference/identical is not an event-loss or harmless-replay conclusion."); + if (stream.Buffered && stream.Findings > 0) + builder.AppendLine($" BRCB context : {ExplainBrcbContinuity(stream)}"); if (stream.AnomalousFrames > 0) builder.AppendLine($" RCB follow-up : receivedAfterLastFinding={stream.FramesAfterLastFinding}, " + $"firstFrameUtc={stream.FirstFrameReceivedAtUtc?.ToString("O") ?? "-"}, " + @@ -384,6 +386,41 @@ static string Safe(string value) builder.AppendLine($" stream list : TRUNCATED • shown={snapshot.Streams.Count}, total={snapshot.StreamCount}"); } + /// + /// IEC 61850-aware interpretation of already observed RCB metadata: + /// temporal context and GI reason are correlations, never proof of lost + /// buffered entries, a server reset, or that a sequence gap is harmless. + /// No extra MMS traffic, raw event retention or timer. + /// + internal static string ExplainBrcbContinuity(Iec61850ReportContinuityStreamSnapshot stream) + { + ArgumentNullException.ThrowIfNull(stream); + var withoutProvenGi = Math.Max(0, stream.SequenceDiscontinuities - stream.SequenceWithGiReason); + var context = $"sequenceDiscontinuities={stream.SequenceDiscontinuities}, " + + $"GI-tagged={stream.SequenceWithGiReason}, withoutConfirmedGI={withoutProvenGi}, " + + $"BufOvflAdvertised={stream.Overflow}"; + + if (stream.ActivationReturnedAtUtc is { } activation && + stream.LastAnomalyReceivedAtUtc is { } anomaly && + anomaly >= activation) + { + var delta = anomaly - activation; + var elapsed = delta.TotalMilliseconds; + if (double.IsFinite(elapsed)) + { + context += $", lastAnomalyAfterStartMs={elapsed:0}"; + if (delta >= TimeSpan.FromSeconds(5)) + context += "; late recurrence observed beyond 5s of RCB activation (NOT startup-only)"; + } + } + else + { + context += "; timing=UNKNOWN (insufficient RCB lifecycle evidence)"; + } + + return context + "; received-frame order only, not TCP wire order, event-loss or GI causality proof"; + } + private static string Verdict(bool? value) => value is true ? "YES" : value is false ? "NO" : "UNKNOWN"; @@ -457,8 +494,10 @@ private static RouteAnalysis AnalyzeRoute(string targetText) return new RouteAnalysis("unavailable", "unavailable", "unavailable", "Route analysis currently supports IPv4 endpoints."); var adapters = GetActiveIpv4Adapters(); - var matching = adapters + var matchingAddresses = adapters .Where(item => IsInSameSubnet(item.Address, target, item.PrefixLength)) + .ToArray(); + var matching = matchingAddresses .Select(item => $"{item.Name}={item.Address}/{item.PrefixLength}") .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -486,19 +525,12 @@ private static RouteAnalysis AnalyzeRoute(string targetText) source = $"unavailable ({ex.GetType().Name}: {ex.Message})"; } - string note; - if (matching.Length == 0) - { - note = $"No active IPv4 adapter is in the same subnet as {target}. Check the PC adapter address, prefix length, VLAN, and static route."; - } - else if (matching.Length == 1) - { - note = $"{target} is directly reachable only through {matching[0]}. Ensure the IED is physically connected to that network."; - } - else - { - note = $"Multiple adapters match {target}; Windows route priority can select the wrong interface. Disable unused adapters or configure interface metrics/static routes."; - } + // Do not confuse multiple IP aliases on ONE KM-TEST interface with + // competing network adapters. Exact local IP means same-host endpoint; + // neither condition warrants telling operators to disable interfaces. + var note = ExplainIpv4Route(target, + matchingAddresses.Select(item => (item.InterfaceId, item.Name)).ToArray(), + adapters.Any(item => item.Address.Equals(target))); return new RouteAnalysis( source, @@ -507,6 +539,39 @@ private static RouteAnalysis AnalyzeRoute(string targetText) note); } + /// + /// Pure, testable interface-aware route advice. The interface ID, not the + /// number of matching IPv4 addresses, defines routing ambiguity. This is + /// descriptive only: the actual OS routing table remains authoritative. + /// + internal static string ExplainIpv4Route( + IPAddress target, + IReadOnlyList<(string InterfaceId, string AdapterName)> matchedAddresses, + bool targetAssignedLocally) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(matchedAddresses); + var distinctInterfaces = matchedAddresses + .GroupBy(item => item.InterfaceId, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First().AdapterName) + .ToArray(); + if (targetAssignedLocally) + return $"Target {target} is assigned to this PC on " + + $"{(distinctInterfaces.Length == 0 ? "a local interface" : string.Join(", ", distinctInterfaces))}; " + + "this can be a same-host IED simulator. Do not infer a competing NIC from IPv4 aliases. " + + "Check the local MMS listener and actual OS route if connection fails."; + if (distinctInterfaces.Length == 0) + return $"No directly matching IPv4 subnet for {target}; a gateway or static route can still provide connectivity. " + + "Check the Windows routing table rather than assuming the endpoint is unreachable."; + if (distinctInterfaces.Length == 1) + return $"One network interface ({distinctInterfaces[0]}) matches {target}, across " + + $"{matchedAddresses.Count} IPv4 address(es). IP aliases are not competing adapters; " + + "confirm actual OS routing and physical/virtual IED connection if needed."; + return $"{distinctInterfaces.Length} distinct network interfaces match {target} " + + $"({string.Join(", ", distinctInterfaces)}); Windows interface metrics/routing table may select " + + "a different path. Check the OS route before changing adapter configuration."; + } + private static AdapterIpv4[] GetActiveIpv4Adapters() { try @@ -517,6 +582,7 @@ private static AdapterIpv4[] GetActiveIpv4Adapters() .SelectMany(adapter => adapter.GetIPProperties().UnicastAddresses .Where(address => address.Address.AddressFamily == AddressFamily.InterNetwork) .Select(address => new AdapterIpv4( + adapter.Id, adapter.Name, adapter.NetworkInterfaceType, address.Address, diff --git a/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs b/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs new file mode 100644 index 000000000..2c256ae77 --- /dev/null +++ b/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Text; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Deterministic IEC-aware decisions: no live NIC, IED, sockets or simulator. +/// Hardware/network routing and physical SOE completeness are separate tests. +/// +public sealed class ContextAwareIec61850DiagnosisTests +{ + private static readonly IPAddress Bcuge = IPAddress.Parse("192.16.1.33"); + + [Fact] + public void SamePhysicalInterfaceWithFiveAliases_DoesNotClaimFiveCompetingNics() + { + var addresses = Enumerable.Range(1, 5) + .Select(_ => (InterfaceId: "KM-TEST-GUID", AdapterName: "KM_Test")).ToArray(); + var description = DiagnosticReportBuilder.ExplainIpv4Route(Bcuge, addresses, false); + Assert.Contains("One network interface (KM_Test)", description); + Assert.Contains("5 IPv4 address(es)", description); + Assert.Contains("not competing adapters", description); + Assert.DoesNotContain("distinct network interfaces", description); + } + + [Fact] + public void DeviceIpIsLocalSimulatorEndpoint_DoesNotRecommendDisablingInterfaces() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, + new[] { (InterfaceId: "KM-TEST-GUID", AdapterName: "KM_Test") }, + targetAssignedLocally: true); + Assert.Contains("assigned to this PC", description); + Assert.Contains("same-host IED simulator", description); + Assert.DoesNotContain("Disable", description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("physically connected", description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DifferentInterfacesWithSameFriendlyName_AreStillARealRoutingAmbiguity() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, + new[] + { + (InterfaceId: "guid-1", AdapterName: "Ethernet"), + (InterfaceId: "guid-2", AdapterName: "Ethernet"), + (InterfaceId: "GUID-1", AdapterName: "Ethernet") + }, + targetAssignedLocally: false); + Assert.Contains("2 distinct network interfaces", description); + Assert.Contains("routing table", description); + } + + [Fact] + public void NoDirectSubnetMatch_DoesNotClaimGatewayRoutingImpossible() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, Array.Empty<(string InterfaceId, string AdapterName)>(), false); + Assert.Contains("gateway or static route can still provide connectivity", description); + Assert.DoesNotContain("unreachable", description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BrcbLateRecurrence_IsNotWronglyExplainedAsStartupOnlyOrGiReset() + { + var activation = new DateTimeOffset(2026, 10, 12, 0, 26, 4, TimeSpan.Zero); + var stream = new Iec61850ReportContinuityStreamSnapshot + { + Rcb = "BCUGEF650/LLN0.BR.BRB_CSWI_GW010101", + Buffered = true, + Frames = 55, + Sequenced = 55, + Findings = 22, + SequenceDiscontinuities = 21, + SequenceWithGiReason = 1, + Overflow = 1, + ActivationReturnedAtUtc = activation, + LastAnomalyReceivedAtUtc = activation.AddSeconds(48.575) + }; + var output = DiagnosticReportBuilder.ExplainBrcbContinuity(stream); + Assert.Contains("GI-tagged=1, withoutConfirmedGI=20", output); + Assert.Contains("BufOvflAdvertised=1", output); + Assert.Contains("lastAnomalyAfterStartMs=48575", output); + Assert.Contains("late recurrence observed beyond 5s", output); + Assert.Contains("not TCP wire order", output); + Assert.DoesNotContain("events were lost", output); + + // Must also surface in ordinary user diagnostic, not only in helpers. + var builder = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(builder, + new Iec61850ReportContinuitySnapshot + { + Frames = 55, Sequenced = 55, Findings = 22, Streams = new[] { stream } + }); + Assert.Contains("BRCB context :", builder.ToString()); + Assert.Contains("NOT startup-only", builder.ToString()); + } + + [Fact] + public void StartupOnlyObservedFrames_AreNotPromotedToCausalCertainty() + { + var activation = DateTimeOffset.UtcNow; + var stream = new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + Findings = 2, + SequenceDiscontinuities = 2, + SequenceWithGiReason = 0, + ActivationReturnedAtUtc = activation, + LastAnomalyReceivedAtUtc = activation.AddMilliseconds(250) + }; + var description = DiagnosticReportBuilder.ExplainBrcbContinuity(stream); + Assert.Contains("lastAnomalyAfterStartMs=250", description); + Assert.DoesNotContain("late recurrence", description); + Assert.Contains("not TCP wire order", description); + Assert.Contains("withoutConfirmedGI=2", description); + } + + [Fact] + public void UnknownOrReversedTimestamps_DoNotFabricateLateRecurrence() + { + var now = DateTimeOffset.UtcNow; + var noTiming = DiagnosticReportBuilder.ExplainBrcbContinuity( + new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + SequenceDiscontinuities = 1 + }); + Assert.Contains("timing=UNKNOWN", noTiming); + var reversed = DiagnosticReportBuilder.ExplainBrcbContinuity( + new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + SequenceDiscontinuities = 1, + ActivationReturnedAtUtc = now, + LastAnomalyReceivedAtUtc = now.AddSeconds(-1) + }); + Assert.Contains("timing=UNKNOWN", reversed); + Assert.DoesNotContain("late recurrence", reversed); + } +} From 14399369abae30d55ec04a9dec051b5679a59e38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 07:40:21 +0700 Subject: [PATCH 92/99] fix(diagnostics): avoid unreachable wording when gateway route is unverified --- Services/DiagnosticReportBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index bcd9e6d94..336332cdd 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -562,7 +562,7 @@ internal static string ExplainIpv4Route( "Check the local MMS listener and actual OS route if connection fails."; if (distinctInterfaces.Length == 0) return $"No directly matching IPv4 subnet for {target}; a gateway or static route can still provide connectivity. " + - "Check the Windows routing table rather than assuming the endpoint is unreachable."; + "Check the Windows routing table before drawing a connectivity conclusion."; if (distinctInterfaces.Length == 1) return $"One network interface ({distinctInterfaces[0]}) matches {target}, across " + $"{matchedAddresses.Count} IPv4 address(es). IP aliases are not competing adapters; " + From 60783e72dbbc884a7267f58f253431a57da2c708 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 13:51:20 +0700 Subject: [PATCH 93/99] feat(p10.9i): qualify bounded canonical report ingress engine under CodeVerified guard --- engines/ARIEC61850.lock.json | 18 +++++++++-- scripts/test-p10-9f-core-routing-trial.ps1 | 31 ++++++++++++++++--- scripts/test-resolve-ariec-lock.ps1 | 12 +++++++ .../P10MainlinePolicyExecutionTests.cs | 18 +++++++++++ 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 811e91def..dedf9f975 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "d700b0df741b466afb8abe6cee1d70b1619d7b4d", - "sourcePullRequest": 165, - "purpose": "P10.9G secondary MMS InformationReport subscriber overload is now fail-closed instead of silently DropOldest; bounded subscriber error leaves canonical static FIFO untouched (ARIEC PR #165). P10.9E client MMS report-routing integrity lock: quarantine contradictory full DatSet/RptID before value projection (PR #164). Also includes P10.9D simulator-only FIFO emission and CI staging PR trigger (PR #163). Previous BCUGE static acquisition remains authority: zero cyclic polling and unchanged decoder, GI, controls and report activation. The simulator FIFO fix is not ARSAS client runtime and does not establish real BCUGE SOE continuity.", + "commit": "9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + "sourcePullRequest": 166, + "purpose": "P10.9I CodeVerified-only canonical InformationReport FIFO ingress protection (ARIEC PR #166, CI #38214165576): bounded by 2048 report envelopes and 32 MiB queued PDU payload, fail closed on saturation or terminal receive pump error while retaining queued FIFO originals. Original P10.9G subscriber isolation and P10.9F RptID/DataSet integrity remain intact. Static configured RCB/GI, decoder, cyclic-polling prohibition, physical R7/R10 authority and unresolved BCUGE SqNum/BufOvfl causality are unchanged.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -84,6 +84,18 @@ "cyclicMmsProcessPollingForbidden": true, "physicalQualificationRequired": true }, + "canonicalReportIngressIntegrityTrial": { + "status": "code-verified-not-physical", + "issue": 536, + "enginePullRequest": 166, + "baseEngineCommit": "d700b0df741b466afb8abe6cee1d70b1619d7b4d", + "exactCommit": "9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + "engineCiRun": 38214165576, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + }, "reportSubscriberIntegrityTrial": { "status": "code-verified-not-physical", "issue": 532, diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 index feb19e196..a16ba7cd5 100644 --- a/scripts/test-p10-9f-core-routing-trial.ps1 +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -1,5 +1,5 @@ # PowerShell 5.1/7 compatible. Code-verification does NOT grant physical acceptance. -# Exact audited code-only candidates (P10.9F and P10.9G); no wildcard SHA, no runtime overrides. +# Exact audited code-only candidates (P10.9F/G/I); no wildcard SHA or runtime overrides. function Test-P109FCoreRoutingTrial { param([Parameter(Mandatory = $true)] [object] $Lock) $candidate = $Lock.coreReportRoutingTrial @@ -10,9 +10,7 @@ function Test-P109FCoreRoutingTrial { ) # P10.9G is a precisely scoped successor to the P10.9F code-only pin, # NOT a promotion to physical or mainline acceptance. - $subscriberHead = ( - [string]$Lock.commit -ceq 'd700b0df741b466afb8abe6cee1d70b1619d7b4d' -and - [int]$Lock.sourcePullRequest -eq 165 -and + $subscriberIntegrityValid = ( $null -ne $subscriber -and [string]$subscriber.status -ceq 'code-verified-not-physical' -and [int]$subscriber.issue -eq 532 -and @@ -25,7 +23,30 @@ function Test-P109FCoreRoutingTrial { [bool]$subscriber.fullDiscoveryFallbackForbidden -and [bool]$subscriber.cyclicMmsProcessPollingForbidden ) - if (-not ($previousHead -or $subscriberHead) -or + $subscriberHead = ( + [string]$Lock.commit -ceq 'd700b0df741b466afb8abe6cee1d70b1619d7b4d' -and + [int]$Lock.sourcePullRequest -eq 165 -and $subscriberIntegrityValid + ) + # P10.9I is allowed only as the exact successor to the independently + # qualified P10.9G engine record; neither entry grants physical authority. + $canonical = $Lock.canonicalReportIngressIntegrityTrial + $canonicalHead = ( + [string]$Lock.commit -ceq '9bd01908ebd6fdf50a74f0a421b4e79613b878d4' -and + [int]$Lock.sourcePullRequest -eq 166 -and + $subscriberIntegrityValid -and + $null -ne $canonical -and + [string]$canonical.status -ceq 'code-verified-not-physical' -and + [int]$canonical.issue -eq 536 -and + [int]$canonical.enginePullRequest -eq 166 -and + [string]$canonical.baseEngineCommit -ceq 'd700b0df741b466afb8abe6cee1d70b1619d7b4d' -and + [string]$canonical.exactCommit -ceq '9bd01908ebd6fdf50a74f0a421b4e79613b878d4' -and + [long]$canonical.engineCiRun -eq 38214165576 -and + [bool]$canonical.physicalQualificationRequired -and + [bool]$canonical.mainlinePromotionForbidden -and + [bool]$canonical.fullDiscoveryFallbackForbidden -and + [bool]$canonical.cyclicMmsProcessPollingForbidden + ) + if (-not ($previousHead -or $subscriberHead -or $canonicalHead) -or [string]$Lock.repository -cne 'masarray/ARIEC61850' -or [string]$Lock.ref -cne 'main' -or $null -eq $candidate) { return $false } diff --git a/scripts/test-resolve-ariec-lock.ps1 b/scripts/test-resolve-ariec-lock.ps1 index d1b0f53cc..071cc51bf 100644 --- a/scripts/test-resolve-ariec-lock.ps1 +++ b/scripts/test-resolve-ariec-lock.ps1 @@ -92,6 +92,18 @@ try { Assert-Resolves (New-TrackedP10Fixture) + $wrongCanonicalCi = New-TrackedP10Fixture + $wrongCanonicalCi.canonicalReportIngressIntegrityTrial.engineCiRun = 0 + Assert-Rejects $wrongCanonicalCi 'physical-tested' + + $wrongCanonicalParent = New-TrackedP10Fixture + $wrongCanonicalParent.canonicalReportIngressIntegrityTrial.baseEngineCommit = ('f' * 40) + Assert-Rejects $wrongCanonicalParent 'physical-tested' + + $wrongCanonicalSafety = New-TrackedP10Fixture + $wrongCanonicalSafety.canonicalReportIngressIntegrityTrial.mainlinePromotionForbidden = $false + Assert-Rejects $wrongCanonicalSafety 'physical-tested' + $wrongP10Parent = New-TrackedP10Fixture $wrongP10Parent.p10IdentityTrial.baseEngineCommit = ('f' * 40) Assert-Rejects $wrongP10Parent 'physical-tested' diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs index bef1f273a..adf2a2af4 100644 --- a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs +++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs @@ -20,6 +20,12 @@ public sealed class P10MainlinePolicyExecutionTests [InlineData("subscriber-trial-ci-denied", false)] [InlineData("subscriber-trial-physical-flag-denied", false)] [InlineData("subscriber-trial-mainline-flag-denied", false)] + [InlineData("canonical-trial-sha-denied", false)] + [InlineData("canonical-trial-parent-denied", false)] + [InlineData("canonical-trial-ci-denied", false)] + [InlineData("canonical-trial-physical-flag-denied", false)] + [InlineData("canonical-trial-mainline-flag-denied", false)] + [InlineData("canonical-trial-polling-flag-denied", false)] [InlineData("p10-pr-main-denied", false)] [InlineData("p10-source-main-denied", false)] [InlineData("p10-wrong-sha-denied", false)] @@ -89,6 +95,18 @@ public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion( lockDoc["reportSubscriberIntegrityTrial"]!["physicalQualificationRequired"] = false; break; case "subscriber-trial-mainline-flag-denied": lockDoc["reportSubscriberIntegrityTrial"]!["mainlinePromotionForbidden"] = false; break; + case "canonical-trial-sha-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["exactCommit"] = new string('0', 40); break; + case "canonical-trial-parent-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "canonical-trial-ci-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["engineCiRun"] = 0; break; + case "canonical-trial-physical-flag-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["physicalQualificationRequired"] = false; break; + case "canonical-trial-mainline-flag-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["mainlinePromotionForbidden"] = false; break; + case "canonical-trial-polling-flag-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["cyclicMmsProcessPollingForbidden"] = false; break; case "p10-source-main-denied": sourceRef = "main"; break; case "p10-wrong-sha-denied": lockDoc["p10IdentityTrial"]!["exactCommit"] = new string('0', 40); break; From 0d00fe332f3e74b138e1ea87c751d3dac64b1370 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 13:56:12 +0700 Subject: [PATCH 94/99] test(provenance): retain P10.9G lineage and assert P10.9I current consumer pin --- .../CanonicalLiveSclExportRegressionTests.cs | 21 ++++++++++++++++--- ...teroperabilityTrialGuardRegressionTests.cs | 17 +++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 2f2511709..0e77a9ff2 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -427,12 +427,27 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter var subscriptionTrial = root.GetProperty("reportSubscriberIntegrityTrial"); Assert.Equal(coreTrial.GetProperty("exactCommit").GetString(), subscriptionTrial.GetProperty("baseEngineCommit").GetString()); - Assert.Equal(subscriptionTrial.GetProperty("exactCommit").GetString(), - root.GetProperty("commit").GetString()); Assert.Equal("code-verified-not-physical", subscriptionTrial.GetProperty("status").GetString()); Assert.True(subscriptionTrial.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(subscriptionTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); - Assert.Equal(165, root.GetProperty("sourcePullRequest").GetInt32()); + // P10.9G remains the immutable CodeVerified ancestor of the current + // P10.9I consumer pin; do not treat the previous SHA as live head. + var canonicalTrial = root.GetProperty("canonicalReportIngressIntegrityTrial"); + Assert.Equal(subscriptionTrial.GetProperty("exactCommit").GetString(), + canonicalTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + canonicalTrial.GetProperty("exactCommit").GetString()); + Assert.Equal(canonicalTrial.GetProperty("exactCommit").GetString(), + root.GetProperty("commit").GetString()); + Assert.Equal("code-verified-not-physical", canonicalTrial.GetProperty("status").GetString()); + Assert.Equal(166, canonicalTrial.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal(536, canonicalTrial.GetProperty("issue").GetInt32()); + Assert.Equal(38214165576L, canonicalTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(canonicalTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); + Assert.Equal(166, root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index 196defff3..ea2e8b3e5 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -76,15 +76,28 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP var subscriberTrial = root.GetProperty("reportSubscriberIntegrityTrial"); Assert.Equal(codeTrial.GetProperty("exactCommit").GetString(), subscriberTrial.GetProperty("baseEngineCommit").GetString()); + var canonicalTrial = root.GetProperty("canonicalReportIngressIntegrityTrial"); + Assert.Equal(subscriberTrial.GetProperty("exactCommit").GetString(), + canonicalTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + canonicalTrial.GetProperty("exactCommit").GetString()); Assert.Equal(root.GetProperty("commit").GetString(), - subscriberTrial.GetProperty("exactCommit").GetString()); + canonicalTrial.GetProperty("exactCommit").GetString()); Assert.Equal("code-verified-not-physical", codeTrial.GetProperty("status").GetString()); Assert.Equal("code-verified-not-physical", subscriberTrial.GetProperty("status").GetString()); Assert.True(subscriberTrial.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(subscriberTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.Equal("code-verified-not-physical", canonicalTrial.GetProperty("status").GetString()); + Assert.Equal(166, canonicalTrial.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal(536, canonicalTrial.GetProperty("issue").GetInt32()); + Assert.Equal(38214165576L, canonicalTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(canonicalTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); Assert.True(codeTrial.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(codeTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); - Assert.Equal(165,root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(166,root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(p10.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); From bf6fcea3d4a33d099ddfa3d1d86ede8d22c1f661 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 14:48:06 +0700 Subject: [PATCH 95/99] fix(control): map BSC TapChg as direction-only Tcmd with verified process feedback --- Models/SignalDefinition.cs | 6 +- Services/Iec61850BscStepControlPolicy.cs | 66 +++++++++++++++ Services/NativeIec61850Client.cs | 34 +++++--- ...icDataSetDualRoleControlRegressionTests.cs | 2 +- .../ZivBscStepControlRegressionTests.cs | 81 +++++++++++++++++++ 5 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 Services/Iec61850BscStepControlPolicy.cs create mode 100644 tests/ARSAS.Tests/ZivBscStepControlRegressionTests.cs diff --git a/Models/SignalDefinition.cs b/Models/SignalDefinition.cs index c52cbed9e..afc068d6b 100644 --- a/Models/SignalDefinition.cs +++ b/Models/SignalDefinition.cs @@ -449,7 +449,7 @@ public bool IsRaiseLowerControl { if (!CanExposeControlActions || IsPositionControl || IsRaiseOnlyControl || IsLowerOnlyControl) return false; var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); - return cdc is "INC" or "ISC" or "INC/ISC"; + return cdc is "INC" or "ISC" or "INC/ISC" or "BSC"; } } public bool IsBooleanControl => CanExposeControlActions && !IsPositionControl && !IsRaiseOnlyControl && !IsLowerOnlyControl && !IsRaiseLowerControl && @@ -460,7 +460,7 @@ public bool IsSetPointControl { if (!CanExposeControlActions || IsPositionControl || IsRaiseOnlyControl || IsLowerOnlyControl || IsRaiseLowerControl || IsBooleanControl) return false; var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); - return cdc is "APC" or "BAC" or "BSC"; + return cdc is "APC" or "BAC"; } } public bool IsGenericControl => !CanExposeControlActions || @@ -478,7 +478,7 @@ public string ControlActionLabel var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); if (cdc == "DPC") return "Open / Close"; if (cdc is "INC" or "ISC") return "Raise / Lower"; - if (cdc == "BSC") return "Tap / step position"; + if (cdc == "BSC") return "Raise / Lower tap"; if (cdc == "SPC") { var commandText = $"{Name} {ObjectReference}"; diff --git a/Services/Iec61850BscStepControlPolicy.cs b/Services/Iec61850BscStepControlPolicy.cs new file mode 100644 index 000000000..52db8948c --- /dev/null +++ b/Services/Iec61850BscStepControlPolicy.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using ArControl = AR.Iec61850.Control; + +namespace ArIED61850Tester.Services; + +/// +/// IEC 61850 BSC ctlVal is directional Tcmd (0 stop, 1 lower, 2 higher, +/// 3 reserved). Not a numeric tap-position setpoint or a DPC Open/Close. +/// No wire request is built without the exact allowed live MMS type. +/// +internal static class Iec61850BscStepControlPolicy +{ + internal static bool TryBind( + string mmsType, int? bitWidth, string command, + out ArControl.Iec61850ControlValue? value, + out string expected, out string error) + { + value = null; + expected = string.Empty; + error = string.Empty; + var supported = mmsType is "integer" or "unsigned" || + (mmsType == "bit-string" && bitWidth == 2); + if (!supported) + { + error = $"BSC Tcmd is not safely supported by live MMS ctlVal type '{mmsType}' (bits={bitWidth}); no control write was sent."; + return false; + } + + var direction = (command ?? string.Empty).Trim().ToLowerInvariant() switch + { + "raise" or "higher" or "up" => 2L, + "lower" or "down" => 1L, + _ => -1L + }; + if (direction < 0) + { + error = "BSC TapChg accepts only Raise or Lower. It does not accept tap-position setpoints, Open/Close, Stop, or reserved Tcmd values."; + return false; + } + + value = mmsType == "unsigned" + ? ArControl.Iec61850ControlValue.Unsigned((ulong)direction) + : ArControl.Iec61850ControlValue.Integer(direction); + expected = direction == 2 ? "Raise" : "Lower"; + return true; + } + + internal static bool MovementConfirmed(string requested, string? before, string? after) + { + if (!TryExactNumericFeedback(before, out var baseline) || + !TryExactNumericFeedback(after, out var observed)) + return false; + + return requested.Trim().ToLowerInvariant() switch + { + "raise" or "higher" or "up" => observed > baseline, + "lower" or "down" => observed < baseline, + _ => false + }; + } + + private static bool TryExactNumericFeedback(string? text, out decimal result) + => decimal.TryParse(text?.Trim(), + NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, out result); +} diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index f8dd38337..a5f2be60c 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1135,7 +1135,7 @@ public async Task InspectControlAsync( var model = MapControlModel(descriptor.ControlModel); var modelText = FriendlyControlModel(descriptor.ControlModel); - var valueType = MapControlValueType(descriptor); + var valueType = MapControlValueType(descriptor, effectiveCdc); signal.ControlCdc = effectiveCdc; signal.ControlValueType = valueType; @@ -1390,7 +1390,7 @@ private Iec61850ControlCapabilities BuildControlCapabilities( ControlModel = MapControlModel(descriptor.ControlModel), ControlModelText = FriendlyControlModel(descriptor.ControlModel), ControlCdc = string.IsNullOrWhiteSpace(effectiveCdc) ? ResolveControlSemanticCdc(descriptor, signal) : effectiveCdc, - ControlValueType = MapControlValueType(descriptor), + ControlValueType = MapControlValueType(descriptor, effectiveCdc), CtlValSignature = descriptor.CtlValSpecification.Signature, CurrentValue = currentValue, CurrentState = "Unknown", @@ -1494,7 +1494,7 @@ private static bool TryBuildNativeControlValue( return false; } - var cdc = (descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant(); + var cdc = ResolveControlSemanticCdc(descriptor, signal).Trim().ToUpperInvariant(); var mmsType = (descriptor.CtlValSpecification.MmsType ?? string.Empty).Trim().ToLowerInvariant(); var normalized = expected.ToLowerInvariant(); var signalText = $"{signal.Name} {signal.ObjectReference}"; @@ -1506,6 +1506,13 @@ private static bool TryBuildNativeControlValue( var isApcStructure = mmsType == "structure" && (ctlChildNames.Contains("f") || ctlChildNames.Contains("i")); + // BSC ctlVal is directional Tcmd, never a tap-position setpoint + // and never a DPC Open/Close command. Validate live encoding first. + if (cdc == "BSC") + return Iec61850BscStepControlPolicy.TryBind( + mmsType, descriptor.CtlValSpecification.Size, + normalized, out value, out expected, out error); + // Some vendor IEDs expose CSWI/XCBR/XSWI.Pos with a Boolean ctlVal while // the process feedback remains the standard double-point Pos.stVal. Preserve // Open/Closed semantics for the user and only adapt the wire value here. @@ -1665,6 +1672,8 @@ private static bool TryParseBooleanControl(string value, out bool result) private static bool ControlFeedbackMatches(string cdc, string expected, string initial, string actual) { var normalizedCdc = (cdc ?? string.Empty).Trim().ToUpperInvariant(); + if (normalizedCdc == "BSC") + return Iec61850BscStepControlPolicy.MovementConfirmed(expected, initial, actual); if (normalizedCdc == "DPC") { return Iec61850ValueFormatter.TryNormalizeDbpos(expected, out var expectedCode) && @@ -1737,14 +1746,14 @@ private static string FriendlyControlSequence(ArControl.Iec61850ControlModel mod _ => "No safe command sequence available" }; - private static string MapControlValueType(ArControl.Iec61850ControlObjectDescriptor descriptor) + private static string MapControlValueType(ArControl.Iec61850ControlObjectDescriptor descriptor, string? semanticCdc = null) { - var cdc = (descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant(); + var cdc = (semanticCdc ?? descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant(); return cdc switch { "DPC" => "Dbpos", "SPC" => "Boolean", - "BSC" => "ValWithTrans", + "BSC" => "Int8", // ST valWTr.posVal is feedback, not an Oper target "APC" => descriptor.CtlValSpecification.MmsType, _ when cdc.Contains("INC", StringComparison.OrdinalIgnoreCase) || cdc.Contains("ISC", StringComparison.OrdinalIgnoreCase) => descriptor.CtlValSpecification.MmsType, @@ -1853,8 +1862,11 @@ private static string InferControlCdc( if (objectName.Contains("TAPOPR") || objectName.Contains("TAPOPL") || objectName.Contains("RAISE") || objectName.Contains("LOWER")) return "SPC"; - if (objectName.Contains("TAPCHG") || objectName.Contains("STEP")) - return "INC"; + // Exact live ValWithTrans status beats name-derived heuristics. + if (hasControlOperation && attributes.Any(attribute => + (attribute.AttributePath ?? string.Empty).EndsWith("valWTr.posVal", StringComparison.OrdinalIgnoreCase) || + (attribute.ObjectReference ?? string.Empty).EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase))) + return "BSC"; var ctlVal = attributes.FirstOrDefault(attribute => (attribute.AttributePath ?? string.Empty).EndsWith("ctlVal", StringComparison.OrdinalIgnoreCase) || @@ -2053,7 +2065,11 @@ private static string NormalizeControlObjectKey(string? reference) private static string ResolveControlSemanticCdc( ArControl.Iec61850ControlObjectDescriptor descriptor, SignalDefinition signal) - => signal.IsPositionControl ? "DPC" : descriptor.Cdc; + => signal.IsPositionControl ? "DPC" + : string.Equals(signal.ControlCdc, "BSC", StringComparison.OrdinalIgnoreCase) && + (signal.ControlStatusReference ?? string.Empty).EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase) + ? "BSC" + : descriptor.Cdc; private static (string Value, ArControl.Iec61850ControlStatusState State) NormalizeControlFeedback( string cdc, diff --git a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs index 9b835b863..a19399fee 100644 --- a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs @@ -50,7 +50,7 @@ public void PositionControlAndStatusRemainSeparateRuntimeFacets() [InlineData("ISC", "IEDCTRL/ATCC1.StepCmd", "regulating")] [InlineData("APC", "IEDCTRL/AVCO1.VRef", "setpoint")] [InlineData("BAC", "IEDCTRL/GAPC1.AnCtl", "setpoint")] - [InlineData("BSC", "IEDCTRL/ATCC1.TapPos", "setpoint")] + [InlineData("BSC", "IEDCTRL/ATCC1.TapPos", "regulating")] public void ExistingCommandSurface_CoversStandardControlFamilies( string cdc, string reference, diff --git a/tests/ARSAS.Tests/ZivBscStepControlRegressionTests.cs b/tests/ARSAS.Tests/ZivBscStepControlRegressionTests.cs new file mode 100644 index 000000000..602afeaaf --- /dev/null +++ b/tests/ARSAS.Tests/ZivBscStepControlRegressionTests.cs @@ -0,0 +1,81 @@ +using AR.Iec61850.Control; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ZivBscStepControlRegressionTests +{ + [Theory] + [InlineData("raise", 2L)] + [InlineData("higher", 2L)] + [InlineData("up", 2L)] + [InlineData("lower", 1L)] + [InlineData("down", 1L)] + public void StandardBscDirection_ProducesExactTcmd_NotDpc(string input, long expectedCode) + { + Assert.True(Iec61850BscStepControlPolicy.TryBind( + "bit-string", 2, input, out var command, out var expected, out var error), error); + Assert.Equal(Iec61850ControlValueKind.Integer, command!.Kind); + Assert.Equal(expectedCode, command.Value); + Assert.Equal(expectedCode == 2 ? "Raise" : "Lower", expected); + } + + [Theory] + [InlineData("open")] + [InlineData("close")] + [InlineData("12")] + [InlineData("-40")] + [InlineData("stop")] + [InlineData("3")] + [InlineData("reserved")] + public void RejectsUnqualifiedBscCommands_BeforeWire(string input) + { + Assert.False(Iec61850BscStepControlPolicy.TryBind( + "bit-string", 2, input, out var value, out _, out var error)); + Assert.Null(value); + Assert.Contains("only Raise or Lower", error); + } + + [Theory] + [InlineData("bit-string", 3)] + [InlineData("structure", 2)] + [InlineData("boolean", 0)] + public void UnsupportedLiveShape_FailsClosed(string type, int bits) + { + Assert.False(Iec61850BscStepControlPolicy.TryBind( + type, bits, "raise", out _, out _, out var error)); + Assert.Contains("not safely supported", error); + } + + [Fact] + public void FeedbackRequiresExactDirectionalNumericMovement() + { + Assert.True(Iec61850BscStepControlPolicy.MovementConfirmed("Raise", "12", "13")); + Assert.True(Iec61850BscStepControlPolicy.MovementConfirmed("Lower", "12", "11")); + Assert.False(Iec61850BscStepControlPolicy.MovementConfirmed("Raise", "12", "12")); + Assert.False(Iec61850BscStepControlPolicy.MovementConfirmed("Raise", "Unknown", "13")); + Assert.False(Iec61850BscStepControlPolicy.MovementConfirmed("Lower", "12", "bad")); + Assert.False(Iec61850BscStepControlPolicy.MovementConfirmed("Lower", "12", "13")); + } + + [Fact] + public void BscShowsRaiseLowerNotSwitchNorSetpoint() + { + var signal = new SignalDefinition + { + Name = "TapChg", + ObjectReference = "ZIVAVRLD1/ATCC1.TapChg", + FunctionalConstraint = "CO", + DataType = "BSC", + Category = "Control", + ControlCdc = "BSC", + ControlStatusReference = "ZIVAVRLD1/ATCC1.TapChg.valWTr.posVal", + IsControlSignal = true + }; + Assert.True(signal.IsRaiseLowerControl); + Assert.False(signal.IsPositionControl); + Assert.False(signal.IsSetPointControl); + Assert.Contains("Raise / Lower", signal.ControlActionLabel); + } +} From 858289e3ddf38414ddf272ec3b8e5602fb737222 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 14:51:25 +0700 Subject: [PATCH 96/99] feat(p10.9j): pin ZIV SCL/BSC engine under exact code-only lineage and negative CI gates --- engines/ARIEC61850.lock.json | 20 +++++++++-- scripts/test-p10-9f-core-routing-trial.ps1 | 33 ++++++++++++++++--- scripts/test-resolve-ariec-lock.ps1 | 16 +++++++++ .../CanonicalLiveSclExportRegressionTests.cs | 17 +++++++++- .../P10MainlinePolicyExecutionTests.cs | 21 ++++++++++++ ...teroperabilityTrialGuardRegressionTests.cs | 17 ++++++++-- 6 files changed, 113 insertions(+), 11 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index dedf9f975..65ee73d7b 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "9bd01908ebd6fdf50a74f0a421b4e79613b878d4", - "sourcePullRequest": 166, - "purpose": "P10.9I CodeVerified-only canonical InformationReport FIFO ingress protection (ARIEC PR #166, CI #38214165576): bounded by 2048 report envelopes and 32 MiB queued PDU payload, fail closed on saturation or terminal receive pump error while retaining queued FIFO originals. Original P10.9G subscriber isolation and P10.9F RptID/DataSet integrity remain intact. Static configured RCB/GI, decoder, cyclic-polling prohibition, physical R7/R10 authority and unresolved BCUGE SqNum/BufOvfl causality are unchanged.", + "commit": "67c6635b7e16139c0661e559a2e6946527c1442a", + "sourcePullRequest": 167, + "purpose": "P10.9J CodeVerified-only: trusted SCL 41-packed AP-Title compatibility and exact BSC TapChg control classification (ARIEC PR #167, CI 38219429896). Consumer BSC Tcmd Raise/Lower is explicitly typed and fail-closed; no unverified physical control acceptance. Preserves immutable P10.9I canonical MMS FIFO protection, no SCL discovery fallback, no cyclic polling, and historical R7/R10 authority.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -84,6 +84,20 @@ "cyclicMmsProcessPollingForbidden": true, "physicalQualificationRequired": true }, + "zivSclBscInteroperabilityTrial": { + "status": "code-verified-not-physical", + "issue": 538, + "enginePullRequest": 167, + "baseEngineCommit": "9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + "exactCommit": "67c6635b7e16139c0661e559a2e6946527c1442a", + "engineCiRun": 38219429896, + "sclApTitlePackedPrefixOnly": true, + "bscLiveSemanticGateRequired": true, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + }, "canonicalReportIngressIntegrityTrial": { "status": "code-verified-not-physical", "issue": 536, diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 index a16ba7cd5..a11500691 100644 --- a/scripts/test-p10-9f-core-routing-trial.ps1 +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -1,5 +1,5 @@ # PowerShell 5.1/7 compatible. Code-verification does NOT grant physical acceptance. -# Exact audited code-only candidates (P10.9F/G/I); no wildcard SHA or runtime overrides. +# Exact audited code-only candidates (P10.9F/G/I/J); no wildcard SHA or runtime overrides. function Test-P109FCoreRoutingTrial { param([Parameter(Mandatory = $true)] [object] $Lock) $candidate = $Lock.coreReportRoutingTrial @@ -30,9 +30,7 @@ function Test-P109FCoreRoutingTrial { # P10.9I is allowed only as the exact successor to the independently # qualified P10.9G engine record; neither entry grants physical authority. $canonical = $Lock.canonicalReportIngressIntegrityTrial - $canonicalHead = ( - [string]$Lock.commit -ceq '9bd01908ebd6fdf50a74f0a421b4e79613b878d4' -and - [int]$Lock.sourcePullRequest -eq 166 -and + $canonicalIntegrityValid = ( $subscriberIntegrityValid -and $null -ne $canonical -and [string]$canonical.status -ceq 'code-verified-not-physical' -and @@ -46,7 +44,32 @@ function Test-P109FCoreRoutingTrial { [bool]$canonical.fullDiscoveryFallbackForbidden -and [bool]$canonical.cyclicMmsProcessPollingForbidden ) - if (-not ($previousHead -or $subscriberHead -or $canonicalHead) -or + $canonicalHead = ( + [string]$Lock.commit -ceq '9bd01908ebd6fdf50a74f0a421b4e79613b878d4' -and + [int]$Lock.sourcePullRequest -eq 166 -and $canonicalIntegrityValid + ) + # P10.9J is admitted only if P10.9I still satisfies *every* explicit + # historical guard; neither the valid OID notation nor BSC metadata + # grants production/physical authority. + $ziv = $Lock.zivSclBscInteroperabilityTrial + $zivHead = ( + [string]$Lock.commit -ceq '67c6635b7e16139c0661e559a2e6946527c1442a' -and + [int]$Lock.sourcePullRequest -eq 167 -and + $canonicalIntegrityValid -and $null -ne $ziv -and + [string]$ziv.status -ceq 'code-verified-not-physical' -and + [int]$ziv.issue -eq 538 -and + [int]$ziv.enginePullRequest -eq 167 -and + [string]$ziv.baseEngineCommit -ceq '9bd01908ebd6fdf50a74f0a421b4e79613b878d4' -and + [string]$ziv.exactCommit -ceq '67c6635b7e16139c0661e559a2e6946527c1442a' -and + [long]$ziv.engineCiRun -eq 38219429896 -and + [bool]$ziv.sclApTitlePackedPrefixOnly -and + [bool]$ziv.bscLiveSemanticGateRequired -and + [bool]$ziv.physicalQualificationRequired -and + [bool]$ziv.mainlinePromotionForbidden -and + [bool]$ziv.fullDiscoveryFallbackForbidden -and + [bool]$ziv.cyclicMmsProcessPollingForbidden + ) + if (-not ($previousHead -or $subscriberHead -or $canonicalHead -or $zivHead) -or [string]$Lock.repository -cne 'masarray/ARIEC61850' -or [string]$Lock.ref -cne 'main' -or $null -eq $candidate) { return $false } diff --git a/scripts/test-resolve-ariec-lock.ps1 b/scripts/test-resolve-ariec-lock.ps1 index 071cc51bf..600d021c4 100644 --- a/scripts/test-resolve-ariec-lock.ps1 +++ b/scripts/test-resolve-ariec-lock.ps1 @@ -104,6 +104,22 @@ try { $wrongCanonicalSafety.canonicalReportIngressIntegrityTrial.mainlinePromotionForbidden = $false Assert-Rejects $wrongCanonicalSafety 'physical-tested' + $wrongZivSha = New-TrackedP10Fixture + $wrongZivSha.zivSclBscInteroperabilityTrial.exactCommit = ('f' * 40) + Assert-Rejects $wrongZivSha 'physical-tested' + + $wrongZivCi = New-TrackedP10Fixture + $wrongZivCi.zivSclBscInteroperabilityTrial.engineCiRun = 0 + Assert-Rejects $wrongZivCi 'physical-tested' + + $wrongZivBase = New-TrackedP10Fixture + $wrongZivBase.zivSclBscInteroperabilityTrial.baseEngineCommit = ('f' * 40) + Assert-Rejects $wrongZivBase 'physical-tested' + + $wrongZivScl = New-TrackedP10Fixture + $wrongZivScl.zivSclBscInteroperabilityTrial.sclApTitlePackedPrefixOnly = $false + Assert-Rejects $wrongZivScl 'physical-tested' + $wrongP10Parent = New-TrackedP10Fixture $wrongP10Parent.p10IdentityTrial.baseEngineCommit = ('f' * 40) Assert-Rejects $wrongP10Parent 'physical-tested' diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 0e77a9ff2..60a88ad90 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -437,7 +437,12 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter canonicalTrial.GetProperty("baseEngineCommit").GetString()); Assert.Equal("9bd01908ebd6fdf50a74f0a421b4e79613b878d4", canonicalTrial.GetProperty("exactCommit").GetString()); + var zivTrial = root.GetProperty("zivSclBscInteroperabilityTrial"); Assert.Equal(canonicalTrial.GetProperty("exactCommit").GetString(), + zivTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("67c6635b7e16139c0661e559a2e6946527c1442a", + zivTrial.GetProperty("exactCommit").GetString()); + Assert.Equal(zivTrial.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); Assert.Equal("code-verified-not-physical", canonicalTrial.GetProperty("status").GetString()); Assert.Equal(166, canonicalTrial.GetProperty("enginePullRequest").GetInt32()); @@ -447,7 +452,17 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter Assert.True(canonicalTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); Assert.True(canonicalTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); Assert.True(canonicalTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); - Assert.Equal(166, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("code-verified-not-physical", zivTrial.GetProperty("status").GetString()); + Assert.Equal(538, zivTrial.GetProperty("issue").GetInt32()); + Assert.Equal(167, zivTrial.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal(38219429896L, zivTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(zivTrial.GetProperty("sclApTitlePackedPrefixOnly").GetBoolean()); + Assert.True(zivTrial.GetProperty("bscLiveSemanticGateRequired").GetBoolean()); + Assert.True(zivTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); + Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); + Assert.Equal(167, root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs index adf2a2af4..3a5724bbc 100644 --- a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs +++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs @@ -26,6 +26,13 @@ public sealed class P10MainlinePolicyExecutionTests [InlineData("canonical-trial-physical-flag-denied", false)] [InlineData("canonical-trial-mainline-flag-denied", false)] [InlineData("canonical-trial-polling-flag-denied", false)] + [InlineData("ziv-trial-sha-denied", false)] + [InlineData("ziv-trial-base-denied", false)] + [InlineData("ziv-trial-ci-denied", false)] + [InlineData("ziv-trial-physical-denied", false)] + [InlineData("ziv-trial-mainline-denied", false)] + [InlineData("ziv-trial-polling-denied", false)] + [InlineData("ziv-trial-bsc-gate-denied", false)] [InlineData("p10-pr-main-denied", false)] [InlineData("p10-source-main-denied", false)] [InlineData("p10-wrong-sha-denied", false)] @@ -107,6 +114,20 @@ public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion( lockDoc["canonicalReportIngressIntegrityTrial"]!["mainlinePromotionForbidden"] = false; break; case "canonical-trial-polling-flag-denied": lockDoc["canonicalReportIngressIntegrityTrial"]!["cyclicMmsProcessPollingForbidden"] = false; break; + case "ziv-trial-sha-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["exactCommit"] = new string('0', 40); break; + case "ziv-trial-base-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "ziv-trial-ci-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["engineCiRun"] = 0; break; + case "ziv-trial-physical-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["physicalQualificationRequired"] = false; break; + case "ziv-trial-mainline-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["mainlinePromotionForbidden"] = false; break; + case "ziv-trial-polling-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["cyclicMmsProcessPollingForbidden"] = false; break; + case "ziv-trial-bsc-gate-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["bscLiveSemanticGateRequired"] = false; break; case "p10-source-main-denied": sourceRef = "main"; break; case "p10-wrong-sha-denied": lockDoc["p10IdentityTrial"]!["exactCommit"] = new string('0', 40); break; diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index ea2e8b3e5..5110430d9 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -81,8 +81,11 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP canonicalTrial.GetProperty("baseEngineCommit").GetString()); Assert.Equal("9bd01908ebd6fdf50a74f0a421b4e79613b878d4", canonicalTrial.GetProperty("exactCommit").GetString()); + var zivTrial = root.GetProperty("zivSclBscInteroperabilityTrial"); + Assert.Equal(canonicalTrial.GetProperty("exactCommit").GetString(), + zivTrial.GetProperty("baseEngineCommit").GetString()); Assert.Equal(root.GetProperty("commit").GetString(), - canonicalTrial.GetProperty("exactCommit").GetString()); + zivTrial.GetProperty("exactCommit").GetString()); Assert.Equal("code-verified-not-physical", codeTrial.GetProperty("status").GetString()); Assert.Equal("code-verified-not-physical", subscriberTrial.GetProperty("status").GetString()); Assert.True(subscriberTrial.GetProperty("physicalQualificationRequired").GetBoolean()); @@ -97,7 +100,17 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP Assert.True(canonicalTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); Assert.True(codeTrial.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(codeTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); - Assert.Equal(166,root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("code-verified-not-physical", zivTrial.GetProperty("status").GetString()); + Assert.Equal(538, zivTrial.GetProperty("issue").GetInt32()); + Assert.Equal(167, zivTrial.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal(38219429896L, zivTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(zivTrial.GetProperty("sclApTitlePackedPrefixOnly").GetBoolean()); + Assert.True(zivTrial.GetProperty("bscLiveSemanticGateRequired").GetBoolean()); + Assert.True(zivTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); + Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); + Assert.Equal(167,root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(p10.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); From 6d4c953677416b32417611c9ad918c56911a8f99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 16:48:24 +0700 Subject: [PATCH 97/99] P10.9K-A: fail-closed CDC semantics, safe control UI and feedback --- ControlCommandWindow.xaml.cs | 18 ++-- Models/SignalDefinition.cs | 21 ++++- Services/NativeIec61850Client.cs | 91 ++++++++----------- .../P109KGenericControlSafetyTests.cs | 42 +++++++++ ...icDataSetDualRoleControlRegressionTests.cs | 4 +- 5 files changed, 103 insertions(+), 73 deletions(-) create mode 100644 tests/ARSAS.Tests/P109KGenericControlSafetyTests.cs diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 5e9b0517e..dfdbf3ea4 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -139,7 +139,7 @@ private async Task InspectAsync() CommandStage = capabilities.SupportsOperate ? "Ready" : "Control unavailable"; CommandStatus = capabilities.SupportsOperate ? $"{capabilities.ControlModelText}. {capabilities.SequenceText}. Select a command value and required check flags." - : "The IED reports status-only or an unknown ctlModel. ArIED will not guess an operating sequence."; + : capabilities.EngineControlServiceStatus; SetResultTone(capabilities.SupportsOperate ? "Ready" : "Error"); } } @@ -243,19 +243,13 @@ private void PopulateValueOptions(string cdc, string currentValue) case "INC": case "ISC": case "INC/ISC": - ValueOptions.Add("Raise"); - ValueOptions.Add("Lower"); + if (long.TryParse(currentValue, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var numericValue)) + ValueOptions.Add(numericValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); break; case "BSC": - if (TryExtractNumber(currentValue, out var step)) - { - ValueOptions.Add(Math.Round(step - 1d).ToString(System.Globalization.CultureInfo.InvariantCulture)); - ValueOptions.Add(Math.Round(step + 1d).ToString(System.Globalization.CultureInfo.InvariantCulture)); - } - else - { - ValueOptions.Add("0"); - } + ValueOptions.Add("Raise"); + ValueOptions.Add("Lower"); break; case "APC": ValueOptions.Add(TryExtractNumber(currentValue, out var analogue) diff --git a/Models/SignalDefinition.cs b/Models/SignalDefinition.cs index afc068d6b..b6c1aa4c2 100644 --- a/Models/SignalDefinition.cs +++ b/Models/SignalDefinition.cs @@ -21,6 +21,7 @@ public class SignalDefinition : ObservableObject private string _controlModelText = "Auto-detect"; private Iec61850ControlModelKind _controlModelKind = Iec61850ControlModelKind.Unknown; private bool _controlModelResolved; + private bool _controlSemanticVerified; private string _controlValueType = string.Empty; private string _controlCurrentValue = "-"; private Iec61850ProcessValueBadge? _controlProcessValue; @@ -120,7 +121,19 @@ public string ControlModelText } public Iec61850ControlModelKind ControlModelKind => _controlModelKind; public bool ControlModelResolved => _controlModelResolved; - public bool ControlSupportsOperate => _controlModelResolved && _controlModelKind is + public bool ControlSemanticVerified + { + get => _controlSemanticVerified; + set + { + if (!Set(ref _controlSemanticVerified, value)) return; + Raise(nameof(ControlSupportsOperate)); + Raise(nameof(ControlCanConfirm)); + Raise(nameof(IsReadOnlyControl)); + RaiseControlActionProperties(); + } + } + public bool ControlSupportsOperate => _controlModelResolved && _controlSemanticVerified && _controlModelKind is Iec61850ControlModelKind.DirectNormal or Iec61850ControlModelKind.SboNormal or Iec61850ControlModelKind.DirectEnhanced or @@ -449,7 +462,7 @@ public bool IsRaiseLowerControl { if (!CanExposeControlActions || IsPositionControl || IsRaiseOnlyControl || IsLowerOnlyControl) return false; var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); - return cdc is "INC" or "ISC" or "INC/ISC" or "BSC"; + return cdc == "BSC"; } } public bool IsBooleanControl => CanExposeControlActions && !IsPositionControl && !IsRaiseOnlyControl && !IsLowerOnlyControl && !IsRaiseLowerControl && @@ -460,7 +473,7 @@ public bool IsSetPointControl { if (!CanExposeControlActions || IsPositionControl || IsRaiseOnlyControl || IsLowerOnlyControl || IsRaiseLowerControl || IsBooleanControl) return false; var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); - return cdc is "APC" or "BAC"; + return cdc is "APC" or "BAC" or "INC" or "ISC"; } } public bool IsGenericControl => !CanExposeControlActions || @@ -477,7 +490,7 @@ public string ControlActionLabel if (IsLowerOnlyControl) return "Lower"; var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); if (cdc == "DPC") return "Open / Close"; - if (cdc is "INC" or "ISC") return "Raise / Lower"; + if (cdc is "INC" or "ISC") return "Numeric control"; if (cdc == "BSC") return "Raise / Lower tap"; if (cdc == "SPC") { diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index a5f2be60c..8ab80c5c0 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1098,7 +1098,9 @@ public async Task InspectControlAsync( var control = await GetOrOpenControlSessionAsync(signal, cancellationToken).ConfigureAwait(false); var descriptor = control.Descriptor; - var effectiveCdc = ResolveControlSemanticCdc(descriptor, signal); + var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve( + descriptor, signal.ControlCdc, signal.ControlStatusReference); + var effectiveCdc = semantic.Cdc; var status = await RunMmsOperationAsync( () => control.ReadStatusAsync(cancellationToken), cancellationToken).ConfigureAwait(false); @@ -1138,6 +1140,7 @@ public async Task InspectControlAsync( var valueType = MapControlValueType(descriptor, effectiveCdc); signal.ControlCdc = effectiveCdc; + signal.ControlSemanticVerified = semantic.IsCommandSafe; signal.ControlValueType = valueType; signal.ControlCurrentValue = currentValue; signal.ControlStatusReference = statusReference; @@ -1157,16 +1160,16 @@ public async Task InspectControlAsync( CurrentValue = currentValue, CurrentState = currentState, EngineControlServiceAvailable = true, - IsOperationallyReady = descriptor.IsOperationallyReady, + IsOperationallyReady = descriptor.IsOperationallyReady && semantic.IsCommandSafe, SupportsCommandTermination = descriptor.SupportsCommandTermination, SupportsTimeActivatedOperate = descriptor.SupportsTimeActivatedOperate, SboTimeoutText = FormatControlTimeout(descriptor.SboTimeout), OperTimeoutText = FormatControlTimeout(descriptor.OperTimeout), SequenceText = FriendlyControlSequence(descriptor.ControlModel), DiscoveryEvidence = descriptor.DiscoveryEvidence, - EngineControlServiceStatus = descriptor.IsOperationallyReady - ? "ARIEC61850 Smart Control is ready. Exact live ctlVal typing and the required Direct/SBO sequence were validated." - : "ARIEC61850 found the control object, but the live descriptor is incomplete and command execution remains blocked." + EngineControlServiceStatus = semantic.IsCommandSafe + ? "ARIEC61850 Smart Control is ready. CDC, live ctlVal, status and Direct/SBO semantics agree." + : semantic.Reason }; } @@ -1198,8 +1201,15 @@ private async Task ExecuteControlCoreAsync( var totalStopwatch = Stopwatch.StartNew(); var control = await GetOrOpenControlSessionAsync(request.Signal, cancellationToken).ConfigureAwait(false); var descriptor = control.Descriptor; - var effectiveCdc = ResolveControlSemanticCdc(descriptor, request.Signal); + var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve( + descriptor, request.Signal.ControlCdc, request.Signal.ControlStatusReference); + var effectiveCdc = semantic.Cdc; + request.Signal.ControlSemanticVerified = semantic.IsCommandSafe; var capabilities = BuildControlCapabilities(descriptor, request.Signal, "-", effectiveCdc); + if (!semantic.IsCommandSafe) + return ControlFailure("Validation", + "Unsafe or ambiguous IEC 61850 control semantics: " + semantic.Reason, + capabilities, request.ValueText); if (!descriptor.IsOperationallyReady) { return ControlFailure( @@ -1379,6 +1389,8 @@ private Iec61850ControlCapabilities BuildControlCapabilities( string currentValue, string? effectiveCdc = null) { + var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve( + descriptor, signal.ControlCdc, signal.ControlStatusReference); var statusReference = !string.IsNullOrWhiteSpace(signal.ControlStatusReference) ? signal.ControlStatusReference : descriptor.StatusReference; @@ -1395,16 +1407,16 @@ private Iec61850ControlCapabilities BuildControlCapabilities( CurrentValue = currentValue, CurrentState = "Unknown", EngineControlServiceAvailable = true, - IsOperationallyReady = descriptor.IsOperationallyReady, + IsOperationallyReady = descriptor.IsOperationallyReady && semantic.IsCommandSafe, SupportsCommandTermination = descriptor.SupportsCommandTermination, SupportsTimeActivatedOperate = descriptor.SupportsTimeActivatedOperate, SboTimeoutText = FormatControlTimeout(descriptor.SboTimeout), OperTimeoutText = FormatControlTimeout(descriptor.OperTimeout), SequenceText = FriendlyControlSequence(descriptor.ControlModel), DiscoveryEvidence = descriptor.DiscoveryEvidence, - EngineControlServiceStatus = descriptor.IsOperationallyReady + EngineControlServiceStatus = semantic.IsCommandSafe ? "ARIEC61850 Smart Control is ready." - : "The native Smart Control descriptor is incomplete." + : semantic.Reason }; } @@ -1501,8 +1513,6 @@ private static bool TryBuildNativeControlValue( var ctlChildNames = descriptor.CtlValSpecification.Children .Select(child => new string((child.Name ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray())) .ToArray(); - var isBscStructure = mmsType == "structure" && - (ctlChildNames.Contains("posval") || ctlChildNames.Contains("transind")); var isApcStructure = mmsType == "structure" && (ctlChildNames.Contains("f") || ctlChildNames.Contains("i")); @@ -1516,16 +1526,16 @@ private static bool TryBuildNativeControlValue( // Some vendor IEDs expose CSWI/XCBR/XSWI.Pos with a Boolean ctlVal while // the process feedback remains the standard double-point Pos.stVal. Preserve // Open/Closed semantics for the user and only adapt the wire value here. - if (signal.IsPositionControl && mmsType == "boolean") + if (cdc == "DPC" && signal.IsPositionControl && mmsType == "boolean") { - if (normalized.Contains("open") || normalized is "off" or "false" or "0" or "01") + if (normalized is "open" or "open [01]" or "off" or "false" or "0" or "01") { value = ArControl.Iec61850ControlValue.Boolean(false); expected = "Open [01]"; expectedState = ArControl.Iec61850ControlStatusState.Open; return true; } - if (normalized.Contains("close") || normalized.Contains("closed") || normalized is "on" or "true" or "1" or "10") + if (normalized is "close" or "closed" or "closed [10]" or "on" or "true" or "1" or "10") { value = ArControl.Iec61850ControlValue.Boolean(true); expected = "Closed [10]"; @@ -1536,16 +1546,16 @@ private static bool TryBuildNativeControlValue( return false; } - if (cdc == "DPC" || (mmsType == "bit-string" && descriptor.CtlValSpecification.Size == 2)) + if (cdc == "DPC" && mmsType == "bit-string" && descriptor.CtlValSpecification.Size == 2) { - if (normalized.Contains("open") || normalized is "off" or "1" or "01") + if (normalized is "open" or "open [01]" or "off" or "1" or "01") { value = ArControl.Iec61850ControlValue.Open(); expected = "Open [01]"; expectedState = ArControl.Iec61850ControlStatusState.Open; return true; } - if (normalized.Contains("close") || normalized.Contains("closed") || normalized is "on" or "2" or "10") + if (normalized is "close" or "closed" or "closed [10]" or "on" or "2" or "10") { value = ArControl.Iec61850ControlValue.Close(); expected = "Closed [10]"; @@ -1556,19 +1566,19 @@ private static bool TryBuildNativeControlValue( return false; } - if (cdc == "SPC" || mmsType == "boolean") + if (cdc == "SPC" && mmsType == "boolean") { var isPulseRaise = signalText.Contains("TapOpR", StringComparison.OrdinalIgnoreCase) || signalText.Contains("Raise", StringComparison.OrdinalIgnoreCase); var isPulseLower = signalText.Contains("TapOpL", StringComparison.OrdinalIgnoreCase) || signalText.Contains("Lower", StringComparison.OrdinalIgnoreCase); - if (normalized is "raise" or "up" || isPulseRaise) + if ((normalized is "raise" or "up") && isPulseRaise) { value = ArControl.Iec61850ControlValue.On(); expected = "Raise"; return true; } - if (normalized is "lower" or "down" || isPulseLower) + if ((normalized is "lower" or "down") && isPulseLower) { value = ArControl.Iec61850ControlValue.On(); expected = "Lower"; @@ -1587,22 +1597,8 @@ private static bool TryBuildNativeControlValue( return false; } - if (cdc.Contains("INC", StringComparison.OrdinalIgnoreCase) || - cdc.Contains("ISC", StringComparison.OrdinalIgnoreCase) || - mmsType is "integer" or "unsigned" or "bcd") + if ((cdc is "INC" or "ISC") && (mmsType is "integer" or "unsigned" or "bcd")) { - if (normalized is "raise" or "up") - { - value = ArControl.Iec61850ControlValue.Raise(); - expected = "Raise"; - return true; - } - if (normalized is "lower" or "down") - { - value = ArControl.Iec61850ControlValue.Lower(); - expected = "Lower"; - return true; - } if (long.TryParse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer)) { if (mmsType == "unsigned") @@ -1621,23 +1617,11 @@ private static bool TryBuildNativeControlValue( expected = integer.ToString(CultureInfo.InvariantCulture); return true; } - error = "Integer control requires Raise, Lower, or a whole-number target."; - return false; - } - - if (cdc == "BSC" || isBscStructure) - { - if (long.TryParse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture, out var position)) - { - value = ArControl.Iec61850ControlValue.StepPosition(position); - expected = position.ToString(CultureInfo.InvariantCulture); - return true; - } - error = "BSC command requires a numeric target position."; + error = "Integer control requires an explicitly selected whole-number target."; return false; } - if (cdc == "APC" || mmsType == "floating-point" || isApcStructure) + if (cdc == "APC" && (mmsType == "floating-point" || isApcStructure)) { var numeric = normalized.Replace(',', '.'); if (double.TryParse(numeric, NumberStyles.Float, CultureInfo.InvariantCulture, out var analogue)) @@ -1686,7 +1670,7 @@ private static bool ControlFeedbackMatches(string cdc, string expected, string i { if (TryExtractNumber(initial, out var before) && TryExtractNumber(actual, out var after)) return expectedText is "raise" or "up" ? after > before : after < before; - return !string.Equals(initial?.Trim(), actual?.Trim(), StringComparison.OrdinalIgnoreCase); + return false; // Arbitrary changed text is not directional evidence. } if (normalizedCdc == "SPC") @@ -2065,11 +2049,8 @@ private static string NormalizeControlObjectKey(string? reference) private static string ResolveControlSemanticCdc( ArControl.Iec61850ControlObjectDescriptor descriptor, SignalDefinition signal) - => signal.IsPositionControl ? "DPC" - : string.Equals(signal.ControlCdc, "BSC", StringComparison.OrdinalIgnoreCase) && - (signal.ControlStatusReference ?? string.Empty).EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase) - ? "BSC" - : descriptor.Cdc; + => ArControl.Iec61850ControlSemanticResolver.Resolve( + descriptor, signal.ControlCdc, signal.ControlStatusReference).Cdc; private static (string Value, ArControl.Iec61850ControlStatusState State) NormalizeControlFeedback( string cdc, diff --git a/tests/ARSAS.Tests/P109KGenericControlSafetyTests.cs b/tests/ARSAS.Tests/P109KGenericControlSafetyTests.cs new file mode 100644 index 000000000..09cc7f204 --- /dev/null +++ b/tests/ARSAS.Tests/P109KGenericControlSafetyTests.cs @@ -0,0 +1,42 @@ +using AR.Iec61850.Control; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class P109KGenericControlSafetyTests +{ + [Fact] + public void ControlModelAloneNeverEnablesUnverifiedCommand() + { + var signal = new SignalDefinition { IsControlSignal = true, ObjectReference = "LD/CSWI1.Pos" }; + Assert.False(signal.ControlSupportsOperate); + Assert.False(signal.ControlSemanticVerified); + } + + [Theory] + [InlineData("BSC", true, false)] + [InlineData("INC", false, true)] + [InlineData("ISC", false, true)] + public void DistinctCdcOperatorActions(string cdc, bool directional, bool numeric) + { + var signal = new SignalDefinition { + Name = "Control", ObjectReference = "LD/ATCC1.Command", + FunctionalConstraint = "CO", IsControlSignal = true, ControlCdc = cdc + }; + Assert.Equal(directional, signal.IsRaiseLowerControl); + Assert.Equal(numeric, signal.IsSetPointControl); + } + + [Fact] + public void ClaimedBscCannotOverrideLiveDpc() + { + var descriptor = new Iec61850ControlObjectDescriptor { + Cdc = "DPC", ObjectReference = "LD/ATCC1.TapChg", + StatusReference = "LD/ATCC1.TapChg.stVal", + ControlModel = Iec61850ControlModel.DirectNormal, + CtlValSpecification = new MmsTypeSpecificationNode { MmsType = "bit-string", Size = 2 } + }; + Assert.False(Iec61850ControlSemanticResolver.Resolve(descriptor, "BSC").IsCommandSafe); + } +} diff --git a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs index a19399fee..ecc12faf0 100644 --- a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs @@ -46,8 +46,8 @@ public void PositionControlAndStatusRemainSeparateRuntimeFacets() [Theory] [InlineData("SPC", "IEDCTRL/GGIO1.Enable", "boolean")] [InlineData("DPC", "IEDQ0/CSWI1.Pos", "position")] - [InlineData("INC", "IEDCTRL/ATCC1.TapCmd", "regulating")] - [InlineData("ISC", "IEDCTRL/ATCC1.StepCmd", "regulating")] + [InlineData("INC", "IEDCTRL/ATCC1.TapCmd", "setpoint")] + [InlineData("ISC", "IEDCTRL/ATCC1.StepCmd", "setpoint")] [InlineData("APC", "IEDCTRL/AVCO1.VRef", "setpoint")] [InlineData("BAC", "IEDCTRL/GAPC1.AnCtl", "setpoint")] [InlineData("BSC", "IEDCTRL/ATCC1.TapPos", "regulating")] From 72bc841d8721b9fafb472109038d856c4ba3a3aa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 16:50:06 +0700 Subject: [PATCH 98/99] P10.9K: pin verified engine #168 with exact additive fail-closed ancestry guards --- engines/ARIEC61850.lock.json | 21 ++++++++++-- scripts/test-p10-9f-core-routing-trial.ps1 | 32 ++++++++++++++++--- scripts/test-resolve-ariec-lock.ps1 | 24 ++++++++++++++ .../P10MainlinePolicyExecutionTests.cs | 18 +++++++++++ 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 65ee73d7b..449ad8e69 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "67c6635b7e16139c0661e559a2e6946527c1442a", - "sourcePullRequest": 167, - "purpose": "P10.9J CodeVerified-only: trusted SCL 41-packed AP-Title compatibility and exact BSC TapChg control classification (ARIEC PR #167, CI 38219429896). Consumer BSC Tcmd Raise/Lower is explicitly typed and fail-closed; no unverified physical control acceptance. Preserves immutable P10.9I canonical MMS FIFO protection, no SCL discovery fallback, no cyclic polling, and historical R7/R10 authority.", + "commit": "a43b9b4864a9565c02f544d4135fe157c2fac577", + "sourcePullRequest": 168, + "purpose": "P10.9K CodeVerified-only: vendor-neutral control CDC fail-closed semantics with live ctlVal/status evidence plus association-scoped NameList single-flight reuse; ARIEC PR #168 CI #38231327892. Retains P10.9J exact OID/BSC interoperability, P10.9I FIFO protection, physical R7/R10 baseline and no-automatic-dynamic reporting. Physical ZIV control still NOT accepted.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -146,5 +146,20 @@ "fullNameListFallbackForbidden": true, "cyclicMmsProcessPollingForbidden": true, "identitySource": "TrustedSclExactDomainMatch" + }, + "genericControlInteroperabilityTrial": { + "status": "code-verified-not-physical", + "issue": 540, + "enginePullRequest": 168, + "baseEngineCommit": "67c6635b7e16139c0661e559a2e6946527c1442a", + "exactCommit": "a43b9b4864a9565c02f544d4135fe157c2fac577", + "engineCiRun": 38231327892, + "typedSemanticGateRequired": true, + "associationScopedInventoryRequired": true, + "vendorDispatchForbidden": true, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true } } diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 index a11500691..2019e2253 100644 --- a/scripts/test-p10-9f-core-routing-trial.ps1 +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -1,5 +1,5 @@ # PowerShell 5.1/7 compatible. Code-verification does NOT grant physical acceptance. -# Exact audited code-only candidates (P10.9F/G/I/J); no wildcard SHA or runtime overrides. +# Exact audited code-only candidates (P10.9F/G/I/J/K); no wildcard SHA or runtime overrides. function Test-P109FCoreRoutingTrial { param([Parameter(Mandatory = $true)] [object] $Lock) $candidate = $Lock.coreReportRoutingTrial @@ -52,9 +52,7 @@ function Test-P109FCoreRoutingTrial { # historical guard; neither the valid OID notation nor BSC metadata # grants production/physical authority. $ziv = $Lock.zivSclBscInteroperabilityTrial - $zivHead = ( - [string]$Lock.commit -ceq '67c6635b7e16139c0661e559a2e6946527c1442a' -and - [int]$Lock.sourcePullRequest -eq 167 -and + $zivIntegrityValid = ( $canonicalIntegrityValid -and $null -ne $ziv -and [string]$ziv.status -ceq 'code-verified-not-physical' -and [int]$ziv.issue -eq 538 -and @@ -69,7 +67,31 @@ function Test-P109FCoreRoutingTrial { [bool]$ziv.fullDiscoveryFallbackForbidden -and [bool]$ziv.cyclicMmsProcessPollingForbidden ) - if (-not ($previousHead -or $subscriberHead -or $canonicalHead -or $zivHead) -or + $zivHead = ( + [string]$Lock.commit -ceq '67c6635b7e16139c0661e559a2e6946527c1442a' -and + [int]$Lock.sourcePullRequest -eq 167 -and $zivIntegrityValid + ) + # P10.9K does not replace any earlier physical/provenance gate. + $generic = $Lock.genericControlInteroperabilityTrial + $genericHead = ( + [string]$Lock.commit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and + [int]$Lock.sourcePullRequest -eq 168 -and + $zivIntegrityValid -and $null -ne $generic -and + [string]$generic.status -ceq 'code-verified-not-physical' -and + [int]$generic.issue -eq 540 -and + [int]$generic.enginePullRequest -eq 168 -and + [string]$generic.baseEngineCommit -ceq [string]$ziv.exactCommit -and + [string]$generic.exactCommit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and + [long]$generic.engineCiRun -eq 38231327892 -and + [bool]$generic.typedSemanticGateRequired -and + [bool]$generic.associationScopedInventoryRequired -and + [bool]$generic.vendorDispatchForbidden -and + [bool]$generic.physicalQualificationRequired -and + [bool]$generic.mainlinePromotionForbidden -and + [bool]$generic.fullDiscoveryFallbackForbidden -and + [bool]$generic.cyclicMmsProcessPollingForbidden + ) + if (-not ($previousHead -or $subscriberHead -or $canonicalHead -or $zivHead -or $genericHead) -or [string]$Lock.repository -cne 'masarray/ARIEC61850' -or [string]$Lock.ref -cne 'main' -or $null -eq $candidate) { return $false } diff --git a/scripts/test-resolve-ariec-lock.ps1 b/scripts/test-resolve-ariec-lock.ps1 index 600d021c4..7de2db225 100644 --- a/scripts/test-resolve-ariec-lock.ps1 +++ b/scripts/test-resolve-ariec-lock.ps1 @@ -120,6 +120,30 @@ try { $wrongZivScl.zivSclBscInteroperabilityTrial.sclApTitlePackedPrefixOnly = $false Assert-Rejects $wrongZivScl 'physical-tested' + $wrongGenericSha = New-TrackedP10Fixture + $wrongGenericSha.genericControlInteroperabilityTrial.exactCommit = ('f' * 40) + Assert-Rejects $wrongGenericSha 'physical-tested' + + $wrongGenericCi = New-TrackedP10Fixture + $wrongGenericCi.genericControlInteroperabilityTrial.engineCiRun = 0 + Assert-Rejects $wrongGenericCi 'physical-tested' + + $wrongGenericParent = New-TrackedP10Fixture + $wrongGenericParent.genericControlInteroperabilityTrial.baseEngineCommit = ('f' * 40) + Assert-Rejects $wrongGenericParent 'physical-tested' + + $wrongGenericSafety = New-TrackedP10Fixture + $wrongGenericSafety.genericControlInteroperabilityTrial.typedSemanticGateRequired = $false + Assert-Rejects $wrongGenericSafety 'physical-tested' + + $wrongGenericInventory = New-TrackedP10Fixture + $wrongGenericInventory.genericControlInteroperabilityTrial.associationScopedInventoryRequired = $false + Assert-Rejects $wrongGenericInventory 'physical-tested' + + $wrongGenericVendor = New-TrackedP10Fixture + $wrongGenericVendor.genericControlInteroperabilityTrial.vendorDispatchForbidden = $false + Assert-Rejects $wrongGenericVendor 'physical-tested' + $wrongP10Parent = New-TrackedP10Fixture $wrongP10Parent.p10IdentityTrial.baseEngineCommit = ('f' * 40) Assert-Rejects $wrongP10Parent 'physical-tested' diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs index 3a5724bbc..5c34abf70 100644 --- a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs +++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs @@ -33,6 +33,12 @@ public sealed class P10MainlinePolicyExecutionTests [InlineData("ziv-trial-mainline-denied", false)] [InlineData("ziv-trial-polling-denied", false)] [InlineData("ziv-trial-bsc-gate-denied", false)] + [InlineData("generic-trial-sha-denied", false)] + [InlineData("generic-trial-parent-denied", false)] + [InlineData("generic-trial-ci-denied", false)] + [InlineData("generic-trial-semantic-gate-denied", false)] + [InlineData("generic-trial-inventory-gate-denied", false)] + [InlineData("generic-trial-vendor-denied", false)] [InlineData("p10-pr-main-denied", false)] [InlineData("p10-source-main-denied", false)] [InlineData("p10-wrong-sha-denied", false)] @@ -128,6 +134,18 @@ public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion( lockDoc["zivSclBscInteroperabilityTrial"]!["cyclicMmsProcessPollingForbidden"] = false; break; case "ziv-trial-bsc-gate-denied": lockDoc["zivSclBscInteroperabilityTrial"]!["bscLiveSemanticGateRequired"] = false; break; + case "generic-trial-sha-denied": + lockDoc["genericControlInteroperabilityTrial"]!["exactCommit"] = new string('0', 40); break; + case "generic-trial-parent-denied": + lockDoc["genericControlInteroperabilityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "generic-trial-ci-denied": + lockDoc["genericControlInteroperabilityTrial"]!["engineCiRun"] = 0; break; + case "generic-trial-semantic-gate-denied": + lockDoc["genericControlInteroperabilityTrial"]!["typedSemanticGateRequired"] = false; break; + case "generic-trial-inventory-gate-denied": + lockDoc["genericControlInteroperabilityTrial"]!["associationScopedInventoryRequired"] = false; break; + case "generic-trial-vendor-denied": + lockDoc["genericControlInteroperabilityTrial"]!["vendorDispatchForbidden"] = false; break; case "p10-source-main-denied": sourceRef = "main"; break; case "p10-wrong-sha-denied": lockDoc["p10IdentityTrial"]!["exactCommit"] = new string('0', 40); break; From 069890df0a007d196ba0a9498c2c5972f8ab5c5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 16:55:14 +0700 Subject: [PATCH 99/99] P10.9K: preserve F650 qualified identities and exact historical engine ancestry --- .../CanonicalLiveSclExportRegressionTests.cs | 11 ++++++++++- .../F650CanonicalCommandFailureTests.cs | 3 +++ ...tInteroperabilityTrialGuardRegressionTests.cs | 16 ++++++++++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 60a88ad90..f2e110668 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -442,8 +442,13 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter zivTrial.GetProperty("baseEngineCommit").GetString()); Assert.Equal("67c6635b7e16139c0661e559a2e6946527c1442a", zivTrial.GetProperty("exactCommit").GetString()); + var genericTrial = root.GetProperty("genericControlInteroperabilityTrial"); Assert.Equal(zivTrial.GetProperty("exactCommit").GetString(), + genericTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(genericTrial.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); + Assert.Equal("a43b9b4864a9565c02f544d4135fe157c2fac577", + genericTrial.GetProperty("exactCommit").GetString()); Assert.Equal("code-verified-not-physical", canonicalTrial.GetProperty("status").GetString()); Assert.Equal(166, canonicalTrial.GetProperty("enginePullRequest").GetInt32()); Assert.Equal(536, canonicalTrial.GetProperty("issue").GetInt32()); @@ -462,7 +467,11 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); - Assert.Equal(167, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(168, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.True(genericTrial.GetProperty("typedSemanticGateRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("associationScopedInventoryRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("vendorDispatchForbidden").GetBoolean()); + Assert.True(genericTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); diff --git a/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs b/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs index c4ba351e7..8565339f5 100644 --- a/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs +++ b/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs @@ -14,6 +14,9 @@ private static SignalDefinition Pos(string dataset, string reference) DataSetReference = dataset, IsControlSignal = true, IsSelected = true }; signal.ControlModelText = "Direct Operate (DO) • Normal security"; + // F650 tests canonical identity of already-qualified command objects. + // Production authority is granted only after ARIEC live semantic verification. + signal.ControlSemanticVerified = true; return signal; } diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index 5110430d9..96e1487dc 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -84,8 +84,13 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP var zivTrial = root.GetProperty("zivSclBscInteroperabilityTrial"); Assert.Equal(canonicalTrial.GetProperty("exactCommit").GetString(), zivTrial.GetProperty("baseEngineCommit").GetString()); - Assert.Equal(root.GetProperty("commit").GetString(), + Assert.Equal("67c6635b7e16139c0661e559a2e6946527c1442a", zivTrial.GetProperty("exactCommit").GetString()); + var genericTrial = root.GetProperty("genericControlInteroperabilityTrial"); + Assert.Equal(zivTrial.GetProperty("exactCommit").GetString(), + genericTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(root.GetProperty("commit").GetString(), + genericTrial.GetProperty("exactCommit").GetString()); Assert.Equal("code-verified-not-physical", codeTrial.GetProperty("status").GetString()); Assert.Equal("code-verified-not-physical", subscriberTrial.GetProperty("status").GetString()); Assert.True(subscriberTrial.GetProperty("physicalQualificationRequired").GetBoolean()); @@ -110,7 +115,14 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); - Assert.Equal(167,root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(168,root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(540, genericTrial.GetProperty("issue").GetInt32()); + Assert.Equal(38231327892L, genericTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(genericTrial.GetProperty("typedSemanticGateRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("associationScopedInventoryRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("vendorDispatchForbidden").GetBoolean()); + Assert.True(genericTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(p10.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean());