A paid car that left via a manual barrier re-open kept no vehicle_exit, so activeSessions() saw it as permanently open and the grace-expiry eviction (which only ran for exited sessions) never fired — it lingered forever (ticket T-397815c0). - reopenBarrier() now signs a vehicle_exit (source:manual) when the session is still open, closing it; still no second exit when already exited (phantom re-close — no double-count). - activeSessions() ages out a PAID open session past grace even with no exit (unpaid open sessions never age out — a car owing money stays). Pure display filter; the signed log is untouched. Verified both fixes + chain integrity on a fresh DB. A one-off corrective vehicle_exit was appended to the live ledger to clear T-397815c0. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
9.9 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-18 | open |
Booth Exit Flow — pay-at-booth, voucher vs. immediate exit
How the manned booth takes payment for a transient ticket and lets the car out. Complements the unattended reader path in parking-session / the exit flow: same signed events, a booth-driven trigger. Decided 2026-06-17.
Operator flow
- Ticket input on the booth screen. The operator scans (HID scanner types the id + Enter) or keys the ticket number.
- On submit, the booth looks up the session and opens a modal: entry time, exit time (now), duration, total owed (the tariff quote), tender (cash/card), and a checkbox "Printo biletë dalje" (print exit ticket).
- The operator takes payment → a signed
paymentevent (parking-session). What happens next depends on the checkbox:- Checked → print an exit voucher. The customer carries it to a (distant) exit and self-exits by scanning it there; that scan runs the normal reader exit flow. The booth does NOT open the barrier.
- Unchecked → immediate exit. When the modal closes after a successful payment, the booth
signs
vehicle_exit, pulses the exit relay, and fires the exit snapshot right away (booth is at/near the exit).
Settled decisions (2026-06-17)
- Voucher carries the SAME ticket id (reprinted as the Code128 barcode). At the exit reader it runs the existing exit validation — which now finds the session paid + within walk-back grace, so it opens. No new identity or code type; the "biletë dalje" is a paid reprint of the entry ticket id. Reuses tariff exactly.
- The checkbox default lives in
site_config(exit_voucher_default, a site-wide boolean edited in Site settings) — because it's booth geography, not per-ticket. The operator may override per transaction. (Per-exit-point config deferred until a site has both a near and a far exit.) - Payment is never rolled back. If the checkbox is OFF and
pulseOpenfails (offline controller), the signedpayment+vehicle_exitalready stand (money was taken, the car is owed an exit). The booth surfaces a clear error and an auditedanomalyso the operator opens manually — we never silently drop the payment, and never leave a paid car without an exit event.
Threat-model notes (threat-model)
- The booth exit reuses the same validation as the reader path (paid + within grace, or free entry-grace) — there is no booth-only bypass that admits an unpaid car. An unpaid ticket sends the operator to take payment first.
- Every booth action is a signed ledger event attributed to the operator's session: the payment,
the exit, and any
anomaly(failed open / override). A colluding operator can't wave a car out without leaving a signed, attributed trail visible to reconciliation. - The voucher path keeps the camera snapshot at the physical exit (the self-scan fires it), so the evidence is captured where the car actually leaves, not where it paid.
Active sessions & human-intervention barrier open
The barrier state is ASSUMED, never confirmed. We send "open" intent and never truly know the car
cleared (barrier-not-a-door, no wired loop/sensor feedback). So a signed vehicle_exit does NOT
mean the car is gone — it may be stuck (damaged ticket / dead scanner, or the barrier re-closed on a
phantom obstacle: an animal, a person, a cardboard box or bag in the wind). These edge cases need a
human in the booth to open the barrier, leaving a signed trace.
A session is "active" (shown in the booth Active Sessions list) while it is EITHER:
- open + unpaid — entered, no
vehicle_exit, owing money. Always shown — a car that owes money never ages out; it's genuinely still inside until it pays, however long that takes. OR - open + paid, still within grace — paid but no exit recorded yet,
now ≤ graceExpiresAt. OR - exited but
now ≤ graceExpiresAt— paid and/or the voucher scanned, but still within the walk-back grace window. Because the barrier is unconfirmed, the car is presumed possibly still present until grace expires. Payment and a successful voucher scan do NOT remove it from the list — only grace expiry does.
A session drops off the list once it is past grace and EITHER exited OR paid (presumed truly
gone). The paid age-out is important: a paid session whose walk-back grace lapsed has left, so it
is omitted even if no vehicle_exit was ever signed. Without this, a paid car that left via a
manual barrier re-open (which historically signed no exit — see below) would linger forever
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
filter (PayStation.activeSessions()).
The one operator action — "Open barrier" (audited re-pulse)
For an active session, the operator can open the barrier as a human intervention. This:
- re-pulses an exit relay (resolved site-wide, as the booth exit does), and
- signs an
anomaly(source: booth, attributed to the operator, reason "manual barrier open"), and - closes the session IF it is still open — i.e. if no
vehicle_exitexists yet, the re-open is this car leaving, so it also signs avehicle_exit(source: manual, reason "human-intervention exit"). If the session is already exited (the phantom re-close case — a second exit would double-count occupancy), it signs no second exit: anomaly only.
Refined 2026-06-18 (was "NEVER a
vehicle_exit"). The original rule never signed an exit on a re-open, on the assumption a normalvehicle_exithad already happened. But when the re-open was the only way a car left (its walk-back grace had expired, so a normal exit was refused), the session kept no exit event and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only when the session is still open, preserving the no-double-count guarantee for the already-exited case. The #a-session-is-active above is the belt-and-braces safety net for any paid session that still slips through.
Guard — no payment, no button. The "Open barrier" action is shown/active only for sessions that have a payment (paid, or paid-and-exited-in-grace). An unpaid open session has no barrier-open affordance at all — the row routes to the #operator-flow instead. The no-unpaid-bypass rule is enforced structurally: the button simply does not exist for an unpaid car. (A future reason-required force exit for genuine disputes would be a separately-audited path — see Open.)
This single mechanism covers both edge cases: a damaged ticket / dead scanner (find the still-open session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a phantom-obstacle re-close (the just-exited car is still in the list within grace → Open barrier).
⚠ Open question — walk-back grace renews on every payment (voucher overstay)
Found 2026-06-17. Not yet fixed. Scenario: customer pays at the booth, takes an exit voucher, then dawdles past the walk-back grace before reaching the exit.
What the code does today (exit-flow.ts, pay-station.ts):
- The exit reader's grace check is
now − paidAt ≤ graceExitMin, reading the latest payment'sgraceExitMin. Over the window → exit refuses ("top-up required"). ✓ Correct — no free exit. - The re-quote (
computeFee(enteredAt, now, …)) always prices from entry, never from the last payment. So a top-up charges the full entry→now fee (minus what's paid is implicit via the ledger). ✓ Correct — the timer does NOT restart; the customer pays the true total. - BUT every
paymentwrites its owngraceExitMin, and the exit flow reads the latest one — so each top-up grants a fresh, full grace window. ✗ This is the bug.
The leak is time, not money. It is not a free-exit hole (the fee always catches up from entry). But the grace window — meant as a one-time walk-from-pay-to-gate allowance — is re-granted in full on every payment, so a customer could pay → wait → pay a tiny delta → get another full window → repeat, riding the gap between "paid" and "next increment accrues." With coarse tariff increments the abuse is bounded but real.
Candidate fixes (business call — fairness vs. anti-abuse):
- Grace on top-up only when the top-up charged new money (recommended). Kills the "tiny delta forever" loop while staying fair to a genuine overstay; re-price stays from entry.
- Single non-renewing window anchored to the FIRST payment — cleanest anti-abuse, but can unfairly trap someone who legitimately paid, walked, then hit a slow elevator after a top-up.
- Cap total grace granted per session regardless of payment count.
Decided halves: refuse-on-expiry and reprice-from-entry are deliberate and correct. The
grace-renews-fully-per-payment consequence was an unintended side effect of reading graceExitMin
off the latest payment. See tariff (walk-back grace) for the pricing side of the same question.
As-built / open
- Backend:
GET /api/session/:identity(lookup + quote),POST /api/exit { identity }(validated booth exit),site_config.exit_voucher_default. Exit validation shared between the booth and the reader path (one code path, two triggers). - Open: walk-back grace renews on every payment — see the flagged section above (voucher overstay re-grants a full grace window; pick a fix before production).
- Voucher print = reprint the ticket id barcode on the booth printer (ticket-encoding).
- Open: a force-open override (lost ticket / equipment fault) — deferred; would be a separately audited signed event, not folded into the validated path.