Files
parking_solution/wiki/log.md
T
julian 2aa1045ddc
Build & push images / images (push) Successful in 2m51s
fix(modules): Car Wash depends on parking only — the discount engine is core, not the validation module
A site entitled to parking,carwash had the wash silently dropped as dependency-broken.
The validation program routes (compose/read) leave the validation module gate; the
merchant scan routes (mine/lookup/apply/void) stay behind it.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 10:40:01 +02:00

3052 lines
294 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Wiki Log
Append-only chronological record. Each entry: `## [YYYY-MM-DD] <op> | <subject>`.
Query with `grep "^## \[" log.md | tail -5`.
## [2026-06-14] ingest | Parking System — Architecture & Design Notes
First source ingested. Bootstrapped wiki scaffolding (CLAUDE.md schema, index.md,
overview.md, log.md). Created source summary, 14 entity pages, 9 concept pages, and
decision records (settled decisions + 6 open questions). Source is a dense design
doc covering stack, threat model, device architecture, UHPPOTE access control, the
custom ESP32 controller alternative, readers, and a reference BOM.
## [2026-06-15] decision | JWT key choice + ESP32 deferred
From app work, not a new source. Added [[open-questions]] #7 (symmetric vs.
asymmetric JWT signing key — raised by the commit security review; prefer RS256/EdDSA
so verifying hosts hold only a public key, mirroring the ATECC608 / challenge-response
property). Marked [[esp32-custom-controller]] `status: deferred` per decision not to
implement device-level auth for now (access control stays on UHPPOTE + network
isolation); noted in [[open-questions]] #6. Updated [[local-jwt-auth]] (hardened secret
handling + 8h expiry, asymmetric-key pointer) and the index.
## [2026-06-15] decision | Device-agnostic registry + first-run setup
From app work. Made the [[device-adapter-pattern]] selectable: added a
[[device-registry]] (catalog of drivers per category) and a [[first-run-setup]]
flow so the admin picks a device per lane at install. Categories: access
(ZKTeco / ESP32 relay), reader (Wiegand / TCP-IP), camera (Hikvision / Dahua,
snapshot-on-event), printer. Added a `CameraDevice` interface; new `lane_devices`
+ `setup_state` tables (migration 0001); admin-only setup endpoints. Stub drivers
for now (no real vendor protocols yet). Verified catalog + assign + validation +
auth end to end.
## [2026-06-15] decision | UHPPOTE library chosen + real driver
Researched Node options for the UHPPOTE controller. Chose the official
**`uhppoted`** npm package (MIT, actively maintained, full API incl. openDoor,
get-event(s)/event-index, set-listener, restore-default — covers the whole
[[event-log-ingestion]] design). Rejected: raw-dgram DIY (reinvents the lib),
node-red-contrib-uhppoted (wrong model), Go REST sidecar (extra runtime). Added
it to @parking/devices and implemented a real `uhppote` [[uhppote-controller]]
access driver (pulseOpen→openDoor, healthCheck→getStatus), registered in the
catalog. CJS interop: default-import + destructure. Verified it builds, appears
in the catalog, and degrades to "offline" gracefully without hardware. Real
on-VLAN test still pending.
## [2026-06-15] feature | Device discovery (UHPPOTE scan in setup)
The frontend had no way to find a UHPPOTE — but the controllers self-announce via
UDP broadcast. Added a generic [[device-discovery]] capability: optional
`DiscoverableDriver.discover()` on the registry, implemented by the `uhppote`
driver via `getDevices`. New admin-only `GET /api/setup/discover/:driverId`
(health-checks each found device); catalog now returns a `discoverable` list.
SetupWizard gains a "Scan for controllers" button that lists found devices with
health badges and auto-fills serial + host on selection. Verified: catalog flags
uhppote; discover runs and fails gracefully without hardware (broadcast EACCES);
non-discoverable driver → 400; no token → 401. Modeled generically so cameras
(ONVIF) can add discovery later.
## [2026-06-15] test+blocker | UHPPOTE hardware bring-up + entry-flow blocker
Brought up the real UHPPOTE (serial 225088491, fw 09120) end to end. Fixed the
networking path: WSL2 mirrored mode, then driver bugs — subnet-directed broadcast
(the lib doesn't enable SO_BROADCAST for global 255.255.255.255), broadcast must
match the target's subnet for unicast reply routing (health-check timeout fix),
multi-subnet discovery, and serialized I/O (concurrent calls collided on :60001).
Added .env loading (Node --env-file), env-gated+fail-closed SETUP_AUTH_BYPASS, and
an authBypass flag so the wizard drops the token field. Test scripts in
apps/server/scripts/ (uhppote-listen, uhppote-relay).
VERIFIED on hardware: discovery; host-commanded openDoor doors 1&2 (physical +
reason="remote open door"); button presses live (reason="push button ok").
BLOCKER FOUND: the controller push-button input auto-opens the relay in firmware —
no command to report-without-opening — so ticket-first entry (button→print→open)
is impossible as wired. UHPPOTE can't do it on that input; ZKTeco *might* via a
programmable aux input + PULL SDK but that's unverified and needs a new driver.
Recorded in [[access-controller-button-flow]] + [[zkteco-controller]]. Entry-lane
hardware decision paused to focus on the business side.
## [2026-06-15] feature | Cookie-based auth/authz (login, CSRF)
Built real authentication: bcrypt login → JWT in an HttpOnly+SameSite=Strict
cookie, readable CSRF cookie + X-CSRF-Token header (double-submit) on mutations,
role-guarded routes. Routes: /api/auth/{login,logout,me}. First admin seeded via
`pnpm --filter @parking/server seed-admin`. Removed the SETUP_AUTH_BYPASS shim
and the wizard token field; the SPA gates on /api/auth/me and only shows setup to
admins. Same-origin via the Vite dev proxy and a new prod nginx config
(deploy/nginx.conf). Verified end to end (curl + browser): wrong pass→401,
login→cookies set, me→admin, assign without CSRF→403 / with→201, no cookie→401,
session persists across reload. Updated [[local-jwt-auth]].
## [2026-06-15] lint+docs | Dev-environment pages (WSL networking, workflow)
Captured hard-won dev knowledge that was only in commit messages: new
[[wsl-dev-networking]] (WSL2 NAT blocks UDP broadcast → mirrored mode + the
multi-interface / subnet-broadcast / IPv6-localhost gotchas that remained) and
[[local-dev-workflow]] (setup, seed:admin, the dev-server-hang from the broken
strip-types script → tsx, the 127.0.0.1 proxy fix, .env loading). Corrected the
earlier "broadcast permission (EACCES)" note in [[device-discovery]] — the real
cause was the lib not enabling SO_BROADCAST for the global 255.255.255.255;
documented the three verified broadcast gotchas + serialization. Added a `reference`
page type to the schema; new "Dev environment" index section.
## [2026-06-15] decision | Dingtian relay chosen; HTTP over MQTT; unmanned direction
New relay+input controller on hand (Dingtian 4ch). Its inputs are decoupled from
relays (configurable via input_link_relay) — solves the [[access-controller-button-flow]]
blocker the UHPPOTE couldn't. Transport decision [[dingtian-vs-mqtt]]: direct
HTTP/UDP now (UDP string for relay control on :60001; device input_link_url HTTP
push for button events), MQTT skipped (broker = infra + failure mode + overkill at
this scale) but kept for later multi-lane scale. Recorded the stated roadmap to
**fully unmanned, no-booth** operation in [[autonomous-direction]] and its threat-model
shift (operator-fraud → unattended-machine threats). New stub [[dingtian-relay]]
with the full protocol from the SDK. Driver + on-hardware test still to build.
## [2026-06-15] driver+test | Dingtian driver built; button blocker RESOLVED
Built the `dingtian` access driver (AccessControlDevice relay control + InputDevice
poll-based button events + new PreconditionDevice capability). Verified end to end on
real hardware (DT-R004 @ 10.0.10.172, HTTP config on :8080, UDP control :60001):
status read, relay pulse, input press/release. Disabled `input_link_relay` via the
driver's fixPreconditions (GET config → flag 0 + clear maps → POST config_set), then
confirmed: pressing inputs now fires NO relay (0000 status) — host-in-the-loop entry
works. The [[access-controller-button-flow]] blocker is RESOLVED. Gotcha recorded in
[[dingtian-relay]]: config_set requires injecting "command":"setconfig" after "status"
(GET omits it) or the write silently no-ops. Added httpPort config field (port 8080 ≠
default 80). Test script apps/server/scripts/dingtian-test.mjs. Next: input HTTP-push
endpoint + wiring input→ticket→pulseOpen.
## [2026-06-15] cleanup | Remove UHPPOTE/ZKTeco code; wiki → rejected/historical
Neither UHPPOTE nor ZKTeco is used (Dingtian chosen). Removed their code:
deleted access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32 stubs), the
three uhppote-*.mjs scripts; dropped the `uhppoted` npm dep from both packages;
unregistered uhppote/zkteco/esp32-relay from the driver registry; updated example
comments. Catalog access drivers now = dingtian only. Build green.
Wiki: kept the pages but marked [[uhppote-controller]] + [[zkteco-controller]]
rejected/historical, [[uhppote-vs-esp32]] historical; re-pointed all "current
device" framing (standing-decisions, bom, overview, open-questions) to
[[dingtian-relay]]; noted no current driver uses [[device-discovery]]. Transferable
concepts (network-isolation, event-log-ingestion, barrier-not-a-door, threat-model)
kept as-is. Links lint clean; raw source untouched (immutable).
## [2026-06-15] feature | Dingtian input HTTP-push → backend (no polling)
Wired the device's "Input Link URL" feature so it HTTP-pushes button events to
our backend — no polling. Driver `configureInputPush()` writes input_link_url
(per-input server/port/path, en=1, active-LOW, plain HTTP) via the config API
(reusing the #writeConfig + command:setconfig helper). New backend route
`routes/devices.ts`: public `GET/POST /api/devices/dingtian/:deviceId/input/:n/{on,off}`
→ emits onto an internal device-events bus (device-events.ts, EventEmitter) for
the entry flow to consume. VERIFIED on hardware: configured device, real presses
on all 4 inputs pushed to the backend (input N on+off, source = device IP). Trust
model recorded in [[device-input-flow]]: flat network / no VLAN → backend is source
of truth, every open is a signed event (out-of-band open = anomaly); push endpoint
not behind cookie auth (machine call), shared-secret available as defence-in-depth.
Next: wire signed event + ticket print + pulseOpen.
## [2026-06-15] feature | Dingtian push auth via HTTP Digest (hardware-tested)
Secured the device→backend input push. Empirically tested auth options on the
device: HTTPS-to-self-signed FAILS, Basic works, **Digest works** → chose Digest
(MD5, qop=auth): password never on the wire, single-use nonces. Backend
digest-auth.ts (challenge/verify) + source-IP allowlist on the push route;
per-device pushUser/pushPassword generated on assign, written to the device and
stored in lane_devices (admin never types a URL/secret). Driver
configureInputPush now sets auth=2 + creds; the assign flow auto-configures the
device and persists the creds (net.ts derives the backend IP on the device's
subnet). Removed the earlier URL-token approach (token in URL is sniffable/logged).
TWO HARD-WON DEVICE BUGS fixed: (1) config_set requires an explicit Content-Length
— the device silently ignores chunked bodies (Node's default), which masqueraded
as "writes don't apply" all session; (2) the `pass` field caps at 31 chars →
use a 24-char password. Driver #writeConfig now polls-until-verified (device
reboots on apply). VERIFIED on hardware: assign auto-configures the device, then
all 4 inputs push with Digest auth, zero failures. Recorded in [[device-input-flow]].
## [2026-06-15] feature | Setup wizard: Test connection + Save & configure
Two-step device setup UX. New admin-only POST /api/setup/test (healthCheck +
checkPreconditions, no save / no device change). The assign (Save) step now also
fixes preconditions (disables input_link_relay) before configuring push — closing
a gap where assigned devices could still auto-fire relays; fails the save with no
DB row if device config fails (no orphan rows). SetupWizard wires the config
fields → Test button (health badge + precondition warnings) → Save & configure
button. Verified in-browser against the real device: Test shows ● ready +
preconditions OK; Save persists the row AND writes the device's Input Link URL
(push path matches the saved device id). Admin never logs into the device web UI.
Updated [[first-run-setup]].
## [2026-06-15] feature | Device hardening: binary relay + relay_pw + disable channels
Hardened the Dingtian relay control for the flat (no-VLAN) network. Switched
pulseOpen from the unauthenticated string protocol (:60001) to the **binary
protocol (:60000) with a relay password** — the only authenticated relay option
(frame verified on hardware: FF AA <sess> 03 <pwLE> <relayByte> <jogLE>). New
HardenableDevice capability: harden() sets a random relay_pw + disables unused
channels (rs485/can/tcp×2/mqtt → p:255, keep UDP binary+string). Folded into the
assign/Save flow (preconditions → harden → push); relayPassword stored in
lane_devices. Verified end to end: assign configures + hardens the device, config
API stays reachable, pulseOpen with the stored password fires the relay, without
it is rejected.
⚠️ LESSON: enabling the device's HTTP CGI session check (session_en) on this
firmware breaks the config-READ API (ECONNRESET) — locked us out, needed a FACTORY
RESET to recover. harden() deliberately does NOT touch session_en. The open CGI
API is accepted as flat-network reality; the signed log is the real guarantee.
Recorded in [[device-input-flow]] + [[dingtian-relay]].
## [2026-06-14] query | Dingtian web-login rotation + CGI API is unauthenticated
While addressing "change the device's default admin/admin", traced the device web
UI JS (system.js) → the change-login endpoint is
`GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&` (response `&0&/&` = success,
`&2&/&` = wrong old pw). Added a best-effort `setWebLogin`/`#rotateWebLogin` step
to `harden()` (new pw stored back as config `webPassword`, stripped from API
responses). KEY FINDING: the device CGI API needs NO authentication — config dump,
config write, relay fire, and userset.cgi itself all return 200 unauthenticated
(verified on 10.0.10.5). admin/admin gates only the browser UI; there is no
inbound-auth setting (only session_en, which bricks the read API). So rotating the
login is COSMETIC, not a boundary — the signed event log remains the real
guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
## [2026-06-14] ingest | Rongta 80mm printer driver + printer roles/failover
- Added `rongta` PrinterDevice driver (ESC/POS over raw TCP 9100); registered in registry.
- Decision: ≥2 printers per lane by role (entry-dispenser outside, booth-receipt inside);
entry ticket fails over outside→booth (asymmetric — receipts never print outside).
- Selection logic lives in packages/devices/printer-routing.ts (orderForRole, printWithFailover).
- One unit verified reachable at 10.0.10.6:9100 from host (TCP connect OK).
- New pages: [[rongta-printer]], [[printer-roles-failover]]. Updated [[bom]], [[index]].
- Open: all-printers-down policy belongs to the (not-yet-built) entry flow, not the printer layer.
## [2026-06-14] ingest | Live printer status monitoring
- Added MonitorableDevice.readStatus()/PrinterStatus capability in packages/devices.
- Rongta readStatus() scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper End/Near End/
Off-Line) — chosen over hand-decoding DLE EOT because this clone's DLE EOT bytes don't match
the canonical ESC/POS bit layout (verified on hardware; risk of false-healthy).
- Server PrinterMonitor: polls enabled monitorable printers (PRINTER_POLL_MS, default 5s),
caches latest, emits "printer-status" on change. API: GET /api/printers/status + SSE stream.
- Verified live: 10.0.10.6 -> ready (all flags clear); unreachable host -> offline (no throw);
bus emits on change, suppresses unchanged. Full repo typechecks (8/8).
- New page: [[printer-status-monitoring]]. Updated [[rongta-printer]], [[index]].
- Open: capture the page's actual text for an ACTIVE fault (pull paper / open cover) to confirm
the Yes flip; wire degraded/offline into failover + entry-flow all-down policy.
## [2026-06-15] ingest | Multi-instance device setup (add/remove per category)
- Confirmed the data model was already multi-instance (lane_devices = one row per instance,
assign always inserts); the limitation was UI-only (one slot per category).
- Backend: added DELETE /api/setup/assign/:id (unassign); /state now redacts secrets
(pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign.
- Web: SetupWizard reworked — each category lists assigned instances (with Remove) + "Add
another" form; select-type config fields now render as dropdowns (fixes printer role input).
- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak,
delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
- Updated [[first-run-setup]].
## [2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist)
- Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps
no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly.
- Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash
chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events;
integrity: GET /api/events/verify (admin).
- Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY)
shipped now since chip wiring is open-question #6. Caveat documented: software signer is
tamper-evident but NOT unforgeable-by-owner.
- Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO).
- Added ParkingEventType 'input_received'.
- Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok;
direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent
appends -> indices 1..5 intact. Full repo typechecks.
- Updated [[append-only-event-chain]], [[dingtian-relay]].
## [2026-06-15] ingest | Event log + Dingtian string-protocol security fix
- Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner
HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received.
Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok.
- SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays
("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated
binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses,
but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack
is dead and binary control/status still work.
- GAP (user-identified): event log captures host-originated actions only; out-of-band relay
actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware.
Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built.
- Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area);
pushPassword/relayPassword stay machine-only.
- harden() warnings surfaced via the assign response.
- localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts).
- INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults).
Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs).
- Updated [[append-only-event-chain]], [[dingtian-relay]].
## [2026-06-15] fix | Dingtian web-password: desired-vs-current split + verify + UI warnings
- BUG (found in real assign): admin typed a web password; harden used it as the OLD cred, rotation
failed silently, DB saved the typed value but device login stayed admin/admin. Also UDP2 warning
never reached the admin (frontend discarded the assign response).
- FIX: split config into webPassword (desired; blank→random) and webPasswordCurrent (existing old
cred, default admin). harden() rotates current→desired, VERIFIES by re-auth with the new pw, and
only returns secrets.webPassword on success (else warning, no save). assign strips typed
webPassword/webPasswordCurrent and persists only verified secrets.
- SetupWizard now shows assign-response warnings (amber banner, per category) — closes the
feedback loop for the UDP2-can't-disable case.
- Verified on hardware (192.168.1.100): harden set login to a chosen pw; device then rejects
admin/admin (&2&) and accepts the chosen pw (&0&). UDP2 warning surfaced as designed.
- Updated [[dingtian-relay]].
## [2026-06-15] update | input_received lane resolution + source semantics
- Wired device→lane resolution: `LaneMap` (`apps/server/src/lane-map.ts`) caches
`lane_devices.id → lane`, refreshed by setup routes on assign/unassign. `input_received`
events now carry the firing device's lane instead of a hardcoded `lane: 0`. Unmapped device →
`lane: -1` + warn (0 is a real lane; never mis-stamp).
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
device field); device provenance is in `identity`.
- Updated [[append-only-event-chain]].
## [2026-06-15] test+lesson | Hikvision camera verified; multi-subnet source-address trap
- Pulled a real snapshot from a Hikvision camera on the bench: `GET
http://10.0.10.121/ISAPI/Streaming/channels/101/picture`, Digest auth, admin/admin123 → HTTP 200,
2688×1520 JPEG. Path + auth + creds confirmed. ISAPI is the right surface; the device's
"Enable Hikvision-CGI" toggle is a *different* legacy CGI API and is NOT needed.
- Caveat recorded: the camera driver is still a STUB — the wizard's "● ready — stub / ●
preconditions OK" contacts nothing; cameras have no preconditions (only [[dingtian-relay]]
implements checkPreconditions). Noted the cosmetic "Backend push IP" bug (camera pulls, doesn't
push; field should gate on a `pushesToBackend` capability).
- LESSON (cost an hour of "why can't we ping the subnet"): with two device subnets stacked on one
NIC (`192.168.1.123` + `10.0.10.203` on eth1), Linux picked the WRONG source address for
`10.0.10.x` → ARP shows REACHABLE but all ping/TCP times out. Fix: pin `src` on the connected
route (`ip route change <subnet>/24 dev <nic> proto kernel scope link src <host-ip>`), or force
source per-call (`ping -I` / `curl --interface`). Devices arrive on assorted static `/24`s; the
host carries one IP per subnet — this trap is the recurring cost of that.
- Decision context: production is a dedicated hardened **Linux appliance** (this WSL2 box is a dev
stand-in). Multi-subnet config + `src` pinning is an appliance deployment concern (made
persistent via networkd/netplan), riding on [[network-isolation]]; long-term answer is to re-IP
devices onto one planned parking subnet at install.
- Updated [[lpr-camera]] (snapshot driver + verified-on-hardware section), [[wsl-dev-networking]]
(multi-subnet source-address trap + appliance pattern).
## [2026-06-15] driver+fix | Real Hikvision/Dahua camera driver; push-IP field gated
- Replaced the camera STUB with a real `HttpCamera` (`packages/devices/src/drivers/camera.ts`):
Hikvision ISAPI (`/ISAPI/Streaming/channels/<ch>01/picture`) + Dahua CGI (0-based channel), both
over client-side HTTP Digest (new `drivers/http-digest.ts`, two-shot 401→challenge→response,
qop=auth MD5 — the client counterpart to the server's digest-auth.ts). `healthCheck()` now
actually pulls a frame instead of returning `ready/stub`. Added `localAddress` + `timeoutMs` +
`channel` config; threads the device-facing NIC for the multi-subnet trap.
- Snapshot interface: `Snapshot` now carries `bytes: Buffer` (driver fetches); `imageRef` is
optional and set by the CALLER once stored — keeps the adapter free of storage deps. Nothing
consumed captureSnapshot yet, so no migration needed.
- Cosmetic bug fixed: "Backend push IP" showed for any reachable host. Added a `pushesToBackend`
flag to `DeviceDriver` (only [[dingtian-relay]] sets it), exposed as `pushCapable` in the catalog
(mirrors `discoverable`), and gated both the wizard's backend-IP fetch and the field on it.
Cameras/printers/readers no longer show it.
- VERIFIED on hardware: built clean (5/5 packages); ran the real driver against the Hikvision at
10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic).
- Updated [[lpr-camera]].
## [2026-06-15] fix | Permanent WSL2 source-address fix (systemd hook)
- The multi-subnet source-address trap kept recurring (every `wsl --shutdown` wipes the runtime
`ip route` pin — mirrored mode re-clones the Windows NIC's addresses fresh each boot, and NOTHING
inside Linux owns them: networkd/NM/netplan all inactive). Made it permanent on the dev box.
- `deploy/wsl-fix-route-source.sh`: walks each `proto kernel scope link` route on the NIC and pins
`src` to the host's own address in that same subnet — no hardcoded IPs (covers future device
subnets), idempotent, preserves route metric, non-fatal per route. `deploy/parking-net.service`:
oneshot, enabled, reapplies on every boot.
- BUGS hit + fixed while building it: (1) `ip route change` errors `RTNETLINK: No such file` when
the route isn't up yet at boot → use `replace`; (2) `set -e` made one failed `ip` abort the whole
unit → dropped it, per-route warnings instead; (3) `network.target` fires before mirrored-mode
addresses land → script waits up to 15s for a route.
- VERIFIED: service enabled+active, journal shows `pinned 10.0.10.0/24 -> src 10.0.10.203`, camera
pings with NO -I flag (0% loss), and the real Hikvision driver pulls a snapshot with NO
`localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the
self-contained Linux answer.
- 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).
## [2026-06-15] design+build | Entry flow (start) + valet/over-capacity captured
- Building the entry flow: device input → signed `vehicle_entry` → print ticket → pulseOpen.
- DECISION (print failure): **hold** — if all printers are down, sign an `anomaly` (entry attempt,
ticket unprinted) and do NOT open (no unticketed transient — couldn't pay on exit; operator
handles the held car). The `vehicle_entry` is appended ONLY on the success path, right before
pulseOpen — preserving "signed before open" and never logging an entry for a car that didn't get in.
- DECISION (capacity): wire transient entry now; the FULL gate comes later (needs capacity config +
occupancy fold).
- VALET / OVER-CAPACITY (user): "full" is a **soft, operator-configurable** policy — operator may
valet-accept over capacity (customer hands over keys + leaves, operator stacks the car). Manned-only,
new custody/session shape. Captured as [[valet-overcapacity]] + made [[capacity-occupancy]] FULL a
soft policy; NOT built into the entry flow (clean seam left). Deferred.
- New page [[valet-overcapacity]]; updated [[capacity-occupancy]], [[index]].
## [2026-06-15] build | Exit flow (pay-on-foot validation)
- Built `apps/server/src/exit-flow.ts`. Added a `read` channel to the device bus (DeviceReadEvent:
ticket/plate/qr/card) — readers/LPR emit reads; entry stays button-driven, so reads are
unambiguously exit/identity events for now.
- Flow: read → fold the SIGNED ledger for that identity → validate open + PAID + within
`gracePeriodExitMin` → signed `vehicle_exit` → pulseOpen → close the session cache. Unpaid /
grace-expired / unknown → signed `anomaly`, barrier stays closed (a deliberate business reject,
NOT a fail-state; "exit fails open" is about host/power loss). Validation reads the ledger
(authoritative), not the cache.
- Pay station doesn't exist yet → no `payment` events → every transient exit currently REJECTS.
Correct end-state, not passable until pay-station lands (decided).
- VERIFIED against stubs: unpaid→anomaly+no-open; paid+grace→vehicle_exit+open+closed; grace-expired
→anomaly; unknown ticket→anomaly; verifyChain ok across entry→pay→exit.
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit);
fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to
[[open-questions]] #1). Updated [[parking-session]] as-built + gap, [[index]].
## [2026-06-15] build | Pay station + fee calc; JWT 8h → until-logout
- JWT: dropped the 8h `expiresIn` (server.ts global + login). Token now valid **until explicit
logout**; cookie maxAge = 30 days so a browser restart doesn't log out an active operator
(auth.ts `COOKIE_MAX_AGE_SECONDS`). Closes the pending change from the shift decision; updated
[[local-jwt-auth]].
- `computeFee(enteredAt, asOf, structure)` in `packages/shared` — pure integer fee calc.
TWO BUGS caught by tests: (1) grace must use RAW duration, not the rounded-up minutes (a 10-min
stay was being charged a full hour); (2) the block ladder must RESET each rolling-24h day (decision:
day 2 restarts at first-block pricing → 25h = 1200 cap + 200). Both fixed; 9 cases pass.
- Pay station (`apps/server/src/pay-station.ts` + routes `GET /api/pay/quote`, `POST /api/pay`):
open session → active tariff version → computeFee → signed `payment` event (amount/currency/tender/
tariffVersionId/graceExitMin); `overrideMinor` for lost-ticket/dispute. Cashier/operator/admin guard.
- VERIFIED: full loop entry→quote(300 for 90min)→pay→exit opens+closes, verifyChain ok. (A
raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.)
- Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full
loop passes).
## [2026-06-15] build | Tariff composer (makes the pay station operable)
- `validateTariffStructure` in `packages/shared` — non-negative ints, ascending block bounds, only
the last block open-ended; a malformed card can't be published.
- Routes (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active + history, any role) and
`POST /api/tariff/versions` (publish immutable version, ADMIN only). Single site `tariffs` row
created lazily. Editing = publish a new version (effective-dated, immutable).
- UI (`apps/web/src/TariffComposer.tsx`, admin shell next to SetupWizard): currency, grace windows,
increment, daily cap, lost-ticket, add/remove rate blocks; major-unit input → minor on submit;
shows active + history.
- VERIFIED via Fastify inject: GET empty→active null; invalid (out-of-order blocks)→400 w/ problem;
valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404
(session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5.
- Updated [[tariff]] (composer as-built).
## [2026-06-15] build | Permit entry/exit branch + read dispatcher
- `apps/server/src/permit-flow.ts` + `read-dispatch.ts`. A credential read now routes by WHAT the
credential is: matches a permit (card/QR credential, or a bound plate) → permit flow; else →
transient exit flow. Lane resolved once (`readerLaneWithAccess`, shared in lane-map.ts). Refactored
ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution.
- Permit DIRECTION inferred from session state for that car (the read value is the per-car session
key): no open session → ENTRY (enforce maxConcurrent, sign vehicle_entry, open); open → EXIT (sign
vehicle_exit, open, close). Fleet permit = one session per car; anti-passback falls out.
- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has
no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed.
No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open.
- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected,
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).
## [2026-06-15] build | Permit admin CRUD (route + UI)
- `apps/server/src/routes/permits.ts`: a permit is an aggregate (row + credentials + bound plates);
create/update replace the child sets as one unit. GET (any role, for lookup), POST/PUT/DELETE +
POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null; must have ≥1
credential OR ≥1 plate. Revoke = soft (keeps history); DELETE = hard (past ledger events untouched).
- `apps/web/src/PermitManager.tsx` in the admin shell: list + add/edit (holder, car-bound toggle →
maxConcurrent or unbound, validity window, credentials add/remove, plates as a list), revoke, delete.
- Makes permits usable without hand-seeding (companion to the tariff composer).
- VERIFIED via inject: empty + maxConcurrent=0 → 400 w/ messages; valid → 201; operator LIST 200 but
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
404, children cleaned. Full build 5/5.
- Updated [[permit]] (CRUD as-built).
## [2026-06-16] build | Shifts: open/close + signed Z-report (manned mode)
- Shift = two signed ledger events, NO mutable table: new `shift_open` event type + existing
`shift_z_report`. Operator = logged-in user (in event `identity`); open iff their latest shift
event is a `shift_open`. `apps/server/src/shift-service.ts`.
- Close sums `payment` events in the window by tender (cash/card, by payment time) → signed
`shift_z_report` (totals/counts/window) → prints via the NEW generic
`PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt printer.
Print is best-effort — failure doesn't undo the signed close (`printed:false` returned).
- Routes (`routes/shift.ts`, cashier/operator/admin): GET /api/shift/current, POST open (409 if
open), POST close (409 if none). UI `ShiftControl` in the shell (non-readonly): Start/End + Z totals.
- Added `printReport` to the PrinterDevice interface + Rongta driver (reusable for receipts later).
- VERIFIED: open→double-open 409→payments (cash+card; one dated outside the window excluded)→close
totals (cash 500/card 250/3)→close-again 409→re-open ok; readonly 403; verifyChain ok. Full build 5/5.
- Updated [[shift]] (as-built).
## [2026-06-16] build | Capacity / FULL gate (occupancy fold + transient refuse)
- Occupancy = fold over the ledger (entries−exits per identity; `apps/server/src/occupancy.ts`),
`getOccupancy` → {count, capacity, free, full}. Capacity = single-row `site_config` table (admin,
null=uncapped); migration 0001 (additive, no prompt).
- FULL gate in the TRANSIENT entry flow: occupancy.full → refuse (no ticket/entry/open) + signed
anomaly. Permit entry NOT gated (subscribers admitted past transient-full; their maxConcurrent
still applies) — occupancy can read over-capacity by design.
- Routes (`routes/site.ts`): GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-neg int or null). UI `SiteSettings`: live occupancy + FULL badge
(all), capacity editor (admin).
- VERIFIED: fill to cap=2 → 3rd transient refused (anomaly, no open); permit still admitted (occ 3/2,
free −1); exit frees a slot; routes RBAC (op can't set, −5→400, set/clear ok); verifyChain ok.
Full build 5/5. Physical FULL-sign relay output deferred.
- Updated [[capacity-occupancy]] (as-built).
## [2026-06-16] ingest | GEE-QR-ER80 QR access reader datasheet
- User has the reader; ingested `raw/GEE-QR-ER80 QR Code Access Control Reader.pdf`.
- CORRECTION: earlier guessed "ER80-EM" = a 125 kHz EM4100 prox-card reader. WRONG — the datasheet
shows **GEE-QR-ER80**, a **QR / DataMatrix / 1D barcode** optical access reader (optional ID/IC
card). It's the [[ticket-encoding|QR ticket]] scanner the design already needed, not a card reader.
- Specs: interfaces Wiegand 26/34 · RS-232 · RS-485 · USB · TCP/IP; 4–15 VDC <800 mA; 360°;
Windows + **Linux**; wiring VCC/GND/D0/D1/TX(R+)/RX(R-)/LED/BEEP. On hand: **`-Q-W`** (QR scanner;
Wiegand/RS-232/RS-485).
- Fit: host-side reader → a serial `ReaderDevice` adapter emitting `read` events → consumed by the
already-built exit flow + QR-permit path. Prefer RS-232/485 (serial) over Wiegand (Wiegand can't
carry variable-length QR; autonomy moot since [[dingtian-relay]] has no onboard ACL).
- New: source [[gee-qr-er80]] summary + entity [[gee-qr-er80]]. Updated [[ticket-encoding]],
[[entry-exit-readers]], [[index]].
- OPEN (blocks the adapter): the RS-232/485 **frame + baud** — is a QR scan an ASCII CR/LF string
(expected) or framed? Datasheet omits it; resolve via vendor docs or by observing the port.
## [2026-06-16] ingest+test | ER80 protocol = HTTP GET poll + JSON verdict (SDK)
- Hardware bring-up: configured the reader via the vendor Windows tool (server IP/port + "server
language"). Moved it to 10.0.10.7. It pings (source-pin must be 10.0.10.203 — trap recurs). No
beep on scans — initially looked like "not scanning."
- Found the QRCode SDK v1.6.5 (`QRCode_sdk - QRCode_v1_6_5/sdk/`). Protocol SETTLED, supersedes the
serial guess in [[gee-qr-er80]]: reader does **HTTP GET** `/qa/mcardsea.php?cardid&mjihao&cjihao&
status&time` on each scan; server replies **JSON** `{data:[{...,status,output}],code:0}`. Reply
`status` 1=valid(beep 2×)/0=invalid(beep 1×); `output` 0=Access/1=WG26/2=WG34; `time` syncs clock.
`status` low digit in the GET = direction (1=in/0=out).
- KEY: feedback/beep is decided by the SERVER REPLY, not locally → the "no beep" was my catch-all
replying plain "OK" not the JSON verdict, NOT a scan failure. Host-in-the-loop + SYNCHRONOUS.
- "Server language" (JSP/PHP/C#/ASP/CGI) only selects the URL PATH; transport is plain HTTP.
- New source [[qrcode-sdk]]; updated [[gee-qr-er80]] (protocol resolved, serial open-Qs dropped),
[[index]]. SDK kept in place (bulky+binaries), not copied to raw/.
- NEXT: backend route — parse GET, DECIDE (reuse permit/exit lookup), reply JSON verdict, emit on
read bus. Refactor read flows to RETURN an outcome so the reply can reflect accept/reject.
## [2026-06-16] build+fix | QR reader endpoint + ReadOutcome refactor; dev-DB migrate fix
- DB FIX: dev server crashed `no such table: lane_devices`. Cause: server `.env` DATABASE_URL points
at `apps/server/parking.sqlite` (the old dev DB I'd moved aside during the ledger split; new
migrations added since). Applied `drizzle-kit migrate` to that path → all 14 tables present. Fresh
DB → needs `seed-admin` + device re-assignment (empty, expected).
- REFACTOR: read flows now RETURN a `ReadOutcome {accepted,direction,reason}` (device-events.ts).
`ReadDispatcher.dispatch`, `ExitFlow.handleAt`, `PermitFlow.run` updated. A synchronous reader can
answer the device; fire-and-forget readers ignore it.
- ENDPOINT: `routes/qr-reader.ts` — `GET/POST /qa/mcardsea.php` (public; reader has no auth, on the
device subnet). Parses the SDK GET, dispatches the scan, replies the SDK verdict (status 1/0 →
beep 2×/1×, output 0, time-sync). Reader's lane keyed off device serial (cjihao) as lane_devices.id
for now.
- VERIFIED via inject: valid permit QR→status:1+open; re-scan→permit exit; unknown→status:0; reader
on barrier-less lane→status:0. Full build 5/5.
- Updated [[gee-qr-er80]] (endpoint as-built + hardware open items).
## [2026-06-16] test+fix | QR reader VERIFIED on hardware; path is .jsp not .php
- Ran a verbatim-vendor logger on :3000 (replies like mcardsea.php: status:0/output:2). Reader
**beeped** → it scans, sends, and acts on the reply. Earlier "no beep" = nothing was answering :3000.
- Real GET captured: `/qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=...`
from 10.0.10.7 (OEM = Fondvision, per referer).
- KEY FIX: the "server language" setting selects the URL EXTENSION — this unit is JSP → posts
**`.jsp`**, but our route was `.php` only (would 404 the reader). Route now registers
php/jsp/asp/aspx/cgi. Build green.
- Real serial **cjihao=H05M2AFA** = the lane key → assign reader as lane_devices.id="H05M2AFA".
Reader beeped on status:0 (invalid/1-beep); a matching permit/session → status:1 (2-beep accept).
- Updated [[gee-qr-er80]] (verified-on-hardware).
## [2026-06-16] feature | gee-qr-reader driver — assign by serial, resolve lane by config
- The QR reader is a push device; setup wizard always assigns a random-UUID id, so "id = serial"
isn't possible via the UI. Clean fix instead: new **`gee-qr-reader`** driver (reader category) with
a single `serial` config field. Admin assigns it in the wizard (UUID id) + types the serial.
- QR endpoint now resolves the lane by **matching `lane_devices.config.serial` to the scan's
`cjihao`** (was: row id == cjihao). `qrReaderRoutes(app, db, dispatcher)`. Unassigned serial →
no lane → status:0 (graceful).
- `tcpip-reader` flagged as the WRONG model for this device (host-connects-out stub).
- VERIFIED via inject through the real /api/setup/assign: assign {serial:"H05M2AFA"} → .jsp scan +
matching permit → status:1 + open; re-scan → exit; unknown card → status:0; unassigned serial →
status:0. Full build 5/5.
- Updated [[gee-qr-er80]] (assignment as-built).
## [2026-06-16] feature | stub-access driver (bench-test the flows without a relay)
- Live QR scan reached the real app (.jsp, serial resolved) but rejected: "reader not on an
access-equipped lane" — lane 1 had the reader but no access device. The dispatcher requires an
access device on the same lane.
- Added a no-op **`stub-access`** driver (access category, no config): `pulseOpen` just logs, no
device I/O — stands in on a lane to test QR→permit→accept (incl. the beep) without the
[[dingtian-relay]] connected. NOT for production. Registered in the catalog.
- To get a live accept: assign Stub barrier to the reader's lane + a permit whose QR = the scanned
code → status:1 (2-beep) + logged pulseOpen.
## [2026-06-16] fix | QR reader 10s beep delay — reply must Connection: close
- Live accept worked (status:1, pulseOpen, 2 beeps) but the beep came ~10 s LATE. Server responded
in 14.7 ms; user confirmed request is fast, only the beep lags → delay is the READER, not us.
- Cause: reader sends `Connection: keep-alive` but only ACTS on the verdict once the socket CLOSES;
Fastify kept it alive → reader waited out a ~10 s keep-alive timeout. Every vendor demo replies
`Connection: close` + shuts the socket.
- Fix: endpoint sets `reply.header("connection","close")`. Verified the header is now sent.
- Updated [[gee-qr-er80]] (⚠️ Connection: close requirement).
## [2026-06-16] feature | lane direction (per-device entry/exit) + camera snapshots
- Direction is per **device**, not per lane: added `direction` (`entry`/`exit`/`both`, default
`both`) to `lane_devices`. A lane's entry set = devices tagged entry|both, exit set likewise —
no join table; tagging in the wizard IS the grouping. Rejected a lane-level `lanes` table (can't
model one bidirectional lane). New [[lane-direction]] concept page; cross-linked [[entry-exit-readers]].
- All flows now resolve via `deviceRowsFor(lane, category, direction)` (lane-map.ts), replacing the
ad-hoc "first access on lane" lookups. Dispatcher trusts the **reader's own direction**; a
directional reader that contradicts the car's session state is a wrong-lane/anti-passback refusal.
`both` keeps the old infer-from-session behavior.
- Relay open channel is now config-driven (`config.openChannel`, default 1) since a lane can hold
an entry **and** an exit relay. LPR stays a snapshot sink — ANPR POSTs a `plate` read to the
reader endpoint (no camera-as-input coupling).
- Camera snapshots wired into all four paths (transient/permit × entry/exit): fired AFTER
pulseOpen, **never awaited** (evidence, not a gate — camera failure can't block an open). Stored
as BLOB in a new `snapshots` table (not files); telemetry `kind:"snapshot"` device_event per
capture/failure; linked to the signed event by `identity`. Served via `GET /api/snapshots/:id`.
- Migration `0002_wild_odin.sql` (additive). Snapshot **retention** left unresolved →
[[open-questions]] #10. Whole monorepo typechecks; no test suite exists in-repo.
## [2026-06-16] redesign | SUPERSEDES the above — pool-of-spaces, per-relay direction, NO lane
- User correction: direction is NOT a property of a device row. One Dingtian board has 2+ relays;
a single board drives both the entry barrier (relay 1) and the exit barrier (relay 2), and a
single relay can even serve **both**. So the row-level `direction` from the entry above was wrong.
- Further: the whole **"lane" concept was dropped**. Occupancy is site-wide, device grouping is now
the reader→relay binding, and anti-fraud never used lane. A parking lot = **one pool of spaces**
with a flexible set of entry/exit points (1 in + 2 out, etc). New [[entry-exit-points]] page
(replaces lane-direction); reworked [[entry-exit-readers]], [[parking-session]], [[first-run-setup]],
[[device-registry]].
- Model now: access `config.relays = [{ relay, direction: entry|exit|both, button? }]` (`button` =
the input terminal the entry button is wired to). Readers/cameras `config.controllerId + relay`
bind to the barrier they sit at; direction inherited ("the relay at that reader" opens on a read).
- Schema: dropped `lane` from `ledger_events`, `device_events`, `sessions`; renamed `lane_devices`
→ `devices` (no lane/direction columns). `lane` was in the SIGNED canonical form, so canonicalize()
dropped it and the signer keyId bumped **sw-hmac-v1 → sw-hmac-v2** (v1 events won't verify under
v2 — intentional, gated by per-event keyId; done pre-deployment on throwaway data). Migration
history reset to a fresh `0000_baseline` (dev DBs deleted + re-migrated).
- Resolvers in new `device-resolve.ts` (replaces lane-map.ts): `relayForButton`, `relayForDevice`,
`firstRelayByDirection`, `devicesByDirection`. `DeviceConfig` widened to nested JSON for `relays[]`.
- Wizard rewritten: no lane selector; Controllers section (relay map + entry-button terminal per
relay), then readers/cameras/printers bind to a controller relay. Whole monorepo typechecks +
builds; no test suite in-repo.
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
(device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass.
## [2026-06-16] build | Scannable ticket — QR + Code128 on the Rongta dispenser
`renderTicket()` now emits the ticket id as a printer-generated QR (ESC/POS `GS ( k`, model 2, ECC M) AND a Code128 1D barcode (`GS k`, set B), plus the human-readable id. Three independently-readable forms so a dead reader is recoverable (imager / 1D laser / phone camera / hand-keyed). No image rendering, no new dependency. Phone-scan operator fallback deferred (reuses the existing dispatch path). See [[ticket-encoding]].
## [2026-06-17] build | Ticket id -> all-numeric 13-digit (12 random + Luhn); barcode-only ticket
Replaced the `T-<uuid>` ticket id with a 13-digit all-numeric code (12 crypto-random digits + Luhn check) in `newTicketId()` so ANY legacy 1D barcode scanner reads it and the operator can hand-key it on total reader failure. Random keeps the unguessable anti-fraud property; Luhn lets manual entry reject typos (`validateTicketCode()`). `renderTicket()` now prints a centered Code128 barcode, the code in large digits below, then the issue time — QR dropped (may return as an admin toggle for mobile users). NOT a schema change: `identity`/`sessions.id` are free-form text; legacy ids coexist. See [[ticket-encoding]].
## [2026-06-17] build | Park metadata in site_config + ticket header
Extended `site_config` (single-row) with optional park identity: `park_name`, `operator_name`, `vat_number`, `registration_number`, `address`, `phone`, `email` — all nullable text (Drizzle migration 0001, additive). `GET`/`PUT /api/site-config` now read/write the full config (PUT is a partial patch; admin only); `SiteSettings.tsx` gained the fields. `renderTicket()` prints a header (park name large or "PARKING", then operator/VAT/Reg/address, plus a "Lost ticket? <phone>" footer) sourced from `site_config` via `EntryFlow.#ticketHeader()`. Open: non-ASCII (accent) chars need a printer codepage. See [[site-metadata]], [[ticket-encoding]].
## [2026-06-17] build | Ticket in Albanian; VAT->NIUS, drop registration; CP852 codepage
Ticket header now prints in Albanian and uses NIUS instead of VAT. Renamed `site_config.vat_number` -> `nius` and DROPPED `registration_number` (regenerated migration 0001 in place; only the dev DB had it, so no migration debt; dev DB reset + re-migrated). `renderTicket()`: NIUS line (only if set), "Printuar më:" before the timestamp, "Keni humbur biletën? <phone>" footer; strings centralised in a `STR` table for future i18n. Added CP852 (Latin-2) codepage support (`ESC t 18` + a Unicode->CP852 `line()` encoder with ASCII fallback) so `ë`/`ç` render. Touched: schema, migration, routes/site.ts, web api.ts + SiteSettings.tsx, devices interfaces + printer-rongta.ts, entry-flow.ts. Byte-verified ë->0x89. See [[site-metadata]], [[ticket-encoding]].
## [2026-06-17] ingest | ParkSQL2017 legacy schema + tariff research
Ingested `raw/parksql2017-legacy-schema.sql` (predecessor SQL Server 2017 schema, decoded from UTF-16; Albanian market — NIVF fiscal codes, Cupons, LostPrice1..4). Source summary in [[parksql2017-legacy-schema]]. Combined with a deep-research run (5 claims verified 3-0/2-0; synthesis + 20 claims aborted on a session limit — treat those as unverified, not refuted). Filed two design pages: [[tariff-time-tiers]] (happy-hour/off-peak/weekend/seasonal + vehicle categories via time-windowed rate cards; the hard part is wall-clock stay-slicing with a continuous block ladder) and [[validation-sponsorship]] (sponsor accounts + postpaid B2B billing; distinct from [[permit]]). Reconciled with the existing [[validation-discounts]] (cross-linked, no duplication — that page owns the signed-event discount mechanism, the new one owns sponsor/settlement). Updated [[tariff]] (new "Extensions under design" section; removed the now-addressed time-tier open item). Legacy confirms our stepped ladder + per-rate lost penalty + typed session discount; adds time-of-day windows + category axis; lacks any postpaid sponsor model (net-new). Flagged legacy anti-patterns we deliberately reject: float money, mutable rate rows, in-row image BLOBs.
## [2026-06-17] build | Booth actions — ticket input, pay/exit modal, voucher, snapshots
Made the booth screen operational (was a passive monitor). Backend: `site_config.exit_voucher_default` (additive migration 0002); `GET /api/session/:identity` (lookup + quote in one read), `POST /api/exit` (booth-driven, VALIDATED exit — reuses ExitFlow's paid+grace checks, no booth bypass; signs vehicle_exit + pulses an exit relay resolved via firstRelayByDirection; payment never rolled back, relay-open failure → signed anomaly + opened:false), `POST /api/voucher` (reprint paid ticket id barcode on the booth printer). Refactored ExitFlow into shared #signExit/#fireExitSnapshot/#closeSessionCache so the reader and booth paths are one validated code path. Frontend: ticket input on /booth (HID-scanner-friendly), Radix pay/exit modal (entry/now/duration/total + tender + 'Printo biletë dalje' checkbox defaulting from site_config), entry/exit SnapshotStrip (thumbnails → zoom). Verified end-to-end in-browser: scan → modal shows ALL 200 + real entry photo → pay → "barrier opened"; ledger recorded entry→payment→vehicle_exit in order; chain verify ok after. Decision recorded in [[booth-exit-flow]]. Unpaid exit correctly 409-refused (threat-model). NOT yet covered: voucher print success path (dev printers physically offline), automated tests.
## [2026-06-17] query | Walk-back grace renews on every payment (voucher overstay)
User flagged: customer pays, takes an exit voucher, dawdles past grace. Traced exit-flow.ts + pay-station.ts. Findings: refuse-on-expiry ✓ (no free exit) and reprice-from-entry ✓ (timer never restarts — `computeFee(enteredAt, now)`, NOT from paidAt) are both correct and deliberate. BUG: each `payment` writes its own `graceExitMin` and the exit flow reads the LATEST one, so every top-up re-grants a full walk-back window → grace doubles/repeats. Leak is TIME not money (fee always catches up from entry), bounded by increment coarseness but real. Flagged as an open question in [[booth-exit-flow]] (full analysis + 3 candidate fixes) and [[tariff]] (cross-ref + corrected the original `f(paidAt,now)` sketch to the as-built entry-based reprice). Decision deferred — fairness vs. anti-abuse business call. Recommended fix: grant grace on top-up only when it charged new money.
## [2026-06-18] build | Active sessions panel + audited barrier re-open
Operator escape hatch for stuck cars (damaged ticket / dead scanner / phantom barrier re-close). Key model from operator: the barrier state is ASSUMED not confirmed, so a session is "active" while OPEN or exited-but-within-grace — payment and a successful voucher scan do NOT remove it; only grace expiry does. Backend: PayStation.activeSessions() (one ledger fold, open OR within-grace, newest first), GET /api/sessions/active; ExitFlow.reopenBarrier() + POST /api/barrier/reopen — re-pulses an exit relay and signs an attributed `anomaly` (barrierReopen, operator), NEVER a 2nd vehicle_exit. Guard: no payment → 409 refuse (no-unpaid-bypass), enforced server-side AND the UI hides the button. Frontend: ActiveSessions panel on /booth (live via WS invalidation + 15s poll for grace expiry), row click → pay/exit modal, "Open barrier" only on paid rows. Verified in-browser: in-grace session stayed listed as "exiting" with the button; unpaid rows had none; click → audited anomaly #53 [op=boothtest], vehicle_exit count stayed 1 (no double-count); chain verify ok. Design recorded in [[booth-exit-flow]] (Active sessions & human-intervention barrier open). Standing gap: still no automated tests.
## [2026-06-18] build | Drawer balance — opening float carry-over + admin cash movements
Cash drawer that carries across shifts. New signed `cash_movement` ledger event type (shared); admin-only POST /api/cash-movement {amountMinor signed +load/-remove, reason}. ShiftService: #drawerBalanceAt(time) folds cash payments + cash_movements BY TIME (not operator — the movement is the admin's); open() auto-inherits openingFloat = drawerBalanceAt(start) and records it on shift_open; close() Z-report adds openingFloat/cashAdded/cashRemoved/expectedDrawer (= opening + taken + added − removed = next shift's opening float). Card payments excluded (settle to bank). GET /api/shift/current returns live drawerMinor. Frontend: ShiftControl shows live drawer + admin Load/Remove form + full Z-report drawer block (admin gate via router context). Verified the canonical scenario on a FRESH DB: load 5000 → shift1 takes 6500 → expected 11500 → shift2 inherits 11500, admin removes 5000, takes 4500 → expected 11000 → shift3 inherits 11000; chain ok. Also verified through the real UI (load/remove → Z-report opening 11200 removed 5000 expected 6200; both cash_movements signed+attributed; chain ok). Decision + worked example in [[shift]] (Drawer balance section). Standing gap: still no automated tests.
## [2026-06-17] build | Live booth WebSocket feed (/api/ws)
Added @fastify/websocket. EventLog.append fires a read-side onAppended callback after each durable insert (never touching the sign/chain path); device-events gained a `ledger` channel (emitLedger). New GET /api/ws fans out ledger + recomputed occupancy + printer-status to authenticated booth clients. Auth: JWT cookie (same as REST) + an Origin allowlist (WS_ALLOWED_ORIGINS) that REPLACES CSRF — a browser WebSocket can't send the double-submit header, so without an Origin check the read-only feed is open to Cross-Site WebSocket Hijacking (found + fixed by automated security review). See [[booth-console]], [[append-only-event-chain]].
## [2026-06-17] build | Frontend foundation — Tailwind terminal theme, Query/Router/Zustand, live booth screen
Operator UI outgrew plain React. Added TanStack Query (server state, wraps apiFetch), TanStack Router (role-guarded routes), Zustand (small client state: WS status + live feed), Tailwind v4 with a Bloomberg-terminal theme + Radix primitives. A /api/ws client invalidates Query caches on ledger pushes. Built the live /booth screen (occupancy gauge + streaming entry/exit/payment ticker). Vite proxies the WS upgrade. SUPERSEDES the "plain React, no framework" note on [[react-vite-spa]]. See [[booth-console]].
## [2026-06-18] build | i18n — Albanian default + English, per-user server-stored preference
Two languages via react-i18next, Albanian default/fallback. Language is a per-user preference: users.language (migration 0003), returned from login/me, changed via PUT /api/auth/language (NOT in the JWT — no re-login). Loaded on login, restored from any booth; SQ/EN header toggle persists. Type-safe key parity (en mirrors sq or the build fails). Translated booth + Login/Shift/Site/Permits/Tariff. SetupWizard deferred (server-provided catalog strings need backend i18n). Printed tickets stay Albanian (customer-facing). See [[i18n]], [[booth-console]].
## [2026-06-18] lint | Reconcile wiki with the session's work
Audited wiki vs. the session: three major builds (live WebSocket, frontend foundation, i18n) had NO log entry and NO concept page. Filed [[i18n]] (resolved a dangling code-comment link) and [[booth-console]] (operator-UI architecture: stack, /api/ws live feed, anti-CSWSH, booth screen). Updated stale [[react-vite-spa]] (the "plain React, no framework" claim is now qualified). Backfilled the three missing build log entries. Standing gaps flagged across pages: NO automated tests (front or back); ATECC608 not yet wired (software-HMAC signing is tamper-evident, not tamper-proof); pre-existing admin screens not on the terminal theme.
## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs
Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web.
## [2026-06-18] ingest | Device-status footer — unified monitor across all categories
Generalised printer-only status monitoring to a booth-wide DEVICE-STATUS FOOTER covering relays/readers/cameras/printers. New `DeviceMonitor` (`apps/server/src/device-monitor.ts`) polls every enabled device each tick (default 8s): printers via rich `readStatus()`, all others via the generic `healthCheck()` reachability probe, flattened to one traffic-light (ready/degraded/offline)+detail, deduped (emits on change only), fail-toward-offline (a throw/timeout → offline, never false-healthy). New `device-status` bus event + `GET /api/devices/status` snapshot; live updates ride the existing `/api/ws` (`hello` now carries the initial device set; `device-status` frame per change). Web: live-store `devices` map (setDevices/upsertDevice), WS handler wired, new `DeviceFooter` chip-per-device with an "all ready / N offline" roll-up, mounted in the app shell; `devices` i18n namespace (sq/en). The PrinterMonitor + its SSE stream stay as the printer-specific authority (both run — see the note in [[device-status-monitoring]]). Filed [[device-status-monitoring]] (resolves the code link), cross-linked [[printer-status-monitoring]] + [[booth-console]], indexed (concepts 41→42). Verified on a fresh DB (relay+reader → ready via healthCheck; unreachable printer → offline with detail, no throw; emit-once-then-silent) — 9/9; server+web build clean.
## [2026-06-18] refine | Device footer — role-only labels + click-to-see-issues
Two refinements to the device-status footer. (1) Chips label by ROLE, not vendor: the server sends a structured `roleKind` token per device (reader/camera → direction inherited from the bound relay via `directionOf()`; access → entry/exit/both, or "mixed" across relays; printer → lane/booth) and the client localises category+role → "Lexuesi hyrje", "Printer kabina", "Kamera dalje". Dropped `label`/`role`/driverId from the chip. (2) Fault detail no longer pollutes the footer: chips are compact (dot + label); a degraded/offline chip (or the "N with issues" roll-up) is clickable and opens a small issues panel above the footer listing only the problem devices with state/detail/checked-time (outside-click/Esc to close; no new dependency). i18n `devices.role.*` + issues keys (sq/en). Verified roleKind resolution on a fresh DB (access→mixed, reader(exit)→exit, camera(entry)→entry, printers→lane/booth) 7/7; server+web build clean. Updated [[device-status-monitoring]].
## [2026-06-18] fix | Stuck active session — paid ticket that never got a vehicle_exit (T-397815c0)
Investigated a paid ticket stuck forever in the Active Sessions tab. Root cause (confirmed from the live ledger): the car left via a **manual barrier re-open**, which by design signed an `anomaly` but **never a `vehicle_exit`** — so `activeSessions()` saw it as permanently `open` (the grace-expiry eviction only applied to *exited* sessions). The normal exit that would have signed the exit was refused because walk-back grace (5 min) had expired ~17h earlier. Two fixes: (1) `ExitFlow.reopenBarrier` now signs a `vehicle_exit` (`source:manual`) **when the session is still open**, closing it — while still NOT double-signing an already-exited session (phantom re-close). (2) `PayStation.activeSessions()` ages out a **paid** open session past grace even with no exit (unpaid open sessions never age out — a car owing money stays). Plus a one-off corrective: appended a signed `vehicle_exit` (index 68, `correction:true`) for T-397815c0 through EventLog (chain verified `{ok:true}`), clearing it from the list. Verified both fixes on a fresh DB (9/9; chain intact). Updated [[booth-exit-flow]] (active-session definition + the re-open rule, was "NEVER a vehicle_exit").
## [2026-06-18] ingest | Permit → Subscription rename + monthly pricing (timeframes deferred)
Renamed the "permit" feature to "subscription" (operator term: abonim) and added recurring monthly pricing. FULL rename of mutable master data: tables permits→subscriptions, permit_credentials→subscription_credentials, permit_plates→subscription_plates, sessions.permit_id→subscription_id (data-preserving ALTER RENAMEs, migration 0004); server permit-flow.ts→subscription-flow.ts (SubscriptionFlow), routes/permits.ts→routes/subscriptions.ts (/api/subscriptions), web PermitManager→SubscriptionManager, api types, i18n (sq "Abonimet"/en "Subscriptions"). The signed ledger `permitId` payload field is INTENTIONALLY kept (immutable hash-chained history — renaming would break verification of past events); code/data are "subscription", the on-chain field stays `permitId`. Pricing: per-subscription priceMinor + period("monthly") + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form; collecting the fee into the ledger/shift is DEFERRED (wiki note only). Time-of-day access windows (e.g. overnight subscriber 19:00–07:00, transient outside) documented as a design note in [[subscription]] — NOT implemented; legacy precedent in [[parksql2017-legacy-schema]] (MembershipPlansTime). Renamed [[entities/permit|permit]]→[[subscription]] and swept all [[permit]] wikilinks across the wiki (log.md historical entries left as-was). Verified end-to-end on a fresh migrated DB (schema+pricing, card entry/exit, maxConcurrent cap, on-chain permitId carries the sub id, chain verify) 6/6; migration also applied cleanly to a copy of the live DB (18 sessions preserved). Full monorepo builds clean.
## [2026-06-18] note | Subscription-fee collection is a SHIFT transaction
Clarified (user): collecting/renewing a subscription's monthly fee is a financial transaction a common operator makes DURING their shift — it must reflect in THAT shift's drawer + Z-report, not be an admin-only edit. Updated [[subscription]] (Pricing → "Collecting the fee is a SHIFT transaction"): model it as a signed `payment` event (same `{amountMinor,currency,tender}` shape) tagged `{subscriptionId}` at collection time, so it folds into the open shift automatically (Z-report sums payments by time; drawer adds cash tenders) with no new summing logic. Admin edits the master data; operator takes the money. Subscription entry/exit stay free — only the plan fee is a payment. Still DEFERRED build; cross-linked from [[shift]] ("What End Shift does"). Open: plain `payment`+tag vs. a distinct `subscription_payment` type (leaning plain).
## [2026-06-18] note | Subscription credential type — operator chooses, QR-only for now
The subscription form lets the operator choose the credential type; for now only QR is live. UI change only: the new-credential default is now QR (was RF), and the RFID option is shown DISABLED ("soon", `subs.rfCardTagSoon`) so the choice is visible. Backend + schema keep accepting `kind:'rf'|'qr'` unchanged — re-enabling RFID later is just dropping `disabled` (no migration). Updated [[subscription]] Credentials section.
## [2026-06-18] feat | Subscription QR auto-generation + multi-month coverage
QR credentials are now AUTO-GENERATED server-side (`SUB-<15×base32>`, crypto-random, globally-unique-checked) — the operator/customer never picks the code (anti-fraud); the UI sends a blank QR credential and the server mints+returns the value to print. RF credentials still carry the operator-entered card id. Reader output decided = TCP/IP full string (host-in-the-loop), so the code length is free; noted the Wiegand-26/34 numeric-truncation alternative if ever wired that way (+ the manufacturer reader's ID/IC/NFC+QR / Wiegand/TCP/USB/RS485 / 125kHz+13.56MHz spec — one device covers QR and future RFID). Multi-month: the form takes a `months` count → server sets `validTo = validFrom + N months` (day-clamp), one record/one window, total = N×monthly (collection still deferred); explicit `validTo` override still works; `months` is input-only (truth is validFrom/validTo). Backend: routes/subscriptions.ts (newQrCode/addMonths/resolveValidTo, validate RF-needs-value + months-needs-validFrom). Web: SubscriptionManager (QR shown auto-gen/read-only, months field + live coverage+total preview), api types, i18n (sq/en). Verified via buildServer+inject 9/9 (autogen, uniqueness, RF-blank reject, Jan31+3mo→Apr30, supplied-value preserved). Updated [[subscription]]. No new migration (uses existing columns).
## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering
The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration.
## [2026-06-18] feat | Subscription RFID enrollment — "Read card" capture on a chosen reader
Enabled RFID subscription credentials with a card-enrollment flow. The operator picks a reader and presents the physical card; the value is captured into the credential instead of being typed. New in-memory `CredentialCapture` (single-shot + ~30s TTL): `arm(deviceId)`; `routes/qr-reader.ts` checks `tryConsume()` on each read — an armed reader's read is captured and NOT dispatched (no barrier for an enrolled card), then auto-disarms; reads on the OTHER reader dispatch normally, so its live entry/exit flow is never blocked. Routes (operator/admin): `GET /api/subscriptions/readers` (picker), `POST /capture/arm`, `GET /capture` (poll: idle|armed|captured|expired), `POST /capture/cancel`. Web: RFID re-enabled in the form (was disabled "soon"); "Read card" → reader picker → arm → poll → fills the value; i18n (sq/en). The GEE readers are combo QR+RFID (ID/IC/NFC), same endpoint, so one device captures both. Verified via buildServer+inject + reader-scan simulation (12/12: captured-not-dispatched, single-shot, other reader still drives a live vehicle_exit while armed, value retrievable, cancel). Updated [[subscription]]. No migration.
## [2026-06-18] feat | Subscriptions — enter with one credential, exit with another (+ FIFO fleets)
Decoupled subscription exit from the entry credential. Previously the session was keyed by the exact credential value read (an accidental coupling → must exit with the same QR/RFID you entered with). Now sessions are keyed by a per-occurrence id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`; `payload.permitId`=subId), so ANY of a subscription's credentials (QR/RFID/NFC/plate) opens or closes. Direction is now decided by the BARRIER the reader sits at (entry-lane→entry, exit-lane→exit; a "both" barrier infers from open state) — this lets a FLEET (maxConcurrent>1) admit several cars (each entry-lane read is an entry) yet exit any of them with any credential; exit closes the OLDEST open occurrence (FIFO). Per-car identity within a fleet isn't tracked (never was once credentials are shared). `#openOccurrences()` replaced `#carHasOpenSession`/`#subscriptionOpenCount`. Exit with nothing open → signed anomaly (anti-passback). Verified 11/11 (enter-QR/exit-RFID + reverse, fleet 2-in mixed-credential FIFO out, capacity, anti-passback, chain intact). Updated [[subscription]] (multi-credential + entry-decoupled-from-exit). No migration.
## [2026-06-18] fix | Subscription occurrences in the booth — prepaid, barrier-open assist (not transient)
A subscription occurrence (SUBSESS-…) showed in Active Sessions but was wrongly treated as an unpaid transient: the modal tried to quote/charge it and the "open barrier" button only appeared for PAID sessions, so a subscriber with a faulty exit reader / missing card couldn't be assisted. Fix: `pay-station.ts` lookup/activeSessions now flag `subscription`/`subscriptionId`/`subscriptionHolder` from the entry payload (permit:true/permitId) and DON'T quote a subscription (amountMinor null). `exit-flow.ts` reopenBarrier now authorizes `paidAt != null || subscription` (prepaid). UI: the pay modal renders a SUBSCRIPTION mode (PREPAID badge, snapshots, single Open-barrier action, no tender/voucher) and the active row badges "abonim" + shows the holder name; both labelled by holder, not the raw key. Also SHORTENED the occurrence id (was SUBSESS-<subId>-<uuid>, ~80 chars) to `SUBSESS-<12hex>` — the subscriptionId lives in the payload (which every fold matches on), so it needn't be embedded in the key. Verified 9/9 (subscription flagged + not charged in lookup/active, reopen works without payment, unpaid-transient guard intact). Updated [[booth-exit-flow]]. No migration.
## [2026-06-18] fix | Tariff versioning was retroactive — forbid backdated effectiveFrom
The version selector picks "latest tariff_version with effectiveFrom ≤ session entry time" (correct intent: a past session reprices against the rate in force when it was incurred). But the publish handler (`routes/tariffs.ts`) accepted ANY `effectiveFrom` (defaulting to now). So an admin could publish a version with a **backdated** effectiveFrom and silently reprice sessions that had already entered — exactly the retroactive rewrite the versioning exists to prevent. Pricing itself was already sound: `quote()` resolves by `entry.occurredAt` and the `payment` event records `tariffVersionId`, so a COMPLETED session is frozen; the leak was entirely the publish side. Fix: reject `effectiveFrom` earlier than now (60 s skew tolerance); future-dated (scheduling a price change) stays allowed; bad ISO → 400. Decision (with user): forbid backdating + keep entry-time pinning; did NOT add tariffVersionId to vehicle_entry (entry-time selection + no-backdating already freezes the price). Together these make it structural: once a car has entered, no later publish can reprice it. Verified 5/5 via inject against a copy of the live DB (now→201, -1h→400, +1h→201, garbage→400, -10s skew→201). Updated [[tariff]]. No migration.
## [2026-06-18] feat | Tariff: complete the progressive ladder — open-ended last block required + hours-based composer
The owner asked for "first N hours × X, next N hours × Y, …, 24h cap" — which the stepped-block engine ALREADY does (ordered blocks, per-block rate, rolling-24h cap; computeFee tested). So no new axis: the work was making the model complete + footgun-free. Two changes. (1) **Forbid a bounded last block** — `validateTariffStructure` (shared) now rejects a final block with a non-null `uptoMin`, so the "thereafter" rate is always explicit; previously a bounded tail silently inherited its own rate past its bound (a hidden, never-stated price — e.g. the live ALL tariff's 180-min last block billed hour 4+ at the 3rd-hour rate). `rateAt()` still prices legacy bounded-tail versions gracefully and validation is publish-only, so immutable published versions are unaffected (no migration). (2) **Composer edits bands as a DURATION in hours**, not cumulative minutes — `BlockForm` carries `hours`; `toStructure` accumulates into cumulative `uptoMin` minutes; the last row is a pinned, non-removable, hours-less "thereafter (open-ended)" band; `blocksToForm` round-trips stored minutes back to band hours (legacy bounded tails still load). i18n: replaced `upToMin`/`egExample` with `bandDuration`/`hoursUnit`/`egHours` in sq+en (catalog parity green). Verified: validator rejects bounded-last / accepts open-ended; computeFee correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full build green (shared/server/web). Updated [[tariff]]. No migration.
NB considered-and-rejected: time-of-day / weekday wall-clock tiers ("timeframe") were offered but the owner explicitly chose the elapsed-duration ladder only — see [[tariff-time-tiers]] for the deferred wall-clock axis.
## [2026-06-18] feat | Tariff V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Brought the legacy ParkSQL2017 pricing BREADTH onto our engine (keeping integer-minor-unit money + immutable signed versions; rejecting legacy float money / mutable rows). `TariffStructure` is now a discriminated union: V1 = the original bare ladder (UNCHANGED, verbatim algorithm — golden-regression-tested against the live production version); V2 = `{version:2, tz, <shared knobs>, defaultCard, windowedCards[]}` where each card is flat OR a block ladder and may be scoped by wall-clock hour window / day-of-week / date range / vehicle category. computeFeeV2 prices by stepping one increment at a time, advancing the ladder by ELAPSED minutes (continuous) while selecting the active card by WALL-CLOCK time in the version's FROZEN tz. Decisions (with user): tz is a per-site setting (site_config.timezone, default Europe/Tirane) stamped server-side into each version on publish — never the host clock (reproducibility); default-card cap governs a mixed day; precedence = specificity (date>dow>hour) → priority → name (total, order-independent), validation rejects ambiguous ties; category = a card FIELD (not tariff scope), frozen in the signed vehicle_entry payload (site_config.default_vehicle_category default), read at both pricing call-sites. Composer: default card front-and-centre (flat/ladder toggle), tiers under an "Advanced" disclosure (window builder), emits BARE V1 when no tiers (back-compat). DB: migrations 0005 (timezone) + 0006 (default_vehicle_category) — applied to the live DB (backed up). Stood up vitest in @parking/shared (was zero tests on the ledger-feeding fee fn); 36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism, validation matrix — all green. Full monorepo build green; V2 publish verified end-to-end via inject (tz stamped from config not client; malformed V2 rejected). Updated [[tariff-time-tiers]] (status open→settled, as-built), [[tariff]], index. No event-chain change.
NB incident: the running tsx-watch dev server (server + vite) crashed mid-edit on a half-saved file + a schema column the live DB lacked; recovered by finishing the edits, applying the migration to the live DB, and restarting both watchers. Live DB backup left at apps/server/parking.sqlite.bak-*.
## [2026-06-18] feat | Booth UI — adopted "TRM" design-system tokens (tokens only)
The owner linked a Claude Design project (`019ddfee-…`, "TRM — Tracking & Race Management") and asked to implement its designs in `apps/web/`. TRM is a RACE-TIMING design system (dashboard/leaderboard/marketing/mobile kits: RaceControl, HeroClock, LiveTable, BibCard, Ticker) — NOT a parking design; literally porting it would have reskinned the booth UI with race components. Flagged the mismatch; owner chose **tokens only**. So: brought TRM's token VOCABULARY into the Tailwind v4 `@theme` (`apps/web/src/index.css`) and ALIGNED the existing `term-*` accents onto TRM's exact values — surfaces → TRM `night` scale (#0b0d10/#14171c/#1e222a/#2a2f38), amber #f5a623→#f2a516, green #2ecc71→#2e8c4a, red #ff4d4f→#e8412b (flag), cyan #38bdf8→#2563c8 (blue). No component touched (170+ `term-*` references resolve unchanged). Also exposed TRM's full vocabulary as utilities for new work: night/ink/paper scales, flag/amber/green/blue + tints, viz-1..8, the 4px spacing scale (s0..s13), type scale (overline..jumbo), square radii, sharp "printed" offset shadows, control/table-row heights. Offline-appliance constraint ⇒ deliberately did NOT keep TRM's Google-Fonts `@import` (no runtime network); Goldplay (TRM display face) left un-self-hosted — display/heading falls back to a sans stack (mono is the booth's primary face anyway), noted for later wiring. Verified: web build green; login renders on the new palette (amber focus ring = #f2a516). Updated [[booth-console]]. No logic/schema/event-chain change.
## [2026-06-18] fix | Cashino printer — ping-only driver (no false status) + Albanian device-role wording
Two device-feedback issues. (1) **Cashino 80mm printer reported wrong status.** It was configured on the `rongta` driver, whose `readStatus()` scrapes the Rongta board's `/prn_stat.htm` status page — which the Cashino does NOT serve. Result: a bogus `degraded`/page-error verdict while the printer was actually online (it printed fine; `healthCheck` TCP-ping passed). Root cause: the Cashino is an ESC/POS PRINT clone but has no trustworthy STATUS mechanism. Fix: extracted the shared ESC/POS rendering + transport (renderTicket/renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/qrCode) from `printer-rongta.ts` into a new `drivers/printer-escpos.ts`; added a dedicated `cashino` driver that reuses that print path but is deliberately **NOT** `MonitorableDevice` (no readStatus). So `isMonitorable()` is false and the device monitor falls back to the generic `healthCheck()` — a plain TCP reachability ping of the print socket: reachable→ready, unreachable→offline, never a guessed paper/cover state. Rongta driver unchanged (still scrapes its page, still monitorable). Registered `cashinoDriver`; re-exported from the package. Switched the live entry-dispenser printer at **10.0.10.9** from `rongta`→`cashino` in the DB (backed up incl. WAL: apps/server/parking.sqlite*.bak-cashino-*); 10.0.10.10 (booth Rongta) left as-is. Verified at runtime: cashino registered, isMonitorable=false, no readStatus, healthCheck→offline on unreachable; live /api/devices/status → both printers `ready` (lane via ping, booth via page). (2) **Albanian device-role chip wording was wrong.** The footer label is `"{category} {role}"`; the role suffixes read badly: access `mixed`="i përzier" gave `Barriera i përzier` ("Barrier mixed" — wrong word + wrong gender; `mixed` actually means a barrier spanning >1 direction) → now `hyrje/dalje` (entry/exit). printer `lane`="korsia" gave `Printer korsia` ("Printer the-lane") → now `në korsi` (at the lane); `booth`="kabina" (`Printer kabina`) → `në kabinë` (at the booth). English tidied to match: mixed→"entry/exit", lane→"at lane", booth→"at booth". i18n catalog parity green. Updated [[printer-status-monitoring]]. No schema/event-chain change.
## [2026-06-18] feat | Payment receipt — transparency slip (entry/paid/duration/amount), voucher or standalone
After a completed payment the customer now always gets a transparency record: ENTRY time, PAYMENT time, DURATION parked, AMOUNT + tender. One shared ESC/POS renderer (`renderReceipt` + `ReceiptData` in @parking/devices), two modes: (a) VOUCHER mode = those figures PLUS the scannable Code128 barcode and an emphasised walk-back-grace line ("Dilni brenda N min — skanoni këtë biletë në dalje") so the one slip both proves payment and self-exits at a distant exit reader — this REPLACED the old barcode-only voucher (printExitVoucher → printPaymentReceipt); (b) STANDALONE mode = detail-only (no barcode), AUTO-printed at payment when the voucher checkbox is OFF (booth at the exit). Figures are folded from the SIGNED ledger (latest payment event), printed on the booth printer (failover to dispenser). Money via Intl minor-units (no float); duration = whole minutes (mirrors UI formatDuration); timestamps use the host-local clock (appliance = site time; distinct from the tariff's frozen tz, which governs PRICING reproducibility not display). Server: booth-print.ts `printPaymentReceipt(db,id,{voucher},log)` + `receiptFigures()`; routes `POST /api/voucher` (voucher) and new `POST /api/receipt` (standalone/reprint — requires paid, allows already-exited so reprint works). Both ESC/POS drivers (rongta + cashino) gained `printReceipt` (PrinterDevice interface). Frontend: BoothPayModal auto-prints the standalone receipt after a non-voucher payment (BEST-EFFORT — a printer fault must not block the exit that already happened; shows a note + a "Reprint receipt" button in the done phase, also for slip jams / later asks); api.ts `printReceipt()`. Decision (with user): auto-print on payment (not on-demand-only / not a new config toggle), with reprint fallback. i18n: receiptPrintFailed/receiptReprinted/reprintReceipt/reprinting (sq+en, parity green). Verified: full build green; renderReceipt output inspected in both modes (correct figures, barcode, grace line, Albanian); /api/receipt + /api/voucher live (404 on unknown id — route+validation reached, no physical print fired on the real booth printer). Updated [[booth-exit-flow]]. No schema/event-chain change.
## [2026-06-18] fix | Receipt/voucher misprint — CP852 `Ë` byte + Intl NBSP (found on a real printout)
A printed exit voucher (photo from the booth) surfaced three glitches in the new payment receipt, all fixed in `printer-escpos.ts`. (1) **Title garbled**: uppercase `Ë` was mapped to CP852 `0xEB` — wrong (that's `ű`); the correct byte is `0xD3` (U+00CB). `BILETË DALJE`/`FATURË PAGESE` now print correctly (lowercase `ë`=0x89 was always fine). (2) **`1000?Lekë`**: `Intl.NumberFormat("sq-AL", currency:"ALL")` separates amount from currency with a NO-BREAK SPACE (U+00A0; some locales U+202F narrow NBSP), which isn't in CP852 → printed as `?`. `line()` now normalises U+00A0/U+202F → plain space before encoding, so any Intl-formatted value on a ticket is safe, not just money. (3) **Grace line wrapped mid-word** ("…në dali / 8."): split `graceLine` into two short lines (`graceLines`) that each fit 80mm, emitted as two centered line() calls. Re-rendered & byte-verified: 0xD3 present, no 0x3f (`?`) byte, two clean grace lines, `1000 Lekë`. Full build green. Documented the CP852 gotchas in [[rongta-printer]]. NB: verify CP852 bytes against Unicode.org CP852.TXT, never guess. No schema/event-chain change.
## [2026-06-18] feat | Dynamic RBAC — composable roles + resource×CRUD permissions
Replaced the hardcoded role enum (admin/operator/cashier/readonly, checked literally as requireRole("admin",...) across ~15 routes) with DYNAMIC RBAC: roles are DATA, route guards check a PERMISSION. @parking/shared now defines a code-defined grid — RESOURCES (user/role/tariff/subscription/site/device/shift/payment/session/event/report) × Action (create/read/update/delete + domain verbs void/cash) → PERMISSIONS (resource:action). DB: new `roles` + `role_permissions` tables; users.role enum → role_id FK; migration 0007_rbac (create tables, seed builtin admin role + all 26 perms, seed operator/cashier/readonly composable roles matching old behaviour, rebuild users to swap the column copying all rows). auth.ts: JWT payload role→roleId; permissionsFor(roleId) with in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler (jwtVerify+CSRF+perm check); requireAuth for /me & /language; initAuth(db) wires the resolver once in buildServer. Every route guard mapped to a permission (tariff:read/update, payment:create/read, session:read, shift:read/create/cash, site:read/update, device:read, subscription:*, event:read; ws→report:read); device ingress (devices.ts/qr-reader.ts) stays auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated against the grid, cache bump on write). auth/me + /login now return {roleId, roleName, permissions, language}. seed-admin.mjs → roleId:'admin'. Frontend: api.ts SessionUser carries permissions + can() helper + users/roles CRUD fns; router.tsx nav/route guards gate by permission (requirePerm factory replaces adminOnly), SiteSettings edit now gated by site:update; new UsersManager.tsx + RolesManager.tsx (permission checkbox grid; admin role read-only/locked); i18n nav.users/roles + users/roles blocks (sq+en, parity green). DECISIONS (with user): one role per user; protected built-in admin (no-lockout); JWT carries roleId, perms resolved per-request (role edits apply immediately). VERIFIED: full monorepo build green; a 20-assertion inject test (cashier 403s on tariff publish + user list, admin passes, granting tariff:update to the cashier role applies on the NEXT request = cache invalidation works, last-admin delete/downgrade → 409, builtin admin role edit/delete → 409) all pass; migration 0007 applied to a COPY of the live DB (incl WAL/shm) → existing admin maps to role_id='admin', 4 roles seeded, 26 admin perms, all user rows preserved. Updated [[local-jwt-auth]]. Append-only event chain untouched (event:void gates appending a void, not a delete).
## [2026-06-19] feat | Explainable activity log — reasons, subscriber names, snapshot gaps
The booth live-feed flagged anomalies with no explanation (a red row + an id). Made events self-describing + clickable. (1) **Localized reason codes (backend i18n for the signed ledger):** reasons were free-text English baked into the immutable signed `payload.reason` → unlocalizable at render time. Now the ledger signs a stable `reasonCode` + `reasonParams` (+ English fallback) from a closed `REASON_CODES` set in @parking/shared (entry.*/exit.*/sub.* groups + `reasonPayload()` helper; emitted from entry/exit/subscription flows). UI translates `reason.<code>` via sq/en catalogs — an Albanian operator reads Albanian from the SAME immutable event; adding a language = catalog change, no re-signing. Legacy events show the signed English fallback. (2) **Clickable rows → event-detail modal:** humanized labelled fields (not raw JSON) + entry/exit snapshots + signed-chain provenance (signature/keyId/prevHash) collapsed behind an "audit data" disclosure. Rationale for showing signatures: makes tamper-evidence visible vs the booth-operator threat, survives a signer swap. (3) **Subscriber names:** a SUBSESS-… occurrence renders the holder name (fallback "Abonent"/"Subscriber"), resolved read-time server-side (events API + WS push attach a non-signed `subscriberLabel` from permitId→holder_name; cached, invalidated on sub edit/delete). (4) **Failed-snapshot visibility:** the snapshots API returns failures[] from telemetry; UI shows a "⚠ camera unreachable" tile so a missing image isn't a silent gap (surfaced a real EHOSTUNREACH on a subscriber entry camera; by design snapshot = evidence not gate, so the open proceeded). Committed f31e57b. Updated [[booth-console]], [[i18n]].
## [2026-06-19] feat | Human + relative dates across UI and printed slips
Dates were raw ISO on paper and time-only in the UI (a 2-day-old session showed just "10:48"). (1) **Printed slips** (tickets/receipts/subscription cards): `stamp()` now formats "19 Qershor 2026 10:48:25" (Albanian month, 24h+seconds) via a hardcoded `SQ_MONTHS` table; exported as `formatStampSq` so the shift Z-report shares it. (2) **Z-report** is now fully Albanian (Operatori/Nga/Deri/Para në dorë/-- Arka --/Arka e pritur…), was English-only with ISO dates. (3) **Web** sessions/logs/history: `formatRelativeDateTime` → "Sot/Today 10:48" / "Dje/Yesterday 17:33" / "17 Qershor/June 10:48". GOTCHA: the appliance browser's ICU has NO Albanian locale data — `Intl.DateTimeFormat("sq",{month:"long"})` returns English ("June"), so month names come from a `common.months` catalog array, not Intl. Also FIXED a latent bug: the SQ/EN + dark/light toggles read the active value from TanStack Router context (`useRouteContext()`), which is captured at route-resolution and does NOT re-render on setUser — so after one switch the highlight froze + switching back was blocked until a page refresh. Now driven off live state (language from i18n.language via useTranslation; theme from local useState). Committed 00f3d14. Updated [[shift]], [[i18n]].
## [2026-06-19] fix | KP-300H barcode line-overflow — ticket id 13→11 digits
The Cashino KP-300H entry dispenser printed entry tickets as RASTER GARBAGE (solid black bars/banding) while the Rongta printed the IDENTICAL byte stream fine. Diagnosed on hardware: plain-text-only prints were clean → isolated to the `GS k` Code128 barcode. ROOT CAUSE = barcode line-overflow, not corruption: a Code128-B symbol is (11·chars+35)·moduleWidth dots; the old 13-digit id at module width 3 = ~534 dots OVERRAN the KP-300H's 72mm line (512 usable dots @ 203 dpi). The Rongta runs 80mm (576 dots) and had just enough room — why only the Cashino failed. FIX: shorten the ticket id 13→11 digits (10 random + Luhn) → ~468 dots, fits 72mm; scanned the full value at the exit reader (verified). Length is driven by GUESS-RESISTANCE not volume (10^10 space, ~1-in-10^7 to hit a live open ticket vs the booth-operator threat); chose 11 over the requested 9 (10^8 → ~1-in-10^5, too weak). validateTicketCode made length-agnostic (\d{10,14}+Luhn) so legacy 13-digit tickets still validate. NB: module width must stay 3 — a width-2 test scanned but returned TRUNCATED values (partial reads logged as exit.refused.noSession anomalies). Also fixed a separate latent transport bug in sendRaw: write-then-destroy could RST mid-stream (the write callback ≠ peer-flushed) and truncate a job; now end(payload)+FIN, resolve on socket `close`, timeout-after-write = success. NOT the cause of the garbage but a real risk. Committed bbf61c4. Updated [[ticket-encoding]], [[rongta-printer]].
## [2026-06-19] feat | Snapshots on refused entry/exit + subscriber access medium in the activity log
Two booth-evidence gaps closed. (1) **Refused entry/exit now snapshot.** Originally only the OPEN paths fired the directional camera; refusal/hold anomalies didn't — yet a turned-away car is exactly the evidence an operator/auditor wants (fraud/dispute signal). Added `#fireSnapshot` to every refusal: entry refused-full + held-no-ticket (a refused entry has no ticket id, so mint a synthetic `REFUSED-…` ref to key the anomaly + photo together), exit refused closed/no-session/unpaid/grace-expired (BOTH booth `exitForBooth` and reader `#runExit` paths), and refused [[subscription]] (the lane the reader sits at — `resolved.direction`, "both"→entry — picks the camera). Same fire-and-forget contract: a refusal is never delayed/blocked by a camera; failed captures still surface as "⚠ camera unreachable" tiles. (2) **Subscriber access medium (`via`) surfaced.** The subscription flow already SIGNED `via` (`"qr"|"card"|"plate"`) into the entry/exit payload but the activity log never showed it. Added it as a typed `LedgerPayload.via` field, a cyan chip in the ticker, and an "Entry medium / Mënyra e hyrjes" row in the detail modal (QR code / RFID card·chip / plate, localized sq+en) — a lost-card investigation can now see which credential opened a barrier. Display-only, no re-signing. Refused-subscription anomalies also now carry `via`. Build+lint green. Updated [[entry-exit-points]], [[booth-console]].
## [2026-06-19] feat | One car = one ticket (entry anti-double-press) + refusal snapshots + subscriber via
FLAW found: the entry button could be pressed without limit — each press minted a fresh ticket + signed vehicle_entry, corrupting occupancy (one car counts as many) and letting a transient SHOP the cheapest ticket at exit. The old `#inFlight` guard only blocked OVERLAPPING presses (released in finally). FIX is per-relay config (`config.relays[]`), mode chosen by available barrier feedback: (1) PRESENCE (preferred) — `presenceInput` ties ticketing to a vehicle loop on a Dingtian input; a press prints only with a car present, and NO second ticket until the loop CLEARS (car drove in) and a new car re-occupies it → physical one-car-one-ticket; (2) COOLDOWN (fallback, no feedback) — `entryCooldownSec` suppresses repeat presses for N seconds (a timer, mitigation not guarantee). New `relayForPresence()` resolves a loop edge to its entry relay; `EntryFlow` keeps a per-relay `#guard` map (present/armed), disarms on PRINT success, re-arms on loop clear. A suppressed press = UNSIGNED device_events telemetry (entrySuppressed:true), NOT a signed anomaly (operator's call — it's a correct no-op, not fraud). SetupWizard relay editor exposes Presence-loop + Cooldown fields (sq+en). Fail-closed entry + barrier-is-not-a-door invariants untouched; guard state is in-memory/rebuildable, starts armed after restart (safe default). New page [[entry-double-press]]; updated [[entry-exit-points]], index. Build+lint green. (Bundled with this session's earlier refusal-snapshots + subscriber-`via` work.)
## [2026-06-19] feat | Application logs — backend pino DB sink + frontend error collection (app_logs)
Added a THIRD data stream (`app_logs`) alongside the signed ledger and device telemetry — operational/diagnostic logs, since an OFFLINE appliance has no Sentry to ship to. BACKEND: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only — no bloat) with NO call-site change; the DB is now built BEFORE Fastify so the logger stream has its sink. FRONTEND (lib/logger.ts): always ships failed API requests (apiFetch non-OK path, minus 401 pre-login churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary (render crash → fatal, not a white screen); console.warn/error forwarded ONLY at client debug/trace level (noisy otherwise). Batched/throttled POST, flush via raw fetch + sendBeacon on pagehide. Reliability invariants: never log the /api/logs call itself (loop guard), LogService reentrancy guard, all writes best-effort/swallowed, bounded queue + clamped rows. API: POST /api/logs (any signed-in user, CSRF, tolerant — never 4xx on a bad entry) + GET /api/logs gated by a NEW `log:read` permission (new `log` resource in the RBAC grid; admin holds it). Retention: pruned by age (LOG_RETENTION_DAYS=30) AND row cap (MAX_ROWS=50k), hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack, 15s poll), sq+en. DB migration 0009_app_logs (+journal idx 9, seeds admin log:read) applied to the live apps/server DB. 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 (the intended split). Build+lint green. New page [[app-logs]]; updated [[event-streams-split]], [[device-events]], index.
## [2026-06-19] query | ANPR recognizer options — fast-alpr evaluated as the baseline
Q: LPR/ANPR options — YOLO, OpenCV, both, another framework? Reframed: "YOLO vs OpenCV" is a category error — they're different pipeline LAYERS (YOLO = plate detector; OpenCV = Apache-2.0 image-handling glue, used regardless; plus an OCR stage). The real choice is which end-to-end recognizer. Researched [fast-alpr](https://github.com/ankandrew/fast-alpr) (latest **v0.4.0, 15 Mar 2026, MIT**): a thin orchestrator over two swappable ONNX stages — detection via [open-image-models](https://github.com/ankandrew/open-image-models) (`yolo-v9-t-384-license-plate-end2end`, MIT) + OCR via [fast-plate-ocr](https://github.com/ankandrew/fast-plate-ocr) (`cct-xs-v2-global-model`, MIT; also has a EUROPEAN model trained on 40+ countries — relevant for AL plates). MIT top-to-bottom (code AND published weights), one maintainer across all three repos, CPU-only + fully offline, backend extras for CPU/CUDA/OpenVINO/DirectML/QNN. KEY FINDING: its detector is open-image-models' OWN YOLOv9 ONNX export, NOT the Ultralytics AGPL package — so fast-alpr is a PERMISSIVE baseline that may not even need the scoped AGPL exception from [[vision-service]]. CAVEAT (flagged, not closed): a repo's LICENSE covers code, not necessarily redistributed model WEIGHTS (YOLOv9 upstream is GPL-3.0; Ultralytics YOLO AGPL) — verify weight provenance before relying on "MIT weights". fast-alpr is PLATE-ONLY → Job 2 (vehicle-attribute anti-spoofing) is still ours to build, but shares the same ONNX runtime. Recommendation: prototype fast-alpr now; Ultralytics-YOLO+PaddleOCR fine-tune only if accuracy disappoints. Recorded as an evaluated-options note; decision kept status:open pending the provenance check + an AL-plate accuracy benchmark. Updated [[opencv-anpr-service]] (new "Recognizer evaluation" section + licensing nuance), [[vision-service]] (open/next), index.
## [2026-06-19] decision | Vision service packaging — apps/vision/ in this monorepo, Turbo shim
Q: how to IMPLEMENT the vision service — can we use this Turborepo? Settled (status:settled): the Python/FastAPI ANPR service lives in THIS monorepo at `apps/vision/`, NOT a separate repo. Key clarification: Turbo orchestrates JS/TS package.json TASKS (+ caches outputs); it has no native Python build — but "in the repo" ≠ "in the Turbo graph", and "separate process" ≠ "separate repo". Decision: (1) co-locate source at apps/vision/ (pnpm-workspace already globs apps/*, so it auto-joins) for atomic cross-cutting changes (the /analyze contract + the Node adapter together), one wiki/history; (2) still a SEPARATE OS process (uvicorn over localhost HTTP) — co-location is source-level only, runtime isolation intact; (3) wire into Turbo via a THIN package.json shim whose scripts shell to Python (dev→uv run uvicorn, lint→ruff, test→pytest, build→no-op/model-fetch since Python has no dist/**), so `turbo run lint/test` covers vision too — deps stay uv/pyproject, not pnpm; (4) Node talks to it via a VisionClient interface (device-adapter style), swappable. WHY co-location honors the [[vision-service]] isolation decision: that decision is about RUNTIME + LICENSE isolation (separate process; AGPL doesn't reach Node because it's not LINKED, just HTTP) — AGPL's reach is a linking/distribution-boundary question, NOT a which-folder question. And with the MIT-end-to-end [[opencv-anpr-service|fast-alpr]] baseline the AGPL pressure to split the repo largely evaporates anyway. Rejected: separate repo (loses atomic changes; fallback if AGPL acute or another team owns it), embed-in-Node (already rejected by vision-service), packages/ (that's for shared JS libs, vision is a deployable app). NOT built yet — packaging decision only; scaffold when vision work starts. New page [[vision-service-packaging]]; updated [[vision-service]], [[opencv-anpr-service]], CLAUDE.md layout, index.
## [2026-06-19] build | Scaffold apps/vision (ANPR microservice skeleton)
Scaffolded the [[opencv-anpr-service|vision service]] per [[vision-service-packaging]]: `apps/vision/` Python/FastAPI, uv-managed, wired into Turbo via a thin package.json shim. Structure: pyproject.toml (light core: fastapi/uvicorn/pydantic; HEAVY recognizer = optional `alpr` extra = fast-alpr+onnxruntime, so `uv sync`+tests run OFFLINE in stub mode with no model download), per-package turbo.json (extends ["//"], build outputs [] → warning-free no-op), .gitignore (venv/caches/*.onnx/models out). vision_service/: app.py (GET /health + POST /analyze, raw octet-stream body so Node POSTs Snapshot.bytes directly; empty→400, oversize→413, not-ready→503), settings.py (env VISION_*), schemas.py (the /analyze contract + a not-yet-populated `vehicle` field for Job 2), recognizer.py (a Recognizer Protocol + StubRecognizer/FastAlprRecognizer — the device-adapter pattern applied to the model; fast-alpr imported lazily so missing models ⇒ ready=False, not a crash). VERIFIED: turbo run lint|test|build includes @parking/vision (ruff/pytest/no-op shim) green; uv run mypy strict-clean; uvicorn boots + serves /health (ready, stub-0) and /analyze (contract shape) live; pnpm workspace 6→7. NOT built: the Node VisionClient adapter, a Dockerfile + model fetch, and Job 2 (vehicle verification). Updated [[vision-service-packaging]] (As-scaffolded section), CLAUDE.md layout already lists apps/vision.
## [2026-06-19] query | Albanian-plate OCR benchmark — keep the default (cct-xs-v2-global)
Benchmarked fast-alpr's four candidate fast-plate-ocr models via the FULL pipeline (YOLOv9 detect → OCR) on real AL plate photos (Wikimedia: AA558EE, AA687KE), CPU, scaffolded apps/vision service. ALL FOUR read both plates correctly; the differentiator is confidence + speed: cct-xs-v2-global (default) 0.999/1.000 @ 33–39ms AND returns region=Albania; cct-s-v2-global same accuracy ~50% slower; global-mobile-vit ~0.955 fast; european-mobile-vit-v2 (the "40+ country EU" model) correct but MUCH lower confidence (~0.77) and misread a synthetic AB123FG→AB123FO. FINDING (overturns the "EU model → better for AL" assumption from the prior research turn): the global cct-xs default WINS for Albania — most accurate AND fastest. Decision: no config change, VISION_OCR_MODEL stays cct-xs-v2-global-model. Caveat: test photos were clean head-on shots; real booth captures (angle/night/dirt/blur) will lower confidence — the min_confidence=0.5 floor → low_confidence → ticket-path fallback covers it; re-benchmark on on-site captures once cameras installed. Resolves the AL-accuracy-benchmark open item in [[opencv-anpr-service]] (added a results table + the keep-default finding); the weight-provenance check remains the one open recognizer item.
## [2026-06-19] query | Vision service fitness for entry/exit flows — advisory YES, sole-authority NO
Q: is the scaffolded ANPR service worthy to consume in entry/exit flows? Assessment recorded in [[opencv-anpr-service]] ("Fitness for the entry/exit flows"). Benchmark settled ACCURACY (0.99+ clean AL plates); "worthy" turns on AUTHORITY. Split verdict: (✅) worthy NOW as an ADVISORY identity source (Job 1) — the flows are ALREADY built for a plate (kind:"plate" read is first-class: exit-flow signs source:"lpr"; subscription-flow matches read plate vs subscriptionPlates), so the service just produces the plate string → DeviceReadEvent{kind:"plate"} on the existing read bus; no flow rewrite. Worthy for hands-free subscriber open + evidence enrichment. (⚠️) NOT worthy as SOLE AUTHORITY to open a TRANSIENT barrier: a plate ≠ payment (would be an unpaid-exit bypass; min_confidence floor → ticket/manual fallback is the guard) and plate-spoofing (printed plate, different car) needs Job 2 vehicle-verification which is NOT built. Gaps before consuming: (1) the Node VisionClient adapter (real integration work), (2) trigger wiring — snapshots fire AFTER open today (evidence); plate-as-identity needs a snapshot BEFORE the decision on a per-camera opt-in lane, (3) field accuracy unknown (re-tune threshold on on-site captures), (4) weight-provenance check. Next step: VisionClient adapter + opt-in trigger, not more model work. (Scaffolding VisionClient next.)
## [2026-06-19] feat | VisionClient Node adapter (apps/server/src/vision-client.ts)
Scaffolded the Node-side adapter to the host vision microservice per the fitness assessment. VisionClient calls apps/vision over localhost HTTP (POST /analyze with snapshot Buffer bytes, GET /health), returning a normalised/camelCased VisionResult (best plate + all plates + lowConfidence + modelVersion + tookMs) or null. THREE guardrails enforce "advisory, never sole authority" at the boundary: (1) OPT-IN — VISION_ENABLED (default OFF), so the appliance runs with no vision service; (2) FAIL-SOFT — disabled/unreachable/timeout/non-2xx/bad-body all resolve to null and NEVER throw into the entry/exit path (→ ticket/manual fallback, never strand a car); (3) CONFIDENCE FLOOR re-applied (VISION_MIN_CONFIDENCE) on top of the service's own low_confidence flag. Per-request AbortController timeout (VISION_TIMEOUT_MS, default 1500ms) so a slow call can't hang the lane. Constructed in server.ts (logs when enabled). VERIFIED: fail-soft (disabled→null, unreachable→null no-throw) and LIVE end-to-end (Node client → running fast_alpr service → AA558EE 0.999 region=Albania, camelCased). NOT yet wired into the read bus — the opt-in snapshot-before-decision trigger that emits DeviceReadEvent{kind:"plate"} is the next deliberate step. Build+lint green. Updated [[opencv-anpr-service]] (gap 1 marked done).
## [2026-06-19] feat | VisionReader — wire ANPR into the read bus (apps/server/src/vision-reader.ts)
Wired the vision service into the entry/exit flows via the READ BUS. VisionReader polls each OPT-IN camera (config.anpr===true, off by default) every VISION_POLL_MS, captures a snapshot → VisionClient.analyze → on a CONFIDENT plate calls deviceEvents.emitRead({kind:"plate", value:PLATE, deviceId, driverId}) — the SAME event a physical plate reader emits, so the existing ReadDispatcher routes it to the subscription/exit flow UNCHANGED (no flow rewrite). The plate stays ADVISORY by construction: the exit flow still demands a covering payment (a plate can't bypass it), the subscription flow only matches a BOUND plate (subscriptionPlates). Guards: low-confidence reads DROPPED (a shaky read isn't an identity); DEBOUNCE (VISION_DEDUPE_MS, default 15s) so a parked car in frame doesn't re-fire the same plate; per-camera in-flight guard; idle when VISION_ENABLED off or no camera opts in; #recognizeOn is public for a future on-demand trigger (loop edge / API). Direction from directionOf (both→entry context). Constructed in server.ts, start on onReady / stop on onClose. VERIFIED END-TO-END: in-memory anpr camera returning the AL plate image + live fast_alpr service → VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} read onto the bus; debounce held it to 1 emit over 7 polls. Build+lint green. Updated [[opencv-anpr-service]] (trigger-wiring gap + per-camera opt-in marked done). Remaining: SetupWizard anpr toggle, field tuning, weight-provenance, Job 2.
## [2026-06-19] feat | Persist every recognized plate + snapshot (ANPR audit trail, non-blocking)
VisionReader now PERSISTS every confident plate read so a recognition is investigable — and switched from emitRead to calling ReadDispatcher.dispatch directly (like qr-reader) to capture the OUTCOME. On a confident plate it: (1) stores the SNAPSHOT bytes in `snapshots` keyed by identity=PLATE — the SAME identity the flow signs its anomaly/event with — so GET /api/snapshots/by-identity/:plate (the booth event-detail modal's SnapshotStrip, which already uses e.identity) shows the car's photo against that anomaly with ZERO UI changes; (2) records an unsigned device_events{kind:"read"} breadcrumb with plate/confidence/region/modelVersion/snapshotId + the dispatch outcome (accepted + reason) = a queryable ANPR log independent of the signed ledger; (3) dispatches the read — NON-BLOCKING: a refused read just returns rejected (no barrier hold), logged with its snapshot for investigation. Plate stays advisory (exit demands payment; subscription matches only a bound plate). VERIFIED e2e: a recognized AL plate (AA558EE) with no open session → signed exit.refused.noSession anomaly (identity=plate), stored a 555KB snapshot under that plate, read-breadcrumb accepted:false reason:"no open session", and by-identity returned the image (status 200, 1 snapshot) → the refused read is investigable with its picture. Build+lint green. VisionReader constructor now takes the ReadDispatcher (wired in server.ts). Answers "is a recognized plate saved?" — now YES for both transient + subscriber, as telemetry + evidence image, regardless of match. Updated [[opencv-anpr-service]].
## [2026-06-19] feat | Vision service configuration — SetupWizard ANPR toggle, footer health chip, .env.example
Made the vision service genuinely configurable (was env-only). THREE additions: (1) SetupWizard CAMERA form now has an "ANPR / Njohja e targave" checkbox (writes config.anpr; only persisted when on; sq+en) — opt-in is no longer raw JSON. (2) DeviceMonitor now optionally takes the VisionClient and probes its /health each tick, emitting a "vision" pseudo-device status (id vision-service, category "vision" — widened the DeviceStatusEvent + frontend DeviceStatus category unions + the footer CATEGORY_KEY/ORDER maps + devices.catVision sq/en) → a "Vision · ready/degraded/offline" chip in the booth footer; NO chip when VISION_ENABLED off. Verified: emits ready/fast_alpr when up, 0 chips when disabled. (3) apps/vision/.env.example (the Python service env) + VISION_* block appended to apps/server/.env.example (the Node side) + a "Configuration" section in [[opencv-anpr-service]] documenting all FOUR layers (python env / node env / per-camera anpr+binding / footer health) and the caveats: the two processes SHARE the VISION_ prefix but need SEPARATE .env files; bind /analyze to 127.0.0.1 (Node is the only caller); models download on first run so cache at deploy; an unbound anpr camera recognizes but every read is refused. Build+lint green. Updated [[opencv-anpr-service]] (Configuration section; SetupWizard-toggle gap closed), vision README.
## [2026-06-19] refactor | ANPR rides the entry/exit snapshot (replaces polling VisionReader)
Reworked the ANPR TRIGGER per the real design goal: when a transient pushes the button or a subscriber passes QR/RFID, the entry/exit fires and takes the evidence snapshot — THAT is the moment to recognize the plate, off the SAME image, tied to the SAME session. So snapshotAsync now takes the VisionClient and, after storing each snapshot from an opt-in (config.anpr) camera, runs ANPR on shot.bytes and records the plate against the session identity (device_events kind:"read" with plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both evidence + plate extraction; recognition fires ONLY on a real entry/exit — NO POLLING. The flows (entry/exit/subscription) now take an optional VisionClient and pass it through; server.ts wires it. REMOVED the polling VisionReader (vision-reader.ts deleted) + VISION_POLL_MS/VISION_DEDUPE_MS env. Advisory + fire-and-forget: low-confidence/no-plate records nothing, a vision failure never delays/changes the open, the plate does NOT feed the access decision (the flow already decided) — it's a record ("session X entered on plate AA558EE"). VERIFIED e2e: a simulated entry snapshot on an anpr camera (live fast_alpr) stored the snapshot for session TICKET-SG-1 AND recorded {identity:TICKET-SG-1, plate:AA558EE, confidence:0.999, region:Albania, snapshotId:…}. Build+lint green. Updated [[opencv-anpr-service]] (trigger section + Configuration, polling refs removed) + both .env.example.
## [2026-06-19] feat | Surface recognized plate in the booth UI (SnapshotStrip)
Made the ANPR plate VIEWABLE (it was saved but had no UI). Extended GET /api/snapshots/by-identity/:identity to also query device_events kind:"read" for that identity and return plates[] (plate, confidence, region, direction, snapshotId, at) alongside the existing snapshots + failures. The SnapshotStrip now renders each recognized plate as a cyan "Plate: AA558EE 100%" chip above the images (deduped by plate+direction; title shows region + time) — so it appears in BOTH the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. session:read gated (same as snapshots). i18n pay.plate sq+en. VERIFIED: by-identity returns plates[] for a seeded read (status 200, {plate:AA558EE, confidence:0.999, region:Albania, direction:entry, snapshotId}). Build+lint green. Updated [[opencv-anpr-service]].
## [2026-06-20] feat | Flag stuck sessions + booth filters (session list & live feed)
Replaced the silent paid age-out in PayStation.activeSessions() with a derived `stuck` flag: an
open + paid + past-grace session with no signed vehicle_exit is no longer dropped — it stays listed
with a red "stuck" badge so the operator can reconcile (top-up exit / void). Root cause surfaced via
the live ledger: 4 such orphans (5717802544704, 1245791632490, 7985713986045, 9340902468934) each
entry=1/exit=0/pay=1, grace=5min lapsed; they linger in the occupancy fold (so occupancy diverges
from the active-list count) and a re-scan re-quotes the tariff from entry (paid customer charged
again). Signed log untouched; subscriptions never stuck (no paidAt). This removed the earlier
unbounded "presumed-left (N)" counter (occupancy − sessions), which had been growing.
Added a shared client-side FilterBar (ui/FilterBar.tsx: search + SegGroup toggles, matched/total
count). Active Sessions: search (ticket/holder) + status (unpaid/paid/exiting/stuck) + transient-vs-
subscriber. Live feed: search (identity/subscriber/plate) + event (entry/exit/pay/void/anomaly) +
direction (entry/exit) + source (booth=manual vs reader=device). No new API. Exit grace re-scan
logic UNCHANGED (still refuse + send to booth — user choice). Build+lint green across the monorepo.
Updated [[booth-exit-flow]].
## [2026-06-20] fix | No free exit on overstay (stuck session) + top-up pricing
SECURITY FIX correcting same-day stuck-flag work. A stuck session (paid + walk-back grace expired +
no signed exit) is AMBIGUOUS — the car may have left OR be overstaying inside. The first cut kept the
"Open barrier" button on these rows (gated on paidAt != null), which would let an operator wave out a
2-day overstay for free — the operator-as-adversary path. Fix: reopenBarrier now refuses a transient
whose payment grace has expired (allow only subscription OR paid-and-within-grace), enforced
server-side (exit-flow.ts), mirrored in the UI (no button on s.stuck → routes to pay/exit modal).
Verified against the live ledger: ticket 5717802544704 (entered 73.9h ago, paid 200000, grace 5min)
computes stuck=true and reopenBarrier REFUSES it.
Top-up pricing — "full stay minus paid" (user choice): quote() now returns grossMinor (whole stay
entry→now) and paidMinor (fold of prior signed payment amounts), with amountMinor = max(0, gross −
paid) — the delta only, never the full stay twice. lookup()/SessionLookup gained `stuck`; the pay
modal shows OVERSTAY status + "Top-up due" + a hint, and canPay now allows payment for a stuck
session. Taking the top-up restarts grace so the car exits normally. i18n pay.overstay/overstayHint/
topUp in sq+en. Partially resolves the grace-overstay Open question (the amount); whether to bill the
overstay delta-from-grace vs full-minus-paid left open. Build+lint green. Updated [[booth-exit-flow]].
## [2026-06-20] fix | Rename stuck→overstay + price overstay as a NEW period (fixes ALL 0)
Two user-driven corrections to the same-day overstay work. (1) NAMING: "stuck"/"i ngecur" wrongly
implied a system fault trapping the customer — but a paid-then-grace-expired car means a NEW parking
period began (re-parked) or the car is faulty/abandoned. Renamed the flag + badge + filter +
SessionLookup/ActiveSession field to `overstay` / "tej afatit" across server + web + i18n.
(2) PRICING BUG: "full stay minus paid" collapsed to 0 under a daily cap — ticket 1245791632490
(entered 06-17, paid 330000, cap 100000/day) had gross=330000, so delta=0 → "Diferenca për pagesë
ALL 0", a free multi-day exit. Fix (user choice): quote() now prices an overstay as a NEW period
anchored at grace-expiry (paidAt+graceExitMin)→now with its own daily-cap ladder, NOT entry→now. The
tariff version stays the one frozen at entry. Quote gained periodStart + overstay; removed
grossMinor/paidMinor. Verified: 1245791632490 now owes 20000 ALL (first half-hour of overstay), not 0.
pay modal: "New period due"/OVERSTAY; handlePayAndExit now charges when canPay (was: only if
!alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp +
booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]]
(overstay section + naming history + partial-resolution note on the grace-renewal open question).
## [2026-06-20] feat | Tariff Lab — pure session-pricing simulator (test rates in time)
The tariff engine is pure but could only be EXERCISED by waiting (booth reads real
wall-clock). Added a simulator. Extracted priceSession(enteredAt, asOf, structure,
payments[], category?) into @parking/shared — the grace/overstay wrapper over
computeFee (unpaid→entry→now; within-grace→settled 0; grace-expired→overstay new
period from grace-expiry). PayStation.quote() now calls it, so booth + lab can't
diverge. New routes (tariffs.ts, tariff:read, no ledger writes): POST
/api/tariff/simulate (price a hypothetical session vs active/any version/inline
structure; returns priceSession outcome + a 30m..3d duration curve) and GET
/api/tariff/simulate/session/:identity (prefill from a real ledger session). UI
apps/web/src/TariffLab.tsx at Setup→"Tariff Lab": version picker, entry/asOf, optional
payment+grace, category, load-a-ticket; shows amount due, billed period, overstay/
settled, curve. i18n lab.* + nav.tariffLab (sq+en). 4 new priceSession unit tests
incl. the ticket-1245791632490 overstay-not-zero regression (40 tests pass). Verified
live via the real UI: a 3h stay → ALL 3,000, curve shows the daily cap flattening at
6h and multi-day stepping; ticket-load returned a real session. Build+lint green.
Updated [[tariff]] + [[booth-exit-flow]].
## [2026-06-20] feat | Stepped ("up-to") tariff mode — total-by-duration pricing
The owner needed a total-by-duration matrix (0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900,
0-12h=1000) the marginal hourly ladder CANNOT express (ladder sums per-increment
rates; this is cumulative totals at thresholds). Added STEPPED pricing as a third
mode alongside ladder + flat. New TariffStep{uptoMin,totalMinor} + steps[] on V1
structures and V2 cards (mutually exclusive w/ blocks/flatMinor). Engine steppedFee():
smallest tier with uptoMin>=duration wins (INCLUSIVE <=), top tier repeats as per-day
cap; wired into computeFeeV1 + computeFeeV2 (V2 defaultCard only — a whole-stay total
can't be sliced by a windowed card). Validation: ascending uptoMin, non-neg totals, no
dailyCap-with-steps, steps-only-on-default. priceSession/quote/booth/Lab all price it
via the shared core (no extra wiring). Composer UI: "By duration (up-to)" radio +
up-to/total table (base card only); i18n modeStepped/steppedHint/stepUpTo/stepTotal/
addStep (sq+en). 8 new unit tests incl. the exact owner matrix + multi-day + overstay
+ validation (53 pass). VERIFIED end-to-end via the real UI: authored the matrix in the
composer, published, Tariff Lab priced it exactly (3h->500, 6h->800, 12h->1000,
2d->2000). Build+lint green. Updated [[tariff]].
## [2026-06-20] fix | Reject stepped base + time tiers (silently-ignored tiers)
Found live: the active tariff had a STEPPED ("up-to") base card AND two windowed tiers
(weekday-night "Nata gjate javes", weekend "Fundjava"). computeFeeV2 short-circuits to
steppedFee on a stepped default card, so the tiers NEVER fired — a 3h stay was 600 ALL
at every hour/day. The composer happily let this contradictory combo be built + published.
Fix: validateTariffV2 now rejects a stepped defaultCard combined with windowedCards
(clear message: switch base to ladder/flat or remove tiers); the composer shows an inline
red warning when base.mode==="stepped" && tiers>0. Also: ApiError now carries the
server's problems[] so the publish error shows the SPECIFIC reason (was generic "invalid
tariff structure"). 2 new validation tests (55 pass). Verified live: warning renders +
publish blocked with the full message. Build+lint green. Updated [[tariff]].
## [2026-06-20] query | "Tariff Lab wrong: weekend 3h shows 600, expected 300"
NOT a bug — the engine was correct. The active tariff's billing increment is 30 min,
and `priceMinorPerIncrement` is PER INCREMENT, not per hour. The Fundjava (weekend) tier
DID apply (traced: every increment of the Saturday stay selected the Fundjava card), but
it bills 100 per 30-min increment = 200/hour, so 3h = 6 increments x 100 = 600. To get
300, set the price to 50/increment OR the increment to 60 min. This per-increment-vs-per-
hour confusion has recurred; documented it as a ⚠ callout in [[tariff]] and filed a
per-hour-preview composer UX idea under Open. No code change.
## [2026-06-20] fix | Subscription sale was off the books — append a signed payment
Operator-reported [[threat-model]] hole: creating a priced [[subscription]] wrote ONLY the
mutable `subscriptions` master row and appended NOTHING to the signed ledger. The cash the
operator collected (e.g. 10,000 ALL) showed in the live feed / drawer / Z-report nowhere —
a clean off-book channel. Confirmed live on the appliance: three priced subscriptions
(27,000 ALL sold) had ZERO payment events. This is the canonical booth-operator-as-adversary
path the [[append-only-event-chain]] exists to close; the "collect-in-shift later" deferral
(decided 2026-06-18) had left it open.
Fix: selling a priced subscription now appends a signed `payment` event (the long-planned
plain-`payment`-not-new-type choice, resolved) at create time — amount = `priceMinor x
months` (full multi-month prepay), operator-chosen tender (cash->drawer / card->bank),
payload `{ subscriptionSale: true, permitId, operator, months }`. Folds into the [[shift]]
Z-report/drawer with no new summing logic; the live feed badges it "subscription sale" and
resolves the holder name. NOT hard-gated on an open shift (a sale can happen outside the
booth money path) — it warns instead; flagged as a remaining sub-question. The 3 historical
off-book sales are NOT back-fillable (append-only forbids forging dated events) —
reconcile via `cash_movement` / a Z-report note.
Verified against a COPY of the live DB with the real signing modules: signed payment
appended (30,000 ALL, 3-month), hash-chain still verifies, lands in shift cash totals.
Build + lint 12/12. Updated [[subscription]] (Collecting the fee → BUILT; data model;
open-question #3 resolved), [[shift]] (sale folds in), [[threat-model]] (worked example:
"store the price ≠ account for the sale").
## [2026-06-20] feat | Show recognized plate in live feed + active sessions
The advisory ANPR plate (device_events kind="read", keyed by session identity — unsigned,
prunable, NEVER an access decision) is now surfaced next to entry/exit events in the live
feed and on active-session rows. Resolved at serialize time (new `plate-lookup.ts`, prefers
an entry read; one device_events scan for the whole page), like subscriber-name enrichment —
the signed ledger is untouched. Added `plate?` to the shared `LedgerEvent` + `ActiveSession`/
`SessionLookup`; a small amber badge in the UI. Caveat: a `vehicle_entry` is signed + pushed
over WS BEFORE the async ANPR read lands, so a fresh feed row may show no plate until reload;
always present on active sessions. Build + lint 12/12.
## [2026-06-20] feat | Drawer cash re-modelled as directional vouchers (Mandat Arkëtimi / Pagese)
Replaced the single signed-± `cash_movement` (one event, +load/−removal in the sign of an
amount) with two distinct financial documents — the direction is now the event TYPE:
`cash_in` = **Mandat Arkëtimi** (receipt / pay-IN, +) and `cash_out` = **Mandat Pagese**
(disbursement / pay-OUT, −). Each carries a positive magnitude, a voucher number (`AR-NNNN`
/ `PA-NNNN`), reason, the operator who raised it and the admin who authorized it, and prints
an Albanian slip. **Authorization changed**: was admin-only; now **operator-RAISED,
admin-AUTHORIZED** — any `shift:create` holder raises the voucher but `POST /api/cash-voucher`
only commits if `authorizedBy` is a real admin (`shift:cash`) re-entering their password.
Legacy `cash_movement` events are KEPT (still verify, still fold into the drawer signed-±) —
the append-only chain is never rewritten. Drawer fold + Z-report window updated to sum all
three types. Verified against a COPY of the live DB with the real signing modules: cash_in
3000 + cash_out 5000 → drawer −2000, hash-chain still verifies OK. Build + lint 12/12.
Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
not yet built (see [[shift]] Open).
## [2026-06-20] feat | Mid-shift X-report (read-only takings-so-far)
The operator can now see, on demand during an open shift, the opening float inherited,
cash/card collected so far, pay-ins/pay-outs, and the current expected drawer balance —
without closing. `GET /api/shift/report` (shift:read; 204 when no shift open) returns the
SAME projection the Z-report prints, factored into a shared `ShiftService.#summariseWindow
(open, asOf)` so X (asOf=now, read-only) and Z (asOf=endedAt, signed) can't drift. Appends
NOTHING — it's a snapshot, not an accountability mark (the Z at close is the signed record).
UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header
keeps the live drawer total. Verified on a copy of the live DB: matches drawerBalance(),
drawer identity holds (expected = opening + cash + added − removed), 0 events appended, chain
verifies. Build + lint 12/12. Resolves the X-report item flagged the same day in [[shift]].
## [2026-06-20] feat | Subscription plan catalog — config-defined, dated spans, no typed prices
Re-modelled subscription pricing from per-row operator-typed `priceMinor` + monthly-only `period`
into an admin-composed, versioned PLAN CATALOG (the tariff pattern). Plans (`subscription_plans`,
migration 0010) are immutable effective-dated versions keyed by a stable planId, with period ∈
day/week/month + per-period price. The operator SELLS by selecting a plan over a date span (start
defaults to today, end required); the price is LOOKED UP — periods = ceil(span / period), amount =
periods × per-period price (ceil = any started period is full; hotel/parking practice). The hotel
1–N day case is a daily plan over a check-in→check-out span. `POST /api/subscriptions/quote` gives a
live server-computed quote so the operator can't override the amount. New `subscription:plan`
permission (admin-only) composes the catalog; selling stays operator-grade `subscription:create`.
The signed-`payment` sale fix is unchanged — only the amount SOURCE moved to the plan quote; payload
now carries planId/planVersionId/periods. Pure span math lives + is unit-tested in @parking/shared
(68 tests incl. ceil/Jan-31 clamp). New SubscriptionPlansManager screen (Setup tab) + reworked
SubscriptionManager sell form (plan picker + dates + quote, no price field). Verified on a copy of
the live DB: 0010 applies (existing subs intact, monthly plan seeds from site default), a 3-night
hotel sale prices to 2,400 ALL, appends ONE signed payment with planVersionId, chain verifies.
Build + lint 12/12. Updated [[subscription]] (plan catalog supersedes typed price; data model) +
[[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the
first plan.
## [2026-06-20] feat | Subscription v2 — quantity, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements (migration 0011, additive columns):
(1) QUANTITY — one subscription covers N cars (a family pays once for 2); sale = span price × quantity,
maxConcurrent defaults to it.
(2) PLAN TIMEFRAMES → TARIFF BRIDGE — a plan may restrict when a subscriber may park (weekday
20:00→08:00, weekend all-day). Outside the window they're charged the TRANSIENT tariff for the gap
(not refused): early entry = arrival→window-open (deferred, signed as windowOwedMinor on the
vehicle_entry); late exit = window-close→departure, and exit is GATED (sub.refused.unpaidWindow) until
paid at the booth. Pure tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing reuses
computeFee + the active tariff version (apps/server/src/subscription-window.ts). The exit refusal is a
host-ONLINE business gate — fail-open still governs the offline path (flagged in the wiki).
(3) RESERVED SPOTS — site toggle reserve_subscriber_spots: occupancy holds max(0, quantity−inside) per
active sub, so transients see "full" sooner; effectiveFree = capacity − count − reserved. Subscribers
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor (SubscriptionPlansManager);
reserve checkbox (SiteSettings); booth pay modal shows an "OUT-OF-WINDOW" charge + takes payment.
Verified on a copy of the live DB: qty 2 = 2× price; night-plan 19:30 entry → 30min/15,000 ALL owed,
stamped + paid → gate clears, chain verifies; reserve toggle holds a qty-2 sub's 2 spots. Build+lint
12/12; 80 shared tests. Updated [[subscription]], [[capacity-occupancy]], [[tariff]].
## [2026-06-21] query | Desktop shell: Tauri v2 vs. Electron
Compared Tauri v2 and Electron for shipping the operator UI as a desktop app (mobile deferred).
Decision (with user): **Tauri v2** — small footprint, no bundled Chromium to patch, deny-by-default
native surface fitting the booth-operator threat model; MIT/Apache. Shell stays thin (device/auth/
ledger/pricing remain in Fastify, per user). Filed [[desktop-shell-tauri]]; cross-linked from
[[standing-decisions]], [[overview]], [[index]]. Open dependency: appliance WebKitGTK version
([[open-questions]] #11) — flips to Electron if ancient/unavailable.
## [2026-06-21] query | Desktop shell — target OS (best/worst case)
User specified deployment span: best = Ubuntu 26.04 LTS desktop, worst = Windows 11 + WSL + Docker.
Refined [[desktop-shell-tauri]] + [[open-questions]] #11: Ubuntu 26.04 LTS ships a current
distro-maintained WebKitGTK → effectively closes the WebView risk; Tauri unconditional there. The
Windows+WSL case is NOT an "Electron instead" fallback — it conflicts with the standing Linux-
appliance platform decision and can't host a GUI shell in headless WSL/Docker; fallback is a kiosk
browser at the local Fastify-served SPA (Electron only if a standalone Windows installer is
mandated). Thin-shell architecture makes that fallback cheap.
## [2026-06-21] query | TPM 2.0 hardening — analysis + pull-the-disk attack trace
How a TPM works (non-extractable keys + PCR sealing) and its limits, recorded after tracing the
"pull the SSD, tamper parking.sqlite offline, reboot" attack against event-log.ts/signer.ts.
Findings: verifyChain() catches every blind tamper (bad sig / index gap / prevHash / unknown keyId)
but (a) nothing runs it at boot, and (b) the software HMAC key lives in .env on the same disk →
attacker can re-sign undetectably. Only a secure-element key (TPM on a PC, ATECC608 on embedded)
makes it tamper-PROOF; TPM-sealed LUKS additionally blocks off-host mount. TPM verdict: recommended
not required on the Ubuntu appliance (sealed-LUKS auto-unlock + non-extractable signing key); does
NOT defend a rooted live host or the operator; bus-sniff/PCR-brittleness caveats → prefer fTPM + PIN,
keep recovery passphrase + re-seal runbook; complements not replaces reconciliation. Also corrected:
ATECC608 is NOT in a PC (external I²C part) → on a PC appliance the TPM is the host secure-element,
ATECC608 reserved for the ESP32 controller. New page [[tpm]]; cross-linked [[disk-os-hardening]],
[[threat-model]], [[atecc608]], [[append-only-event-chain]]; open-questions #12 (TPM impl, to build),
#13 (startup verifyChain self-check, to build); index + counts updated.
## [2026-06-21] build | apps/desktop — Tauri v2 kiosk shell scaffolded
Built the thin Tauri v2 shell per [[desktop-shell-tauri]]: new apps/desktop package wrapping the
SAME apps/web SPA (dev → localhost:5173 with HMR; prod → bundled web dist/), so desktop and browser
UIs can't drift (user requirement). Rust core holds no business logic; capabilities core:default
only (deny-by-default). One apps/web change: centralized the backend origin into lib/origin.ts
(API_BASE/apiUrl/wsUrl from VITE_API_BASE) — no-op in the browser, lets the Tauri build target the
Fastify origin. Turbo build is a no-op; real bundle = `pnpm --filter @parking/desktop bundle`.
VERIFIED: cargo check + full tauri build → working .deb/.rpm/.AppImage; turbo run build lint 14/14
green; prereqs present (Rust 1.93, WebKitGTK 4.1, libsoup-3, WSLg). Filled the As-built section of
[[desktop-shell-tauri]]. Deferred: kiosk lockdown, auto-update, signing, Windows kiosk-browser path.
## [2026-06-21] build | apps/desktop — window/right-click, auto-update, code-signing, env wiring
Per user choices on the Tauri shell: window starts MAXIMIZED (not fullscreen — operator keeps OS
access); right-click context menu blocked in PROD only (lib/kiosk.ts, dev keeps devtools).
VITE_API_BASE wired via apps/web/.env.production (committed non-secret, allow-listed in .gitignore;
auto-loaded by vite build → desktop bundle targets Fastify, no manual export). Auto-update built:
tauri-plugin-updater + -process, prompt-on-update flow (lib/desktop-updater.ts, no-op in browser/
offline) → downloadAndInstall + relaunch; endpoint is a self-hosted PLACEHOLDER to fill in. Updater
keypair generated: pubkey embedded in tauri.conf.json; private key + password kept OUTSIDE the repo
(~/.parking-updater-keys, 0600) + as TAURI_SIGNING_* build secrets. VERIFIED: signed bundle →
.deb/.rpm/.AppImage + .sig updater signatures; turbo run build lint 14/14 green; no key material in
the repo. Updated As-built in [[desktop-shell-tauri]]. Deferred: real update URL, OS installer
signing, Windows kiosk-browser fallback.
## [2026-06-21] fix | Subscription out-of-window charge — marker-not-fixed-amount; scannable ticket; booth flow
Corrected the [[subscription]] tariff-bridge charging model after operator feedback. The entry path
stamped a FIXED `windowOwedMinor` = the whole gap-to-window-open (e.g. 800 ALL for a 13:21 arrival to
a 20:00 window) and deferred it — over-charging anyone who left before the window opened (a 1-hour
visit billed as 6.5h). Now the `vehicle_entry` carries only a MARKER (`outOfWindow` +
`windowTariffVersionId`); the amount is priced LIVE from `minutesOutsideWindow(entry → settle-time)`,
which caps at the window edges, so one hour parked = one hour's transient fee, in-window time free, and
the late-exit tail keeps accruing until payment. The advisory slip is now a scannable Code128 + QR
TICKET of the occurrence id (operator scans it into the booth pay modal). Also: removed the always-on
"Open barrier" from the active-sessions list AND modal for subscribers — a prepaid sub shows only a
small "assist open" reveal; an out-of-window sub is pay-first-then-open. Fixed the ESC/POS encoder so
typographic chars (— ⚠ … ' ") transliterate to ASCII instead of "?". `windowOwedMinor`/`windowGap*`
kept as deprecated read-only in `LedgerPayload` for historic events. Verified live model on a DB copy
(13:21→14:30 = 200 ALL; 19:55-grace→23:00 = 0; 19:00→21:30-cross = 100 ALL). build+lint 14/14, shared
87/87. Existing signed occurrences left untouched (immutable). See [[subscription]] tariff-bridge-history.
## [2026-06-21] feat | Subscription plan-version correction (admin)
Added an admin-only path to move an existing [[subscription]] to a different VERSION of its SAME plan
(e.g. v1 "every day" → v2 "weekdays only" of mujor-naten-cdo-dite). PUT /api/subscriptions/:id now
accepts planVersionId, gated on subscription:plan (403 for non-privileged), validated to share the
sub's existing planId (cross-plan = 400 — that'd be a re-sale). Price/currency/period stay frozen;
only the access rules change going forward (past signed events keep their own windowTariffVersionId).
Server-logged for audit. UI: admin-only "Versioni" picker in the edit modal, listing every version by
effective date + timeframe summary, current pre-selected. Verified on a writable DB copy: version
changed, price + planId frozen, cross-plan rejected. build+lint 14/14, i18n parity (sq+en). Live DB
untouched. See [[subscription]] "Version correction".
## [2026-06-21] feat | Shift report split (tickets vs subscriptions) + confirm-before-close + dark <select>
Three UI/report changes. (1) The [[shift]] report now splits takings by SOURCE — Tickets (transient)
vs Subscriptions (monthly sales + a subscriber's out-of-window charge), derived from the signed
payment payload flags (subscriptionSale / subscriptionWindowCharge), always reconciling to cash+card.
Carried on the signed shift_z_report payload + shown in X-report, close modal, history detail, and the
printed Z-report; pre-split reports default subscription to 0. (2) The header shift button no longer
closes directly — it opens a confirm modal showing the live X-report (the split + expected drawer)
before signing the irreversible Z-report. Opening stays immediate. (3) Fixed dark-theme native
<select> popups rendering WHITE on WebKitGTK (Tauri Linux) via color-scheme + explicit option colours.
Verified the split on a read-only DB copy (tickets 0, subs 10,200 = 10,000 sale + 200 out-of-window,
reconciles). build+lint 14/14, i18n parity (sq+en). See [[shift]] "Takings split by source".
## [2026-06-21] feat | Promote Subscriptions to a top-level section with its own tabs
Moved Subscriptions out of /setup into a standalone /subscriptions section with a header nav entry
(Kabina · Turni · Abonimet · Konfigurimi) and its own tab bar: Abonimet (/subscriptions), Planet
(/subscriptions/plans), Lab Tarife (/subscriptions/tariff-lab). Removed those three tabs from the Setup
layout (Setup now: Pajisjet · Tarifa · Park · Përdoruesit · Rolet · Turnet · Loget). Tabs are
permission-gated (subscription:read / subscription:plan / tariff:read), so an operator with only
subscription:read sees just Abonimet; the index redirects to the first allowed sub-tab otherwise.
Legacy /setup/subscriptions, /setup/plans, /setup/tariff-lab redirect to the new paths; the old
/subscriptions→/setup redirect was removed (it's a real route now). Verified at runtime via Playwright
(header order, the 3 sub-tabs, Setup no longer shows them, /setup/subscriptions redirects). The Tariff
COMPOSER stays in Setup; only the Tariff LAB simulator moved. build+lint 14/14.
## [2026-06-21] fix | Header "Turni"→"Turnet" (plural); drop the duplicate Setup shifts tab
The header shift link was nav.shift (singular: Turni/Shift) but points at the /shift HISTORY hub →
relabelled to nav.shifts (plural: Turnet/Shifts). Removed the duplicate "Turnet" tab from Setup (the
/setup/shifts route + tab rendered the SAME ShiftsHistory as the standalone /shift). /setup/shifts now
redirects to /shift; the operator-landing fallback (shift:read user opening /setup) points at /shift.
nav.shift key left in the catalogs (now orphaned, harmless). Verified at runtime. build+lint 14/14.
## [2026-06-21] feat | /shift→/shifts; clickable activity log (shared event-detail); booth-style full-height layout
Three changes to the shift hub. (1) Renamed the route /shift→/shifts (matches the plural Turnet label
+ the section); /shift and /setup/shifts both redirect there. (2) The activity-log rows are now
CLICKABLE and open the SAME read-only event-detail modal the booth live feed uses (full signed payload
+ snapshots + chain provenance). Extracted EVENT_STYLE + the row + the modal + their helpers from
BoothScreen into a shared apps/web/src/ui/event-detail.tsx, imported by both — so the booth feed and the
shift log render/behave identically and can't drift. (3) Reworked the /shifts layout to fill the
viewport like /booth: a fixed title+filters, then a two-pane area (shift list | activity log) where each
pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the whole page growing.
Verified at runtime (Playwright): /shift redirects, rows open the detail modal, layout fills height,
booth still works (0 console errors). build+lint 14/14.
## [2026-06-21] feat | Booth: focus-independent hardware-scan capture
A scan now opens the /booth pay/exit modal no matter what's focused (or if nothing is) — the operator
needn't click the ticket field first. New useScanner hook (apps/web/src/lib/use-scanner.ts): a
document-level keydown listener that detects the HID scanner's fast keystroke burst ended by Enter (gap
> 50ms resets the buffer, so human-paced typing never triggers it; min length 3) and fires
setActiveTicket. Ignores keystrokes into editable fields so the manual ticket input is unaffected;
paused while a modal is open so a scan can't abandon an in-progress payment. Verified at runtime
(Playwright): scan with focus on BODY opens the modal; second scan while open is ignored; slow typing
doesn't trigger; manual form submit still works. build+lint 14/14. See [[booth-console]].
## [2026-06-21] test | Automated test coverage across every service (was shared + vision only)
Added a fresh-SQLite test harness and suites for all six packages; `pnpm test` (turbo `test` task) now
covers them all (previously only @parking/shared + @parking/vision had test scripts). New
`@parking/db/testing` exports `createTestDb()` — an in-memory SQLite with the real Drizzle migrations
applied, so server tests run against the production schema with NO live-DB risk. Coverage: **server**
(anti-fraud core) — event-log hash-chain linkage + `verifyChain` catching every tamper class (edited
payload, deleted row/index gap, broken prevHash, unknown keyId), signer round-trip/forgery/rotation,
occupancy fold + reserved-spots (no double-count of a parked subscriber), pay-station quote/sign/lookup,
the exit GATE (refuse unknown/unpaid/grace-expired; no booth subscription bypass; assist path), and the
shift takings-SPLIT by source (subscription sales vs out-of-window vs transient tickets) + drawer
carry-forward + Z-report; plus an HTTP integration suite booting the real Fastify app via `app.inject`
for the auth/RBAC/CSRF guards. **devices** — ESC/POS byte stream (CP852 ë/Ë mapping + em-dash/⚠ ASCII
fallbacks, no stray "?"; the Code128 module-width contract: width 2 for the ~20-char out-of-window id so
it fits the 80mm head) + printer-routing failover. **web** — booth formatters + the focus-independent
`useScanner` hook (jsdom). **vision** — fixed 2 pre-existing stub-mode test failures via a conftest
autouse fixture that pins `VISION_RECOGNIZER=stub` (the dev `.env` had set `fast_alpr`, which broke the
model-free smoke tests). Also stopped `*.test.ts` leaking into shipped `dist/` (server + shared
tsconfig excludes). Totals: shared 87, server 75, devices 18, web 17, vision 7 = 204 tests; build/lint
14/14. See [[booth-console]].
## [2026-06-21] fix | Auth cookies Secure-by-default; COOKIE_SECURE=0 in the appliance deploy runbook
secureCookies() keyed off NODE_ENV==="production", so an appliance deployed without that var
silently dropped the Secure flag on the auth/CSRF cookies (the code-review's one Medium finding).
Flipped to FAIL-SAFE: Secure by DEFAULT, dropped only on a deliberate COOKIE_SECURE=0/false/no/off
(or NODE_ENV=development as a dev fallback). The plain-http LAN appliance sets COOKIE_SECURE=0 ON
PURPOSE (a Secure cookie is never sent over its http origin → operators couldn't log in); a TLS
deploy leaves it unset. Added a "Deploy-time server configuration (runbook)" section to
[[disk-os-hardening]] documenting COOKIE_SECURE=0 (+ JWT_SECRET / EVENT_SIGNING_KEY) and corrected
the stale "Secure when NODE_ENV=production" line on [[local-jwt-auth]]. auth.test.ts (5) pins the
matrix; server 80/80.
## [2026-06-22] feat | Admin Reports dashboard v1 (ledger-first charts) + camera "Test ANPR"
Built the admin Reports screen (`/setup/reports`, gated `report:read`): one server call
(`GET /api/reports/summary?from&to&bucket`, + `.csv` export) aggregates entry/exit counts and all
money straight from the signed `ledger_events` (LEDGER-FIRST decision) — the same source the
`shift_z_report` reconciles, so totals tie out to the drawer; the 3-way revenue split (ticket /
subscription sale / out-of-window) mirrors the Z-report. Duration/occupancy stats come from the
`sessions` cache (flagged). All bucketing is in the SITE timezone (`siteTz()`). Views: KPI cards,
entry/exit line, revenue bar + cash/card split, revenue-mix pie, peak-hours histogram, numeric
breakdown, subscription stats. Charts via Recharts (MIT), lazy-loaded into its own chunk (111KB gz)
so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/duration/subs; server
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
## [2026-06-22] feat | Soft delete + recycle bin for master data (migration 0012)
Accidental admin deletes used to be hard + unrecoverable. Now users/roles/subscriptions/plans/
tariffs soft-delete: migration 0012 adds nullable deleted_at + deleted_by; each resource's DELETE
route STAMPS instead of removing, and every catalog list filters deleted_at IS NULL. A recycle bin
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge — gated recyclebin:read/update/delete,
new resource in the PERMISSIONS grid) lists everything soft-deleted, restores, or purges; a 6-hourly
+ startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever).
Key invariants: soft-deleted users CAN'T log in (login rejects deleted_at; no-lockout counts live
admins only); a soft-deleted subscription doesn't open the barrier; PLANS are versioned so a delete
stamps all version rows of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role
(guard resolves missing role → empty perms, safe). Signed ledger is OUT of scope (no delete path).
Web: a Recycle bin tab under Setup (RecycleBin.tsx). Tests: recycle-bin.test.ts (9 unit) +
recycle-bin-routes.test.ts (4 integration: delete→can't-login→restore→login, purge, gating, 409
reuse); server 103/103, build+lint 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]].
## [2026-06-22] feat | Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware (Event → Smart/VCA "Detection Target: Human/Vehicle" + Notify Surveillance
Center + Alarm Settings → Alarm Server) HTTP-POSTs an EventNotificationAlert on each detection.
Added POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts) — same machine-push
pattern as the Dingtian Input Link: source-IP guarded + OPTIONAL Digest, not behind SPA cookie/CSRF.
Permissive/discovery-first: a wildcard content-type parser takes ANY body as raw bytes (XML,
multipart+JPEG, JSON — Hik varies by firmware), stores it verbatim as a kind:"alarm" device_event,
and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + log line. The
hikvision DRIVER gained alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (so
setup offers the backend push IP). NOT yet a barrier trigger or DeviceReadEvent — records only; the
read-bus/ANPR wiring is the next step once the real payload is captured (advisory-only rule still
governs). Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw JSON, wrong-IP
404, disabled 404, unknown-device 404); server 109/109, build+lint 14/14. See [[lpr-camera]].
## [2026-06-22] debug | Hikvision event-push field session — fixes + a verified-dead camera
Long session getting a real Hik camera to POST events. Server/integration fixes (committed): listen
on ALL methods (camera probes with GET/etc, not just POST); record rejected pushes too (kind
"alarm-rejected" + reason) so "nothing arrived" is never ambiguous; a GET /api/devices/hikvision/
alarms read endpoint; per-device skipSourceIpCheck (WSL mirrored mode REWRITES the inbound source IP
to the host's own, so the source-IP guard rejected every push); and a real checkbox renderer for
type:"boolean" config fields (they were saving the STRING "true"). Then proved — via the camera's
OWN state, not our server — that the specific DS-2CD1043G2-LIU unit has a DEAD event engine: silent
alertStream (no heartbeat), EventScribe:except, diskfull on Event/triggers, dead RTC (rtc get time
error / clock at 1970), and ZERO outbound to :3000 over a 3-min netstat watch, surviving reboot +
basic reset + FULL factory reset. Verdict: defective camera (RMA), not our code. Captured the
diagnostic method (alertStream silence / SSH showStatus / netstat) in [[lpr-camera]]. Fallback for a
dead-push camera: pull + [[opencv-anpr-service|vision]] (the same camera still serves snapshots).
## [2026-06-22] CORRECTION | Hik camera was NOT defective — the cause was an undrawn detection area
Supersedes the earlier "[2026-06-22] debug" entry's conclusion that the DS-2CD1043G2-LIU had a dead
event engine needing RMA. WRONG. The camera is healthy; it pushed a clean event the instant a
detection AREA was drawn on the frame (the "Draw Area" step). With no region drawn, the camera
detects nothing → generates no event → posts nothing anywhere — which produced all the symptoms
(silent alertStream, zero outbound to :3000). The diskfull / EventScribe:except / dead-RTC findings
were red herrings (the RTC is genuinely dead, hence a bogus 2032 dateTime in the payload, but it does
NOT block event push). Lesson: don't escalate to "hardware fault" while a basic config precondition
is unmet; vendor status-API error strings are unreliable. Confirmed real payload: multipart/form-data
(MoveDetection.xml) with EventNotificationAlert -> eventType=VMD, eventState=active,
targetType=vehicle (vehicle/human classified ON-DEVICE), targetRect bounding box. The push endpoint +
all-methods + skipSourceIpCheck + rejection-recording are all validated against the real device now.
See [[lpr-camera]] (corrected).
## [2026-06-22] design+build | Lane presence (BUILT) + ANPR subscriber-entry "bridge" (PLANNED)
Off the now-working Hik vehicle event: BUILT advisory lane busy/free booth barrier lights
(LaneStatus + WS; timeout-driven "free" since the camera sends no leave signal — TTL settled at 30s
after a controlled in/out test showed movement-driven ~1-3s re-fire but ~15-25s gaps for a still
car, and ~no dwell lag on leave). Measured the camera's hard limits: no current-state poll exists,
and flipping notificationRecurrence beginning->recurring via ISAPI is silently reverted (firmware
locked). Then narrowed the bigger ambition to a clean, high-value scope: ANPR for SUBSCRIBERS ONLY —
a plate read at the lane admits a subscriber through the EXISTING gated subscription flow. Found the
whole subscription side already supports via:"plate" (match + dispatch + gate); the one missing piece
is a small `apps/server` HANDLER ("the bridge", ~40 lines, NOT a new service/container) that on a
camera vehicle event snapshots -> ANPR -> on a HIGH-confidence match (new VISION_ENTRY_MIN_CONFIDENCE)
-> debounces (required for ledger correctness, not CPU: ~1Hz re-fire would drive repeat entries) ->
emitRead{kind:"plate"}. Both directions, opt-in per camera (config.anpr), plate never the sole
authority (routes through the gate). REJECTED: continuous livestream presence + per-car queue
tracking/make-model (needs a vehicle detector the plate-only vision lacks + appliance compute we can't
measure on the dev PC). Vision checked: fast_alpr live, ~50ms/frame on DEV PC (appliance TBD —
booth-PC test ~2026-06-23). New page [[lane-presence-and-anpr-entry]]; updated [[lpr-camera]],
[[subscription]], index.
## [2026-06-22] build | ANPR subscriber-entry "bridge" — BUILT
Built the bridge planned in the previous entry: `apps/server/src/anpr-entry.ts` (`AnprBridge`). On a
vehicle/non-`inactive` push from an `anpr`-opted-in camera, `hikvision-alarm.ts` hands the deviceId
to the bridge (fire-and-forget, never awaited on the camera's 200). The bridge debounces
(camera-level, pre-snapshot), pulls a FRESH snapshot (reused `snapshot.ts buildCamera`), runs
`vision.analyze`, applies a stricter entry floor (`VISION_ENTRY_MIN_CONFIDENCE`=0.85), then — the key
safety choice settled with the user — MATCHES the plate to a subscription BEFORE emitting: a
subscriber → `emitRead{kind:"plate"}` (→ existing `ReadDispatcher`→gated `SubscriptionFlow`); a
non-subscriber → advisory `anpr-skip` device_event, nothing emitted (so a random/printed plate never
reaches the transient plate-as-ticket exit path). Fail-soft throughout. `server.ts` reordered so the
read flows are constructed before the hik-alarm registration. New env: `VISION_ENTRY_MIN_CONFIDENCE`,
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
## [2026-06-22] build | Cancel (void) a wrongly-printed ticket + refused-vs-anomaly display split
Operator need: cancel a misprinted/test/wrong-vehicle ticket, traceably. Built it as a SIGNED `void`
(append-only — the vehicle_entry is never touched): new `apps/server/src/void-flow.ts` (`VoidFlow`)
appends void{ voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; operator from JWT, reason REQUIRED. Refuses a
subscription / already-exited / already-voided / PAID ticket (refund = out of scope). The CRUX: a
void must fold the session CLOSED everywhere it's counted — done in occupancy.ts (count +
reserved-spots, −1 like an exit), pay-station.ts (lookup/activeSessions), exit-flow.ts (#sessionFor),
and reports.ts (excluded from the entries stat). No barrier action (the car never entered). Booth UI:
"Cancel ticket" in the pay/exit lookup modal (transient + unpaid + open; gated on event:void) with a
preset-or-free reason prompt. Part 2 (display-only): the Live feed mislabeled benign refused-action
events (exitRefused/entryRefused/permitRefused — e.g. a double card-scan) as red ANOMALI; now
classified via event-detail.tsx isRefusedWarning and shown as amber REFUZUAR/REFUSED, reserving red
ANOMALI for genuine red-flags. No ledger change → historical events reclassify too. New reason code
void.ticketCancelled (shared + both web catalogs). Tests: void-flow.test.ts (8) + occupancy void fold;
141 server + 87 shared green; build+lint (TS + i18n parity) green. Updated [[parking-session]].
## [2026-06-22] build | Docker images for non-desktop apps (server+SPA, vision) + branch-aware build pipeline
Containerized the two runtime apps. parking-server = Fastify API + the bundled React SPA (wired
@fastify/static in new static-spa.ts — serves apps/web/dist with an SPA index.html fallback, GET-only
and excluding /api + /health so it never shadows the backend; a NO-OP in dev where no dist exists).
parking-vision = the Python/uv ANPR service, ships --extra alpr with model weights pre-warmed into the
image (offline-first), engine env-selected (VISION_RECOGNIZER stub|fast_alpr). Branch-aware per the
user: images tagged branch + branch-<sha>; base docker-compose.yml + docker-compose.dev.yml (build
local, stub, ports) / docker-compose.prod.yml (pull pinned, fast_alpr, vision internal, restart
always). New .gitea/workflows/build-images.yml pushes both to git.infra.msai.al/mca/parking_solution
on push to dev/main, after a full turbo build+lint+test gate (mirrors trm/processor; optional Komodo
webhook behind KOMODO_ENABLED). KEY build lessons: use `pnpm deploy --legacy --prod` NOT
`pnpm prune` (monorepo: prune leaves the native better-sqlite3 unresolved); Alpine needs
python3/make/g++ (build) + libstdc++ (runtime); set CI=true so pnpm wipes node_modules; migrate at
BOOT via a drizzle-kit-free runtime migrator (packages/db/scripts/migrate-runtime.mjs) against the
mounted /data volume; .dockerignore must exclude **/parking.sqlite* (deploy ignores .gitignore) so the
signed ledger is NEVER baked. JWT_SECRET must be a real >=32-char value (auth.ts rejects dev-only/
insecure/change-me). VERIFIED: server image builds + runs — migrates, SPA serving on, /health 200,
/ + /booth serve HTML, /api/nope JSON 404, no sqlite outside /data. Vision image build + smoke in
progress. New page [[container-deployment]]; updated [[vision-service-packaging]] (resolved its two
open Qs), index. Server tests stay 141 green (SPA serving guarded on dist existence).
## [2026-06-23] provision | First booth appliance — Dell OptiPlex 7070, Win11 → Ubuntu 26.04 LTS, encrypted + TPM-sealed
Provisioned the first real booth PC. Hardware: Dell OptiPlex 7070, i5-8500, 238GB SSD, discrete
Nuvoton TPM 2.0 (NOT Intel PTT — Get-Tpm ManufacturerIdTxt NTC). Formatted Win11 → Ubuntu 26.04 LTS
(the decided platform). Gotchas hit + resolved, in order: (1) Ventoy USB → 0x1A Security Violation
under Secure Boot (Ventoy's loader not in db) → flash the ISO directly; (2) the 7070 BIOS Expert Key
Management is edit-only, no "View Key" → can't inspect db, so the live-USB boot IS the verification
(it reached the installer with Secure Boot ON → MS 3rd-party UEFI CA confirmed present); (3) the
installer's "Use hardware-backed encryption" FAILED with PCR_UNUSABLE / "secure boot policy (PCR7) …
timestamp revocation (dbt) … not supported" — Ubuntu's automated FDE profiler can't model PCR7 on
Dell firmware with a dbt; NOT a TPM/SB fault. Workaround: "Encrypt with a passphrase" (plain LUKS) +
MANUAL TPM seal after boot via systemd-cryptenroll --tpm2-pcrs=7 /dev/sda3 (PCR 7 only — avoids
kernel-churned 4/8/9 that would drop every boot to the passphrase). Two LUKS slots kept (0 password =
recovery, 1 tpm2 = auto-unlock); crypttab gets tpm2-device=auto; update-initramfs; reboot → BOOTS
STRAIGHT TO LOGIN, no passphrase → TPM auto-unlock VERIFIED (unattended reboot achieved). New runbook
page [[appliance-provisioning]] (every command verified on hardware); cross-linked from
[[disk-os-hardening]] (resolves impl half of open-questions #12 for unit 1) + index. REMAINING on the
box: GRUB password, Docker install, run the parking-server/parking-vision stack.
## [2026-06-23] provision | First booth appliance — GRUB edit-lock added; OS hardening COMPLETE
Added the GRUB password (edit-only mode via --unrestricted) to the first booth unit. WHY it matters
specifically: the PCR-7 TPM seal does NOT cover the GRUB-cmdline attack (editing the kernel line to
init=/bin/bash doesn't change PCR 7, so the TPM still releases the LUKS key → root shell on the
decrypted disk). Edit-only mode keeps unattended boot (the box still boots password-free; the
password is required only to EDIT entries / open the GRUB shell) — the right config for an unattended
booth. Verified BOTH halves in /boot/grub/grub.cfg before rebooting (password_pbkdf2 ≥1, unrestricted
≥1) and on reboot: boots straight to login (no GRUB prompt, TPM auto-unlock intact) AND pressing `e`
prompts for admin+password. OS hardening on unit 1 is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7)
+ Secure Boot (Deployed) + GRUB edit-lock. Updated [[appliance-provisioning]] (§5 GRUB now a verified
step, §5b further-hardening TODO: SSH key-only, kiosk lockdown, signing key→TPM, autoremove old
kernel) + [[disk-os-hardening]]. STILL TODO on the box: Docker install + run the parking stack (needs
the images pushed — dev push + registry secrets pending).
## [2026-06-23] deploy | First booth GO-LIVE — Docker stack running + web-access fixes (CI uv, compose env, relative /api, Caddy)
Deployed the two images onto the hardened booth (Dell 7070, Ubuntu 26.04) and worked through the
real-world bring-up issues. (1) Operator/admin OS user split: created a dedicated sudo `admin` user,
removed the auto-login operator from `sudo` (and should drop `lxd`/`lpadmin` — lxd is a root-escape
path); admin is the only sudo, operator auto-logs in unprivileged. (2) Docker 29.6 installed; deploy
dir /opt/parking_solution with hand-copied compose + .env; registry login to git.infra.msai.al; the
stack came up clean — vision fast_alpr loaded from the BAKED cache (0 downloads → offline-first
confirmed on real hardware), server migrated /data, both healthy. (3) Seeded the first admin via
`docker compose exec server node scripts/seed-admin.mjs` (bcrypt, writes users table — NOT the signed
ledger). FIXES committed this session: CI `astral-sh/setup-uv` action failed on the Gitea runner →
install uv via its official curl script instead (both ci.yml + build-images.yml) [0a22eab]; the base
compose only forwarded JWT_SECRET/DATABASE_URL/VISION_URL → added COOKIE_SECURE (CRITICAL on plain-
http or login cookies never send), WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED [1092316];
the SPA had VITE_API_BASE=http://127.0.0.1:3000 baked in (leaked from apps/web/.env.production, which
is for the TAURI build but Vite auto-loads it for every build) → server Dockerfile now empties it via
.env.production.local so the SPA uses RELATIVE /api and works from ANY host [77b2acb]; added a CADDY
reverse proxy (prod override) so the booth is reached on a clean port-80 URL, server goes internal,
Caddyfile binds :80 to match any hostname incl. parksystems.msai.al [c637b27]. NET RESULT: no domain
baked into any image — naming controlled by hosts/DNS on-site; admin can reach it from another LAN PC.
Verified the relative-/api + Caddy fix end-to-end locally (Host: parksystems.msai.al through :80 →
SPA + /api/auth/login reach the server, no CORS). See [[container-deployment]] "Web access",
[[appliance-provisioning]]. REMAINING on the box: push dev so CI rebuilds parking-server:dev with the
relative-/api fix, then pull on the booth; kiosk autostart; operator user lxd/lpadmin cleanup.
## [2026-06-24] build | Self-service user profile + desktop installers in CI
Two app-side additions. (1) **Self-service profile** — any signed-in user can now edit their OWN
`fullName`/`email` and change their OWN password (proving the current one), without any `user:*`
permission. New routes `PUT /api/auth/profile` + `PUT /api/auth/password` (act only on `req.user.sub`;
cannot touch username/role; CSRF-guarded), SPA screen `apps/web/src/Profile.tsx` at `/profile` (header
username chip links to it), `email` added to the session view + `SessionUser`. 7 new tests
(`routes/profile.test.ts`); server 148/148 green. Distinct from the admin user-manager (`routes/users.ts`,
`user:*`-gated). See [[local-jwt-auth]]. (2) **Desktop in CI** — new `.gitea/workflows/build-desktop.yml`
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
[[desktop-shell-tauri]] "Desktop in CI".
## [2026-06-24] build | Radar presence input + button-light output on the Dingtian
The first booth wired an **entry button on I1** and a **[[hikvision-radar|Hikvision radar]] on I2**
(closes a dry contact on detection), plus the **button's 12 V lamp on a spare relay**. Modelled as
children of the access controller config — no new device category. (1) The radar reuses the existing
`relays[].presenceInput` one-car-one-ticket gate; added `presenceKind: loop|radar` (label) and
`presenceActiveLow` (a radar may idle opposite the button — the Dingtian has ONE board-wide resting
level, so a per-input override `inputActiveLow` inverts just that terminal; pure helper
`inputActive()`). (2) New device-agnostic **`AuxOutputDevice.setAux(channel,on)`** capability (Dingtian
latch) so business logic drives a NON-barrier lamp through the interface — barriers still only
`pulseOpen` ([[barrier-not-a-door]] preserved). (3) New `ButtonLightController`
(`apps/server/src/button-light.ts`): subscribes to the radar input edge + the camera
[[lpr-camera|lane status]] and drives a **3-state lamp** — radar+car=SOLID, radar-only=BLINK (~1 Hz),
else OFF; **fails OFF**; de-duped. (4) SetupWizard: presence kind + active-low + a button-light relay
picker; i18n parity (sq+en). Tests: `button-light.test.ts` (truth table + blink + fail-OFF + de-dupe),
`access-dingtian.test.ts` (active-level inversion). Workspace build+lint+test green (158 server tests).
A radar detection NEVER opens a barrier on its own — it only gates the button ([[threat-model]]). See
[[hikvision-radar]], [[button-light-indicator]], [[entry-double-press]], [[dingtian-relay]].
## [2026-06-24] fix | Booth bring-up fixes — relay password, form split, lamp concurrency
Three fixes from wiring the radar/lamp on the first booth (committed 420542c, fd15988, 830993b on
top of the 2915d14 feature). (1) **"Offline despite ping"** — the Dingtian's `relay_pw` is in every
binary frame incl. the status read, but had NO form field, so Test connection sent 0 → device
silently drops the packet → "offline" (ping is ICMP, unrelated). Added a **"Relay control password"**
secret field; because the secret is redacted, the test endpoint re-merges it by device id but ONLY
when host/port/driver match the stored row (a redirected probe can't exfiltrate it — `setup-secrets.test.ts`).
(2) **Form split** — the controller editor now has separate **Outputs** (relays + pulse-open + lamp)
and **Inputs** (button + presence/radar terminals, "For relay N") sections; UI-only, storage
unchanged. `pulse open (ms)` clarified as a relay/output setting, not an input. (3) **Lamp stuck
on/off** — the blink fired fire-and-forget `setAux` over UNORDERED UDP; concurrent on/off packets
reordered and the relay latched on the last-processed one. Replaced with a serialized desired-state
worker (one in-flight send/lamp, re-converges to the latest state → final state authoritative). Also
**hot-reload**: the lamp map now reconciles against live config each event, so a button light added
in the UI works without a server restart. Workspace build+lint+test green (163 server tests). See
[[dingtian-relay]] ("offline despite ping" + secret re-merge), [[button-light-indicator]] (serialized
sends + hot-reload).
## [2026-06-24] build | Printer USB transport behind the ESC/POS render layer
The ESC/POS printer drivers were **TCP-only** (every path went through `sendRaw`/`probe` to a raw
socket on port 9100); the original BOM intended one adapter to cover "USB **or** network". Added a
**USB transport** behind the existing render layer without touching a single `render*()` function:
a discriminated `Transport` (`transportFromConfig` → `{kind:"tcp",host,port}` | `{kind:"usb",
devicePath}`) and `sendTo`/`probeTo` dispatchers in `printer-escpos.ts`; USB writes the same ESC/POS
bytes to a kernel **`usblp`** char device (`/dev/usb/lp0`) via a plain `fs` write — **no libusb/CUPS/
native dep** (keeps MIT-only + minimal-deps appliance). `cashino` + `rongta` resolve a Transport once;
both are reachability-only over USB, and the Rongta's HTTP **status page degrades to the open-the-node
probe** over USB (no guessed paper/cover — the standing honesty rule). Non-`usb` configs are unchanged
(host-only = TCP), so no migration. Setup UI gains a **Connection** select + **USB device** field;
host/port made not-required so a USB printer needs neither. Tests: `printer-escpos.test.ts` (USB writes
the exact rendered bytes; probe present/absent; `transportFromConfig` TCP back-compat) +
`printer-cashino.test.ts` (USB-configured driver prints to the node, ready/offline). Devices suite
green (29). **Flagged open-questions #14**: confirm the on-site printer is USB and bake the
**usblp + udev write-access** rule into the appliance image (provisioning, not app code; unverified on
hardware). See [[printer-usb-transport]], [[rongta-printer]].
## [2026-06-24] build | Booth operator wrapper script — scripts/booth.sh
The booth PC (Ubuntu) needs one command instead of the long
`docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env …` line over the
three compose files. Added **`scripts/booth.sh`** (+ root **`.env.example`**): **prod by default**
(`ENV=dev` for the dev override); subcommands `up`/`down`/`restart`/`status`/`logs`/`pull`/`config`/
`exec`, and the requested **`update`** = `compose pull` the moving branch tag → `up -d --remove-orphans`
(recreates only digest-changed services, **named volumes/SQLite ledger preserved**) → `docker image
prune -f`. Prod **refuses to run without `.env`** (no safe `JWT_SECRET` default); dev with no `.env`
injects the documented benign local secret (the base file makes `JWT_SECRET` shell-required via
`${JWT_SECRET:?}`, which the dev override's service-level default alone can't satisfy). `down` never
passes `-v` (would wipe the signed [[append-only-event-chain|ledger]] volume); `help`/unknown-command
short-circuit before any Docker/.env requirement. Verified: prod `config` renders Caddy:80 + internal
server + pinned images + `fast_alpr`; dev `config` renders `:dev` images + `stub` + published ports.
Documented in [[container-deployment]] ("Booth operator wrapper").
## [2026-06-25] fix | Local ANPR silently degraded — `uv run` strips the alpr extra
Diagnosed via the live DB (read-only `VACUUM INTO` copy) why entry `26799912337` recorded a snapshot
but no plate: the dev box's vision service was running **stub**, and earlier real ANPR had stopped.
Root cause (NOT the Docker/compose work, which was an innocent coincidence): the dev machine runs vision
as **bare `uv run uvicorn`** against `apps/vision/.venv`, and a plain `uv run`/`uv sync` re-resolves the
venv to the lockfile **defaults**, **stripping** fast-alpr/onnxruntime — so after any `pnpm dev` the
recognizer vanishes (weights orphaned in `~/.cache`, no module in the venv) and ANPR silently becomes
"snapshot, no plate". Evidence: 28 real reads through 06-22 (yolo-v9 model, ~99% conf), venv frozen lean
since 06-19, no other env with fast_alpr on the box. **The BOOTH was never affected** — it runs the
Docker image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights pre-warmed); a
booth `ModuleNotFoundError` is a STALE image (fix: `booth.sh update`). **Fix:** vision `package.json`
`dev`/`start`/`recognize` now `uv sync --extra alpr &&` first (self-healing), `.env` set to `fast_alpr`,
+ a `dev:stub` escape hatch. Restored real ANPR locally (`/health` → `fast_alpr` ready, model loaded from
cache, no download). Documented in [[vision-service-packaging]] ("Two runtimes, one fragile").
## [2026-06-26] fix | Hikvision snapshot 503 "Device Busy" — stream selection + retry + Alarm URL helper
Three camera fixes. (1) **503 Device Busy — the REAL fix is stream selection.** First framed as
"transient, just retry" — WRONG for this camera. Hardware probe of **DS-2CD1047G3H-LIU** (10.0.10.13):
`channels/101/picture` (MAIN) → 503 `deviceBusy` on 5 consecutive probes 800ms apart, while
`channels/102/picture` (SUB) → 200 clean JPEG every time. The main encoder is PERSISTENTLY saturated;
a retry loop can't fix it. Added a **`stream` config field** to the Hikvision driver (1=main default
for back-compat, 2=sub; ISAPI id `<channel><stream>`). Verified live: setting the camera to Sub flips
its status degraded→ready (14.7KB JPEG in ~87ms). (2) **Transient retry** (still useful for a genuine
momentary blip + the de-dup case): `HttpCamera.captureSnapshot` retries 503/500 with linear backoff
(250/500/750ms ×4), fails naming it `(device busy)`, does NOT retry 401/404. Plus the already-landed
`captureSnapshotShared` removing concurrent self-collision. `healthCheck` reports a live 503 as
`degraded` (surfaces a saturated main stream rather than hiding it). Covered by `camera.test.ts`
(10 tests: retry + main/sub path). (3) **Alarm Server URL helper:** the camera setup form now generates the camera's Alarm
Settings (Destination IP / URL / Protocol / Port) ready to paste, so the operator never hunts the
deviceId or memorises the endpoint. CRUCIAL: host/port come from the **backend address on the camera's
subnet** (`backendIpForDevice` + server port, the same probe the push-IP picker uses) — NOT
`window.location.origin` (the SPA's dev/proxy origin, which would wrongly say `localhost:5173`).
Verified live: matches the on-camera config field-for-field (10.0.10.203 / …/event / HTTP / 3000).
Shows a "save first" (needs a deviceId) then "test first" (needs the resolved backend IP) hint.
Documented in [[lpr-camera]] ("503 Device Busy"). Devices 6 new tests; server 168 green.
## [2026-06-26] fix | QR reader status was a LIE (hardcoded "ready") → real ICMP liveness
Two genuinely-OFFLINE QR readers showed GREEN in the status bar. Cause: the QR-reader adapter
(`StubReader`) had `healthCheck → { ready, "stub" }` hardcoded — it never probed anything. These are
PUSH devices (scan → GET our backend, resolve by serial) that expose **no TCP port**, so a connect
probe (cameras/printers) has nothing to hit; the stub "solved" that by lying. False-healthy is the
worst failure for a status bar. Fix: an **optional reader IP** (monitor-ONLY — scans still resolve by
serial, operation unchanged) + an **unprivileged ICMP ping** (`drivers/icmp.ts`: shells `/bin/ping`
`-c1`, exit-0 = reply; no native dep, no CAP_NET_RAW). `healthCheck`: IP replies → `ready`, no reply →
`offline`, **no IP → `degraded` ("set IP to monitor")** (never a false green). Booth compose
(`docker-compose.prod.yml`) sets `net.ipv4.ping_group_range=0 2147483647` so `/bin/ping` works
unprivileged for the non-root container user. Verified on hardware: the readers (10.0.10.7/.8) answer
ICMP on the device VLAN (eth1) — distinct MACs — and the UI Test connection shows "● ready — ping
10.0.10.7". (NB: an earlier "offline" reading was a WSL wrong-route artifact, not the readers.) Covered
by `reader.test.ts` (4 tests). Documented in [[device-status-monitoring]]. Devices +4 tests, all green.
## [2026-06-27] fix | booth.sh failed in the flat /opt layout (couldn't find compose files)
The booth deploys the compose files **flat** in `/opt/parking_systems/` with `booth.sh` next to
them, but the script assumed `<repo>/scripts/` and did `cd ..` → `REPO_DIR=/opt` (no compose
files); `usage()` then `sed`-read a now-relative `$0` → "can't read booth.sh". That's why
`sudo ./booth.sh` only printed help and `/bin/bash booth.sh` errored. Fix: **discover** the
compose files (script's own dir → `../` → `$PWD`), `usage()` reads an absolute `$SELF`. Also:
`.env.example` defaulted `TAG=main`, but the registry only has `dev`/`dev-<sha>` (no main build) →
`compose pull` 404s; default to `TAG=dev` + documented the moving-vs-immutable tag scheme.
Reproduced the booth's flat layout in a scratch dir; all forms (`./booth.sh`, `/bin/bash
booth.sh`, `config`, absolute-path) verified. Commit 83298bc.
## [2026-06-27] decision | Fleet deployment → Komodo Periphery over NetBird
booth.sh hit its ceiling: fine for one SSH-able box, but no remote/no-SSH op, no fleet view, no
deploy history, no rollback — and the fleet is **many/growing**. Decision: **Komodo Periphery**
on each appliance, driven by an existing **Komodo Core** over the **NetBird** mesh, running the
**same** compose files ([[container-deployment]] pipeline unchanged); `booth.sh` demoted to
break-glass. Three settled choices: many/growing fleet · deploys **manual + pinned** to a
`dev-<sha>` (no webhook — preserves the determinism we chose by pinning) · secrets
**Komodo-managed, per-booth + unique**. Threat-model caveats recorded: Periphery is a root agent
(bind to NetBird interface only, passkey+TLS, part of the TCB); `EVENT_SIGNING_KEY` in Core is a
fraud-root blast radius → per-booth keys + [[atecc608|ATECC608]] as the real
long-term signer; Core becomes Tier-0. GPL-3.0 OK (external ops tooling, not a shipped dep — same
boundary logic as the AGPL vision exception). New page [[fleet-deployment-komodo]]; infra-as-code
sketch in `komodo/` (`resources.toml` + README + `.env.komodo.example`). Catalogued in `index.md`;
`container-deployment` cross-linked + reframed (booth.sh = fallback).
## [2026-06-27] deploy | First Komodo booth deploy VERIFIED end-to-end (park-buzi)
Took the first booth through the whole Komodo flow on real hardware (Core v2.1.2 → agent reported
v2.2): onboarding key → Periphery installed **user-mode** (runs as `admin`, no root daemon,
**outbound** so the booth opens no inbound port) → server `park-buzi` **OK** in Core → Stack
(repo `mca/parking_solution`@`dev`, base+prod compose, registry account `komodo`, per-booth
`[[…]]` secrets) → all containers green → admin seeded via Komodo's container terminal (no SSH).
Then `komodo/resources.toml` rewritten to mirror the **working** Stack (exported from Core, v2.2
field shape, **Stack-only — no `[[server]]`** since onboarding owns the server), committed + pushed
(`dev` 9918f27); a ResourceSync reads it clean — **empty diff / Execute disabled = already in
sync** (success, not error). `booth.sh` fixed for the flat `/opt` layout earlier (83298bc).
Gotchas that bit us (now in [[appliance-provisioning]] §7 + gotchas 7–11): `core_address` is Core's
**proxy URL** not `:9120` (exposed-not-published → Connection refused); **git-auth ≠ registry-auth**
(blank registry account → `no basic auth credentials`); user-mode + `/etc/komodo` root_directory →
`Permission denied`; config key is **`core_address`** singular. [[appliance-provisioning]] §6 split:
§6 = engine, §7 = Komodo deploy (PRIMARY) with §7c manual `booth.sh` break-glass.
## [2026-06-27] query | "Subscribers auto-enter but don't auto-exit" → NOT a bug; G3H main-stream snapshot is structurally dead
Investigated via a read-only VACUUM copy of the dev DB. Caca subscriber's ledger: clean
entry/exit pairs until ~16:38, then 6 entries + 0 exits. Traced to the **exit camera 10.0.10.13
(DS-2CD1047G3H-LIU)** producing only 2 reads ever (vs 60 on the entry cam) — every exit-direction
snapshot after 16:38 was **HTTP 503 "device busy"**, so the ANPR exit read never got a frame →
no `emitRead` → no auto-exit. The subscription-flow exit logic, camera→relay binding (relay 2 =
exit, anpr on), and `stream: "2"` config were all **correct**. Direct hardware re-probe after a
camera reboot + closing web connections: **sub (102) 10/10 rapid → 200 (~50 ms)**, **main (101)
3/3 → 503 in ~20 ms (instant reject)**. So main-stream snapshots are **structurally unavailable**
on this model (sub mandatory), and the 503 storm was **connection-slot exhaustion from manual
main-stream testing** holding the camera's slots (reboot clears). Recorded in the
the `g3h-main-stream-snapshot-503` LLM memory + a 2026-06-27 sharper-finding note in [[lpr-camera]]
("503 Device Busy"). No code changed — diagnosis only.
## [2026-06-27] query | "Auto-exit" RESOLVED — a 4-layer CAMERA fault on the G3H, never our code
Continuation of the above. Drove the full diagnosis to ground using a **dumb HTTP sink**
(`scratch-camera-sink.py`) the exit camera's Alarm Server was pointed at, to capture the verbatim
push. Every assumption about *our* code was wrong; all real causes were camera-side on the
**DS-2CD1047G3H-LIU** (`10.0.10.13`):
(1) 503s were mostly **manual main-stream testing** (main 101 = instant 503 structurally; sub 102 =
perfect) + connection-slot exhaustion (reboot clears) — NOT a retry/flow bug.
(2) **Blocker #1:** the camera never POSTed at all (0 alarms ever vs 1126 on the entry cam); its
**Diagnose dump showed a CORRUPT config DB** (`Main Db is broken`/`db_restore failed`/`reset cfg`) —
factory-reset fixed it.
(3) Gateway/DNS was a **red herring** (working cam had none; broken cam had both).
(4) **Blocker #2:** post-reset it pushed **plain `VMD` with no target** → backend gates on
`target=="vehicle"` (`hikvision-alarm.ts` reads `<targetType>`/`<detectionTarget>`/`<objectType>`)
→ ignored. Enabling the AcuSense **Vehicle target filter** made the push carry
`<targetType>vehicle</targetType>` (confirmed live on a drive-through). "VMD/Motion" is the right
event for this class — it's the *target filter* that matters.
Flagged an **observability gap**: a camera with `alarmPushEnabled=true` and 0 pushes ever should be
a surfaced status (cf. the reader-liveness fix). Recorded in the `g3h-anpr-push-gotchas` memory + a
new troubleshooting section in [[lpr-camera]]. No code changed — diagnosis + camera reconfig only.
## [2026-06-28] refactor | Unified controller relays into one event→action list (drop config.buttonLight)
Reframed the controller "Outputs — relays" model with the user: **Entry / Exit / Both are EVENTS**,
not a "direction" — a relay is uniformly *"when EVENT X happens, do action Y"*. Barrier events
(`entry`/`exit`/`both`) `pulseOpen`; a new **`radarAlert`** event drives a non-barrier alert lamp
(blink while its trigger input is active, SOLID once the camera confirms a car). **Dropped the
separate `config.buttonLight` block** — the lamp is now just another `config.relays[]` row
(`direction:"radarAlert"`, carrying `triggerInput` + blink cadence). One list, one editor, one shape;
a future "R4 alert" is just another row with its own trigger input — no new config, no code change.
The proven `ButtonLightController` 3-state machine (serialized UDP, fail-OFF, hot-reload) is kept
verbatim — only its source changed from `buttonLightOf()` to `alertRelaysOf()`, keyed per
`controllerId:relay` so several alert relays on one controller run independently. Every barrier
resolver skips `radarAlert` rows (no auto-open; barrier-not-a-door intact). Touched
`device-resolve.ts`, `button-light.ts`, `device-monitor.ts`, web `api.ts` + `SetupWizard.tsx` (the
dropdown gained a "Radar alert" option that reveals trigger/blink inputs), i18n sq+en. Tests:
rewrote `button-light.test.ts` to the `radarAlert` row + added a two-independent-alert-relays case;
full workspace `build lint test` green (173 server tests). Updated [[button-light-indicator]],
[[dingtian-relay]], memory `access-direction-is-per-relay`.
## [2026-06-28] refactor | Generic controller inputs (config.inputs[]) — the twin of unified relays[]
After unifying OUTPUTS into one event→action `relays[]`, did the same for INPUTS — the user hit the
wall that **there was no way to add a free-standing input** (e.g. an EXIT radar): inputs were fields
bolted onto an entry barrier relay (`relays[].button/presenceInput/...`) and the UI only rendered a
button+presence block per entry/both relay. Now a first-class **`config.inputs[]`** list — each row
`{ input, role: "button"|"presence"|"alertTrigger", relay?, kind?, activeLow?, cooldownSec? }` — with
a "+ Add input" button. An exit radar = just another `presence` row serving the exit relay. **Keystone:
`inputsOf(row)`** returns `config.inputs[]` or SYNTHESIZES it from the legacy per-relay fields, so
`relayForButton`/`relayForPresence` resolve identically from either shape — **zero-downtime, no DB
migration** (old configs keep working until re-saved; the UI seeds its editor from the synth).
`entry-flow.ts` is unchanged (resolves through the same functions). Also fixed a latent bug this
exposed: the alert lamp's camera **lock** was hardcoded to the ENTRY camera — added
`relays[].lockLane: "entry"|"exit"` (button-light tracks both `#entryBusy`/`#exitBusy`; a lamp goes
SOLID off its own lane's camera), so an exit radar's lamp locks on the EXIT camera. Driver: extracted
`activeLowFrom(config)` (merges inputs[] `activeLow` + legacy `presenceActiveLow` + the `inputActiveLow`
escape hatch). Touched `device-resolve.ts`, `button-light.ts`, `access-dingtian.ts`, web `api.ts` +
`SetupWizard.tsx` (InputEditor rewritten to a generic list; role select folds loop/radar; OutputEditor
radarAlert row gained a lock-lane select), i18n sq+en. Tests: new `device-resolve.test.ts` (inputs[]
resolution + legacy-fallback identical + exit-radar resolves to the exit relay), exit-lamp lockLane
case in `button-light.test.ts`, `activeLowFrom` cases in the dingtian suite. Full workspace
`build lint test` green. Updated [[entry-double-press]], [[button-light-indicator]], [[dingtian-relay]],
memory `access-direction-is-per-relay`.
## [2026-06-28] feat | Booth Entry/Exit lights blink on radar presence (mirror relay 3)
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy) off the camera
lane-status only — they couldn't show the radar-only "detected, not yet confirmed" state that makes
the physical button lamp (relay 3) blink. Added that signal end-to-end: a small server tracker
**`LanePresence`** (lane-presence.ts) subscribes to `deviceEvents.onInput`, resolves each presence
edge to its lane via a new **`presenceLaneOf`** (device-resolve.ts) — direction-agnostic (entry AND
exit), unlike the entry-gated `relayForPresence` — and emits a `lane-presence {entry,exit}` bus
event on change. The WS forwards it (hello snapshot + push) into `live-store.radar`; `BarrierLight`
(BoothScreen.tsx) became **3-state**, mirroring relay 3 exactly: radar+camera-free → BLINK green↔red
~1 Hz (`.lane-blink` keyframe in index.css, holds solid-red under prefers-reduced-motion);
camera-busy → SOLID red; else SOLID green. Same input + same rule as the lamp, so screen and post
never disagree. A test (lane-presence.test.ts) caught a real bug: the first cut reused
`relayForPresence`, so the EXIT lane never resolved (entry-gated) and never blinked —
`presenceLaneOf` fixes it. Full workspace build/lint/test green (185 server tests). Updated
[[button-light-indicator]] (new "On-screen twin" section).
## [2026-06-28] fix+feat | Booth feed plate backfill, plate search, + per-user font scale
Three booth fixes + one prefs feature:
- **Plate not showing until refresh (fixed).** Plate recognition is async/advisory
(snapshot.ts recognizePlate → a kind:"read" device_event keyed by session identity), so it
lands AFTER the entry/exit event already shipped over the WS without a plate. Added a
`plate-recognized` bus event (device-events.ts) emitted when the read is written; ws.ts
forwards it; the client `patchPlate(identity,plate)` (live-store) backfills the already-
rendered feed row in place and invalidates the Query-owned active-sessions list. No refresh.
- **Plate search didn't filter (fixed).** Both the live-feed (BoothScreen) and active-sessions
(ActiveSessions) search haystacks used the wrong field — the plate is the ENRICHED top-level
`e.plate`/`s.plate` (set by enrichEvent), not `payload.plate` (plate is unsigned, never in the
payload). Switched the haystacks to the displayed field.
- **Per-user font scale (new).** A header A−/value/A+ control scales the root font-size app-wide
(rem-based tokens scale proportionally), persisted on `users.font_scale` (migration
0014_user_font_scale, percent 100=base, clamp 80–160 step 10) and restored on login — cloning
the theme-pref pattern end to end (PUT /api/auth/font-scale, sessionView, setFontScalePref,
applyFontScale in App). i18n sq+en. Tests: 4 font-scale auth-route cases (persist+/me, clamp/
snap, 400, default). Full workspace build/lint/test green (189 server tests).
## [2026-06-28] fix | Font scale: rem-based root scaling (CSS `zoom` broke modal/footer layout)
The first cut of the per-user font scale used CSS `zoom` on the root so it would scale the app's
px-pinned type (text-[12px] etc.). But `zoom` scales the WHOLE box model including viewport-locked
containers — the h-screen app frame and max-h-[90vh] modals — so at 130% they overflowed the viewport
and modal headers/footers were pushed out of view (user had to scroll, big-modal chrome hidden).
Reworked to the correct fix: converted ALL `text-[Npx]` font utilities to rem across the web app
(~230 sites in 25 .tsx files + the .label/.hint/.btn component classes + body in index.css; 16px root
→ 12px=0.75rem etc., so 100% is visually identical), and applyFontScale now sets the ROOT font-size
(percent) instead of zoom. Only TEXT scales; vh/h-screen layout stays viewport-locked, so modals cap
at 90vh and scroll their own body — chrome never clips. Verified with Playwright: at 130% root, sample
text 12px→15.6px while the h-screen frame stayed exactly viewport-height and 90vh resolved unchanged.
Full workspace build/lint/test green.
## [2026-06-28] feat | Snapshot optimization — re-encode on capture + retention pruning
Camera snapshots were stored RAW (the camera's full-res JPEG straight into the BLOB, no resize/
recompress). Measured on the dev DB: 300 snapshots = 81.7 MB = ~72% of the 114 MB SQLite file;
the big ones 2688×1520 / ~600 KB (Hikvision main stream). Two fixes:
- **Re-encode on capture** (`snapshot.ts` `encodeForStorage`, via `sharp`/libvips, Apache-2.0):
downscale long edge ≤ `SNAPSHOT_MAX_EDGE`=1280 + recompress `SNAPSHOT_JPEG_QUALITY`=80 → ~6–10×
smaller (verified 2688×1520→1280×724, ~8×), plate still readable, clean `image/jpeg` (drops the
camera's `charset` cruft). STORAGE-ONLY — recognition keeps the ORIGINAL full-res bytes (downscale
hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot or blocks
the (already-open) path. `sharp` lives in `apps/server` (owns the capture path), where `bcrypt`
already establishes the native-dep pattern.
- **Retention** (`snapshot-retention.ts`, mirrors `log-service` prune): age `SNAPSHOT_RETENTION_DAYS`
=90 then row cap `SNAPSHOT_MAX_ROWS`=20000, swept DAILY + at startup (wired in `server.ts`). Resolves
the "retention is an open question" the schema flagged. Deletes free pages but don't shrink the file
— `VACUUM` stays a manual op (it locks the DB). None of this touches the signed ledger (snapshots
are unsigned/advisory, referenced only by id).
Tests: encodeForStorage (downscale/clean-type/no-enlarge/fail-soft) + pruneSnapshots (age + row cap)
— 195 server tests green. Env documented in komodo/.env.komodo.example. Existing 81.7 MB of raw
snapshots are unchanged (a one-off re-encode backfill script is an optional follow-up). Updated
[[entry-exit-points]] + [[technology-stack]].
## [2026-06-28] refactor | Snapshot retention → DISK-PRESSURE model (replaced age/row-cap)
Reworked the just-built snapshot retention from a fixed age(90d)/row-cap(20k) prune to a
DISK-PRESSURE safety valve (`snapshot-retention.ts` `pruneSnapshots`, now async). Daily check
reads the DB filesystem used% (`statfs` on `db.$client.name`); no-op unless ≥
`SNAPSHOT_DISK_HIGH_PCT`=70. Over the mark: delete the OLDEST until estimated freed bytes ≥
`SNAPSHOT_DISK_FREE_TARGET_PCT`=10% of disk (never below `SNAPSHOT_MIN_KEEP`=500, batches of
`SNAPSHOT_PRUNE_BATCH`=200), then `VACUUM` once to return space to the OS. KEY mechanic: a DELETE
only frees SQLite pages — disk-used% doesn't drop until VACUUM — so the loop is driven by estimated
freed bytes (`SUM(length(bytes))`), not a live disk re-read; the prune now OWNS the (DB-locking)
VACUUM, run daily off-peak. `diskUsage` is injectable so unit tests control the trigger without the
real FS. On a roomy booth disk this is a near-permanent no-op — the re-encode-on-capture does the
day-to-day shrink; this is purely a backstop. 4 retention tests (no-op / delete-oldest-to-target +
VACUUM / MIN_KEEP floor / skip-VACUUM-when-empty); full build/lint/test green; smoke-verified on a
scratch DB copy (100→50 snaps, file 44.7→38.1 MB after VACUUM). Updated [[entry-exit-points]] + the
snapshot-storage memory + komodo env. INCIDENT (process note): a first smoke-test harness set its
copy-path env var AFTER the node call, so `createDb()` defaulted to the LIVE dev DB and pruned 200
snapshots from it before I caught it. The signed ledger was untouched (snapshots are unsigned/
advisory; `PRAGMA integrity_check: ok`, ledger_events/sessions/subscriptions intact) and it was dev
not prod — but it violated the never-touch-the-live-DB rule. Lesson: pass the scratch path
explicitly + guard-refuse any non-scratch path BEFORE any destructive op (the corrected harness does).
## [2026-06-28] fix | The QR reader is a Dingtian DT-008, NOT "GEE" (correct an early mis-id)
A wrong early assumption named the QR/RFID access reader "GEE" / "GEE/Fondvision" / "GEE-QR-ER80"
(and summarized a `raw/GEE-QR-ER80….pdf` as its datasheet). There is no GEE device — it's the
**Dingtian DT-008** (https://www.dingtian-tech.com/en_us/qr_code_reader.html), the SAME vendor as
the relay board, which is why it integrates the identical HTTP-GET-push way. Corrected across the
codebase + wiki: driver symbol `geeQrReaderDriver`→`dingtianQrReaderDriver`, **persisted driverId
`gee-qr-reader`→`dingtian-qr-reader`** (migration **0015** rewrites the existing `devices.driver_id`
rows so readers keep resolving — applied to the dev DB, 2 rows; the booth applies it on boot), label
"Dingtian DT-008 QR/RFID reader (HTTP push)", and all server/test/comment refs. Wiki: renamed
`entities/gee-qr-er80.md`→`entities/dingtian-dt008-reader.md` and `sources/gee-qr-er80.md`→
`sources/dingtian-dt008.md`, rewrote both to the REAL DT-008 specs (Wiegand26/34 + TCP/IP + USB +
RS485 — NOT RS-232; QR/barcode + ID/IC/NFC — NOT DataMatrix/1D; 9–16 V/800 mA) while keeping all the
verified-on-hardware protocol facts (cjihao serial, `.jsp` path, `Connection: close`). Memory
`gee-reader-serial-binding`→`dingtian-reader-serial-binding`. The only surviving "GEE" mentions are
deliberate naming-correction notes + the raw PDF filename. Behaviour unchanged — naming + the
persisted id only. build/lint/test green.
## [2026-06-29] design | On-site encrypted backup + disaster recovery (resolves open-question #5 design)
New concept page [[backup-recovery]]. Driving scenario: the PC is stolen/destroyed and its LUKS+TPM
SSD is unrecoverable by design — recovery must stand up a NEW PC, restore a backup, and keep signing
the SAME chain. Settled: admin-driven **encrypted full-DB backup** (SQLite online-backup/`VACUUM INTO`,
snapshots INCLUDED) to **local/USB · SMB/NFS · SFTP** targets; **manual button + in-process daily timer**
(same pattern as snapshot prune); **keep-last-N + dailies** retention; restore is **admin-only/out-of-band**
(operator-adversary surface). Restored copy must still `verifyChain`.
KEY-CUSTODY decision (the load-bearing part, bears on #6): three independent keys — (1) `EVENT_SIGNING_KEY`
kept an **extractable, escrowed software key DECOUPLED from the TPM** so the ledger survives total hardware
loss [conscious trade: a TPM-sealed signing key would be unforgeable but PERMANENTLY UNVERIFIABLE after the
machine dies — same property from two sides]; (2) **new dedicated `park_buzi_backup_key`** in Komodo for
backup encryption, SEPARATE from the signing key (independent rotation; backups travel, signing key
shouldn't; keeps the #6 TPM door open); (3) LUKS/TPM disk key, appliance-only, deliberately non-recoverable.
Keys are NEVER inside the backup they unlock — recovery = backup file + both escrowed keys, out-of-band.
Updated: [[open-questions]] #5 (design SETTLED) + #10 note (backup includes snapshot BLOBs by default, future
exclude toggle); [[disk-os-hardening]] deploy env runbook (EVENT_SIGNING_KEY-not-sealed rationale +
`park_buzi_backup_key`); index catalog + concept count 45→46. Design only — NOT yet built.
## [2026-06-29] feat | Backup engine + local target (first slice of backup-recovery)
Built the durability engine designed in [[backup-recovery]]. `apps/server/src/backup.ts`: consistent
online copy via better-sqlite3 `.backup()` (NOT a raw file copy of a live WAL DB) → AES-256-GCM with a
scrypt-derived key from BACKUP_KEY, self-describing header (magic|ver|salt|iv|…|tag), zero new deps;
plaintext intermediate kept in scratch + wiped in finally; keep-last-N + dailies retention. Tested:
round-trip → byte-identical queryable DB, GCM tamper/wrong-key fails, short-key rejected, scratch always
cleaned. `backup-service.ts` (env config, single in-flight guard, last-success/error) + `routes/backup.ts`
(GET /api/backup/status backup:read, POST /api/backup/run backup:create, 409 when unconfigured; NO restore
route — out-of-band by design). New `backup` permission resource in @parking/shared. server.ts: unref'd
daily timer, no-op until configured, NOT run at startup. `openRawDb()` added to @parking/db/testing.
SMB/NFS work as mount paths; SFTP + admin UI + restore runbook deferred. build/lint/test green (212 server
tests, 25 files). Updated [[open-questions]] #5 (first slice BUILT). NOT yet committed beyond this branch.
## [2026-06-29] feat | Backup admin UI + admin-chosen target (site_config, not env)
The backup TARGET DIRECTORY is now chosen by the on-site admin in the UI, not a server env var — env
target defeats the purpose (admin can't change where backups land without editing .env + restart). Moved
to `site_config.backup_target_dir` (migration 0016, nullable); BackupService reads it fresh each run (no
restart to change). Only BACKUP_KEY stays an env secret — a key must NEVER live in the DB it backs up.
New routes: PUT /api/backup/config (set/clear target, backup:update, upserts the id=1 row), POST
/api/backup/test (server-side path probe: exists/is-dir/writable, backup:update). status() now exposes
targetDir + keyPresent so the UI tells "no target" from "no key". UI: Setup → Backup tab
(apps/web/src/BackupSettings.tsx) — editable target field + Test-target probe (localized reasons) + Save +
status panel (distinct amber "BACKUP_KEY missing" warning) + Back-up-now + restore-out-of-band note; full
i18n sq+en; nav.backup. Verified live with Playwright: typed path → Test "writable" → Save persisted →
status reflects it + key-missing warning shown. build/lint/test green (whole monorepo). Updated
[[backup-recovery]] as-built + [[open-questions]] #5.
## [2026-06-29] feat | Backup retention admin-tunable + BACKUP_KEY wired into Komodo
Same reasoning as the target dir: backup retention is operational policy the on-site admin tunes, not a
server env var requiring a redeploy. Moved BACKUP_KEEP_LAST/BACKUP_KEEP_DAILY_DAYS env → site_config
(migration 0017: backup_keep_last, backup_keep_daily_days, both nullable → code default 7/30 per field).
BackupService reads retention fresh each run; status() now exposes keepLast/keepDailyDays. PUT
/api/backup/config extended to accept keepLast/keepDailyDays (non-negative int or null=reset-to-default,
400 on negative). UI: two retention number fields on the Backup config card, one Save covers target +
retention; i18n sq+en. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads dropped). Komodo:
BACKUP_KEY wired as a per-booth secret ([[park_buzi_backup_key]] in komodo/resources.toml; documented in
komodo/.env.komodo.example as the ONLY backup env var — target+retention are UI/DB). Server .env.example
trimmed to just BACKUP_KEY. build/lint/test green (218 server tests, incl. retention persist/reset/negative
+ updated status shape). NOTE: dev API process was down after this round (live process, not code) — verified
via the full test harness, not a live click-through this time. Updated [[backup-recovery]] as-built.
## [2026-06-29] decision | Staging tier: dev → stage → main; park-buzi is the staging booth
Modelled the staging-vs-production split that fleet-deployment-komodo flagged as open. THREE tiers: dev
(working, no booth runs it) → stage (staging booth park-buzi, real-world test) → main (production, manual+
pinned). park-buzi tracks the `stage` branch + `:stage` image but is deployed MANUAL + PINNED (TAG=stage-<sha>,
NO webhook — we hold the no-moving-tag-on-a-booth line even on staging, rejecting the earlier 'webhook on
staging' sketch). Promotion = merge dev→stage when confident → CI builds :stage/:stage-<sha> → bump TAG in
resources.toml → deploy from Core. `stage` branched from dev HEAD (84f00db) so the first real-world test
carries the full current app. Changes: build-images.yml triggers on [dev, stage, main] (tagging already
branch-derived, so :stage works with no other change); komodo/resources.toml park-buzi branch=stage +
TAG=stage-84f00db; komodo/README.md promotion section + per-booth secret list now includes backup_key;
fleet-deployment-komodo open-item resolved + new 'Promotion tiers' table; container-deployment tag list +
:stage. Per-booth secrets (jwt/event_signing/backup) must pre-exist in Core for park-buzi; migrations run at
boot so a promotion auto-migrates the staging ledger (where a bad migration is caught before prod).
## [2026-06-29] fix+doc | First park-buzi backup deploy: BACKUP_KEY allowlist + container mount constraint
First real-world staging deploy surfaced two backup gotchas, both now in [[backup-recovery]]:
(1) BACKUP_KEY was wired as a Komodo secret + Stack-env line but NEVER added to docker-compose.yml's
server `environment:` ALLOWLIST — so the container came up without it (docker inspect: JWT/SIGN present,
BACKUP_KEY absent-not-empty; Backup screen "BACKUP_KEY missing"). A whole session was lost chasing Komodo
(secret name, re-sync, destroy/redeploy, env-only-change-doesn't-force-recreate) before checking the
compose allowlist. Fix: `BACKUP_KEY: ${BACKUP_KEY:-}` next to EVENT_SIGNING_KEY (commit on dev 8f32d90,
promoted dev→stage merge d0b609e → built stage-d0b609e). Lesson recorded: a new server env var ALSO needs a
line in the compose environment block.
(2) The backup target must be a HOST path BIND-MOUNTED into the container — a casually-plugged USB at
/run/media/<user>/<UUID> is invisible inside the container, so Test target rightly says "does not exist".
Provisioning = fstab-by-UUID a stable host path (e.g. /mnt/backup) + bind-mount it in prod compose + set
the in-container path as the UI target. Acknowledged limitation: backups are NOT operator-flexible (no
plug-a-USB-and-go); adding a destination is an admin host+compose change. Partly a feature vs the
operator-adversary threat model (operator can't redirect backups to a removable stick). USB-automount-to-
container flow deferred/not built.
## [2026-06-30] note | UEFI dbx / firmware update vs TPM-sealed LUKS — GRUB panic + PCR-7 re-seal + operator lockdown (park-buzi)
Real-world on park-buzi. The GNOME "Firmware Updater" (Ubuntu = the `firmware-updater` snap) surfaced a
pending UEFI dbx (Secure Boot revocation DB) update, vendor Microsoft, delivered by fwupd/LVFS — a channel
SEPARATE from APT (apt list --upgradable was clean except 2 cups packages). 2026-06-28 a dbx update against a
stale GRUB revoked the bootloader → GRUB panic / unbootable → user reinstalled Ubuntu 26.04 LTS (resolute) to
recover (fresh install ships a current GRUB). Correct order is `apt full-upgrade` (current grub-efi/shim-signed)
FIRST, then dbx.
Even with a current GRUB, applying dbx moves PCR 7 (Secure-Boot-policy measurement) → the TPM (slot 1) refuses
to release the LUKS key → next boot drops to the slot-0 passphrase prompt. VERIFIED end-to-end: proved the
slot-0 typed passphrase first, applied dbx, rebooted to a passphrase prompt, unlocked with slot 0, re-enrolled
`systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` → silent auto-unlock restored.
Gotcha: `cryptsetup open --test-passphrase /dev/sda3` SILENTLY passes via the TPM token (auto-unlocks the tpm2
slot without prompting) — a false safety signal. Force a real typed-passphrase test with
`--disable-external-tokens` (→ "No usable token is available." then prompts; success on slot 0 proves it).
Threat-model lockdown (operator is the adversary): a firmware/dbx update makes the booth need the passphrase to
boot unattended, so operators must not be able to trigger one and must never hold the passphrase. Applied on
park-buzi: `systemctl mask fwupd.service fwupd-refresh.timer` (→ masked/masked, persists), `snap remove
firmware-updater` (remove the GUI; re-check, seeded snaps can re-install), BIOS admin password gates setup
entry, slot-0 passphrase stays escrowed off-machine. Firmware updates are now admin-only/on-site/deliberate.
Recorded in appliance-provisioning.md §4 re-seal runbook + new §4a + gotchas #12/#13.
## [2026-06-30] note | Created disk-os-hardening.md (resolved a long-standing orphan)
`[[disk-os-hardening]]` was referenced from ~18 pages (overview, threat-model, tpm, fleet-deployment,
appliance-provisioning, backup-recovery, index, …) but never written — a dangling wikilink. Wrote it as
the *rationale* page (the why): the five host controls (LUKS FDE, TPM-sealed PCR-7 auto-unlock, Secure
Boot Deployed, GRUB edit-lock, unprivileged-operator) + the firmware/dbx lockdown (§4a cross-ref), each
with its load-bearing nuance, plus the standing caveat that this is the SECONDARY control —
reconciliation over the signed chain is the main anti-fraud event. Commands stay in appliance-provisioning
(the how); this page points there. Updated the index.md line accordingly.
## [2026-06-30] fix | Snapshot content-type bug — every legacy image rendered blank
Symptom: no snapshot showed in the booth modal. Root cause: Hikvision-style cameras return
`Content-Type: image/jpeg; charset="UTF-8"` (a charset param on a binary body = malformed; browsers
refuse to decode an <img> declared that way). Old capture code persisted that raw header into
snapshots.content_type (100 of 101 dev-DB rows); the serve route GET /api/snapshots/:id re-emitted it
verbatim → broken render for every legacy row. Capture was already hardened (encodeForStorage →
clean image/jpeg, fail-soft cleanType), but the serve route trusted the stored value. Fix: route now
runs cleanType(row.contentType) on the way OUT too → bare image/jpeg, un-breaks all legacy rows with
NO data migration. Verified via Playwright: a previously-unrenderable 2560×1440 row now decodes
in-browser; clean + malformed rows both load. Exported cleanType from snapshot.ts + unit tests.
Recorded in entry-exit-points.md. Lesson: normalize a device-supplied content-type on capture AND on
serve (a stored value from an untrusted camera is itself input).
## [2026-06-30] feat | Booth Active-Sessions + pay/exit modal rework
(1) The inline "Open barrier" button on paid-in-grace Active-Session ROWS was removed; the audited
re-pulse now lives only in the modal. Reason: a paid+exited session is open=false, so clicking its
row dead-ended on "already closed" — useless for the exact case (paid, barrier unconfirmed) that
needs a re-pulse. The modal now recognizes closed-within-grace (found && !open && withinGrace) and
shows the session view + Open barrier. Server reopenBarrier guard unchanged (already handled the
closed-in-grace case — the T-397815c0 fix). (2) Active-Sessions rows show a LIVE grace-remaining
countdown badge (exited · M:SS, 1s tick off graceExpiresAt) instead of a static label. (3) Settled
sessions show the ACTUAL sum paid (new SessionLookup.paidMinor, summed across payment events) not a
flat "PAID". (4) A fully-closed (grace-expired) session's modal is no longer a dead-end: it shows a
read-only review view (figures + paid amount + entry/exit snapshot strip) for dispute/audit review,
with no pay/exit/open controls. i18n sq+en parity kept; web build/tests green. Recorded in
booth-exit-flow.md.
## [2026-06-30] feat | DB reset CLI for training/demo (packages/db/scripts/reset-db.mjs)
A site is sometimes run live to train operators/admins; afterwards the demo data must go WITHOUT an
obvious self-serve button (operator must not wipe history). So: a CLI script `pnpm db:reset`, not UI.
Category flags grounded in the table map — --financial (ledger/telemetry/snapshots/subscription
instances/blocklist; keeps users/devices/config/tariffs/plans), --config, --users, --all. Because
shifts/cash/payments all live as event types INSIDE the hash-chained ledger_events, "financial" =
truncate the whole signed ledger back to empty (re-seed starts a new chain under the SAME
EVENT_SIGNING_KEY — key untouched). Two safety gates (decided with user): RESET_ALLOWED=1 env (real
booths never set it) + typed DB-filename confirmation (--yes skips for CI). Single txn + VACUUM;
re-seed admin after --users/--all. On the BOOTH there is no pnpm — only containers — so it runs via
`docker exec` into the server container (node node_modules/@parking/db/scripts/reset-db.mjs,
DATABASE_URL=/data/parking.sqlite); the script ships in the deploy bundle next to the boot migrator
(@parking/db has no `files` allowlist → whole pkg copied). Verified on throwaway dev-DB copies (both
gates refuse correctly; each flag wipes/keeps the right tables; real dev DB never touched). Recorded
in local-dev-workflow.md + appliance-provisioning.md §7d.
## [2026-07-01] feat | Card tender DISABLED until a P2PE POS is on-site (cash-only)
No card processor / POS terminal on any site yet, so offering "Card" would let an operator record a
card payment that never cleared → corrupts till reconciliation ([[threat-model]] surface). Disabled
the card option in the UI: new apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false gates both
tender pickers (BoothPayModal.tsx, SubscriptionManager.tsx); with card off there's nothing to choose,
so the tender row is suppressed entirely and payment silently defaults to cash. UI-only gate — the
Tender="cash"|"card" type, payment events, shift accounting, and reports still understand card (so
historical card events + a future re-enable stay coherent). Verified via Playwright: an unpaid-ticket
modal shows Total + "Pay + open barrier" with NO tender/cash/card row. Re-enable = flip the flag once
a bank-certified P2PE terminal is provisioned (PCI scope stays out of the app — the terminal captures
card data, not the app). New page concepts/card-payments.md documents current state + future-POS
device requirements + re-enable path; linked from index, parking-session, open-questions #3.
## [2026-07-01] feat | Drawer redesign — operator records freely, admin reviews after; moved to /drawer
Reworked drawer cash movements from synchronous admin-authorization-at-creation (operator typed an
admin's password inline at the booth for every receipt/disbursement) to operator-records → admin-
reviews-after. An operator with drawer:create RECORDS a cash_in/cash_out freely; it counts in the
drawer immediately. An admin with drawer:review AUTHORIZES/DENIES it after via a new signed cash_review
event { refId, decision, reviewedBy, note? }. THE LOAD-BEARING CHOICE (settled with user): a denial is
a FLAG, not a reversal — it never appends reversing cash and never touches the drawer balance (the
correction is the admin's/accountant's job outside the app; we are NOT building accounting). This kills
the cross-shift-leak problem the user raised: a denial that lands after the reviewed shift closed can't
pollute the next operator's inherited drawer, because it moves no cash. New `drawer` resource +
drawer:create (per-role revocable) / drawer:review permissions; migration 0018 grants operator
drawer:create. Feature moved OFF the polluted /shifts route to a top-level /drawer (operator: record +
own; admin: review queue + all). New routes/drawer.ts (lifted from routes/shift.ts, retired the
authorizer-password gate; kept shift:cash for its other job = admin-sees-all-shifts scope),
DrawerManager.tsx, drawer.* i18n (sq+en). Verified: full monorepo build/lint/test green (225 server
tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer
(record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no
authorizedBy). Recorded in shift.md "Drawer review".
## [2026-07-01] feat | Operator-issued entry + exit plate-swap reconciliation (one anti-fraud design)
Two halves of one design. (A) When the physical entry button is broken, an operator can ISSUE an entry
ticket so a real car isn't blocked out of the lot — but this hands the operator-adversary a mint, so
it's (1) PRESENCE-GATED exactly like the physical button (radar/loop present AND camera busy = a real
car; enforced BOTH sides, server re-checks so a direct POST can't bypass a disabled button; no presence
loop → feature unavailable; a no-presence attempt signs an entry.issue.noPresence anomaly), (2) FLAGGED
(vehicle_entry source=manual + operatorInitiated + operator, PLUS a companion entry.operatorIssued
anomaly), (3) capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap a legit car).
New session:create permission (migration 0019 → operator role; admin-revocable), POST /api/entry/issue
(open-shift gated), EntryFlow.issueForOperator; the fraud-critical print→sign→open→snapshot sequence
factored into one shared #issueTicket (button + operator). UI: the entry BarrierLight becomes a
clickable issue-control when presence+permission+shift meet (confirm → issue).
(B) Plate-swap fraud (user's scenario): operator scans exiting ticket 1234 (owes 10000), pockets cash
WITHOUT recording payment, mints fresh 1237 (owes ~0), lets the car out on 1237 → 1234 lingers "inside"
forever, occupancy drifts up by phantom cars. Defense = ANPR plate as invariant: the car's plate is the
same either way. ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN sessions'
entry plates — EXACT, HIGH-CONFIDENCE only (≥0.85; a fuzzy/low read never gates, ANPR is advisory). On
a match under a DIFFERENT ticket: BOOTH path returns swap_suspected + signs exit.plateSwapSuspected
anomaly + the pay/exit modal shows a red warning with "Override & release" (override signs an attributed
exit.plateSwapOverride) — flag+override, never a silent hard block (exit fails-open, plate never the
sole gate). READER path (no operator) = log-only anomaly + fail-open (user's call). Extended
BoothExitResult + /api/exit (override param), boothExit client returns a structured swap result.
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new: hold-on-swap,
override-releases-with-attribution, low-confidence-no-warning, own-plate-no-warning). New wiki pages
operator-issued-entry.md + plate-reconciliation.md; cross-linked from entry-exit-points,
capacity-occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never TRAPS a car
alone either."
## [2026-07-01] deploy | Promote dev → stage (d2ab2e0) → park-buzi, pinned TAG=stage-d2ab2e0
Merged dev → stage (no-ff, clean — stage content was fully contained in dev). Shipped to the staging
booth: snapshot content-type fix, Active Sessions/modal rework, DB reset CLI, drawer redesign (operator
records / admin reviews), card tender disabled (no POS), operator-issued entry + exit plate-swap
reconciliation. Push to stage triggered CI → built parking-server/vision :stage + :stage-d2ab2e0. Pinned
TAG=stage-d2ab2e0 in komodo/resources.toml on BOTH stage and dev (the ResourceSync's source branch is a
Core-side config, so both agree — see komodo/README.md; they're identical content anyway).
Migration note: this promotion carries migrations 0018 (drawer:create) + 0019 (session:create). BOTH are
DATA SEEDS, not schema — INSERT OR IGNORE one role_permissions row each for the built-in `operator` role;
idempotent, no CREATE/ALTER, existing data untouched. They apply automatically at container boot
(docker-entrypoint.sh → migrate-runtime.mjs, before the server starts) against the /data volume DB, which
survives the redeploy. Caveat recorded in container-deployment.md: a permission seeded to the built-in
operator role does NOT reach a CUSTOM role — an admin toggles it in Setup → Roles.
Deploy (operator, in Komodo Core): refresh ResourceSync (TAG diff enables Execute) → Execute → Deploy
(Destroy+Deploy for a clean recreate; parking-data volume persists). Watch for `[migrate] done` in logs.
## [2026-07-02] query | Physical-tamper of the booth disk + ledger signing reality (ATECC608 is upcoming, not present)
Q (operator): can a malicious user boot a live Ubuntu / reset the BIOS (coin cell or PSWD jumper) and
get root on the storage? Traced on the actual box (Dell OptiPlex 7070): the BIOS admin password DOES
gate the F12 boot menu (selecting the USB prompts for it), so the live-USB path is closed **while the
password holds**. But a CMOS reset clears the admin password + reopens the boot menu WITHOUT wiping the
Secure-Boot key DBs (SPI-flash NVRAM, not RTC), and the 7070 default is Secure Boot=Enabled → PCR 7
reconstructs to the SAME value → a signed live Ubuntu (same signing authorities) matches the PCR-7-only
seal → the TPM releases the LUKS key → root on the decrypted disk. Battery-pull alone = nothing;
battery-pull → live-USB → PCR-7 unseal = realistic root-on-data. (Disabling Secure Boot instead CHANGES
PCR 7 → passphrase prompt → locked out; the same-signer default is the hole. systemd docs: PCR 7 + PIN.)
BIGGER correction surfaced: the ledger is NOT ATECC608-signed today. No secure element is on-site. Signing
runs on the software SoftwareSigner (HMAC, key = EVENT_SIGNING_KEY, an env var on the host disk). So the
chain is tamper-EVIDENT but forgeable by whoever owns the host — the disk-decryption chain above hands
them the key too. The ATECC608 was overstated as present in several pages; it's also the wrong part for a
PC (external I²C, embedded-native) — reserve it for the deferred ESP32; the realistic host signer is the
on-board TPM or a USB HSM.
Actions:
- NEW concepts/hardware-signer-options.md — four options (USB HSM/Nitrokey HSM 2 [target], YubiKey, reuse
the TPM [free interim, bind signing key with NO PCR policy], plain USB dongle [trap, avoid]) + the
recommendation (TPM now → USB-HSM target; ATECC608 stays for embedded). Notes the signer.ts keyId seam.
- Retag pass ATECC608 → UPCOMING/NOT-PRESENT + "software-signed today, forgeable by host owner" caveat:
entities/atecc608.md (status banner + PC-vs-embedded), append-only-event-chain already honest,
standing-decisions.md, overview.md, threat-model.md, open-questions.md #6 (reframed), index.md.
- disk-os-hardening.md: fixed the live-USB row (BIOS boot-order password is load-bearing, not Secure
Boot — signed live USB runs), added a caveat banner (software signer → disk decryption = ledger
forgery) + a "Physical-tamper chain & accepted risks" section (CMOS-reset chain; accepted risks:
PCR-7 same-signer unseal, unsigned initramfs evil-maid, operator-USB read TODO).
- Verify items for the box: (a) confirm F12/one-time-boot is password-gated (done — it is); (b) after a
CMOS clear does Secure Boot return Enabled? (expected yes on Dell); (c) can the unprivileged operator
login read /data or EVENT_SIGNING_KEY?
- Residual: signer.ts still uses HMAC (no code change this pass); the load-bearing anti-fraud control
remains reconciliation + escrowed offsite backups, NOT on-disk confidentiality/signature.
## [2026-07-02] review | Vision service (apps/vision/) hardening + fix backlog
Two code reviews of the Python/FastAPI ANPR service (general: bottlenecks/bugs/best-practice,
and a security-focused pass). Filed the findings as a prioritised, not-yet-fixed to-do list at
[[vision-service-hardening]]; cross-linked from [[opencv-anpr-service]] ("consult before touching")
and cataloged in index.md. Headline items: DoS (12MB cap checked *after* the body is buffered;
`cv2.imdecode` pixel-bomb; CPU inference on the async event loop stalling `/health`);
unauthenticated **and** operator-writable model weights → persistent recognition-poisoning
([[threat-model]]); `0.0.0.0`-by-default bind at all three layers; dev compose publishing 8089 on
all interfaces; plus correctness/hygiene (cwd-relative `.env`, `/health` always-200, pre-warm
swallowing failures, unbounded `min_confidence`). Reassurance recorded: a forged image can't open a
barrier (server re-gates at 0.85 + debounce), content-type isn't trusted, non-root, `.env` not baked
into the image. Nothing fixed yet — this is the backlog to work from.
## [2026-07-04] update | Entry press gate: camera enforced, cooldown backstop, duplicate-plate anomaly
Field report from park-buzi: a BLINKING entry button (radar-only, no camera confirmation) still
printed a ticket — the [[button-light-indicator]] encoded blink-vs-solid but `#suppressReason`
only ever checked the radar. Fixed in [[entry-double-press]]: (1) CAMERA gate on the physical
press — live only in the lamp's SOLID state when an entry camera is configured; honors
[[entry-presence-bypass]]; suppress-only, so the camera stays advisory; (2) cooldown now a REAL
backstop behind presence (the old code returned early, so `entryCooldownSec` was dead in presence
mode) — bounds the stationary-car motion-radar-dropout double-ticket; (3) post-hoc
`entry.duplicatePlate` signed anomaly when the recognized entry plate is already open under a
recent session (entry-side twin of [[plate-reconciliation]]; ANPR stays non-blocking). REJECTED
along the way: camera-vetoed re-arm (defer re-arm until the lane flips free) — the camera's ~30 s
silence-timeout "free" never fires inside a queue, so it would suppress every queued car after the
first. Proper preventive fix noted open: a pass-through sensor (`passedInput`). 13 new tests
(`entry-press-gate.test.ts`, `entry-duplicate-plate.test.ts`); suite 258 green.
## [2026-07-04] update | Exit reader phantom scans traced to optical 1D decodes (sun patterns)
Pre-opening park-buzi, empty site: the exit [[dingtian-dt008-reader]] pushed spontaneous 6-digit
numeric scans (+ one lone "C") at low-sun afternoon hours; all refused fail-closed as
exit.refused.noSession. Server READ logs confirmed the reader's own serial (H05MA5B0) → the
physical device decoding, not a network source; a live snapshot confirmed nobody present.
Diagnosis: default-enabled weak-checksum 1D symbologies (I2of5 6-digit signature; "C" =
Code39/Codabar artifact) decoding sun-made stripe patterns (striped barrier arm, fence shadows,
glare). No fraud exposure (11-digit Luhn ids can't match a 6-digit read). Fix recorded on the
entity page: vendor-tool symbology cut to QR+Code128 + min length, BOTH readers, re-apply after
any factory reset (config lives on the device). Deliberately NOT filtering impossible codes
server-side — probe recording is the anomaly path's job.
## [2026-07-04] update | Backfilled missing concept pages: entry-presence-bypass + setup-relay-test
Two shipped features (2026-07-01/02) had no wiki pages — worse, six code files and
[[entry-double-press]] already linked [[entry-presence-bypass]] as if it existed. Written now:
[[entry-presence-bypass]] (admin drops a faulty radar/camera signal, granular by decision, every
flip a signed config_change, tickets stamped presenceBypassed, radar-bypass cooldown tradeoff,
"the admin is not the adversary" threat-model nuance) and [[setup-relay-test]] (admin-only
commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the
out-of-band-open fraud signal, saved-controllers-only, radarAlert lamps excluded). Cross-linked
from [[operator-issued-entry]] (bypass note) and cataloged in index.md.
## [2026-07-04] update | Reader channel tagging: printed-card-clone hole closed
Investigating the phantom scans surfaced a real vulnerability: the DT-008 push is channel-blind
and SubscriptionFlow.match matched by value only, so printing an RF card's UID (written on the
card face) as a barcode cloned the card. Fixed with channel tagging: vendor-tool output prefixes
(Q:/K:) → routes/qr-reader.ts strips + tags DeviceReadEvent.channel (optical|rf) → match requires
channel agreement, refusing a mismatch + signing a sub.refused.channelMismatch anomaly (a clone
attempt is a fraud signal). Untagged (unprefixed) reads keep legacy behavior — enforcement only
bites where prefixes are deployed. Enrollment capture stores bare values. Recorded on
[[dingtian-dt008-reader]] incl. the two device-side settings now part of the credential contract
(prefixes + Card Input format 6H — re-apply after factory reset). 14 new tests; suite 272 green.
## [2026-07-04] update | Structural read filter: phantom scans out of the signed feed
Operator-requested reversal of the earlier "do not filter" position (recorded as superseded on
[[dingtian-dt008-reader]]): phantom decodes were signing exit.refused.noSession anomalies — red
rows for nobody, training the operator to ignore the feed. read-dispatch.ts now drops a no-match
reader value that cannot possibly be ours (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to unsigned device_events telemetry (unrecognizedRead:true). The
plausibility rule is deliberately wide so every real probe (forged ticket shape, unknown physical
card, unknown SUB- code) still signs the normal anomaly; enrolled credentials match before the
filter and can never be hidden by it. Works for legacy unprefixed reads too, so the feed cleans up
before the vendor-tool visit. 6 new tests; suite 278 green. Also this session: reader channel
tagging (clone defense) — see the prior entry.
## [2026-07-04] update | Logging: 2-month rotation, ISO timestamps, level names
Operator asked for bounded container logs (~2 months), human-readable timestamps, and clarity on
levels. Findings + changes on [[app-logs]]: levels EXISTED (LOG_LEVEL env, pino, warn+ teed to
app_logs); the "level":30 numbers and epoch-ms times were pino defaults — the logger now stamps
ISO-8601 UTC + level names (pinoDbStream hardened to accept both encodings so the DB tee can't
silently break). Rotation: docker json-file caps in docker-compose.prod.yml resized from 10m×3
(≈30 MB!) to ≈2 months by volume (server 20m×30, vision 20m×10, proxy 10m×5; json-file rotates by
SIZE — time-based isn't a driver feature). app_logs retention default aligned 30→60 days.
## [2026-07-05] update | Window-package tariff mode (packageMinor) + honest flat labels
Tariff-lab verification of a 1,850 ALL bill exposed a field misread: the V2 card "flat price" is
PER INCREMENT (400/h), not per window — park-buzi's "night 400" card billed each night hour 400.
Built the missing concept on [[tariff-time-tiers]]: `packageMinor`, a whole-window package
("any presence in 20:00–07:00 = 400 total"). Operator decisions: per-occurrence repeat (two nights
= two charges), any-touch-pays-full, windowed-cards-only (base "price per day" = a 1-row up-to
table). Engine charges once per contiguous run of increments the card wins, tracked across
rolling-day segments (a night crossing the 24h boundary charges once). Validator: exclusive with
flat/blocks/steps, no per-card cap, defaultCard forbidden. Composer offers the mode on tier cards;
flat relabeled "Flat price / hour" (sq+en). Also flagged from the same session: windowed-card
dailyCapMinor is inert by design (only the base card's cap clamps a day) — park-buzi's weekend
card carries a dead 1000 cap. Tests: shared 93 green (6 new), server 283 green.
## [2026-07-05] update | Tariff Lab redesign: DB-backed drafts, sidebar, composer modal
The lab previously simulated only against PUBLISHED versions, so experimenting meant publishing —
churning the immutable history and risking real tickets pricing against a half-baked card while
the admin iterated (operator: "we risk taking tickets with a grossly wrong version"). Redesign on
[[tariff]] (Tariff Lab section): new mutable `tariff_drafts` table (migration 0021 — the one
deliberate exception to "editing publishes a version"; a draft prices/signs nothing, only the
normal publish path makes it real), drafts validated + tz-stamped on save exactly like a publish,
CRUD under /api/tariff/drafts (list tariff:read, mutations tariff:update). UI rebuilt: sidebar
lists active card + drafts (click to price against), main pane cut to pure entry/exit (ticket
loader, payment, category inputs dropped), composer form extracted to TariffEditorForm.tsx and
reused in a modal (new drafts prefill from the active card), per-draft Publish with confirm.
Simulation passes the draft's stored structure inline to the existing /api/tariff/simulate.
Tests: server 288 green (5 new: RBAC, roundtrip, validation, tz-stamp + simulate + publish flow).
## [2026-07-05] update | Published tariff versions get optional names + lab sidebar lists history
Follow-up to the lab redesign (same session): the sidebar now also lists the PUBLISHED versions
(active first, then the immutable history; click to price against by tariffVersionId), and
`tariff_versions` gained a nullable `name` (migration 0022) — stamped at publish, immutable like
the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew
an optional version-name field (never prefilled — republishing a tweak under last season's name
would mislabel history). Details on [[tariff]] (Tariff Lab section).
## [2026-07-05] update | Drawer hub: balance now, live daily activity, shift history
Operator: "/drawer is too simple — no daily activity, current shift incomings not reflected, closed
shifts history missing, drawer current state not shown." Rebuilt DrawerManager as a five-panel hub
(details on [[shift]] §Drawer hub): Drawer-now panel (new GET /api/drawer/balance exposing the
service's existing drawerBalance(); open-shift X-report breakdown alongside so float + takings +
vouchers = expected = balance is explicit), today's cash feed (existing /api/events since local
midnight, cash-only, live), the unchanged record/review flow, and a drawer-focused closed-shifts
list (existing scope-aware /api/shifts). No new ledger surface — one read-only endpoint, everything
else composes what the chain already records. RBAC test added (shift:read 200 / other 403 / anon 401).
## [2026-07-05] update | Drawer "this shift" figure + spinners on shift buttons; O(chain) flagged
Follow-ups to the drawer hub (same session): the Drawer-now panel gained "This shift: ±X"
(expected − opening float — the shift's own contribution vs what it inherited), and every shift
open/close button got an animated spinner + dim while busy (ui/Spinner.tsx) — the old label-swap
read as a dead click on a slow open. Root cause of the slowness recorded as an open item on
[[shift]]: drawer/shift reads fold the WHOLE chain (O(chain) — grows forever); fix when it bites =
fold from the last z-report's signed expectedDrawerMinor forward. Also from this session: dev-DB
migrations are MANUAL (pnpm db:migrate in packages/db) — a 500 "no such column" after pulling a
migration means it was skipped; booth containers migrate on boot and are immune.
## [2026-07-05] update | Camera health-check detail bucketed — device-monitor log noise fix
The monitor logs + re-emits a device status only when state OR detail changes, but the camera
probe's detail was the exact snapshot byte count — which differs on every JPEG frame, so healthy
cameras "changed" on nearly every poll (a log line + websocket event each, inflating the freshly
budgeted container logs). Camera healthCheck detail is now a stable power-of-two bucket
("snapshot ≈16 KB" / "≈256 KB"; "<1 KB" stays exact as a suspicious-frame flag), so it moves only
on a real shift (stream/resolution change, empty body). packages/devices camera.ts + 3 tests.
## [2026-07-05] query | Industry survey: most-used parking tariff systems
Web research filed as [[tariff-industry-survey]] (reference). Field taxonomy: linear-hourly per
started increment (per-MINUTE tried and rolled back — Leuven), degressive ladders, day caps,
grace; whole-stay prices (day tickets, up-to matrices, evening/overnight packages, event rates);
entry-time-conditioned EARLY BIRD (enter-before cutoff, anti-arbitrage exit conditions); calendar
windows (day/night, weekend/holiday, seasonal); category pricing; monthly contracts; merchant
VALIDATIONS (amount/percent/time-credit/full-comp/re-rate); dynamic demand pricing (SFpark 60-80%
occupancy banding — municipal/airport scale only). Coverage map: our engine expresses everything
a staffed single lot advertises; the two real gaps are early bird (= the pick-table-by-entry-time
future design, same mechanism as weekend menus) and validation overlays. Anti-features confirmed:
per-minute billing, dynamic pricing.
## [2026-07-05] update | Composer page gets the published-versions sidebar too
The published-history sidebar built for the lab landed only there; the operator expected it on
/setup/tariff as well. TariffComposer now mirrors it: right sidebar of published versions (name or
effective date, active badge), click → formFromVersion loads it into the editor as the seed for
the next publish (which, per the sidebar hint, always creates a NEW immutable version). Details on
[[tariff]] (Composer section).
## [2026-07-05] update | Reports dashboard: occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
Operator ask on /reports. Added the parking-shaped graphics (details on [[reporting-analytics]]):
occupancy step-area vs capacity line (prior-ledger fold for range-start, per-bucket occupancyEnd),
entries heatmap hour×day-of-week (replaces the flat hour histogram; feeds tariff-window design per
[[tariff-industry-survey]]), stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h),
voids+anomalies KPI counters (accented when >0 — the look-closer signal), cash/card stacked revenue
bars, peak-occupancy KPI, richer CSV (cash/card/occupancy_end). Server: reports.ts aggregation +
Intl formatter cached per tz; db package re-exports lt/gt. 5 new tests (295 server green).
## [2026-07-06] update | USB printer truncation fixed (NONBLOCK partial write) — ICS XP-K200L
First on-hardware USB print test failed exactly as the transport bug predicts: text printed,
barcode + cut missing (both live in the dropped tail). sendRawUsb did ONE write() on an O_NONBLOCK
usblp fd and never checked bytesWritten — anything past the printer's ~8 KB USB buffer was
silently discarded; TCP was immune. Fixed with writeAllUsb (4 KB chunks, partial-write
continuation, EAGAIN retry, deadline with N/M diagnostic) + 4 fake-handle tests. Also verified on
hardware (10.0.10.11): the ICS XP-K200L serves NO /prn_stat.htm → on network use the cashino
driver (reachability-only), not rongta, or monitoring calls a working printer offline. Details on
[[printer-usb-transport]].
## [2026-07-06] update | Driver rename: "cashino" → "escpos" (generic ESC/POS printer)
The ICS XP-K200L exposed that the reachability-only clone driver carried its first unit's vendor
name — "cashino" in the setup UI was misleading for every other clone. Renamed properly:
printer-cashino.ts → printer-generic.ts, id "cashino" → "escpos", label "Generic ESC/POS 80mm
printer (Cashino, ICS/Xprinter…)". Stored rows rewritten by migration 0023; the registry keeps a
PERMANENT cashino→escpos alias so restored pre-rename backups still resolve. Prose mentions of the
Cashino as hardware stay (it's a real printer). Driver guidance for the XP-K200L: escpos on both
transports (it serves no /prn_stat.htm — verified; rongta would false-flag it). See
[[printer-usb-transport]], [[printer-status-monitoring]].
## [2026-07-06] update | Setup wizard: printers no longer forced to bind to a barrier relay
Operator hit the wizard's blanket "pick the controller and relay this device sits at" gate while
adding the ICS printer. The binding (controllerId+relay → which barrier a scan opens + inherited
direction) is load-bearing for READERS and CAMERAS only; nothing consumes it on a printer —
printer routing is role + failoverRank (printer-routing.ts). Wizard now skips the requirement,
hides the "Cilën barrierë shërben kjo pajisje?" panel, and stops persisting the binding for
printers (a stale pre-fix binding drops off on next edit); the device list shows the printer's
ROLE instead of a bogus amber "unbound". Server never required it (no validation change).
## [2026-07-06] update | Tariff Lab: fee breakdown — "how is this sum produced"
Operator: a lab outcome of "ALL 740 / 3h 2m" gave no derivation. Added explainFee to
@parking/shared: the SAME computeFee walk with an optional trace collector (zero fee change —
golden V1 regression still green), so Σ line items ≡ the amount by construction. Items: banded
same-price increment runs (time window · N × unit · card name), window-package occurrences,
stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE adjustments, entry
grace. /api/tariff/simulate returns `breakdown` (null when settled); the lab's Outcome panel
renders it as a lined table with the rounding note (raw min → billed min at the increment) and a
total row. 4 new engine tests pin the sum invariant + item shapes. This also largely delivers the
wiki's open "composer price preview" item — see [[tariff]].
## [2026-07-06] update | Composer: increment-unit price labels + ≠60 warning (the 60→10 trap)
Operator walked into the sharp edge the wiki flat-rate warning had already named: ladder/flat
prices are PER BILLING INCREMENT, so changing "Intervali i faturimit" 60→10 silently multiplies
every price ×6, while the price header just said "Çmimi / interval". Composer now: price labels
are DYNAMIC ("Çmimi / orë" at 60, "Çmimi / {{N}} min" otherwise — same for the flat-mode radio),
and an amber warning appears whenever the increment ≠ 60 ("çdo çmim faturohet për çdo N minuta,
JO për orë"). Band DURATIONS stay in hours — they're real wall time, increment-independent (the
operator asked if "orë" there was wrong; it isn't). See [[tariff]] (§increment).
## [2026-07-06] update | UI-wide date standard ("25 Qer") + currency-scaled composer examples
Two operator UX complaints. (1) Dates were a mix of browser-locale "7/6/2026" (raw
toLocaleString) and catalog "25 Qershor" — unified: formatDate/formatDateTime/formatClock in
lib/format.ts using new common.monthsShort ("25 Qer 14:30", year only when ≠ current, 24h clock);
formatRelativeDateTime switched to short months; ALL ~20 raw toLocale* date call sites swept
(shifts, subs, plans, drawer, snapshots, device footer, event detail, tariff composer + lab incl.
the fee-breakdown row times). Number toLocaleString (thousand separators on money) untouched.
(2) Composer example defaults were euro-scaled ("2.00"/hour ≈ 2 lekë) — now currency-aware
(ALL: 200/100 ladder, 200/500 steps, 2000 lost ticket; EUR/USD keep 2/1/2/5/20), threaded through
emptyForm/emptyLadder/emptyTier/pricingFromCard so a mode switch on an ALL card also shows lek-
plausible templates. Blank-form currency stays ALL.
## [2026-07-06] update | seed-admin signs a ledger event; lost-app-admin-password runbook (§7e)
Follow-through on the FK fix: seed-admin.mjs now appends a signed config_change
(admin.passwordReset / admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer from dist/ — a console reset by the Linux admin can't gate on the app, but it
stays attributable in the chain. Best-effort: no build/key → loud warning, seed still proceeds
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.
## [2026-07-06] update | Runbook §5c: gpasswd -d, not deluser (hyphenated-username perl bug)
Demoting the operator on park-buzi hit `sanitize_string: invalid characters in 'park-operator'` —
Ubuntu's perl adduser/deluser tooling rejects the hyphenated username. [[appliance-provisioning]]
§5c now uses `gpasswd -d <operator> sudo|lxd|lpadmin` (shadow-suite, no perl sanitize) and notes
that group removal applies at NEXT login — the auto-login operator session keeps old memberships
until reboot/relog, so verify `groups` from inside the session afterwards.
## [2026-07-07] update | Periphery v2.2.0 --user installer writes /etc/komodo root_directory
Lab test box (park-test): periphery crash-looped at startup — panic writing the private key to
/etc/komodo/keys/periphery.key, Permission denied. Gotcha #9 was documented as a hand-config
hazard, but v2.2.0's installer now DEFAULTS root_directory to /etc/komodo even with --user (the
June park-buzi install defaulted under $HOME). [[appliance-provisioning]] §7a now says: verify
root_directory after every install + the sed one-liner fix; also notes `sudo systemctl restart
periphery` → "unit not found" (user unit) and that the single-use onboarding key survives a
pre-connect crash.
## [2026-07-07] update | Setup: printers addable with NO controller configured
Lab bench (USB printer test, no relays on hand) hit a SECOND printer/relay coupling the
2026-07-06 fix missed: the category section's add-button gate ("Shto fillimisht një kontroller…")
blocks every non-access category while zero controllers exist. Printers are now exempt there too
— the binding fix removed the requirement inside the form; this removes the gate in front of it.
A controller-less box can configure + test a printer.
## [2026-07-07] update | USB truncation, mode 2: close() kills the in-flight usblp URB
Lab hardware test of the chunked-write fix STILL truncated (slip stopped mid-sentence, no cut,
text hidden until the feed button). Root cause verified against kernel usblp.c: write() returns at
URB submission; one URB in flight; usblp_release (close) kills it; the printer drains at print
speed — so the accepted-but-untransferred tail (incl. feed+cut, always the last bytes) died at
close. writeAllUsb now holds back the FINAL byte as its own write — usblp's one-URB rule makes its
acceptance a completion certificate for everything before it — then drains 300ms for that single
packet before close. Tests updated (+ final-byte-alone assertion). [[printer-usb-transport]] has
the full kernel-level account.
## [2026-07-07] update | USB printing HARDWARE-VERIFIED; wizard lists real /dev/usb devices
Lab retest after the close-cancel fix: full slip + feed + CUT over USB — parity with TCP; the
transport is done. Follow-up UX (operator had to `ls /dev/usb` to find lp1 on park-buzi): new
GET /api/setup/usb-printers enumerates /dev/usb/lpN + sysfs ieee1284_id make/model; the wizard's
devicePath is now a select of PRESENT printers (fresh form preselects the first; a saved-but-
absent path stays selectable, flagged; none found → free-text + hint). Transport option label no
longer hardcodes lp0. Details on [[printer-usb-transport]].
## [2026-07-07] update | Camera clock sync via ISAPI — the 1970 power-cut reset healed
park-buzi observation: power-cut Hik cameras reboot at the 1970 epoch (no RTC battery, no NTP)
until a web-UI login pushes the browser clock — corrupting snapshot OSD timestamps (evidence) and
ANPR push times meanwhile. Built host-as-time-authority sync (details on [[lpr-camera]] §Clock
sync): device monitor triggers at the offline→ready edge + 24h backstop; HikvisionCamera.syncClock
GETs /ISAPI/System/time, and beyond 60s drift PUTs manual time with the site's wall-clock + explicit
offset, echoing the camera's timeZone verbatim; >1h jumps log warn (persisted). digest client
generalised GET→GET/PUT/POST with body (the handshake was already method-aware). Capability-guarded
(isClockSyncable — hikvision only). 8 new tests (5 devices, 3 tz-offset).
## [2026-07-07] lint | Wiki catch-up sweep after the lab-bench sprint
Audit found five pages lagging the log: [[rongta-printer]] still named the `cashino` driver id
(→ escpos + migration note); [[tariff-time-tiers]] still listed the composer price preview as
deferred (→ delivered by the lab fee breakdown); [[tariff]] lab section gained the breakdown +
composer increment-guard paragraph; [[i18n]] now records the "25 Qer 14:30" date standard and the
never-toLocaleString-for-dates rule; [[fleet-deployment-komodo]] gained the park-lab stack + tier
table (also logging the park-lab addition itself, which had slipped the log).
## [2026-07-08] update | Log-storm hardening + reset-db drift guard
Field incident 2026-07-07: an unreachable UHPPOTE (`ENETUNREACH 10.0.10.5:60000`) put the
button-light `#pump` worker in a zero-backoff hot loop — hundreds of identical `setAux failed`
error rows per minute into [[app-logs]]. Three-layer fix: (1) failed sends now arm a 1s→30s
exponential retry (reset on success), with only the first failure logged, one summary/minute
after, and one info on recovery ([[button-light-indicator]] §Implementation); (2) LogService
coalesces a row identical to the last (level+source+message+path, 5-min refreshing window) by
bumping `context._repeat` instead of inserting — the viewer badges `×N` ([[app-logs]]);
(3) the user's training reset had ALSO left logs behind: `app_logs` and `tariff_drafts` belonged
to no reset-db category, silently surviving even `--all`. Added `--diagnostics` (app_logs), put
tariff_drafts under `--config`, and a drift guard that refuses to run when any table is
uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests
(3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB.
## [2026-07-13] decision | Cloud service — multi-tenant SaaS (postponed, context captured)
From a design conversation, not a source. The user floated an online, multi-tenant SaaS (the
"cloud service") on TOP of the offline backup model (which stays, as the offline-site tradeoff):
subscribing park sites get real-time (link-up) monitoring of the signed ledger, device status,
and financial reports; one admin owns many sites; the cloud custodies per-site secrets; recurring
per-site fee = a revenue line. Recorded as [[cloud-service-saas]] (status: open, POSTPONED per the
user) so it isn't re-derived later. It productises the off-site control plane already stood up in
[[fleet-deployment-komodo]] (Komodo Core + NetBird). Captured: the four hard tensions (offline-first
vs real-time; the ledger must be VERIFIABLE not just displayed in the cloud; central secret custody;
two-level tenancy under operator-as-adversary), the secrets boundary the user confirmed (sync creds
+ device-password ESCROW + app identity — but NOT the signing/ATECC608 key, which stays on the
booth), and TWO in-discussion corrections that stand: (1) NetBird already solves the "cloud reaches
booth" isolation objection — park-buzi is monitored that way today, booth-dialed, nothing exposed;
(2) remote barrier-open is COMPATIBLE with [[barrier-not-a-door]] (it's `pulseOpen`/intent, never
timed-close) and is DRIVEN by the [[autonomous-direction]] unmanned future — gated as a distinct
privilege + a signed ledger event with actor+reason, with the booth as enforcer and a local
fail-open that can't depend on the cloud. Four open questions parked (real-time definition, where
reports are computed, hosting/licensing, custodianship-as-liability). Cross-linked; index count
7→8 decisions.
## [2026-07-13] update | Owner requirement — in-park merchant validations (car-wash "lavazh", bar)
The [[validation-discounts]] feature is now asked-for, not just an industry-survey gap: the park
may host an in-park car-wash and/or bar whose customers the owner wants discharged for the stay —
full comp, free-first-N-minutes (`time-credit`), or consumption-offset (`fixed`, variable amount:
300 ALL consumed vs 500 ALL fee → pay 200). Must be admin-composable at runtime like
tariffs/subscription plans. Driving-cases section added to [[validation-discounts]]. Open: merchant
ownership (owner-run → pure discount; tenant → [[validation-sponsorship]] settlement), who applies
(operator vs merchant code/portal), stacking rules, caps.
## [2026-07-13] update | Merchant validations refined — merchant STATIONS (users), not sponsors
Second pass on the [[validation-discounts]] requirement: ownership immaterial, sponsor layer
dropped. Merchant = a system user on their own device who scans the ticket to validate (signed,
attributed); admin checkbox per station = may collect parking payments (then shift + till +
Z-report apply to them like the booth); paid/zero-due tickets self-exit at the reader.
Consequences: per-station shifts/drawers (breaks the site-wide single-open invariant), exit-reader
live due=0 branch. Details on [[validation-discounts]].
## [2026-07-13] update | Merchant validations settled — validation-only merchants, all money at the booth
Third pass, settled: the merchant-collects-payments variant is REJECTED. Merchant users only scan
+ validate (signed, attributed); every car checks in at the booth to settle (net may be 0 — still
a signed payment) and gets the detailed gross/discount/net receipt there. Per-station
shifts/drawers and the exit-reader due=0 branch are no longer needed — shift/drawer/exit flows
stay as built; Z/X-reports gain discount lines. Build surface: validation_programs master data,
signed validation event, priceSession validations[] extension, merchant scan page, booth
quote/receipt/Z-report lines. Details on [[validation-discounts]].
## [2026-07-13] decision | Merchant validations — design SETTLED, build started
Setup UX on /setup/site (Bar/Lavazh checkboxes → right-column config panel, tabs when both);
fixed UI over generic storage (validation_programs + user binding, well-known bar/lavazh rows,
mutable config — the signed validation event carries resolved values); RBAC = new `validation`
resource (create/read), guard = permission AND station binding; merchant-only users land on
/validate; merchants may void their own unused validation. See [[validation-discounts]].
## [2026-07-13] update | Merchant validations BUILT end-to-end (bar / lavazh)
Shipped the settled design: `validation` permission + ledger event (resolved values, refId-void),
priceSession validations[] canonical fold (timeCredit→percent→fixed→comp, Σ lines ≡ gross−net),
validation_programs(+users) tables (migration 0024, reset-db config category), routes/validations.ts
(programs PUT signs config_change; apply guards: binding → open transient → no dup → maxPerDay →
amount cap; void own-unused-only), PayStation quote/pay/lookup net folding + payment consumption
(grossMinor/discountMinor/validationIds/validationLines), receipt gross+discount lines, Z/X-report
discountTotalMinor ("Zbritje (validime)", printed only when >0), /setup/site two-column Bar/Lavazh
checkboxes + config panel (tabs), /validate merchant screen (merchant-only users land there),
booth-modal gross→lines→net, feed label VALIDIM. 8 new route integration tests + shared fold suite;
workspace build/typecheck/test green. As-built + remaining polish on [[validation-discounts]].
## [2026-07-13] decision | Merchant scan input: HID barcode scanner on web/desktop; camera paths postponed
The bar/lavazh stations use a USB/HID scanner (or hand-keying + Luhn) into /validate on the
web/desktop app. Two evaluated camera alternatives deliberately POSTPONED: web getUserMedia
scanning (blocked on secure-context TLS for LAN phones + weak Code128-via-camera — would want
QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning via the official
barcode-scanner plugin; deferred over Android build/distribution overhead + the
configurable-server-URL prerequisite). Full analysis on [[validation-discounts]].
## [2026-08-23] ingest | HIKVISION DS-2CD1047G3H-LIU-F datasheet
Vendor datasheet dropped in `raw/DS-2CD1047G3H-LIU.md`. Key new fact: main stream on this model
supports H.265+/H.265/H.264+/H.264 only — **no MJPEG**; sub-stream adds MJPEG. Likely mechanical
explanation for the persistent ISAPI main-stream snapshot 503 (`deviceBusy`) already logged on this
model in [[lpr-camera]] (2026-06-26/27): the on-demand JPEG snapshot has no native path on main,
so it has to transcode from H.264/H.265 live, which this SKU's firmware apparently can't do
reliably at 2560×1440. Bitrate spec (32Kbps–16Mbps) also confirms the site's main-stream config
(6144–12288Kbps) was never out of range — rules out misconfiguration definitively.
## [2026-08-23] query | Main-stream 503: model-specific or config? RTSP as a workaround?
Live-compared two Hikvision units at park-buzi via their public port forwards
(`park-buzi.msai.al:8081`/`:8082`) plus the `:8082` unit directly on the LAN (`10.0.10.13`).
Channel-101 (main) config is **byte-identical** between a working `DS-2CD1043G2-LIU` (8081, 200 OK)
and the failing `DS-2CD1047G3H-LIU` (8082 / `10.0.10.13`, persistent 503 `deviceBusy`, 3/3 retries
instant) — same resolution/bitrate/framerate/SmartCodec state. Rules out config as the cause;
confirms it's model/firmware-specific (matches the datasheet finding above). Then tested RTSP
(`rtsp://…@10.0.10.13:554/Streaming/Channels/101` via ffmpeg, TCP transport) against the SAME
failing camera: returned a valid 2560×1440 JPEG on the first try. RTSP taps the continuously-
running encode rather than asking for an on-demand re-encoded JPEG, so it sidesteps whatever the
ISAPI snapshot path chokes on. Not yet built into `camera.ts` (would add an `ffmpeg` child-process
dependency + RTSP auth/transport handling) — filed as a viable, proven fallback if full-resolution
main-stream stills are ever needed; sub-stream ISAPI snapshot remains sufficient for current ANPR
use. Full comparison table + RTSP command on [[lpr-camera]].
## [2026-08-23] update | Firmware update tested and ruled out; sub-stream confirmed too weak for ANPR — RTSP is now required
Two developments on the DS-2CD1047G3H-LIU (`10.0.10.13`) main-stream 503: (1) the owner reports the
sub-stream (768×432) **fails to read plates "from time to time"** in real use — sub-stream-only is
no longer an acceptable mitigation, it's an accuracy problem. (2) Before building RTSP, tested
whether this was a day-one firmware bug: the camera's original `V5.8.11`/250415 build was confirmed
(via Hikvision's own release note) to be the FIRST H13U firmware to support this camera family at
all. Upgraded live to `V5.11.0`/260701 (~15 months newer, spanning an intermediate release that
explicitly claimed "image stability" fixes). Result: **no change** — identical `deviceBusy` 503,
5/5 attempts, post-upgrade. Firmware is now a ruled-out cause, not a theory; this looks like a real
encoder/hardware ceiling on this SKU. Next step: build the RTSP-based main-stream capture path into
`packages/devices/src/drivers/camera.ts` (not yet started). Full detail on [[lpr-camera]].
## [2026-08-23] update | Root cause nailed down: broken ISAPI handler, not "busy" — decision to REPLACE the camera line
Final test on the DS-2CD1047G3H-LIU snapshot 503: swept every channel/stream ID against
`GET .../channels/<id>/picture`, including nonexistent ones (1, 100, 103, 201, 999). Every single
ID returns the identical `503 deviceBusy` body EXCEPT exactly `102` (the real sub-stream), which is
always 200. A real busy/saturated encoder would not succeed on one specific value while failing
garbage IDs identically — this is a generic fallback error: the firmware's snapshot handler is only
correctly wired for channel 102, and everything else (valid main-stream 101 included) falls through
to a stock, mislabeled "Device Busy" response. Confirms the firmware-upgrade non-result from
earlier today (a wrong-code-path bug wouldn't be fixed by more capacity). Owner's decision: replace
the DS-2CD1047G3H-LIU units rather than carry an RTSP/ffmpeg workaround dependency — the sibling
DS-2CD1043G2-LIU (no such bug, ISAPI main-stream snapshot works natively) is the reference model
going forward. RTSP main-stream capture remains documented as a proven, viable fallback if a G3H
camera is ever unavoidable, but is not being built. Full sweep table + reasoning on [[lpr-camera]].
## [2026-08-30] update | Booth USB printer cover-open bug: leading theory is a stale container bind-mount, not a stale app-layer handle
Live troubleshooting request (park-buzi): opening the printer's paper-roll cover reliably wedges its
status to offline/faulty, surviving a full appliance reboot; only `docker restart server` clears it.
Traced `sendRawUsb`/`probeUsb` end-to-end in `printer-escpos.ts` plus both poll loops
(`device-monitor.ts`, `printer-monitor.ts`): every print AND every poll does a fresh
open→write/probe→close with no persistent fd/socket/driver instance anywhere — ruling out a naive
"stale Node handle" explanation. Leading hypothesis instead: the cover-open microswitch cuts power
to the printer's USB interface board, causing a real bus re-enumeration; the container's directory
bind-mount of `/dev/usb` (chosen specifically to survive `lpN` renumbering) can retain a stale view
of the old device node until the container's mount namespace is recreated — which `docker restart`
does and a policy-driven reboot-time restart may not (boot-order race). Not yet confirmed on
hardware (host-vs-container `stat`/inode comparison at the next occurrence is the next step); lab
repro is blocked because the lab has a RONGTA, not the park-buzi unit's actual (still unidentified,
"Generic (unknown)") model. Full writeup, confirmation commands, and candidate fixes on
[[printer-usb-transport]].
## [2026-08-30] update | Backup status "Never" despite valid rotating backups — restart amnesia in BackupService, fixed
Admin noticed park-buzi's Backup screen showed "last successful backup: Never" despite 7 real,
correctly-rotating encrypted backup files on disk, plus a 2-day gap since the last file. Traced
both symptoms to the same cause: `BackupService` tracked last-success/last-error as PLAIN
IN-PROCESS FIELDS (never written to the DB), and the daily schedule was a `setInterval(...,24h)`
measured from PROCESS START, not wall-clock time since the last real backup — so any server
restart (routine under `restart: always`: deploy/crash/OOM/host reboot) simultaneously wiped the
visible status back to "Never" and reset the 24h countdown, independent of the actual
file-writing/retention engine (`backup.ts`), which was working correctly the whole time and
explains why files existed on disk despite the UI's contradictory-seeming status. Fix: four new
nullable `site_config` columns (migration `0025_backup_last_status.sql`) persist last-success/
error there instead of in memory; `BackupService.status()` reads them fresh each call so a new
instance (= a restart) sees the prior instance's outcome; a new `isDue()` method computes
schedule-due-ness from the persisted last-success timestamp; `server.ts`'s scheduler is now a
15-minute poll gated by `isDue()` instead of a 24h `setInterval`, making the real cadence immune
to restart timing. New test file `backup-service.test.ts` (6 tests) covers restart-durability and
`isDue()` directly; full existing suite (319 tests) still green. No API/UI contract change. Not
yet committed (holding per instruction). Full writeup on [[backup-recovery]].
## [2026-08-30] update | Two Komodo Periphery gotchas: connect_as renaming, agent upgrade procedure
Two real incidents this session, both closed out as new gotchas (#12, #13) on
[[appliance-provisioning]] §7: (1) a lab box installed with a leftover template placeholder
left in `--connect-as` kept reappearing under that name in Core no matter how many times it was
renamed in the UI — because `connect_as` is a plain field in the agent's own
`periphery.config.toml`, and a Core-UI rename never touches it; fixed by editing the field
directly on the host + `systemctl --user restart periphery`, no reinstall needed. (2) Upgrading
Periphery from a version-mismatch (Core bumped to v2.3.2, an agent still on v2.2.0) has no
separate update mechanism — confirmed against Komodo's own `setup-periphery.py` source that
re-running the same installer with unchanged `--connect-as` is config-preserving (it explicitly
skips rewriting an existing config) and safe; verified dry-run on `art-docker-station` (lab) then
applied to `park-buzi` (live booth) with no disruption to the running app containers. Full detail
+ exact commands on [[appliance-provisioning]].
## [2026-09-03] fix | Desktop updater endpoint was unreachable — pointed at a private repo
The Tauri auto-updater ([[desktop-shell-tauri]]) was fully implemented — signed builds, keypair,
`latest.json`, `release.yml` — but its endpoint pointed at `mca/parking_solution`'s own Gitea
"latest release" redirect, and that repo is **private**. Field appliances have no Gitea
credentials, so every update check was silently failing (caught by a `try/catch`); this was never
actually field-verified end to end. Fix: signed installers now mirror to a new public,
installers-only repo `mca/public_releases` (org-shared, not parking-specific), published to a fixed
`desktop-latest` tag so other apps releasing there later can't shadow ours. Considered and rejected
embedding a `read:repository` token in the app instead — ruled out given the appliance's own threat
model (booth operator as primary adversary) makes an extractable, hard-to-rotate credential in every
deployed binary worse than just publishing installers publicly. `release.yml`,
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
[[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop login broken by a VITE_API_BASE regression from the booth same-origin fix
The 2026-06-27 booth fix (commit 96fd97e) correctly blanked `apps/web/.env.production`'s
`VITE_API_BASE` for the browser/booth same-origin case, but the desktop build shares that same
file and was never given its own override — the desktop shell has been building with an empty
API base since that commit, unnoticed until now. Symptom: login threw `DOMException: "The string
did not match the expected pattern."` — WebKitGTK rejecting a relative `fetch()` URL with no base
to resolve against, since the desktop window's origin is `tauri://localhost`. Browser login was
unaffected (same-origin, no absolute URL needed), which is why this went unnoticed through the CI
mirror-repo debugging session. Fixed by setting `VITE_API_BASE=http://127.0.0.1:3000` inline in
`tauri.conf.json`'s `beforeBuildCommand`, overriding the shared `.env.production` for the desktop
build only (process env wins in Vite's load order) — verified both builds independently. Full
detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop updater silently failed: tag/version drift + swallowed install errors
Two compounding bugs, both closed out on [[desktop-shell-tauri]] §"Desktop in CI": (1) the v0.1.1
release bumped only the git tag — tauri.conf.json's own "version" field (what Tauri actually bakes
into the bundle filename and internal version) stayed at 0.1.0, so the signed binary didn't match
what latest.json claimed to describe, and signature verification failed on every download; (2)
desktop-updater.ts's single blanket try/catch swallowed that failure identically to "offline/no
update," so the operator saw the prompt, watched it download, then nothing — repeating forever with
zero diagnostic trail. Fixed release.yml to sed-patch tauri.conf.json's version from the git tag
right before building (checked-in value is now dev-only, never hand-maintained for releases), and
split desktop-updater.ts's catch so a real post-accept failure logs instead of vanishing. Full
detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop login "Load failed": WebKit mixed-content, not CORS/CSP
After fixing VITE_API_BASE, login still failed with WebKit's generic "Load failed" — a raw browser
fetch() rejection with no server-side trace, since the request never reached the network. Root
cause: WebKitGTK treats tauri://localhost as a secure origin, so http://127.0.0.1:3000 (and
ws://127.0.0.1:3000) from inside it is blocked as mixed content — a known WebKit limitation, NOT
fixable via CSP connect-src. Fixed by routing both through Tauri plugins that use the native (Rust)
HTTP/WS client instead of the webview's own: tauri-plugin-http (a genuine fetch() drop-in, wired
into api.ts/logger.ts via a new platformFetch() in origin.ts) and tauri-plugin-websocket (NOT a
drop-in — async/listener API — adapted behind a native-WebSocket-shaped interface in the new
platform-ws.ts so use-live-feed.ts needed no changes). Full detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop live feed offline: native WS plugin sends no Origin, prod allowlist was empty
Login worked after the mixed-content fix, but the live feed showed offline in the desktop app while
the browser showed LIVE, same server. tauri-plugin-websocket's connect() runs on Tauri's Rust side,
not inside the webview page, so it never auto-attaches an Origin header — routes/ws.ts's anti-CSWSH
check treats a missing Origin as untrusted and 403s before auth. Compounded by a second, independent
gap: komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production, despite
.env.example documenting tauri://localhost as required for the desktop app. Fixed both: platform-ws.ts
now passes Origin: tauri://localhost explicitly in connect()'s headers; resources.toml's two Stacks
get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, not just a git push.
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Update failures were invisible: console-forward gate blocked the error logging
The desktop-updater.ts error logging added earlier this session used console.error/console.warn,
but logger.ts only forwards console output to the server when the client log level is debug/trace
(default: info) — so the "fix" never actually surfaced anything, and a real v0.1.3→v0.1.4 update
failure showed zero logs anywhere, sending debugging in circles (a WebKit remote-inspector attempt
via WEBKIT_INSPECTOR_SERVER also dead-ended — this build doesn't answer standard discovery
endpoints). Fixed by calling logClient() directly in desktop-updater.ts, unconditionally, bypassing
the console-forward gate entirely — a genuine post-accept install failure now always reaches
app_logs regardless of client log level. Also added download-progress logging. Separately: found
and fixed a real, pre-existing Komodo ResourceSync misconfig (resource-sync-park-systems pointed at
`dev`, not `stage`, silently reading resources.toml from the wrong branch for months with zero
effect until dev/stage first diverged today) — full writeup on [[fleet-deployment-komodo]], which
had already warned about exactly this gotcha back in 2026-07-07 and it happened anyway.
## [2026-09-03] feat | Desktop app version now visible in the UI (was invisible)
There was no way to see which desktop build was actually installed anywhere in the app — an
operator debugging a stuck update had to infer it backwards from the update prompt's target
version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBadge next to the
existing server-side VersionBadge in router.tsx, using @tauri-apps/api's getVersion() (the real
running app version, synced to the git tag at build time by release.yml). No-ops in a browser.
Full detail on [[desktop-shell-tauri]].
## [2026-09-04] feat | Desktop backend origin is now runtime-configurable (was build-time)
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases — not built
per-booth — but VITE_API_BASE was a build-time env var hardcoded to http://127.0.0.1:3000, so the
same installer could only ever talk to a server on its own machine. Added ConnectScreen (shown
before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the
operator-entered URL across restarts; origin.ts's API_BASE became a runtime-settable `let`. CSP's
connect-src tightened to 'self' only (all backend traffic already went through
tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway); the
real boundary moved to capabilities/default.json's http:default scope, wildcarded to any host so
the operator-chosen address is actually reachable. Added a "Change server" control (Setup nav,
desktop-only) that clears the saved URL and reloads back to ConnectScreen.
While tracing the desktop auth path for this, found a pre-existing (not newly introduced) bug:
tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate
from the webview — document.cookie on tauri://localhost never sees the parking_csrf cookie the
server sets (open upstream bug, tauri-apps/tauri#13045/#11518), so the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was first built, regardless of
which host it targeted. Fixed by having sessionView() (routes/auth.ts) also echo the same csrf
value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of
reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified,
and reqwest was already sending it correctly; this only fixes how the desktop client *learns* the
value. Full detail (including the exact CSP/capability tradeoffs) on [[desktop-shell-tauri]].
## [2026-09-04] fix | Desktop live feed: WS handshake can't carry the cookie → single-use ticket; desktop logs never reached app_logs
Retrospective of the 2026-09-03/04 desktop run (six releases in 26 h) found the v0.1.4 Origin fix
cleared only gate one of two in routes/ws.ts: gate two is req.jwtVerify() reading the HttpOnly
cookie, and tauri-plugin-websocket has no cookie jar at all — so every desktop handshake 401'd and
use-live-feed reconnected every 10 s (confirmed in the park-2 server log). Fixed with a 30-second,
single-use, in-memory WS ticket minted by POST /api/ws/ticket over normal cookie+CSRF auth and
presented in an x-ws-ticket header; Origin check still runs first, browser path unchanged, JWT
stays out of JS. Second finding: logger.ts read the CSRF cookie via document.cookie, null on
desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had
EVER reached app_logs, which is why "no logs whatsoever" kept happening and why yesterday's
logClient fix couldn't help. Stash moved to lib/desktop-csrf.ts, shared by api.ts and logger.ts;
WS connect failures now go through logClient (rate-limited). Third: the ConnectScreen probe now
uses the unauthenticated /health (extended with app: "parking-system") instead of accepting any
401. Also corrected four wiki citations (WebKit 171934 scope, tauri#11518 is closed, the HTTP
plugin does set Origin itself, the http-scope "quirk" is URLPattern default-port semantics) and
added a local-AppImage pre-tag gate to the desktop README, since tauri dev cannot reproduce any
of these origin-dependent bugs. Full detail on [[desktop-shell-tauri]].
## [2026-09-04] fix | Desktop in-app update never worked: latest.json described only the AppImage, booths run the .deb
tauri-plugin-updater looks up `{os}-{arch}-{installer}` first (linux-x86_64-deb — the bundler
stamps the installer type into the binary; verified with strings on a local .deb) and only then
bare linux-x86_64. release.yml's latest.json carried only the bare key → the AppImage, so every
.deb install downloaded the AppImage, passed signature verification, then failed install_deb()'s
is_deb check with InvalidUpdaterFormat — invisible until v0.1.6 fixed the desktop log channel.
This, not version drift or swallowed errors, is why v0.1.0→…→v0.1.6 never self-updated.
latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb update ends in a
polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed
package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the
prompt on the v0.1.6 booth.
## [2026-09-04] decision | Desktop updates are admin-only: keep the polkit prompt; AppImage rejected on field evidence
First successful desktop self-update (v0.1.6 → v0.1.7, pkexec dpkg -i + polkit dialog) raised
the question of the admin password the operator lacks. Tried the AppImage as the no-root path:
it fails to start on the Ubuntu 26.04 booth (bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa —
EGL_BAD_PARAMETER abort), and structurally it abandons the distro-maintained WebKitGTK the
platform decision depends on. Passwordless polkit for dpkg is root-for-the-operator, rejected.
Decision (user, 2026-09-04): the .deb stays, updates are an admin action behind the prompt; the
in-app prompt now says so (en + sq). A root systemd updater timer shipped in the .deb (minisign-
verified, notify-only in-app) is recorded as the deferred fleet-grade option on
[[desktop-shell-tauri]].
## [2026-09-04] decision | Venue modules design recorded as OPEN — Car Wash / Bar as peers of Parking
Captured the 2026-09-04 design conversation on [[venue-modules]]: a manifest-registry module
system (folder per module, always-migrated schema, one ledger union with prefixed event types,
relations only via manifest dependsOn + ledger events), enablement as entitled ∩ activated
(vendor-set Komodo env vs site-admin site-config toggle recorded as config_change; server
enforces with requireModule, web only hides; disabling never deletes), Parking recast as one
module on a venue POS/audit core, validation decommissioned (ledger type kept for history), the
desktop identifier / .deb name flagged as the one irreversible naming step, and vision-derived
vehicle category (SUV vs Car for the wash tariff) as an advisory signal that raises an `anomaly`
ledger event on operator override — never a tariff input by itself. Added as open-questions #15;
indexed.
## [2026-09-05] decision | Car Wash is the pilot module; wash inside the parking; bay camera
Settled with the user on [[venue-modules]]: Car Wash is built as the first module and is the
acceptance test of the registry (zero changes outside its folder). The wash sits inside the
parking, so every vehicle already has a session, plate and entry snapshot — no intake capture,
walk-ins out of scope. A bay camera (same stateless vision service, presence/counting not
plates) gives two anti-fraud signals for the unrecorded-wash vector: session-vs-order and
bay-count-vs-order-count per shift, both as `anomaly` ledger events, never blocking. v1 scope
(catalogue by category, queue, advisory vision category, existing shift/cash/receipts, one
parking comp/credit event, per-operator reports) and the out-list (memberships, loyalty, stock,
scheduling, booking, accounts) recorded, plus the build order. Remaining before code: platform
name (→ desktop identifier) and the validation-removal go.
## [2026-09-05] decision | Name stays parking-system; validation kept for the Bar, only the Lavazh station retires
Two revisions to [[venue-modules]] from the user: (1) the platform name stays — "this is a
Parking Systems after all" — so `com.parking.desktop` and the .deb name are untouched and the
irreversible-identifier concern is moot; the peer-module architecture, not a rename, answers the
second-tier worry. (2) Validation is NOT decommissioned: the merchant-scan flow is needed as is
for the Bar until a Bar module exists and absorbs it. Only the Lavazh station is retired when Car
Wash ships (Car Wash sponsors parking via its own order event). Two sponsorship mechanisms
coexist for now, accepted. Build order updated; validation is registered as its own module in the
registry so Bar can later depend on or absorb it.
## [2026-09-05] feat | Venue-module registry built (pilot groundwork): entitled ∩ activated, requireModule, Setup panel
Implemented build-order steps 1 + 3 of [[venue-modules]]: Lavazh validation station retired
(STATIONS = ["bar"], rows untouched); @parking/shared gains MODULE_IDS / ModuleManifest / MODULES
(parking required, validation dependsOn parking) plus the pure rule functions; site_config
gets modules_json (migration 0026, hand-written — drizzle-kit generate needs a TTY and the
snapshots end at 0003); server modules.ts adds requireModule(db, id) (403 module_disabled,
composed before requirePermission), modules/index.ts registers folder-based modules by
iterating the registry (validation is the first), site-config GET/PUT expose and set the
activation with dependency rules and one signed config_change per module that actually flips,
/api/auth/me carries the effective set; web gains lib/modules.ts + modules/{index,validation}
and router.tsx spreads WEB_MODULES into nav + route tree, SiteSettings gets a Modules panel and
hides the validation section when the module is off; MODULES_ENTITLED set explicitly in both
booth stacks. 7 new server tests, suite 329/329, web build clean. Car Wash next.
## [2026-09-05] feat | Car Wash v1 built as the pilot venue module — one manifest, two folders, two core seams
Delivered the pilot on [[venue-modules]]: carwash_{categories,services,prices,orders} (migration
0027), ledger types carwash_order (created/done/void, names + price frozen) and carwash_payment
(bay money), a server module (settings, oldest-first queue, intake against an open ticket, done →
applies the "carwash" validation program via the extracted applyValidation(), bay payment →
signs carwash_payment and, if the sponsorship made the session zero-due, the $0 parking payment
the exit reader needs; void takes back a live sponsorship) and a web module (/wash desk,
/setup/carwash master data + sponsorship editor). Two deliberate core seams: PayStation charge
providers (a wash paid at the booth rides the parking payment as chargeLines; BoothPayModal shows
them) and applyValidation() shared with the merchant route; shift money folds include bay
payments. Registry proof: the module needed exactly one manifest entry + one line in each
registry + its folders. 7 new tests, suite 337/337; full bay flow verified live in the browser.
Booth stacks not yet entitled to carwash. Next increment: vision category flag + bay camera.
## [2026-09-05] query | Car Wash review pass: discount modes, price matrix, 74-day comp bug, finished list — and the tills requirement
Hands-on review of Car Wash v1 with the user, recorded on [[venue-modules]] §"Review log":
renamed the discount section; added two wash-only discount modes (free during the wash +
tolerance; wash price off the fee, floored at 0) resolved at done via applyValidation() and
refused at merchant scan; hid typed-amount and percent from the wash editor (typed amount =
the operator picks the money → highest fraud exposure; kept for the Bar); fixed the
price-matrix cells for unsaved rows (two-request save); found and fixed a real money bug —
"free until done" was anchored at entry and would have comped a 74-day stay for one wash
(ticket 92498375903) — now anchored at the order's intake; long durations render y/d/h/m;
added a Finished list to the desk. Raised a structural requirement: shifts must become
per-till (booth / carwash) — a bay payment currently needs the booth shift and folds into
the booth drawer, which breaks both operators' Z-reports; design recorded, awaiting a go
([[shift]] carries a forward pointer).
## [2026-09-05] ingest | Tills built: one shift + one drawer per money-taking desk
The user confirmed the tills design ("go ahead and start building it"). Built in the core:
a shift is opened on a till (`booth` | `carwash`), every money event names its till
(`payload.till`, absent = booth so the chain re-folds identically), the ShiftService folds,
X/Z-reports, vouchers and carry-forward are per till, single-open is per till, and a bay
payment now requires the **carwash** shift. Web: the shift button became a per-till
component (header = booth, wash desk = carwash, with "Wash drawer now" and pay buttons
gated on my wash shift); the shift hub lists every open shift with till badges + a till
filter; the drawer hub switches tills. 6 new shift tests + the bay test now proves the booth
shift does not cover the bay; verified live with the booth held by another operator. Recorded
on [[shift]] §"Tills" and [[venue-modules]] §"Tills → As-built". Follow-ups: per-till
activity log, wash bucket on the booth Z, wash-desk printer role.
## [2026-09-05] ingest | Where wash money is taken became a site setting (Setup → Car wash)
User: the booth|bay choice belongs in `/setup/carwash` ("Pagesa: në kabinë / në lavazh"), and
the per-order radio goes away from `/wash`. Built: `carwash_config` singleton (migration 0028,
default booth), `payAt` on the settings view/body, signed `config_change carwash.payAt` on a
flip, orders freeze the policy in force, `409 pay_at_policy` for a stale client; Setup radio;
desk shows the policy read-only. Tests: 1 new (default, persist, sign, freeze, refuse).
Recorded on [[venue-modules]] §"v1 answers" item 2.
## [2026-09-05] ingest | Till access by module permission; landing per module
The user found their wash user could open the booth's shift (any `shift:create` could open any
till) and asked for the wash interface to be filtered off booth screens. Built: manifest
`tillPermission` + `tillsFor()`; server `accessibleTillsFor()` guards shift open/close/state
and cash movements (`403 till_forbidden`), `/api/shift/tills` returns only the role's tills;
header shift button needs `session:read`; module `landing` replaces the hard-coded merchant
landing, all guards bounce to `/`, `/booth` needs `session:read`. Diagnosed the dev
`Lavazhier` role: it holds booth permissions (`session:read`, `payment:create`,
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
and [[venue-modules]] §Tills → As-built.
## [2026-09-05] ingest | Permissions matrix rethink — three moves built
User: "we opened Pandora's box with this car wash module … rethink the permissions matrix";
and "the user should have websocket for live events — this does not mean it can read
/reports". Decision recorded on [[venue-modules]] §"Permissions matrix" (open-questions
#16), then built: (1) per-desk till guards — manifest `tillGuards`, new `carwash:cash`,
`requireTill(kind)` resolves the guard from the till, `tillPermission`/`session:read`
borrowing removed; (2) jobs — manifest `jobs[]` (booth-operator, booth-supervisor,
merchant, wash-operator) as one-click chips in Setup → Roles with "mixes desks" / "partial
job" lints; (3) the live feed admits any WATCH permission (event/session/device read or a
module's `feedPermission`) and filters every push per role — `report:read` is the reports
screen only. 352/352 server tests; lavazhier (event:read) now shows LIVE. Their dev role
still needs `carwash:cash` (+ create/update) and should drop the booth permissions — the
"Wash operator" chip is exactly that.
## [2026-09-05] ingest | Role reassignment now takes effect without re-login
User: a user moved to a new "Lavazh NEW" role kept getting `403` on `POST /api/carwash/orders`.
Cause: the login token pins the `roleId` current at LOGIN; `/api/auth/me` read the user row (new
role) while every guard read the token (old role). Editing a role already took effect per
request (the permission cache); reassigning one did not. Fix in `auth.ts`: `refreshRole()` after
every `jwtVerify` resolves the user's CURRENT role from the DB (cached per user, cleared by
`bumpPermsCache()`, which the user update/delete routes now call); a deleted user's session
ends with 401 on its next request; the WS cookie path uses the same. Test: moved user creates
an order on the next request with the same cookie. Recorded on [[local-jwt-auth]].
## [2026-09-06] ingest | MODULES_ENTITLED never reached the container
User set park-2 to `MODULES_ENTITLED=parking`, re-synced, destroyed + redeployed the stack —
Lavazh still there. Cause: the variable was in the Komodo stack env and `.env.example` but not
in `docker-compose.yml`'s server `environment:` block, so the container never saw it; unset =
every module → every booth on 55d6242 had Car Wash entitled. Fix: compose forwards it with
default `parking,validation`. Troubleshoot on a booth with `docker exec … env | grep MODULES`
and the boot log line `venue modules (entitled = …; effective = …)`. Recorded on
[[venue-modules]] §As-built (deploy gotcha).
## [2026-09-06] ingest | Car Wash no longer depends on the validation module
User set `MODULES_ENTITLED=parking,carwash` on park-2 — no Lavazh. Cause: the manifest said
carwash `dependsOn: ["parking","validation"]`, so the effective set dropped it as dependency-
broken, and the program compose/read routes sat behind the validation module gate. That was a
design error: the discount ENGINE (validation program rows + `applyValidation()`) is core; the
`validation` module is only the merchant's scan screen. Fixed: `dependsOn: ["parking"]`; the
program routes are plain site:read/site:update; the merchant routes (mine/lookup/apply/void)
stay module-gated. Tests updated. Recorded on [[venue-modules]] (v1 answers item 4 + As-built).