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
+29 -2
View File
@@ -86,8 +86,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
} }
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */ /** A fake SubscriptionFlow: only `match()` is called by the bridge. */
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow { function fakeSubFlow(
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow; match: SubscriptionMatch | null,
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
// credential closing an occurrence mid-poll → count changes).
openCounts: number | number[] = 1,
): SubscriptionFlow {
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
return {
match: vi.fn(() => match),
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
} as unknown as SubscriptionFlow;
} }
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" }; const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
@@ -152,6 +161,10 @@ describe("AnprBridge", () => {
tookMs: 1, tookMs: 1,
})), })),
} as unknown as VisionClient; } as unknown as VisionClient;
// Generous window so all 3 escalation attempts run deterministically under suite load
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
process.env.ANPR_POLL_MS = "1";
process.env.ANPR_POLL_WINDOW_MS = "2000";
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger()); const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam)); const reads = await captureReads(() => bridge.onVehicleDetected(cam));
@@ -160,6 +173,20 @@ describe("AnprBridge", () => {
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
}); });
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
// The car's plate is read (identity known) but stays below the floor; meanwhile the
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
const bridge = new AnprBridge(db, vision, sub, silentLogger());
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
expect(reads).toEqual([]); // aborted — the credential already handled it
});
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => { it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
const cam = seedCamera({ anpr: true }); const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 }); const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
+40
View File
@@ -68,6 +68,12 @@ function pollWindowMs(): number {
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); 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 { export class AnprBridge {
readonly #db: Db; readonly #db: Db;
readonly #vision: VisionClient | null; 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 // 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 // #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. // 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 result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
let watchedSubId: string | null = null;
let baselineOpen = 0;
const deadline = Date.now() + this.#pollWindowMs; const deadline = Date.now() + this.#pollWindowMs;
let attempts = 0; let attempts = 0;
try { try {
@@ -146,6 +160,25 @@ export class AnprBridge {
attempts++; attempts++;
const shot = await camera.captureSnapshot({ direction }); const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType); 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) { if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
result = r; result = r;
break; break;
@@ -190,6 +223,13 @@ export class AnprBridge {
return; 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 // Plate-level debounce — belt-and-suspenders against a gap that slips the
// camera-level gate re-emitting the SAME plate. // camera-level gate re-emitting the SAME plate.
const plateKey = `${deviceId}:${plate}`; const plateKey = `${deviceId}:${plate}`;
+9
View File
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
* subscription, (b) pick which occurrence a read closes, and (c) enforce * subscription, (b) pick which occurrence a read closes, and (c) enforce
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that. * `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
*/ */
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
* the count drops while it's polling, the subscriber already transacted and the bridge must
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
* a fleet sub). See anpr-entry.ts. */
openOccurrenceCount(subscriptionId: string): number {
return this.#openOccurrences(subscriptionId).length;
}
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] { #openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Net entries−exits per occurrence identity, keeping the entry order (oldest first). // Net entries−exits per occurrence identity, keeping the entry order (oldest first).