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