Files
parking_solution/wiki/concepts/entry-double-press.md
T
julian 30e7fe85de feat(booth): refusal snapshots, subscriber access medium, one-car-one-ticket entry
Three booth-integrity improvements that share the entry/exit flows and activity log.

Refusal snapshots: previously only an accepted open captured a camera image; now
every refusal/hold anomaly fires the directional camera too (a turned-away car is
exactly the evidence wanted) — entry refused-full/held, exit refused
closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription.
A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo
together. Same fire-and-forget contract; failed captures still show as tiles.

Subscriber access medium: the subscription flow already signed `via`
(qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a
cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only.

One car = one ticket: the entry button could be mashed to mint many tickets per car
(corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard
only blocked overlapping presses. Add a per-relay guard configured on the relay spec:
PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input —
one ticket per car, re-armed when the loop clears) or COOLDOWN fallback
(entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned
device_events telemetry, not a signed anomaly. SetupWizard exposes both fields.
Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is
in-memory/rebuildable, starts armed after restart.

Wiki: new entry-double-press; updated entry-exit-points, booth-console, index.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 12:54:54 +02:00

91 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, entry, anti-fraud, safety, devices]
sources: []
updated: 2026-06-19
status: open
---
# One car = one ticket (entry anti-double-press)
A transient [[parking-session|entry]] is a button press → print a ticket → sign a `vehicle_entry`
→ open the barrier. **Nothing stopped a driver pressing the button repeatedly** and minting a fresh
ticket each time — a real flaw found 2026-06-19. The damage is threefold:
- **Ticket spam.** One car walks away with a fistful of tickets.
- **Occupancy corruption.** Each press signs a `vehicle_entry`, so [[capacity-occupancy|occupancy]]
(a fold over open sessions) counts one car as many — the lot reads "full" with empty spaces.
- **Ticket-shopping at exit.** With several open sessions for the same car, the driver pays the
cheapest and exits on it; the rest linger. A direct [[threat-model|booth/customer]] abuse.
The old `#inFlight` guard only blocked *overlapping* presses (it released in `finally`), so
press → print → press again issued a second ticket immediately. That is not enough.
## The fix is PER-RELAY CONFIG, chosen by available barrier feedback
The guard lives on the entry relay's spec (`config.relays[]` — see [[entry-exit-points]]), because
whether real one-car-one-ticket is *possible* depends on the hardware at that lane. Two modes:
### PRESENCE mode (preferred — when a vehicle loop is wired)
`relays[].presenceInput` = the 1-based input terminal of an **induction loop / barrier presence
signal** on the same [[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its
relays, and loops are already in the [[bom]]). The rule makes one-car-one-ticket **physical**:
- A press prints **only while a car is present** on the loop.
- After a ticket prints, the relay is **disarmed** — no second ticket — **until the loop CLEARS**
(the car drove through = it entered) **and a new car re-occupies** it.
So mashing the button while sitting on the loop does nothing; a *new* car must physically arrive
before another ticket can issue. The flow observes the loop's input edges (both directions) to track
`present` + `armed` per relay. This is how real lanes behave.
### COOLDOWN mode (fallback — no barrier feedback)
When no loop is wired, `relays[].entryCooldownSec` suppresses repeat presses on that relay for N
seconds after a ticket (default unset = no guard; a sensible value is ~10–12 s — long enough for the
car to pull through, short enough not to block the next legitimate car). It is a **timer, a
mitigation, not a guarantee** — a determined abuser can wait it out. Use it only where presence
feedback isn't available; prefer wiring a loop.
The two can coexist (presence first, cooldown as a backstop), but presence is authoritative when set.
## A suppressed press is a NO-OP, not an anomaly
A blocked/repeat press is recorded as **unsigned [[device-events|telemetry]]** (a `device_events`
`kind:"input"` row with `entrySuppressed:true` + the reason), **not** a signed ledger anomaly. It
isn't fraud — it's the system correctly refusing to double-issue — so it stays out of the immutable
chain and off the red activity-log feed. (Operator's call, 2026-06-19.) The press is still auditable
in telemetry if ever needed.
## Invariants preserved
- **Fail-closed entry is untouched.** A suppressed press simply does nothing; the printer-down HOLD
path ([[append-only-event-chain]]) and the [[fail-state-safety]] rules are unchanged.
- **The barrier is still intent-only.** No timed close; presence is only a *gate on ticketing*, not
a barrier-close trigger ([[barrier-not-a-door]]).
- **State is in-memory + rebuildable.** The per-relay `armed/present` map is runtime state on the
host (single-writer); it is derived from live input edges, never the source of truth. A restart
starts armed (the first press after a restart works), which is the safe default.
## As-built (2026-06-19)
- `RelaySpec` gains `presenceInput?` + `entryCooldownSec?` (`device-resolve.ts`); `relayForButton`
carries them onto the `ResolvedRelay`, and a new `relayForPresence()` resolves a loop-input edge to
the entry relay it gates.
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the
telemetry. The guard disarms + stamps the cooldown on **print success** (not on open).
- [[first-run-setup|SetupWizard]] relay editor: entry/both relays expose a **Presence loop
(terminal)** field and, when no loop is set, a **Cooldown after ticket (s)** field (localized
sq+en — see [[i18n]]).
## Open
- **No automated test yet** (the standing harness gap) — verify on hardware: with a loop, a held
button issues one ticket; after the car clears the loop a new car gets a fresh one. Without a loop,
a cooldown blocks the repeat and the suppressed press lands in telemetry.
- **Exit side:** the symmetric concern (re-reading a ticket at exit) is already handled differently —
exit validates against an open session, so a second read finds the session closed (no double-exit).
No presence gate needed there today.
- **Loop as a safety/anti-tailgate signal** is a larger future use of the same input (free-exit
detection is noted in [[bom]]); this change uses it only to gate ticketing.