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
+9
View File
@@ -310,6 +310,15 @@ export class SubscriptionFlow {
* subscription, (b) pick which occurrence a read closes, and (c) enforce
* `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 }[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).