3 Commits

Author SHA1 Message Date
julian 8c2cf93067 db: business-layer schema — ledger/device event split, tariffs, permits, sessions
Implements the wiki design in packages/db + packages/shared.

Event split: rename events -> ledger_events (signed business ledger) and add
device_events (unsigned telemetry). ledger_events gains a signed JSON payload
(amount/tariffVersionId/sessionRef/tender…) + keyId; canonicalize() includes
the payload via sorted-key serialization so business data is tamper-evident.
Raw Dingtian input now writes device_events, not a signed input_received.

New tables: tariffs + immutable tariff_versions (composable/versioned, currency
+ FX-ready), permits (+ permit_credentials, permit_plates; maxConcurrent default
1), blocklist, sessions (rebuildable projection cache — not a source of truth).

shared: split ParkingEvent/Type into LedgerEvent/LedgerEventType + DeviceEventKind;
add LedgerPayload, Tender, TariffStructure/TariffBlock.

Regenerated a single baseline migration (no production chain data existed).
Verified: chain appends + verifyChain ok; tampering a payment payload breaks
the signature. Full repo builds (5/5).
2026-06-15 18:13:35 +02:00
julian 9a4c7ee27b wiki: split signed business ledger from device telemetry
Correction before schema work: the events table conflated the anti-fraud
business ledger with device telemetry. Decision: ledger_events (signed,
chained, reconciled) holds only business facts; device_events (unsigned,
prunable) holds relay/printer/camera/reader/input telemetry. A raw button
press is telemetry; the entry flow mints a signed vehicle_entry. Drops
input_received-as-signed-event.

New: decisions/event-streams-split, concepts/device-events; updated
append-only-event-chain, index, log.
2026-06-15 18:08:56 +02:00
julian 8a8e74561d wiki: design the business layer (session, tariff, permit, vision, shift, ops)
Pivot from the hardware/integrity layer to the parking operation. All
wiki-only; no code yet. Core principle throughout: business entities are
projections over the signed append-only event log, never mutable tables.

New concepts: parking-session, tariff (composable/versioned, FX-ready),
shift (manned-only Z-report), capacity-occupancy, validation-discounts,
reporting-analytics, clock-integrity, ticket-encoding, anti-passback.
New entities: permit, opencv-anpr-service, blocklist.
Decisions: session-model, vision-service (host-side ANPR + vehicle
verification; scoped AGPL exception for the isolated service).

Updates: append-only-event-chain (new event types + vision witness),
local-jwt-auth (drop 8h expiry -> until logout; code change pending),
lpr-camera (host-side recognition supersedes edge-AI), standing-decisions
(AGPL exception), open-questions (+FX, +pay-station money corners, backup).

Deferred + flagged: intercom/help-call, receipts/refunds/change, FX engine,
lane topology (#1).
2026-06-15 17:41:38 +02:00
34 changed files with 2281 additions and 378 deletions
+39 -10
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { desc, events, type Db, type EventRow } from "@parking/db"; import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared"; import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
// The append-only, hash-chained, signed event log — the system's core anti-fraud // The append-only, hash-chained, signed event log — the system's core anti-fraud
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device // primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
@@ -16,11 +16,13 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
// so we guard it with an in-process async lock as well. // so we guard it with an in-process async lock as well.
export interface AppendInput { export interface AppendInput {
readonly type: ParkingEventType; readonly type: LedgerEventType;
readonly lane: number; readonly lane: number;
readonly direction?: Direction | null; readonly direction?: Direction | null;
readonly source?: IdentitySource | null; readonly source?: IdentitySource | null;
readonly identity?: string | null; readonly identity?: string | null;
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
readonly payload?: LedgerPayload | null;
/** Event time (ISO-8601). Defaults to now. */ /** Event time (ISO-8601). Defaults to now. */
readonly occurredAt?: string; readonly occurredAt?: string;
} }
@@ -39,6 +41,7 @@ export function canonicalize(e: {
lane: number; lane: number;
source: string | null; source: string | null;
identity: string | null; identity: string | null;
payload: Record<string, unknown> | null;
occurredAt: string; occurredAt: string;
prevHash: string | null; prevHash: string | null;
}): string { }): string {
@@ -49,11 +52,33 @@ export function canonicalize(e: {
e.lane, e.lane,
e.source ?? null, e.source ?? null,
e.identity ?? null, e.identity ?? null,
// Payload is part of the signed form so business data is tamper-evident.
// Serialize with sorted keys for byte-stability (object key order must not
// change a signature). null when the event type carries no payload.
canonicalPayload(e.payload),
e.occurredAt, e.occurredAt,
e.prevHash ?? null, e.prevHash ?? null,
]); ]);
} }
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
if (p == null) return null;
const sort = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sort);
if (v && typeof v === "object") {
return Object.keys(v as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((o, k) => {
o[k] = sort((v as Record<string, unknown>)[k]);
return o;
}, {});
}
return v;
};
return sort(p);
}
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */ /** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
export function hashEvent(canonical: string): string { export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex"); return createHash("sha256").update(canonical, "utf8").digest("hex");
@@ -71,24 +96,25 @@ export class EventLog {
} }
/** Append one event to the chain. Returns the persisted row. Serialized. */ /** Append one event to the chain. Returns the persisted row. Serialized. */
append(input: AppendInput): Promise<EventRow> { append(input: AppendInput): Promise<LedgerEventRow> {
const run = this.#tail.then(() => this.#appendNow(input)); const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock). // Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined); this.#tail = run.catch(() => undefined);
return run; return run;
} }
#appendNow(input: AppendInput): EventRow { #appendNow(input: AppendInput): LedgerEventRow {
const prev = this.#db const prev = this.#db
.select() .select()
.from(events) .from(ledgerEvents)
.orderBy(desc(events.index)) .orderBy(desc(ledgerEvents.index))
.limit(1) .limit(1)
.get(); .get();
const index = (prev?.index ?? 0) + 1; const index = (prev?.index ?? 0) + 1;
const prevHash = prev ? hashEvent(canonicalize(prev)) : null; const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
const occurredAt = input.occurredAt ?? new Date().toISOString(); const occurredAt = input.occurredAt ?? new Date().toISOString();
const payload = input.payload ?? null;
const canonical = canonicalize({ const canonical = canonicalize({
index, index,
@@ -97,6 +123,7 @@ export class EventLog {
lane: input.lane, lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload,
occurredAt, occurredAt,
prevHash, prevHash,
}); });
@@ -109,13 +136,15 @@ export class EventLog {
lane: input.lane, lane: input.lane,
source: input.source ?? null, source: input.source ?? null,
identity: input.identity ?? null, identity: input.identity ?? null,
payload,
occurredAt, occurredAt,
prevHash, prevHash,
signature: this.#signer.sign(canonical), signature: this.#signer.sign(canonical),
keyId: this.#signer.keyId,
}; };
this.#db.insert(events).values(row).run(); this.#db.insert(ledgerEvents).values(row).run();
return row as EventRow; return row as LedgerEventRow;
} }
/** /**
@@ -125,7 +154,7 @@ export class EventLog {
* row (index gap), and a forged/invalid signature. * row (index gap), and a forged/invalid signature.
*/ */
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(events).orderBy(events.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
let expectedIndex = 1; let expectedIndex = 1;
let prevHash: string | null = null; let prevHash: string | null = null;
for (const row of rows) { for (const row of rows) {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, events, type Db } from "@parking/db"; import { desc, ledgerEvents, type Db } from "@parking/db";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
@@ -22,7 +22,7 @@ export async function eventRoutes(
{ preHandler: guard }, { preHandler: guard },
async (req) => { async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all(); const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all();
return { events: rows }; return { events: rows };
}, },
); );
+25 -22
View File
@@ -1,7 +1,8 @@
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt"; import jwt from "@fastify/jwt";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { createDb, type Db } from "@parking/db"; import { randomUUID } from "node:crypto";
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
import { deviceEvents } from "./device-events.js"; import { deviceEvents } from "./device-events.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
@@ -70,39 +71,41 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => printerMonitor.start()); app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop()); app.addHook("onClose", async () => printerMonitor.stop());
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button // Append-only signed business LEDGER (ledger_events). Holds only business facts
// presses) into the hash-chained, signed `events` table — the anti-fraud audit // (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// trail. The device is NOT trusted; the host record is the source of truth, and // button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// a relay open with no matching signed event is itself the anomaly. We record // in device_events. The entry flow (TODO) turns an input into a signed
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that // vehicle_entry once a ticket prints + the barrier is commanded.
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md. // See wiki/decisions/event-streams-split.md.
const eventLog = new EventLog(db, buildSigner(app.log)); const eventLog = new EventLog(db, buildSigner(app.log));
await eventRoutes(app, db, eventLog); await eventRoutes(app, db, eventLog);
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
// faithfully (the chain is append-only) rather than silently dropped or // faithfully rather than silently dropped or mis-stamped as lane 0 (a real lane).
// mis-stamped as lane 0, which is a real lane.
const lane = laneMap.laneFor(e.deviceId) ?? -1; const lane = laneMap.laneFor(e.deviceId) ?? -1;
if (lane === -1) { if (lane === -1) {
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`); app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
} }
eventLog try {
.append({ db.insert(deviceEventsTable)
type: "input_received", .values({
lane, id: randomUUID(),
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a deviceId: e.deviceId,
// VEHICLE was identified. A raw input has none, so it stays null. The lane,
// device provenance lives in `identity` instead. category: "access",
source: null, kind: "input",
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`, detail: { driverId: e.driverId, input: e.input, edge: e.edge },
occurredAt: e.at, occurredAt: e.at,
}) })
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`)); .run();
} catch (err) {
app.log.error(`device-event insert failed: ${(err as Error).message}`);
}
}); });
app.addHook("onClose", async () => unsubscribeInput()); app.addHook("onClose", async () => unsubscribeInput());
// TODO: entry flow (input event → signed event → print → relay). // TODO: entry flow (device input → signed vehicle_entry → print → relay).
return app; return app;
} }
@@ -1,23 +0,0 @@
CREATE TABLE `events` (
`id` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`type` text NOT NULL,
`direction` text,
`lane` integer NOT NULL,
`source` text,
`identity` text,
`occurred_at` text NOT NULL,
`prev_hash` text,
`signature` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `events_index_unique` ON `events` (`index`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+113
View File
@@ -0,0 +1,113 @@
CREATE TABLE `blocklist` (
`id` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`value` text NOT NULL,
`reason` text,
`active` integer DEFAULT true NOT NULL,
`added_by` text,
`added_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `device_events` (
`id` text PRIMARY KEY NOT NULL,
`device_id` text,
`lane` integer,
`category` text,
`kind` text NOT NULL,
`detail` text,
`occurred_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `lane_devices` (
`id` text PRIMARY KEY NOT NULL,
`lane` integer NOT NULL,
`category` text NOT NULL,
`driver_id` text NOT NULL,
`config` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `ledger_events` (
`id` text PRIMARY KEY NOT NULL,
`index` integer NOT NULL,
`type` text NOT NULL,
`direction` text,
`lane` integer NOT NULL,
`source` text,
`identity` text,
`payload` text,
`occurred_at` text NOT NULL,
`prev_hash` text,
`signature` text NOT NULL,
`key_id` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `ledger_events_index_unique` ON `ledger_events` (`index`);--> statement-breakpoint
CREATE TABLE `permit_credentials` (
`id` text PRIMARY KEY NOT NULL,
`permit_id` text NOT NULL,
`kind` text NOT NULL,
`value` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `permit_plates` (
`id` text PRIMARY KEY NOT NULL,
`permit_id` text NOT NULL,
`plate` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `permits` (
`id` text PRIMARY KEY NOT NULL,
`holder_name` text,
`contact` text,
`max_concurrent` integer DEFAULT 1,
`valid_from` text,
`valid_to` text,
`status` text DEFAULT 'active' NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`lane` integer,
`identity` text,
`source` text,
`permit_id` text,
`entered_at` text NOT NULL,
`exited_at` text,
`state` text DEFAULT 'open' NOT NULL,
`last_event_index` integer
);
--> statement-breakpoint
CREATE TABLE `setup_state` (
`id` integer PRIMARY KEY NOT NULL,
`completed_at` text
);
--> statement-breakpoint
CREATE TABLE `tariff_versions` (
`id` text PRIMARY KEY NOT NULL,
`tariff_id` text NOT NULL,
`effective_from` text NOT NULL,
`currency` text NOT NULL,
`structure` text NOT NULL,
`created_by` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tariffs` (
`id` text PRIMARY KEY NOT NULL,
`scope` text DEFAULT 'site' NOT NULL,
`name` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
@@ -1,14 +0,0 @@
CREATE TABLE `lane_devices` (
`id` text PRIMARY KEY NOT NULL,
`lane` integer NOT NULL,
`category` text NOT NULL,
`driver_id` text NOT NULL,
`config` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `setup_state` (
`id` integer PRIMARY KEY NOT NULL,
`completed_at` text
);
+537 -5
View File
@@ -1,11 +1,193 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "721bbb8f-b929-4018-9420-0ae75b03ff93", "id": "cd09c11f-4306-4ac8-a335-7c050d080ab6",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"events": { "blocklist": {
"name": "events", "name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lane_devices": {
"name": "lane_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": { "columns": {
"id": { "id": {
"name": "id", "name": "id",
@@ -56,6 +238,13 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": { "occurred_at": {
"name": "occurred_at", "name": "occurred_at",
"type": "text", "type": "text",
@@ -76,11 +265,18 @@
"primaryKey": false, "primaryKey": false,
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
} }
}, },
"indexes": { "indexes": {
"events_index_unique": { "ledger_events_index_unique": {
"name": "events_index_unique", "name": "ledger_events_index_unique",
"columns": [ "columns": [
"index" "index"
], ],
@@ -92,6 +288,342 @@
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "checkConstraints": {}
}, },
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": { "users": {
"name": "users", "name": "users",
"columns": { "columns": {
-245
View File
@@ -1,245 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1073123c-0df9-4109-84bf-7f23b95ec5bd",
"prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93",
"tables": {
"events": {
"name": "events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"events_index_unique": {
"name": "events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lane_devices": {
"name": "lane_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lane": {
"name": "lane",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+2 -9
View File
@@ -5,15 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1781389618205, "when": 1781539958008,
"tag": "0000_absent_rocket_raccoon", "tag": "0000_baseline",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781416636098,
"tag": "0001_cuddly_maria_hill",
"breakpoints": true "breakpoints": true
} }
] ]
+164 -10
View File
@@ -2,10 +2,16 @@ import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Schema notes: // Schema notes:
// - `events` is APPEND-ONLY. Never expose UPDATE/DELETE on it. A correction or // - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
// void is a new row of type 'void'. Each row chains to the previous via // • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
// `prevHash` and is signed by the ATECC608 (`signature`). This is the core // Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
// anti-fraud integrity mechanism. See wiki/concepts/append-only-event-chain.md. // chains via `prevHash` and is signed (`signature`). The anti-fraud record; sessions,
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first). // - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
// See wiki/entities/local-jwt-auth.md. // See wiki/entities/local-jwt-auth.md.
@@ -21,7 +27,13 @@ export const users = sqliteTable("users", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
export const events = sqliteTable("events", { // --- The signed business ledger (formerly `events`) ----------------------
// Holds ONLY business/accountability facts: vehicle_entry, vehicle_exit, payment,
// void, shift_z_report, plus witness-grade barrier_open_command/observed, anomaly.
// `payload` carries type-specific data (amount, tariffVersionId, sessionRef, tender,
// plate confidence…) and is part of the SIGNED canonical form, so it is tamper-evident
// like the rest of the row. See packages/shared ParkingEventType + LedgerPayload.
export const ledgerEvents = sqliteTable("ledger_events", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
// Monotonic chain index. Gaps are alarms (see event-log-ingestion). // Monotonic chain index. Gaps are alarms (see event-log-ingestion).
index: integer("index").notNull().unique(), index: integer("index").notNull().unique(),
@@ -30,18 +42,43 @@ export const events = sqliteTable("events", {
lane: integer("lane").notNull(), lane: integer("lane").notNull(),
source: text("source"), source: text("source"),
identity: text("identity"), identity: text("identity"),
// Type-specific business payload (JSON). Signed as part of the canonical form.
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>(),
occurredAt: text("occurred_at").notNull(), occurredAt: text("occurred_at").notNull(),
// Hash of the previous event (hex). Null only for the genesis event. // Hash of the previous event (hex). Null only for the genesis event.
prevHash: text("prev_hash"), prevHash: text("prev_hash"),
// ATECC608 signature over the canonical event payload (hex). // ATECC608 signature over the canonical event payload (hex).
signature: text("signature").notNull(), signature: text("signature").notNull(),
// Which signer/key produced `signature` (e.g. "sw-hmac-v1", "atecc608-slot0"),
// so old events stay verifiable across a signer swap. See packages/shared Signer.
keyId: text("key_id").notNull(),
}); });
// Per-lane device assignments chosen by the admin during first-run setup. // --- Device telemetry (unsigned, prunable) -------------------------------
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
// offline, reader read, raw input edges. Keyed to a lane_devices instance; lane is
// resolved via the LaneMap. No prevHash/signature — this stream may rotate/prune.
export const deviceEvents = sqliteTable("device_events", {
id: text("id").primaryKey(),
// The lane_devices instance that produced it (raw provenance).
deviceId: text("device_id"),
lane: integer("lane"),
category: text("category", {
enum: ["access", "reader", "camera", "printer"],
}),
// e.g. "input", "relay", "status", "read", "snapshot".
kind: text("kind").notNull(),
// Free-form telemetry detail (input number + edge, status flags, error…).
detail: text("detail", { mode: "json" }).$type<Record<string, unknown>>(),
occurredAt: text("occurred_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Per-lane device assignments (first-run setup) -----------------------
// One row per (lane, category, instance). `driverId` references a driver in the // One row per (lane, category, instance). `driverId` references a driver in the
// @parking/devices registry; `config` is that driver's JSON config (host, port, // @parking/devices registry; `config` is that driver's JSON config. Keeps the
// credentials…). Lets the system stay device-agnostic and admin-configurable. // system device-agnostic + admin-configurable. See device-registry.md, first-run-setup.md.
// See wiki/concepts/device-registry.md and first-run-setup.md.
export const laneDevices = sqliteTable("lane_devices", { export const laneDevices = sqliteTable("lane_devices", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
lane: integer("lane").notNull(), lane: integer("lane").notNull(),
@@ -64,7 +101,124 @@ export const setupState = sqliteTable("setup_state", {
completedAt: text("completed_at"), completedAt: text("completed_at"),
}); });
// --- Tariffs (composable, versioned) -------------------------------------
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
// A session reprices against the version in force at its entry time; the `payment`
// ledger event records the tariffVersionId used. "One active tariff per site" today;
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
export const tariffs = sqliteTable("tariffs", {
id: text("id").primaryKey(),
// Only "site" used now; "lane"/"zone" reserved for multi-tariff later.
scope: text("scope", { enum: ["site", "lane", "zone"] }).notNull().default("site"),
name: text("name").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
export const tariffVersions = sqliteTable("tariff_versions", {
id: text("id").primaryKey(),
tariffId: text("tariff_id").notNull(),
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
effectiveFrom: text("effective_from").notNull(),
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
currency: text("currency").notNull(),
// The composable rate card (stepped blocks + caps/grace). Shape: TariffStructure
// in packages/shared. Immutable once published.
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Permits (subscriptions) ---------------------------------------------
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
export const permits = sqliteTable("permits", {
id: text("id").primaryKey(),
holderName: text("holder_name"),
contact: text("contact"),
// Car-count binding: how many of the permit's cars may be inside at once.
// null = unbound. Default 1.
maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"),
validTo: text("valid_to"),
status: text("status", { enum: ["active", "suspended", "revoked"] })
.notNull()
.default("active"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
export const permitCredentials = sqliteTable("permit_credentials", {
id: text("id").primaryKey(),
permitId: text("permit_id").notNull(),
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
value: text("value").notNull(),
});
// Plate binding (optional). When a permit has plate rows, a matching plate read is
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
export const permitPlates = sqliteTable("permit_plates", {
id: text("id").primaryKey(),
permitId: text("permit_id").notNull(),
plate: text("plate").notNull(),
});
// --- Blocklist (banlist) -------------------------------------------------
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
export const blocklist = sqliteTable("blocklist", {
id: text("id").primaryKey(),
kind: text("kind", { enum: ["plate", "card", "qr"] }).notNull(),
value: text("value").notNull(),
reason: text("reason"),
active: integer("active", { mode: "boolean" }).notNull().default(true),
addedBy: text("added_by"),
addedAt: text("added_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Sessions (PROJECTION cache) -----------------------------------------
// NOT a source of truth — a rebuildable fold over ledger_events for fast queries
// (occupancy, pay-station lookup, anti-passback, plate search). Always reconstructable
// from the signed chain; never the authority for "paid". See wiki/concepts/parking-session.md.
export const sessions = sqliteTable("sessions", {
// The session key = the entry's identity (ticket id or plate).
id: text("id").primaryKey(),
lane: integer("lane"),
// Identity that opened the session, and how it was read.
identity: text("identity"),
source: text("source"),
// null while transient; set when matched to a permit.
permitId: text("permit_id"),
enteredAt: text("entered_at").notNull(),
// null until exit; presence = CLOSED.
exitedAt: text("exited_at"),
// Derived state for quick filtering: open | paid | closed | voided.
state: text("state", { enum: ["open", "paid", "closed", "voided"] })
.notNull()
.default("open"),
// Index of the last ledger event folded into this row (cache freshness / rebuild).
lastEventIndex: integer("last_event_index"),
});
export type UserRow = typeof users.$inferSelect; export type UserRow = typeof users.$inferSelect;
export type EventRow = typeof events.$inferSelect; export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
export type LaneDeviceRow = typeof laneDevices.$inferSelect; export type LaneDeviceRow = typeof laneDevices.$inferSelect;
export type SetupStateRow = typeof setupState.$inferSelect; export type SetupStateRow = typeof setupState.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type PermitRow = typeof permits.$inferSelect;
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
export type PermitPlateRow = typeof permitPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
+76 -9
View File
@@ -13,39 +13,106 @@ export type Direction = "entry" | "exit";
export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual"; export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual";
/** /**
* An append-only parking event. Records are never mutated; corrections are new * A signed business-LEDGER event. Records are never mutated; corrections are new
* events. `prevHash` chains each event to the previous one; `signature` is the * events. `prevHash` chains each event to the previous one; `signature` is the
* ATECC608 signature over the event contents. See wiki/append-only-event-chain. * ATECC608 signature over the canonical contents (which INCLUDE `payload`).
* Distinct from device telemetry — see wiki/decisions/event-streams-split.md.
*/ */
export interface ParkingEvent { export interface LedgerEvent {
readonly id: string; readonly id: string;
readonly index: number; readonly index: number;
readonly type: ParkingEventType; readonly type: LedgerEventType;
readonly direction: Direction | null; readonly direction: Direction | null;
readonly lane: number; readonly lane: number;
readonly source: IdentitySource | null; readonly source: IdentitySource | null;
/** Card number, plate, ticket id, etc. — depends on `source`. */ /** Card number, plate, ticket id, etc. — depends on `source`. */
readonly identity: string | null; readonly identity: string | null;
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
readonly payload: LedgerPayload | null;
readonly occurredAt: string; // ISO-8601 readonly occurredAt: string; // ISO-8601
/** Hash of the previous event in the chain (hex). Null only for genesis. */ /** Hash of the previous event in the chain (hex). Null only for genesis. */
readonly prevHash: string | null; readonly prevHash: string | null;
/** ATECC608 signature over the canonical event payload (hex). */ /** ATECC608 signature over the canonical event payload (hex). */
readonly signature: string; readonly signature: string;
/** Which signer/key produced `signature` (verifiable across a signer swap). */
readonly keyId: string;
} }
export type ParkingEventType = /** Business/accountability events that live in the SIGNED, hash-chained ledger. */
// A raw device input (e.g. a Dingtian button press) was received and recorded. export type LedgerEventType =
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
// entry flow once a ticket prints and the barrier is commanded.
| "input_received"
| "vehicle_entry" | "vehicle_entry"
| "vehicle_exit" | "vehicle_exit"
| "payment"
| "void" | "void"
// Witness-grade: a host-commanded open, and an independently-observed open
// (loop/sensor) — reconciled against each other.
| "barrier_open_command" | "barrier_open_command"
| "barrier_open_observed" | "barrier_open_observed"
| "shift_z_report" | "shift_z_report"
| "anomaly"; | "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */
export type Tender = "cash" | "card";
/**
* Type-specific data carried on a ledger event's `payload`. All amounts are
* integer minor units in the named currency — never floats. Fields are optional
* because they're event-type-specific; the producer fills what applies.
*/
export interface LedgerPayload {
/** The parking_session this event concerns (entry/exit/payment/void). */
readonly sessionRef?: string;
/** payment: amount in minor units, its currency, and how it was tendered. */
readonly amountMinor?: number;
readonly currency?: string;
readonly tender?: Tender;
/** payment: which tariff_version priced it (reproducible repricing). */
readonly tariffVersionId?: string;
/** payment: gross/discount/net split when a validation applied. */
readonly grossMinor?: number;
readonly discountMinor?: number;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null;
/** void / anomaly / override: a human/machine reason code. */
readonly reason?: string;
/** plate/vehicle from the vision service (advisory). */
readonly plate?: string;
readonly plateConfidence?: number;
/** Free-form for forward-compat without a schema change. */
readonly [k: string]: unknown;
}
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
/**
* The composable rate card stored in a tariff_version.structure. Pure data the
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
* a flat rate is just one block. See wiki/concepts/tariff.md.
*/
export interface TariffStructure {
/** Free if exited within this (drop-off/turnaround). */
readonly gracePeriodEntryMin: number;
/** Billing granularity; partial increments round UP. */
readonly incrementMin: number;
/** Consumed in order as duration accrues; last block may be open-ended. */
readonly blocks: readonly TariffBlock[];
/** Cap per rolling 24h (null = no cap). */
readonly dailyCapMinor: number | null;
/** Flat charge when there's no entry id (admin may override at the moment). */
readonly lostTicketMinor: number;
/** Pay-on-foot walk-back window: minutes after payment to reach the car. */
readonly gracePeriodExitMin: number;
/** How an overstay top-up is charged. "reprice" = recompute(entry→now) − paid. */
readonly overstay: "reprice";
}
export interface TariffBlock {
/** Upper bound of this block in minutes; null = open-ended (thereafter). */
readonly uptoMin: number | null;
readonly priceMinorPerIncrement: number;
}
export const ROLES: readonly Role[] = [ export const ROLES: readonly Role[] = [
"admin", "admin",
"operator", "operator",
+57
View File
@@ -0,0 +1,57 @@
---
type: concept
tags: [parking, domain, business, anti-fraud, access-control]
sources: []
updated: 2026-06-15
status: open
---
# Anti-Passback
Stop one credential/ticket from getting **two cars in** without an exit between — the classic
"pass the card/ticket back over the fence" abuse. A control on the entry validation, leaning on the
session projection.
## The rule
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an
OPEN [[parking-session|session]].** At entry:
```
identify vehicle → is there already an OPEN session for this id?
no → proceed (mint vehicle_entry, open)
yes → passback violation → refuse or flag (see policy)
```
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`.
## Interaction with the limits already designed
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
id is always a violation (or a re-print/duplication attempt).
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a
multi-car permit legitimately has several open sessions, but **the same car/credential** entering
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
## Policy (operator choice)
- **Hard** — refuse the second entry (strict; risks stranding a legitimate car after a *missed
exit*, which is common — tailgated out, sensor missed).
- **Soft** — allow but **flag an `anomaly`** (the type exists) for review. Safer against
false-positives from missed exits, consistent with the append-only "record + flag, don't block"
ethos elsewhere.
- Likely **soft by default**, hard as an opt-in for high-control sites.
## Honest limits
- Depends on **reliable exit detection** — if exits are routinely missed (no exit loop/plate read),
passback produces false positives; tune to the site's exit fidelity.
- A spoofed/duplicated ticket QR is caught here (same id already open) — complements
[[ticket-encoding]]'s opaque-id requirement.
## Open
- Default policy (soft/hard) and per-site override.
- Grace for legitimate quick re-entry vs. the missed-exit false-positive.
+47 -20
View File
@@ -21,9 +21,25 @@ Three layered properties:
self-consistent — someone who owns the machine still cannot forge a valid entry. self-consistent — someone who owns the machine still cannot forge a valid entry.
It only becomes trustworthy as an external fraud control when paired with [[reconciliation]] It only becomes trustworthy as an external fraud control when paired with [[reconciliation]]
against an authority the operator can't alter. Every device event — including those ingested against an authority the operator can't alter.
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
chain. ## Two event streams — the signed ledger vs. device telemetry (decision 2026-06-15)
These are **different concerns and live in different tables**:
- **`ledger_events`** — this signed, hash-chained, [[atecc608]]-signed **business ledger**:
`vehicle_entry` / `vehicle_exit` / `payment` / `void` / `shift_z_report`, plus the witness-grade
`barrier_open_command` / `barrier_open_observed` and `anomaly`. This is the anti-fraud record that
[[reconciliation]] runs against; sessions/[[tariff]]/occupancy are projections over it. (This is
the table formerly called `events`.)
- **`device_events`** — **unsigned operational telemetry**: relay fired, printer paper-out, camera
offline, reader read, raw input edges. High-volume, churny, **not** anti-fraud; may rotate/prune.
Keeping it out of the signed chain keeps the ledger small and high-value.
> A raw button press is **device telemetry**, not a business fact. It lands in `device_events`; the
> entry flow then mints a **signed `vehicle_entry`** in the ledger once a ticket prints and the
> barrier is commanded. (This supersedes the earlier "every device event lands in the chain" framing
> and the `input_received`-as-signed-event approach — see [[device-input-flow]].)
## Implementation (apps/server) ## Implementation (apps/server)
@@ -58,22 +74,31 @@ so old events stay verifiable.
> *accidental* corruption, but an operator with the signing key + DB access could re-sign a > *accidental* corruption, but an operator with the signing key + DB access could re-sign a
> forged chain. This is the central reason #6 matters. > forged chain. This is the central reason #6 matters.
### What currently feeds the log ### Business-layer event types (the ledger)
Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]], The [[parking-session]] domain folds over these **signed ledger** events:
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
the richer entry event waits for the entry flow (ticket print + barrier command).
- **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`) - `vehicle_entry` / `vehicle_exit` — a stay's endpoints; `identity` carries the ticket id or plate.
caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every - `payment` — a settled fee at the pay station, referencing the session it pays for (amount in
assign/unassign. Device events carry the device instance id, not a lane; the handler looks it integer minor units; see [[tariff]]). Making "paid" a signed event — not a mutable row — is the
up. A device with no mapping (assigned without a lane, or a stale id) logs **`lane: -1`** and a whole point: an operator can't forge it or silently delete it.
warning — never `0`, which is a real lane — and is still recorded (the chain is append-only; - `void` — a correction / lost-ticket write-off; like every other void here it is an **appended
nothing is dropped). event, never an erasure**.
- **`source` stays `null`** for `input_received`, and deliberately so: `source` is an - `shift_z_report` — the signed per-[[shift]] takings summary.
`IdentitySource` (`wiegand | lpr | qr | ticket | manual`) — *how a vehicle was identified* — not
a device/IP field. A raw button push has no vehicle identity. The device provenance lives in A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
**`identity`** (e.g. `dingtian:<id> input:1/on`). the chain exists. See [[parking-session]].
### ⚠️ As-built vs. the table split (pending)
The current code records Dingtian **input (button) pushes** as `input_received` rows **in the
signed chain** (with `lane` resolved via the `LaneMap`, `source` null, device provenance in
`identity`). Per the 2026-06-15 split (above), a raw button press is **device telemetry** and
belongs in **`device_events`**, *not* the signed ledger — only the business `vehicle_entry` it
drives gets signed. So `input_received`-in-the-ledger is **transitional**; the pending refactor
moves raw inputs to `device_events` and renames the chain table to `ledger_events`. (`LaneMap`
lane-resolution and the "never stamp `lane: 0` for an unmapped device" rule carry over to whichever
stream records the event.)
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only ### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
@@ -91,7 +116,9 @@ host. **Proven on hardware**: a binary relay command sent directly to the device
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** — So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input **independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching → which DOES push + log; the [[opencv-anpr-service|vision service]]'s plate **and vehicle** read;
signed command is the fraud signal.** Both the witness sources and the reconciliation logic are payment/Z-report). **A physical open with no matching signed command is the fraud signal** — and,
**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't with vehicle verification, **a plate that enters/exits on a different car** is too (the
plate-spoofing case). Both the witness sources and the reconciliation logic are **NOT yet built** —
this is the main open gap. Prevention (VLAN isolation so the attacker can't
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop. reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
+40
View File
@@ -0,0 +1,40 @@
---
type: concept
tags: [parking, domain, business, occupancy]
sources: []
updated: 2026-06-15
status: open
---
# Capacity & Occupancy
How many vehicles are inside, how many spaces remain, and what happens when the lot is full.
## Occupancy is a projection (like everything else)
`occupancy = count(open [[parking-session|sessions]])` — an entry with no matching exit. It is a
**fold over the signed [[append-only-event-chain]]**, never a hand-maintained counter (a counter is
editable and drifts; the chain is the truth). Spaces-free = `capacity − occupancy`.
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one
permit's open sessions.
## Full → refuse entry + FULL sign
- When `occupancy ≥ capacity`, the entry flow **refuses** (no `vehicle_entry`, no barrier open) and
can drive a **"FULL" sign** (a relay/output, via the device adapter layer).
- **Safety/policy nuance:** "full" blocks *entry* only — **exit always works** ([[fail-state-safety]]:
exit fails open; never trap a vehicle). Permit holders may be allowed in past a "transient full"
threshold (reserve spaces for subscribers) — an optional policy knob.
- **Counting drift is real:** tailgating (two cars, one entry) and missed reads make the live count
diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
not silently corrected.
## 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.
+48
View File
@@ -0,0 +1,48 @@
---
type: concept
tags: [parking, security, integrity, offline-first, anti-fraud]
sources: []
updated: 2026-06-15
status: open
---
# Clock Integrity
Fees are a function of **time** ([[tariff]]: `fee = f(enteredAt, asOf)`), and the event chain is
ordered/timestamped. So **the host clock is part of the trust model** — and on an offline appliance
([[offline-first]], no NTP guarantee) it's a real attack surface, fitting the
[[threat-model|operator-as-adversary]] frame:
- **Backdating to cut a fee** — wind the clock back so a long stay computes as short, or so an exit
timestamps before its entry.
- **Forward/backward jumps** that corrupt durations, the rolling-24h cap, or shift boundaries
([[shift]]).
- An operator with host access changing the system time deliberately.
## What protects it
- **Monotonic chain order is independent of wall-clock.** The [[append-only-event-chain]] `index`
is strictly increasing regardless of timestamps, so **reordering** is caught even if timestamps
are forged. But the *durations* used for pricing still rely on the wall clock — so:
- **Detect clock anomalies and record them as events.** A timestamp that goes **backwards** between
consecutive chain events, or jumps implausibly, is an `anomaly` (the type already exists) — signed
and surfaced to [[reconciliation]], not silently accepted.
- **Hardware-backed time where possible.** A battery-backed RTC on the appliance; the
[[atecc608]]/secure element and [[disk-os-hardening]] reduce casual tampering. An operator
changing time should require privilege the booth login doesn't have.
- **Opportunistic trusted sync** when a [[reconciliation]] channel is briefly online (the same
USB/hotspot path) — set/check the clock against an external authority, log any correction as an
event.
## Stance
Like the rest of the system: **prevention (hardened host, privileged-only time change) first,
detection (anomaly on clock regression, reconciliation) as the backstop.** The clock can't be made
unforgeable on an offline box, but a forged clock can be made **visible**.
## Open
- RTC / time source on the chosen appliance ([[bom]]).
- Tolerance thresholds for "implausible" jumps before flagging.
- Whether to hard-refuse an event on a backwards clock vs. record-and-flag (record-and-flag matches
the append-only ethos — never drop).
+45
View File
@@ -0,0 +1,45 @@
---
type: concept
tags: [parking, devices, monitoring, telemetry]
sources: []
updated: 2026-06-15
status: open
---
# Device Events (telemetry)
The **unsigned** operational record of what the hardware did and reported — distinct from the
signed business [[append-only-event-chain|ledger]] (see [[event-streams-split]]). For monitoring,
diagnostics, and live booth status — **not** anti-fraud.
## What lands here
- **Relays/barriers:** relay fired/released, pulseOpen issued (the *device-side* echo; the
authoritative `barrier_open_command` is a signed ledger event).
- **Printers:** paper-out / near-end / cover-open / cutter / offline (already polled —
[[printer-status-monitoring]]).
- **Cameras:** reachable/offline, snapshot success/failure ([[lpr-camera]]).
- **Readers / inputs:** a raw read, raw input edges (Dingtian button `input N on/off` —
[[device-input-flow]]).
## Properties
- **Unsigned, not chained** — no `prevHash`/`signature`. It's telemetry, so it carries none of the
ledger's integrity machinery.
- **Disposable** — high-volume and churny; **may rotate/prune** on a retention policy (the ledger
never does).
- **Device-keyed** — references the `lane_devices` instance; `lane` resolved via the same `LaneMap`
as before. Stores raw device provenance.
## The boundary that matters
A device event is *evidence the host saw something happen*; it does **not** by itself authorize or
record a business fact. A button press here becomes a **signed `vehicle_entry`** in the ledger only
after the entry flow runs (ticket + barrier command). This keeps device chatter on the device side
of the [[device-adapter-pattern|adapter boundary]] and the signed ledger focused on money/access.
## Open
- Retention/rotation policy (size- or age-based).
- Whether any witness-grade device fact (e.g. a loop-sensor `barrier_open_observed`) should *also*
write a signed ledger entry for [[reconciliation]] — see [[append-only-event-chain]].
+103
View File
@@ -0,0 +1,103 @@
---
type: concept
tags: [parking, domain, business, anti-fraud]
sources: []
updated: 2026-06-15
status: open
---
# Parking Session
The core business-domain entity: one vehicle's stay, from entry to exit, plus the money owed and
paid for it. Everything on the business side — [[tariff|tariffs]], payment, [[reconciliation]],
revenue reporting — hangs off the session. This page defines what a session **is** and, just as
importantly, what it is **not**.
> Scope decision (2026-06-15): build the **transient** (casual, pay-for-duration) session first;
> layer **permit holders** on top as a second identity source that short-circuits payment. Mixed
> site, transient-first — see [[entry-exit-readers]] ("two populations, one shared relay") and
> [[session-model]].
## A session is a PROJECTION over the signed event log — not a mutable table
This is the single most important rule, and it falls straight out of the [[threat-model]] (the
adversary is the insider who can edit the database) and the [[append-only-event-chain]]:
- The **events** table is the ledger and the **only** source of truth. `vehicle_entry`,
`vehicle_exit`, `payment`, `void` are all **appended + signed**, never updated or deleted.
- A **session** is a **read-model folded from those events** — open when an entry has no matching
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
from the chain and never authoritative**. Same pattern as the `LaneMap`
([[append-only-event-chain]]), scaled to the business domain.
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
can't forge or silently delete — a deletion breaks the chain visibly. See [[session-model]] for
the rejected mutable-table alternative.
## Identity — how an entry is tied to its exit
A session needs a key that survives from entry to exit. Two populations, two keys
([[entry-exit-readers]]):
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid
permit means the session owes nothing — the PAY step is skipped (see below).
## Lifecycle (pay-on-foot / pay station model)
Payment is **decoupled from exit** (decision 2026-06-15, matching the [[autonomous-direction|
unmanned]] roadmap): the customer pays at a central station before walking back to the car; the
exit lane only *validates* that the session is settled.
```
ENTRY (lane) vehicle_entry event → session OPEN
(ticket printed / plate read; barrier opens)
PAY (pay station) payment event {sessionRef, fee, paidAt}
→ session PAID (grace window starts)
EXIT (lane) validate: PAID && now ≤ paidAt + graceMinutes ?
yes → vehicle_exit event → session CLOSED → pulseOpen
no → reject → re-pay overstay top-up at station, then exit
```
States, as derived from events:
| State | Condition (over the event chain) |
| --- | --- |
| **OPEN** | a `vehicle_entry` with no later matching `vehicle_exit` |
| **PAID** | OPEN + a `payment` event covering the fee due, within its grace window |
| **CLOSED** | a matching `vehicle_exit` event exists |
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close.
## Edge cases the model must name (not yet designed in full)
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
stateful rule; handled as a second `payment` event, fee = f(time since paid).
- **Lost ticket** — no entry id to match. A default flat "lost ticket" fee (see [[tariff]]), **or
an amount the admin sets at the moment** (operator judgement — e.g. they can establish entry time
from [[opencv-anpr-service|plate]] capture or CCTV and charge accordingly, or apply a fixed
penalty). Recorded as a `payment` (with the chosen amount + a reason) + a `void`/annotation so it
reconciles; the admin-set amount is captured in the signed event, attributed.
- **Manual override** — an operator/admin opens the barrier for a stuck or disputed car, or writes
off a session, as a deliberate act. Each is a **signed, reason-coded event**
(`barrier_open_command` / a void with reason) — so an override is *authorized and logged*, while
an open with **no** such signed event remains the fraud signal ([[append-only-event-chain]]). The
override is the legitimate counterpart to the out-of-band-open anomaly.
- **Forced / fail-open exit** — barrier failed open ([[fail-state-safety]]): the vehicle leaves with
**no `vehicle_exit`**. This is an open session that never closes — a **reconciliation anomaly by
design** ([[append-only-event-chain]]'s "physical open with no signed command"), not something to
paper over. (A *manual* override above is the signed, non-anomalous version.)
- **Re-entry / never-exited** — stale open sessions (drove out tailgating, sensor missed). Surface
as anomalies; never auto-close silently.
## What this unblocks (build order)
The device layer left the entry flow dangling — `input_received` events land in the log and stop
([[device-input-flow]] "the entry flow itself is the next build"). The session domain is that next
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]];
the decision is recorded in [[session-model]].
+48
View File
@@ -0,0 +1,48 @@
---
type: concept
tags: [parking, domain, business, reporting]
sources: []
updated: 2026-06-15
status: open
---
# Reporting & Analytics
Turning the signed event log into the numbers an owner runs the business on. All reports are
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
derived and rebuildable, never a separate ledger.
## Reports (driven by the events already designed)
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split.
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
the `anomaly` events + reconciliation findings ([[reconciliation]]).
## Plate / entry search (admin lookup) — user-requested 2026-06-15
The admin can **search for an entry/session by licence plate** — *if the plate was captured* (by
the [[opencv-anpr-service|vision service]] or an LPR read; a pure-ticket transient has no plate).
Returns the matching session(s): entry/exit times, fee, payment, snapshot image. Useful for
disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and incident review.
- Search keys: plate (when captured), ticket id, session id, time range.
- Read-only over the chain; surfaces the linked snapshot ([[lpr-camera]] `imageRef`) as evidence.
- Honest limit: **no plate → no plate-search hit.** The UI must say "not captured", not "no such
car", so the absence isn't mistaken for a missing record.
## Properties
- **Offline** ([[offline-first]]): all computed locally from the local DB; no cloud BI dependency.
- **Reproducible**: a report run twice over the same chain gives the same answer; figures trace to
signed events.
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
([[open-questions]] #4).
## Open
- Which reports matter at launch vs. later; the export format/cadence.
- Dashboard (live) vs. on-demand reports.
+77
View File
@@ -0,0 +1,77 @@
---
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-06-15
status: open
---
# Shift (manned mode) & the Z-Report
A **shift** is one operator's accountability period at a manned booth: from the moment they take
over to the moment they hand over, however long that is. At the end, the system signs and **prints
a Z-report** — the cash and POS totals taken during the shift. (Decisions 2026-06-15.)
## Shifts exist ONLY in manned mode
A shift is fundamentally a **human accountability boundary** — "this person was responsible for the
takings from here to here." In the [[autonomous-direction|fully-automated / unmanned]] system there
is **no operator and no shift**; what replaces it is the pay station's **cash-collection cycle**
(who emptied the vault, when, how much vs. what the signed log expected) plus ongoing
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
don't force one model across both.
## A shift is NOT time-based
It is delimited by **explicit operator action**, never by a clock:
- Booth reality: relief comes late, doesn't show, or one operator is **forced to work two shifts in
a row**. A fixed 8h boundary (or an 8h token expiry) would be wrong — it could strand an active
operator. So the [[local-jwt-auth|login token has no time expiry]] (valid until logout).
- **Start Shift / End Shift are explicit, and independent of login.** One login can span many
shifts; a back-to-back double is simply *End Shift → Start Shift again*, no re-login. The
operator (the same person or the next) marks the boundary.
```
login ——————————————————————————————————————————————→ (until logout)
[Start shift] … takings … [End shift→sign+print Z] [Start shift] … [End shift] …
```
## What End Shift does
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
[[append-only-event-chain]]) between this shift's start mark and now.
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
(the card line is omitted when there's no terminal).
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
startedAt, endedAt, cashTotal, cardTotal?, paymentCount, eventRange, prevZHash }` — chained to
the prior Z so a missing/out-of-order Z-report is itself visible.
4. **Print the Z-report** (cash total, POS total if any, counts, shift window, operator) on the
booth printer.
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
no variance gate, no manager override.
## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
append-only chain**, the printed cash figure *is* the system's tamper-evident truth. A manager
reconciles the signed Z-report against the actual drawer and the bank/POS batch **later** — that's
[[reconciliation]], the real control (deferred). The tradeoff vs. a heavier control is purely
*when* a skim is caught (after the fact, by a human), not *whether*.
> **Optional enhancement (not building now): blind cash count.** Have the operator enter the
> counted cash *before* the system reveals the expected figure, and record the variance into the
> `shift_z_report`. Blindness removes the operator's ability to back-fill their declaration to match
> expectation, catching a skim **at close** rather than later. Explicitly out of scope per
> 2026-06-15; documented as a clean add-on if ever wanted.
## Open
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
the money. Confirm that's the intended accountability (vs. by entry).
- **Mid-shift report / X-report** (read-only "so far" total without closing) — add if booths want
it; the sum is the same projection.
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
(relates to [[open-questions]] #1 lane topology).
+155
View File
@@ -0,0 +1,155 @@
---
type: concept
tags: [parking, domain, business, pricing]
sources: []
updated: 2026-06-15
status: open
---
# Tariff (Fee Model)
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
not code** — the park owner builds and constantly edits the rate card at runtime (like a
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host).
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
> over time), modelled with an id/scope so multiple rate cards can be added later without migration;
> (3) **currency is selectable** (ISO 4217) and the money model is **FX-ready but FX is deferred**.
## Design principles
- **Pure function of (entry time, charge time, tariff).** `fee = f(enteredAt, asOf, tariff)`. No
side effects, deterministic, unit-testable. The pay station calls it with `asOf = now`; the exit
lane re-checks against the recorded payment.
- **Data-driven.** The tariff lives as a config record (its own table or seeded config), versioned,
so a historical session always reprices against the tariff in force when it was incurred. Never
hard-code rates (this is an [[open-questions|open-question]]-adjacent procurement input — sites
differ).
- **Integer minor units.** Money is integer cents (or the site currency's minor unit) — never
floats. Avoids rounding drift across a revenue ledger.
- **The fee, once paid, is a signed `payment` event** ([[parking-session]]) — the computation is
reproducible, but the *charged* amount is fixed in the chain.
## The composable structure — stepped blocks + daily cap
The admin composes a **rate card** the fee function interprets. The general model is an **ordered
list of duration blocks** (flat rate is just one block) plus a daily cap — chosen because it
expresses every common operator shape (first-hour pricing, tapering, caps) with no special cases in
code. All amounts are **integer minor units** in the tariff's currency.
```jsonc
{
"currency": "EUR", // ISO 4217; selectable per tariff version
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
"incrementMin": 60, // billing granularity; partial increments round UP
"blocks": [ // consumed in order as duration accrues
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter
],
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
"lostTicketMinor": 2000, // flat charge when there's no entry id
"gracePeriodExitMin": 15, // pay-on-foot walk-back window
"overstay": "reprice" // top-up = recompute(entry→now) − alreadyPaid (decided)
}
```
> **The numbers above are illustrative, not defaults to ship.** "No one knows the pricing and it
> changes constantly" — so the admin authors all of it; the system ships with **no rate card** and
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
> operator policy, see Open).
**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary
amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]]
capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
default; the chosen amount is recorded in the signed `payment` event ([[parking-session]]).
## The fee algorithm (pure, integer, offline)
```
fee(enteredAt, asOf, tariff):
minutes = roundUp(asOf − enteredAt, incrementMin)
if minutes ≤ gracePeriodEntryMin: return 0
total = 0
for each rolling 24h segment of the stay:
segMinutes = minutes within this segment
segFee = walk `blocks` in order, charging priceMinorPerIncrement for each
incrementMin that falls in each block's [prevUpto, uptoMin) range
if dailyCapMinor: segFee = min(segFee, dailyCapMinor)
total += segFee
return total
```
Deterministic, side-effect-free, unit-testable; the daily cap is applied **per rolling 24h** (so an
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
because the chain + reconciliation depend on the result being reproducible.
## The pay-on-foot consequence
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
time references**, not one:
1. At the **pay station**: `fee = f(enteredAt, now, tariff)` — charge for time parked so far.
2. At the **exit lane**: the session is valid to leave iff `now ≤ paidAt + gracePeriodExit`.
Past that, an **overstay top-up** = `f(paidAt, now, tariff.overstayRate)` is due before exit.
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
who paid; too long gives free parking between pay and exit.
## Permit holders
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
the uncovered time — an edge case to design with [[permit]].
## Versioning — edits publish immutable, effective-dated versions
Prices change constantly, **and** a historical [[parking-session]] must reprice against the rate
that was in force when it was incurred — never today's. So a tariff is **never edited in place**:
- Each save **publishes a new version** with an `effectiveFrom` timestamp; prior versions are
**immutable**. Picking the version for a session = "the latest version with `effectiveFrom ≤
session entry time`".
- The session's **`payment` event records the `tariffVersionId`** it was priced under
([[parking-session]], [[append-only-event-chain]]). The charged amount is then both reproducible
*and* fixed in the signed chain — an admin can't retroactively rewrite prices to alter what a past
session "should have" paid without it being visible.
- An **in-progress** session that crosses a version boundary uses the version in force at **entry**
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
## Data model (first cut — with [[session-model]])
| Table / field | Notes |
| --- | --- |
| `tariffs` | a logical rate card: `id`, `scope` (site/lane/zone — only "site" used now), `name`. |
| `tariff_versions` | `id`, `tariffId`, `effectiveFrom`, `currency`, `structure` (the JSON above), `createdBy`, `createdAt`. **Immutable.** |
| (active) | "one active tariff per site" = one `tariffs` row; multiple `tariff_versions` over time. The `scope`/`id` exist so multiple rate cards can be added later **without migration**. |
Unlike the event log, tariff data is **mutable master data** in the sense that new versions are
*added*; but each version row, once published, is never changed — close to append-only, and the
*use* of it is fixed in the signed `payment` event.
## Currency & FX — selectable now, FX deferred
- Each `tariff_version` names its **`currency`** (ISO 4217), admin-selectable. Amounts everywhere
are `{ minorUnits, currency }` — never a bare number, never a float.
- A `payment` event stores its **`currency`** and a reserved **`fxRate` (null for now)** + optional
`baseCurrency`. So when an exchange-rate system is added later, historical payments stay
reproducible (you know the currency charged and, once FX exists, the rate applied) — **no
migration** of stored amounts.
- **FX engine is NOT built now.** When it is, it needs an *offline* rate source (rates can't depend
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
[[open-questions]].
## Open
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site
needs day/night/weekend cards (deferred until asked).
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
+54
View File
@@ -0,0 +1,54 @@
---
type: concept
tags: [parking, domain, business, devices, entry-flow]
sources: []
updated: 2026-06-15
status: open
---
# Ticket Encoding & Scanning
How a transient [[parking-session]]'s **ticket id** is printed, carried by the customer, and read
back at the pay station and exit. This is the **physical backbone of the transient flow** — the
thing that links entry → pay → exit when there's no plate.
## The ticket id is the session key
At entry the system mints a `vehicle_entry` event with a **ticket id** (`identity`) and prints a
ticket the customer keeps. That same id is read back later to find the session. Properties the id
must have:
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
someone else's cheaper session). Sequential **physical** stock numbering is a separate
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
- **Single logical session** — scanning it at the pay station finds the open session; after payment
it's the proof-of-paid the exit checks.
## Encoding: QR (preferred) — printed by the booth dispenser
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
camera/imager to read.
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
payment ([[tariff]], pay-on-foot) → appends `payment`.
- **Exit lane** — customer scans the (now paid) ticket → host validates paid + within
`gracePeriodExit` → `vehicle_exit` → `pulseOpen`.
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
## Ticketless alternative (plate as the ticket)
Where the [[opencv-anpr-service|vision service]]/LPR captures the plate, the **plate can be the
session key** instead of a printed ticket — drive in, plate read, drive to pay station and enter
plate (or it's looked up), pay, exit by plate. No paper. The two can coexist per lane
([[entry-exit-readers]] "both share a relay"); a printed QR ticket is the fallback when a plate
isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-service]]).
## Open
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number).
- Scanner hardware (imager model; same unit at pay station and exit?).
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
amount).
+46
View File
@@ -0,0 +1,46 @@
---
type: concept
tags: [parking, domain, business, pricing, revenue]
sources: []
updated: 2026-06-15
status: open
---
# Validation & Discounts
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
a common revenue/retention feature that modifies what a [[parking-session]] owes.
## Model: a discount is a signed event, applied at fee time
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
as everything else ([[threat-model]]: an operator/merchant could otherwise fake free parking). It's
recorded so the fee computation and the audit both see it:
- A **discount/validation event** references the session: `{ sessionRef, kind, value, issuedBy,
ts }` — e.g. *2 hours free*, *€5 off*, *flat €1*, *100% off*. Appended + signed
([[append-only-event-chain]]).
- The [[tariff]] fee function applies eligible validations when computing what's due at the pay
station: `due = max(0, tariff_fee − discounts)` (or time-based: subtract validated minutes before
pricing). Pure + reproducible, like the base fee.
- The `payment` event then records gross fee, discount total, and net paid — so revenue reporting
([[reporting-analytics]]) can show **discount leakage** (how much was given away, by whom).
## How a validation is presented
- **Merchant terminal / portal** stamps the customer's ticket id (or plate) — issues the validation
event for that session.
- Or a **validation code** the customer enters at the pay station.
- Either way it ties to the session by **ticket id or plate** ([[parking-session]] identity).
## Anti-abuse
Because each validation is signed and attributed (`issuedBy`), over-validation by a colluding
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
an anomaly), rather than invisible free parking.
## Open
- Validation types the site needs (free hours / fixed amount / percentage / flat rate).
- Whether merchants self-serve (portal/terminal) or the operator applies it.
- Caps (max discount, max per merchant/day).
+57
View File
@@ -0,0 +1,57 @@
---
type: decision
tags: [parking, decisions, integrity, devices, schema]
sources: []
updated: 2026-06-15
status: open
---
# Decision: Split the signed business ledger from device telemetry
Taken 2026-06-15, at the start of the business-layer schema work.
## The problem
The existing `events` table (signed, hash-chained — [[append-only-event-chain]]) had grown to carry
**two unrelated concerns**: the financial/accountability ledger *and* raw device telemetry (button
pushes recorded as `input_received`). They have opposite requirements — the ledger must be small,
signed, and reconciled; telemetry is high-volume, churny, and disposable.
## Decision — two tables
- **`ledger_events`** — the signed, hash-chained, [[atecc608]]-signed **business ledger** (rename of
`events`). Holds only business/accountability facts: `vehicle_entry`, `vehicle_exit`, `payment`,
`void`, `shift_z_report`, and the witness-grade `barrier_open_command` / `barrier_open_observed` /
`anomaly`. [[reconciliation]] runs against this; sessions/[[tariff]]/occupancy are projections of
it.
- **`device_events`** — **unsigned** operational telemetry (see [[device-events]]): relay fired,
printer paper-out, camera offline, reader read, raw input edges. May rotate/prune. Never signed,
never reconciled.
A raw button press is **telemetry** → `device_events`. The entry flow then mints a **signed
`vehicle_entry`** in the ledger once a ticket prints + the barrier is commanded. So
`input_received`-as-a-signed-event is **dropped** (it was transitional).
## Why
- Keeps the **signed ledger small and high-value** — fewer rows to sign, hash, verify, reconcile,
and export; signal isn't drowned in device noise.
- Right **durability semantics per stream**: the ledger is precious + append-only forever; telemetry
can age out.
- Clean separation matches the [[device-adapter-pattern]] philosophy — device chatter stays on the
device side of the boundary.
## Consequences / migration (no production data yet)
- No `.sqlite` with real chain data exists, so renaming + restructuring is safe now (no signatures
to invalidate). This is the moment to do it.
- Code: rename `events` → `ledger_events`; `EventLog`/`canonicalize`/`verifyChain` and the
`/api/events` routes follow the rename; add an unsigned `device_events` writer; move the Dingtian
input-push handler to emit `device_events` (+ the entry flow signs `vehicle_entry`).
- `ParkingEventType` in `packages/shared` splits into ledger types vs. a device-event type set.
## Open
- `device_events` retention/rotation policy.
- Which device facts (if any) are witness-grade enough to *also* warrant a signed ledger entry
(e.g. `barrier_open_observed` from a loop sensor) — see [[append-only-event-chain]] witness gap.
+18 -1
View File
@@ -24,7 +24,12 @@ status: open
manager visit) to reconcile the signed log against an external authority — the real anti-fraud manager visit) to reconcile the signed log against an external authority — the real anti-fraud
control. See [[reconciliation]]. control. See [[reconciliation]].
5. **Durability / backup.** Backup strategy for the [[sqlite]] database + recovery plan; "sync 5. **Durability / backup.** Backup strategy for the [[sqlite]] database + recovery plan; "sync
later" currently leaves a disk failure as **total revenue-history loss**. later" currently leaves a disk failure as **total revenue-history loss**. _(Confirmed in-scope
to design, 2026-06-15.)_ Because the DB is the signed [[append-only-event-chain]], a backup must
preserve the chain intact (a restored copy must still `verifyChain`); options include SQLite
WAL/online-backup snapshots to a second disk/USB + the periodic external export that doubles as
the [[reconciliation]] channel (#4). Encryption at rest already applies ([[disk-os-hardening]]).
Design TBD.
6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event 6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event
signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not
being implemented for now** (access control is the [[dingtian-relay]] behind being implemented for now** (access control is the [[dingtian-relay]] behind
@@ -39,3 +44,15 @@ status: open
compromising a verifying host yields nothing that can forge a token. Decide before compromising a verifying host yields nothing that can forge a token. Decide before
multi-host / multi-lane deployment (see #1 lane topology), since that's when shared-secret multi-host / multi-lane deployment (see #1 lane topology), since that's when shared-secret
distribution becomes the liability. distribution becomes the liability.
8. **Exchange-rate (FX) system.** _(Raised by the [[tariff]] design, 2026-06-15.)_ Currency is
selectable per tariff version and the money model is FX-ready (`payment` stores currency + a
reserved `fxRate`), but **no conversion is built**. If multi-currency pricing/charging is ever
needed, it requires an **offline** rate source (rates can't depend on the network —
[[offline-first]]), a base currency, and a rounding policy. Deferred; nothing blocks adding it
later without migrating stored amounts.
9. **Pay-station money corners — receipts & refunds/change.** _(Raised by the scope sweep,
2026-06-15; deferred until pay-station hardware is chosen.)_ Not yet designed: **receipts / VAT
invoices** (fiscal receipt with tax number + sequential numbering may be legally required — could
change what the `payment` event must store) and **refunds / overpayment / change** (cash change,
"exact change only", a refund as a signed reversal event). Both depend on the unmanned-vs-manned
payment subsystem (#3) and the note/coin/card acceptor hardware. Revisit at procurement.
+55
View File
@@ -0,0 +1,55 @@
---
type: decision
tags: [parking, decisions, domain, business]
sources: []
updated: 2026-06-15
status: open
---
# Decision: Parking Session Model
The starting decision for the **business layer**, taken 2026-06-15 as the project pivots from the
(now hardware-verified) device/integrity layer to the parking *operation*.
## Decisions
1. **A session is a projection over the signed event log, not a mutable table.** The
[[append-only-event-chain]] `events` table stays the only source of truth; a
[[parking-session]] is folded from `vehicle_entry` / `vehicle_exit` / `payment` / `void`
events. A cache table is allowed for query speed but is always rebuildable and never
authoritative.
2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first;
layer [[permit]] holders on top as a second identity source that short-circuits payment
([[entry-exit-readers]]).
3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a
central station; the exit lane only validates the session is paid and within the walk-back
grace window before opening ([[parking-session]] lifecycle). Matches the
[[autonomous-direction|unmanned]] roadmap and sharpens [[open-questions]] #3 toward an unmanned
pay station (PCI scope still kept out of the app via a certified terminal).
4. **New signed event types:** `vehicle_entry`, `vehicle_exit`, `payment`, `void` — extend the
existing `input_received`. Recorded in [[append-only-event-chain]].
## Why (rejected alternative)
A **mutable `sessions` table** carrying `amountOwed` / `paidStatus` as the source of truth was
rejected: it reopens the exact fraud vector the system exists to close ([[threat-model]] — the
insider edits the row, marks it paid, pockets the cash). Making "paid" a **signed `payment`
event** means it can't be forged and can't be silently deleted (a deletion breaks the chain). The
projection approach costs a fold/cache but keeps the anti-fraud guarantee intact end-to-end.
## What this unblocks
Closes the dangling thread from [[device-input-flow]] ("the entry flow itself is the next
build"): `input_received` → signed `vehicle_entry` → ticket print → `pulseOpen`, then the
pay-station and exit-validation flows. Schema (`packages/db`) + shared types follow the
[[parking-session]] + [[tariff]] design pages.
## Open / next
- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]).
- Tariff versioning (effective-dated) for historical repricing.
- [[permit]] data model + lapsed-mid-stay handling.
- Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract
(payment = an independent signed event referencing a session) until procurement settles.
- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4
+ the unbuilt witness/reconciliation gap in [[append-only-event-chain]].
+4
View File
@@ -14,6 +14,10 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch
- **Stack:** [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · [[sqlite]] + - **Stack:** [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · [[sqlite]] +
[[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — **no vendor lock, no rug-pull [[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — **no vendor lock, no rug-pull
risk** (see [[payload-cms]]). Full table in [[technology-stack]]. risk** (see [[payload-cms]]). Full table in [[technology-stack]].
- **Scoped exception (2026-06-15):** the [[opencv-anpr-service]] — a **separate local process**,
not linked into the app — **may use AGPL** components (plate/vehicle models). The exception is
bounded to that process; the Node/React app stays strictly MIT/Apache/BSD. See
[[vision-service]].
- **Platform:** a **dedicated, hardened Linux appliance** (LUKS + GRUB password + Secure Boot), - **Platform:** a **dedicated, hardened Linux appliance** (LUKS + GRUB password + Secure Boot),
**not Windows/WSL** — see [[disk-os-hardening]]. **not Windows/WSL** — see [[disk-os-hardening]].
- **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log - **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log
+61
View File
@@ -0,0 +1,61 @@
---
type: decision
tags: [parking, decisions, vision, anpr, anti-fraud]
sources: []
updated: 2026-06-15
status: open
---
# Decision: Host-side Vision Service (ANPR + vehicle verification)
Taken 2026-06-15, as part of the business-layer build ([[session-model]]).
## Decisions
1. **Build a host-side vision service** ([[opencv-anpr-service]]) that does ANPR (plate → identity)
**and** vehicle-attribute verification (anti-spoofing witness) on snapshots from ordinary
Hikvision/Dahua cameras.
2. **It replaces the dedicated edge-AI [[lpr-camera]]** as the recognition path: ordinary IP cam →
snapshot (`Snapshot.bytes`, already pulled by the camera driver) → vision service → plate +
vehicle. Removes the special LPR camera from the [[bom]] as a requirement (still allowed as an
option).
3. **Deployment: a separate local Python/OpenCV microservice** on the appliance, called over
**localhost HTTP** by the Node backend. Fully offline ([[offline-first]]); its own process and
failure domain; the host falls back to the ticket path if it's unavailable.
4. **Licensing exception:** AGPL components (e.g. YOLO plate/vehicle models, OpenALPR) are
**permitted inside this service only**, because it's a separate process not linked into the app —
the app stays strictly MIT/Apache/BSD. Amends [[standing-decisions]].
5. **Recognition is advisory, evidence is authoritative.** A read never single-handedly authorizes
a paid/access barrier open; it flags for [[reconciliation]] and attaches (with the source image)
to the signed [[append-only-event-chain]] entry. Low confidence → fallback, never strand a car
([[fail-state-safety]]).
## Why
- **Replace vs. edge-AI camera:** host-side recognition on cheap IP cams shifts cost from per-lane
smart cameras to one compute box + our software; gives us the raw image for the second job below.
- **Vehicle verification is the real prize (user-driven, 2026-06-15):** plate-only ANPR can't catch
a **printed/spoofed plate on a different car**. Extracting vehicle attributes/fingerprint lets the
system reconcile *the car*, not just the number — directly filling the independent-witness gap the
[[append-only-event-chain]] calls out as unbuilt.
- **Separate-process + AGPL-scoped** keeps the app's permissive-license guarantee intact while not
crippling accuracy (the strict permissive-only ANPR path is markedly weaker — that tradeoff was
weighed and the scoped exception chosen).
## Rejected / alternatives
- **Strict permissive-only ANPR in-app** — license-clean but weaker accuracy and more build; the
separate-process AGPL exception was chosen instead.
- **Keep the edge-AI LPR camera as primary** — viable fallback if host-side accuracy disappoints;
not chosen now, kept on the table in [[opencv-anpr-service]].
- **Embed OpenCV in Node** (opencv4nodejs/WASM) — rejected: native-build pain, weaker model
ecosystem, no process isolation, shares the app's failure + license surface.
## Open / next
- Recognizer + vehicle-model selection and accuracy targets; fingerprint method + anomaly
threshold ([[opencv-anpr-service]]).
- Appliance compute footprint (CPU vs. small GPU/NPU) — [[bom]] / [[open-questions]].
- Service API + the Node-side adapter; per-camera opt-in wiring.
- Reconciliation logic that consumes plate+vehicle witness vs. commanded opens (still unbuilt — see
[[append-only-event-chain]], [[reconciliation]]).
+35
View File
@@ -0,0 +1,35 @@
---
type: entity
tags: [parking, domain, business, access-control]
sources: []
updated: 2026-06-15
status: open
---
# Blocklist (Banlist)
Plates or credentials the lot **refuses** — barred vehicles (non-payers, abusers, court orders) and
revoked/stolen cards. Checked in the entry flow.
## Model
- A `blocklist` table of `{ kind: 'plate' | 'card' | 'qr', value, reason, addedBy, addedAt }` —
admin-managed master data (mutable: add/lift a ban).
- **Entry check:** after identifying the vehicle ([[parking-session]] identity — plate via
[[opencv-anpr-service|vision]]/LPR, or card/QR), if it matches an active blocklist entry, **refuse
entry** and append a signed event (`anomaly` / a refused-entry record) so the attempt is logged.
- **Exit is never blocked** — a barred car already inside must still leave ([[fail-state-safety]]:
never trap a vehicle). A blocklist hit at exit is logged for follow-up, not used to detain.
## Notes
- Plate matching depends on capture quality — a blocklist-by-plate is only as good as the
[[opencv-anpr-service|vision]] read; treat a near-miss as a flag for a human, not an automatic
refusal that could strand a misread innocent car.
- Bans are attributed (`addedBy`) and their enforcement is logged, so the control is auditable
([[reconciliation]]) rather than an invisible operator lever.
## Open
- Plate-match tolerance (exact vs. fuzzy) and the false-positive handling.
- Expiry / review of bans.
+8 -1
View File
@@ -13,7 +13,14 @@ Authentication and authorization, kept **fully local** — a direct consequence
- `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to - `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to
start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no
insecure default — and mints tokens with an **8h expiry** (bound to a shift). insecure default.
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15).
Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is
forced to work two shifts in a row — a token that expired mid-duty would strand an active
operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**,
not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.)
> ⚠️ Code still mints an 8h-expiry token — this page records the decided design; the server
> change (drop `expiresIn`, persist until logout) is pending.
- A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The - A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The
first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint). first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint).
- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier / - Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
+10 -5
View File
@@ -7,12 +7,17 @@ updated: 2026-06-15
# LPR Camera # LPR Camera
License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For License-plate-recognition camera. For **casual/transient** vehicles, the **plate acts as ticket +
**casual/transient** vehicles, the **plate acts as ticket + an independent record**. (See an independent record**. (See [[parking-system-architecture]] §8, §9.)
[[parking-system-architecture]] §8, §9.)
- **Edge AI**: recognition runs **on-device**, so it keeps working with no internet — fits > **Superseded direction (2026-06-15):** recognition now runs **host-side** on snapshots from
[[offline-first]]. > ordinary Hikvision/Dahua cameras via the [[opencv-anpr-service]], **not** on a dedicated edge-AI
> LPR camera — see [[vision-service]]. The edge-AI camera below is kept as the original assumption /
> a fallback option, but is no longer the planned path. The host-side service also does **vehicle
> verification** (anti-plate-spoofing), which an edge-LPR camera does not.
- **Edge AI (original assumption)**: recognition runs **on-device**, so it keeps working with no
internet — fits [[offline-first]].
- It's a **host-side** identity source: only the host sees the read; the host decides and - It's a **host-side** identity source: only the host sees the read; the host decides and
commands the relay open (the [[uhppote-controller]] is demoted to a commanded relay for that commands the relay open (the [[uhppote-controller]] is demoted to a commanded relay for that
lane). See [[entry-exit-readers]]. lane). See [[entry-exit-readers]].
+85
View File
@@ -0,0 +1,85 @@
---
type: entity
tags: [parking, vision, anpr, anti-fraud, service]
sources: []
updated: 2026-06-15
status: open
---
# OpenCV ANPR / Vision Service
A **local microservice** that analyses camera snapshots: reads the licence **plate** (ANPR) and
extracts **vehicle attributes** for verification. Built by us (decision 2026-06-15) to do
recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicated edge-AI
[[lpr-camera]]. See decision [[vision-service]].
## Two jobs
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
a plate-bound [[permit]] is matched.
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives
in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at
entry vs. exit (and vs. the [[permit]]'s known car) can. A plate that entered on a red hatchback
but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role
the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]].
> The two jobs are why this is worth building rather than just plate-OCR: the service is both an
> **identity source** and an **independent witness**, the visual analogue of the whole system's
> "two records that must reconcile" thesis.
## Architecture — separate localhost process
- A **Python service** (e.g. FastAPI) running **on the appliance**, called by the Node backend over
**localhost HTTP** (`POST /analyze` with the JPEG bytes the camera driver already pulls — see
[[lpr-camera]] "driver/storage boundary": `Snapshot.bytes`).
- **Fully offline** ([[offline-first]]): all inference is local, no cloud. Model weights ship on the
appliance.
- **Process isolation is deliberate** — it keeps a heavy Python/native/AGPL stack out of the
Node app's process and license surface (see licensing below), and gives it its own failure
domain. If the service is down/slow, the host falls back (transient ticket path) rather than
blocking the lane.
- **Request/response (first cut):**
- `POST /analyze` → `{ plate: {text, confidence, bbox}|null, vehicle: {colour, bodyType, make?, model?, embedding?}, modelVersion, tookMs }`
- `GET /health` → readiness + model versions.
- The Node side wraps it behind an internal interface (like a device adapter) so the recognizer can
be swapped without touching business logic.
## Licensing — scoped AGPL exception (amends the standing rule)
The app is strictly **MIT/Apache/BSD** ([[technology-stack]], [[standing-decisions]]). Accurate
ANPR/vehicle models are mostly **AGPL** (YOLO/Ultralytics detectors, OpenALPR) or commercial.
Decision (2026-06-15): **allow AGPL inside this service only.** It is a **separate process**, not
linked into the app, so its obligations don't reach the Node/React codebase; the app's permissive
guarantee is preserved. Recorded as an explicit exception in [[standing-decisions]] /
[[vision-service]].
- OpenCV core itself is **Apache-2.0** (clean either way).
- AGPL note: if the appliance is ever offered as a network service to third parties, AGPL's
network-use clause could require offering the service's source — relevant only if productised
beyond the on-site appliance; flag at that point.
## Anti-fraud / threat-model fit
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
vs. a [[permit]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record.
- The recognition result and the source image both attach to the signed [[append-only-event-chain]]
entry, so the *evidence* is tamper-evident even though recognition itself is host-side and
fallible.
- Recognition is **advisory, never the sole authority** to open a barrier where money/access is at
stake — confidence thresholds + fallback to ticket/manual; a low-confidence read must not strand a
car ([[fail-state-safety]]).
## Open
- **Recognizer choice** (permissive-only vs. AGPL model) and accuracy targets — see
[[vision-service]]; AGPL now permitted in-service.
- **Vehicle fingerprint**: attribute classifier vs. embedding-similarity; what threshold makes a
mismatch an anomaly without false-positiving on lighting/angle.
- **Compute footprint** on the appliance (CPU-only vs. a small GPU/NPU) — procurement input
([[bom]], [[open-questions]]).
- Per-camera **opt-in** ("optionally bound", user's word): which lanes/cameras route snapshots to
the service.
+119
View File
@@ -0,0 +1,119 @@
---
type: entity
tags: [parking, domain, business, subscriptions, identity]
sources: []
updated: 2026-06-15
status: open
---
# Permit (Subscription)
A **subscription**: a known holder authorized to enter/exit without paying per-stay, for a covered
period. The second of the "two populations" ([[entry-exit-readers]]); a valid permit
**short-circuits the payment step** of a [[parking-session]] ([[session-model]]). Transient is
built first; permits layer on top.
## Credentials (how a permit is presented) — confirmed with operator 2026-06-15
A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity
paths:
- **RF tag / chip / card.** An RFID/proximity credential. Read **host-side** (reader → host →
`pulseOpen`): autonomy isn't required (resolved below), and the [[dingtian-relay]] has no onboard
card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still
fine and keeps a future autonomous path open ([[entry-exit-readers]]), but isn't required.
- **QR code.** Read by the **optical reader** — inherently **host-side** ([[entry-exit-readers]]:
pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the
permit → decides.
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
already in the model) and whose value is the credential id.
## Two optional, independent bindings — confirmed 2026-06-15
A permit has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
neither — the four combinations are all valid.
### 1. Car-count binding (default: 1)
- **Optional.** By default a permit is bound to **1 car at a time**. The admin may raise the limit
(a household, a company fleet) or **unbind it entirely** (no cap on how many cars use it).
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the
[[parking-session]] projection: at entry, count the permit's currently-open sessions; if
`< maxConcurrent` (or unbound) allow, else reject (allowance full). This is exactly why
sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold
over open entry/exit events, **not a counter someone can edit**.
### 2. Plate binding (default: off)
- **Optional.** By default a permit is **not** plate-bound — any car may use it (identity is the
card/QR). The admin may bind it to a set of specific licence plates.
- When **bound**, an allowed plate is an **accepted identity in its own right** — a valid
**card/QR OR a matching plate** opens the lane (either, not a second factor):
```
entry: read card/QR → find permit → car-count ok → open
OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open
```
- **Accepted tradeoff:** card-OR-plate is the most convenient but does **not** prevent
card-sharing (a lent card still opens). Fine for a trusted permit population; the signed
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible
to [[reconciliation]] after the fact.
- **Plate-spoofing defence:** a printed copy of a registered plate on a *different* car is caught
not here but by the [[opencv-anpr-service]]'s **vehicle-attribute verification** — the seen car
must reconcile with the permit's known car, not just the plate string.
> The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept
> any plate. The binding fields are simply absent/null when a constraint isn't applied.
## Data model (first cut — to firm up with [[session-model]])
A `permits` table (and supporting rows). Unlike the event log, reference/master data like permits
**is** mutable (an admin grants/revokes/renews) — but every *use* of a permit still produces a
signed `vehicle_entry`/`vehicle_exit` event in the [[append-only-event-chain]], so the audit trail
stays append-only even though the permit record itself is editable.
| Field | Notes |
| --- | --- |
| `id`, `holderName`/contact | the subscriber |
| `credentials[]` | one or more: `{ kind: 'rf' \| 'qr', value }` |
| `maxConcurrent` | car-count binding; **default 1**, raise for fleets, or `null` = unbound |
| `plates[]` | plate binding; **default empty/false** = any car; when set, these plates are accepted identities |
| `validFrom`, `validTo` | coverage window |
| `status` | active / suspended / revoked |
> Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate,
> identified by its card/QR".
## Interaction with the session model
- **Entry:** credential read → permit lookup → valid (active, in window, plate allowed **if
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
(source = `wiegand`/`qr`/`lpr`), open barrier. No ticket, no fee. (A bare permit applies neither
extra check — just active + in window.)
- **Exit:** credential/plate read → matching open permit session → signed `vehicle_exit`, open. No
payment required.
- **Lapsed mid-stay:** permit expires while a car is parked → the uncovered time falls back to the
transient [[tariff]] (edge case to design).
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
refused, per policy (OPEN).
## Resolved (2026-06-15)
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
unbound) and plate-binding (`plates[]`, **default off** = any car). Either, both, or neither.
- **Plate vs. credential:** when plate-bound, **card/QR OR matching plate** — either is accepted
identity (not a second factor); card-sharing not prevented by design, caught by
[[reconciliation]] after.
- **Autonomy:** **host-in-the-loop for everything** — no onboard card list needed, so the
[[dingtian-relay]] stays sufficient (no new controller). Permit entry **fails closed** if the
host is down ([[fail-state-safety]]). One code path for transient + permit.
## Open questions
1. **Reader hardware** — confirm the RF reader and the QR/optical reader models (procurement;
relates to [[bom]] and [[open-questions]]). RF need not be Wiegand now that autonomy isn't
required, but a Wiegand-out reader keeps options open.
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm
with operator.
+20 -2
View File
@@ -7,7 +7,7 @@ updated: 2026-06-14
# Index # Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 1 source · 15 entities · 12 concepts · 2 decision records. Counts: 1 source · 18 entities · 23 concepts · 5 decision records.
## Overview & navigation ## Overview & navigation
- [[overview]] — the top-level synthesis and entry point. - [[overview]] — the top-level synthesis and entry point.
@@ -71,13 +71,31 @@ Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
- [[entry-exit-readers]] — two populations, two integration paths; both can share a relay. - [[entry-exit-readers]] — two populations, two integration paths; both can share a relay.
- [[uhppote-vs-esp32]] — comparison: detection vs. prevention. - [[uhppote-vs-esp32]] — comparison: detection vs. prevention.
## Concepts — business domain
- [[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.
- [[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.
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[permit]] — subscription; RF/QR or plate identity, registered-cars + max-concurrent, host-in-loop; short-circuits payment.
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness).
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
## Dev environment (reference) ## Dev environment (reference)
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin. - [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after. - [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
## Decisions ## Decisions
- [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers). - [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers).
- [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred. - [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker). - [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state. - [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale. - [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
+131
View File
@@ -355,3 +355,134 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
`localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the `localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the
self-contained Linux answer. self-contained Linux answer.
- Updated [[wsl-dev-networking]]. - Updated [[wsl-dev-networking]].
## [2026-06-15] design | Business layer kickoff — parking session model
- Pivoted from the (hardware-verified) device/integrity layer to the business domain. Wiki-first.
- KEY DECISION: a [[parking-session]] is a PROJECTION over the signed [[append-only-event-chain]],
never a mutable table — a mutable sessions row with paid/owed would reopen the operator-fraud
hole the whole system closes. "Paid" = a signed `payment` event (unforgeable, undeletable).
- Scope (user): mixed site, TRANSIENT-FIRST; [[permit]] holders layered as a 2nd identity source
that short-circuits payment. Payment = PAY-ON-FOOT / pay station (decoupled from exit; exit lane
only validates paid + within walk-back grace). Matches [[autonomous-direction]].
- New signed event types designed (not yet built): `vehicle_entry`, `vehicle_exit`, `payment`,
`void` — extend `input_received`. Lifecycle OPEN→PAID→CLOSED (+VOIDED); overstay top-up is the
one genuinely stateful edge case.
- New pages: [[parking-session]], [[tariff]] (pure/data-driven fee fn; gracePeriodExit is a real
pay-on-foot revenue param), decision [[session-model]]. Updated [[append-only-event-chain]],
[[index]]. Closes the dangling entry-flow thread from [[device-input-flow]].
- [[permit]] drafted + RESOLVED from user input: credentials = RF tag/chip/card + QR (optical
reader). Car limits = two numbers: `registeredCars[]` whitelist + admin-set `maxConcurrent` (in
at once) — enforced as a fold over the permit's open sessions. Identity = card/QR OR matching
plate (either opens; card-sharing not prevented by design, caught by reconciliation). Autonomy =
host-in-the-loop for everything → Dingtian stays sufficient, no new controller; permit entry
fails closed if host down. Remaining open: reader hardware models; lapsed/revoked policy.
- NEXT: schema (`packages/db`: permits/tariffs + session projection) + the
input_received→vehicle_entry flow (closes the [[device-input-flow]] thread).
## [2026-06-15] design | Host-side vision service (ANPR + vehicle verification)
- User: optionally bind camera images to an OpenCV service we build. Resolved scope: ANPR (plate →
`IdentitySource='lpr'`); a **separate local Python/OpenCV microservice** on the appliance (Node →
localhost HTTP), offline; it **replaces the dedicated edge-AI [[lpr-camera]]** (recognition on
ordinary Hikvision/Dahua snapshots — reuses `Snapshot.bytes`).
- LICENSING: best ANPR/vehicle models are AGPL/commercial vs. the MIT/Apache/BSD standing rule.
Decision: **scoped AGPL exception** — allowed INSIDE the vision service only (separate process,
not linked); app stays permissive. Amended [[standing-decisions]].
- USER ANTI-FRAUD INSIGHT: a fraudster can print a registered plate and enter with a different car.
→ service also does **vehicle-attribute / fingerprint verification**, so the *car* reconciles, not
just the plate. This fills the independent-witness gap [[append-only-event-chain]] calls out:
plate-on-different-car = anomaly. Recognition is advisory (confidence + ticket fallback), evidence
(read + image) attaches to the signed event.
- New pages: [[opencv-anpr-service]], decision [[vision-service]]. Updated [[standing-decisions]],
[[lpr-camera]] (host-side supersedes edge-AI), [[permit]] (plate-spoof defence),
[[append-only-event-chain]] (vision as witness), [[index]].
- Open: recognizer/vehicle-model choice + accuracy; fingerprint method + anomaly threshold; appliance
compute (CPU vs GPU/NPU); per-camera opt-in; the still-unbuilt reconciliation logic.
## [2026-06-15] design | Transient pricing — composable, versioned tariff
- User: pricing is unknown + constantly changing → must be **admin-composable at runtime**, currency
selectable, FX later. Reframed [[tariff]] from "config we ship with numbers" to a first-class
editable entity.
- DECISIONS: (1) rate structure = **stepped duration blocks + rolling-24h daily cap** (flat rate is
one block; expresses first-hour/taper/cap with no special cases); (2) overstay top-up =
**reprice the difference** (recompute entry→now − alreadyPaid); (3) tariffs are **effective-dated
immutable versions** — edits publish a new version, sessions reprice against the version in force,
the `payment` event records `tariffVersionId` (reproducible + fixed in the signed chain); (4)
**one active tariff per site**, but modelled with id/scope so multi-tariff needs no migration;
(5) **currency selectable (ISO 4217)**, money = `{minorUnits, currency}`, payment reserves a null
`fxRate` → FX-ready, **FX engine deferred** (needs offline rate source — new [[open-questions]] #8).
- Ships with **no rate card**; owner must compose+publish one (blank = free or gated, operator
policy — open). Numbers in the page are illustrative, not defaults.
- Wrote the pure integer fee algorithm into [[tariff]] (data model: `tariffs` + immutable
`tariff_versions`). Updated [[open-questions]] (#8 FX), [[index]].
- NEXT: schema (`packages/db`) for tariffs/versions + permits + session projection, then the
composer UI + the input_received→vehicle_entry flow.
## [2026-06-15] design | Shifts (manned-only) + Z-report; drop time-based token
- Q: what happens at operator shift end? Resolved scope, deliberately small.
- Shifts exist ONLY in manned mode — a human accountability boundary. The fully-automated/unmanned
system has NO shifts; the pay-station cash-collection cycle + [[reconciliation]] replace it.
- Shift is NOT time-based: relief arrives late / no-shows / one operator forced into a double.
→ **drop the 8h token expiry**; login valid **until explicit logout** (updated [[local-jwt-auth]];
code change pending). Start/End Shift are **explicit, independent of login** — one login spans many
shifts; a double = End then Start again, no re-login.
- End Shift = sum signed `payment` events in the shift by tender → append a signed `shift_z_report`
(type already in packages/shared, chained to prior Z) → **PRINT cash total + POS total (if a POS
is configured)**. That's the whole human-side ask. No blind count / variance gate / manager
override. Fraud control stays in the signed chain + later [[reconciliation]] (catch a skim after
the fact, not at close). Blind-count documented as an explicit optional add-on, not built.
- New page [[shift]]; updated [[local-jwt-auth]], [[index]].
- Open: Z sums by payment-time (the operator who took the money) — confirm; X-report (read-only
mid-shift); per-operator vs per-booth vs per-site (ties to [[open-questions]] #1). `payment` event
needs a `tender` field (cash/card) — fold into the schema step.
## [2026-06-15] design | Scope sweep — capacity, validation, reporting, integrity gaps
- "What else can a PMS do?" — swept the full feature surface against the design; user picked the
in-scope gaps. New pages:
- [[capacity-occupancy]] — occupancy = fold over open sessions; refuse entry + drive a FULL sign
when full; **exit never blocked** ([[fail-state-safety]]); zone-ready; counting-drift = anomaly.
- [[validation-discounts]] — merchant validates a ticket → **signed discount event** applied at
fee time ([[tariff]]); over-validation visible to [[reconciliation]]; payment records gross/disc/net.
- [[reporting-analytics]] — revenue/occupancy/stay/permit/anomaly reports as projections over the
chain; **plate-search** (admin looks up a session by plate IF captured — honest "not captured").
- [[clock-integrity]] — fees depend on the host clock; offline box → backdating attack; monotonic
index catches reorder, clock-regression = `anomaly`, RTC + privileged-only time change.
- [[blocklist]] — barred plates/cards refused at **entry only**; signed + attributed.
- Folded into existing pages: **manual overrides** = signed reason-coded events (legitimate
counterpart to the out-of-band-open anomaly) + **lost-ticket admin-arbitrary amount** →
[[parking-session]] + [[tariff]]; **backup/restore** confirmed in-scope, expanded [[open-questions]]
#5 (restored copy must still verifyChain; doubles as the reconciliation export).
- NOT captured (flagged): **intercom/help-call** — user didn't select it, but it's the only human
fallback for an unmanned lane; revisit. Deferred roadmap: reservations, mobile app, EV, loyalty.
- Updated [[index]].
## [2026-06-15] design | Second sweep — ticket encoding + anti-passback; money corners deferred
- More gap-hunting. New pages:
- [[ticket-encoding]] — the transient session key: opaque/unguessable **ticket id printed as QR**
by [[rongta-printer]], **scanned at pay station + exit** (new ReaderDevice/imager behind the
adapter); plate-as-ticket ticketless alt coexists per lane. The physical backbone of the
transient flow (was only implied).
- [[anti-passback]] — one id can't enter while it already has an OPEN session (card/ticket-passing
over the fence); a fold over the chain, *under* permit `maxConcurrent`. Soft (flag `anomaly`) by
default vs. hard (refuse); honest dependence on reliable exit detection.
- DEFERRED (user): **receipts/VAT invoices** + **refunds/change/overpay** — depend on pay-station
hardware + manned/unmanned payment subsystem; recorded as [[open-questions]] #9, revisit at
procurement (may change what the `payment` event stores → flagged before schema).
- Still open & load-bearing: **lane topology** (#1) — not resolved; scopes sessions/occupancy/shifts.
- Updated [[open-questions]] (#9), [[index]].
## [2026-06-15] decision | Split signed business ledger from device telemetry
- User correction before schema: the `events` table conflated TWO things — the anti-fraud business
ledger AND device telemetry (button pushes as `input_received`). Split them.
- `ledger_events` (rename of `events`): signed, hash-chained, ATECC608-signed business facts only
(vehicle_entry/exit, payment, void, shift_z_report + witness barrier_open_command/observed,
anomaly). Reconciliation + session/tariff/occupancy projections run on this.
- `device_events` (new, [[device-events]]): UNSIGNED hardware telemetry (relay fired, paper-out,
camera offline, reader read, raw input edges); high-volume, may rotate/prune; never reconciled.
- A raw button press is telemetry → device_events; the entry flow then mints a SIGNED vehicle_entry.
So `input_received`-as-signed-event is dropped (was transitional). No prod chain data exists, so
the rename/restructure is safe now (no signatures to invalidate).
- New: decision [[event-streams-split]], concept [[device-events]]; updated [[append-only-event-chain]]
(two streams + as-built-vs-pending), [[index]].
- 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).