--- 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**. Same pattern as the `LaneMap` ([[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 [[permit]] 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 [[permit]] at exit is itself the authorization to close. ## 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 [[permit]] 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. - **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens, session closed, `verifyChain` ok. > **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is > button-driven and exit is read-driven, so they don't currently collide — but a lane with both an > entry reader and an exit reader can't yet be distinguished. A lane-direction/role model is needed > before multi-reader lanes (relates to [[open-questions]] #1 topology).