diff --git a/wiki/concepts/booth-exit-flow.md b/wiki/concepts/booth-exit-flow.md
index 69c7d35..4b47c8f 100644
--- a/wiki/concepts/booth-exit-flow.md
+++ b/wiki/concepts/booth-exit-flow.md
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, booth, exit, payment, threat-model]
sources: []
-updated: 2026-06-18
+updated: 2026-06-30
status: open
---
@@ -162,6 +162,22 @@ server-side in `reopenBarrier`: allow only when `subscription` OR (`paidAt != nu
paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone)
would be a separately-audited path — see Open.
+> **Open-barrier moved INTO the modal — the inline row button is gone (2026-06-30).** The audited
+> re-pulse was previously an inline button on the paid-in-grace Active Sessions *row*. It was removed:
+> clicking any row now opens the modal, which carries the Open-barrier action. Why: a paid-and-exited
+> session is `open=false`, so clicking its row used to dead-end on *"This session is already closed"* —
+> useless for the very case (paid, barrier didn't confirm) where the operator needs to re-pulse. The
+> modal now recognizes a **closed-within-grace** transient (`found && !open && withinGrace`) and renders
+> the session view + **Open barrier** instead of the dead-end notice. The server guard is unchanged
+> (`reopenBarrier` already handled the closed-but-in-grace case — the T-397815c0 fix above). The
+> Active Sessions list distinguishes these rows with a **live grace-remaining countdown** badge
+> (`exited · M:SS`, ticking each second off `graceExpiresAt`) instead of a static "exited" label.
+> Settled amounts now show the **actual sum paid** (new `SessionLookup.paidMinor`, summed across
+> payments) rather than a flat "PAID" badge. And a **fully-closed (grace-expired) session** is no longer
+> a pure dead-end: its modal shows a read-only **review view** — figures + paid amount + the entry/exit
+> [[entry-exit-points#camera-snapshots-evidence-not-a-gate|snapshot strip]] — so an operator can review
+> evidence for a car that just left (disputes/audits), with no pay/exit/open controls.
+
### Subscription occurrences in the booth (built 2026-06-18)
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
diff --git a/wiki/concepts/entry-exit-points.md b/wiki/concepts/entry-exit-points.md
index 8944996..5a8ffcd 100644
--- a/wiki/concepts/entry-exit-points.md
+++ b/wiki/concepts/entry-exit-points.md
@@ -2,7 +2,7 @@
type: concept
tags: [parking, architecture, devices, setup]
sources: []
-updated: 2026-06-16
+updated: 2026-06-30
---
# Entry / Exit Points (pool-of-spaces model)
@@ -114,6 +114,21 @@ re-encode is **storage-only** — ANPR recognition runs on the **original full-r
(downscaling hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot
(`snapshot.ts` `encodeForStorage`).
+> **Content-type bug — every legacy snapshot rendered blank (fixed 2026-06-30).** Symptom: *no*
+> snapshot showed in the booth modal. Root cause: some cameras (Hikvision) return
+> `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is **malformed**, and
+> browsers refuse to decode an `
` declared that way. Old capture code persisted that raw header
+> into `snapshots.content_type` (100 of 101 rows in the dev DB), and the serve route
+> (`GET /api/snapshots/:id`) re-emitted it **verbatim** → broken render for every legacy row. The
+> capture path was *already* hardened (`encodeForStorage` re-encodes to a clean `image/jpeg`; its
+> fail-soft branch calls `cleanType` to strip `; charset=…`), so NEW rows were fine — but the serve
+> route trusted the stored value. Fix: the route now also runs `cleanType(row.contentType)` on the way
+> out (a bare `image/jpeg`), which un-breaks all legacy rows with **no data migration**. Verified: a
+> previously-unrenderable 2560×1440 row now decodes in-browser. Lesson: **normalize a camera-supplied
+> content-type both on capture AND on serve** — a stored value from an untrusted device is itself input.
+> The stored `content_type` column could be backfilled to `image/jpeg` for cleanliness, but serving
+> normalizes so it isn't required.
+
**Retention (2026-06-28, resolves the old open question) — DISK-PRESSURE safety valve.** Snapshots
are unsigned/advisory, so they prune freely. The day-to-day shrink is the re-encode above; pruning is
a backstop that only fires under real disk pressure. A **daily** check (`snapshot-retention.ts`
diff --git a/wiki/concepts/local-dev-workflow.md b/wiki/concepts/local-dev-workflow.md
index e10d1cc..55f3983 100644
--- a/wiki/concepts/local-dev-workflow.md
+++ b/wiki/concepts/local-dev-workflow.md
@@ -2,7 +2,7 @@
type: reference
tags: [parking, dev-environment, workflow]
sources: []
-updated: 2026-06-15
+updated: 2026-06-30
---
# Local Dev Workflow
@@ -54,3 +54,44 @@ Production uses an **nginx** reverse proxy (`deploy/nginx.conf`) for the same sa
`ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin`. Reset a password: add `FORCE=1`.
- Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events),
`uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]].
+
+## Database reset — training / demo only (2026-06-30)
+
+A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data
+must go without leaving an obvious self-serve button (an operator must not be able to wipe history).
+So the reset is a **CLI script**, not UI: `packages/db/scripts/reset-db.mjs`, run via `pnpm db:reset`.
+
+```bash
+RESET_ALLOWED=1 pnpm db:reset --financial # default DB = apps/server/parking.sqlite
+RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all
+```
+
+**Category flags** (combinable; ≥1 required) — grounded in which tables hold what:
+
+| Flag | Wipes | Keeps |
+| --- | --- | --- |
+| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** |
+| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions, subscription plans | everything else |
+| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else |
+| `--all` | every table (blank slate) | — |
+
+> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].**
+> That is the anti-fraud record; a *partial* delete would break the hash chain, so a financial reset
+> wipes the whole ledger back to empty (re-seeding starts a NEW chain under the **same**
+> `EVENT_SIGNING_KEY` — the key is **not** touched). This is the opposite of how the ledger is meant to
+> behave, hence the gates below. It is a **training/demo** tool; never point it at a live booth.
+
+**Two safety gates ([[threat-model|operator-as-adversary]]):**
+1. **`RESET_ALLOWED=1`** env must be set — a real booth never sets it, so the command is inert in
+ production even if typed.
+2. **Typed confirmation** of the DB filename (interactive). `--yes` skips it for CI/scripted training
+ setup only.
+
+Runs as a single transaction (all-or-nothing) + `VACUUM` to shrink the re-used demo DB. After
+`--users`/`--all` (users cleared), re-seed an admin: `pnpm seed:admin`. The `EVENT_SIGNING_KEY` and
+`BACKUP_KEY` are intentionally left alone (see [[backup-recovery]] on key custody).
+
+> **On the BOOTH there is no `pnpm`** — only Docker containers. `pnpm db:reset` is the *dev* form;
+> on an appliance, run the same script via `docker exec` into the `server` container
+> (`node node_modules/@parking/db/scripts/reset-db.mjs …`, `DATABASE_URL=/data/parking.sqlite`).
+> Full booth procedure: [[appliance-provisioning]] §7d.
diff --git a/wiki/decisions/appliance-provisioning.md b/wiki/decisions/appliance-provisioning.md
index 91e7ee3..feda482 100644
--- a/wiki/decisions/appliance-provisioning.md
+++ b/wiki/decisions/appliance-provisioning.md
@@ -351,6 +351,42 @@ ENV=prod ./booth.sh up
`booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g.
`/opt/parking_systems/`). See [[container-deployment]].
+### 7d. Reset the DB for TRAINING/DEMO — `docker exec`, not `pnpm` (2026-06-30)
+
+A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data
+must go without leaving an obvious self-serve button (the [[threat-model|operator must not be able to
+wipe history]]). The reset is a **CLI script** (`packages/db/scripts/reset-db.mjs`), and on the booth
+there is **no `pnpm`** — only the running containers. So run it the same way as the seed-admin step in
+§7b: **`docker exec` into the `server` container**, where the script ships inside the deploy bundle at
+`node_modules/@parking/db/scripts/reset-db.mjs` (the same place the boot migrator lives — see the
+entrypoint). `DATABASE_URL` in-container is **`/data/parking.sqlite`** (the `parking-data` volume).
+
+```bash
+# On the booth (or via Komodo's terminal on the server container). Category flags:
+# --financial ledger (entry/exit/payment/void/shift/cash/anomaly) + device_events + snapshots +
+# subscription INSTANCES/credentials/plates + blocklist. KEEPS users/devices/config/
+# tariffs/subscription PLANS.
+# --config site_config, devices, setup_state (re-runs first-run setup), tariffs + versions, plans.
+# --users users, roles, role_permissions, auth sessions. --all every table.
+docker exec -it \
+ -e RESET_ALLOWED=1 \
+ -e DATABASE_URL=/data/parking.sqlite \
+ park-buzi-server-1 \
+ node node_modules/@parking/db/scripts/reset-db.mjs --financial
+```
+
+> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]]** —
+> the anti-fraud record. A *partial* delete would break the hash chain, so a financial reset wipes the
+> whole ledger back to empty (re-seeding starts a NEW chain under the **same** `EVENT_SIGNING_KEY`/
+> `BACKUP_KEY` — the keys are **not** touched). This is the opposite of how the ledger is meant to
+> behave, hence the two gates: it refuses unless **`RESET_ALLOWED=1`** is set (a real booth never sets
+> it) **and** you type the DB filename to confirm (`parking.sqlite`; `--yes` skips that for scripted
+> setup only). It is a **training/demo** tool — never run on a production booth's data.
+
+After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
+(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. For dev (where `pnpm` exists)
+the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
+
### Healthy startup + web-access
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
diff --git a/wiki/index.md b/wiki/index.md
index 1a06386..d00d23e 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -111,7 +111,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
- [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian.
## Dev environment (reference)
-- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
+- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin, the gated `db:reset` training/demo tool.
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
## Decisions
diff --git a/wiki/log.md b/wiki/log.md
index 6faa7ed..76cf259 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -2028,3 +2028,49 @@ Boot Deployed, GRUB edit-lock, unprivileged-operator) + the firmware/dbx lockdow
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
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.