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
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.FirstPublishedByis the publishing user;ClusterIdis the publishing cluster. - Already present → only
LastPublishedAtis 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), andReleaseReasonrather than deleting the row, so the history is preserved. - Once released, the
(Kind, Value)pair is free — a differentEquipmentUuidcan 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.
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.