fix(exit): stuck active session — paid ticket with no vehicle_exit

A paid car that left via a manual barrier re-open kept no vehicle_exit, so
activeSessions() saw it as permanently open and the grace-expiry eviction
(which only ran for exited sessions) never fired — it lingered forever
(ticket T-397815c0).

- reopenBarrier() now signs a vehicle_exit (source:manual) when the session
  is still open, closing it; still no second exit when already exited
  (phantom re-close — no double-count).
- activeSessions() ages out a PAID open session past grace even with no exit
  (unpaid open sessions never age out — a car owing money stays). Pure
  display filter; the signed log is untouched.

Verified both fixes + chain integrity on a fresh DB. A one-off corrective
vehicle_exit was appended to the live ledger to clear T-397815c0.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 13:14:45 +02:00
parent f87e4c0d6b
commit ca8c7f2fa2
3 changed files with 64 additions and 16 deletions
+29 -8
View File
@@ -170,10 +170,16 @@ export class ExitFlow {
* opens the barrier with a signed trace. * opens the barrier with a signed trace.
* *
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule; * Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
* the UI also hides the button). Unlike exitForBooth this does NOT sign a * the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
* `vehicle_exit` (the session may already be exited; a second exit would
* double-count occupancy). It re-pulses the exit relay and signs an `anomaly`
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight. * ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
*
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
* second exit (that would double-count occupancy): anomaly only, as before.
* See wiki/concepts/booth-exit-flow.md. * See wiki/concepts/booth-exit-flow.md.
*/ */
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> { async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
@@ -195,7 +201,7 @@ export class ExitFlow {
try { try {
const resolved = firstRelayByDirection(this.#db, "exit"); const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not // Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds) — never a second vehicle_exit. // the physical open succeeds).
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
identity: id, identity: id,
@@ -207,6 +213,16 @@ export class ExitFlow {
}, },
}); });
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
// session leaves the active list + occupancy settles. Skip when already exited
// (no double-count). Recorded as a human-intervention exit for the audit trail.
if (view.open) {
await this.#signExit(id, "manual");
this.#closeSessionCache(id);
this.#fireExitSnapshot(id);
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
}
if (!resolved) { if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`); this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: "no exit barrier configured — open manually" }; return { ok: true, opened: false, reason: "no exit barrier configured — open manually" };
@@ -230,7 +246,7 @@ export class ExitFlow {
} }
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the /** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher from the reader's binding, which has ruled out a permit match). */ * read dispatcher from the reader's binding, which has ruled out a subscription match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> { async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`; const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
@@ -321,14 +337,19 @@ export class ExitFlow {
return { accepted: true, direction: "exit" }; return { accepted: true, direction: "exit" };
} }
/** Append the signed vehicle_exit. `source` defaults to "ticket" (booth/manual). */ /** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
async #signExit(identity: string, source: "ticket" | "lpr" = "ticket"): Promise<void> { * or "manual" (a human-intervention barrier re-open that closes an open session —
* see reopenBarrier). */
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
await this.#log.append({ await this.#log.append({
type: "vehicle_exit", type: "vehicle_exit",
direction: "exit", direction: "exit",
source, source,
identity, identity,
payload: { sessionRef: identity }, payload: {
sessionRef: identity,
...(source === "manual" ? { reason: "human-intervention exit (manual barrier open)" } : {}),
},
}); });
} }
+12 -2
View File
@@ -250,10 +250,20 @@ export class PayStation {
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString() ? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
: null; : null;
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt); const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
// ACTIVE = still inside, OR exited but still within the (unconfirmed) grace window. // ACTIVE membership:
// An exited session past grace is presumed truly gone → omitted. // - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
// manual barrier re-open before that path closed the session, or a historical
// session like T-397815c0) it would otherwise linger forever. The signed log
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
if (!open && !withinGrace) continue; if (!open && !withinGrace) continue;
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
// Amount owed now: only meaningful for an open + unpaid session. // Amount owed now: only meaningful for an open + unpaid session.
let amountMinor: number | null = null; let amountMinor: number | null = null;
+23 -6
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, booth, exit, payment, threat-model] tags: [parking, domain, booth, exit, payment, threat-model]
sources: [] sources: []
updated: 2026-06-17 updated: 2026-06-18
status: open status: open
--- ---
@@ -62,21 +62,38 @@ phantom obstacle: an animal, a person, a cardboard box or bag in the wind). Thes
**human in the booth** to open the barrier, leaving a signed trace. **human in the booth** to open the barrier, leaving a signed trace.
**A session is "active" (shown in the booth Active Sessions list) while it is EITHER:** **A session is "active" (shown in the booth Active Sessions list) while it is EITHER:**
- **open** — entered, no `vehicle_exit` yet (still inside), OR - **open + unpaid** — entered, no `vehicle_exit`, owing money. **Always shown** — a car that owes
money never ages out; it's genuinely still inside until it pays, however long that takes. OR
- **open + paid, still within grace** — paid but no exit recorded yet, `now ≤ graceExpiresAt`. OR
- **exited but `now ≤ graceExpiresAt`** — paid and/or the voucher scanned, but still within the - **exited but `now ≤ graceExpiresAt`** — paid and/or the voucher scanned, but still within the
walk-back grace window. Because the barrier is unconfirmed, the car is presumed *possibly still walk-back grace window. Because the barrier is unconfirmed, the car is presumed *possibly still
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
list** — only grace expiry does. list** — only grace expiry does.
A session drops off the list once it is exited **and** past grace (presumed truly gone). A session drops off the list once it is **past grace** and EITHER exited OR **paid** (presumed truly
gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it
is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a
manual barrier re-open (which historically signed no exit — see below) would linger **forever**
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
filter (`PayStation.activeSessions()`).
### The one operator action — "Open barrier" (audited re-pulse) ### The one operator action — "Open barrier" (audited re-pulse)
For an active session, the operator can open the barrier as a **human intervention**. This: For an active session, the operator can open the barrier as a **human intervention**. This:
- **re-pulses an exit relay** (resolved site-wide, as the booth exit does), and - **re-pulses an exit relay** (resolved site-wide, as the booth exit does), and
- signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open") - signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open"), and
— **NEVER a second `vehicle_exit`** (a second exit would double-count occupancy and corrupt the - **closes the session IF it is still open** — i.e. if no `vehicle_exit` exists yet, the re-open *is*
ledger's meaning). It is an audited *re-open*, not a new exit. this car leaving, so it also signs a **`vehicle_exit`** (`source: manual`, reason "human-intervention
exit"). If the session is **already exited** (the phantom re-close case — a second exit would
double-count occupancy), it signs **no** second exit: anomaly only.
> **Refined 2026-06-18 (was "NEVER a `vehicle_exit`").** The original rule never signed an exit on a
> re-open, on the assumption a normal `vehicle_exit` had already happened. But when the re-open was the
> *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
> kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
> when the session is **still open**, preserving the no-double-count guarantee for the already-exited
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
> paid session that still slips through.
**Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that **Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that
have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open