diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index bfc5115..0f42903 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -170,10 +170,16 @@ export class ExitFlow { * opens the barrier with a signed trace. * * 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 - * `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` + * the UI also hides the button). It re-pulses the exit relay and signs an `anomaly` * ("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. */ async reopenBarrier(identity: string, operator?: string): Promise { @@ -195,7 +201,7 @@ export class ExitFlow { try { const resolved = firstRelayByDirection(this.#db, "exit"); // 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({ type: "anomaly", 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) { this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`); 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 - * 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 { const key = `${e.deviceId}:${e.value}`; 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" }; } - /** Append the signed vehicle_exit. `source` defaults to "ticket" (booth/manual). */ - async #signExit(identity: string, source: "ticket" | "lpr" = "ticket"): Promise { + /** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate), + * 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 { await this.#log.append({ type: "vehicle_exit", direction: "exit", source, identity, - payload: { sessionRef: identity }, + payload: { + sessionRef: identity, + ...(source === "manual" ? { reason: "human-intervention exit (manual barrier open)" } : {}), + }, }); } diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index cfae9da..7f064bc 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -250,10 +250,20 @@ export class PayStation { ? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString() : null; 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. - // An exited session past grace is presumed truly gone → omitted. + // ACTIVE membership: + // - 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 && paid && graceExpiresAt != null && !withinGrace) continue; // Amount owed now: only meaningful for an open + unpaid session. let amountMinor: number | null = null; diff --git a/wiki/concepts/booth-exit-flow.md b/wiki/concepts/booth-exit-flow.md index 2e38b76..da2a6d1 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-17 +updated: 2026-06-18 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. **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 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 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) 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 -- signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open") - — **NEVER a second `vehicle_exit`** (a second exit would double-count occupancy and corrupt the - ledger's meaning). It is an audited *re-open*, not a new exit. +- signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open"), and +- **closes the session IF it is still open** — i.e. if no `vehicle_exit` exists yet, the re-open *is* + 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 have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open