server: exit flow (pay-on-foot validation)

A credential read at an exit lane validates the session, then opens. Adds a
'read' channel to the device bus (DeviceReadEvent: ticket/plate/qr/card);
entry stays button-driven so reads are exit/identity events.

Flow: read -> fold the SIGNED ledger for that identity -> validate open + PAID
+ within gracePeriodExitMin -> signed vehicle_exit -> pulseOpen -> close the
session cache. Unpaid / grace-expired / unknown -> signed anomaly, barrier
stays closed (a deliberate business reject, not a fail-state; 'exit fails open'
is about host/power loss). Validation reads the ledger (authoritative), not the
cache.

No payment events exist until the pay station is built, so every transient exit
currently rejects -- the correct end-state, not yet passable. Verified against
stubs: unpaid->anomaly+no-open; paid+grace->exit+open+closed; expired->anomaly;
unknown->anomaly; verifyChain ok across entry->pay->exit.

Flagged: lane_devices has no entry/exit direction model (exit door hardcoded to
1); needs a lane-direction/role model before multi-reader lanes.
This commit is contained in:
2026-06-15 18:57:14 +02:00
parent 2696d281ce
commit 2a36830880
5 changed files with 263 additions and 5 deletions
+22 -5
View File
@@ -96,8 +96,25 @@ Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorizatio
## What this unblocks (build order)
The device layer left the entry flow dangling — `input_received` events land in the log and stop
([[device-input-flow]] "the entry flow itself is the next build"). The session domain is that next
step: consume `input_received` / a reader event → mint a signed `vehicle_entry` → print + open.
Then the pay-station and exit-validation flows. Schema + code follow this page and [[tariff]];
the decision is recorded in [[session-model]].
The device layer left the entry flow dangling — the session domain is that next step. Schema + code
follow this page and [[tariff]]; the decision is recorded in [[session-model]].
### As-built (2026-06-15)
- **Entry flow** (`apps/server/src/entry-flow.ts`): access-device input edge → print ticket
(failover) → signed `vehicle_entry` → `pulseOpen`. Holds (anomaly, no open, no entry) if printing
fails. See [[device-input-flow]].
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (new `read` bus channel) →
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
stays closed. Validation folds the **ledger** (authoritative), then updates the `sessions` cache.
- **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to
the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
to decide (host/power loss), not an unpaid car.
- **Currently every transient exit rejects** — no `payment` events exist until the pay station is
built; the validation is the correct end-state, just not passable yet.
> **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is
> button-driven and exit is read-driven, so they don't currently collide — but a lane with both an
> entry reader and an exit reader can't yet be distinguished. A lane-direction/role model is needed
> before multi-reader lanes (relates to [[open-questions]] #1 topology).