--- 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.