Files
parking_solution/wiki/concepts/capacity-occupancy.md
T
julian 33c4ea1e91
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s
feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.

(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
  - PRESENCE-GATED like the physical button: a real car must be present (radar/
    loop AND camera busy). Enforced BOTH sides — the server re-checks current
    presence so a direct POST can't bypass a disabled button; no presence loop
    => feature unavailable; a no-presence attempt signs an anomaly.
  - FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
    companion entry.operatorIssued anomaly (the adversary path always leaves a
    red-flag row).
  - capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
    a legit car).
  New session:create permission (migration 0019 -> operator role, admin-
  revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
  issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
  factored into one shared #issueTicket (button + operator). UI: the entry
  BarrierLight becomes a clickable issue-control when presence+permission+shift
  meet (confirm -> issue).

(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
  - BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
    pay/exit modal shows a red warning + "Override & release" (override signs an
    attributed exit.plateSwapOverride). Flag+override, never a silent hard block
    (exit fails-open; a plate is never the sole gate).
  - READER path (no operator): log-only anomaly + fail-open.
  Extended BoothExitResult + /api/exit (override); boothExit client returns a
  structured swap result.

Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 12:17:52 +02:00

88 lines
5.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, domain, business, occupancy]
sources: []
updated: 2026-06-15
status: open
---
# Capacity & Occupancy
How many vehicles are inside, how many spaces remain, and what happens when the lot is full.
## Occupancy is a projection (like everything else)
`occupancy = count(open [[parking-session|sessions]])` — an entry with no matching exit. It is a
**fold over the signed [[append-only-event-chain]]**, never a hand-maintained counter (a counter is
editable and drifts; the chain is the truth). Spaces-free = `capacity − occupancy`.
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
- Permit concurrency (`maxConcurrent`, see [[subscription]]) is the same kind of fold, scoped to one
permit's open sessions.
## Full → refuse entry + FULL sign
- When `occupancy ≥ capacity`, the entry flow **refuses** (no `vehicle_entry`, no barrier open) and
can drive a **"FULL" sign** (a relay/output, via the device adapter layer).
- **Safety/policy nuance:** "full" blocks *entry* only — **exit always works** ([[fail-state-safety]]:
exit fails open; never trap a vehicle). Permit holders may be allowed in past a "transient full"
threshold (reserve spaces for subscribers) — an optional policy knob.
- **Counting drift is real:** tailgating (two cars, one entry) and missed reads make the live count
diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
not silently corrected.
- **A DELIBERATE drift attack — the ticket swap:** a paid car let out on a fresh $0 ticket leaves its
original ticket "inside" forever, inflating occupancy by phantom cars. Defended by
[[plate-reconciliation]] (the exiting plate is already open under the original ticket → flag/hold).
## Reserved subscriber spots (admin toggle, built 2026-06-20)
By default occupancy counts only cars **physically inside** — a subscriber who isn't parked frees
their spot to transients, and the operator handles any overflow by valet/key-juggling. A site can
instead **hold a spot for every active subscriber**, so the lot reads "full" to transients sooner and
the subscriber's place is guaranteed:
- `site_config.reserve_subscriber_spots` (bool, default off). When ON,
`reservedSubscriberSpots(db)` sums, over every **active** subscription (status active AND
`now ∈ [validFrom, validTo]`), `max(0, quantity − itsCarsCurrentlyInside)` — i.e. it reserves only
the **not-yet-parked** portion of each subscription's [[subscription|quantity]] (a parked
subscriber already occupies a real spot; counting them twice would over-reserve).
- `getOccupancy` gains `reserved` + `effectiveFree = capacity − count − reserved`. The transient FULL
gate becomes **`count + reserved ≥ capacity`**. Subscribers are still **never** gated by full
(their flow ignores it) — reservation only tightens the *transient* gate.
- OFF = the prior behaviour exactly (`reserved = 0`).
## "Full" is a soft, operator-configurable policy
Refusing at capacity is the **default**, not an absolute. An operator may opt into
**[[valet-overcapacity|valet over-capacity]]** — accept the car into operator custody (keys handed
over, stacked beyond the marked count) instead of refusing. So the FULL gate is a policy knob
(refuse vs. valet-accept), set by the operator per site. Valet is a manned-mode feature with its
own custody/session shape — see [[valet-overcapacity]] (deferred).
## As-built (2026-06-16)
- **Occupancy** = `occupancyCount` (`apps/server/src/occupancy.ts`): a fold over the ledger —
entries minus exits per identity, count those `> 0`. `getOccupancy` returns `{count, capacity,
free, full}`.
- **Capacity** is a single-row `site_config` table (admin-set; `null` = uncapped). Routes
(`routes/site.ts`): `GET /api/occupancy` + `GET /api/site-config` (any role), `PUT /api/site-config`
(admin; non-negative int or null).
- **FULL gate** is in the **transient entry flow**: `occupancy.full` → refuse (no ticket, no
`vehicle_entry`, no open) + signed `anomaly`. **Permit entry is NOT gated** here — subscribers are
admitted past transient-full (their own `maxConcurrent` still applies); occupancy can read
over-capacity (`free` negative) when permits enter a full lot, as intended.
- **UI** `SiteSettings`: live occupancy + FULL badge (everyone); capacity editor (admin).
- Verified: fill to cap → 3rd transient refused; permit still admitted past full; exit frees a
slot; RBAC (operator can't set capacity); verifyChain ok. Physical FULL-sign relay output is
**deferred** (needs a sign device).
## Open
- Zone/level granularity at launch vs. single capacity number.
- Reserve-for-permits **threshold** (a soft transient cap below the hard capacity) — currently
permits are simply ungated; a tunable threshold is the richer version.
- Physical FULL-sign relay output (a sign-device role).
- The valet over-capacity mode + custody model ([[valet-overcapacity]]).