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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user