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:
@@ -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)" } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user