server: exit flow (pay-on-foot validation)
A credential read at an exit lane validates the session, then opens. Adds a 'read' channel to the device bus (DeviceReadEvent: ticket/plate/qr/card); entry stays button-driven so reads are exit/identity events. Flow: read -> fold the SIGNED ledger for that identity -> validate open + PAID + within gracePeriodExitMin -> signed vehicle_exit -> pulseOpen -> close the session cache. Unpaid / grace-expired / unknown -> signed anomaly, barrier stays closed (a deliberate business reject, not a fail-state; 'exit fails open' is about host/power loss). Validation reads the ledger (authoritative), not the cache. No payment events exist until the pay station is built, so every transient exit currently rejects -- the correct end-state, not yet passable. Verified against stubs: unpaid->anomaly+no-open; paid+grace->exit+open+closed; expired->anomaly; unknown->anomaly; verifyChain ok across entry->pay->exit. Flagged: lane_devices has no entry/exit direction model (exit door hardcoded to 1); needs a lane-direction/role model before multi-reader lanes.
This commit is contained in:
@@ -15,6 +15,17 @@ export interface DeviceInputEvent {
|
|||||||
readonly source: "push" | "poll";
|
readonly source: "push" | "poll";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A credential read at a lane: a ticket scanned at exit, a plate from LPR, a card
|
||||||
|
// at a reader. Drives identity-based flows (exit validation, and later permits /
|
||||||
|
// pay-station lookup). `kind` mirrors IdentitySource. See parking-session.md.
|
||||||
|
export interface DeviceReadEvent {
|
||||||
|
readonly driverId: string;
|
||||||
|
readonly deviceId: string; // lane_devices id of the reader/scanner/camera
|
||||||
|
readonly value: string; // the ticket id / plate / card number
|
||||||
|
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||||
|
readonly at: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||||
export interface PrinterStatusEvent {
|
export interface PrinterStatusEvent {
|
||||||
readonly deviceId: string; // lane_devices id
|
readonly deviceId: string; // lane_devices id
|
||||||
@@ -33,6 +44,15 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
return () => this.off("input", cb);
|
return () => this.off("input", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A credential read (ticket scan, plate, card) at a lane. */
|
||||||
|
emitRead(event: DeviceReadEvent): void {
|
||||||
|
this.emit("read", event);
|
||||||
|
}
|
||||||
|
onRead(cb: (event: DeviceReadEvent) => void): () => void {
|
||||||
|
this.on("read", cb);
|
||||||
|
return () => this.off("read", cb);
|
||||||
|
}
|
||||||
|
|
||||||
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
||||||
emitPrinterStatus(event: PrinterStatusEvent): void {
|
emitPrinterStatus(event: PrinterStatusEvent): void {
|
||||||
this.emit("printer-status", event);
|
this.emit("printer-status", event);
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { and, eq, laneDevices, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||||
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
|
import type { LedgerPayload } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { DeviceReadEvent } from "./device-events.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
|
||||||
|
// the session → validate it is PAID and within the walk-back grace → sign a
|
||||||
|
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
|
||||||
|
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
|
||||||
|
//
|
||||||
|
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
|
||||||
|
// projection cache: find the open vehicle_entry for this identity, then a covering
|
||||||
|
// payment within grace. The cache is updated after, for fast reads.
|
||||||
|
//
|
||||||
|
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
|
||||||
|
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
|
||||||
|
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
|
||||||
|
// is sent back to the pay station, the rejection is logged.
|
||||||
|
//
|
||||||
|
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
|
||||||
|
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
|
||||||
|
// passable once the pay-station + `payment` events land.
|
||||||
|
|
||||||
|
interface SessionView {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly lane: number;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
readonly open: boolean; // no vehicle_exit yet
|
||||||
|
readonly paidAt: string | null; // latest payment time, if any
|
||||||
|
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExitFlow {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #inFlight = new Set<string>();
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a credential read at an exit lane. */
|
||||||
|
async onRead(e: DeviceReadEvent): Promise<void> {
|
||||||
|
// Resolve which lane this reader belongs to, and that it's an exit reader.
|
||||||
|
const lane = await this.#exitLaneFor(e.deviceId);
|
||||||
|
if (lane == null) return; // not an exit-lane reader — ignore (other flows may handle)
|
||||||
|
|
||||||
|
const key = `${e.deviceId}:${e.value}`;
|
||||||
|
if (this.#inFlight.has(key)) return;
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
await this.#runExit(lane, e);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`exit-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runExit(lane: number, e: DeviceReadEvent): Promise<void> {
|
||||||
|
const view = this.#sessionFor(e.value);
|
||||||
|
|
||||||
|
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||||
|
if (!view || !view.open) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
lane,
|
||||||
|
identity: e.value,
|
||||||
|
payload: { reason: view ? "exit refused — session already closed" : "exit refused — no open session for credential", exitRefused: true },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`exit refused (lane ${lane}): no open session for ${e.value}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAID + within walk-back grace?
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid &&
|
||||||
|
view.graceExitMin != null &&
|
||||||
|
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
|
if (!paid || !withinGrace) {
|
||||||
|
const reason = !paid
|
||||||
|
? "exit refused — not paid (pay at the station)"
|
||||||
|
: "exit refused — walk-back grace expired (top-up required)";
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
lane,
|
||||||
|
identity: e.value,
|
||||||
|
payload: { reason, exitRefused: true, sessionRef: e.value },
|
||||||
|
});
|
||||||
|
this.#logger.warn(`exit refused (lane ${lane}, ${e.value}): ${reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_exit",
|
||||||
|
lane,
|
||||||
|
direction: "exit",
|
||||||
|
source: e.kind === "plate" ? "lpr" : "ticket",
|
||||||
|
identity: e.value,
|
||||||
|
payload: { sessionRef: e.value },
|
||||||
|
});
|
||||||
|
|
||||||
|
const access = await this.#exitAccess(lane);
|
||||||
|
if (access) {
|
||||||
|
await access.pulseOpen(1); // exit barrier; door mapping is config-driven later
|
||||||
|
} else {
|
||||||
|
this.#logger.warn(`exit signed for ${e.value} but lane ${lane} has no access device to open`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||||
|
.where(eq(sessions.id, e.value))
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
|
#sessionFor(identity: string): SessionView | null {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
const exited = rows.some((r) => r.type === "vehicle_exit");
|
||||||
|
|
||||||
|
let paidAt: string | null = null;
|
||||||
|
let graceExitMin: number | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "payment") {
|
||||||
|
paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
lane: entry.lane,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
open: !exited,
|
||||||
|
paidAt,
|
||||||
|
graceExitMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lane this reader belongs to, IF that lane has an access (barrier) device
|
||||||
|
* to open. A read event is an identity/exit signal (entry is button-driven), so
|
||||||
|
* any read at an access-equipped lane is treated as an exit attempt for now.
|
||||||
|
* (Distinguishing entry vs. exit readers per lane is a later lane-direction model.) */
|
||||||
|
async #exitLaneFor(deviceId: string): Promise<number | null> {
|
||||||
|
const row = await this.#db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const access = await this.#db
|
||||||
|
.select()
|
||||||
|
.from(laneDevices)
|
||||||
|
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, row.lane)))
|
||||||
|
.get();
|
||||||
|
return access && access.enabled ? row.lane : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lane's access device, to open the exit barrier. */
|
||||||
|
async #exitAccess(lane: number): Promise<AccessControlDevice | null> {
|
||||||
|
const row = await this.#db
|
||||||
|
.select()
|
||||||
|
.from(laneDevices)
|
||||||
|
.where(and(eq(laneDevices.category, "access"), eq(laneDevices.lane, lane)))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(row.config as never) as AccessControlDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
|||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
import { LaneMap } from "./lane-map.js";
|
import { LaneMap } from "./lane-map.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
import { buildSigner } from "./signer.js";
|
import { buildSigner } from "./signer.js";
|
||||||
@@ -91,6 +92,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Exit flow: a credential read (ticket scan / plate) at an exit lane → validate
|
||||||
|
// the session is PAID + within grace → signed vehicle_exit → open. Pay-on-foot:
|
||||||
|
// the exit lane only validates; payment happens at the station. See parking-session.md.
|
||||||
|
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
||||||
|
const unsubscribeExit = deviceEvents.onRead((e) => {
|
||||||
|
void exitFlow.onRead(e);
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => unsubscribeExit());
|
||||||
|
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
||||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||||
|
|||||||
@@ -96,8 +96,25 @@ Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorizatio
|
|||||||
|
|
||||||
## What this unblocks (build order)
|
## What this unblocks (build order)
|
||||||
|
|
||||||
The device layer left the entry flow dangling — `input_received` events land in the log and stop
|
The device layer left the entry flow dangling — the session domain is that next step. Schema + code
|
||||||
([[device-input-flow]] "the entry flow itself is the next build"). The session domain is that next
|
follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
||||||
step: consume `input_received` / a reader event → mint a signed `vehicle_entry` → print + open.
|
|
||||||
Then the pay-station and exit-validation flows. Schema + code follow this page and [[tariff]];
|
### As-built (2026-06-15)
|
||||||
the decision is recorded in [[session-model]].
|
|
||||||
|
- **Entry flow** (`apps/server/src/entry-flow.ts`): access-device input edge → print ticket
|
||||||
|
(failover) → signed `vehicle_entry` → `pulseOpen`. Holds (anomaly, no open, no entry) if printing
|
||||||
|
fails. See [[device-input-flow]].
|
||||||
|
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (new `read` bus channel) →
|
||||||
|
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
|
||||||
|
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
|
||||||
|
stays closed. Validation folds the **ledger** (authoritative), then updates the `sessions` cache.
|
||||||
|
- **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to
|
||||||
|
the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
|
||||||
|
to decide (host/power loss), not an unpaid car.
|
||||||
|
- **Currently every transient exit rejects** — no `payment` events exist until the pay station is
|
||||||
|
built; the validation is the correct end-state, just not passable yet.
|
||||||
|
|
||||||
|
> **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is
|
||||||
|
> button-driven and exit is read-driven, so they don't currently collide — but a lane with both an
|
||||||
|
> entry reader and an exit reader can't yet be distinguished. A lane-direction/role model is needed
|
||||||
|
> before multi-reader lanes (relates to [[open-questions]] #1 topology).
|
||||||
|
|||||||
+17
@@ -500,3 +500,20 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
|||||||
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
|
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
|
||||||
soft policy; NOT built into the entry flow (clean seam left). Deferred.
|
soft policy; NOT built into the entry flow (clean seam left). Deferred.
|
||||||
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
|
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
|
||||||
|
|
||||||
|
## [2026-06-15] build | Exit flow (pay-on-foot validation)
|
||||||
|
- Built `apps/server/src/exit-flow.ts`. Added a `read` channel to the device bus (DeviceReadEvent:
|
||||||
|
ticket/plate/qr/card) — readers/LPR emit reads; entry stays button-driven, so reads are
|
||||||
|
unambiguously exit/identity events for now.
|
||||||
|
- Flow: read → fold the SIGNED ledger for that identity → validate open + PAID + within
|
||||||
|
`gracePeriodExitMin` → signed `vehicle_exit` → pulseOpen → close the session cache. Unpaid /
|
||||||
|
grace-expired / unknown → signed `anomaly`, barrier stays closed (a deliberate business reject,
|
||||||
|
NOT a fail-state; "exit fails open" is about host/power loss). Validation reads the ledger
|
||||||
|
(authoritative), not the cache.
|
||||||
|
- Pay station doesn't exist yet → no `payment` events → every transient exit currently REJECTS.
|
||||||
|
Correct end-state, not passable until pay-station lands (decided).
|
||||||
|
- VERIFIED against stubs: unpaid→anomaly+no-open; paid+grace→vehicle_exit+open+closed; grace-expired
|
||||||
|
→anomaly; unknown ticket→anomaly; verifyChain ok across entry→pay→exit.
|
||||||
|
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit);
|
||||||
|
fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to
|
||||||
|
[[open-questions]] #1). Updated [[parking-session]] as-built + gap, [[index]].
|
||||||
|
|||||||
Reference in New Issue
Block a user