Files
lmxopcua/docs/Reservations.md
Joseph Doherty da074adce9 docs(audit): Reservations.md — accuracy pass
STALE-STATUS / CODE-REALITY fixes:
- Table row ReleasedAt/ReleasedBy: "FleetAdmin" → "Administrator" (AdminRole
  enum renamed in CanonicalizeAdminRoles migration).  ReleasedBy now documents
  that it is the LDAP operator name passed as explicit @ReleasedBy param — not
  SUSER_SNAME() — per migration 20260522000001_AddReleasedByToReleaseExternalIdReservation.
- §4 Release: "FleetAdmin" → "Administrator"; added @ReleasedBy required param
  requirement matching the updated stored-proc signature; replaced "SUSER_SNAME()"
  attribution claim with the correct explicit-param description.
- §The Admin page: replaced entirely.  Actual Reservations.razor uses bare
  [Authorize] (not [Authorize(Policy="FleetAdmin")] and not "CanPublish").
  The page is a read-only flat list (no Active/Released split, no Release row
  action, no Release dialog).  Redirected release-flow readers to
  docs/v2/admin-ui.md §"Release an external-ID reservation".

Evidence:
  src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor:2
  src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs:36
  src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:130
  src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260522000001_AddReleasedByToReleaseExternalIdReservation.cs
2026-06-03 16:22:08 -04:00

6.2 KiB

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 an Administrator explicitly releases the claim. ReleasedBy is the LDAP operator name (passed explicitly as @ReleasedBy; not SUSER_SNAME()). 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 an Administrator 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.
  • Requires a non-empty @ReleasedBy — the LDAP operator name supplied by the caller; the procedure raises an error without it.
  • Stamps ReleasedAt, ReleasedBy (the supplied operator name), 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 requires authentication ([Authorize]) but is not restricted to a specific Admin UI role — any signed-in user can view it.

The page is a read-only flat list of all ExternalIdReservation rows, ordered by Kind then Value. It shows Kind, Value, owning EquipmentUuid, and Cluster. There is no Active/Released split, no Release action, and no Release dialog on this page.

You cannot create a reservation from this page — reservations only ever come into existence as a side-effect of publishing a generation. The release flow is described in docs/v2/admin-ui.md § "Release an external-ID reservation" and runs via sp_ReleaseExternalIdReservation.

  • 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.