feat(booth): refusal snapshots, subscriber access medium, one-car-one-ticket entry

Three booth-integrity improvements that share the entry/exit flows and activity log.

Refusal snapshots: previously only an accepted open captured a camera image; now
every refusal/hold anomaly fires the directional camera too (a turned-away car is
exactly the evidence wanted) — entry refused-full/held, exit refused
closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription.
A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo
together. Same fire-and-forget contract; failed captures still show as tiles.

Subscriber access medium: the subscription flow already signed `via`
(qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a
cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only.

One car = one ticket: the entry button could be mashed to mint many tickets per car
(corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard
only blocked overlapping presses. Add a per-relay guard configured on the relay spec:
PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input —
one ticket per car, re-armed when the loop clears) or COOLDOWN fallback
(entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned
device_events telemetry, not a signed anomaly. SetupWizard exposes both fields.
Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is
in-memory/rebuildable, starts armed after restart.

Wiki: new entry-double-press; updated entry-exit-points, booth-console, index.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 12:54:54 +02:00
parent bfb6ab0b36
commit 30e7fe85de
14 changed files with 436 additions and 28 deletions
+4
View File
@@ -99,6 +99,7 @@ export class ExitFlow {
if (!view || !view.open) {
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
}
@@ -112,6 +113,7 @@ export class ExitFlow {
if (!freeGrace && (!paid || !withinGrace)) {
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
this.#fireExitSnapshot(id);
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
}
@@ -274,6 +276,7 @@ export class ExitFlow {
identity: e.value,
payload: { ...rp, exitRefused: true },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused: no open session for ${e.value}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}
@@ -314,6 +317,7 @@ export class ExitFlow {
identity: e.value,
payload: { ...rp, exitRefused: true, sessionRef: e.value },
});
this.#fireExitSnapshot(e.value);
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
return { accepted: false, direction: "exit", reason: rp.reason };
}