Document the ZTag / SAPID external-ID reservation subsystem: what a reservation is, why it sits outside the generation flow (decision #124), the ExternalIdReservation table, the lifecycle (author → publish precheck → publish-time MERGE → FleetAdmin release), and the /reservations Admin page. Linked from the docs README Operational table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
6.0 KiB
Markdown
140 lines
6.0 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 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.
|