From b90718013ef294235783332fcafe5296b165c17a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 03:48:21 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20add=20Reservations.md=20=E2=80=94=20ext?= =?UTF-8?q?ernal-ID=20reservation=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the ZTag / SAPID external-ID reservation subsystem: what a reservation is, why it sits outside the generation flow (decision #124), the ExternalIdReservation table, the lifecycle (author → publish precheck → publish-time MERGE → FleetAdmin release), and the /reservations Admin page. Linked from the docs README Operational table. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/README.md | 1 + docs/Reservations.md | 139 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 docs/Reservations.md diff --git a/docs/README.md b/docs/README.md index 36d05a4..09d3012 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,6 +55,7 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics | [Configuration.md](v1/Configuration.md) | appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — `OTOPCUA_GALAXY_*` env vars now live in mxaccessgw config) | | [security.md](security.md) | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer | | [Redundancy.md](Redundancy.md) | `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, Prometheus metrics | +| [Reservations.md](Reservations.md) | Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow | | [ServiceHosting.md](ServiceHosting.md) | Two-process deploy (Server + Admin) install/uninstall, plus the optional `OtOpcUaWonderwareHistorian` sidecar | | [StatusDashboard.md](StatusDashboard.md) | Pointer — superseded by [v2/admin-ui.md](v2/admin-ui.md) | diff --git a/docs/Reservations.md b/docs/Reservations.md new file mode 100644 index 0000000..d835e50 --- /dev/null +++ b/docs/Reservations.md @@ -0,0 +1,139 @@ +# External-ID Reservations + +The reservation subsystem guarantees that an asset's **external identifiers** +— its `ZTag` and `SAPID` — belong to exactly one piece of equipment across the +entire fleet, for all time. It is the mechanism that stops two pieces of +equipment (in the same cluster or different clusters, in the current generation +or an old one) from silently claiming the same plant tag. + +This is **decision #124** in `docs/v2/plan.md`. + +## What a reservation is + +An `ExternalIdReservation` row is a permanent, fleet-wide claim on one +identifier value by one `EquipmentUuid`. There are two kinds +(`ReservationKind`): + +| Kind | What it is | +|---------|------------| +| `ZTag` | The plant's tag identity for a physical asset. | +| `SAPID` | The asset's SAP record ID. | + +An `Equipment` row may carry a `ZTag`, a `SAPID`, both, or neither. Whenever it +carries one and the generation is published, a reservation is created for that +value. + +## Why it sits outside the generation flow + +Every other part of the configuration is **generation-versioned** — authored in +a draft, promoted by publish, superseded by the next publish, and reversible by +rollback. Reservations deliberately are **not**. + +The reason: a single ZTag can legitimately appear in many places at once — the +current published generation, several superseded generations, and a piece of +equipment that has since been disabled. A per-generation uniqueness index would +fail the instant you roll back to an older generation or re-enable disabled +equipment, because the "old" copy of the identifier is still on disk. + +So the reservation table is a flat, fleet-wide ledger that lives *beside* the +generation flow. It is append-mostly: rows are created, their `LastPublishedAt` +is refreshed, and they are eventually *released* — but never silently deleted. + +## The table + +`ExternalIdReservation` (Config DB): + +| Column | Notes | +|--------------------|-------| +| `ReservationId` | Surrogate PK (`NEWSEQUENTIALID()`). | +| `Kind` | `ZTag` or `SAPID`. | +| `Value` | The reserved identifier string. | +| `EquipmentUuid` | The equipment that owns the claim. Stays bound even when that equipment is disabled. | +| `ClusterId` | The first cluster to publish the reservation. | +| `FirstPublishedAt` / `FirstPublishedBy` | When and by whom the claim was first made. | +| `LastPublishedAt` | Refreshed on every subsequent publish that re-asserts the same `(Kind, Value, EquipmentUuid)`. | +| `ReleasedAt` / `ReleasedBy` / `ReleaseReason` | Non-null once a FleetAdmin explicitly releases the claim. A row with `ReleasedAt IS NULL` is *active*. | + +There is no foreign key from `EquipmentUuid` / `ClusterId` to their tables — by +design, so a reservation survives the deletion or disabling of the equipment +that owns it. + +## Lifecycle + +### 1. Authoring + +You give an `Equipment` row a `ZTag` and/or `SAPID` in a **draft** generation — +either by hand in the draft editor or via equipment CSV import. Nothing is +reserved yet; the draft is just a proposal. + +> Equipment CSV import does **not** pre-check reservation conflicts (tracked as +> task #197). A conflict introduced by import surfaces at publish time, below. + +### 2. Publish precheck + +`sp_PublishGeneration` runs the draft validation first. If a `ZTag` or `SAPID` +in the draft is already reserved — `ReleasedAt IS NULL` — by a **different** +`EquipmentUuid`, the publish is rejected: + +``` +BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a +different EquipmentUuid +``` + +The same value owned by the *same* `EquipmentUuid` is fine — that is just the +asset keeping its identifier across generations. + +### 3. Publish (the reservation is created) + +When the publish succeeds, `sp_PublishGeneration` runs a `MERGE` into +`ExternalIdReservation` for every `ZTag`/`SAPID` in the published generation: + +- **New** `(Kind, Value, EquipmentUuid)` → a reservation row is **inserted**. + `FirstPublishedBy` is the publishing user; `ClusterId` is the publishing + cluster. +- **Already present** → only `LastPublishedAt` is bumped. + +So the *first* publish of an equipment carrying a ZTag is what claims that ZTag +for the fleet. After that the claim is permanent — it survives the equipment +being disabled, the generation being superseded, or a rollback. + +### 4. Release + +Reusing an identifier for a **different** piece of equipment requires a +FleetAdmin to explicitly release the existing claim. Release runs +`sp_ReleaseExternalIdReservation`, which: + +- Requires a non-empty **reason** — a hard audit invariant; the procedure + raises an error without one. +- Stamps `ReleasedAt`, `ReleasedBy` (`SUSER_SNAME()`), and `ReleaseReason` + rather than deleting the row, so the history is preserved. +- Once released, the `(Kind, Value)` pair is free — a different + `EquipmentUuid` can claim it on a future publish. + +Release the claim **only** when the physical asset is permanently retired and +its identifier genuinely needs to be reused. A reservation is meant to be +permanent for the life of the asset. + +## The Admin page + +`/reservations` (Admin UI) is the operator surface. It is **FleetAdmin-only** +(the `CanPublish` policy). + +- **Active** table — every reservation with `ReleasedAt IS NULL`: kind, value, + owning `EquipmentUuid`, cluster, and the first/last publish stamps. Each row + has a **Release…** action. +- **Released** table — the 100 most recently released reservations, with the + releasing user and reason. +- **Release dialog** — opened from an active row; it requires a reason before + the Release button will submit, mirroring the procedure's audit invariant. + +You cannot *create* a reservation from this page — reservations only ever come +into existence as a side-effect of publishing a generation. The page is for +inspection and for the release flow. + +## Related + +- `docs/v2/plan.md` — decision #124 (reservations outside the generation flow). +- `docs/v2/admin-ui.md` — § "Release an external-ID reservation". +- `docs/v2/config-db-schema.md` — full Config DB schema. +- `OpcUaServer.md` — generations, draft/publish flow.