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
+24 -13
View File
@@ -98,8 +98,11 @@ export class SubscriptionFlow {
}
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
// The physical side the reader sits at — used to fire the right camera on a refusal
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!sub) return { accepted: false, reason: await this.#reject(m, "sub.refused.notFound") };
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
@@ -108,7 +111,7 @@ export class SubscriptionFlow {
(sub.validFrom != null && now < sub.validFrom) ||
(sub.validTo != null && now > sub.validTo);
if (invalid) {
const reason = await this.#reject(m, "sub.refused.outOfWindow", { status: sub.status });
const reason = await this.#reject(m, lane, "sub.refused.outOfWindow", { status: sub.status });
return { accepted: false, reason };
}
@@ -136,7 +139,7 @@ export class SubscriptionFlow {
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
const oldest = open[0];
if (!oldest) {
const reason = await this.#reject(m, "sub.refused.noSession");
const reason = await this.#reject(m, "exit", "sub.refused.noSession");
return { accepted: false, direction: "exit", reason };
}
const occurrenceId = oldest.identity;
@@ -156,7 +159,7 @@ export class SubscriptionFlow {
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
// fresh per-occurrence id so a fleet can have several open at once.
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
const reason = await this.#reject(m, "sub.refused.atCapacity", {
const reason = await this.#reject(m, "entry", "sub.refused.atCapacity", {
inUse: open.length,
max: sub.maxConcurrent,
});
@@ -227,10 +230,14 @@ export class SubscriptionFlow {
return open;
}
/** Sign a refused-subscription anomaly with a localizable reason code, and return
* the rendered English reason for the caller's ReadOutcome. */
/** Sign a refused-subscription anomaly with a localizable reason code, fire the
* directional evidence camera, and return the rendered English reason for the
* caller's ReadOutcome. `dir` is the lane the refusal happened at (entry/exit) so
* the right camera captures the turned-away subscriber. `via` records which
* credential was presented. */
async #reject(
m: SubscriptionMatch,
dir: FlowDirection,
code: ReasonCode,
params?: Record<string, string | number>,
): Promise<string> {
@@ -239,24 +246,28 @@ export class SubscriptionFlow {
type: "anomaly",
identity: m.carKey,
// `permitId`/`permitRefused` are the on-chain field names (immutable).
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true },
payload: { ...rp, permitId: m.subscriptionId, permitRefused: true, via: m.via },
});
this.#fireSnapshot(dir, m.carKey);
this.#logger.warn(`subscription refused (${m.carKey}): ${rp.reason}`);
return rp.reason;
}
/** Fire the directional camera(s) for a refused-subscription event; never awaited
* (evidence, not a gate). The accepted entry/exit paths snapshot inside #open. */
#fireSnapshot(dir: FlowDirection, identity: string): void {
void snapshotAsync({ db: this.#db, direction: dir, identity, logger: this.#logger }).catch((err) =>
this.#logger.error(`subscription snapshot error: ${(err as Error).message}`),
);
}
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
void snapshotAsync({
db: this.#db,
direction: dir,
identity: carKey,
logger: this.#logger,
}).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`));
this.#fireSnapshot(dir, carKey);
}
#closeCache(carKey: string): void {