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.
|
||||
*
|
||||
* 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<BoothReopenResult> {
|
||||
@@ -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<ReadOutcome> {
|
||||
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<void> {
|
||||
/** 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<void> {
|
||||
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)" } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user