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
+114 -17
View File
@@ -15,7 +15,7 @@ import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import type { VisionClient } from "./vision-client.js";
@@ -229,9 +229,29 @@ export class EntryFlow {
return;
}
await this.#issueTicket(resolved, { source: "ticket" });
}
/**
* The shared "issue a transient ticket" sequence used by BOTH the physical button
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
* signed entry AND append a companion `anomaly` (the operator-adversary path always
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
*/
async #issueTicket(
resolved: ResolvedRelay,
opts: { source: "ticket" | "manual"; operator?: string; overCapacity?: { count: number; capacity: number | null } },
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
const printers = this.#loadPrinters();
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
// + operatorInitiated:true in the payload. The button path is source "ticket".
const operatorInitiated = opts.source === "manual";
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
@@ -260,17 +280,14 @@ export class EntryFlow {
// Capture who is held at the barrier (evidence for the operator handling the car).
this.#fireSnapshot("entry", ticketId);
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return;
return { ok: false, reason };
}
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
// `category` is FROZEN here (in the signed payload) so the tariff prices and
// later reprices the same way at exit. Today every transient takes the SITE
// default category (operator policy, site_config.default_vehicle_category;
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
// is the future seam — source it from `resolved` then. A V1/no-category tariff
// ignores it; only V2 category cards consult it.
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const category =
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
@@ -279,33 +296,113 @@ export class EntryFlow {
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: "ticket",
source: opts.source,
identity: ticketId,
payload: { sessionRef: ticketId, ticketPrinted: true, category },
payload: {
sessionRef: ticketId,
ticketPrinted: true,
category,
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
},
occurredAt: issuedAt,
});
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
// always leaves a red-flag row in the tamper-evident record for reconciliation.
if (operatorInitiated) {
await this.#log.append({
type: "anomaly",
identity: ticketId,
payload: {
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
source: "booth",
operatorInitiated: true,
...(opts.operator ? { operator: opts.operator } : {}),
...(opts.overCapacity ? { lotFull: true } : {}),
},
});
}
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
let opened = false;
if (access) {
await access.pulseOpen(resolved.relay);
opened = true;
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
// a camera failure must not delay or block the already-open barrier).
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
// camera failure must not delay or block the already-open barrier). This is ALSO
// what records the plate that plate-reconciliation reads at exit.
this.#fireSnapshot("entry", ticketId);
// 4. Update the session projection cache (rebuildable from the ledger; this is
// just a fast read-model, never the source of truth).
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
try {
this.#db
.insert(sessions)
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
.run();
} catch (err) {
// Cache miss is non-fatal — the ledger is authoritative and the projection
// can be rebuilt. Log it; don't fail the (already-open) entry.
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
}
return { ok: true, ticketId, opened };
}
/**
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
* presence is this flow's own per-relay guard state. If a site has no presence loop the
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
* See wiki/concepts/operator-issued-entry.md.
*/
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
> {
const resolved = firstRelayByDirection(this.#db, "entry");
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
// PRESENCE GATE — require BOTH a presence loop (configured + currently occupied) AND
// the camera confirming a vehicle. No loop configured → feature unavailable here.
if (typeof resolved.presenceInput !== "number") {
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable" };
}
const present = this.#guardState(resolved).present;
if (!present || !cameraBusy) {
await this.#log.append({
type: "anomaly",
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
payload: {
...reasonPayload("entry.issue.noPresence", { operator }),
source: "booth",
operator,
radarPresent: present,
cameraBusy,
},
});
this.#logger.warn(`operator entry refused by ${operator}: no vehicle present (radar=${present}, camera=${cameraBusy})`);
return { ok: false, reason: "no vehicle detected at the entry" };
}
const key = `operator-issue:${this.#relayKey(resolved)}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
this.#inFlight.add(key);
try {
const occ = getOccupancy(this.#db);
const res = await this.#issueTicket(resolved, {
source: "manual",
operator,
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
});
if (!res.ok) return res;
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
} finally {
this.#inFlight.delete(key);
}
}
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).