c24d99b0f4
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.
140 lines
7.7 KiB
Markdown
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.
|