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).
This commit is contained in:
2026-06-15 18:13:35 +02:00
parent 9a4c7ee27b
commit 8c2cf93067
11 changed files with 958 additions and 349 deletions
+164 -10
View File
@@ -2,10 +2,16 @@ import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Schema notes:
// - `events` is APPEND-ONLY. Never expose UPDATE/DELETE on it. A correction or
// void is a new row of type 'void'. Each row chains to the previous via
// `prevHash` and is signed by the ATECC608 (`signature`). This is the core
// anti-fraud integrity mechanism. See wiki/concepts/append-only-event-chain.md.
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
// • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
// Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
// 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).
// See wiki/entities/local-jwt-auth.md.
@@ -21,7 +27,13 @@ export const users = sqliteTable("users", {
.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(),
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
index: integer("index").notNull().unique(),
@@ -30,18 +42,43 @@ export const events = sqliteTable("events", {
lane: integer("lane").notNull(),
source: text("source"),
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(),
// Hash of the previous event (hex). Null only for the genesis event.
prevHash: text("prev_hash"),
// ATECC608 signature over the canonical event payload (hex).
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
// @parking/devices registry; `config` is that driver's JSON config (host, port,
// credentials…). Lets the system stay device-agnostic and admin-configurable.
// See wiki/concepts/device-registry.md and first-run-setup.md.
// @parking/devices registry; `config` is that driver's JSON config. Keeps the
// system device-agnostic + admin-configurable. See device-registry.md, first-run-setup.md.
export const laneDevices = sqliteTable("lane_devices", {
id: text("id").primaryKey(),
lane: integer("lane").notNull(),
@@ -64,7 +101,124 @@ export const setupState = sqliteTable("setup_state", {
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 EventRow = typeof events.$inferSelect;
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
export type LaneDeviceRow = typeof laneDevices.$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;