Files
julian 33c4ea1e91
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s
feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.

(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
  - PRESENCE-GATED like the physical button: a real car must be present (radar/
    loop AND camera busy). Enforced BOTH sides — the server re-checks current
    presence so a direct POST can't bypass a disabled button; no presence loop
    => feature unavailable; a no-presence attempt signs an anomaly.
  - FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
    companion entry.operatorIssued anomaly (the adversary path always leaves a
    red-flag row).
  - capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
    a legit car).
  New session:create permission (migration 0019 -> operator role, admin-
  revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
  issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
  factored into one shared #issueTicket (button + operator). UI: the entry
  BarrierLight becomes a clickable issue-control when presence+permission+shift
  meet (confirm -> issue).

(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
  - BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
    pay/exit modal shows a red warning + "Override & release" (override signs an
    attributed exit.plateSwapOverride). Flag+override, never a silent hard block
    (exit fails-open; a plate is never the sole gate).
  - READER path (no operator): log-only anomaly + fail-open.
  Extended BoothExitResult + /api/exit (override); boothExit client returns a
  structured swap result.

Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 12:17:52 +02:00

4.5 KiB

type, tags, sources, updated, status
type tags sources updated status
concept
parking
anpr
exit
threat-model
reconciliation
fraud
2026-07-01 settled

Plate reconciliation at exit (ticket-swap defense)

Uses the ANPR plate as an invariant to catch a ticket-swap fraud: the car's plate is the same regardless of which ticket it holds, so if a car tries to exit on a ticket whose plate is already inside under a different ticket, something is wrong. Built 2026-07-01 alongside operator-issued-entry (the capability that makes the fraud easy). The [[threat-model|adversary is the operator]], but the same swap happens innocently (two people mix up tickets).

The fraud (worked scenario)

A lot with 1000 spots:

  1. Real car enters on ticket 1234 → ANPR records plate AA123BB at entry.
  2. Car comes to exit owing 10,000 ALL. Operator scans 1234, pockets the cash, does NOT record the payment.
  3. Operator mints a fresh ticket 1237 (age ≈ 0 → owes ~0) and lets the car out on 1237.
  4. 1234 lingers "inside" forever — a phantom car. Repeat → +100, +200 phantom cars; occupancy becomes meaningless and the operator skims cash while the books look internally consistent (a ticket was "paid" — 1237 for 0; a ticket is "inside" — 1234).

The plate is what the swap can't hide: entry-1234 = AA123BB, and the car exiting on 1237 is AA123BB.

The check

ExitFlow.#reconcilePlateAtExit(exitingId):

  1. Resolve the exiting ticket's plate (its own exit read, else its entry read).
  2. Enumerate all currently-open sessions (projection cache) and their entry plates (platesForIdentities).
  3. If the exiting plate exactly matches an open session under a DIFFERENT identity → swap suspected, returning { plate, otherIdentity, otherEnteredAt }.

EXACT, HIGH-CONFIDENCE only. Both the exiting read AND the matched session's entry read must be ≥ PLATE_MATCH_MIN_CONFIDENCE (0.85), normalized exact string match. No fuzzy/edit-distance matching. Rationale: ANPR is advisory and misses (G3H snapshot 503s, camera-side push failures, no-plate reads — see the ANPR memory notes). A fuzzy/low-confidence read must never be the reason a car is held — so a shaky read simply doesn't trigger the warning (fails toward not-annoying).

What happens on a suspected swap

Booth path (operator-mediated) — FLAG LOUDLY + require an override

Exit fails-OPEN for safety and a plate is never the sole gate, so we do not silently hard-block (that would trap a legit car on a bad read). Instead:

  • exitForBooth returns status swap_suspected with the detail; the barrier does not open.
  • A anomaly (exit.plateSwapSuspected) is signed immediately — so even if the operator walks away, the suspicion is in the tamper-evident record.
  • The pay/exit modal shows a prominent red warning ("Plate AA123BB is already inside under ticket 1234, entered 3h ago") with an explicit "Override & release" action.
  • On override, exitForBooth(id, { override, operator }) proceeds AND signs an attributed anomaly (exit.plateSwapOverride) — the override is itself a signed, named decision.

Reader path (automated, no operator) — LOG-ONLY, fail-open

At an unmanned exit lane there's no one to make the override decision, and exit fails-open, so the reader path signs the exit.plateSwapSuspected anomaly and still lets the car out. The anomaly is the control there (a manager reconciles it later). This is a smaller surface — the fraud scenario is booth-mediated.

Why this is the right shape

  • Occupancy stops drifting. A swap can no longer silently strand ticket 1234 "inside" — the exit attempt on 1237 surfaces it. Directly serves capacity-occupancy integrity.
  • The signed anomaly is the audit signal a manager reconciles (reconciliation) — consistent with "the fraud control lives in the signed chain + human review, not a real-time hard gate".
  • Advisory-not-a-gate is preserved both ways: a plate never opens a barrier by itself, and now a plate never traps a car by itself either (flag + override, never a silent hard block).

Relates