--- type: concept tags: [parking, domain, business, anti-fraud] sources: [] updated: 2026-06-15 status: open --- # Parking Session The core business-domain entity: one vehicle's stay, from entry to exit, plus the money owed and paid for it. Everything on the business side — [[tariff|tariffs]], payment, [[reconciliation]], revenue reporting — hangs off the session. This page defines what a session **is** and, just as importantly, what it is **not**. > Scope decision (2026-06-15): build the **transient** (casual, pay-for-duration) session first; > layer **permit holders** on top as a second identity source that short-circuits payment. Mixed > site, transient-first — see [[entry-exit-readers]] ("two populations, one shared relay") and > [[session-model]]. ## A session is a PROJECTION over the signed event log — not a mutable table This is the single most important rule, and it falls straight out of the [[threat-model]] (the adversary is the insider who can edit the database) and the [[append-only-event-chain]]: - The **events** table is the ledger and the **only** source of truth. `vehicle_entry`, `vehicle_exit`, `payment`, `void` are all **appended + signed**, never updated or deleted. - A **session** is a **read-model folded from those events** — open when an entry has no matching exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable from the chain and never authoritative** ([[append-only-event-chain]]), scaled to the business domain. - **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator can't forge or silently delete — a deletion breaks the chain visibly. See [[session-model]] for the rejected mutable-table alternative. ## Identity — how an entry is tied to its exit A session needs a key that survives from entry to exit. Two populations, two keys ([[entry-exit-readers]]): - **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]]) or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field. - **Permit holder:** a **credential** (card / plate / QR) matched to a [[subscription]] record. A valid permit means the session owes nothing — the PAY step is skipped (see below). ## Lifecycle (pay-on-foot / pay station model) Payment is **decoupled from exit** (decision 2026-06-15, matching the [[autonomous-direction| unmanned]] roadmap): the customer pays at a central station before walking back to the car; the exit lane only *validates* that the session is settled. ``` ENTRY (lane) vehicle_entry event → session OPEN (ticket printed / plate read; barrier opens) PAY (pay station) payment event {sessionRef, fee, paidAt} → session PAID (grace window starts) EXIT (lane) validate: PAID && now ≤ paidAt + graceMinutes ? yes → vehicle_exit event → session CLOSED → pulseOpen no → reject → re-pay overstay top-up at station, then exit ``` States, as derived from events: | State | Condition (over the event chain) | | --- | --- | | **OPEN** | a `vehicle_entry` with no later matching `vehicle_exit` | | **PAID** | OPEN + a `payment` event covering the fee due, within its grace window | | **CLOSED** | a matching `vehicle_exit` event exists | | **VOIDED** | a `void` event references the session (lost ticket written off, error correction) | Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close. ### Cancel a wrongly-printed ticket — BUILT (2026-06-22) A ticket printed in error (misprint, test press, wrong vehicle) is cancelled by appending a **signed `void`** event — the `vehicle_entry` is NEVER edited or deleted (append-only; [[append-only-event-chain]]). `apps/server/src/void-flow.ts` (`VoidFlow`) appends `{ type:"void", identity, payload:{ sessionRef, voidedEntryRef:, voidReason, operator, reasonCode:"void.ticketCancelled" } }`. Traceable: the operator (from the JWT) + a **REQUIRED reason** are signed in. Route `POST /api/tickets/void` gated on `event:void` + an open shift. **No barrier action** — a misprinted ticket's car never entered. - **Refused** for: a subscription occurrence (closed via its own flow), an already-exited session, an already-voided ticket, or a **paid** ticket (a refund is a separate, out-of-scope action) → 409. - **The void folds the session CLOSED everywhere it's counted** — this is the correctness crux. A `void` decrements like a `vehicle_exit` in `occupancy.ts` (count + reserved-spots), and reads as closed in `pay-station.ts` (`lookup`/`activeSessions`) and `exit-flow.ts` (`#sessionFor`), and is excluded from the `reports.ts` entries stat. So a voided car stops occupying a spot, can't be paid/exited, and doesn't inflate "cars entered". The booth surfaces it in the pay/exit lookup modal (transient + unpaid + open only). ### Live-feed display: refused-action WARNING vs. genuine ANOMALY The signed ledger `type:"anomaly"` is overloaded: it carries both benign **refused-action** events (`exitRefused` / `entryRefused` / `permitRefused` — e.g. a double card-scan, an at-capacity subscriber, an exit on an already-closed session) AND genuine red-flags (barrier-open failure, opened-without-ticket). The booth feed now classifies from those existing payload flags (`event-detail.tsx isRefusedWarning`) and shows the refused ones as an amber **REFUZUAR / REFUSED** warning, reserving red **ANOMALI** for true anomalies. **Display-only** — no ledger type/data change, so historical events reclassify correctly too. ## Edge cases the model must name (not yet designed in full) - **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely stateful rule; handled as a second `payment` event, fee = f(time since paid). - **Lost ticket** — no entry id to match. A default flat "lost ticket" fee (see [[tariff]]), **or an amount the admin sets at the moment** (operator judgement — e.g. they can establish entry time from [[opencv-anpr-service|plate]] capture or CCTV and charge accordingly, or apply a fixed penalty). Recorded as a `payment` (with the chosen amount + a reason) + a `void`/annotation so it reconciles; the admin-set amount is captured in the signed event, attributed. - **Manual override** — an operator/admin opens the barrier for a stuck or disputed car, or writes off a session, as a deliberate act. Each is a **signed, reason-coded event** (`barrier_open_command` / a void with reason) — so an override is *authorized and logged*, while an open with **no** such signed event remains the fraud signal ([[append-only-event-chain]]). The override is the legitimate counterpart to the out-of-band-open anomaly. - **Forced / fail-open exit** — barrier failed open ([[fail-state-safety]]): the vehicle leaves with **no `vehicle_exit`**. This is an open session that never closes — a **reconciliation anomaly by design** ([[append-only-event-chain]]'s "physical open with no signed command"), not something to paper over. (A *manual* override above is the signed, non-anomalous version.) - **Re-entry / never-exited** — stale open sessions (drove out tailgating, sensor missed). Surface as anomalies; never auto-close silently. ## What this unblocks (build order) The device layer left the entry flow dangling — the session domain is that next step. Schema + code follow this page and [[tariff]]; the decision is recorded in [[session-model]]. ### As-built (2026-06-15) - **Entry flow** (`apps/server/src/entry-flow.ts`): access-device input edge → print ticket (failover) → signed `vehicle_entry` → `pulseOpen`. Holds (anomaly, no open, no entry) if printing fails. See [[device-input-flow]]. - **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the **permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**. Lane resolved once (`readerLaneWithAccess`). See [[subscription]] as-built. - **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) → fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`** → signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier stays closed. Validation folds the **ledger** (authoritative), then updates the `sessions` cache. - **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable to decide (host/power loss), not an unpaid car. - **Pay station** (`apps/server/src/pay-station.ts`, routes `GET /api/pay/quote` + `POST /api/pay`): look up the open session → resolve the active tariff version (latest `effectiveFrom ≤ entry`) → `computeFee` → append a signed `payment` event (amount, currency, tender, `tariffVersionId`, `graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal. (Card is currently **disabled in the UI** — no POS on-site yet; cash-only. See [[card-payments]].) - **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens, session closed, `verifyChain` ok. > **Resolved (2026-06-16):** the earlier "no entry/exit direction" gap is closed by the > [[entry-exit-points]] model. Direction lives on each access **relay**; readers/cameras bind to a > relay and inherit it. The "lane" concept was dropped entirely (pool-of-spaces) — separate in/out > readers are distinguished by their relay binding, not a lane.