server: permit entry/exit branch + read dispatcher

A credential read now routes by what the credential IS: matches a permit
(card/QR credential or a bound plate) -> permit flow; else -> transient exit
flow. Lane resolved once (readerLaneWithAccess); ExitFlow.onRead -> handleAt so
the dispatcher owns lane resolution.

Permit direction is inferred from session state for that car (the read value is
the per-car session key): no open session -> ENTRY (enforce maxConcurrent, sign
vehicle_entry, open); open -> EXIT (sign vehicle_exit, open, close). Fleet
permit = one session per car; anti-passback falls out naturally.

maxConcurrent enforced as a fold over the signed ledger (null = unbound).
Validity window + status + plate-OR-card identity as designed. No ticket/fee;
every use is a signed event carrying permitId. Refusals (revoked / out-of-window
/ at-capacity) are signed anomalies, barrier stays closed.

Verified against stubs: card entry -> inferred exit; fleet cap 2 (F3 rejected
at 2/2, then admitted after F1 exits); plate-bound opens; revoked rejects;
unknown credential falls through to exit reject; verifyChain ok.
This commit is contained in:
2026-06-15 19:47:01 +02:00
parent b4d0dfadd6
commit c24d99b0f4
8 changed files with 317 additions and 29 deletions
+4 -1
View File
@@ -104,7 +104,10 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
- **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) →
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built.
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `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.
+20
View File
@@ -99,6 +99,26 @@ stays append-only even though the permit record itself is editable.
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
refused, per policy (OPEN).
## As-built (2026-06-15)
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
- **Direction is inferred from session state for that car** — the read credential value is the
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
inside car is its exit, never a second entry).
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
carrying `permitId`.
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
## Resolved (2026-06-15)
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
+16
View File
@@ -548,3 +548,19 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404
(session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5.
- Updated [[tariff]] (composer as-built).
## [2026-06-15] build | Permit entry/exit branch + read dispatcher
- `apps/server/src/permit-flow.ts` + `read-dispatch.ts`. A credential read now routes by WHAT the
credential is: matches a permit (card/QR credential, or a bound plate) → permit flow; else →
transient exit flow. Lane resolved once (`readerLaneWithAccess`, shared in lane-map.ts). Refactored
ExitFlow.onRead → handleAt(lane,e) so the dispatcher owns lane resolution.
- Permit DIRECTION inferred from session state for that car (the read value is the per-car session
key): no open session → ENTRY (enforce maxConcurrent, sign vehicle_entry, open); open → EXIT (sign
vehicle_exit, open, close). Fleet permit = one session per car; anti-passback falls out.
- maxConcurrent enforced as a fold over the signed ledger (count the permit's entries whose car has
no later exit); null = unbound. Validity window + status + plate-OR-card identity as designed.
No ticket/fee; every use is a signed event carrying permitId. Refusals = signed anomaly, no open.
- VERIFIED against stubs: card entry → inferred exit; fleet maxConcurrent=2 (F1,F2 in, F3 rejected,
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).