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

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.