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>
6.0 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 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.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 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()), 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 is FleetAdmin-only
(the CanPublish policy).
- Active table — every reservation with
ReleasedAt IS NULL: kind, value, owningEquipmentUuid, 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.