From 6d8b20b5a186d79a7406f8669d8de43786292d67 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:03:03 +0530
Subject: [PATCH 01/84] FC2-COM-001: add SQL-backed site line and machine
inventory store
---
.../MasterData/MachineAdministrationStore.cs | 111 ++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
new file mode 100644
index 00000000..5d7e42ef
--- /dev/null
+++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
@@ -0,0 +1,111 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+
+namespace FactoryConnect.Sql.MasterData;
+
+///
+/// SQL-backed site, line and machine inventory. Registration must be called only
+/// after the HTTP boundary has independently authorized an administrator.
+/// No runtime configuration, acquisition or activation is performed here.
+///
+public sealed class MachineAdministrationStore(string connectionString)
+{
+ public sealed record Site(Guid SiteId, string DisplayName, bool IsRetired);
+ public sealed record ProductionLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired);
+ public sealed record Machine(Guid MachineId, Guid SiteId, string DisplayName, bool IsRetired);
+
+ public async Task> ListSitesAsync(CancellationToken ct = default)
+ {
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var command = new SqlCommand(
+ "SELECT SiteId, DisplayName, IsRetired FROM master.Site ORDER BY DisplayName, SiteId", connection);
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ var rows = new List();
+ while (await reader.ReadAsync(ct))
+ rows.Add(new Site(reader.GetGuid(0), reader.GetString(1), reader.GetBoolean(2)));
+ return rows;
+ }
+
+ public async Task> ListLinesAsync(Guid siteId, CancellationToken ct = default)
+ {
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var command = new SqlCommand(
+ "SELECT ProductionLineId, SiteId, DisplayName, IsRetired FROM master.ProductionLine WHERE SiteId=@site ORDER BY DisplayName, ProductionLineId", connection);
+ command.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId;
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ var rows = new List();
+ while (await reader.ReadAsync(ct))
+ rows.Add(new ProductionLine(reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetBoolean(3)));
+ return rows;
+ }
+
+ public async Task> ListMachinesAsync(Guid siteId, CancellationToken ct = default)
+ {
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var command = new SqlCommand(
+ "SELECT MachineId, SiteId, DisplayName, IsRetired FROM master.Machine WHERE SiteId=@site ORDER BY DisplayName, MachineId", connection);
+ command.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId;
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ var rows = new List();
+ while (await reader.ReadAsync(ct))
+ rows.Add(new Machine(reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetBoolean(3)));
+ return rows;
+ }
+
+ public Task RegisterSiteAsync(string name, CancellationToken ct = default)
+ => RegisterAsync("Site", "SiteId", null, name, ct);
+
+ public Task RegisterLineAsync(Guid siteId, string name, CancellationToken ct = default)
+ => RegisterAsync("ProductionLine", "ProductionLineId", siteId, name, ct);
+
+ public Task RegisterMachineAsync(Guid siteId, string name, CancellationToken ct = default)
+ => RegisterAsync("Machine", "MachineId", siteId, name, ct);
+
+ private async Task RegisterAsync(string table, string idColumn, Guid? siteId, string name, CancellationToken ct)
+ {
+ // Identifiers are selected exclusively from the fixed methods above.
+ if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 200)
+ throw new ArgumentException("Display name must contain 1 to 200 characters.", nameof(name));
+ if (siteId == Guid.Empty) throw new ArgumentException("Site ID is required.", nameof(siteId));
+ name = name.Trim();
+ var id = Guid.NewGuid();
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(IsolationLevel.Serializable, ct);
+ // Serializable range locking prevents two concurrent registrations of the same
+ // normalized display name within a site (or globally for sites).
+ var predicate = siteId.HasValue ? "SiteId=@site AND " : "";
+ await using (var check = new SqlCommand(
+ $"SELECT COUNT_BIG(*) FROM master.{table} WITH (UPDLOCK, HOLDLOCK) WHERE {predicate}UPPER(LTRIM(RTRIM(DisplayName)))=UPPER(@name)",
+ connection, transaction))
+ {
+ check.Parameters.Add("@name", SqlDbType.NVarChar, 200).Value = name;
+ if (siteId.HasValue) check.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value;
+ if ((long)(await check.ExecuteScalarAsync(ct) ?? 0L) != 0)
+ throw new InvalidOperationException("A record with this display name already exists in the selected scope.");
+ }
+ if (siteId.HasValue)
+ {
+ await using var parent = new SqlCommand(
+ "SELECT COUNT_BIG(*) FROM master.Site WITH (UPDLOCK, HOLDLOCK) WHERE SiteId=@site AND IsRetired=0",
+ connection, transaction);
+ parent.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value;
+ if ((long)(await parent.ExecuteScalarAsync(ct) ?? 0L) != 1)
+ throw new InvalidOperationException("The selected site does not exist or is retired.");
+ }
+ await using (var insert = new SqlCommand(
+ $"INSERT master.{table} ({idColumn}, {(siteId.HasValue ? "SiteId, " : "")}DisplayName) VALUES (@id, {(siteId.HasValue ? "@site, " : "")}@name)",
+ connection, transaction))
+ {
+ insert.Parameters.Add("@id", SqlDbType.UniqueIdentifier).Value = id;
+ insert.Parameters.Add("@name", SqlDbType.NVarChar, 200).Value = name;
+ if (siteId.HasValue) insert.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value;
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+ await transaction.CommitAsync(ct);
+ return id;
+ }
+}
From 6d7c0b964b31c3916b7b48cf96109f394ded479d Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:05:41 +0530
Subject: [PATCH 02/84] FC2-COM-001: keep registration store internal until
transactional authorization is integrated
---
.../MasterData/MachineAdministrationStore.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
index 5d7e42ef..456cd226 100644
--- a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
+++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
@@ -5,10 +5,10 @@ namespace FactoryConnect.Sql.MasterData;
///
/// SQL-backed site, line and machine inventory. Registration must be called only
-/// after the HTTP boundary has independently authorized an administrator.
+/// after an audited, transaction-bound SQL authorization command has verified the actor.
/// No runtime configuration, acquisition or activation is performed here.
///
-public sealed class MachineAdministrationStore(string connectionString)
+internal sealed class MachineAdministrationStore(string connectionString)
{
public sealed record Site(Guid SiteId, string DisplayName, bool IsRetired);
public sealed record ProductionLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired);
@@ -55,7 +55,7 @@ public async Task> ListMachinesAsync(Guid siteId, Cancell
return rows;
}
- public Task RegisterSiteAsync(string name, CancellationToken ct = default)
+ // Internal-only until the write-authority integration can prove permission,\n // operation identity and audit in the same SQL transaction.\n public Task RegisterSiteAsync(string name, CancellationToken ct = default)
=> RegisterAsync("Site", "SiteId", null, name, ct);
public Task RegisterLineAsync(Guid siteId, string name, CancellationToken ct = default)
From ace2010a57b36c75e22c5e026c4affc0e3c4c25f Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:09:58 +0530
Subject: [PATCH 03/84] FC2-COM-001: add authenticated read-only machine
directory screen
---
.../MachineAdministrationPage.tsx | 71 +++++++++++++++++++
1 file changed, 71 insertions(+)
create mode 100644 src/presentation/MachineAdministrationPage.tsx
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
new file mode 100644
index 00000000..c1084adc
--- /dev/null
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useMemo, useState } from "react";
+import { readAllOperatorEvidenceMachines, type OperatorEvidenceMachine } from "../api/evidence/operator-evidence-machines-client.ts";
+
+/**
+ * First machine-administration surface: authenticated, read-only discovery.
+ * This is an evidence-authorized subset, not a complete administrative inventory.
+ * Registration controls will be enabled only after audited SQL write authority exists.
+ */
+export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
+ readonly sessionBearer: string | null;
+ readonly baseAddress: string;
+}) {
+ const [revision, setRevision] = useState(0);
+ const [search, setSearch] = useState("");
+ const [state, setState] = useState<
+ { kind: "loading" | "signIn" | "unavailable" } |
+ { kind: "ready"; bearer: string; machines: readonly OperatorEvidenceMachine[]; complete: boolean }
+ >({ kind: "loading" });
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setState({ kind: "loading" });
+ if (!sessionBearer) {
+ setState({ kind: "signIn" });
+ return () => controller.abort();
+ }
+ void readAllOperatorEvidenceMachines(baseAddress, sessionBearer, controller.signal)
+ .then(result => {
+ if (!controller.signal.aborted)
+ setState({ kind: "ready", bearer: sessionBearer, machines: result.machines, complete: result.isComplete });
+ })
+ .catch(() => { if (!controller.signal.aborted) setState({ kind: "unavailable" }); });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
+ const current = state.kind === "ready" && state.bearer !== sessionBearer
+ ? { kind: "signIn" as const } : state;
+ const matches = useMemo(() => current.kind === "ready" ? current.machines.filter(machine =>
+ machine.displayName.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) ||
+ machine.machineId.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) ||
+ machine.siteId.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())) : [],
+ [current, search]);
+
+ return
+ Machine administration
+ Authorized machine discovery — read only. This list includes only machines for which your session has
+ commissioning evidence access. It is not the complete registered machine inventory or proof of live connectivity.
+ Site, production-line and machine registration is not yet enabled. No configuration or acquisition changes can be made here.
+ setRevision(value => value + 1)}>Refresh authorized machines
+ {current.kind === "loading" && Checking authorized machines…
}
+ {current.kind === "signIn" && Sign in to view authorized machines.
}
+ {current.kind === "unavailable" && Machine discovery is unavailable. No cached inventory is shown.
}
+ {current.kind === "ready" && <>
+ {!current.complete && The authorized machine list is incomplete. Counts below are partial.
}
+ Search by machine, site or ID
+ setSearch(event.currentTarget.value)} />
+ {matches.length} matching machines from {current.machines.length} returned.
+
+
Machine name Machine ID Site ID
+ {matches.map(machine =>
+ {machine.displayName} {machine.machineId}
+ {machine.siteId}
+ )}
+
+
+ {matches.length === 0 && No authorized machines match the current search.
}
+ >}
+ ;
+}
From ccdee06e75d72c43587192a432f6aba5ab4dd795 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:10:03 +0530
Subject: [PATCH 04/84] FC2-COM-001: register machine administration route
---
src/routing/application-route.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/routing/application-route.ts b/src/routing/application-route.ts
index 9e0563af..8d061f06 100644
--- a/src/routing/application-route.ts
+++ b/src/routing/application-route.ts
@@ -1,6 +1,7 @@
export type ApplicationRoute =
| { kind: "supervisorPreview" }
| { kind: "commissioning" }
+ | { kind: "machineAdministration" }
| { kind: "operatorEvidence" }
| { kind: "overview"; search: string }
| { kind: "fleet" }
@@ -19,6 +20,7 @@ export const applicationBasePath = "/";
export function parseApplicationRoute(pathname: string, search = ""): ApplicationRoute {
if (pathname === "/supervisor-preview") return { kind: "supervisorPreview" };
if (pathname === "/operator-evidence") return { kind: "operatorEvidence" };
+ if (pathname === "/admin/machines") return { kind: "machineAdministration" };
if (pathname === "/commissioning") return { kind: "commissioning" };
if (pathname === "/overview") return { kind: "overview", search };
if (pathname === "/fleet") return { kind: "fleet" };
@@ -91,6 +93,7 @@ export function routePath(route: Exclude
switch (route.kind) {
case "supervisorPreview": return "/supervisor-preview";
case "commissioning": return "/commissioning";
+ case "machineAdministration": return "/admin/machines";
case "operatorEvidence": return "/operator-evidence";
case "overview": return `/overview${route.search}`;
case "fleet": return "/fleet";
From 216e384d69d899d7806373fbc0e6b1f44dc524b9 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:10:09 +0530
Subject: [PATCH 05/84] FC2-COM-001: expose authorized machine directory in
dashboard navigation
---
src/App.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/App.tsx b/src/App.tsx
index 34702bbf..826899a8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
import { lazy, Suspense } from "react";
+import { MachineAdministrationPage } from "./presentation/MachineAdministrationPage.tsx";
import { CommissioningPage } from "./presentation/CommissioningPage.tsx";
import { OperatorEvidencePage } from "./presentation/OperatorEvidencePage.tsx";
import { RetainedMachineEvidence } from "./presentation/RetainedMachineEvidence.tsx";
@@ -107,6 +108,7 @@ export function App({ runtime }: AppProps) {
{SupervisorWorkspace && Supervisor desk preview }
Operator evidence {" "}
+ Machine administration {" "}
Commissioning {" "}
Overview {" "}
Fleet status {" "}
@@ -160,6 +162,8 @@ function RouteView({ route, navigate, runtime }: RouteViewProps) {
? Loading screen review…
}>
: Supervisor workspace not enabled The screen-review preview is available only in development builds. No production data-entry service is connected.
;
case "commissioning": return ;
+ case "machineAdministration": return ;
case "operatorEvidence": return ;
case "overview": return ;
From 74969ec7364c9fbcf86ab0856b86102a95c1e960 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:21:34 +0530
Subject: [PATCH 06/84] FC2-COM-001: link authorized directory entries to
operator evidence
---
src/presentation/MachineAdministrationPage.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index c1084adc..450ae358 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -58,10 +58,11 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
onChange={event => setSearch(event.currentTarget.value)} />
{matches.length} matching machines from {current.machines.length} returned.
-
Machine name Machine ID Site ID
+ Machine name Machine ID Site ID Evidence
{matches.map(machine =>
{machine.displayName} {machine.machineId}
{machine.siteId}
+ View evidence
)}
From 1d013de9f551c493aac8a170c77e14e285e5d271 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:21:40 +0530
Subject: [PATCH 07/84] FC2-COM-001: preselect authorized machine when opening
evidence from directory
---
src/presentation/OperatorEvidencePage.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/presentation/OperatorEvidencePage.tsx b/src/presentation/OperatorEvidencePage.tsx
index 02e0a28a..6b3e1c76 100644
--- a/src/presentation/OperatorEvidencePage.tsx
+++ b/src/presentation/OperatorEvidencePage.tsx
@@ -10,13 +10,13 @@ export function OperatorEvidencePage({
{ kind: "loading" | "denied" | "failed" } |
{ kind: "ready"; bearer: string; machines: readonly OperatorEvidenceMachine[]; isComplete: boolean }
>({ kind: "loading" });
- const [selectedMachine, setSelectedMachine] = useState("");
+ const [selectedMachine, setSelectedMachine] = useState(() => new URLSearchParams(window.location.search).get("machineId") ?? "");
const [machineSearch, setMachineSearch] = useState("");
const [accessRefresh, setAccessRefresh] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState({ kind: "loading" });
- setSelectedMachine("");
+ setSelectedMachine(new URLSearchParams(window.location.search).get("machineId") ?? "");
setMachineSearch("");
if (!sessionBearer) {
setState({ kind: "denied" });
From d27382d61efe0aaae5a7d7780b81efbba01b0466 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:25:54 +0530
Subject: [PATCH 08/84] Fix CS8057 caused by escaped newline in registration
store comment
---
.../MasterData/MachineAdministrationStore.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
index 456cd226..d88a5437 100644
--- a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
+++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
@@ -55,7 +55,9 @@ public async Task> ListMachinesAsync(Guid siteId, Cancell
return rows;
}
- // Internal-only until the write-authority integration can prove permission,\n // operation identity and audit in the same SQL transaction.\n public Task RegisterSiteAsync(string name, CancellationToken ct = default)
+ // Internal-only until the write-authority integration can prove permission,
+ // operation identity and audit in the same SQL transaction.
+ public Task RegisterSiteAsync(string name, CancellationToken ct = default)
=> RegisterAsync("Site", "SiteId", null, name, ct);
public Task RegisterLineAsync(Guid siteId, string name, CancellationToken ct = default)
From 6ef535e11ed18b08374105ad6e2f7c029cf9390f Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:42:39 +0530
Subject: [PATCH 09/84] FC2-COM-001: present existing Keycloak sign-in as
FactoryConnect sign-in
---
src/App.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/App.tsx b/src/App.tsx
index 826899a8..c564daf3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -104,7 +104,7 @@ export function App({ runtime }: AppProps) {
+ : Sign in to FactoryConnect }
{SupervisorWorkspace && Supervisor desk preview }
Operator evidence {" "}
From 1a3fe549bd3341a3fd55af75a3e41ff9c844f8b4 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:46:23 +0530
Subject: [PATCH 10/84] Fix browser acceptance login selector after
FactoryConnect sign-in label change
---
scripts/ci/operator-browser.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/ci/operator-browser.mjs b/scripts/ci/operator-browser.mjs
index 3f56082c..a0ff5035 100644
--- a/scripts/ci/operator-browser.mjs
+++ b/scripts/ci/operator-browser.mjs
@@ -17,7 +17,7 @@ try {
const url = process.env.FC2_BROWSER_URL;
async function login() {
await page.goto(url + '/operator-evidence');
- await page.getByRole('link', { name: 'Sign in to view machine evidence' }).click();
+ await page.getByRole('link', { name: 'Sign in to FactoryConnect' }).click();
await page.getByLabel('Password').fill(process.env.FC2_BROWSER_PASSWORD);
await page.getByRole('button', { name: 'Provider sign in' }).click();
}
From 5008abc1d91aa45e672cff3c612a22b1916c6524 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Wed, 11 Nov 2026 23:49:59 +0530
Subject: [PATCH 11/84] Align activity browser acceptance login selector with
FactoryConnect sign-in label
---
scripts/ci/activity-browser.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/ci/activity-browser.mjs b/scripts/ci/activity-browser.mjs
index 16a5ebf4..57c76eb0 100644
--- a/scripts/ci/activity-browser.mjs
+++ b/scripts/ci/activity-browser.mjs
@@ -17,7 +17,7 @@ try {
const url = process.env.FC2_BROWSER_URL;
async function login() {
await page.goto(url + '/operator-evidence');
- await page.getByRole('link', { name: 'Sign in to view machine evidence' }).click();
+ await page.getByRole('link', { name: 'Sign in to FactoryConnect' }).click();
await page.getByLabel('Password').fill(process.env.FC2_BROWSER_PASSWORD);
await page.getByRole('button', { name: 'Provider sign in' }).click();
}
From 80c3828276ddc0ed386d2488d356e62c30684bba Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 00:10:34 +0530
Subject: [PATCH 12/84] Improve machine administration sign-in and empty-state
guidance
---
src/presentation/MachineAdministrationPage.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 450ae358..8bf70940 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -49,7 +49,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
setRevision(value => value + 1)}>Refresh authorized machines
{current.kind === "loading" && Checking authorized machines…
}
- {current.kind === "signIn" && Sign in to view authorized machines.
}
+ {current.kind === "signIn" && Sign in to view authorized machines. Sign in to FactoryConnect
}
{current.kind === "unavailable" && Machine discovery is unavailable. No cached inventory is shown.
}
{current.kind === "ready" && <>
{!current.complete && The authorized machine list is incomplete. Counts below are partial.
}
@@ -66,7 +66,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
)}
- {matches.length === 0 && No authorized machines match the current search.
}
+ {matches.length === 0 && {current.machines.length === 0 ? "No machines are available to this account. This does not mean the factory has no registered machines." : "No authorized machines match the current search."}
}
>}
;
}
From 6baaedc9873b67b5f441ad8bc7818b9ab9de2576 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 00:25:52 +0530
Subject: [PATCH 13/84] Require caller-owned authorized SQL transaction for
master-data registration
---
.../MasterData/MachineAdministrationStore.cs | 27 +++++++++----------
1 file changed, 12 insertions(+), 15 deletions(-)
diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
index d88a5437..b22c8074 100644
--- a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
+++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
@@ -55,18 +55,19 @@ public async Task> ListMachinesAsync(Guid siteId, Cancell
return rows;
}
- // Internal-only until the write-authority integration can prove permission,
- // operation identity and audit in the same SQL transaction.
- public Task RegisterSiteAsync(string name, CancellationToken ct = default)
- => RegisterAsync("Site", "SiteId", null, name, ct);
+ // The caller must supply the SQL transaction that performed authorization.
+ // No independent connection or commit is permitted for administrative writes.
+ internal Task RegisterSiteAsync(SqlConnection connection, SqlTransaction transaction, string name, CancellationToken ct = default)
+ => RegisterAsync(connection, transaction, "Site", "SiteId", null, name, ct);
- public Task RegisterLineAsync(Guid siteId, string name, CancellationToken ct = default)
- => RegisterAsync("ProductionLine", "ProductionLineId", siteId, name, ct);
+ internal Task RegisterLineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, CancellationToken ct = default)
+ => RegisterAsync(connection, transaction, "ProductionLine", "ProductionLineId", siteId, name, ct);
- public Task RegisterMachineAsync(Guid siteId, string name, CancellationToken ct = default)
- => RegisterAsync("Machine", "MachineId", siteId, name, ct);
+ internal Task RegisterMachineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, CancellationToken ct = default)
+ => RegisterAsync(connection, transaction, "Machine", "MachineId", siteId, name, ct);
- private async Task RegisterAsync(string table, string idColumn, Guid? siteId, string name, CancellationToken ct)
+ private static async Task RegisterAsync(SqlConnection connection, SqlTransaction transaction,
+ string table, string idColumn, Guid? siteId, string name, CancellationToken ct)
{
// Identifiers are selected exclusively from the fixed methods above.
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 200)
@@ -74,11 +75,8 @@ private async Task RegisterAsync(string table, string idColumn, Guid? site
if (siteId == Guid.Empty) throw new ArgumentException("Site ID is required.", nameof(siteId));
name = name.Trim();
var id = Guid.NewGuid();
- await using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(ct);
- await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(IsolationLevel.Serializable, ct);
- // Serializable range locking prevents two concurrent registrations of the same
- // normalized display name within a site (or globally for sites).
+ // The security authority owns transaction isolation and commit. UPDLOCK/HOLDLOCK
+ // serializes duplicate-name checks until that transaction completes.
var predicate = siteId.HasValue ? "SiteId=@site AND " : "";
await using (var check = new SqlCommand(
$"SELECT COUNT_BIG(*) FROM master.{table} WITH (UPDLOCK, HOLDLOCK) WHERE {predicate}UPPER(LTRIM(RTRIM(DisplayName)))=UPPER(@name)",
@@ -107,7 +105,6 @@ private async Task RegisterAsync(string table, string idColumn, Guid? site
if (siteId.HasValue) insert.Parameters.Add("@site", SqlDbType.UniqueIdentifier).Value = siteId.Value;
await insert.ExecuteNonQueryAsync(ct);
}
- await transaction.CommitAsync(ct);
return id;
}
}
From ff70e1b8289a88b58cf13cbde966cc661a7da31c Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 00:43:45 +0530
Subject: [PATCH 14/84] Implement transaction-bound authorized and audited
master-data registration
---
...SqlSecurityAuthority.MasterRegistration.cs | 60 +++++++++++++++++++
1 file changed, 60 insertions(+)
create mode 100644 backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
new file mode 100644
index 00000000..6945da7b
--- /dev/null
+++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
@@ -0,0 +1,60 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+using FactoryConnect.Sql.MasterData;
+
+namespace FactoryConnect.Sql.Security;
+
+public sealed partial class SqlSecurityAuthority
+{
+ // Registration is a master-data change, not an acquisition or machine activation.
+ // The installation-level grant is deliberately separate from evidence-reading rights.
+ public Task RegisterMasterRecordAsync(byte[] sessionHash, Guid operationId,
+ string kind, Guid? siteId, string displayName, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32 || operationId == Guid.Empty)
+ throw new SecurityDeniedException();
+ if (kind is not ("site" or "line" or "machine") ||
+ (kind == "site" ? siteId is not null : siteId is null || siteId == Guid.Empty) ||
+ string.IsNullOrWhiteSpace(displayName) || displayName.Trim().Length > 200)
+ throw new ArgumentException("Invalid master-data registration request.");
+ var name = displayName.Trim();
+ var token = sessionHash.ToArray();
+ var request = JsonSerializer.SerializeToUtf8Bytes(new { version = 1, kind, siteId, displayName = name });
+ return Transaction(operationId, true, async (connection, transaction) =>
+ {
+ var state = await State(connection, transaction, ct);
+ var actor = await Session(connection, transaction, token, state.Now, ct);
+ await Authorize(connection, transaction, actor, "configuration.master.manage",
+ AuthorizationScope.Installation, state.Now, ct);
+ var completed = await FindOperation(connection, transaction, operationId, ct);
+ if (completed is not null)
+ {
+ if (completed.Actor != actor || !completed.Request.AsSpan().SequenceEqual(request))
+ throw new SecurityConflictException();
+ using var result = JsonDocument.Parse(completed.Result);
+ return result.RootElement.GetProperty("recordId").GetGuid();
+ }
+
+ var store = new MachineAdministrationStore(connectionString);
+ var recordId = kind switch
+ {
+ "site" => await store.RegisterSiteAsync(connection, transaction, name, ct),
+ "line" => await store.RegisterLineAsync(connection, transaction, siteId!.Value, name, ct),
+ _ => await store.RegisterMachineAsync(connection, transaction, siteId!.Value, name, ct)
+ };
+ var resultBytes = JsonSerializer.SerializeToUtf8Bytes(new { recordId });
+ await Exec(connection, transaction, """
+ INSERT security.SecurityOperation
+ (OperationId,ActorPrincipalId,RequestBytes,RequestHash,ScopeKind,SiteId,MachineId,ResultBytes)
+ VALUES (@op,@actor,@bytes,@hash,'I',NULL,NULL,@result);
+ """, ct, ("@op", operationId), ("@actor", actor), ("@bytes", request),
+ ("@hash", SHA256.HashData(request)), ("@result", resultBytes));
+ await Exec(connection, transaction, """
+ INSERT security.SecurityAudit(AuditId,OperationId,ActorPrincipalId,Action,Reason)
+ VALUES (@id,@op,@actor,@action,@reason);
+ """, ct, ("@id", Guid.NewGuid()), ("@op", operationId), ("@actor", actor),
+ ("@action", "master-register-" + kind), ("@reason", "Administrator registered " + kind));
+ return recordId;
+ }, ct);
+ }
+}
From bab7528852aebf01c2d91149a12fe345cf2d497e Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 00:59:11 +0530
Subject: [PATCH 15/84] Expose authenticated audited master-data registration
API
---
.../MasterRegistrationApi.cs | 60 +++++++++++++++++++
1 file changed, 60 insertions(+)
create mode 100644 backend/FactoryConnect.Api/MasterRegistrationApi.cs
diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
new file mode 100644
index 00000000..0032d944
--- /dev/null
+++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
@@ -0,0 +1,60 @@
+using System.Security.Cryptography;
+using FactoryConnect.Sql.Security;
+
+namespace FactoryConnect.Api;
+
+public static class MasterRegistrationApi
+{
+ private sealed record RegistrationRequest(Guid OperationId, string Kind, Guid? SiteId, string DisplayName);
+
+ public static void MapMasterRegistration(this WebApplication app)
+ {
+ app.MapPost("/api/admin/v2/master-registration", async (
+ HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps)
+ return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192)
+ return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var sessionHash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ RegistrationRequest? request;
+ try { request = await http.Request.ReadFromJsonAsync(cancellationToken: ct); }
+ catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "Invalid registration JSON" }); }
+ if (request is null || request.OperationId == Guid.Empty ||
+ request.Kind is not ("site" or "line" or "machine") ||
+ (request.Kind == "site" ? request.SiteId is not null :
+ request.SiteId is null || request.SiteId == Guid.Empty) ||
+ string.IsNullOrWhiteSpace(request.DisplayName) ||
+ request.DisplayName.Trim().Length > 200)
+ return Results.BadRequest(new { error = "Invalid registration request" });
+ var recordId = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .RegisterMasterRecordAsync(sessionHash, request.OperationId, request.Kind,
+ request.SiteId, request.DisplayName, ct);
+ return Results.Ok(new { recordId, request.OperationId, request.Kind });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (SecurityConflictException) { return Results.Conflict(new { error = "Operation ID conflict" }); }
+ catch (InvalidOperationException) { return Results.Conflict(new { error = "Registration conflicts with existing master data" }); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "master-registration", "Master-data authority unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(sessionHash); }
+ }).RequireRateLimiting("security-boundary");
+ }
+}
From c6b00be187b693f4c6854eb9caf7113186feda7e Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 00:59:26 +0530
Subject: [PATCH 16/84] Register master-data administration endpoint in API
host
---
backend/FactoryConnect.Api/EvidenceApiHost.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/backend/FactoryConnect.Api/EvidenceApiHost.cs b/backend/FactoryConnect.Api/EvidenceApiHost.cs
index 2cf7a67f..b2908007 100644
--- a/backend/FactoryConnect.Api/EvidenceApiHost.cs
+++ b/backend/FactoryConnect.Api/EvidenceApiHost.cs
@@ -247,6 +247,7 @@ public static WebApplication Build(string[] args, Action?
}).RequireRateLimiting("security-boundary");
app.MapActivityHistory();
+app.MapMasterRegistration();
app.MapMethods("/api/{**path}", ["GET", "POST"], NotReady);
app.MapFallbackToFile("index.html");
From 0021b0777d9851da8834becfd30b2b8d7b490632 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 01:00:24 +0530
Subject: [PATCH 17/84] Add authenticated site line and machine registration
form
---
.../MachineAdministrationPage.tsx | 49 ++++++++++++++++++-
1 file changed, 48 insertions(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 8bf70940..a035b6ff 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -12,6 +12,32 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
}) {
const [revision, setRevision] = useState(0);
const [search, setSearch] = useState("");
+ const [kind, setKind] = useState<"site" | "line" | "machine">("site");
+ const [siteId, setSiteId] = useState("");
+ const [name, setName] = useState("");
+ const [registrationMessage, setRegistrationMessage] = useState("");
+ const [saving, setSaving] = useState(false);
+ async function register(event: { preventDefault(): void }) {
+ event.preventDefault();
+ if (!sessionBearer || saving) return;
+ setSaving(true);
+ setRegistrationMessage("");
+ try {
+ const response = await fetch(new URL("/api/admin/v2/master-registration", baseAddress), {
+ method: "POST",
+ headers: { Authorization: "Bearer " + sessionBearer, "Content-Type": "application/json" },
+ body: JSON.stringify({ operationId: crypto.randomUUID(), kind,
+ siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim() })
+ });
+ if (!response.ok) throw new Error("Registration failed (HTTP " + response.status + ").");
+ const result: { recordId: string } = await response.json();
+ setRegistrationMessage("Registered " + kind + ": " + result.recordId + ". Acquisition remains inactive.");
+ setName("");
+ } catch (error) {
+ setRegistrationMessage(error instanceof Error ? error.message : "Registration failed.");
+ } finally { setSaving(false); }
+ }
+
const [state, setState] = useState<
{ kind: "loading" | "signIn" | "unavailable" } |
{ kind: "ready"; bearer: string; machines: readonly OperatorEvidenceMachine[]; complete: boolean }
@@ -45,7 +71,28 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Machine administration
Authorized machine discovery — read only. This list includes only machines for which your session has
commissioning evidence access. It is not the complete registered machine inventory or proof of live connectivity.
- Site, production-line and machine registration is not yet enabled. No configuration or acquisition changes can be made here.
+
+ Register master data below. Registration does not activate MTConnect acquisition.
+ {sessionBearer && }
+
setRevision(value => value + 1)}>Refresh authorized machines
{current.kind === "loading" && Checking authorized machines…
}
From 4f246f8cc36447d5c92f998b78b8c10bd4cd528d Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 01:16:15 +0530
Subject: [PATCH 18/84] Add permission-scoped administration site inventory
read
---
...SqlSecurityAuthority.MasterRegistration.cs | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
index 6945da7b..878021c7 100644
--- a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
+++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
@@ -6,6 +6,26 @@ namespace FactoryConnect.Sql.Security;
public sealed partial class SqlSecurityAuthority
{
+ public sealed record AdministrationSite(Guid SiteId, string DisplayName, bool IsRetired);
+
+ public Task> ReadAdministrationSitesAsync(
+ byte[] sessionHash, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException();
+ return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation,
+ async (connection, transaction, token) =>
+ {
+ await using var command = new Microsoft.Data.SqlClient.SqlCommand(
+ "SELECT SiteId, DisplayName, IsRetired FROM master.Site ORDER BY DisplayName, SiteId",
+ connection, transaction);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ var sites = new List();
+ while (await reader.ReadAsync(token))
+ sites.Add(new AdministrationSite(reader.GetGuid(0), reader.GetString(1), reader.GetBoolean(2)));
+ return (IReadOnlyList)sites;
+ }, ct);
+ }
+
// Registration is a master-data change, not an acquisition or machine activation.
// The installation-level grant is deliberately separate from evidence-reading rights.
public Task RegisterMasterRecordAsync(byte[] sessionHash, Guid operationId,
From 99256b0ec1b3187b4df74116e146713d182b078d Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 01:16:22 +0530
Subject: [PATCH 19/84] Expose authorized site inventory for machine
registration
---
.../MasterRegistrationApi.cs | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
index 0032d944..037e19b6 100644
--- a/backend/FactoryConnect.Api/MasterRegistrationApi.cs
+++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
@@ -9,6 +9,38 @@ private sealed record RegistrationRequest(Guid OperationId, string Kind, Guid? S
public static void MapMasterRegistration(this WebApplication app)
{
+ app.MapGet("/api/admin/v2/sites", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var sites = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationSitesAsync(hash, ct);
+ return Results.Ok(new { sites });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-sites", "Site inventory unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
app.MapPost("/api/admin/v2/master-registration", async (
HttpContext http, IConfiguration config, CancellationToken ct) =>
{
From c49a4b1f69bd9337d30d4b9fe9597705052d5782 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 01:17:16 +0530
Subject: [PATCH 20/84] Replace manual site GUID entry with active-site
selection
---
.../MachineAdministrationPage.tsx | 27 +++++++++++++++++--
1 file changed, 25 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index a035b6ff..b4352413 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -14,6 +14,23 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [search, setSearch] = useState("");
const [kind, setKind] = useState<"site" | "line" | "machine">("site");
const [siteId, setSiteId] = useState("");
+ const [sites, setSites] = useState([]);
+ const [sitesError, setSitesError] = useState("");
+ useEffect(() => {
+ if (!sessionBearer) { setSites([]); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/sites", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Site inventory unavailable.");
+ const data: { sites: { siteId: string; displayName: string; isRetired: boolean }[] } = await response.json();
+ if (!controller.signal.aborted) { setSites(data.sites); setSitesError(""); }
+ }).catch(() => {
+ if (!controller.signal.aborted) { setSites([]); setSitesError("Site selection unavailable for this account."); }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
const [name, setName] = useState("");
const [registrationMessage, setRegistrationMessage] = useState("");
const [saving, setSaving] = useState(false);
@@ -33,6 +50,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const result: { recordId: string } = await response.json();
setRegistrationMessage("Registered " + kind + ": " + result.recordId + ". Acquisition remains inactive.");
setName("");
+ setRevision(value => value + 1);
} catch (error) {
setRegistrationMessage(error instanceof Error ? error.message : "Registration failed.");
} finally { setSaving(false); }
@@ -83,8 +101,13 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{kind !== "site" && <>
Existing site ID
- setSiteId(event.currentTarget.value)} placeholder="Site GUID" />
+ setSiteId(event.currentTarget.value)}>
+ Select an active site
+ {sites.filter(site => !site.isRetired).map(site =>
+ {site.displayName} )}
+
+ {sitesError && {sitesError}
}
>}
Display name
Date: Thu, 12 Nov 2026 01:34:15 +0530
Subject: [PATCH 21/84] Provide complete authorized machine inventory separate
from evidence discovery
---
...SqlSecurityAuthority.MasterRegistration.cs | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
index 878021c7..7a2b462a 100644
--- a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
+++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
@@ -26,6 +26,27 @@ public Task> ReadAdministrationSitesAsync(
}, ct);
}
+
+ public sealed record AdministrationMachine(Guid MachineId, Guid SiteId, string DisplayName, bool IsRetired);
+ public Task> ReadAdministrationMachinesAsync(
+ byte[] sessionHash, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException();
+ return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation,
+ async (connection, transaction, token) =>
+ {
+ await using var command = new Microsoft.Data.SqlClient.SqlCommand(
+ "SELECT MachineId, SiteId, DisplayName, IsRetired FROM master.Machine ORDER BY DisplayName, MachineId",
+ connection, transaction);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ var machines = new List();
+ while (await reader.ReadAsync(token))
+ machines.Add(new AdministrationMachine(reader.GetGuid(0), reader.GetGuid(1),
+ reader.GetString(2), reader.GetBoolean(3)));
+ return (IReadOnlyList)machines;
+ }, ct);
+ }
+
// Registration is a master-data change, not an acquisition or machine activation.
// The installation-level grant is deliberately separate from evidence-reading rights.
public Task RegisterMasterRecordAsync(byte[] sessionHash, Guid operationId,
From dd0d2a10e5accc6dcc064e457b0c02a9b73df976 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 01:34:21 +0530
Subject: [PATCH 22/84] Expose authorized full machine inventory API
---
.../MasterRegistrationApi.cs | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
index 037e19b6..26d07fe6 100644
--- a/backend/FactoryConnect.Api/MasterRegistrationApi.cs
+++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
@@ -41,6 +41,38 @@ public static void MapMasterRegistration(this WebApplication app)
finally { CryptographicOperations.ZeroMemory(hash); }
}).RequireRateLimiting("security-boundary");
+ app.MapGet("/api/admin/v2/machines", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationMachinesAsync(hash, ct);
+ return Results.Ok(new { machines });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-machines", "Machine inventory unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
app.MapPost("/api/admin/v2/master-registration", async (
HttpContext http, IConfiguration config, CancellationToken ct) =>
{
From 4ad88d1f246ee4b707fbf6a48acae1eba7fcd2e6 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 01:35:12 +0530
Subject: [PATCH 23/84] Show registered machines independently from
evidence-authorized machine list
---
.../MachineAdministrationPage.tsx | 34 +++++++++++++++++--
1 file changed, 31 insertions(+), 3 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index b4352413..e60f9022 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -2,9 +2,8 @@ import { useEffect, useMemo, useState } from "react";
import { readAllOperatorEvidenceMachines, type OperatorEvidenceMachine } from "../api/evidence/operator-evidence-machines-client.ts";
/**
- * First machine-administration surface: authenticated, read-only discovery.
- * This is an evidence-authorized subset, not a complete administrative inventory.
- * Registration controls will be enabled only after audited SQL write authority exists.
+ * Administrative inventory and registration require a current authorized session.
+ * Evidence discovery is independently permission-scoped and may be incomplete.
*/
export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
readonly sessionBearer: string | null;
@@ -31,6 +30,23 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
return () => controller.abort();
}, [baseAddress, sessionBearer, revision]);
+
+ const [inventory, setInventory] = useState(null);
+ const [inventoryError, setInventoryError] = useState("");
+ useEffect(() => {
+ if (!sessionBearer) { setInventory(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/machines", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Administrator machine inventory unavailable.");
+ const data: { machines: { machineId: string; siteId: string; displayName: string; isRetired: boolean }[] } = await response.json();
+ if (!controller.signal.aborted) { setInventory(data.machines); setInventoryError(""); }
+ }).catch(() => {
+ if (!controller.signal.aborted) { setInventory(null); setInventoryError("Full machine inventory requires administrator access."); }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
const [name, setName] = useState("");
const [registrationMessage, setRegistrationMessage] = useState("");
const [saving, setSaving] = useState(false);
@@ -116,6 +132,18 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{registrationMessage && {registrationMessage}
}
}
+
+ Registered machine inventory
+ This administrator inventory includes registered machines whether or not they have published evidence.
+ {inventoryError && {inventoryError}
}
+ {inventory &&
+
Machine Machine ID Site Status
+ {inventory.map(machine =>
+ {machine.displayName} {machine.machineId}
+ {machine.siteId}{machine.isRetired ? "Retired" : "Registered"}
+ )}
+ {inventory.length === 0 &&
No machines registered yet.
}
+
}
setRevision(value => value + 1)}>Refresh authorized machines
{current.kind === "loading" && Checking authorized machines…
}
From 7cc0e08df01ae36406677a7a611463f6a95e72d3 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 09:34:30 +0530
Subject: [PATCH 24/84] Add authorized production-line inventory for
administration
---
...SqlSecurityAuthority.MasterRegistration.cs | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
index 7a2b462a..d6b7bfe4 100644
--- a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
+++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
@@ -27,6 +27,26 @@ public Task> ReadAdministrationSitesAsync(
}
+ public sealed record AdministrationLine(Guid ProductionLineId, Guid SiteId, string DisplayName, bool IsRetired);
+ public Task> ReadAdministrationLinesAsync(
+ byte[] sessionHash, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException();
+ return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation,
+ async (connection, transaction, token) =>
+ {
+ await using var command = new Microsoft.Data.SqlClient.SqlCommand(
+ "SELECT ProductionLineId, SiteId, DisplayName, IsRetired FROM master.ProductionLine ORDER BY DisplayName, ProductionLineId",
+ connection, transaction);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ var lines = new List();
+ while (await reader.ReadAsync(token))
+ lines.Add(new AdministrationLine(reader.GetGuid(0), reader.GetGuid(1),
+ reader.GetString(2), reader.GetBoolean(3)));
+ return (IReadOnlyList)lines;
+ }, ct);
+ }
+
public sealed record AdministrationMachine(Guid MachineId, Guid SiteId, string DisplayName, bool IsRetired);
public Task> ReadAdministrationMachinesAsync(
byte[] sessionHash, CancellationToken ct = default)
From 7219a9b58edc3a575ec56b98af7a07119539a4ff Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 09:34:35 +0530
Subject: [PATCH 25/84] Expose permission-controlled production-line inventory
endpoint
---
.../MasterRegistrationApi.cs | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
index 26d07fe6..367cc5a6 100644
--- a/backend/FactoryConnect.Api/MasterRegistrationApi.cs
+++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
@@ -73,6 +73,38 @@ public static void MapMasterRegistration(this WebApplication app)
finally { CryptographicOperations.ZeroMemory(hash); }
}).RequireRateLimiting("security-boundary");
+ app.MapGet("/api/admin/v2/lines", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var lines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationLinesAsync(hash, ct);
+ return Results.Ok(new { lines });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "administration-lines", "Production-line inventory unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
app.MapPost("/api/admin/v2/master-registration", async (
HttpContext http, IConfiguration config, CancellationToken ct) =>
{
From 5fb65e8a5327950427e51ecad0a4f69d11b4b4c6 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 09:34:42 +0530
Subject: [PATCH 26/84] Show production-line inventory with site names in
administration
---
.../MachineAdministrationPage.tsx | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index e60f9022..5c89144b 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -47,6 +47,21 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
});
return () => controller.abort();
}, [baseAddress, sessionBearer, revision]);
+
+ const [lines, setLines] = useState(null);
+ useEffect(() => {
+ if (!sessionBearer) { setLines(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/lines", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Production-line inventory unavailable.");
+ const data: { lines: { productionLineId: string; siteId: string; displayName: string; isRetired: boolean }[] } = await response.json();
+ if (!controller.signal.aborted) setLines(data.lines);
+ }).catch(() => { if (!controller.signal.aborted) setLines(null); });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
const [name, setName] = useState("");
const [registrationMessage, setRegistrationMessage] = useState("");
const [saving, setSaving] = useState(false);
@@ -133,6 +148,17 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
}
+
+ Registered production lines
+ {lines &&
+
Production line Site Status
+ {lines.map(line =>
+ {line.displayName}
+ {sites.find(site => site.siteId === line.siteId)?.displayName ?? line.siteId}
+ {line.isRetired ? "Retired" : "Registered"}
+ )}
+ {lines.length === 0 &&
No production lines registered yet.
}
+
}
Registered machine inventory
This administrator inventory includes registered machines whether or not they have published evidence.
{inventoryError && {inventoryError}
}
From be678cd1698876be91e2d5e9e4bbf0c9ebe2b97a Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 09:52:06 +0530
Subject: [PATCH 27/84] Preserve registration operation IDs for safe network
retries
---
src/presentation/MachineAdministrationPage.tsx | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 5c89144b..6a549a4c 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { readAllOperatorEvidenceMachines, type OperatorEvidenceMachine } from "../api/evidence/operator-evidence-machines-client.ts";
/**
@@ -65,21 +65,28 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [name, setName] = useState("");
const [registrationMessage, setRegistrationMessage] = useState("");
const [saving, setSaving] = useState(false);
+ const pendingRegistration = useRef<{ fingerprint: string; operationId: string } | null>(null);
async function register(event: { preventDefault(): void }) {
event.preventDefault();
if (!sessionBearer || saving) return;
+ const fingerprint = JSON.stringify({ sessionBearer, kind,
+ siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim() });
+ if (pendingRegistration.current?.fingerprint !== fingerprint)
+ pendingRegistration.current = { fingerprint, operationId: crypto.randomUUID() };
+ const operationId = pendingRegistration.current.operationId;
setSaving(true);
setRegistrationMessage("");
try {
const response = await fetch(new URL("/api/admin/v2/master-registration", baseAddress), {
method: "POST",
headers: { Authorization: "Bearer " + sessionBearer, "Content-Type": "application/json" },
- body: JSON.stringify({ operationId: crypto.randomUUID(), kind,
+ body: JSON.stringify({ operationId, kind,
siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim() })
});
if (!response.ok) throw new Error("Registration failed (HTTP " + response.status + ").");
const result: { recordId: string } = await response.json();
setRegistrationMessage("Registered " + kind + ": " + result.recordId + ". Acquisition remains inactive.");
+ pendingRegistration.current = null;
setName("");
setRevision(value => value + 1);
} catch (error) {
From ab2bbc4003121057c72c8d3a3dd6a851137fb832 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 10:23:52 +0530
Subject: [PATCH 28/84] Show registered sites and site names throughout machine
administration
---
src/presentation/MachineAdministrationPage.tsx | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 6a549a4c..a1c1fbaf 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -138,7 +138,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Machine
{kind !== "site" && <>
- Existing site ID
+ Site
setSiteId(event.currentTarget.value)}>
Select an active site
@@ -150,12 +150,22 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Display name
setName(event.currentTarget.value)} />
- {saving ? "Registering…" : "Register"}
+ {saving ? "Registering…" : "Register"}
{registrationMessage && {registrationMessage}
}
}
+ Registered sites
+ {sitesError && {sitesError}
}
+ {!sitesError &&
+
Site Site ID Status
+ {sites.map(site =>
+ {site.displayName} {site.siteId}
+ {site.isRetired ? "Retired" : "Registered"}
+ )}
+ {sites.length === 0 &&
No sites available to this administrator.
}
+
}
Registered production lines
{lines &&
Production line Site Status
@@ -173,7 +183,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Machine Machine ID Site Status
{inventory.map(machine =>
{machine.displayName} {machine.machineId}
- {machine.siteId}{machine.isRetired ? "Retired" : "Registered"}
+ {sites.find(site => site.siteId === machine.siteId)?.displayName ?? machine.siteId} {machine.isRetired ? "Retired" : "Registered"}
)}
{inventory.length === 0 && No machines registered yet.
}
}
From cd16f2a2b9c708a6e427d2ea787e0995a4c03af9 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 10:38:11 +0530
Subject: [PATCH 29/84] Add site and status filters to registered machine
inventory
---
.../MachineAdministrationPage.tsx | 26 ++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index a1c1fbaf..5b2f5e58 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -33,6 +33,17 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [inventory, setInventory] = useState(null);
const [inventoryError, setInventoryError] = useState("");
+ const [inventorySearch, setInventorySearch] = useState("");
+ const [inventorySite, setInventorySite] = useState("");
+ const [includeRetired, setIncludeRetired] = useState(false);
+ const visibleInventory = useMemo(() => (inventory ?? []).filter(machine => {
+ if (!includeRetired && machine.isRetired) return false;
+ if (inventorySite && machine.siteId !== inventorySite) return false;
+ const term = inventorySearch.trim().toLocaleLowerCase();
+ return !term || machine.displayName.toLocaleLowerCase().includes(term) ||
+ machine.machineId.toLocaleLowerCase().includes(term) ||
+ (sites.find(site => site.siteId === machine.siteId)?.displayName ?? "").toLocaleLowerCase().includes(term);
+ }), [inventory, inventorySearch, inventorySite, includeRetired, sites]);
useEffect(() => {
if (!sessionBearer) { setInventory(null); return; }
const controller = new AbortController();
@@ -178,14 +189,27 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
}
Registered machine inventory
This administrator inventory includes registered machines whether or not they have published evidence.
+ Search registered machines
+ setInventorySearch(event.currentTarget.value)}
+ placeholder="Machine name or ID" />
+ Filter by site
+ setInventorySite(event.currentTarget.value)}>
+ All sites
+ {sites.map(site => {site.displayName} )}
+
+
+ setIncludeRetired(event.currentTarget.checked)} /> Include retired machines
+
{inventoryError && {inventoryError}
}
{inventory &&
Machine Machine ID Site Status
- {inventory.map(machine =>
+ {visibleInventory.map(machine =>
{machine.displayName} {machine.machineId}
{sites.find(site => site.siteId === machine.siteId)?.displayName ?? machine.siteId} {machine.isRetired ? "Retired" : "Registered"}
)}
{inventory.length === 0 &&
No machines registered yet.
}
+ {inventory.length > 0 && visibleInventory.length === 0 &&
No machines match the current filters.
}
}
setRevision(value => value + 1)}>Refresh authorized machines
From 51ca25420c896782498512c7a1220167228be988 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 10:54:53 +0530
Subject: [PATCH 30/84] Filter production lines by site and retired status
---
src/presentation/MachineAdministrationPage.tsx | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 5b2f5e58..b11336c7 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -59,7 +59,12 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
return () => controller.abort();
}, [baseAddress, sessionBearer, revision]);
+ const [lineSiteFilter, setLineSiteFilter] = useState("");
+ const [showRetiredLines, setShowRetiredLines] = useState(false);
const [lines, setLines] = useState(null);
+ const visibleLines = useMemo(() => (lines ?? []).filter(line =>
+ (showRetiredLines || !line.isRetired) && (!lineSiteFilter || line.siteId === lineSiteFilter)
+ ), [lines, lineSiteFilter, showRetiredLines]);
useEffect(() => {
if (!sessionBearer) { setLines(null); return; }
const controller = new AbortController();
@@ -178,14 +183,24 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{sites.length === 0 && No sites available to this administrator.
}
}
Registered production lines
+ Production-line site
+ setLineSiteFilter(event.currentTarget.value)}>
+ All sites
+ {sites.map(site => {site.displayName} )}
+
+
+ setShowRetiredLines(event.currentTarget.checked)} /> Include retired lines
+
{lines &&
Production line Site Status
- {lines.map(line =>
+ {visibleLines.map(line =>
{line.displayName}
{sites.find(site => site.siteId === line.siteId)?.displayName ?? line.siteId}
{line.isRetired ? "Retired" : "Registered"}
)}
{lines.length === 0 &&
No production lines registered yet.
}
+ {lines.length > 0 && visibleLines.length === 0 &&
No production lines match the current filters.
}
}
Registered machine inventory
This administrator inventory includes registered machines whether or not they have published evidence.
From c2245095c4ea30e0d5ea444cf4a1cc9e60138f2b Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 11:12:01 +0530
Subject: [PATCH 31/84] Provide actionable registration conflict and
authorization feedback
---
src/presentation/MachineAdministrationPage.tsx | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index b11336c7..39be1fcc 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -99,7 +99,23 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
body: JSON.stringify({ operationId, kind,
siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim() })
});
- if (!response.ok) throw new Error("Registration failed (HTTP " + response.status + ").");
+ if (!response.ok) {
+ if (response.status === 409) {
+ let detail = "";
+ try {
+ const body: { error?: string } = await response.json();
+ detail = body.error ?? "";
+ } catch { /* The status alone is sufficient. */ }
+ if (detail === "Operation ID conflict")
+ throw new Error("This registration retry conflicts with a previous request. Change the details to start a new registration.");
+ throw new Error("A record with this name already exists, or its selected site is no longer active. Review the inventory before retrying.");
+ }
+ if (response.status === 401 || response.status === 403)
+ throw new Error("Your session cannot register master data. Sign in with an administrator account that has master-data management access.");
+ if (response.status === 400)
+ throw new Error("Invalid registration details. Check the name and selected site.");
+ throw new Error("Registration could not be confirmed (HTTP " + response.status + "). Retry the unchanged request to avoid duplicates.");
+ }
const result: { recordId: string } = await response.json();
setRegistrationMessage("Registered " + kind + ": " + result.recordId + ". Acquisition remains inactive.");
pendingRegistration.current = null;
From fe9c42d6599694ecfc781b1a5009092a9a0c3270 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 11:31:29 +0530
Subject: [PATCH 32/84] Export filtered machine inventory as CSV for
commissioning review
---
.../MachineAdministrationPage.tsx | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 39be1fcc..45252a99 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -33,6 +33,27 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [inventory, setInventory] = useState(null);
const [inventoryError, setInventoryError] = useState("");
+ function exportMachineInventory() {
+ if (!inventory) return;
+ const quote = (value: string) => '"' + value.replaceAll('"', '""') + '"';
+ const rows = [
+ ["Machine ID", "Machine", "Site ID", "Site", "Status"],
+ ...visibleInventory.map(machine => [
+ machine.machineId, machine.displayName, machine.siteId,
+ sites.find(site => site.siteId === machine.siteId)?.displayName ?? "",
+ machine.isRetired ? "Retired" : "Registered"
+ ])
+ ];
+ const csv = "\uFEFF" + rows.map(row => row.map(quote).join(",")).join("\r\n");
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "factoryconnect-machine-inventory.csv";
+ document.body.append(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ }
const [inventorySearch, setInventorySearch] = useState("");
const [inventorySite, setInventorySite] = useState("");
const [includeRetired, setIncludeRetired] = useState(false);
@@ -219,6 +240,8 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{lines.length > 0 && visibleLines.length === 0 && No production lines match the current filters.
}
}
Registered machine inventory
+ Export filtered machines (CSV)
This administrator inventory includes registered machines whether or not they have published evidence.
Search registered machines
setInventorySearch(event.currentTarget.value)}
From 16ec8c49670f0b13f8f8660526a55b464b463458 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 11:50:36 +0530
Subject: [PATCH 33/84] Clarify registration versus unverified acquisition in
machine inventory
---
src/presentation/MachineAdministrationPage.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 45252a99..208cd247 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -242,7 +242,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Registered machine inventory
Export filtered machines (CSV)
- This administrator inventory includes registered machines whether or not they have published evidence.
+ This administrator inventory includes registered machines whether or not they have published evidence. Registration does not establish an MTConnect connection, prove acquisition is running, or activate a machine. Acquisition status is not yet verified by this screen.
Search registered machines
setInventorySearch(event.currentTarget.value)}
placeholder="Machine name or ID" />
@@ -257,10 +257,10 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{inventoryError && {inventoryError}
}
{inventory &&
-
Machine Machine ID Site Status
+ Machine Machine ID Site Registration Acquisition
{visibleInventory.map(machine =>
{machine.displayName} {machine.machineId}
- {sites.find(site => site.siteId === machine.siteId)?.displayName ?? machine.siteId} {machine.isRetired ? "Retired" : "Registered"}
+ {sites.find(site => site.siteId === machine.siteId)?.displayName ?? machine.siteId} {machine.isRetired ? "Retired" : "Registered"} Not verified — configure connection and activate separately
)}
{inventory.length === 0 && No machines registered yet.
}
{inventory.length > 0 && visibleInventory.length === 0 && No machines match the current filters.
}
From 2c22d0ed4370ce997d54a74bfedf9ac97821dbba Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 12:05:22 +0530
Subject: [PATCH 34/84] Define MTConnect discovery using existing Edge Agent
configuration
---
.../mtconnect-agent-discovery.md | 39 +++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 docs/commissioning/mtconnect-agent-discovery.md
diff --git a/docs/commissioning/mtconnect-agent-discovery.md b/docs/commissioning/mtconnect-agent-discovery.md
new file mode 100644
index 00000000..64915885
--- /dev/null
+++ b/docs/commissioning/mtconnect-agent-discovery.md
@@ -0,0 +1,39 @@
+# FC2-COM-002 — MTConnect Agent discovery and machine commissioning
+
+Status: implementation contract; discovery and activation are **not** implemented by PR #76.
+
+## Reuse existing acquisition configuration
+
+FactoryConnect already has Edge-side MTConnect acquisition. Before adding any new persistent Agent configuration, inspect the existing Edge configuration model, its storage, deployment flow and the actual URL consumer. Extend that model and its approved configuration path instead of creating a second independent list of MTConnect URLs.
+
+An Agent endpoint and a CNC machine are different identities. One Agent can expose many devices. Store or reference the Agent configuration once; associate selected device identities with commissioned machine records. Never infer identity from a display name alone.
+
+## Required administrator workflow
+
+1. List existing configured Agents (URL, descriptive label and configuration status) from the authoritative configuration source. Do not expose credentials or secrets.
+2. Read the Agent's MTConnect `/probe` document through a server-side, explicitly configured Agent endpoint; do not accept arbitrary URLs from an unauthenticated browser request.
+3. Parse `Devices` and `Device` identities, including UUID when supplied, device name, data items and data item IDs. Treat probe data as untrusted external XML; disable DTD/external entities and bound response size and request duration.
+4. Show discovered devices separately from registered machines. Indicate matched, available-to-add and ambiguous identities. Require explicit review for ambiguous matches.
+5. For selected devices, register or associate the machine with an existing site and production line, then configure signal mappings through the existing configuration approval workflow.
+6. Test reachability and usable observations using the existing Edge/MTConnect integration. A successful probe does not prove a live device or usable OEE data.
+7. Activate acquisition only through the existing controlled activation path, after an administrator explicitly confirms the target machine and configuration. Do not activate on discovery or registration.
+
+## Product acceptance
+
+- One Agent with multiple devices: administrator can select a subset; no duplicate Agent URL per machine.
+- Multiple Agents: distinguish devices by stable Agent and device identity; report ambiguous identity conflicts.
+- Missing, malformed or unreachable Agent: actionable diagnostics, no machine registration or activation side effect.
+- Existing machine: reconcile by durable configured identity; no silent duplicate registration.
+- Machine assignment: site and production line are visible and editable through a controlled configuration path.
+- Read-only discovery and test: no acquisition activation, no production database change.
+- Once activated, Edge observations can be traced through accepted activity/quantity processing to operator screens and management OEE reports.
+
+## Delivery order
+
+1. Identify existing Edge Agent URL configuration and source-of-truth (V1 and V2), plus configuration publishing/acknowledgement contract.
+2. Add a read-only, authenticated API that enumerates configured Agents and discovers their `/probe` devices using that configuration.
+3. Add discovery and selection UI to Machine Administration.
+4. Implement machine-to-line assignment and durable device-to-machine mapping using the established master/configuration schema.
+5. Implement connection test, signal mapping, controlled activation and end-to-end data acceptance.
+
+Keep production activation and deployment out of repository-only work.
From 63bfc62b343f56809feae4c38fdf6d33fae9a19d Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 12:23:40 +0530
Subject: [PATCH 35/84] Add bounded secure MTConnect probe device metadata
parser
---
.../MtConnectProbeDevices.cs | 49 +++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 backend/FactoryConnect.Api/MtConnectProbeDevices.cs
diff --git a/backend/FactoryConnect.Api/MtConnectProbeDevices.cs b/backend/FactoryConnect.Api/MtConnectProbeDevices.cs
new file mode 100644
index 00000000..d6d3d64e
--- /dev/null
+++ b/backend/FactoryConnect.Api/MtConnectProbeDevices.cs
@@ -0,0 +1,49 @@
+using System.Xml;
+using System.Xml.Linq;
+
+namespace FactoryConnect.Api;
+
+///
+/// Reads MTConnect /probe device metadata without making network requests or changing
+/// machine configuration. The caller is responsible for using a trusted configured Agent.
+///
+internal static class MtConnectProbeDevices
+{
+ internal sealed record DataItem(string Id, string? Name, string? Type, string? SubType);
+ internal sealed record Device(string Id, string Name, string? Uuid, IReadOnlyList DataItems);
+
+ internal static IReadOnlyList Parse(Stream xml)
+ {
+ ArgumentNullException.ThrowIfNull(xml);
+ using var reader = XmlReader.Create(xml, new XmlReaderSettings
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null,
+ MaxCharactersInDocument = 4_000_000,
+ MaxCharactersFromEntities = 0,
+ IgnoreComments = true
+ });
+ var document = XDocument.Load(reader);
+ if (document.Root?.Name.LocalName != "MTConnectDevices")
+ throw new InvalidDataException("Response is not an MTConnect Devices document.");
+ var devices = document.Descendants().Where(node => node.Name.LocalName == "Device")
+ .Take(501).ToArray();
+ if (devices.Length > 500) throw new InvalidDataException("Too many MTConnect devices.");
+ var result = new List(devices.Length);
+ foreach (var device in devices)
+ {
+ var id = (string?)device.Attribute("id");
+ var name = (string?)device.Attribute("name");
+ if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name))
+ throw new InvalidDataException("MTConnect device is missing its ID or name.");
+ var items = device.Descendants().Where(node => node.Name.LocalName == "DataItem")
+ .Take(5001).ToArray();
+ if (items.Length > 5000) throw new InvalidDataException("Too many data items in MTConnect device.");
+ result.Add(new Device(id, name, (string?)device.Attribute("uuid"),
+ items.Select(item => new DataItem((string?)item.Attribute("id") ?? "",
+ (string?)item.Attribute("name"), (string?)item.Attribute("type"),
+ (string?)item.Attribute("subType"))).ToArray()));
+ }
+ return result;
+ }
+}
From 103177340f5845894412b7fd25804bc21983a9ae Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 12:50:52 +0530
Subject: [PATCH 36/84] Reject duplicate MTConnect device and data item
identities during discovery
---
.../FactoryConnect.Api/MtConnectProbeDevices.cs | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/backend/FactoryConnect.Api/MtConnectProbeDevices.cs b/backend/FactoryConnect.Api/MtConnectProbeDevices.cs
index d6d3d64e..15cbb5b3 100644
--- a/backend/FactoryConnect.Api/MtConnectProbeDevices.cs
+++ b/backend/FactoryConnect.Api/MtConnectProbeDevices.cs
@@ -30,16 +30,30 @@ internal static IReadOnlyList Parse(Stream xml)
.Take(501).ToArray();
if (devices.Length > 500) throw new InvalidDataException("Too many MTConnect devices.");
var result = new List(devices.Length);
+ var deviceIds = new HashSet(StringComparer.Ordinal);
+ var deviceUuids = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var device in devices)
{
var id = (string?)device.Attribute("id");
var name = (string?)device.Attribute("name");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name))
throw new InvalidDataException("MTConnect device is missing its ID or name.");
+ if (!deviceIds.Add(id))
+ throw new InvalidDataException("Duplicate MTConnect device ID.");
+ var uuid = ((string?)device.Attribute("uuid"))?.Trim();
+ if (!string.IsNullOrEmpty(uuid) && !deviceUuids.Add(uuid))
+ throw new InvalidDataException("Duplicate MTConnect device UUID.");
var items = device.Descendants().Where(node => node.Name.LocalName == "DataItem")
.Take(5001).ToArray();
if (items.Length > 5000) throw new InvalidDataException("Too many data items in MTConnect device.");
- result.Add(new Device(id, name, (string?)device.Attribute("uuid"),
+ var dataItemIds = new HashSet(StringComparer.Ordinal);
+ foreach (var item in items)
+ {
+ var dataItemId = (string?)item.Attribute("id");
+ if (string.IsNullOrWhiteSpace(dataItemId) || !dataItemIds.Add(dataItemId))
+ throw new InvalidDataException("Missing or duplicate MTConnect data item ID.");
+ }
+ result.Add(new Device(id, name, uuid,
items.Select(item => new DataItem((string?)item.Attribute("id") ?? "",
(string?)item.Attribute("name"), (string?)item.Attribute("type"),
(string?)item.Attribute("subType"))).ToArray()));
From c9620d775b11d45133b21cf0373bbe964ef770f6 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 13:09:08 +0530
Subject: [PATCH 37/84] Add read-only MTConnect Devices XML commissioning
preview
---
.../MachineAdministrationPage.tsx | 46 +++++++++++++++++++
1 file changed, 46 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 208cd247..7d3aab19 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -9,6 +9,39 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
readonly sessionBearer: string | null;
readonly baseAddress: string;
}) {
+ const [probeDevices, setProbeDevices] = useState([]);
+ const [probeError, setProbeError] = useState("");
+ async function previewProbeFile(file: File | undefined) {
+ setProbeDevices([]);
+ setProbeError("");
+ if (!file) return;
+ if (file.size > 4_000_000) { setProbeError("Devices XML must be smaller than 4 MB."); return; }
+ try {
+ const xml = new DOMParser().parseFromString(await file.text(), "application/xml");
+ if (xml.querySelector("parsererror") || xml.documentElement.localName !== "MTConnectDevices" ||
+ xml.doctype || / node.localName === "Device");
+ if (devices.length > 500) throw new Error("Too many devices.");
+ const ids = new Set();
+ const uuids = new Set();
+ const found = devices.map(device => {
+ const id = device.getAttribute("id")?.trim() ?? "";
+ const name = device.getAttribute("name")?.trim() ?? "";
+ const uuid = device.getAttribute("uuid")?.trim() ?? "";
+ if (!id || !name || ids.has(id) || (uuid && uuids.has(uuid)))
+ throw new Error("Missing or duplicate device identity.");
+ ids.add(id);
+ if (uuid) uuids.add(uuid);
+ const dataItems = Array.from(device.getElementsByTagName("*")).filter(node => node.localName === "DataItem");
+ if (dataItems.length > 5000) throw new Error("Too many data items.");
+ return { id, name, uuid, dataItems: dataItems.length };
+ });
+ setProbeDevices(found);
+ } catch (error) {
+ setProbeError(error instanceof Error ? error.message : "Unable to read MTConnect Devices XML.");
+ }
+ }
const [revision, setRevision] = useState(0);
const [search, setSearch] = useState("");
const [kind, setKind] = useState<"site" | "line" | "machine">("site");
@@ -239,6 +272,19 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{lines.length === 0 && No production lines registered yet.
}
{lines.length > 0 && visibleLines.length === 0 && No production lines match the current filters.
}
}
+ Preview MTConnect devices.xml
+ Read-only commissioning preview: choose a Devices XML file exported from an existing MTConnect Agent /probe endpoint. This does not save URLs, register machines or activate acquisition.
+ MTConnect Devices XML
+ void previewProbeFile(event.currentTarget.files?.[0])} />
+ {probeError && {probeError}
}
+ {probeDevices.length > 0 &&
+
Device Device ID UUID Data items
+ {probeDevices.map(device =>
+ {device.name} {device.id}{device.uuid || "Not supplied"}
+ {device.dataItems}
+ )}
+
}
Registered machine inventory
Export filtered machines (CSV)
From c8e3622d879bec3aa24a4b74fda651a2d8c1855e Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 13:13:30 +0530
Subject: [PATCH 38/84] Align MTConnect XML preview validation with backend
parser
---
src/presentation/MachineAdministrationPage.tsx | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 7d3aab19..63c585dd 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -17,9 +17,12 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
if (!file) return;
if (file.size > 4_000_000) { setProbeError("Devices XML must be smaller than 4 MB."); return; }
try {
- const xml = new DOMParser().parseFromString(await file.text(), "application/xml");
+ const source = await file.text();
+ if (/ node.localName === "Device");
if (devices.length > 500) throw new Error("Too many devices.");
@@ -35,6 +38,13 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
if (uuid) uuids.add(uuid);
const dataItems = Array.from(device.getElementsByTagName("*")).filter(node => node.localName === "DataItem");
if (dataItems.length > 5000) throw new Error("Too many data items.");
+ const dataItemIds = new Set();
+ for (const item of dataItems) {
+ const dataItemId = item.getAttribute("id")?.trim() ?? "";
+ if (!dataItemId || dataItemIds.has(dataItemId))
+ throw new Error("Missing or duplicate MTConnect data item ID.");
+ dataItemIds.add(dataItemId);
+ }
return { id, name, uuid, dataItems: dataItems.length };
});
setProbeDevices(found);
From 82a98efa4917cd2ae3034dcf7f54f254146a50b4 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 17:06:55 +0530
Subject: [PATCH 39/84] Connect MTConnect device preview to reviewed machine
registration form
---
src/presentation/MachineAdministrationPage.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 63c585dd..e0fc9323 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -289,10 +289,17 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
onChange={event => void previewProbeFile(event.currentTarget.files?.[0])} />
{probeError && {probeError}
}
{probeDevices.length > 0 &&
-
Device Device ID UUID Data items
+ Device Device ID UUID Data items Inventory comparison Action
{probeDevices.map(device =>
{device.name} {device.id}{device.uuid || "Not supplied"}
{device.dataItems}
+ {(inventory ?? []).some(machine => machine.displayName.toLocaleLowerCase() === device.name.toLocaleLowerCase())
+ ? "Name already in inventory — verify identity manually"
+ : "Not matched by name"}
+ !site.isRetired).length === 0}
+ onClick={() => { setKind("machine"); setName(device.name); setRegistrationMessage(
+ "Device name copied into machine registration. Choose a site and review identity before submitting. Device ID/UUID is not yet linked."); }}>
+ Prepare registration
)}
}
Registered machine inventory
From ed90a4cd3331e3e571d77a825cfc6efa09f8271d Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 17:29:31 +0530
Subject: [PATCH 40/84] Enable selected MTConnect device identity export for
commissioning review
---
.../MachineAdministrationPage.tsx | 29 ++++++++++++++++++-
1 file changed, 28 insertions(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index e0fc9323..846f01eb 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -11,8 +11,29 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
}) {
const [probeDevices, setProbeDevices] = useState([]);
const [probeError, setProbeError] = useState("");
+ const [selectedProbeIds, setSelectedProbeIds] = useState([]);
+ function exportProbeSelection() {
+ const selected = probeDevices.filter(device => selectedProbeIds.includes(device.id));
+ if (!selected.length) return;
+ const quote = (value: string) => '"' + value.replaceAll('"', '""') + '"';
+ const rows = [
+ ["MTConnect Device ID", "MTConnect Device UUID", "Device Name", "Data Item Count", "Commissioning Status"],
+ ...selected.map(device => [device.id, device.uuid, device.name, String(device.dataItems), "Review required"])
+ ];
+ const csv = "\uFEFF" + rows.map(row => row.map(quote).join(",")).join("\r\n");
+ const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "factoryconnect-mtconnect-discovered-devices.csv";
+ document.body.append(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ }
+
async function previewProbeFile(file: File | undefined) {
setProbeDevices([]);
+ setSelectedProbeIds([]);
setProbeError("");
if (!file) return;
if (file.size > 4_000_000) { setProbeError("Devices XML must be smaller than 4 MB."); return; }
@@ -289,8 +310,14 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
onChange={event => void previewProbeFile(event.currentTarget.files?.[0])} />
{probeError && {probeError}
}
{probeDevices.length > 0 &&
-
Device Device ID UUID Data items Inventory comparison Action
+
+ Export selected devices ({selectedProbeIds.length})
+ Select Device Device ID UUID Data items Inventory comparison Action
{probeDevices.map(device =>
+ setSelectedProbeIds(current => event.currentTarget.checked
+ ? [...current, device.id] : current.filter(id => id !== device.id))} />
{device.name} {device.id}{device.uuid || "Not supplied"}
{device.dataItems}
{(inventory ?? []).some(machine => machine.displayName.toLocaleLowerCase() === device.name.toLocaleLowerCase())
From 0930ade029c5527ca9b19a57420e8d42d8be5a09 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 17:46:18 +0530
Subject: [PATCH 41/84] Show discovered MTConnect DataItem metadata for signal
mapping review
---
.../MachineAdministrationPage.tsx | 28 +++++++++++++++----
1 file changed, 23 insertions(+), 5 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 846f01eb..5d771deb 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -9,8 +9,9 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
readonly sessionBearer: string | null;
readonly baseAddress: string;
}) {
- const [probeDevices, setProbeDevices] = useState([]);
+ const [probeDevices, setProbeDevices] = useState([]);
const [probeError, setProbeError] = useState("");
+ const [expandedProbeId, setExpandedProbeId] = useState(null);
const [selectedProbeIds, setSelectedProbeIds] = useState([]);
function exportProbeSelection() {
const selected = probeDevices.filter(device => selectedProbeIds.includes(device.id));
@@ -18,7 +19,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const quote = (value: string) => '"' + value.replaceAll('"', '""') + '"';
const rows = [
["MTConnect Device ID", "MTConnect Device UUID", "Device Name", "Data Item Count", "Commissioning Status"],
- ...selected.map(device => [device.id, device.uuid, device.name, String(device.dataItems), "Review required"])
+ ...selected.map(device => [device.id, device.uuid, device.name, String(device.dataItems.length), "Review required"])
];
const csv = "\uFEFF" + rows.map(row => row.map(quote).join(",")).join("\r\n");
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
@@ -34,6 +35,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
async function previewProbeFile(file: File | undefined) {
setProbeDevices([]);
setSelectedProbeIds([]);
+ setExpandedProbeId(null);
setProbeError("");
if (!file) return;
if (file.size > 4_000_000) { setProbeError("Devices XML must be smaller than 4 MB."); return; }
@@ -66,7 +68,10 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
throw new Error("Missing or duplicate MTConnect data item ID.");
dataItemIds.add(dataItemId);
}
- return { id, name, uuid, dataItems: dataItems.length };
+ return { id, name, uuid, dataItems: dataItems.map(item => ({
+ id: item.getAttribute("id") ?? "", name: item.getAttribute("name") ?? "",
+ type: item.getAttribute("type") ?? "", subType: item.getAttribute("subType") ?? ""
+ })) };
});
setProbeDevices(found);
} catch (error) {
@@ -319,15 +324,28 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
onChange={event => setSelectedProbeIds(current => event.currentTarget.checked
? [...current, device.id] : current.filter(id => id !== device.id))} />
{device.name} {device.id}{device.uuid || "Not supplied"}
- {device.dataItems}
+ {device.dataItems.length}
{(inventory ?? []).some(machine => machine.displayName.toLocaleLowerCase() === device.name.toLocaleLowerCase())
? "Name already in inventory — verify identity manually"
: "Not matched by name"}
!site.isRetired).length === 0}
onClick={() => { setKind("machine"); setName(device.name); setRegistrationMessage(
"Device name copied into machine registration. Choose a site and review identity before submitting. Device ID/UUID is not yet linked."); }}>
- Prepare registration
+ Prepare registration
+ setExpandedProbeId(current => current === device.id ? null : device.id)}>
+ {expandedProbeId === device.id ? "Hide signals" : "Inspect signals"}
+
)}
+ {probeDevices.filter(device => device.id === expandedProbeId).map(device =>
+
Signals: {device.name}
+
Read-only MTConnect DataItem metadata. Signal roles and mappings are not yet approved or saved.
+
DataItem ID Name Type Sub-type
+ {device.dataItems.map(item =>
+ {item.id}{item.name || "—"}
+ {item.type || "—"} {item.subType || "—"}
+ )}
+
+
)}
}
Registered machine inventory
Date: Thu, 12 Nov 2026 18:00:38 +0530
Subject: [PATCH 42/84] Preview existing Edge MTConnect Agent URLs and machine
identities for commissioning reconciliation
---
.../MachineAdministrationPage.tsx | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 5d771deb..b6811ceb 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -11,6 +11,44 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
}) {
const [probeDevices, setProbeDevices] = useState([]);
const [probeError, setProbeError] = useState("");
+
+ const [edgeConfigured, setEdgeConfigured] = useState([]);
+ const [edgeConfigError, setEdgeConfigError] = useState("");
+ async function previewEdgeConfig(file: File | undefined) {
+ setEdgeConfigured([]);
+ setEdgeConfigError("");
+ if (!file) return;
+ if (file.size > 2_000_000) { setEdgeConfigError("Edge configuration exceeds 2 MB."); return; }
+ try {
+ const parsed: unknown = JSON.parse(await file.text());
+ if (!parsed || typeof parsed !== "object") throw new Error("Invalid Edge configuration.");
+ const root = parsed as Record;
+ const mtconnect = root.MTConnect;
+ if (!mtconnect || typeof mtconnect !== "object") throw new Error("MTConnect configuration missing.");
+ const machines = (mtconnect as Record).Machines;
+ if (!Array.isArray(machines) || machines.length > 500) throw new Error("Invalid MTConnect machines list.");
+ const identities = new Set();
+ const configured = machines.map((entry: unknown) => {
+ if (!entry || typeof entry !== "object") throw new Error("Invalid configured machine.");
+ const value = entry as Record;
+ const machineId = value.MachineId;
+ const deviceKey = value.DeviceKey;
+ const baseUri = value.BaseUri;
+ if (typeof machineId !== "string" || typeof deviceKey !== "string" ||
+ typeof baseUri !== "string" || !machineId.trim() || !deviceKey.trim() ||
+ !/^https?:\/\//i.test(baseUri))
+ throw new Error("Configured machine is missing MachineId, DeviceKey or BaseUri.");
+ const identity = machineId.toLowerCase();
+ if (identities.has(identity)) throw new Error("Duplicate configured MachineId.");
+ identities.add(identity);
+ return { machineId, deviceKey, baseUri };
+ });
+ setEdgeConfigured(configured);
+ } catch (error) {
+ setEdgeConfigError(error instanceof Error ? error.message : "Cannot read Edge configuration.");
+ }
+ }
+
const [expandedProbeId, setExpandedProbeId] = useState(null);
const [selectedProbeIds, setSelectedProbeIds] = useState([]);
function exportProbeSelection() {
@@ -308,6 +346,22 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{lines.length === 0 && No production lines registered yet.
}
{lines.length > 0 && visibleLines.length === 0 && No production lines match the current filters.
}
}
+ Review existing Edge MTConnect configuration
+ Choose an existing Edge JSON configuration file locally to compare configured Agent URLs and machine identities. The file stays in your browser; no configuration is uploaded, saved or activated.
+ Existing Edge configuration JSON
+ void previewEdgeConfig(event.currentTarget.files?.[0])} />
+ {edgeConfigError && {edgeConfigError}
}
+ {edgeConfigured.length > 0 &&
+
Device key Machine ID Agent URL Registered machine
+ {edgeConfigured.map(machine =>
+ {machine.deviceKey} {machine.machineId}
+ {machine.baseUri}
+ {inventory === null ? "Inventory unavailable" :
+ inventory.some(item => item.machineId.toLowerCase() === machine.machineId.toLowerCase())
+ ? "Existing MachineId found" : "MachineId not registered"}
+ )}
+
}
Preview MTConnect devices.xml
Read-only commissioning preview: choose a Devices XML file exported from an existing MTConnect Agent /probe endpoint. This does not save URLs, register machines or activate acquisition.
MTConnect Devices XML
From 90709c60dde9700260dc67713f7efc0d0c25f069 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 18:17:49 +0530
Subject: [PATCH 43/84] Compare MTConnect probe device identities with existing
Edge configuration
---
.../MachineAdministrationPage.tsx | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index b6811ceb..7789c092 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -14,6 +14,14 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [edgeConfigured, setEdgeConfigured] = useState([]);
const [edgeConfigError, setEdgeConfigError] = useState("");
+
+ const probeEdgeComparison = useMemo(() => probeDevices.map(device => {
+ const matches = edgeConfigured.filter(configured =>
+ configured.deviceKey === device.name || configured.deviceKey === device.id ||
+ (device.uuid !== "" && configured.deviceKey === device.uuid));
+ return { device, matches };
+ }), [probeDevices, edgeConfigured]);
+
async function previewEdgeConfig(file: File | undefined) {
setEdgeConfigured([]);
setEdgeConfigError("");
@@ -401,6 +409,22 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
)}
}
+ {probeDevices.length > 0 && edgeConfigured.length > 0 &&
+
MTConnect / Edge identity comparison
+
Comparison uses exact DeviceKey against discovered device name, ID or UUID. Review matches before commissioning; this comparison does not authorize an identity mapping.
+
+
Discovered device Configured MachineId Result
+ {probeEdgeComparison.map(({device, matches}) =>
+ {device.name} ({device.id})
+ {matches.length === 1 ? {matches[0].machineId} : "—"}
+ {matches.length === 1 ? "Potential configuration match — verify" :
+ matches.length === 0 ? "No matching Edge DeviceKey" : "Ambiguous: multiple Edge entries"}
+ )}
+
+ {edgeConfigured.filter(configured => !probeEdgeComparison.some(row =>
+ row.matches.some(match => match.machineId === configured.machineId))).length > 0 &&
+
Some configured Edge machines were not found in the selected Devices XML. Check Agent selection and device identities.
}
+
}
Registered machine inventory
Export filtered machines (CSV)
From a256329bc02413d65b743bd422c0da44d3fb4b6c Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 18:25:51 +0530
Subject: [PATCH 44/84] Fix TypeScript nullable match access in MTConnect Edge
comparison
---
src/presentation/MachineAdministrationPage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 7789c092..2e944e7f 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -416,7 +416,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Discovered device Configured MachineId Result
{probeEdgeComparison.map(({device, matches}) =>
{device.name} ({device.id})
- {matches.length === 1 ? {matches[0].machineId} : "—"}
+ {matches.length === 1 && matches[0] ? {matches[0].machineId} : "—"}
{matches.length === 1 ? "Potential configuration match — verify" :
matches.length === 0 ? "No matching Edge DeviceKey" : "Ambiguous: multiple Edge entries"}
)}
From de201762d2282a782ae7f8c403663beec47276db Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 18:52:08 +0530
Subject: [PATCH 45/84] Flag conflicting Edge device identities and reconcile
configured MachineIds with V2 inventory
---
.../MachineAdministrationPage.tsx | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 2e944e7f..e0b69c53 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -19,8 +19,13 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const matches = edgeConfigured.filter(configured =>
configured.deviceKey === device.name || configured.deviceKey === device.id ||
(device.uuid !== "" && configured.deviceKey === device.uuid));
- return { device, matches };
- }), [probeDevices, edgeConfigured]);
+ const configuredIds = new Set(matches.map(match => match.machineId.toLowerCase()));
+ const duplicateDeviceKey = matches.length > 1;
+ const registered = matches.length === 1 && inventory?.find(machine =>
+ machine.machineId.toLowerCase() === matches[0]?.machineId.toLowerCase());
+ return { device, matches, duplicateDeviceKey, registered,
+ identityConflict: configuredIds.size > 1 || duplicateDeviceKey };
+ }), [probeDevices, edgeConfigured, inventory]);
async function previewEdgeConfig(file: File | undefined) {
setEdgeConfigured([]);
@@ -411,14 +416,16 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
}
{probeDevices.length > 0 && edgeConfigured.length > 0 &&
MTConnect / Edge identity comparison
-
Comparison uses exact DeviceKey against discovered device name, ID or UUID. Review matches before commissioning; this comparison does not authorize an identity mapping.
+
Comparison uses exact DeviceKey against discovered device name, ID or UUID. Multiple matching Edge entries block automatic reconciliation. Existing MachineIds must be reused; names are not authoritative identities. No mapping is approved or saved.
Discovered device Configured MachineId Result
- {probeEdgeComparison.map(({device, matches}) =>
+ {probeEdgeComparison.map(({device, matches, registered, identityConflict}) =>
{device.name} ({device.id})
{matches.length === 1 && matches[0] ? {matches[0].machineId} : "—"}
- {matches.length === 1 ? "Potential configuration match — verify" :
- matches.length === 0 ? "No matching Edge DeviceKey" : "Ambiguous: multiple Edge entries"}
+ {identityConflict ? "BLOCKED: multiple Edge entries match this device" :
+ matches.length === 0 ? "No matching Edge DeviceKey" :
+ registered ? "Configured MachineId exists in V2 — verify identity" :
+ "Configured MachineId absent from V2 — review before registration"}
)}
{edgeConfigured.filter(configured => !probeEdgeComparison.some(row =>
From 7f54e8a0d88559cd7f876ed021d8c205c0f46b38 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 18:59:20 +0530
Subject: [PATCH 46/84] Fix dashboard TypeScript declaration order for machine
identity reconciliation
---
.../MachineAdministrationPage.tsx | 23 ++++++++++---------
1 file changed, 12 insertions(+), 11 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index e0b69c53..c1386565 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -15,17 +15,6 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [edgeConfigured, setEdgeConfigured] = useState([]);
const [edgeConfigError, setEdgeConfigError] = useState("");
- const probeEdgeComparison = useMemo(() => probeDevices.map(device => {
- const matches = edgeConfigured.filter(configured =>
- configured.deviceKey === device.name || configured.deviceKey === device.id ||
- (device.uuid !== "" && configured.deviceKey === device.uuid));
- const configuredIds = new Set(matches.map(match => match.machineId.toLowerCase()));
- const duplicateDeviceKey = matches.length > 1;
- const registered = matches.length === 1 && inventory?.find(machine =>
- machine.machineId.toLowerCase() === matches[0]?.machineId.toLowerCase());
- return { device, matches, duplicateDeviceKey, registered,
- identityConflict: configuredIds.size > 1 || duplicateDeviceKey };
- }), [probeDevices, edgeConfigured, inventory]);
async function previewEdgeConfig(file: File | undefined) {
setEdgeConfigured([]);
@@ -153,6 +142,18 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [inventory, setInventory] = useState(null);
const [inventoryError, setInventoryError] = useState("");
+ const probeEdgeComparison = useMemo(() => probeDevices.map(device => {
+ const matches = edgeConfigured.filter(configured =>
+ configured.deviceKey === device.name || configured.deviceKey === device.id ||
+ (device.uuid !== "" && configured.deviceKey === device.uuid));
+ const configuredIds = new Set(matches.map(match => match.machineId.toLowerCase()));
+ const duplicateDeviceKey = matches.length > 1;
+ const registered = matches.length === 1 && inventory?.find(machine =>
+ machine.machineId.toLowerCase() === matches[0]?.machineId.toLowerCase());
+ return { device, matches, duplicateDeviceKey, registered,
+ identityConflict: configuredIds.size > 1 || duplicateDeviceKey };
+ }), [probeDevices, edgeConfigured, inventory]);
+
function exportMachineInventory() {
if (!inventory) return;
const quote = (value: string) => '"' + value.replaceAll('"', '""') + '"';
From bf4e66303851758ce2f54aa90c7adbbae1c713f3 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 19:55:25 +0530
Subject: [PATCH 47/84] Add authenticated read-only MTConnect probe discovery
from existing Edge configuration
---
.../MtConnectDiscoveryApi.cs | 89 +++++++++++++++++++
1 file changed, 89 insertions(+)
create mode 100644 backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
diff --git a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
new file mode 100644
index 00000000..d80a59d3
--- /dev/null
+++ b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
@@ -0,0 +1,89 @@
+using System.Net;
+using System.Security.Cryptography;
+using FactoryConnect.Sql.Security;
+
+namespace FactoryConnect.Api;
+
+/// Read-only discovery against explicitly configured Edge MTConnect machines.
+public static class MtConnectDiscoveryApi
+{
+ private sealed record ConfiguredMachine(string MachineId, string DeviceKey, string BaseUri);
+
+ public static void MapMtConnectDiscovery(this WebApplication app)
+ {
+ app.MapGet("/api/admin/v2/mtconnect/discover/{machineId:guid}", async (
+ Guid machineId, HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Administration authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ // The same administration permission as machine inventory is required.
+ await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationMachinesAsync(hash, ct);
+
+ // Read the Edge configuration as supplied to this host; no second Agent URL registry.
+ var configured = config.GetSection("MTConnect:Machines").Get() ?? [];
+ var target = configured.SingleOrDefault(machine =>
+ Guid.TryParse(machine.MachineId, out var id) && id == machineId);
+ if (target is null) return Results.NotFound(new { error = "Machine not configured for MTConnect discovery" });
+ if (!Uri.TryCreate(target.BaseUri, UriKind.Absolute, out var baseUri) ||
+ baseUri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(baseUri.UserInfo) ||
+ !string.IsNullOrEmpty(baseUri.Query) || !string.IsNullOrEmpty(baseUri.Fragment))
+ return Results.Problem(statusCode: 503, title: "Configured MTConnect Agent URI invalid");
+ // The endpoint is derived exclusively from trusted host configuration, never user input.
+ var probeUri = new Uri(baseUri.ToString().TrimEnd('/') + "/probe");
+ using var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = false,
+ UseCookies = false,
+ UseProxy = false
+ };
+ using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(8) };
+ using var response = await client.GetAsync(probeUri, HttpCompletionOption.ResponseHeadersRead, ct);
+ if (!response.IsSuccessStatusCode)
+ return Results.Problem(statusCode: 502, title: "Configured MTConnect Agent probe failed");
+ if (response.Content.Headers.ContentLength is > 4_000_000)
+ return Results.Problem(statusCode: 502, title: "MTConnect probe response exceeds limit");
+ await using var source = await response.Content.ReadAsStreamAsync(ct);
+ await using var limited = new MemoryStream();
+ var chunk = new byte[8192];
+ int count;
+ while ((count = await source.ReadAsync(chunk, ct)) != 0)
+ {
+ if (limited.Length + count > 4_000_000)
+ return Results.Problem(statusCode: 502, title: "MTConnect probe response exceeds limit");
+ limited.Write(chunk, 0, count);
+ }
+ limited.Position = 0;
+ var devices = MtConnectProbeDevices.Parse(limited);
+ return Results.Ok(new { machineId, target.DeviceKey, devices, acquisitionActivated = false });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (InvalidDataException) { return Results.Problem(statusCode: 502, title: "Invalid MTConnect probe document"); }
+ catch (System.Xml.XmlException) { return Results.Problem(statusCode: 502, title: "Invalid MTConnect probe XML"); }
+ catch (HttpRequestException) { return Results.Problem(statusCode: 502, title: "Configured MTConnect Agent unreachable"); }
+ catch (TaskCanceledException) when (!ct.IsCancellationRequested)
+ { return Results.Problem(statusCode: 504, title: "MTConnect Agent probe timed out"); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "mtconnect-discovery", "MTConnect discovery unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+ }
+}
From 76eee3288e4f441190b6553e672bb264b6dd4a3d Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 19:55:32 +0530
Subject: [PATCH 48/84] Wire read-only MTConnect Agent discovery endpoint into
API host
---
backend/FactoryConnect.Api/EvidenceApiHost.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/backend/FactoryConnect.Api/EvidenceApiHost.cs b/backend/FactoryConnect.Api/EvidenceApiHost.cs
index b2908007..28f24cab 100644
--- a/backend/FactoryConnect.Api/EvidenceApiHost.cs
+++ b/backend/FactoryConnect.Api/EvidenceApiHost.cs
@@ -248,6 +248,7 @@ public static WebApplication Build(string[] args, Action?
app.MapActivityHistory();
app.MapMasterRegistration();
+app.MapMtConnectDiscovery();
app.MapMethods("/api/{**path}", ["GET", "POST"], NotReady);
app.MapFallbackToFile("index.html");
From 08234a1f23588161c23efa2d8b1de6c1dbbfec1a Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 20:17:28 +0530
Subject: [PATCH 49/84] Connect machine administration to authenticated live
MTConnect Agent discovery
---
.../MachineAdministrationPage.tsx | 55 +++++++++++++++++++
1 file changed, 55 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index c1386565..f2f3da8e 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -15,6 +15,47 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [edgeConfigured, setEdgeConfigured] = useState([]);
const [edgeConfigError, setEdgeConfigError] = useState("");
+ const [liveDiscoveryMachineId, setLiveDiscoveryMachineId] = useState("");
+ const [liveDiscoveryBusy, setLiveDiscoveryBusy] = useState(false);
+ const [liveDiscoveryMessage, setLiveDiscoveryMessage] = useState("");
+ async function discoverConfiguredMachine() {
+ if (!sessionBearer || !liveDiscoveryMachineId || liveDiscoveryBusy) return;
+ setLiveDiscoveryBusy(true);
+ setLiveDiscoveryMessage("");
+ try {
+ const response = await fetch(new URL("/api/admin/v2/mtconnect/discover/" +
+ encodeURIComponent(liveDiscoveryMachineId), baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }
+ });
+ if (!response.ok) {
+ if (response.status === 404)
+ throw new Error("This machine is not configured in the API host's existing Edge MTConnect settings.");
+ if (response.status === 401 || response.status === 403)
+ throw new Error("Administrator authorization is required for live discovery.");
+ throw new Error("Agent discovery failed (HTTP " + response.status + ").");
+ }
+ const payload: { devices: readonly {
+ id: string; name: string; uuid?: string | null;
+ dataItems: readonly { id: string; name?: string | null; type?: string | null; subType?: string | null }[]
+ }[] } = await response.json();
+ const discovered = payload.devices.map(device => ({
+ id: device.id, name: device.name, uuid: device.uuid ?? "",
+ dataItems: device.dataItems.map(item => ({
+ id: item.id, name: item.name ?? "", type: item.type ?? "", subType: item.subType ?? ""
+ }))
+ }));
+ setProbeDevices(discovered);
+ setSelectedProbeIds([]);
+ setExpandedProbeId(null);
+ setProbeError("");
+ setLiveDiscoveryMessage("Read-only Agent probe completed: " + discovered.length +
+ " devices. Review identities and signals before commissioning.");
+ } catch (error) {
+ setLiveDiscoveryMessage(error instanceof Error ? error.message : "Agent discovery unavailable.");
+ } finally { setLiveDiscoveryBusy(false); }
+ }
+
+
async function previewEdgeConfig(file: File | undefined) {
setEdgeConfigured([]);
@@ -360,6 +401,20 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{lines.length === 0 && No production lines registered yet.
}
{lines.length > 0 && visibleLines.length === 0 && No production lines match the current filters.
}
}
+ Discover devices from a configured MTConnect Agent
+ Read-only server-side /probe request. The API host must already have the trusted Edge MTConnect configuration loaded. Discovery does not register or activate machines.
+ Configured machine
+ setLiveDiscoveryMachineId(event.currentTarget.value)}>
+ Choose an existing registered machine
+ {(inventory ?? []).filter(machine => !machine.isRetired).map(machine =>
+ {machine.displayName} )}
+
+ void discoverConfiguredMachine()}>
+ {liveDiscoveryBusy ? "Discovering…" : "Discover Agent devices"}
+
+ {liveDiscoveryMessage && {liveDiscoveryMessage}
}
Review existing Edge MTConnect configuration
Choose an existing Edge JSON configuration file locally to compare configured Agent URLs and machine identities. The file stays in your browser; no configuration is uploaded, saved or activated.
Existing Edge configuration JSON
From 9133915b7548cf98df5c80edc96729c377dab9da Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 20:38:03 +0530
Subject: [PATCH 50/84] Qualify live MTConnect probe against registered
machines and configured DeviceKey
---
.../MtConnectDiscoveryApi.cs | 20 ++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
index d80a59d3..32ecf26f 100644
--- a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
+++ b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
@@ -1,4 +1,3 @@
-using System.Net;
using System.Security.Cryptography;
using FactoryConnect.Sql.Security;
@@ -35,8 +34,10 @@ public static void MapMtConnectDiscovery(this WebApplication app)
try
{
// The same administration permission as machine inventory is required.
- await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
.ReadAdministrationMachinesAsync(hash, ct);
+ if (!machines.Any(machine => machine.MachineId == machineId && !machine.IsRetired))
+ return Results.NotFound(new { error = "Active registered machine not found" });
// Read the Edge configuration as supplied to this host; no second Agent URL registry.
var configured = config.GetSection("MTConnect:Machines").Get() ?? [];
@@ -73,7 +74,20 @@ baseUri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(baseUri.UserI
}
limited.Position = 0;
var devices = MtConnectProbeDevices.Parse(limited);
- return Results.Ok(new { machineId, target.DeviceKey, devices, acquisitionActivated = false });
+ var matching = devices.Where(device =>
+ string.Equals(device.Id, target.DeviceKey, StringComparison.Ordinal) ||
+ string.Equals(device.Name, target.DeviceKey, StringComparison.Ordinal) ||
+ (!string.IsNullOrWhiteSpace(device.Uuid) &&
+ string.Equals(device.Uuid, target.DeviceKey, StringComparison.OrdinalIgnoreCase))).ToArray();
+ var identityStatus = matching.Length switch
+ {
+ 0 => "unmatched",
+ 1 => "potential-match",
+ _ => "ambiguous"
+ };
+ return Results.Ok(new { machineId, target.DeviceKey, devices, identityStatus,
+ matchingDeviceIds = matching.Select(device => device.Id).ToArray(),
+ acquisitionActivated = false });
}
catch (SecurityDeniedException) { return Results.Unauthorized(); }
catch (InvalidDataException) { return Results.Problem(statusCode: 502, title: "Invalid MTConnect probe document"); }
From e6c6524c3585deea6019e4ce31cbbddfe7594655 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 20:38:12 +0530
Subject: [PATCH 51/84] Display live Agent DeviceKey reconciliation status in
machine administration
---
src/presentation/MachineAdministrationPage.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index f2f3da8e..4916e73c 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -34,7 +34,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
throw new Error("Administrator authorization is required for live discovery.");
throw new Error("Agent discovery failed (HTTP " + response.status + ").");
}
- const payload: { devices: readonly {
+ const payload: { identityStatus: string; matchingDeviceIds: readonly string[]; devices: readonly {
id: string; name: string; uuid?: string | null;
dataItems: readonly { id: string; name?: string | null; type?: string | null; subType?: string | null }[]
}[] } = await response.json();
@@ -49,7 +49,11 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
setExpandedProbeId(null);
setProbeError("");
setLiveDiscoveryMessage("Read-only Agent probe completed: " + discovered.length +
- " devices. Review identities and signals before commissioning.");
+ " devices. Configured DeviceKey identity: " + payload.identityStatus +
+ " (" + payload.matchingDeviceIds.length + " matches). " +
+ (payload.identityStatus === "potential-match"
+ ? "Verify the matching device before commissioning."
+ : "Identity requires administrator review; no device mapping was saved."));
} catch (error) {
setLiveDiscoveryMessage(error instanceof Error ? error.message : "Agent discovery unavailable.");
} finally { setLiveDiscoveryBusy(false); }
From 39d3c570d4bbf30eb15ed53da8bb189d119c9b68 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 21:18:25 +0530
Subject: [PATCH 52/84] Load trusted Edge MTConnect machine addresses without
importing unrelated Edge settings
---
.../EdgeMtConnectConfiguration.cs | 51 +++++++++++++++++++
1 file changed, 51 insertions(+)
create mode 100644 backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs
diff --git a/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs b/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs
new file mode 100644
index 00000000..44a4f30c
--- /dev/null
+++ b/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+using Microsoft.Extensions.Configuration;
+
+namespace FactoryConnect.Api;
+
+///
+/// Imports only MTConnect machine addresses from a trusted local Edge configuration file.
+/// Never merges the Edge document's security, database or identity settings into the API.
+///
+internal static class EdgeMtConnectConfiguration
+{
+ internal static void Load(WebApplicationBuilder builder)
+ {
+ var path = builder.Configuration["MTConnect:EdgeConfigurationFile"];
+ if (string.IsNullOrWhiteSpace(path)) return;
+ if (!Path.IsPathFullyQualified(path))
+ throw new InvalidOperationException("MTConnect:EdgeConfigurationFile must be an absolute path.");
+ using var file = File.OpenRead(path);
+ if (file.Length > 2_000_000)
+ throw new InvalidOperationException("Edge configuration exceeds the 2 MB limit.");
+ using var document = JsonDocument.Parse(file, new JsonDocumentOptions { MaxDepth = 32 });
+ if (!document.RootElement.TryGetProperty("MTConnect", out var mtconnect) ||
+ !mtconnect.TryGetProperty("Machines", out var machines) ||
+ machines.ValueKind != JsonValueKind.Array || machines.GetArrayLength() > 500)
+ throw new InvalidOperationException("Edge configuration must contain MTConnect.Machines.");
+ var values = new Dictionary();
+ var seen = new HashSet();
+ var index = 0;
+ foreach (var machine in machines.EnumerateArray())
+ {
+ if (machine.ValueKind != JsonValueKind.Object ||
+ !machine.TryGetProperty("MachineId", out var idValue) ||
+ !Guid.TryParse(idValue.GetString(), out var machineId) ||
+ !seen.Add(machineId) ||
+ !machine.TryGetProperty("DeviceKey", out var keyValue) ||
+ string.IsNullOrWhiteSpace(keyValue.GetString()) ||
+ !machine.TryGetProperty("BaseUri", out var uriValue) ||
+ !Uri.TryCreate(uriValue.GetString(), UriKind.Absolute, out var uri) ||
+ uri.Scheme is not ("http" or "https") ||
+ !string.IsNullOrEmpty(uri.UserInfo) ||
+ !string.IsNullOrEmpty(uri.Query) ||
+ !string.IsNullOrEmpty(uri.Fragment))
+ throw new InvalidOperationException("Invalid or duplicate MTConnect machine entry in Edge configuration.");
+ var prefix = $"MTConnect:Machines:{index++}:";
+ values[prefix + "MachineId"] = machineId.ToString();
+ values[prefix + "DeviceKey"] = keyValue.GetString();
+ values[prefix + "BaseUri"] = uri.ToString();
+ }
+ builder.Configuration.AddInMemoryCollection(values);
+ }
+}
From 3fccc90bb78649c85d892cfc13b8164f6221f8b2 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 21:18:31 +0530
Subject: [PATCH 53/84] Enable opt-in trusted local Edge configuration for
MTConnect live discovery
---
backend/FactoryConnect.Api/EvidenceApiHost.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/backend/FactoryConnect.Api/EvidenceApiHost.cs b/backend/FactoryConnect.Api/EvidenceApiHost.cs
index 28f24cab..242ee9a2 100644
--- a/backend/FactoryConnect.Api/EvidenceApiHost.cs
+++ b/backend/FactoryConnect.Api/EvidenceApiHost.cs
@@ -16,6 +16,8 @@ public static WebApplication Build(string[] args, Action?
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args,
ApplicationName = typeof(EvidenceApiHost).Assembly.FullName, WebRootPath = webRoot });
configure?.Invoke(builder);
+// Opt-in: import only MTConnect machine entries from the trusted local Edge file.
+EdgeMtConnectConfiguration.Load(builder);
builder.Services.AddOperatorLogin(builder.Configuration);
// Forwarding is opt-in and accepts only explicitly enumerated proxy IP addresses.
// An unset configuration keeps direct HTTPS behavior unchanged.
From d01d7ee384cf730488ae641549c2498b3265f37b Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 21:18:42 +0530
Subject: [PATCH 54/84] Document local Edge configuration opt-in and live
MTConnect discovery prerequisites
---
docs/commissioning/mtconnect-agent-discovery.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/commissioning/mtconnect-agent-discovery.md b/docs/commissioning/mtconnect-agent-discovery.md
index 64915885..761a3b3b 100644
--- a/docs/commissioning/mtconnect-agent-discovery.md
+++ b/docs/commissioning/mtconnect-agent-discovery.md
@@ -37,3 +37,14 @@ An Agent endpoint and a CNC machine are different identities. One Agent can expo
5. Implement connection test, signal mapping, controlled activation and end-to-end data acceptance.
Keep production activation and deployment out of repository-only work.
+
+
+## Local developer-machine live discovery (opt-in)
+
+The V2 API can now read the existing Edge `MTConnect.Machines` entries from a trusted, absolute local file path. No Agent URL is entered through the browser and no new Agent registry is created.
+
+In the **API process environment** set `MTConnect__EdgeConfigurationFile` to the absolute path of the existing Edge JSON configuration (for example, a local development copy of `edge.production.json`). Restart the API after changing the path. The API fails startup if an explicitly configured file is absent, malformed, duplicated by MachineId, or contains invalid machine addresses. Only MachineId, DeviceKey and BaseUri are imported; database credentials, security configuration and other Edge settings are not loaded.
+
+The machine must already be registered in V2 with the **same MachineId** and must not be retired. Open **Administration → Machines**, select the registered machine, then choose **Discover Agent devices**. The API makes a bounded read-only `/probe` request to the configured Agent. It reports the devices and whether the configured DeviceKey has zero, one, or multiple matching devices. A potential match still requires administrator verification. Discovery never enables acquisition or saves a mapping.
+
+The API host must be able to reach the configured Agent address from its own network context. Edge loopback URLs such as `127.0.0.1` only work when the API and Agent run on the same host; do not assume a copied production Edge configuration is reachable from a developer machine. Never activate production machines just to test discovery.
From 721def505f86e66e1dcd8b33172c46984a264166 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 21:33:37 +0530
Subject: [PATCH 55/84] Expose authenticated Edge machine commissioning
readiness without Agent URL disclosure
---
.../MtConnectDiscoveryApi.cs | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
index 32ecf26f..a6ba0aba 100644
--- a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
+++ b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
@@ -10,6 +10,49 @@ private sealed record ConfiguredMachine(string MachineId, string DeviceKey, stri
public static void MapMtConnectDiscovery(this WebApplication app)
{
+ app.MapGet("/api/admin/v2/mtconnect/configured-machines", async (
+ HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Administration authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var machines = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadAdministrationMachinesAsync(hash, ct);
+ var registered = machines.Where(machine => !machine.IsRetired)
+ .Select(machine => machine.MachineId).ToHashSet();
+ var configured = config.GetSection("MTConnect:Machines").Get() ?? [];
+ var entries = configured.Select(machine => new
+ {
+ machineId = machine.MachineId,
+ deviceKey = machine.DeviceKey,
+ registered = Guid.TryParse(machine.MachineId, out var id) && registered.Contains(id)
+ }).ToArray();
+ return Results.Ok(new { configured = entries, configuredCount = entries.Length });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex,
+ "mtconnect-configuration-inventory", "MTConnect configuration inventory unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
app.MapGet("/api/admin/v2/mtconnect/discover/{machineId:guid}", async (
Guid machineId, HttpContext http, IConfiguration config, CancellationToken ct) =>
{
From 19e6eccccdc50b6b3809b232547b07d63dec0d90 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 21:33:53 +0530
Subject: [PATCH 56/84] Show live Edge-to-V2 registration readiness on Machine
Administration screen
---
.../MachineAdministrationPage.tsx | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 4916e73c..25743834 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -187,6 +187,29 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [inventory, setInventory] = useState(null);
const [inventoryError, setInventoryError] = useState("");
+ const [configuredMachines, setConfiguredMachines] = useState(null);
+ const [configuredError, setConfiguredError] = useState("");
+ useEffect(() => {
+ if (!sessionBearer) { setConfiguredMachines(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/mtconnect/configured-machines", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Configured Edge machine inventory unavailable.");
+ const data: { configured: { machineId: string; deviceKey: string; registered: boolean }[] } =
+ await response.json();
+ if (!controller.signal.aborted) { setConfiguredMachines(data.configured); setConfiguredError(""); }
+ }).catch(() => {
+ if (!controller.signal.aborted) {
+ setConfiguredMachines(null);
+ setConfiguredError("Edge configuration status unavailable.");
+ }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision]);
+
const probeEdgeComparison = useMemo(() => probeDevices.map(device => {
const matches = edgeConfigured.filter(configured =>
configured.deviceKey === device.name || configured.deviceKey === device.id ||
@@ -405,6 +428,18 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{lines.length === 0 && No production lines registered yet.
}
{lines.length > 0 && visibleLines.length === 0 && No production lines match the current filters.
}
}
+ Existing Edge configuration readiness
+ {configuredError && {configuredError}
}
+ {configuredMachines !== null &&
+ {configuredMachines.length} Edge machine entries loaded by API;
+ {" "}{configuredMachines.filter(machine => machine.registered).length} registered and active in V2.
+
}
+ {configuredMachines !== null && configuredMachines.length > 0 &&
+ {configuredMachines.map(machine =>
+ {machine.deviceKey} — {machine.registered ? "Registered in V2" : "Registration required"}
+ {" "}({machine.machineId})
+ )}
+ }
Discover devices from a configured MTConnect Agent
Read-only server-side /probe request. The API host must already have the trusted Edge MTConnect configuration loaded. Discovery does not register or activate machines.
Configured machine
From ad42c2f88e0dd8814dc4d29e6876e1fd8b1021da Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 21:53:29 +0530
Subject: [PATCH 57/84] Let administrators select Edge-configured machines
directly for live discovery
---
src/presentation/MachineAdministrationPage.tsx | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 25743834..878ac805 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -438,6 +438,13 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{configuredMachines.map(machine =>
{machine.deviceKey} — {machine.registered ? "Registered in V2" : "Registration required"}
{" "}({machine.machineId})
+ {machine.registered && {
+ setLiveDiscoveryMachineId(machine.machineId);
+ setLiveDiscoveryMessage("Selected " + machine.deviceKey +
+ ". Use Discover Agent devices to inspect its configured Agent.");
+ }}>Select for discovery }
)}
}
Discover devices from a configured MTConnect Agent
From d0691d429cbfe030b44c953ca850282b4cb76346 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 21:53:35 +0530
Subject: [PATCH 58/84] Reject malformed Edge configuration field types with
clear startup validation
---
backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs b/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs
index 44a4f30c..926e9aeb 100644
--- a/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs
+++ b/backend/FactoryConnect.Api/EdgeMtConnectConfiguration.cs
@@ -30,11 +30,14 @@ internal static void Load(WebApplicationBuilder builder)
{
if (machine.ValueKind != JsonValueKind.Object ||
!machine.TryGetProperty("MachineId", out var idValue) ||
+ idValue.ValueKind != JsonValueKind.String ||
!Guid.TryParse(idValue.GetString(), out var machineId) ||
!seen.Add(machineId) ||
!machine.TryGetProperty("DeviceKey", out var keyValue) ||
+ keyValue.ValueKind != JsonValueKind.String ||
string.IsNullOrWhiteSpace(keyValue.GetString()) ||
!machine.TryGetProperty("BaseUri", out var uriValue) ||
+ uriValue.ValueKind != JsonValueKind.String ||
!Uri.TryCreate(uriValue.GetString(), UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https") ||
!string.IsNullOrEmpty(uri.UserInfo) ||
From 79f4afe1ff807b631052186ba11c90e35b7595eb Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 22:13:24 +0530
Subject: [PATCH 59/84] Add live-probe commissioning review with device and
same-site production line selection
---
.../MachineAdministrationPage.tsx | 70 +++++++++++++++++++
1 file changed, 70 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 878ac805..624f45cf 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -18,6 +18,43 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [liveDiscoveryMachineId, setLiveDiscoveryMachineId] = useState("");
const [liveDiscoveryBusy, setLiveDiscoveryBusy] = useState(false);
const [liveDiscoveryMessage, setLiveDiscoveryMessage] = useState("");
+ const [discoverySourceMachineId, setDiscoverySourceMachineId] = useState("");
+ const [discoveryIdentityStatus, setDiscoveryIdentityStatus] = useState("");
+ const [discoveryMatchedIds, setDiscoveryMatchedIds] = useState([]);
+ const [commissioningLineId, setCommissioningLineId] = useState("");
+ const [commissioningDeviceId, setCommissioningDeviceId] = useState("");
+ function exportCommissioningReview() {
+ const machine = inventory?.find(item => item.machineId === discoverySourceMachineId);
+ const device = probeDevices.find(item => item.id === commissioningDeviceId);
+ const line = lines?.find(item => item.productionLineId === commissioningLineId);
+ if (!machine || !device || !line || line.siteId !== machine.siteId ||
+ machine.isRetired || line.isRetired) return;
+ const review = {
+ format: "factoryconnect-v2-mtconnect-commissioning-review-v1",
+ status: "review-only-not-approved",
+ acquisitionActivated: false,
+ machineId: machine.machineId,
+ machineName: machine.displayName,
+ siteId: machine.siteId,
+ productionLineId: line.productionLineId,
+ productionLineName: line.displayName,
+ device: { id: device.id, name: device.name, uuid: device.uuid },
+ configuredIdentityStatus: discoveryIdentityStatus,
+ configuredIdentityMatches: discoveryMatchedIds,
+ dataItems: device.dataItems
+ };
+ const url = URL.createObjectURL(new Blob([JSON.stringify(review, null, 2)], {
+ type: "application/json"
+ }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "factoryconnect-commissioning-review-" + machine.machineId + ".json";
+ document.body.append(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ }
+
async function discoverConfiguredMachine() {
if (!sessionBearer || !liveDiscoveryMachineId || liveDiscoveryBusy) return;
setLiveDiscoveryBusy(true);
@@ -45,6 +82,11 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
}))
}));
setProbeDevices(discovered);
+ setDiscoverySourceMachineId(liveDiscoveryMachineId);
+ setDiscoveryIdentityStatus(payload.identityStatus);
+ setDiscoveryMatchedIds(payload.matchingDeviceIds);
+ setCommissioningDeviceId(payload.matchingDeviceIds.length === 1 ? payload.matchingDeviceIds[0] : "");
+ setCommissioningLineId("");
setSelectedProbeIds([]);
setExpandedProbeId(null);
setProbeError("");
@@ -119,6 +161,11 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
async function previewProbeFile(file: File | undefined) {
setProbeDevices([]);
+ setDiscoverySourceMachineId("");
+ setDiscoveryIdentityStatus("");
+ setDiscoveryMatchedIds([]);
+ setCommissioningDeviceId("");
+ setCommissioningLineId("");
setSelectedProbeIds([]);
setExpandedProbeId(null);
setProbeError("");
@@ -461,6 +508,29 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{liveDiscoveryBusy ? "Discovering…" : "Discover Agent devices"}
{liveDiscoveryMessage && {liveDiscoveryMessage}
}
+ {discoverySourceMachineId && probeDevices.length > 0 &&
+ Prepare commissioning review
+ Select the exact MTConnect device and a production line in the registered machine's site.
+ This exports a review document only; it does not save or activate configuration.
+ Discovered device
+ setCommissioningDeviceId(event.currentTarget.value)}>
+ Select a device
+ {probeDevices.map(device =>
+ {device.name} ({device.id}) )}
+
+ Production line
+ setCommissioningLineId(event.currentTarget.value)}>
+ Select a production line
+ {(lines ?? []).filter(line => !line.isRetired && line.siteId ===
+ inventory?.find(machine => machine.machineId === discoverySourceMachineId)?.siteId)
+ .map(line =>
+ {line.displayName} )}
+
+ Export commissioning review JSON
+ }
Review existing Edge MTConnect configuration
Choose an existing Edge JSON configuration file locally to compare configured Agent URLs and machine identities. The file stays in your browser; no configuration is uploaded, saved or activated.
Existing Edge configuration JSON
From c9522ffd54434fe81cf2816c8403e81d7735df25 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 22:16:01 +0530
Subject: [PATCH 60/84] Fix optional matched device ID in commissioning review
TypeScript
---
src/presentation/MachineAdministrationPage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 624f45cf..dc94d90f 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -85,7 +85,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
setDiscoverySourceMachineId(liveDiscoveryMachineId);
setDiscoveryIdentityStatus(payload.identityStatus);
setDiscoveryMatchedIds(payload.matchingDeviceIds);
- setCommissioningDeviceId(payload.matchingDeviceIds.length === 1 ? payload.matchingDeviceIds[0] : "");
+ setCommissioningDeviceId(payload.matchingDeviceIds.length === 1 ? (payload.matchingDeviceIds[0] ?? "") : "");
setCommissioningLineId("");
setSelectedProbeIds([]);
setExpandedProbeId(null);
From 2cab62dd2b0c417b1fcee45f6c94f325f1a12301 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 22:37:23 +0530
Subject: [PATCH 61/84] Allow preserving an existing Edge machine ID during
registration
---
.../MasterData/MachineAdministrationStore.cs | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
index b22c8074..69b90d58 100644
--- a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
+++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
@@ -63,18 +63,19 @@ internal Task RegisterSiteAsync(SqlConnection connection, SqlTransaction t
internal Task RegisterLineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, CancellationToken ct = default)
=> RegisterAsync(connection, transaction, "ProductionLine", "ProductionLineId", siteId, name, ct);
- internal Task RegisterMachineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, CancellationToken ct = default)
- => RegisterAsync(connection, transaction, "Machine", "MachineId", siteId, name, ct);
+ internal Task RegisterMachineAsync(SqlConnection connection, SqlTransaction transaction, Guid siteId, string name, Guid? machineId = null, CancellationToken ct = default)
+ => RegisterAsync(connection, transaction, "Machine", "MachineId", siteId, name, ct, machineId);
private static async Task RegisterAsync(SqlConnection connection, SqlTransaction transaction,
- string table, string idColumn, Guid? siteId, string name, CancellationToken ct)
+ string table, string idColumn, Guid? siteId, string name, CancellationToken ct, Guid? requestedId = null)
{
// Identifiers are selected exclusively from the fixed methods above.
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 200)
throw new ArgumentException("Display name must contain 1 to 200 characters.", nameof(name));
if (siteId == Guid.Empty) throw new ArgumentException("Site ID is required.", nameof(siteId));
name = name.Trim();
- var id = Guid.NewGuid();
+ var id = requestedId ?? Guid.NewGuid();
+ if (id == Guid.Empty) throw new ArgumentException("Machine ID cannot be empty.");
// The security authority owns transaction isolation and commit. UPDLOCK/HOLDLOCK
// serializes duplicate-name checks until that transaction completes.
var predicate = siteId.HasValue ? "SiteId=@site AND " : "";
From af6962bc3c820943977efba219e204ad98b8995a Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 22:37:26 +0530
Subject: [PATCH 62/84] Audit explicit machine identity in master registration
operation
---
.../Security/SqlSecurityAuthority.MasterRegistration.cs | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
index d6b7bfe4..c6ffc829 100644
--- a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
+++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
@@ -70,17 +70,18 @@ public Task> ReadAdministrationMachinesAsyn
// Registration is a master-data change, not an acquisition or machine activation.
// The installation-level grant is deliberately separate from evidence-reading rights.
public Task RegisterMasterRecordAsync(byte[] sessionHash, Guid operationId,
- string kind, Guid? siteId, string displayName, CancellationToken ct = default)
+ string kind, Guid? siteId, string displayName, CancellationToken ct = default, Guid? machineId = null)
{
if (sessionHash is null || sessionHash.Length != 32 || operationId == Guid.Empty)
throw new SecurityDeniedException();
if (kind is not ("site" or "line" or "machine") ||
(kind == "site" ? siteId is not null : siteId is null || siteId == Guid.Empty) ||
- string.IsNullOrWhiteSpace(displayName) || displayName.Trim().Length > 200)
+ string.IsNullOrWhiteSpace(displayName) || displayName.Trim().Length > 200 ||
+ (machineId.HasValue && (kind != "machine" || machineId == Guid.Empty)))
throw new ArgumentException("Invalid master-data registration request.");
var name = displayName.Trim();
var token = sessionHash.ToArray();
- var request = JsonSerializer.SerializeToUtf8Bytes(new { version = 1, kind, siteId, displayName = name });
+ var request = JsonSerializer.SerializeToUtf8Bytes(new { version = 1, kind, siteId, displayName = name, machineId });
return Transaction(operationId, true, async (connection, transaction) =>
{
var state = await State(connection, transaction, ct);
@@ -101,7 +102,7 @@ await Authorize(connection, transaction, actor, "configuration.master.manage",
{
"site" => await store.RegisterSiteAsync(connection, transaction, name, ct),
"line" => await store.RegisterLineAsync(connection, transaction, siteId!.Value, name, ct),
- _ => await store.RegisterMachineAsync(connection, transaction, siteId!.Value, name, ct)
+ _ => await store.RegisterMachineAsync(connection, transaction, siteId!.Value, name, machineId, ct)
};
var resultBytes = JsonSerializer.SerializeToUtf8Bytes(new { recordId });
await Exec(connection, transaction, """
From d3b737ad823189588a06b91a37d98109fe890d9e Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 22:37:28 +0530
Subject: [PATCH 63/84] Accept optional existing Edge machine ID in admin
registration API
---
backend/FactoryConnect.Api/MasterRegistrationApi.cs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
index 367cc5a6..437fcfe4 100644
--- a/backend/FactoryConnect.Api/MasterRegistrationApi.cs
+++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
@@ -5,7 +5,7 @@ namespace FactoryConnect.Api;
public static class MasterRegistrationApi
{
- private sealed record RegistrationRequest(Guid OperationId, string Kind, Guid? SiteId, string DisplayName);
+ private sealed record RegistrationRequest(Guid OperationId, string Kind, Guid? SiteId, string DisplayName, Guid? MachineId = null);
public static void MapMasterRegistration(this WebApplication app)
{
@@ -138,11 +138,12 @@ request.Kind is not ("site" or "line" or "machine") ||
(request.Kind == "site" ? request.SiteId is not null :
request.SiteId is null || request.SiteId == Guid.Empty) ||
string.IsNullOrWhiteSpace(request.DisplayName) ||
- request.DisplayName.Trim().Length > 200)
+ request.DisplayName.Trim().Length > 200 ||
+ (request.MachineId.HasValue && (request.Kind != "machine" || request.MachineId == Guid.Empty)))
return Results.BadRequest(new { error = "Invalid registration request" });
var recordId = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
.RegisterMasterRecordAsync(sessionHash, request.OperationId, request.Kind,
- request.SiteId, request.DisplayName, ct);
+ request.SiteId, request.DisplayName, ct, request.MachineId);
return Results.Ok(new { recordId, request.OperationId, request.Kind });
}
catch (SecurityDeniedException) { return Results.Unauthorized(); }
From 616a1215056689ed3e330b500245798dc12e43c9 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 22:37:40 +0530
Subject: [PATCH 64/84] Register Edge-configured machines in V2 using their
existing machine IDs
---
.../MachineAdministrationPage.tsx | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index dc94d90f..ba6f491f 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -336,6 +336,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
}, [baseAddress, sessionBearer, revision]);
const [name, setName] = useState("");
+ const [existingMachineId, setExistingMachineId] = useState("");
const [registrationMessage, setRegistrationMessage] = useState("");
const [saving, setSaving] = useState(false);
const pendingRegistration = useRef<{ fingerprint: string; operationId: string } | null>(null);
@@ -343,7 +344,8 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
event.preventDefault();
if (!sessionBearer || saving) return;
const fingerprint = JSON.stringify({ sessionBearer, kind,
- siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim() });
+ siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim(),
+ machineId: kind === "machine" && existingMachineId ? existingMachineId : null });
if (pendingRegistration.current?.fingerprint !== fingerprint)
pendingRegistration.current = { fingerprint, operationId: crypto.randomUUID() };
const operationId = pendingRegistration.current.operationId;
@@ -354,7 +356,8 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
method: "POST",
headers: { Authorization: "Bearer " + sessionBearer, "Content-Type": "application/json" },
body: JSON.stringify({ operationId, kind,
- siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim() })
+ siteId: kind === "site" ? null : siteId.trim(), displayName: name.trim(),
+ machineId: kind === "machine" && existingMachineId ? existingMachineId : null })
});
if (!response.ok) {
if (response.status === 409) {
@@ -377,6 +380,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
setRegistrationMessage("Registered " + kind + ": " + result.recordId + ". Acquisition remains inactive.");
pendingRegistration.current = null;
setName("");
+ setExistingMachineId("");
setRevision(value => value + 1);
} catch (error) {
setRegistrationMessage(error instanceof Error ? error.message : "Registration failed.");
@@ -439,6 +443,17 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Display name
setName(event.currentTarget.value)} />
+ {kind === "machine" && <>
+ Existing Edge machine ID (optional)
+ setExistingMachineId(event.currentTarget.value)}>
+ Generate a new machine ID
+ {(configuredMachines ?? []).filter(machine => !machine.registered).map(machine =>
+
+ {machine.deviceKey} ({machine.machineId}) )}
+
+ Choose an existing Edge machine to preserve its identity. Registration does not activate acquisition.
+ >}
{saving ? "Registering…" : "Register"}
{registrationMessage && {registrationMessage}
}
}
From b2c73842138934e671bce1e8e5cf66bea7269064 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 22:51:27 +0530
Subject: [PATCH 65/84] Prepare one-click registration for unregistered
Edge-configured machines
---
src/presentation/MachineAdministrationPage.tsx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index ba6f491f..e40335e0 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -500,6 +500,14 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{configuredMachines.map(machine =>
{machine.deviceKey} — {machine.registered ? "Registered in V2" : "Registration required"}
{" "}({machine.machineId})
+ {!machine.registered && {
+ setKind("machine");
+ setExistingMachineId(machine.machineId);
+ setName(machine.deviceKey);
+ setRegistrationMessage("Registration prepared for existing Edge machine " +
+ machine.deviceKey + ". Select its site and submit to register without activating acquisition.");
+ document.getElementById("admin-record-kind")?.scrollIntoView({ behavior: "smooth" });
+ }}>Prepare registration }
{machine.registered && {
From 2e1f7cde8f18e50f8592745dc1975778d67d5bfc Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 23:13:49 +0530
Subject: [PATCH 66/84] Expose duplicate Edge machine identities and return
actionable discovery conflict
---
.../FactoryConnect.Api/MtConnectDiscoveryApi.cs | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
index a6ba0aba..f7c441f1 100644
--- a/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
+++ b/backend/FactoryConnect.Api/MtConnectDiscoveryApi.cs
@@ -38,11 +38,16 @@ public static void MapMtConnectDiscovery(this WebApplication app)
var registered = machines.Where(machine => !machine.IsRetired)
.Select(machine => machine.MachineId).ToHashSet();
var configured = config.GetSection("MTConnect:Machines").Get() ?? [];
+ var duplicateIds = configured.GroupBy(machine => machine.MachineId, StringComparer.OrdinalIgnoreCase)
+ .Where(group => group.Count() > 1)
+ .Select(group => group.Key)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
var entries = configured.Select(machine => new
{
machineId = machine.MachineId,
deviceKey = machine.DeviceKey,
- registered = Guid.TryParse(machine.MachineId, out var id) && registered.Contains(id)
+ registered = Guid.TryParse(machine.MachineId, out var id) && registered.Contains(id),
+ duplicateConfiguration = duplicateIds.Contains(machine.MachineId)
}).ToArray();
return Results.Ok(new { configured = entries, configuredCount = entries.Length });
}
@@ -84,9 +89,11 @@ public static void MapMtConnectDiscovery(this WebApplication app)
// Read the Edge configuration as supplied to this host; no second Agent URL registry.
var configured = config.GetSection("MTConnect:Machines").Get() ?? [];
- var target = configured.SingleOrDefault(machine =>
- Guid.TryParse(machine.MachineId, out var id) && id == machineId);
- if (target is null) return Results.NotFound(new { error = "Machine not configured for MTConnect discovery" });
+ var targets = configured.Where(machine =>
+ Guid.TryParse(machine.MachineId, out var id) && id == machineId).ToArray();
+ if (targets.Length == 0) return Results.NotFound(new { error = "Machine not configured for MTConnect discovery" });
+ if (targets.Length > 1) return Results.Conflict(new { error = "Multiple Edge entries reference this MachineId; resolve the configuration before discovery" });
+ var target = targets[0];
if (!Uri.TryCreate(target.BaseUri, UriKind.Absolute, out var baseUri) ||
baseUri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(baseUri.UserInfo) ||
!string.IsNullOrEmpty(baseUri.Query) || !string.IsNullOrEmpty(baseUri.Fragment))
From 34acd9eda8c18e5df0d836152a3513a18385d41c Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 23:14:02 +0530
Subject: [PATCH 67/84] Flag duplicate Edge MachineIds and disable unsafe
commissioning actions
---
src/presentation/MachineAdministrationPage.tsx | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index e40335e0..de9976b9 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -235,7 +235,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [inventory, setInventory] = useState(null);
const [inventoryError, setInventoryError] = useState("");
const [configuredMachines, setConfiguredMachines] = useState(null);
const [configuredError, setConfiguredError] = useState("");
useEffect(() => {
@@ -494,13 +494,15 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{configuredError && {configuredError}
}
{configuredMachines !== null &&
{configuredMachines.length} Edge machine entries loaded by API;
+ {" "}{configuredMachines.filter(machine => machine.duplicateConfiguration).length} duplicate entries;
{" "}{configuredMachines.filter(machine => machine.registered).length} registered and active in V2.
}
{configuredMachines !== null && configuredMachines.length > 0 &&
- {configuredMachines.map(machine =>
+ {configuredMachines.map((machine, index) =>
{machine.deviceKey} — {machine.registered ? "Registered in V2" : "Registration required"}
{" "}({machine.machineId})
- {!machine.registered && {
+ {machine.duplicateConfiguration && — Duplicate MachineId in Edge configuration; resolve before commissioning. }
+ {!machine.duplicateConfiguration && !machine.registered && {
setKind("machine");
setExistingMachineId(machine.machineId);
setName(machine.deviceKey);
@@ -508,7 +510,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
machine.deviceKey + ". Select its site and submit to register without activating acquisition.");
document.getElementById("admin-record-kind")?.scrollIntoView({ behavior: "smooth" });
}}>Prepare registration }
- {machine.registered && {
setLiveDiscoveryMachineId(machine.machineId);
From 3909bec573126d4e7012c1c7f501ceabcda89d17 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 23:15:07 +0530
Subject: [PATCH 68/84] Align configured Edge inventory response type with
duplicate identity field
---
src/presentation/MachineAdministrationPage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index de9976b9..8363847f 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -245,7 +245,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
}).then(async response => {
if (!response.ok) throw new Error("Configured Edge machine inventory unavailable.");
- const data: { configured: { machineId: string; deviceKey: string; registered: boolean }[] } =
+ const data: { configured: { machineId: string; deviceKey: string; registered: boolean; duplicateConfiguration: boolean }[] } =
await response.json();
if (!controller.signal.aborted) { setConfiguredMachines(data.configured); setConfiguredError(""); }
}).catch(() => {
From 6f9fe8164fffc3de28d711a95835f18977833d46 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 23:32:56 +0530
Subject: [PATCH 69/84] Reject duplicate imported Edge machine identities
within registration transaction
---
.../MasterData/MachineAdministrationStore.cs | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
index 69b90d58..9d718708 100644
--- a/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
+++ b/backend/FactoryConnect.Sql/MasterData/MachineAdministrationStore.cs
@@ -76,6 +76,17 @@ private static async Task RegisterAsync(SqlConnection connection, SqlTrans
name = name.Trim();
var id = requestedId ?? Guid.NewGuid();
if (id == Guid.Empty) throw new ArgumentException("Machine ID cannot be empty.");
+ // A supplied Edge identity must not silently collide with an existing V2 machine.
+ // Check it under the same transaction as the registration insert.
+ if (requestedId.HasValue)
+ {
+ await using var identityCheck = new SqlCommand(
+ "SELECT COUNT_BIG(*) FROM master.Machine WITH (UPDLOCK, HOLDLOCK) WHERE MachineId=@id",
+ connection, transaction);
+ identityCheck.Parameters.Add("@id", SqlDbType.UniqueIdentifier).Value = id;
+ if ((long)(await identityCheck.ExecuteScalarAsync(ct) ?? 0L) != 0)
+ throw new InvalidOperationException("This Edge machine identity is already registered in V2.");
+ }
// The security authority owns transaction isolation and commit. UPDLOCK/HOLDLOCK
// serializes duplicate-name checks until that transaction completes.
var predicate = siteId.HasValue ? "SiteId=@site AND " : "";
From e3aceaf0d6624d541ab4ce124c6bebe806b46430 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 23:48:37 +0530
Subject: [PATCH 70/84] Add SQL-backed machine commissioning assignment with
audited operation reference
---
.../Migrations/022-machine-commissioning.sql | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql
diff --git a/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql b/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql
new file mode 100644
index 00000000..aaca072f
--- /dev/null
+++ b/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql
@@ -0,0 +1,17 @@
+-- Commissioning assignments are administrative intent only; acquisition remains independently disabled.
+CREATE TABLE master.MachineCommissioning (
+ MachineId uniqueidentifier NOT NULL PRIMARY KEY,
+ ProductionLineId uniqueidentifier NOT NULL,
+ MtConnectDeviceId nvarchar(200) NOT NULL,
+ LastOperationId uniqueidentifier NOT NULL,
+ CONSTRAINT FK_MachineCommissioning_Machine FOREIGN KEY (MachineId)
+ REFERENCES master.Machine(MachineId),
+ CONSTRAINT FK_MachineCommissioning_Line FOREIGN KEY (ProductionLineId)
+ REFERENCES master.ProductionLine(ProductionLineId),
+ CONSTRAINT FK_MachineCommissioning_Operation FOREIGN KEY (LastOperationId)
+ REFERENCES security.SecurityOperation(OperationId),
+ CONSTRAINT CK_MachineCommissioning_DeviceId CHECK (LEN(LTRIM(RTRIM(MtConnectDeviceId))) BETWEEN 1 AND 200)
+);
+GO
+CREATE UNIQUE INDEX UX_MachineCommissioning_LineDevice
+ ON master.MachineCommissioning(ProductionLineId, MtConnectDeviceId);
From 5c35f8fd071d329d7c38021d8649c93cb90c2aa6 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 23:48:46 +0530
Subject: [PATCH 71/84] Avoid treating MTConnect device IDs as globally unique
across agents
---
.../Migrations/022-machine-commissioning.sql | 3 ---
1 file changed, 3 deletions(-)
diff --git a/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql b/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql
index aaca072f..bd6b5c2a 100644
--- a/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql
+++ b/backend/FactoryConnect.Sql/Migrations/022-machine-commissioning.sql
@@ -12,6 +12,3 @@ CREATE TABLE master.MachineCommissioning (
REFERENCES security.SecurityOperation(OperationId),
CONSTRAINT CK_MachineCommissioning_DeviceId CHECK (LEN(LTRIM(RTRIM(MtConnectDeviceId))) BETWEEN 1 AND 200)
);
-GO
-CREATE UNIQUE INDEX UX_MachineCommissioning_LineDevice
- ON master.MachineCommissioning(ProductionLineId, MtConnectDeviceId);
From 52a5b9110f816bb243a089219a8d08109fb1df10 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 23:48:53 +0530
Subject: [PATCH 72/84] Expose authorized SQL read model for saved
commissioning assignments
---
...SqlSecurityAuthority.MasterRegistration.cs | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
index c6ffc829..b40597eb 100644
--- a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
+++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
@@ -67,6 +67,28 @@ public Task> ReadAdministrationMachinesAsyn
}, ct);
}
+ public sealed record CommissioningAssignment(Guid MachineId, Guid ProductionLineId,
+ string MtConnectDeviceId, Guid LastOperationId);
+
+ public Task> ReadCommissioningAssignmentsAsync(
+ byte[] sessionHash, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32) throw new SecurityDeniedException();
+ return ReadAsync(sessionHash, "configuration.master.manage", AuthorizationScope.Installation,
+ async (connection, transaction, token) =>
+ {
+ await using var command = new Microsoft.Data.SqlClient.SqlCommand(
+ "SELECT MachineId, ProductionLineId, MtConnectDeviceId, LastOperationId FROM master.MachineCommissioning ORDER BY MachineId",
+ connection, transaction);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ var rows = new List();
+ while (await reader.ReadAsync(token))
+ rows.Add(new CommissioningAssignment(reader.GetGuid(0), reader.GetGuid(1),
+ reader.GetString(2), reader.GetGuid(3)));
+ return (IReadOnlyList)rows;
+ }, ct);
+ }
+
// Registration is a master-data change, not an acquisition or machine activation.
// The installation-level grant is deliberately separate from evidence-reading rights.
public Task RegisterMasterRecordAsync(byte[] sessionHash, Guid operationId,
From 90017199a410797504967a5cc2f84cb61d2ec0f8 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Thu, 12 Nov 2026 23:49:02 +0530
Subject: [PATCH 73/84] Add authenticated API to list persisted machine
commissioning assignments
---
.../MasterRegistrationApi.cs | 32 +++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
index 437fcfe4..536fe249 100644
--- a/backend/FactoryConnect.Api/MasterRegistrationApi.cs
+++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
@@ -73,6 +73,38 @@ public static void MapMasterRegistration(this WebApplication app)
finally { CryptographicOperations.ZeroMemory(hash); }
}).RequireRateLimiting("security-boundary");
+ app.MapGet("/api/admin/v2/commissioning-assignments", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps) return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192) return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var hash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ var assignments = await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .ReadCommissioningAssignmentsAsync(hash, ct);
+ return Results.Ok(new { assignments });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "commissioning-assignments", "Commissioning assignments unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(hash); }
+ }).RequireRateLimiting("security-boundary");
+
app.MapGet("/api/admin/v2/lines", async (HttpContext http, IConfiguration config, CancellationToken ct) =>
{
http.Response.Headers.CacheControl = "no-store";
From 03770f77ede7b7a064a6f4e5105840982ae104c5 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:09:09 +0530
Subject: [PATCH 74/84] Implement authorized audited idempotent SQL
commissioning assignment write
---
...SqlSecurityAuthority.MasterRegistration.cs | 69 +++++++++++++++++++
1 file changed, 69 insertions(+)
diff --git a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
index b40597eb..3fb836d2 100644
--- a/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
+++ b/backend/FactoryConnect.Sql/Security/SqlSecurityAuthority.MasterRegistration.cs
@@ -89,6 +89,75 @@ public Task> ReadCommissioningAssignments
}, ct);
}
+ // A commissioning assignment is administrative configuration, never acquisition activation.
+ public Task SaveCommissioningAssignmentAsync(byte[] sessionHash, Guid operationId,
+ Guid machineId, Guid productionLineId, string deviceId, CancellationToken ct = default)
+ {
+ if (sessionHash is null || sessionHash.Length != 32 || operationId == Guid.Empty)
+ throw new SecurityDeniedException();
+ if (machineId == Guid.Empty || productionLineId == Guid.Empty ||
+ string.IsNullOrWhiteSpace(deviceId) || deviceId.Trim().Length > 200)
+ throw new ArgumentException("Invalid commissioning assignment.");
+ var normalizedDeviceId = deviceId.Trim();
+ var token = sessionHash.ToArray();
+ var request = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ version = 1, action = "commissioning-assign", machineId, productionLineId,
+ deviceId = normalizedDeviceId
+ });
+ return Transaction(operationId, true, async (connection, transaction) =>
+ {
+ var state = await State(connection, transaction, ct);
+ var actor = await Session(connection, transaction, token, state.Now, ct);
+ await Authorize(connection, transaction, actor, "configuration.master.manage",
+ AuthorizationScope.Installation, state.Now, ct);
+ var completed = await FindOperation(connection, transaction, operationId, ct);
+ if (completed is not null)
+ {
+ if (completed.Actor != actor || !completed.Request.AsSpan().SequenceEqual(request))
+ throw new SecurityConflictException();
+ return 0;
+ }
+
+ await using (var check = new Microsoft.Data.SqlClient.SqlCommand("""
+ SELECT COUNT_BIG(*)
+ FROM master.Machine AS m WITH (UPDLOCK, HOLDLOCK)
+ JOIN master.ProductionLine AS l WITH (UPDLOCK, HOLDLOCK) ON l.SiteId=m.SiteId
+ WHERE m.MachineId=@machine AND l.ProductionLineId=@line
+ AND m.IsRetired=0 AND l.IsRetired=0
+ """, connection, transaction))
+ {
+ check.Parameters.AddWithValue("@machine", machineId);
+ check.Parameters.AddWithValue("@line", productionLineId);
+ if ((long)(await check.ExecuteScalarAsync(ct) ?? 0L) != 1)
+ throw new InvalidOperationException("Machine and production line must be active and in the same site.");
+ }
+ await Exec(connection, transaction, """
+ INSERT security.SecurityOperation
+ (OperationId,ActorPrincipalId,RequestBytes,RequestHash,ScopeKind,SiteId,MachineId,ResultBytes)
+ VALUES (@op,@actor,@bytes,@hash,'I',NULL,NULL,@result);
+ """, ct, ("@op", operationId), ("@actor", actor), ("@bytes", request),
+ ("@hash", SHA256.HashData(request)),
+ ("@result", JsonSerializer.SerializeToUtf8Bytes(new { saved = true })));
+ await Exec(connection, transaction, """
+ MERGE master.MachineCommissioning WITH (HOLDLOCK) AS target
+ USING (SELECT @machine AS MachineId) AS source ON target.MachineId=source.MachineId
+ WHEN MATCHED THEN UPDATE SET ProductionLineId=@line,
+ MtConnectDeviceId=@device, LastOperationId=@op
+ WHEN NOT MATCHED THEN INSERT (MachineId,ProductionLineId,MtConnectDeviceId,LastOperationId)
+ VALUES (@machine,@line,@device,@op);
+ """, ct, ("@machine", machineId), ("@line", productionLineId),
+ ("@device", normalizedDeviceId), ("@op", operationId));
+ await Exec(connection, transaction, """
+ INSERT security.SecurityAudit(AuditId,OperationId,ActorPrincipalId,Action,Reason)
+ VALUES (@id,@op,@actor,@action,@reason);
+ """, ct, ("@id", Guid.NewGuid()), ("@op", operationId), ("@actor", actor),
+ ("@action", "machine-commissioning-assign"),
+ ("@reason", "Administrator assigned production line and MTConnect device"));
+ return 0;
+ }, ct);
+ }
+
// Registration is a master-data change, not an acquisition or machine activation.
// The installation-level grant is deliberately separate from evidence-reading rights.
public Task RegisterMasterRecordAsync(byte[] sessionHash, Guid operationId,
From 7f0d6de156605124212db82ebaa2d611785d68e7 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:09:17 +0530
Subject: [PATCH 75/84] Add authenticated commissioning assignment save API
---
.../MasterRegistrationApi.cs | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/backend/FactoryConnect.Api/MasterRegistrationApi.cs b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
index 536fe249..27418791 100644
--- a/backend/FactoryConnect.Api/MasterRegistrationApi.cs
+++ b/backend/FactoryConnect.Api/MasterRegistrationApi.cs
@@ -5,6 +5,7 @@ namespace FactoryConnect.Api;
public static class MasterRegistrationApi
{
+ private sealed record CommissioningRequest(Guid OperationId, Guid MachineId, Guid ProductionLineId, string DeviceId);
private sealed record RegistrationRequest(Guid OperationId, string Kind, Guid? SiteId, string DisplayName, Guid? MachineId = null);
public static void MapMasterRegistration(this WebApplication app)
@@ -185,5 +186,49 @@ request.Kind is not ("site" or "line" or "machine") ||
catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "master-registration", "Master-data authority unavailable"); }
finally { CryptographicOperations.ZeroMemory(sessionHash); }
}).RequireRateLimiting("security-boundary");
+ app.MapPost("/api/admin/v2/commissioning-assignments", async (
+ HttpContext http, IConfiguration config, CancellationToken ct) =>
+ {
+ http.Response.Headers.CacheControl = "no-store";
+ if (!http.Request.IsHttps)
+ return Results.Problem(statusCode: 403, title: "HTTPS required");
+ if (!Guid.TryParse(config["Evidence:InstallationId"], out var installationId) ||
+ string.IsNullOrWhiteSpace(config.GetConnectionString("Evidence")))
+ return Results.Problem(statusCode: 503, title: "Master-data authority unavailable");
+ if (!http.Request.Headers.TryGetValue("Authorization", out var authorization) ||
+ authorization.Count != 1 || !authorization[0]!.StartsWith("Bearer ", StringComparison.Ordinal) ||
+ authorization[0]!.Length > 192)
+ return Results.Unauthorized();
+ byte[] bearer;
+ try { bearer = Convert.FromBase64String(authorization[0]![7..]); }
+ catch (FormatException) { return Results.Unauthorized(); }
+ if (bearer.Length != 32)
+ {
+ CryptographicOperations.ZeroMemory(bearer);
+ return Results.Unauthorized();
+ }
+ var sessionHash = SHA256.HashData(bearer);
+ CryptographicOperations.ZeroMemory(bearer);
+ try
+ {
+ CommissioningRequest? request;
+ try { request = await http.Request.ReadFromJsonAsync(cancellationToken: ct); }
+ catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = "Invalid commissioning JSON" }); }
+ if (request is null || request.OperationId == Guid.Empty ||
+ request.MachineId == Guid.Empty || request.ProductionLineId == Guid.Empty ||
+ string.IsNullOrWhiteSpace(request.DeviceId) || request.DeviceId.Trim().Length > 200)
+ return Results.BadRequest(new { error = "Invalid commissioning assignment" });
+ await new SqlSecurityAuthority(config.GetConnectionString("Evidence")!, installationId)
+ .SaveCommissioningAssignmentAsync(sessionHash, request.OperationId,
+ request.MachineId, request.ProductionLineId, request.DeviceId, ct);
+ return Results.Ok(new { saved = true, request.OperationId, acquisitionActivated = false });
+ }
+ catch (SecurityDeniedException) { return Results.Unauthorized(); }
+ catch (SecurityConflictException) { return Results.Conflict(new { error = "Operation ID conflict" }); }
+ catch (InvalidOperationException) { return Results.Conflict(new { error = "Machine or production line invalid for commissioning" }); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex) { return AuthorityDiagnostics.Unavailable(http, ex, "commissioning-save", "Commissioning authority unavailable"); }
+ finally { CryptographicOperations.ZeroMemory(sessionHash); }
+ }).RequireRateLimiting("security-boundary");
}
}
From bc97005a1e7981f2dff5320561a2e4f3ec0cff16 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:09:30 +0530
Subject: [PATCH 76/84] Let administrators save selected device and production
line assignment to SQL
---
.../MachineAdministrationPage.tsx | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 8363847f..789a5169 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -23,6 +23,36 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [discoveryMatchedIds, setDiscoveryMatchedIds] = useState([]);
const [commissioningLineId, setCommissioningLineId] = useState("");
const [commissioningDeviceId, setCommissioningDeviceId] = useState("");
+ const [commissioningSaveMessage, setCommissioningSaveMessage] = useState("");
+ const [commissioningSaving, setCommissioningSaving] = useState(false);
+ const pendingCommissioning = useRef<{ fingerprint: string; operationId: string } | null>(null);
+ async function saveCommissioningAssignment() {
+ if (!sessionBearer || commissioningSaving || !commissioningDeviceId || !commissioningLineId ||
+ !discoverySourceMachineId) return;
+ const fingerprint = JSON.stringify({
+ machineId: discoverySourceMachineId, productionLineId: commissioningLineId,
+ deviceId: commissioningDeviceId
+ });
+ if (pendingCommissioning.current?.fingerprint !== fingerprint)
+ pendingCommissioning.current = { fingerprint, operationId: crypto.randomUUID() };
+ setCommissioningSaving(true);
+ setCommissioningSaveMessage("");
+ try {
+ const response = await fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
+ method: "POST",
+ headers: { Authorization: "Bearer " + sessionBearer, "Content-Type": "application/json" },
+ body: JSON.stringify({ ...JSON.parse(fingerprint) as {
+ machineId: string; productionLineId: string; deviceId: string
+ }, operationId: pendingCommissioning.current.operationId })
+ });
+ if (!response.ok) throw new Error("Commissioning assignment was not saved (HTTP " +
+ response.status + "). Retry the same selection to preserve the operation ID.");
+ pendingCommissioning.current = null;
+ setCommissioningSaveMessage("Assignment saved in V2. Machine acquisition remains inactive.");
+ } catch (error) {
+ setCommissioningSaveMessage(error instanceof Error ? error.message : "Assignment save failed.");
+ } finally { setCommissioningSaving(false); }
+ }
function exportCommissioningReview() {
const machine = inventory?.find(item => item.machineId === discoverySourceMachineId);
const device = probeDevices.find(item => item.id === commissioningDeviceId);
@@ -555,6 +585,12 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Export commissioning review JSON
+ { void saveCommissioningAssignment(); }}>
+ {commissioningSaving ? "Saving…" : "Save commissioning assignment"}
+
+ {commissioningSaveMessage && {commissioningSaveMessage}
}
}
Review existing Edge MTConnect configuration
Choose an existing Edge JSON configuration file locally to compare configured Agent URLs and machine identities. The file stays in your browser; no configuration is uploaded, saved or activated.
From 8cc6a1c41423033c887b698c2e82ed0b359f80cb Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:21:47 +0530
Subject: [PATCH 77/84] Display saved commissioning assignments and require
verified Agent identity before save
---
.../MachineAdministrationPage.tsx | 62 ++++++++++++++++++-
1 file changed, 59 insertions(+), 3 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 789a5169..e50de7f8 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -23,12 +23,51 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [discoveryMatchedIds, setDiscoveryMatchedIds] = useState([]);
const [commissioningLineId, setCommissioningLineId] = useState("");
const [commissioningDeviceId, setCommissioningDeviceId] = useState("");
+ const [savedAssignments, setSavedAssignments] = useState(null);
+ const [savedAssignmentsError, setSavedAssignmentsError] = useState("");
+ const [assignmentRevision, setAssignmentRevision] = useState(0);
+ useEffect(() => {
+ if (!sessionBearer) { setSavedAssignments(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Saved commissioning assignments unavailable.");
+ const payload: { assignments: {
+ machineId: string; productionLineId: string; mtConnectDeviceId: string; lastOperationId: string
+ }[] } = await response.json();
+ if (!controller.signal.aborted) {
+ setSavedAssignments(payload.assignments);
+ setSavedAssignmentsError("");
+ }
+ }).catch(() => {
+ if (!controller.signal.aborted) {
+ setSavedAssignments(null);
+ setSavedAssignmentsError("Cannot load saved commissioning assignments.");
+ }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision, assignmentRevision]);
+ const selectedCommissioningMachine = inventory?.find(machine =>
+ machine.machineId === discoverySourceMachineId && !machine.isRetired);
+ const selectedCommissioningLine = lines?.find(line =>
+ line.productionLineId === commissioningLineId && !line.isRetired);
+ const commissioningSelectionValid = Boolean(selectedCommissioningMachine &&
+ selectedCommissioningLine &&
+ selectedCommissioningLine.siteId === selectedCommissioningMachine.siteId &&
+ probeDevices.some(device => device.id === commissioningDeviceId) &&
+ discoveryIdentityStatus === "potential-match" &&
+ discoveryMatchedIds.length === 1 &&
+ discoveryMatchedIds[0] === commissioningDeviceId &&
+ liveDiscoveryMachineId === discoverySourceMachineId);
const [commissioningSaveMessage, setCommissioningSaveMessage] = useState("");
const [commissioningSaving, setCommissioningSaving] = useState(false);
const pendingCommissioning = useRef<{ fingerprint: string; operationId: string } | null>(null);
async function saveCommissioningAssignment() {
if (!sessionBearer || commissioningSaving || !commissioningDeviceId || !commissioningLineId ||
- !discoverySourceMachineId) return;
+ !discoverySourceMachineId || !commissioningSelectionValid) return;
const fingerprint = JSON.stringify({
machineId: discoverySourceMachineId, productionLineId: commissioningLineId,
deviceId: commissioningDeviceId
@@ -48,6 +87,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
if (!response.ok) throw new Error("Commissioning assignment was not saved (HTTP " +
response.status + "). Retry the same selection to preserve the operation ID.");
pendingCommissioning.current = null;
+ setAssignmentRevision(value => value + 1);
setCommissioningSaveMessage("Assignment saved in V2. Machine acquisition remains inactive.");
} catch (error) {
setCommissioningSaveMessage(error instanceof Error ? error.message : "Assignment save failed.");
@@ -566,7 +606,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{discoverySourceMachineId && probeDevices.length > 0 &&
Prepare commissioning review
Select the exact MTConnect device and a production line in the registered machine's site.
- This exports a review document only; it does not save or activate configuration.
+ Export a review or save the verified assignment in V2. Saving never activates acquisition.
Discovered device
setCommissioningDeviceId(event.currentTarget.value)}>
@@ -586,12 +626,28 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
Export commissioning review JSON
{ void saveCommissioningAssignment(); }}>
{commissioningSaving ? "Saving…" : "Save commissioning assignment"}
{commissioningSaveMessage && {commissioningSaveMessage}
}
}
+ Saved commissioning assignments
+ Persisted machine-to-line and MTConnect device mappings. These are administrative records, not proof of active acquisition.
+ {savedAssignmentsError && {savedAssignmentsError}
}
+ {savedAssignments &&
+
Machine Production line MTConnect device ID Acquisition
+ {savedAssignments.map(assignment =>
+ {inventory?.find(machine => machine.machineId === assignment.machineId)?.displayName ??
+ assignment.machineId}
+ {lines?.find(line => line.productionLineId === assignment.productionLineId)?.displayName ??
+ assignment.productionLineId}
+ {assignment.mtConnectDeviceId}
+ Not activated by commissioning
+ )}
+ {savedAssignments.length === 0 &&
No commissioning assignments saved yet.
}
+
}
Review existing Edge MTConnect configuration
Choose an existing Edge JSON configuration file locally to compare configured Agent URLs and machine identities. The file stays in your browser; no configuration is uploaded, saved or activated.
Existing Edge configuration JSON
From e901b6caa3f3d94e2b63803f73a9bd042886c15e Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:24:01 +0530
Subject: [PATCH 78/84] Fix dashboard TypeScript declaration order for
commissioning assignment state
---
.../MachineAdministrationPage.tsx | 78 +++++++++----------
1 file changed, 39 insertions(+), 39 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index e50de7f8..f15c3e0b 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -23,45 +23,6 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [discoveryMatchedIds, setDiscoveryMatchedIds] = useState([]);
const [commissioningLineId, setCommissioningLineId] = useState("");
const [commissioningDeviceId, setCommissioningDeviceId] = useState("");
- const [savedAssignments, setSavedAssignments] = useState(null);
- const [savedAssignmentsError, setSavedAssignmentsError] = useState("");
- const [assignmentRevision, setAssignmentRevision] = useState(0);
- useEffect(() => {
- if (!sessionBearer) { setSavedAssignments(null); return; }
- const controller = new AbortController();
- void fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
- headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
- }).then(async response => {
- if (!response.ok) throw new Error("Saved commissioning assignments unavailable.");
- const payload: { assignments: {
- machineId: string; productionLineId: string; mtConnectDeviceId: string; lastOperationId: string
- }[] } = await response.json();
- if (!controller.signal.aborted) {
- setSavedAssignments(payload.assignments);
- setSavedAssignmentsError("");
- }
- }).catch(() => {
- if (!controller.signal.aborted) {
- setSavedAssignments(null);
- setSavedAssignmentsError("Cannot load saved commissioning assignments.");
- }
- });
- return () => controller.abort();
- }, [baseAddress, sessionBearer, revision, assignmentRevision]);
- const selectedCommissioningMachine = inventory?.find(machine =>
- machine.machineId === discoverySourceMachineId && !machine.isRetired);
- const selectedCommissioningLine = lines?.find(line =>
- line.productionLineId === commissioningLineId && !line.isRetired);
- const commissioningSelectionValid = Boolean(selectedCommissioningMachine &&
- selectedCommissioningLine &&
- selectedCommissioningLine.siteId === selectedCommissioningMachine.siteId &&
- probeDevices.some(device => device.id === commissioningDeviceId) &&
- discoveryIdentityStatus === "potential-match" &&
- discoveryMatchedIds.length === 1 &&
- discoveryMatchedIds[0] === commissioningDeviceId &&
- liveDiscoveryMachineId === discoverySourceMachineId);
const [commissioningSaveMessage, setCommissioningSaveMessage] = useState("");
const [commissioningSaving, setCommissioningSaving] = useState(false);
const pendingCommissioning = useRef<{ fingerprint: string; operationId: string } | null>(null);
@@ -405,6 +366,45 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
return () => controller.abort();
}, [baseAddress, sessionBearer, revision]);
+ const [savedAssignments, setSavedAssignments] = useState(null);
+ const [savedAssignmentsError, setSavedAssignmentsError] = useState("");
+ const [assignmentRevision, setAssignmentRevision] = useState(0);
+ useEffect(() => {
+ if (!sessionBearer) { setSavedAssignments(null); return; }
+ const controller = new AbortController();
+ void fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }, signal: controller.signal
+ }).then(async response => {
+ if (!response.ok) throw new Error("Saved commissioning assignments unavailable.");
+ const payload: { assignments: {
+ machineId: string; productionLineId: string; mtConnectDeviceId: string; lastOperationId: string
+ }[] } = await response.json();
+ if (!controller.signal.aborted) {
+ setSavedAssignments(payload.assignments);
+ setSavedAssignmentsError("");
+ }
+ }).catch(() => {
+ if (!controller.signal.aborted) {
+ setSavedAssignments(null);
+ setSavedAssignmentsError("Cannot load saved commissioning assignments.");
+ }
+ });
+ return () => controller.abort();
+ }, [baseAddress, sessionBearer, revision, assignmentRevision]);
+ const selectedCommissioningMachine = inventory?.find(machine =>
+ machine.machineId === discoverySourceMachineId && !machine.isRetired);
+ const selectedCommissioningLine = lines?.find(line =>
+ line.productionLineId === commissioningLineId && !line.isRetired);
+ const commissioningSelectionValid = Boolean(selectedCommissioningMachine &&
+ selectedCommissioningLine &&
+ selectedCommissioningLine.siteId === selectedCommissioningMachine.siteId &&
+ probeDevices.some(device => device.id === commissioningDeviceId) &&
+ discoveryIdentityStatus === "potential-match" &&
+ discoveryMatchedIds.length === 1 &&
+ discoveryMatchedIds[0] === commissioningDeviceId &&
+ liveDiscoveryMachineId === discoverySourceMachineId);
const [name, setName] = useState("");
const [existingMachineId, setExistingMachineId] = useState("");
const [registrationMessage, setRegistrationMessage] = useState("");
From acfa7fe10c3b0107d16be9ab1e7ae6e747e1b549 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:37:53 +0530
Subject: [PATCH 79/84] Give authorized machine search its own accessible input
identifier
---
src/presentation/MachineAdministrationPage.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index f15c3e0b..a53aa55e 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -754,8 +754,8 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
{current.kind === "unavailable" && Machine discovery is unavailable. No cached inventory is shown.
}
{current.kind === "ready" && <>
{!current.complete && The authorized machine list is incomplete. Counts below are partial.
}
- Search by machine, site or ID
- Search by machine, site or ID
+ setSearch(event.currentTarget.value)} />
{matches.length} matching machines from {current.machines.length} returned.
From 2eefe47a70223de00d52a26479adb1ef90b74352 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:38:01 +0530
Subject: [PATCH 80/84] Clear stale Agent discovery before commissioning a
different machine
---
src/presentation/MachineAdministrationPage.tsx | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index a53aa55e..efebbae4 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -27,8 +27,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const [commissioningSaving, setCommissioningSaving] = useState(false);
const pendingCommissioning = useRef<{ fingerprint: string; operationId: string } | null>(null);
async function saveCommissioningAssignment() {
- if (!sessionBearer || commissioningSaving || !commissioningDeviceId || !commissioningLineId ||
- !discoverySourceMachineId || !commissioningSelectionValid) return;
+ if (!sessionBearer || commissioningSaving || !commissioningSelectionValid) return;
const fingerprint = JSON.stringify({
machineId: discoverySourceMachineId, productionLineId: commissioningLineId,
deviceId: commissioningDeviceId
@@ -88,6 +87,13 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
async function discoverConfiguredMachine() {
if (!sessionBearer || !liveDiscoveryMachineId || liveDiscoveryBusy) return;
+ setDiscoverySourceMachineId("");
+ setDiscoveryIdentityStatus("");
+ setDiscoveryMatchedIds([]);
+ setCommissioningDeviceId("");
+ setCommissioningLineId("");
+ setProbeDevices([]);
+ setCommissioningSaveMessage("");
setLiveDiscoveryBusy(true);
setLiveDiscoveryMessage("");
try {
From 0abb268de6337d7f8370fe54557e3f0de486363a Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:53:10 +0530
Subject: [PATCH 81/84] Scope commissioning retry operation identity to
authenticated session
---
src/presentation/MachineAdministrationPage.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index efebbae4..35effd91 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -29,7 +29,7 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
async function saveCommissioningAssignment() {
if (!sessionBearer || commissioningSaving || !commissioningSelectionValid) return;
const fingerprint = JSON.stringify({
- machineId: discoverySourceMachineId, productionLineId: commissioningLineId,
+ sessionBearer, machineId: discoverySourceMachineId, productionLineId: commissioningLineId,
deviceId: commissioningDeviceId
});
if (pendingCommissioning.current?.fingerprint !== fingerprint)
@@ -40,9 +40,9 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
const response = await fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
method: "POST",
headers: { Authorization: "Bearer " + sessionBearer, "Content-Type": "application/json" },
- body: JSON.stringify({ ...JSON.parse(fingerprint) as {
- machineId: string; productionLineId: string; deviceId: string
- }, operationId: pendingCommissioning.current.operationId })
+ body: JSON.stringify({ machineId: discoverySourceMachineId,
+ productionLineId: commissioningLineId, deviceId: commissioningDeviceId,
+ operationId: pendingCommissioning.current.operationId })
});
if (!response.ok) throw new Error("Commissioning assignment was not saved (HTTP " +
response.status + "). Retry the same selection to preserve the operation ID.");
From 969a41cd88c9ba9f9bef9f09b03525e09896aa8d Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:53:22 +0530
Subject: [PATCH 82/84] Verify saved commissioning assignment by immediate
authenticated SQL read-back
---
.../MachineAdministrationPage.tsx | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 35effd91..78e81ead 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -48,7 +48,24 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
response.status + "). Retry the same selection to preserve the operation ID.");
pendingCommissioning.current = null;
setAssignmentRevision(value => value + 1);
- setCommissioningSaveMessage("Assignment saved in V2. Machine acquisition remains inactive.");
+ const readback = await fetch(new URL("/api/admin/v2/commissioning-assignments", baseAddress), {
+ headers: { Authorization: "Bearer " + sessionBearer }
+ });
+ if (!readback.ok) {
+ setCommissioningSaveMessage("Save accepted, but the assignment could not be verified by read-back. Refresh the inventory before continuing.");
+ return;
+ }
+ const persisted: { assignments: {
+ machineId: string; productionLineId: string; mtConnectDeviceId: string; lastOperationId: string
+ }[] } = await readback.json();
+ const match = persisted.assignments.find(assignment =>
+ assignment.machineId.toLowerCase() === discoverySourceMachineId.toLowerCase() &&
+ assignment.productionLineId.toLowerCase() === commissioningLineId.toLowerCase() &&
+ assignment.mtConnectDeviceId === commissioningDeviceId);
+ setSavedAssignments(persisted.assignments);
+ setCommissioningSaveMessage(match
+ ? "Assignment saved and verified in V2. Machine acquisition remains inactive."
+ : "Save accepted, but the selected assignment was not found on read-back. Review the saved assignments.");
} catch (error) {
setCommissioningSaveMessage(error instanceof Error ? error.message : "Assignment save failed.");
} finally { setCommissioningSaving(false); }
From 49dfb7b0bd83caac0c7e66346955f506466217ae Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 00:53:28 +0530
Subject: [PATCH 83/84] Distinguish confirmed commissioning save from read-back
parsing failure
---
src/presentation/MachineAdministrationPage.tsx | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/presentation/MachineAdministrationPage.tsx b/src/presentation/MachineAdministrationPage.tsx
index 78e81ead..df482820 100644
--- a/src/presentation/MachineAdministrationPage.tsx
+++ b/src/presentation/MachineAdministrationPage.tsx
@@ -55,9 +55,14 @@ export function MachineAdministrationPage({ sessionBearer, baseAddress }: {
setCommissioningSaveMessage("Save accepted, but the assignment could not be verified by read-back. Refresh the inventory before continuing.");
return;
}
- const persisted: { assignments: {
+ let persisted: { assignments: {
machineId: string; productionLineId: string; mtConnectDeviceId: string; lastOperationId: string
- }[] } = await readback.json();
+ }[] };
+ try { persisted = await readback.json(); }
+ catch {
+ setCommissioningSaveMessage("Save accepted, but the read-back response was invalid. Refresh the inventory to verify.");
+ return;
+ }
const match = persisted.assignments.find(assignment =>
assignment.machineId.toLowerCase() === discoverySourceMachineId.toLowerCase() &&
assignment.productionLineId.toLowerCase() === commissioningLineId.toLowerCase() &&
From 1fd1f1704f29bcf7b9993124dd1852402f18ac13 Mon Sep 17 00:00:00 2001
From: Ajay Bilgaiyan
Date: Fri, 13 Nov 2026 01:06:10 +0530
Subject: [PATCH 84/84] Document focused local SQL commissioning save/read
acceptance procedure
---
.../mtconnect-agent-discovery.md | 29 +++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/docs/commissioning/mtconnect-agent-discovery.md b/docs/commissioning/mtconnect-agent-discovery.md
index 761a3b3b..65a8eaeb 100644
--- a/docs/commissioning/mtconnect-agent-discovery.md
+++ b/docs/commissioning/mtconnect-agent-discovery.md
@@ -48,3 +48,32 @@ In the **API process environment** set `MTConnect__EdgeConfigurationFile` to the
The machine must already be registered in V2 with the **same MachineId** and must not be retired. Open **Administration → Machines**, select the registered machine, then choose **Discover Agent devices**. The API makes a bounded read-only `/probe` request to the configured Agent. It reports the devices and whether the configured DeviceKey has zero, one, or multiple matching devices. A potential match still requires administrator verification. Discovery never enables acquisition or saves a mapping.
The API host must be able to reach the configured Agent address from its own network context. Edge loopback URLs such as `127.0.0.1` only work when the API and Agent run on the same host; do not assume a copied production Edge configuration is reachable from a developer machine. Never activate production machines just to test discovery.
+
+
+## Local commissioning save/read acceptance (COM-001)
+
+Run against the developer V2 database only, after applying migration
+`022-machine-commissioning.sql`. Use an administrator session with
+`configuration.master.manage`. No Edge acquisition or production deployment is required.
+
+1. Open `/admin/machines` and register a site and production line if none exist.
+2. Register an Edge-configured machine using its existing MachineId. Verify that a
+ second registration using the same MachineId is rejected, not duplicated.
+3. Select the registered machine for live Agent discovery. Confirm that the
+ configured DeviceKey has exactly one match; ambiguous or failed discovery
+ must not allow the Save commissioning assignment action.
+4. Choose a production line belonging to the machine's site and save the
+ matching device. Confirm the UI reports **saved and verified**, then refresh
+ the browser and confirm the assignment is still listed.
+5. Change the assignment to another valid line in the same site, if available.
+ Confirm the machine has one current assignment and the audit history records
+ the change. Reusing an operation ID with different request bytes must fail.
+6. Attempt to assign a production line from a different site (using the
+ authenticated API, not just the UI). Expect HTTP 409 and no assignment change.
+7. Confirm acquisition has not been activated: the saved assignment is
+ administrative intent, not a running connector or proof of production data.
+
+If any step fails, keep PR #76 in draft and record the request, HTTP status,
+database migration version, and relevant application log without exposing
+session tokens or Edge credentials. Do not perform these checks against Gajra
+production systems.