License Plate Credentials (LPR / ANPR)¶
A license plate is a credential like any other: it belongs to a person, it is authorized by the same access policies, and it reaches an adapter through the same access-upload path as a card or a PIN. Usually that person is a vehicle, but a plate can just as well hang off a human person — the server puts no category restriction on the owner, so an adapter must never assume one.
This page documents the wire contract as the code implements it today, because an adapter author has no
other way to learn it — CredentialEntry itself only names three of the four types.
The model: a vehicle is a person¶
There is no separate vehicle entity. A vehicle is a Person whose category is
EntityType.Vehicle (= 1004), and its credential is a LicensePlate whose value is the plate text.
That choice is what lets everything else stay unchanged: policies, profiles, schedules, antipassback and audit all keep working on "a person with a credential", and an ANPR barrier is just a reader whose credential happens to arrive as text.
The owner category is not a guard
CredentialService.CreateLicensePlate only checks that the person exists (ArgumentException —
surfaced as HTTP 400). Any person can own a plate, vehicle-category or human, and both shapes reach
the adapter through the same PersonAccess. Do not branch on the owner's category.
Value normalization¶
The platform normalizes the plate when it stores the credential, not at the API boundary. The stored value is upper-cased and stripped of every space:
So: uppercase, all spaces removed. "ba 123 xy" is stored and shipped as "BA123XY".
Do not re-normalize differently in an adapter
Matching is exact-string against an already-normalized value. An adapter that strips dashes, trims to a country prefix, or lower-cases before comparing will silently stop matching what the operator entered. If a device needs a different shape, transform on the way out and document it — and keep the transformation deterministic, because sync diffing depends on it (see patterns/01-access-synchronization.md).
There is no format validation at all
Nothing checks length, charset or country pattern. The client dialog only requires a non-empty
string (AddLicensePlateDialogViewModel.Validate), and the server only checks that the owner exists.
A device with a fixed-width plate field will receive values it cannot store, so decide the rule
yourself: truncate or reject deterministically, and report it in the sync report rather than
silently dropping the record.
What the adapter receives¶
Plates arrive inside the ordinary access model, in PersonAccess.Credentials:
public record PersonAccess(
/* ... */
CredentialEntry[] Credentials,
DeviceAccess[] Devices,
bool ApbExempt = false,
ProfileData? Profile = null);
For a plate the entry looks like this — note Data is a bare JSON string, not an object:
{
"type": "LicensePlate",
"credentialId": "0198f3c1-2a44-7c31-9f02-6b1d8e77a0c4",
"data": "BA123XY"
}
| Field | Value |
|---|---|
Type |
exactly LicensePlate (the EF discriminator is nameof(LicensePlate)) |
CredentialId |
the credential's own id — use it to attribute uploaded records in sync reporting |
Data |
the normalized plate as a string |
CredentialDataConverter deserializes it with dataElement.GetString(), so in a Transform() the value
is reached as entry.Data as string.
All four credential types, for contrast¶
Type |
Data shape |
|---|---|
Pin |
string — the PIN code |
PersonCard |
object — card id, name, codes, code bit lengths, optional PIN |
Biometric |
object — BioType, Index, BioData |
LicensePlate |
string — the normalized plate |
End-to-end path¶
sequenceDiagram
participant Op as Operator
participant API as PQ Server
participant AM as Access model
participant Ad as Adapter
participant Dev as Device
Op->>API: PUT /api/credential/plate (personId, plate)
Note over API: owner may be any person<br/>plate normalized: upper, no spaces
API-->>Op: 201 / 400
API->>API: publish LicensePlateCreated (audit)
Note over AM: policies decide which devices<br/>this person is authorized on
Ad->>API: fetch access model for its devices
API-->>Ad: PersonAccess[] with CredentialEntry[]
Note over Ad: Transform(): filter Type == "LicensePlate",<br/>project to device records, diff, upload
Ad->>Dev: device-native plate list
Creating the credential does not push anything to a device on its own. The
LicensePlateCreated event is audit; the upload happens on the next access synchronization for the
devices the person is authorized on.
The trap that will bite first¶
Every device gets every credential type
AccessAuthorizationService.LoadCredentials selects all enabled credentials of the authorized
persons and ships them to every device in that access model. There is no per-device or
per-capability filtering of credential types.
A card-only panel therefore receives LicensePlate entries, and an ANPR barrier receives
PersonCard, Pin and Biometric entries. Transform() must filter on Type and ignore what
the hardware cannot store. Projecting an unknown type — or worse, projecting a plate string into a
card-code field — is on the adapter.
Two smaller ones:
One owner can carry several plates
Nothing limits a person to one plate. The multi-credential determinism rules apply exactly
as they do for cards: project all supported credentials, iterate in a deterministic order
(CredentialId, then value), never FirstOrDefault(). Otherwise two sync runs reshuffle which
plate lands in which device slot and the differ reports endless churn.
Disabled credentials never arrive
The query filters Status == CredentialStatus.Enabled. Unlike PersonCard — which has a second
gate on the shared card record's status — a plate has only its own status, so there is no
equivalent second condition to honour.
Current state¶
The wire contract above is implemented and stable: platform-side creation, the access model projection and the framework's deserialization all exist and are covered by tests.
No shipped adapter uploads plates yet. The first LPR/ANPR adapter will be the first consumer of this contract, which is why the warnings above are written down before anyone builds against it. LPR events are a separate concern — a camera reporting a recognized plate is a video-analytics event, not a credential upload.
See also¶
- patterns/01-access-synchronization.md — Transform, diffing, multi-credential determinism
- concepts/access-control-assembly.md — how access entities are assembled
- concepts/card-wire-bit-length.md — the equivalent contract detail for cards