fix(anpr): abort the poll loop if the subscriber transacts by card/QR mid-poll

The poll-until-confident loop (prev commit) opened a race: during its ~8s window a
subscriber could scan their card/QR at the reader and exit immediately — but the ANPR
loop kept polling and would ALSO emit a confident read a moment later, exiting the
NEXT open occurrence (a phantom double-exit, worst for a fleet sub with several open).

Guard it with the subscriber's open-occurrence count: the bridge identifies the
subscription as soon as a frame reads the bound plate (identity needs no confidence),
baselines openOccurrenceCount, then each tick AND before emit checks if it moved. If a
credential closed/opened an occurrence mid-poll, the subscriber already transacted →
abort, don't emit. New public SubscriptionFlow.openOccurrenceCount(). Bounded loop is
unchanged (ANPR_POLL_WINDOW_MS=8000 cap; never infinite).

+1 test (credential transacts mid-poll → no double-act); 170 server tests green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 23:05:31 +02:00
parent 513566c89e
commit 2a13b95da6
3 changed files with 78 additions and 2 deletions
+40
View File
@@ -68,6 +68,12 @@ function pollWindowMs(): number {
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
* subscriber by plate during the poll loop without re-building the whole event. */
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
}
export class AnprBridge {
readonly #db: Db;
readonly #vision: VisionClient | null;
@@ -138,7 +144,15 @@ export class AnprBridge {
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
// While polling, watch whether THIS subscriber transacts by another credential
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
let watchedSubId: string | null = null;
let baselineOpen = 0;
const deadline = Date.now() + this.#pollWindowMs;
let attempts = 0;
try {
@@ -146,6 +160,25 @@ export class AnprBridge {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
// Identify the subscriber from ANY readable plate (even below the barrier floor),
// and baseline their open count once — so we can detect a credential beating us.
if (r?.plate?.text) {
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
if (m0 && watchedSubId == null) {
watchedSubId = m0.subscriptionId;
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
}
}
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
// they already transacted; stop polling and do NOT emit.
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
this.#logger.info(
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
);
return;
}
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
result = r;
break;
@@ -190,6 +223,13 @@ export class AnprBridge {
return;
}
// Final guard against the credential-mid-poll race: if the subscriber transacted between
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
return;
}
// Plate-level debounce — belt-and-suspenders against a gap that slips the
// camera-level gate re-emitting the SAME plate.
const plateKey = `${deviceId}:${plate}`;