Files
parking_solution/wiki/entities/permit.md
T
julian c24d99b0f4 server: permit entry/exit branch + read dispatcher
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.
2026-06-15 19:47:01 +02:00

140 lines
7.7 KiB
Markdown

---
type: entity
tags: [parking, domain, business, subscriptions, identity]
sources: []
updated: 2026-06-15
status: 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 `< maxConcurrent` **if car-bound**) → signed `vehicle_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`, sign
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_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).
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
`vehicle_entry` events 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
carrying `permitId`.
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; 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
1. **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.
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm
with operator.