Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.
Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.
CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.
- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
10 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||
|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-15 | 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, 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,voidare 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
paymentevent 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
sessionsrow 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 signedpaymentevent 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. This id is carried in the event's
identityfield. - 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:<entry id>, 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
voiddecrements like avehicle_exitinoccupancy.ts(count + reserved-spots), and reads as closed inpay-station.ts(lookup/activeSessions) andexit-flow.ts(#sessionFor), and is excluded from thereports.tsentries 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
paymentevent, 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 capture or CCTV and charge accordingly, or apply a fixed
penalty). Recorded as a
payment(with the chosen amount + a reason) + avoid/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) → signedvehicle_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 (thereadbus channel) → fold the signed ledger for that identity → validate open + PAID + withingracePeriodExitMin→ signedvehicle_exit→pulseOpen. Unpaid / expired / unknown → signedanomaly, barrier stays closed. Validation folds the ledger (authoritative), then updates thesessionscache.- 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, routesGET /api/pay/quote+POST /api/pay): look up the open session → resolve the active tariff version (latesteffectiveFrom ≤ entry) →computeFee→ append a signedpaymentevent (amount, currency, tender,tariffVersionId,graceExitMin). An operatoroverrideMinorcovers 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 —tenderonly 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,
verifyChainok.
- The full transient loop now passes end to end (verified): entry → quote → pay → exit opens,
session closed,
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.