A credential read now routes by what the credential IS: matches a permit (card/QR credential or a bound plate) -> permit flow; else -> transient exit flow. Lane resolved once (readerLaneWithAccess); ExitFlow.onRead -> handleAt so the dispatcher owns lane resolution. Permit direction is inferred from session state for that car (the read value is the per-car session key): no open session -> ENTRY (enforce maxConcurrent, sign vehicle_entry, open); open -> EXIT (sign vehicle_exit, open, close). Fleet permit = one session per car; anti-passback falls out naturally. maxConcurrent enforced as a fold over the signed ledger (null = unbound). Validity window + status + plate-OR-card identity as designed. No ticket/fee; every use is a signed event carrying permitId. Refusals (revoked / out-of-window / at-capacity) are signed anomalies, barrier stays closed. Verified against stubs: card entry -> inferred exit; fleet cap 2 (F3 rejected at 2/2, then admitted after F1 exits); plate-bound opens; revoked rejects; unknown credential falls through to exit reject; verifyChain ok.
7.7 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| entity |
|
2026-06-15 | open |
Permit (Subscription)
A subscription: a known holder authorized to enter/exit without paying per-stay, for a covered period. The second of the "two populations" (entry-exit-readers); a valid permit short-circuits the payment step of a parking-session (session-model). Transient is built first; permits layer on top.
Credentials (how a permit is presented) — confirmed with operator 2026-06-15
A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity paths:
- RF tag / chip / card. An RFID/proximity credential. Read host-side (reader → host →
pulseOpen): autonomy isn't required (resolved below), and the dingtian-relay has no onboard card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still fine and keeps a future autonomous path open (entry-exit-readers), but isn't required. - QR code. Read by the optical reader — inherently host-side (entry-exit-readers: pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the permit → decides.
Both feed the host as a reader event whose source is wiegand / qr (the IdentitySource
already in the model) and whose value is the credential id.
Two optional, independent bindings — confirmed 2026-06-15
A permit has two constraints the admin may or may not apply, orthogonally. Either, both, or neither — the four combinations are all valid.
1. Car-count binding (default: 1)
- Optional. By default a permit is bound to 1 car at a time. The admin may raise the limit (a household, a company fleet) or unbind it entirely (no cap on how many cars use it).
- The limit is on cars inside at once (
maxConcurrent), enforced over the parking-session projection: at entry, count the permit's currently-open sessions; if< maxConcurrent(or unbound) allow, else reject (allowance full). This is exactly why sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold over open entry/exit events, not a counter someone can edit.
2. Plate binding (default: off)
- Optional. By default a permit is not plate-bound — any car may use it (identity is the card/QR). The admin may bind it to a set of specific licence plates.
- When bound, an allowed plate is an accepted identity in its own right — a valid card/QR OR a matching plate opens the lane (either, not a second factor):
entry: read card/QR → find permit → car-count ok → open
OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open
- Accepted tradeoff: card-OR-plate is the most convenient but does not prevent card-sharing (a lent card still opens). Fine for a trusted permit population; the signed append-only-event-chain records exactly which credential/plate entered, so abuse is visible to reconciliation after the fact.
- Plate-spoofing defence: a printed copy of a registered plate on a different car is caught not here but by the opencv-anpr-service's vehicle-attribute verification — the seen car must reconcile with the permit's known car, not just the plate string.
The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept any plate. The binding fields are simply absent/null when a constraint isn't applied.
Data model (first cut — to firm up with session-model)
A permits table (and supporting rows). Unlike the event log, reference/master data like permits
is mutable (an admin grants/revokes/renews) — but every use of a permit still produces a
signed vehicle_entry/vehicle_exit event in the append-only-event-chain, so the audit trail
stays append-only even though the permit record itself is editable.
| Field | Notes |
|---|---|
id, holderName/contact |
the subscriber |
credentials[] |
one or more: { kind: 'rf' | 'qr', value } |
maxConcurrent |
car-count binding; default 1, raise for fleets, or null = unbound |
plates[] |
plate binding; default empty/false = any car; when set, these plates are accepted identities |
validFrom, validTo |
coverage window |
status |
active / suspended / revoked |
Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate, identified by its card/QR".
Interaction with the session model
- Entry: credential read → permit lookup → valid (active, in window, plate allowed if
plate-bound, concurrent cars
< maxConcurrentif car-bound) → signedvehicle_entry(source =wiegand/qr/lpr), open barrier. No ticket, no fee. (A bare permit applies neither extra check — just active + in window.) - Exit: credential/plate read → matching open permit session → signed
vehicle_exit, open. No payment required. - Lapsed mid-stay: permit expires while a car is parked → the uncovered time falls back to the transient tariff (edge case to design).
- Revoked: a revoked permit fails the entry check → treated as transient (take a ticket) or refused, per policy (OPEN).
As-built (2026-06-15)
apps/server/src/permit-flow.ts, reached via the read dispatcher
(read-dispatch.ts): a credential read routes to the permit flow if it matches a permit
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
serves both populations (entry-exit-readers), disambiguated by what the credential is.
- Direction is inferred from session state for that car — the read credential value is the
per-car session key. No open session for that car → ENTRY (check
maxConcurrent, signvehicle_entry, open); an open session → EXIT (signvehicle_exit, open, close). A fleet permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an inside car is its exit, never a second entry). maxConcurrentis enforced as a fold over the signed ledger — count the permit'svehicle_entryevents whose car has no later exit; reject at the limit (null= unbound).- Validity (active + within
validFrom/validTo) and plate-OR-card identity as designed. No ticket, no fee — the permit is the authorization; every use is still a signed ledger event carryingpermitId. - Refusals (revoked / out-of-window / at-capacity) are signed
anomalyevents; the barrier stays closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
Resolved (2026-06-15)
- Two optional bindings, independent: car-count (
maxConcurrent, default 1, raisable or unbound) and plate-binding (plates[], default off = any car). Either, both, or neither. - Plate vs. credential: when plate-bound, card/QR OR matching plate — either is accepted identity (not a second factor); card-sharing not prevented by design, caught by reconciliation after.
- Autonomy: host-in-the-loop for everything — no onboard card list needed, so the dingtian-relay stays sufficient (no new controller). Permit entry fails closed if the host is down (fail-state-safety). One code path for transient + permit.
Open questions
- Reader hardware — confirm the RF reader and the QR/optical reader models (procurement; relates to bom and open-questions). RF need not be Wiegand now that autonomy isn't required, but a Wiegand-out reader keeps options open.
- Lapsed-mid-stay & revoked policy (fall back to transient tariff vs. refuse) — confirm with operator.