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>
This commit is contained in:
@@ -55,6 +55,7 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
||||
| [Configuration.md](v1/Configuration.md) | appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — `OTOPCUA_GALAXY_*` env vars now live in mxaccessgw config) |
|
||||
| [security.md](security.md) | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer |
|
||||
| [Redundancy.md](Redundancy.md) | `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, Prometheus metrics |
|
||||
| [Reservations.md](Reservations.md) | Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow |
|
||||
| [ServiceHosting.md](ServiceHosting.md) | Two-process deploy (Server + Admin) install/uninstall, plus the optional `OtOpcUaWonderwareHistorian` sidecar |
|
||||
| [StatusDashboard.md](StatusDashboard.md) | Pointer — superseded by [v2/admin-ui.md](v2/admin-ui.md) |
|
||||
|
||||
|
||||
139
docs/Reservations.md
Normal file
139
docs/Reservations.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user