Files
lmxopcua/docs/Reservations.md
Joseph Doherty b90718013e docs: add Reservations.md — external-ID reservation flow
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>
2026-05-18 03:48:21 -04:00

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

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