Files
parking_solution/wiki/log.md
T
julian bba988c4e8 feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card
Builds out subscription credentials on top of the rename.

- Operator chooses the credential type; only QR is live (RFID shown disabled
  "soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
  globally-unique-checked) — the customer/operator never picks the value.
  RF stays operator-entered (the physical card id). Reader output decided =
  TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
  validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
  (GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
  create (best-effort — never fails the create; returns {printed,printError})
  + reprint via POST /api/subscriptions/:id/print and a "Print code" button.

Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 14:48:38 +02:00

79 KiB
Raw Blame History

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.

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 03 ). 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 /24s; 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 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? " 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? " 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.

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.

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