Skip to content

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:

plate = (value ?? string.Empty).ToUpperInvariant().Replace(" ", string.Empty);

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