Files
parking_solution/wiki/decisions/event-streams-split.md
T
julian bfb6ab0b36 feat(logs): app log store — backend pino DB sink + frontend error collection
Add a third data stream (app_logs), distinct from the signed ledger and device
telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to
ship to, so the host is the log store.

Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay
stdout-only) with no call-site change; the DB is built before Fastify so the logger
has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn),
window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console
warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on
pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere.

POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new
log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by
age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter
level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs.

Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record;
backend warn/error persisted, info dropped; non-admin GET 403 / POST 204.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 12:54:22 +02:00

3.2 KiB

type, tags, sources, updated, status
type tags sources updated status
decision
parking
decisions
integrity
devices
schema
2026-06-15 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.

A third stream followed (2026-06-19)

The same separation logic produced a third stream: app_logs — operational/diagnostic logs (backend warn+ via a pino sink, plus frontend errors). They're neither business facts (ledger) nor hardware telemetry (device_events), so they get their own unsigned, prunable table. See app-logs. The principle generalizes: one stream per durability/meaning class.

Open

  • device_events retention/rotation policy. (Resolved for app_logs: age + row cap — see app-logs; the same policy is a candidate for device_events.)
  • 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.