da074adce9
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
141 lines
6.2 KiB
Markdown
141 lines
6.2 KiB
Markdown
# 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`.
|
|
|
|
## 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.
|