Files
parking_solution/wiki/entities/permit.md
julian 3429642edb permits: admin CRUD (route + UI)
A permit is an aggregate (row + credentials + bound plates); create/update
treat it as one unit (child sets replaced on update). GET /api/permits (any
signed-in role, for lookup); POST/PUT/DELETE + POST /:id/revoke (admin only).
Validation: maxConcurrent positive-int-or-null (unbound); a permit must have at
least one credential OR one bound plate. Revoke is the soft common case (keeps
history, barred at the barrier); DELETE hard-removes — past ledger events that
reference it are untouched (append-only audit trail, independent of this row).

Web PermitManager in the admin shell: list + add/edit (holder, car-bound toggle,
validity, credentials, plates), revoke, delete. Makes permits usable without
hand-seeding (companion to the tariff composer).

Verified via inject: validation (empty / maxConcurrent=0 -> 400), create -> 201,
operator can LIST but not write (403), update replaces child rows, revoke ->
revoked, delete -> 204 then 404 with children cleaned.
2026-06-15 19:53:03 +02:00

8.5 KiB

type, tags, sources, updated, status
type tags sources updated status
entity
parking
domain
business
subscriptions
identity
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 < 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).

Admin CRUD (apps/server/src/routes/permits.ts + apps/web/src/PermitManager.tsx): a permit is an aggregate (the row + its credentials + bound plates); create/update treat it as one unit (child sets are replaced on update). GET /api/permits (any signed-in role — for lookup), POST/PUT/DELETE /api/permits[/:id] + POST /api/permits/:id/revoke (admin only). Validation: maxConcurrent is a positive int or null (unbound); a permit must have at least one credential or one bound plate (else nothing identifies it). Revoke is the soft, common case (keeps history, barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are untouched (the audit trail is append-only and independent). Verified via inject (validation, child replacement, RBAC, revoke/delete).

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.