Compare commits
2 Commits
8c2cf93067
...
2696d281ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 2696d281ce | |||
| 648d3254d6 |
@@ -0,0 +1,184 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, laneDevices, sessions, type Db } from "@parking/db";
|
||||
import {
|
||||
NoPrinterAvailableError,
|
||||
printWithFailover,
|
||||
registry,
|
||||
type AccessControlDevice,
|
||||
type PrinterDevice,
|
||||
type PrinterInstance,
|
||||
type TicketData,
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import type { LaneMap } from "./lane-map.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").
|
||||
//
|
||||
// Two invariants from the threat model + safety analysis:
|
||||
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
|
||||
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
|
||||
// signal (wiki/concepts/append-only-event-chain.md).
|
||||
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
|
||||
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
|
||||
// unprinted) and leave the barrier closed; the operator handles the held car.
|
||||
// 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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. */
|
||||
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;
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
this.#logger.error(`entry-flow failed (lane ${lane}): ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
|
||||
const ticketId = newTicketId();
|
||||
const issuedAt = new Date().toISOString();
|
||||
const printers = await this.#loadPrinters(lane);
|
||||
|
||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||
const ticket: TicketData = { ticketId, lane, 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})`);
|
||||
} 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.
|
||||
const reason =
|
||||
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)`);
|
||||
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,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true },
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
// 3. OPEN the barrier (intent only; the barrier owns the close).
|
||||
await access.pulseOpen(doorForInput(input));
|
||||
|
||||
// 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" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
||||
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
||||
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as AccessControlDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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();
|
||||
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>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
role,
|
||||
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||
device: driver.create(cfg as never) as PrinterDevice,
|
||||
});
|
||||
} catch {
|
||||
// skip a printer whose config won't build
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */
|
||||
function newTicketId(): string {
|
||||
return `T-${randomUUID()}`;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { LaneMap } from "./lane-map.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
@@ -79,6 +80,17 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// See wiki/decisions/event-streams-split.md.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
||||
await eventRoutes(app, db, eventLog);
|
||||
|
||||
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
|
||||
// Subscribes to the SAME input bus as the telemetry writer below; the two are
|
||||
// independent (telemetry always records; the entry flow acts only on an access
|
||||
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
|
||||
const entryFlow = new EntryFlow(db, eventLog, laneMap, app.log);
|
||||
const unsubscribeEntry = deviceEvents.onInput((e) => {
|
||||
void entryFlow.onInput(e);
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeEntry());
|
||||
|
||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||
// 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
|
||||
|
||||
@@ -33,8 +33,16 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
|
||||
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
|
||||
not silently corrected.
|
||||
|
||||
## "Full" is a soft, operator-configurable policy
|
||||
|
||||
Refusing at capacity is the **default**, not an absolute. An operator may opt into
|
||||
**[[valet-overcapacity|valet over-capacity]]** — accept the car into operator custody (keys handed
|
||||
over, stacked beyond the marked count) instead of refusing. So the FULL gate is a policy knob
|
||||
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
|
||||
own custody/session shape — see [[valet-overcapacity]] (deferred).
|
||||
|
||||
## Open
|
||||
|
||||
- Whether "FULL" is a hard block or a soft warning (operator can wave one in) — operator policy.
|
||||
- Zone/level granularity at launch vs. single capacity number.
|
||||
- Reserve-for-permits threshold.
|
||||
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, capacity, manned]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Valet / Over-Capacity Mode
|
||||
|
||||
"Full" is **not** necessarily a hard stop. If the operator opts in, a lot at nominal capacity can
|
||||
still accept cars via **valet**: the customer hands over the keys and leaves, and the operator
|
||||
stacks/double-parks the vehicle beyond the marked space count. (User direction, 2026-06-15.)
|
||||
|
||||
## "Full" is a soft, operator-configurable policy
|
||||
|
||||
The [[capacity-occupancy]] FULL gate is therefore a **policy knob**, not a physical absolute:
|
||||
|
||||
- **Refuse** — hard stop at nominal capacity (the default/strict behaviour).
|
||||
- **Valet over-capacity** — accept beyond capacity into operator custody.
|
||||
|
||||
The choice is the operator's, per site (and possibly per time/condition).
|
||||
|
||||
## Valet is a manned-mode feature with a different session shape
|
||||
|
||||
Valet only exists when there's an operator (cf. [[shift]] — manned-only). It adds a **custody**
|
||||
dimension the normal [[parking-session]] doesn't have:
|
||||
|
||||
- The **operator takes custody** of the car — identity is a **claim/valet ticket**, and the
|
||||
operator (not the driver) is accountable for the vehicle between handover and return.
|
||||
- New facts to record (as signed [[append-only-event-chain]] events when built): **key handover**,
|
||||
where/when parked, and **return** to the customer. The operator's accountability ties into the
|
||||
[[shift]] Z-report and [[reconciliation]] (a valet car with no return record is an anomaly).
|
||||
- Payment still flows through the normal [[tariff]] (duration-based) unless a separate valet fee
|
||||
applies.
|
||||
|
||||
## Status — deferred
|
||||
|
||||
Captured now so the [[capacity-occupancy]] design treats "full" as soft and the entry flow leaves a
|
||||
clean seam. **Not** built into the current transient entry flow (decision 2026-06-15). Full design —
|
||||
the valet session/custody model, the claim ticket, the over-capacity accept path — is future work.
|
||||
|
||||
## Open
|
||||
|
||||
- Valet session/custody data model (claim ticket, parked location, return event).
|
||||
- Whether a distinct valet fee/tariff applies, or normal duration pricing.
|
||||
- Operator UI for handover/return; how it ties to the [[shift]] accountability record.
|
||||
+3
-2
@@ -7,7 +7,7 @@ updated: 2026-06-14
|
||||
# Index
|
||||
|
||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||
Counts: 1 source · 18 entities · 23 concepts · 5 decision records.
|
||||
Counts: 1 source · 18 entities · 24 concepts · 5 decision records.
|
||||
|
||||
## Overview & navigation
|
||||
- [[overview]] — the top-level synthesis and entry point.
|
||||
@@ -75,7 +75,8 @@ Counts: 1 source · 18 entities · 23 concepts · 5 decision records.
|
||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS).
|
||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full; exit never blocked.
|
||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
|
||||
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
||||
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
||||
|
||||
+14
@@ -486,3 +486,17 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
||||
- NEXT (schema): rename events→ledger_events; add device_events; split ParkingEventType in shared;
|
||||
then tariffs/versions, permits, blocklist, sessions projection. EventLog/canonicalize/verifyChain
|
||||
+ /api/events follow the rename (code refactor, separate from this wiki commit).
|
||||
|
||||
## [2026-06-15] design+build | Entry flow (start) + valet/over-capacity captured
|
||||
- Building the entry flow: device input → signed `vehicle_entry` → print ticket → pulseOpen.
|
||||
- DECISION (print failure): **hold** — if all printers are down, sign an `anomaly` (entry attempt,
|
||||
ticket unprinted) and do NOT open (no unticketed transient — couldn't pay on exit; operator
|
||||
handles the held car). The `vehicle_entry` is appended ONLY on the success path, right before
|
||||
pulseOpen — preserving "signed before open" and never logging an entry for a car that didn't get in.
|
||||
- DECISION (capacity): wire transient entry now; the FULL gate comes later (needs capacity config +
|
||||
occupancy fold).
|
||||
- VALET / OVER-CAPACITY (user): "full" is a **soft, operator-configurable** policy — operator may
|
||||
valet-accept over capacity (customer hands over keys + leaves, operator stacks the car). Manned-only,
|
||||
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.
|
||||
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
|
||||
|
||||
Reference in New Issue
Block a user