Files
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

65 lines
3.2 KiB
Markdown

---
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.
## 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.