Files
julian b4f1418858 fix(entry): enforce the camera press-gate + duplicate-ticket defenses
Field report (park-buzi): a BLINKING entry button still printed — the lamp
encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only
checked the radar, so a radar false-positive (rain, pedestrian) minted a real
signed ticket. Three layered fixes:

1. CAMERA gate on the physical press: with an entry camera configured, a press
   is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into
   EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never
   opens, never traps). Camera-less sites keep the radar-only gate; a faulty
   camera is dropped via the existing bypassPresenceCamera admin toggle.

2. Cooldown as a REAL backstop behind presence: the presence branch returned
   early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds
   the stationary-car double-ticket (a motion radar drops a motionless car →
   spurious loop-clear re-arms one-car-one-ticket → same car reprints).

3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected):
   when entry ANPR recognizes a plate already OPEN under another session entered
   within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE
   entry.duplicatePlate anomaly naming both tickets for the operator to void.
   ANPR stays non-blocking (rides the post-open snapshot as before).

REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The
camera has no leave events — "free" is a ~30s silence timeout that never lapses
inside a queue, so every queued car after the first would be suppressed until
an operator intervened. Blocking legit entry at peak beats nothing; the proper
preventive fix is a pass-through sensor (passedInput) — recorded as open in
wiki/concepts/entry-double-press.md.

Also: setup.relayTest reason was missing from both web catalogs (parity is only
enforced sq<->en, so the build passed) — added.

Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less
unaffected / bypass honored / cooldown catches the dropout re-press / residual
risk documented / still-present re-press stays suppressed) +
entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other
plates). Suite 258 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 18:41:06 +02:00

146 lines
9.9 KiB
Markdown
Raw Permalink 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-07-04
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-presence sensor is wired)
Inputs are a first-class `config.inputs[]` list (the twin of `relays[]`): each row is a terminal +
a **role** (`button` / `presence` / `alertTrigger`) + the `relay` it serves. A **presence** row =
the 1-based input terminal of a **vehicle-presence sensor** serving an entry/both relay on the same
[[dingtian-relay|controller]] (the Dingtian's inputs are decoupled from its relays). The sensor may
be an **induction loop** OR a **[[hikvision-radar|radar]]** (`inputs[].kind: "loop"|"radar"` — a
label; the gate behaviour is identical). A radar wired to idle opposite the button needs
`inputs[].activeLow: true` so its edge reads correctly. **Multiple radars (entry + exit) are just
multiple presence rows** — adding an exit radar is adding a row. (Pre-2026-06-28 configs wired this
on `relays[].presenceInput/presenceKind/presenceActiveLow`; the resolvers still read those as
back-compat, synthesizing inputs[] from them.) 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 **coexist** (2026-07-04): presence is checked first, and the cooldown now genuinely runs as
a **backstop behind it** (before that date the presence branch returned early and the cooldown never
ran when a loop was wired). The backstop exists because of the **stationary-car radar dropout**: a
motion (doppler) radar loses a car that stops moving — the flow sees a spurious loop-clear, re-arms
one-car-one-ticket, and the *same* car's next press mints a second ticket. A configured cooldown
bounds how fast that can happen. Keep it short (~10–15 s): in presence mode it only fires on a
press the loop already approved, which includes the *next legitimate car in a queue*.
### CAMERA gate (2026-07-04 — when an entry camera is configured)
The [[button-light-indicator]] lamp always encoded the intended UX — **blink** = radar-only
(something in the zone, no confirmed car), **solid** = radar + camera agree — but the press handler
only ever checked the radar, so a blinking button still printed (radar false-positives: rain, a
pedestrian, reflections). Now the press gate enforces the lamp's rule: with an entry camera
configured, a press is live **only while the entry lane's camera confirms a vehicle**
([[lane-presence-and-anpr-entry|LaneStatus]] busy — the SOLID state). This keeps the camera
**advisory** in the safety sense: it only ever *suppresses a ticket*, never opens a barrier and
never traps a car. A camera-less site keeps the radar-only gate; a faulty camera is dropped via the
admin [[entry-presence-bypass]] (`bypassPresenceCamera`), same as the operator-issued path.
### REJECTED: camera-vetoed re-arm (the queue trap)
The obvious fix for the stationary-car dropout — *don't re-arm on loop-clear while the camera still
sees a car; re-arm when the lane flips free* — was designed and **rejected** (2026-07-04). The
entry camera sends no leave events; "free" is a ~30 s detection-silence timeout. In any queue the
next car occupies the zone before that timeout can lapse, so the lane never flips free between two
legitimate cars — every queued car after the first would be suppressed until an operator intervened.
Blocking legitimate entry at peak load is strictly worse than the occasional duplicate ticket. The
duplicate is handled **post-hoc** instead:
### Duplicate-plate reconciliation (post-hoc, entry-side twin of [[plate-reconciliation]])
ANPR already rides the entry snapshot (never blocking — the barrier is open before recognition
starts). When the recognized plate is **already OPEN under another session entered within the last
~15 min** (`ENTRY_DUP_PLATE_WINDOW_MIN`), the flow signs ONE `entry.duplicatePlate` **anomaly**
pointing at both tickets; the operator voids the duplicate. Window short on purpose — a closed
session or an old read is a legit re-visit, not a double press. The proper *preventive* fix is a
**pass-through sensor** (closing loop / photocell past the barrier) as an unambiguous "a car went
through" signal — noted below as open.
## 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; inputs[] 2026-06-28)
- Inputs live in `config.inputs[] = [{ input, role, relay?, kind?, activeLow?, cooldownSec? }]`
(`device-resolve.ts`). `inputsOf(row)` returns them, **or synthesizes** the list from the legacy
`relays[].button/presenceInput/...` fields when a controller predates inputs[] (one back-compat
shim; the UI no longer writes the legacy fields). `relayForButton`/`relayForPresence` resolve
through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever
gate entry/both relays. An exit radar = a `presence` row on the exit relay.
- `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge`
tracks the loop, `#suppressReason` decides camera → presence → cooldown (in that order),
`#recordSuppressedPress` writes the telemetry. The guard disarms + stamps the cooldown on
**print success** (not on open). The camera state is a live `LaneStatus` mirror
(`onLaneStatus`, wired in `server.ts`); "an entry camera is configured" is read per press so
adding/removing one needs no restart. The duplicate-plate check is
`flagDuplicateEntryPlate` in `snapshot.ts`, called from the ANPR ride-along when the entry
flow passes its `EventLog`.
- [[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~~ **Covered 2026-07-04**: `entry-press-gate.test.ts` pins the camera
gate (blink suppresses / solid prints / camera-less unaffected / bypass honored), the cooldown
backstop behind presence, and one-car-one-ticket; `entry-duplicate-plate.test.ts` pins the
post-hoc plate anomaly. Hardware verification on park-buzi still worthwhile.
- **Pass-through sensor** (closing loop / photocell past the barrier, a `passedInput` role): the
only *unambiguous* "the car went through" signal. Would let re-arm key on actual passage instead
of loop-clear, killing the stationary-car dropout without the queue trap. Procurement + wiring
question for the lanes.
- **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.