feat(entry): operator-issued entry + exit plate-swap reconciliation
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s

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
This commit is contained in:
2026-07-01 12:17:52 +02:00
parent 114a32e6f2
commit 33c4ea1e91
20 changed files with 760 additions and 62 deletions
+102 -1
View File
@@ -1,6 +1,7 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
@@ -47,6 +48,11 @@ interface SessionView {
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
// See wiki/concepts/plate-reconciliation.md.
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
@@ -57,6 +63,11 @@ export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
* exiting read and the matched open session's entry read. Below this, the read is advisory-
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
export class ExitFlow {
readonly #db: Db;
readonly #log: EventLog;
@@ -88,7 +99,7 @@ export class ExitFlow {
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string): Promise<BoothExitResult> {
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
@@ -122,6 +133,40 @@ export class ExitFlow {
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
}
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
// (unless they consciously override). A denial here never traps the car — exit fails
// open and the operator can override; the anomaly is the control either way.
const swap = this.#reconcilePlateAtExit(id);
if (swap) {
if (!opts?.override) {
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
await this.#log.append({
type: "anomaly",
identity: id,
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
});
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
}
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
source: "booth",
plateSwapOverride: true,
plate: swap.plate,
otherIdentity: swap.otherIdentity,
...(opts.operator ? { operator: opts.operator } : {}),
},
});
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
@@ -336,6 +381,25 @@ export class ExitFlow {
return { accepted: false, direction: "exit", reason: rp.reason };
}
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
// automated lane to make the override decision, and exit fails open for safety, so we
// sign the suspicion anomaly (the control here) and still let the car out. The booth
// path (operator-mediated) is where the hold + override lives.
const swap = this.#reconcilePlateAtExit(e.value);
if (swap) {
await this.#log.append({
type: "anomaly",
identity: e.value,
payload: {
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
plateSwapSuspected: true,
plate: swap.plate,
otherIdentity: swap.otherIdentity,
},
});
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
}
// Valid (a real payment within walk-back grace): sign + open.
return this.#signExitAndOpen(resolved, e);
}
@@ -406,6 +470,43 @@ export class ExitFlow {
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
}
/**
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
* or two tickets were mixed up. We compare the EXITING plate against every open session's
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
* or null. See wiki/concepts/plate-reconciliation.md.
*/
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
// The exiting car's plate: prefer its own exit read, else its entry read.
const mine = plateForIdentity(this.#db, exitingId);
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
const wanted = mine.plate.trim().toUpperCase();
// All currently-open sessions (from the projection cache — a fast read-model; the check
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
const openIds = this.#db
.select({ id: sessions.id })
.from(sessions)
.where(eq(sessions.state, "open"))
.all()
.map((r) => r.id)
.filter((id) => id !== exitingId);
if (openIds.length === 0) return null;
const plates = platesForIdentities(this.#db, openIds);
for (const [otherId, pv] of plates) {
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
if (pv.plate.trim().toUpperCase() !== wanted) continue;
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
}
return null;
}
/** Fold the signed ledger into a session view for one identity (authoritative). */
#sessionFor(identity: string): SessionView | null {
const rows = this.#db