devices: pool-of-spaces model — drop lane, per-relay direction

A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.

Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
  config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)

Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
  (v1 events won't verify under v2 — intentional, gated per-event by keyId)

Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
  relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
  relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]

Web:
- wizard: no lane selector; add controllers (relay map + entry-button
  terminal) first, then bind readers/cameras/printers to a controller relay

Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
This commit is contained in:
2026-06-16 20:29:38 +02:00
parent 15d3e1ba08
commit 1efa77bf56
46 changed files with 1221 additions and 1167 deletions
+42 -53
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { and, eq, laneDevices, sessions, type Db } from "@parking/db";
import { sessions, type Db, type DeviceRow } from "@parking/db";
import {
NoPrinterAvailableError,
printWithFailover,
@@ -13,11 +13,13 @@ import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import type { LaneMap } from "./lane-map.js";
import { devicesByDirection, relayForButton, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
// → open the barrier. This is the step the device layer left dangling
// (wiki/concepts/device-input-flow.md "the entry flow itself is the next build").
// → open the barrier. The button is wired into an access controller's input; the
// admin maps that input terminal to a relay (config.relays[].button), so a press
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
//
// Two invariants from the threat model + safety analysis:
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
@@ -29,55 +31,46 @@ import type { LaneMap } from "./lane-map.js";
// Crucially, NO vehicle_entry is written in that case — we never record an
// "entered" event for a car that didn't get in (decision 2026-06-15).
//
// Ordering, therefore: print → (ok) sign vehicle_entry → pulseOpen → cache session.
// (fail) sign anomaly, stop.
/** Map a 1-based entry input to the relay/door it opens. Default: same channel. */
function doorForInput(input: number): number {
return input;
}
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
// (fail) sign anomaly, stop.
export class EntryFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #laneMap: LaneMap;
readonly #logger: FastifyBaseLogger;
/** Guard against double-fire from the same physical press (on edge only). */
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, laneMap: LaneMap, logger: FastifyBaseLogger) {
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#laneMap = laneMap;
this.#logger = logger;
}
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
* button in a lane that has an access (barrier) device. */
* button — an input terminal mapped to an entry relay on its controller. */
async onInput(e: DeviceInputEvent): Promise<void> {
if (e.edge !== "on") return; // release edge is just telemetry
const lane = this.#laneMap.laneFor(e.deviceId);
if (lane == null) return; // unmapped device — telemetry already recorded, no entry
// Only treat this as an entry trigger if the firing device IS the lane's
// access controller (a reader/printer input edge isn't an entry button).
const access = await this.#loadAccess(lane, e.deviceId);
if (!access) return;
// The firing device must be an access controller, and the pressed input terminal
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
const resolved = relayForButton(this.#db, e.deviceId, e.input);
if (!resolved) return;
const key = `${e.deviceId}:${e.input}`;
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
this.#inFlight.add(key);
try {
await this.#runEntry(lane, e.input, access);
await this.#runEntry(resolved);
} catch (err) {
this.#logger.error(`entry-flow failed (lane ${lane}): ${(err as Error).message}`);
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
} finally {
this.#inFlight.delete(key);
}
}
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
@@ -87,24 +80,23 @@ export class EntryFlow {
if (occ.full) {
await this.#log.append({
type: "anomaly",
lane,
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
});
this.#logger.warn(`transient entry REFUSED on lane ${lane}: full (${occ.count}/${occ.capacity})`);
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
return;
}
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
const printers = await this.#loadPrinters(lane);
const printers = this.#loadPrinters();
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, lane, issuedAt };
const ticket: TicketData = { ticketId, issuedAt };
try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket),
);
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy} (lane ${lane})`);
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
} catch (err) {
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
// failed attempt is in the tamper-evident record for the operator.
@@ -112,18 +104,16 @@ export class EntryFlow {
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
await this.#log.append({
type: "anomaly",
lane,
identity: ticketId,
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false },
});
this.#logger.warn(`entry HELD on lane ${lane}: ${reason} (barrier NOT opened)`);
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
return;
}
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
await this.#log.append({
type: "vehicle_entry",
lane,
direction: "entry",
source: "ticket",
identity: ticketId,
@@ -131,15 +121,26 @@ export class EntryFlow {
occurredAt: issuedAt,
});
// 3. OPEN the barrier (intent only; the barrier owns the close).
await access.pulseOpen(doorForInput(input));
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
// a camera failure must not delay or block the already-open barrier).
void snapshotAsync({
db: this.#db,
direction: "entry",
identity: ticketId,
logger: this.#logger,
}).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`));
// 4. Update the session projection cache (rebuildable from the ledger; this is
// just a fast read-model, never the source of truth).
try {
this.#db
.insert(sessions)
.values({ id: ticketId, lane, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
.run();
} catch (err) {
// Cache miss is non-fatal — the ledger is authoritative and the projection
@@ -148,15 +149,8 @@ export class EntryFlow {
}
}
/** The lane's access device, but only if it's the one that fired (the entry
* button). Returns a live adapter or null. */
async #loadAccess(lane: number, deviceId: string): Promise<AccessControlDevice | null> {
const row = await this.#db
.select()
.from(laneDevices)
.where(and(eq(laneDevices.id, deviceId), eq(laneDevices.category, "access")))
.get();
if (!row || !row.enabled || row.lane !== lane) return null;
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
@@ -166,16 +160,11 @@ export class EntryFlow {
}
}
/** Build live printer instances for a lane (for failover selection). */
async #loadPrinters(lane: number): Promise<PrinterInstance[]> {
const rows = await this.#db
.select()
.from(laneDevices)
.where(and(eq(laneDevices.category, "printer"), eq(laneDevices.lane, lane)))
.all();
/** Build live ENTRY printer instances (for failover selection). */
#loadPrinters(): PrinterInstance[] {
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
const out: PrinterInstance[] = [];
for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;