diff --git a/wiki/concepts/anti-passback.md b/wiki/concepts/anti-passback.md new file mode 100644 index 0000000..8be12e1 --- /dev/null +++ b/wiki/concepts/anti-passback.md @@ -0,0 +1,57 @@ +--- +type: concept +tags: [parking, domain, business, anti-fraud, access-control] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Anti-Passback + +Stop one credential/ticket from getting **two cars in** without an exit between — the classic +"pass the card/ticket back over the fence" abuse. A control on the entry validation, leaning on the +session projection. + +## The rule + +An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an +OPEN [[parking-session|session]].** At entry: + +``` +identify vehicle → is there already an OPEN session for this id? + no → proceed (mint vehicle_entry, open) + yes → passback violation → refuse or flag (see policy) +``` + +This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist +with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that +powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`. + +## Interaction with the limits already designed + +- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same + id is always a violation (or a re-print/duplication attempt). +- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a + multi-car permit legitimately has several open sessions, but **the same car/credential** entering + twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance. + +## Policy (operator choice) + +- **Hard** — refuse the second entry (strict; risks stranding a legitimate car after a *missed + exit*, which is common — tailgated out, sensor missed). +- **Soft** — allow but **flag an `anomaly`** (the type exists) for review. Safer against + false-positives from missed exits, consistent with the append-only "record + flag, don't block" + ethos elsewhere. +- Likely **soft by default**, hard as an opt-in for high-control sites. + +## Honest limits + +- Depends on **reliable exit detection** — if exits are routinely missed (no exit loop/plate read), + passback produces false positives; tune to the site's exit fidelity. +- A spoofed/duplicated ticket QR is caught here (same id already open) — complements + [[ticket-encoding]]'s opaque-id requirement. + +## Open + +- Default policy (soft/hard) and per-site override. +- Grace for legitimate quick re-entry vs. the missed-exit false-positive. diff --git a/wiki/concepts/append-only-event-chain.md b/wiki/concepts/append-only-event-chain.md index 833c4ff..f61703f 100644 --- a/wiki/concepts/append-only-event-chain.md +++ b/wiki/concepts/append-only-event-chain.md @@ -64,6 +64,19 @@ Dingtian **input (button) pushes** → bus → `input_received` events (see [[de [[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` — the richer entry event waits for the entry flow (ticket print + barrier command). +**Business-layer event types (designed, not yet implemented — see [[session-model]]).** The +[[parking-session]] domain folds over these signed events, extending `input_received`: + +- `vehicle_entry` / `vehicle_exit` — a stay's endpoints; `identity` carries the ticket id or plate. +- `payment` — a settled fee at the pay station, referencing the session it pays for (amount in + integer minor units; see [[tariff]]). Making "paid" a signed event — not a mutable row — is the + whole point: an operator can't forge it or silently delete it. +- `void` — a correction / lost-ticket write-off; like every other void here it is an **appended + event, never an erasure**. + +A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason +the chain exists. See [[parking-session]]. + - **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`) caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every assign/unassign. Device events carry the device instance id, not a lane; the handler looks it @@ -91,7 +104,9 @@ host. **Proven on hardware**: a binary relay command sent directly to the device So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** — the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an **independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input -→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching -signed command is the fraud signal.** Both the witness sources and the reconciliation logic are -**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't +→ which DOES push + log; the [[opencv-anpr-service|vision service]]'s plate **and vehicle** read; +payment/Z-report). **A physical open with no matching signed command is the fraud signal** — and, +with vehicle verification, **a plate that enters/exits on a different car** is too (the +plate-spoofing case). Both the witness sources and the reconciliation logic are **NOT yet built** — +this is the main open gap. Prevention (VLAN isolation so the attacker can't reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop. diff --git a/wiki/concepts/capacity-occupancy.md b/wiki/concepts/capacity-occupancy.md new file mode 100644 index 0000000..84b5c0d --- /dev/null +++ b/wiki/concepts/capacity-occupancy.md @@ -0,0 +1,40 @@ +--- +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 [[permit]]) 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. + +## Open + +- Whether "FULL" is a hard block or a soft warning (operator can wave one in) — operator policy. +- Zone/level granularity at launch vs. single capacity number. +- Reserve-for-permits threshold. diff --git a/wiki/concepts/clock-integrity.md b/wiki/concepts/clock-integrity.md new file mode 100644 index 0000000..eddc5e1 --- /dev/null +++ b/wiki/concepts/clock-integrity.md @@ -0,0 +1,48 @@ +--- +type: concept +tags: [parking, security, integrity, offline-first, anti-fraud] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Clock Integrity + +Fees are a function of **time** ([[tariff]]: `fee = f(enteredAt, asOf)`), and the event chain is +ordered/timestamped. So **the host clock is part of the trust model** — and on an offline appliance +([[offline-first]], no NTP guarantee) it's a real attack surface, fitting the +[[threat-model|operator-as-adversary]] frame: + +- **Backdating to cut a fee** — wind the clock back so a long stay computes as short, or so an exit + timestamps before its entry. +- **Forward/backward jumps** that corrupt durations, the rolling-24h cap, or shift boundaries + ([[shift]]). +- An operator with host access changing the system time deliberately. + +## What protects it + +- **Monotonic chain order is independent of wall-clock.** The [[append-only-event-chain]] `index` + is strictly increasing regardless of timestamps, so **reordering** is caught even if timestamps + are forged. But the *durations* used for pricing still rely on the wall clock — so: +- **Detect clock anomalies and record them as events.** A timestamp that goes **backwards** between + consecutive chain events, or jumps implausibly, is an `anomaly` (the type already exists) — signed + and surfaced to [[reconciliation]], not silently accepted. +- **Hardware-backed time where possible.** A battery-backed RTC on the appliance; the + [[atecc608]]/secure element and [[disk-os-hardening]] reduce casual tampering. An operator + changing time should require privilege the booth login doesn't have. +- **Opportunistic trusted sync** when a [[reconciliation]] channel is briefly online (the same + USB/hotspot path) — set/check the clock against an external authority, log any correction as an + event. + +## Stance + +Like the rest of the system: **prevention (hardened host, privileged-only time change) first, +detection (anomaly on clock regression, reconciliation) as the backstop.** The clock can't be made +unforgeable on an offline box, but a forged clock can be made **visible**. + +## Open + +- RTC / time source on the chosen appliance ([[bom]]). +- Tolerance thresholds for "implausible" jumps before flagging. +- Whether to hard-refuse an event on a backwards clock vs. record-and-flag (record-and-flag matches + the append-only ethos — never drop). diff --git a/wiki/concepts/parking-session.md b/wiki/concepts/parking-session.md new file mode 100644 index 0000000..e9c8385 --- /dev/null +++ b/wiki/concepts/parking-session.md @@ -0,0 +1,103 @@ +--- +type: concept +tags: [parking, domain, business, anti-fraud] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Parking Session + +The core business-domain entity: one vehicle's stay, from entry to exit, plus the money owed and +paid for it. Everything on the business side — [[tariff|tariffs]], payment, [[reconciliation]], +revenue reporting — hangs off the session. This page defines what a session **is** and, just as +importantly, what it is **not**. + +> Scope decision (2026-06-15): build the **transient** (casual, pay-for-duration) session first; +> layer **permit holders** on top as a second identity source that short-circuits payment. Mixed +> site, transient-first — see [[entry-exit-readers]] ("two populations, one shared relay") and +> [[session-model]]. + +## A session is a PROJECTION over the signed event log — not a mutable table + +This is the single most important rule, and it falls straight out of the [[threat-model]] (the +adversary is the insider who can edit the database) and the [[append-only-event-chain]]: + +- The **events** table is the ledger and the **only** source of truth. `vehicle_entry`, + `vehicle_exit`, `payment`, `void` are all **appended + signed**, never updated or deleted. +- A **session** is a **read-model folded from those events** — open when an entry has no matching + exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in + a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable + from the chain and never authoritative**. Same pattern as the `LaneMap` + ([[append-only-event-chain]]), scaled to the business domain. +- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen + exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets + the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator + can't forge or silently delete — a deletion breaks the chain visibly. See [[session-model]] for + the rejected mutable-table alternative. + +## Identity — how an entry is tied to its exit + +A session needs a key that survives from entry to exit. Two populations, two keys +([[entry-exit-readers]]): + +- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]]) + or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field. +- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid + permit means the session owes nothing — the PAY step is skipped (see below). + +## Lifecycle (pay-on-foot / pay station model) + +Payment is **decoupled from exit** (decision 2026-06-15, matching the [[autonomous-direction| +unmanned]] roadmap): the customer pays at a central station before walking back to the car; the +exit lane only *validates* that the session is settled. + +``` +ENTRY (lane) vehicle_entry event → session OPEN + (ticket printed / plate read; barrier opens) +PAY (pay station) payment event {sessionRef, fee, paidAt} + → session PAID (grace window starts) +EXIT (lane) validate: PAID && now ≤ paidAt + graceMinutes ? + yes → vehicle_exit event → session CLOSED → pulseOpen + no → reject → re-pay overstay top-up at station, then exit +``` + +States, as derived from events: + +| State | Condition (over the event chain) | +| --- | --- | +| **OPEN** | a `vehicle_entry` with no later matching `vehicle_exit` | +| **PAID** | OPEN + a `payment` event covering the fee due, within its grace window | +| **CLOSED** | a matching `vehicle_exit` event exists | +| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) | + +Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close. + +## Edge cases the model must name (not yet designed in full) + +- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely + stateful rule; handled as a second `payment` event, fee = f(time since paid). +- **Lost ticket** — no entry id to match. A default flat "lost ticket" fee (see [[tariff]]), **or + an amount the admin sets at the moment** (operator judgement — e.g. they can establish entry time + from [[opencv-anpr-service|plate]] capture or CCTV and charge accordingly, or apply a fixed + penalty). Recorded as a `payment` (with the chosen amount + a reason) + a `void`/annotation so it + reconciles; the admin-set amount is captured in the signed event, attributed. +- **Manual override** — an operator/admin opens the barrier for a stuck or disputed car, or writes + off a session, as a deliberate act. Each is a **signed, reason-coded event** + (`barrier_open_command` / a void with reason) — so an override is *authorized and logged*, while + an open with **no** such signed event remains the fraud signal ([[append-only-event-chain]]). The + override is the legitimate counterpart to the out-of-band-open anomaly. +- **Forced / fail-open exit** — barrier failed open ([[fail-state-safety]]): the vehicle leaves with + **no `vehicle_exit`**. This is an open session that never closes — a **reconciliation anomaly by + design** ([[append-only-event-chain]]'s "physical open with no signed command"), not something to + paper over. (A *manual* override above is the signed, non-anomalous version.) +- **Re-entry / never-exited** — stale open sessions (drove out tailgating, sensor missed). Surface + as anomalies; never auto-close silently. + +## 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]]. diff --git a/wiki/concepts/reporting-analytics.md b/wiki/concepts/reporting-analytics.md new file mode 100644 index 0000000..5e13700 --- /dev/null +++ b/wiki/concepts/reporting-analytics.md @@ -0,0 +1,48 @@ +--- +type: concept +tags: [parking, domain, business, reporting] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Reporting & Analytics + +Turning the signed event log into the numbers an owner runs the business on. All reports are +**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are +derived and rebuildable, never a separate ledger. + +## Reports (driven by the events already designed) + +- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source: + `payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]). +- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover. +- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split. +- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`. +- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation — + the `anomaly` events + reconciliation findings ([[reconciliation]]). + +## Plate / entry search (admin lookup) — user-requested 2026-06-15 + +The admin can **search for an entry/session by licence plate** — *if the plate was captured* (by +the [[opencv-anpr-service|vision service]] or an LPR read; a pure-ticket transient has no plate). +Returns the matching session(s): entry/exit times, fee, payment, snapshot image. Useful for +disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and incident review. + +- Search keys: plate (when captured), ticket id, session id, time range. +- Read-only over the chain; surfaces the linked snapshot ([[lpr-camera]] `imageRef`) as evidence. +- Honest limit: **no plate → no plate-search hit.** The UI must say "not captured", not "no such + car", so the absence isn't mistaken for a missing record. + +## Properties + +- **Offline** ([[offline-first]]): all computed locally from the local DB; no cloud BI dependency. +- **Reproducible**: a report run twice over the same chain gives the same answer; figures trace to + signed events. +- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path + ([[open-questions]] #4). + +## Open + +- Which reports matter at launch vs. later; the export format/cadence. +- Dashboard (live) vs. on-demand reports. diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md new file mode 100644 index 0000000..57b7bd4 --- /dev/null +++ b/wiki/concepts/shift.md @@ -0,0 +1,77 @@ +--- +type: concept +tags: [parking, domain, business, shifts, anti-fraud] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Shift (manned mode) & the Z-Report + +A **shift** is one operator's accountability period at a manned booth: from the moment they take +over to the moment they hand over, however long that is. At the end, the system signs and **prints +a Z-report** — the cash and POS totals taken during the shift. (Decisions 2026-06-15.) + +## Shifts exist ONLY in manned mode + +A shift is fundamentally a **human accountability boundary** — "this person was responsible for the +takings from here to here." In the [[autonomous-direction|fully-automated / unmanned]] system there +is **no operator and no shift**; what replaces it is the pay station's **cash-collection cycle** +(who emptied the vault, when, how much vs. what the signed log expected) plus ongoing +[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation; +don't force one model across both. + +## A shift is NOT time-based + +It is delimited by **explicit operator action**, never by a clock: + +- Booth reality: relief comes late, doesn't show, or one operator is **forced to work two shifts in + a row**. A fixed 8h boundary (or an 8h token expiry) would be wrong — it could strand an active + operator. So the [[local-jwt-auth|login token has no time expiry]] (valid until logout). +- **Start Shift / End Shift are explicit, and independent of login.** One login can span many + shifts; a back-to-back double is simply *End Shift → Start Shift again*, no re-login. The + operator (the same person or the next) marks the boundary. + +``` +login ——————————————————————————————————————————————→ (until logout) + [Start shift] … takings … [End shift→sign+print Z] [Start shift] … [End shift] … +``` + +## What End Shift does + +1. Determine the shift's payment set: the signed `payment` events ([[parking-session]], + [[append-only-event-chain]]) between this shift's start mark and now. +2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured** + (the card line is omitted when there's no terminal). +3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator, + startedAt, endedAt, cashTotal, cardTotal?, paymentCount, eventRange, prevZHash }` — chained to + the prior Z so a missing/out-of-order Z-report is itself visible. +4. **Print the Z-report** (cash total, POS total if any, counts, shift window, operator) on the + booth printer. + +That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count, +no variance gate, no manager override. + +## Where the fraud control actually lives + +Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the +append-only chain**, the printed cash figure *is* the system's tamper-evident truth. A manager +reconciles the signed Z-report against the actual drawer and the bank/POS batch **later** — that's +[[reconciliation]], the real control (deferred). The tradeoff vs. a heavier control is purely +*when* a skim is caught (after the fact, by a human), not *whether*. + +> **Optional enhancement (not building now): blind cash count.** Have the operator enter the +> counted cash *before* the system reveals the expected figure, and record the variance into the +> `shift_z_report`. Blindness removes the operator's ability to back-fill their declaration to match +> expectation, catching a skim **at close** rather than later. Explicitly out of scope per +> 2026-06-15; documented as a clean add-on if ever wanted. + +## Open + +- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the + Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled + the money. Confirm that's the intended accountability (vs. by entry). +- **Mid-shift report / X-report** (read-only "so far" total without closing) — add if booths want + it; the sum is the same projection. +- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site + (relates to [[open-questions]] #1 lane topology). diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md new file mode 100644 index 0000000..df06041 --- /dev/null +++ b/wiki/concepts/tariff.md @@ -0,0 +1,155 @@ +--- +type: concept +tags: [parking, domain, business, pricing] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Tariff (Fee Model) + +How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data, +not code** — the park owner builds and constantly edits the rate card at runtime (like a +[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to +reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority +beyond the host). + +> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing +> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned +> over time), modelled with an id/scope so multiple rate cards can be added later without migration; +> (3) **currency is selectable** (ISO 4217) and the money model is **FX-ready but FX is deferred**. + +## Design principles + +- **Pure function of (entry time, charge time, tariff).** `fee = f(enteredAt, asOf, tariff)`. No + side effects, deterministic, unit-testable. The pay station calls it with `asOf = now`; the exit + lane re-checks against the recorded payment. +- **Data-driven.** The tariff lives as a config record (its own table or seeded config), versioned, + so a historical session always reprices against the tariff in force when it was incurred. Never + hard-code rates (this is an [[open-questions|open-question]]-adjacent procurement input — sites + differ). +- **Integer minor units.** Money is integer cents (or the site currency's minor unit) — never + floats. Avoids rounding drift across a revenue ledger. +- **The fee, once paid, is a signed `payment` event** ([[parking-session]]) — the computation is + reproducible, but the *charged* amount is fixed in the chain. + +## The composable structure — stepped blocks + daily cap + +The admin composes a **rate card** the fee function interprets. The general model is an **ordered +list of duration blocks** (flat rate is just one block) plus a daily cap — chosen because it +expresses every common operator shape (first-hour pricing, tapering, caps) with no special cases in +code. All amounts are **integer minor units** in the tariff's currency. + +```jsonc +{ + "currency": "EUR", // ISO 4217; selectable per tariff version + "gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround) + "incrementMin": 60, // billing granularity; partial increments round UP + "blocks": [ // consumed in order as duration accrues + { "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour + { "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min + { "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter + ], + "dailyCapMinor": 1200, // cap per rolling 24h (null = no cap) + "lostTicketMinor": 2000, // flat charge when there's no entry id + "gracePeriodExitMin": 15, // pay-on-foot walk-back window + "overstay": "reprice" // top-up = recompute(entry→now) − alreadyPaid (decided) +} +``` + +> **The numbers above are illustrative, not defaults to ship.** "No one knows the pricing and it +> changes constantly" — so the admin authors all of it; the system ships with **no rate card** and +> the owner must compose + publish one before the lot can charge (until then: free, or gated — +> operator policy, see Open). + +**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary +amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]] +capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the +default; the chosen amount is recorded in the signed `payment` event ([[parking-session]]). + +## The fee algorithm (pure, integer, offline) + +``` +fee(enteredAt, asOf, tariff): + minutes = roundUp(asOf − enteredAt, incrementMin) + if minutes ≤ gracePeriodEntryMin: return 0 + total = 0 + for each rolling 24h segment of the stay: + segMinutes = minutes within this segment + segFee = walk `blocks` in order, charging priceMinorPerIncrement for each + incrementMin that falls in each block's [prevUpto, uptoMin) range + if dailyCapMinor: segFee = min(segFee, dailyCapMinor) + total += segFee + return total +``` + +Deterministic, side-effect-free, unit-testable; the daily cap is applied **per rolling 24h** (so an +overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec +because the chain + reconciliation depend on the result being reproducible. + +## The pay-on-foot consequence + +Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two +time references**, not one: + +1. At the **pay station**: `fee = f(enteredAt, now, tariff)` — charge for time parked so far. +2. At the **exit lane**: the session is valid to leave iff `now ≤ paidAt + gracePeriodExit`. + Past that, an **overstay top-up** = `f(paidAt, now, tariff.overstayRate)` is due before exit. + +`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people +who paid; too long gives free parking between pay and exit. + +## Permit holders + +A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription +already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for +the uncovered time — an edge case to design with [[permit]]. + +## Versioning — edits publish immutable, effective-dated versions + +Prices change constantly, **and** a historical [[parking-session]] must reprice against the rate +that was in force when it was incurred — never today's. So a tariff is **never edited in place**: + +- Each save **publishes a new version** with an `effectiveFrom` timestamp; prior versions are + **immutable**. Picking the version for a session = "the latest version with `effectiveFrom ≤ + session entry time`". +- The session's **`payment` event records the `tariffVersionId`** it was priced under + ([[parking-session]], [[append-only-event-chain]]). The charged amount is then both reproducible + *and* fixed in the signed chain — an admin can't retroactively rewrite prices to alter what a past + session "should have" paid without it being visible. +- An **in-progress** session that crosses a version boundary uses the version in force at **entry** + (consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter. + +## Data model (first cut — with [[session-model]]) + +| Table / field | Notes | +| --- | --- | +| `tariffs` | a logical rate card: `id`, `scope` (site/lane/zone — only "site" used now), `name`. | +| `tariff_versions` | `id`, `tariffId`, `effectiveFrom`, `currency`, `structure` (the JSON above), `createdBy`, `createdAt`. **Immutable.** | +| (active) | "one active tariff per site" = one `tariffs` row; multiple `tariff_versions` over time. The `scope`/`id` exist so multiple rate cards can be added later **without migration**. | + +Unlike the event log, tariff data is **mutable master data** in the sense that new versions are +*added*; but each version row, once published, is never changed — close to append-only, and the +*use* of it is fixed in the signed `payment` event. + +## Currency & FX — selectable now, FX deferred + +- Each `tariff_version` names its **`currency`** (ISO 4217), admin-selectable. Amounts everywhere + are `{ minorUnits, currency }` — never a bare number, never a float. +- A `payment` event stores its **`currency`** and a reserved **`fxRate` (null for now)** + optional + `baseCurrency`. So when an exchange-rate system is added later, historical payments stay + reproducible (you know the currency charged and, once FX exists, the rate applied) — **no + migration** of stored amounts. +- **FX engine is NOT built now.** When it is, it needs an *offline* rate source (rates can't depend + on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to + [[open-questions]]. + +## Open + +- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the + composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work. +- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site + needs day/night/weekend cards (deferred until asked). +- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy). +- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed). +- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]). diff --git a/wiki/concepts/ticket-encoding.md b/wiki/concepts/ticket-encoding.md new file mode 100644 index 0000000..83c2020 --- /dev/null +++ b/wiki/concepts/ticket-encoding.md @@ -0,0 +1,54 @@ +--- +type: concept +tags: [parking, domain, business, devices, entry-flow] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Ticket Encoding & Scanning + +How a transient [[parking-session]]'s **ticket id** is printed, carried by the customer, and read +back at the pay station and exit. This is the **physical backbone of the transient flow** — the +thing that links entry → pay → exit when there's no plate. + +## The ticket id is the session key + +At entry the system mints a `vehicle_entry` event with a **ticket id** (`identity`) and prints a +ticket the customer keeps. That same id is read back later to find the session. Properties the id +must have: + +- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim + someone else's cheaper session). Sequential **physical** stock numbering is a separate + reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key. +- **Single logical session** — scanning it at the pay station finds the open session; after payment + it's the proof-of-paid the exit checks. + +## Encoding: QR (preferred) — printed by the booth dispenser + +- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and + entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap + camera/imager to read. +- **Scan points** (both host-side reads — [[entry-exit-readers]]): + - **Pay station** — customer scans the ticket → host finds the session → shows fee → takes + payment ([[tariff]], pay-on-foot) → appends `payment`. + - **Exit lane** — customer scans the (now paid) ticket → host validates paid + within + `gracePeriodExit` → `vehicle_exit` → `pulseOpen`. +- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice` + kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the + app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]). + +## Ticketless alternative (plate as the ticket) + +Where the [[opencv-anpr-service|vision service]]/LPR captures the plate, the **plate can be the +session key** instead of a printed ticket — drive in, plate read, drive to pay station and enter +plate (or it's looked up), pay, exit by plate. No paper. The two can coexist per lane +([[entry-exit-readers]] "both share a relay"); a printed QR ticket is the fallback when a plate +isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-service]]). + +## Open + +- QR symbology/error-correction level + what else prints (site name, tariff summary, help number). +- Scanner hardware (imager model; same unit at pay station and exit?). +- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary + amount). diff --git a/wiki/concepts/validation-discounts.md b/wiki/concepts/validation-discounts.md new file mode 100644 index 0000000..d9c64fb --- /dev/null +++ b/wiki/concepts/validation-discounts.md @@ -0,0 +1,46 @@ +--- +type: concept +tags: [parking, domain, business, pricing, revenue] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Validation & Discounts + +A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing — +a common revenue/retention feature that modifies what a [[parking-session]] owes. + +## Model: a discount is a signed event, applied at fee time + +A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason +as everything else ([[threat-model]]: an operator/merchant could otherwise fake free parking). It's +recorded so the fee computation and the audit both see it: + +- A **discount/validation event** references the session: `{ sessionRef, kind, value, issuedBy, + ts }` — e.g. *2 hours free*, *€5 off*, *flat €1*, *100% off*. Appended + signed + ([[append-only-event-chain]]). +- The [[tariff]] fee function applies eligible validations when computing what's due at the pay + station: `due = max(0, tariff_fee − discounts)` (or time-based: subtract validated minutes before + pricing). Pure + reproducible, like the base fee. +- The `payment` event then records gross fee, discount total, and net paid — so revenue reporting + ([[reporting-analytics]]) can show **discount leakage** (how much was given away, by whom). + +## How a validation is presented + +- **Merchant terminal / portal** stamps the customer's ticket id (or plate) — issues the validation + event for that session. +- Or a **validation code** the customer enters at the pay station. +- Either way it ties to the session by **ticket id or plate** ([[parking-session]] identity). + +## Anti-abuse + +Because each validation is signed and attributed (`issuedBy`), over-validation by a colluding +merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is +an anomaly), rather than invisible free parking. + +## Open + +- Validation types the site needs (free hours / fixed amount / percentage / flat rate). +- Whether merchants self-serve (portal/terminal) or the operator applies it. +- Caps (max discount, max per merchant/day). diff --git a/wiki/decisions/open-questions.md b/wiki/decisions/open-questions.md index c241b29..56bfbee 100644 --- a/wiki/decisions/open-questions.md +++ b/wiki/decisions/open-questions.md @@ -24,7 +24,12 @@ status: open manager visit) to reconcile the signed log against an external authority — the real anti-fraud control. See [[reconciliation]]. 5. **Durability / backup.** Backup strategy for the [[sqlite]] database + recovery plan; "sync - later" currently leaves a disk failure as **total revenue-history loss**. + later" currently leaves a disk failure as **total revenue-history loss**. _(Confirmed in-scope + to design, 2026-06-15.)_ Because the DB is the signed [[append-only-event-chain]], a backup must + preserve the chain intact (a restored copy must still `verifyChain`); options include SQLite + WAL/online-backup snapshots to a second disk/USB + the periodic external export that doubles as + the [[reconciliation]] channel (#4). Encryption at rest already applies ([[disk-os-hardening]]). + Design TBD. 6. **Secure-element integration.** Confirm [[atecc608]] wiring/usage on the host (event signing). The [[esp32-custom-controller]] command-authentication use is **deferred — not being implemented for now** (access control is the [[dingtian-relay]] behind @@ -39,3 +44,15 @@ status: open compromising a verifying host yields nothing that can forge a token. Decide before multi-host / multi-lane deployment (see #1 lane topology), since that's when shared-secret distribution becomes the liability. +8. **Exchange-rate (FX) system.** _(Raised by the [[tariff]] design, 2026-06-15.)_ Currency is + selectable per tariff version and the money model is FX-ready (`payment` stores currency + a + reserved `fxRate`), but **no conversion is built**. If multi-currency pricing/charging is ever + needed, it requires an **offline** rate source (rates can't depend on the network — + [[offline-first]]), a base currency, and a rounding policy. Deferred; nothing blocks adding it + later without migrating stored amounts. +9. **Pay-station money corners — receipts & refunds/change.** _(Raised by the scope sweep, + 2026-06-15; deferred until pay-station hardware is chosen.)_ Not yet designed: **receipts / VAT + invoices** (fiscal receipt with tax number + sequential numbering may be legally required — could + change what the `payment` event must store) and **refunds / overpayment / change** (cash change, + "exact change only", a refund as a signed reversal event). Both depend on the unmanned-vs-manned + payment subsystem (#3) and the note/coin/card acceptor hardware. Revisit at procurement. diff --git a/wiki/decisions/session-model.md b/wiki/decisions/session-model.md new file mode 100644 index 0000000..90c0b6c --- /dev/null +++ b/wiki/decisions/session-model.md @@ -0,0 +1,55 @@ +--- +type: decision +tags: [parking, decisions, domain, business] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Decision: Parking Session Model + +The starting decision for the **business layer**, taken 2026-06-15 as the project pivots from the +(now hardware-verified) device/integrity layer to the parking *operation*. + +## Decisions + +1. **A session is a projection over the signed event log, not a mutable table.** The + [[append-only-event-chain]] `events` table stays the only source of truth; a + [[parking-session]] is folded from `vehicle_entry` / `vehicle_exit` / `payment` / `void` + events. A cache table is allowed for query speed but is always rebuildable and never + authoritative. +2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first; + layer [[permit]] holders on top as a second identity source that short-circuits payment + ([[entry-exit-readers]]). +3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a + central station; the exit lane only validates the session is paid and within the walk-back + grace window before opening ([[parking-session]] lifecycle). Matches the + [[autonomous-direction|unmanned]] roadmap and sharpens [[open-questions]] #3 toward an unmanned + pay station (PCI scope still kept out of the app via a certified terminal). +4. **New signed event types:** `vehicle_entry`, `vehicle_exit`, `payment`, `void` — extend the + existing `input_received`. Recorded in [[append-only-event-chain]]. + +## Why (rejected alternative) + +A **mutable `sessions` table** carrying `amountOwed` / `paidStatus` as the source of truth was +rejected: it reopens the exact fraud vector the system exists to close ([[threat-model]] — the +insider edits the row, marks it paid, pockets the cash). Making "paid" a **signed `payment` +event** means it can't be forged and can't be silently deleted (a deletion breaks the chain). The +projection approach costs a fold/cache but keeps the anti-fraud guarantee intact end-to-end. + +## What this unblocks + +Closes the dangling thread from [[device-input-flow]] ("the entry flow itself is the next +build"): `input_received` → signed `vehicle_entry` → ticket print → `pulseOpen`, then the +pay-station and exit-validation flows. Schema (`packages/db`) + shared types follow the +[[parking-session]] + [[tariff]] design pages. + +## Open / next + +- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]). +- Tariff versioning (effective-dated) for historical repricing. +- [[permit]] data model + lapsed-mid-stay handling. +- Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract + (payment = an independent signed event referencing a session) until procurement settles. +- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4 + + the unbuilt witness/reconciliation gap in [[append-only-event-chain]]. diff --git a/wiki/decisions/standing-decisions.md b/wiki/decisions/standing-decisions.md index da37875..9abde6c 100644 --- a/wiki/decisions/standing-decisions.md +++ b/wiki/decisions/standing-decisions.md @@ -14,6 +14,10 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch - **Stack:** [[turborepo]] · [[fastify]] (Node) · [[react-vite-spa]] · [[sqlite]] + [[drizzle-orm]] · [[local-jwt-auth]]. All MIT/Apache/BSD — **no vendor lock, no rug-pull risk** (see [[payload-cms]]). Full table in [[technology-stack]]. + - **Scoped exception (2026-06-15):** the [[opencv-anpr-service]] — a **separate local process**, + not linked into the app — **may use AGPL** components (plate/vehicle models). The exception is + bounded to that process; the Node/React app stays strictly MIT/Apache/BSD. See + [[vision-service]]. - **Platform:** a **dedicated, hardened Linux appliance** (LUKS + GRUB password + Secure Boot), **not Windows/WSL** — see [[disk-os-hardening]]. - **Integrity:** append-only, hash-chained, [[atecc608]]-signed event log diff --git a/wiki/decisions/vision-service.md b/wiki/decisions/vision-service.md new file mode 100644 index 0000000..22d1973 --- /dev/null +++ b/wiki/decisions/vision-service.md @@ -0,0 +1,61 @@ +--- +type: decision +tags: [parking, decisions, vision, anpr, anti-fraud] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Decision: Host-side Vision Service (ANPR + vehicle verification) + +Taken 2026-06-15, as part of the business-layer build ([[session-model]]). + +## Decisions + +1. **Build a host-side vision service** ([[opencv-anpr-service]]) that does ANPR (plate → identity) + **and** vehicle-attribute verification (anti-spoofing witness) on snapshots from ordinary + Hikvision/Dahua cameras. +2. **It replaces the dedicated edge-AI [[lpr-camera]]** as the recognition path: ordinary IP cam → + snapshot (`Snapshot.bytes`, already pulled by the camera driver) → vision service → plate + + vehicle. Removes the special LPR camera from the [[bom]] as a requirement (still allowed as an + option). +3. **Deployment: a separate local Python/OpenCV microservice** on the appliance, called over + **localhost HTTP** by the Node backend. Fully offline ([[offline-first]]); its own process and + failure domain; the host falls back to the ticket path if it's unavailable. +4. **Licensing exception:** AGPL components (e.g. YOLO plate/vehicle models, OpenALPR) are + **permitted inside this service only**, because it's a separate process not linked into the app — + the app stays strictly MIT/Apache/BSD. Amends [[standing-decisions]]. +5. **Recognition is advisory, evidence is authoritative.** A read never single-handedly authorizes + a paid/access barrier open; it flags for [[reconciliation]] and attaches (with the source image) + to the signed [[append-only-event-chain]] entry. Low confidence → fallback, never strand a car + ([[fail-state-safety]]). + +## Why + +- **Replace vs. edge-AI camera:** host-side recognition on cheap IP cams shifts cost from per-lane + smart cameras to one compute box + our software; gives us the raw image for the second job below. +- **Vehicle verification is the real prize (user-driven, 2026-06-15):** plate-only ANPR can't catch + a **printed/spoofed plate on a different car**. Extracting vehicle attributes/fingerprint lets the + system reconcile *the car*, not just the number — directly filling the independent-witness gap the + [[append-only-event-chain]] calls out as unbuilt. +- **Separate-process + AGPL-scoped** keeps the app's permissive-license guarantee intact while not + crippling accuracy (the strict permissive-only ANPR path is markedly weaker — that tradeoff was + weighed and the scoped exception chosen). + +## Rejected / alternatives + +- **Strict permissive-only ANPR in-app** — license-clean but weaker accuracy and more build; the + separate-process AGPL exception was chosen instead. +- **Keep the edge-AI LPR camera as primary** — viable fallback if host-side accuracy disappoints; + not chosen now, kept on the table in [[opencv-anpr-service]]. +- **Embed OpenCV in Node** (opencv4nodejs/WASM) — rejected: native-build pain, weaker model + ecosystem, no process isolation, shares the app's failure + license surface. + +## Open / next + +- Recognizer + vehicle-model selection and accuracy targets; fingerprint method + anomaly + threshold ([[opencv-anpr-service]]). +- Appliance compute footprint (CPU vs. small GPU/NPU) — [[bom]] / [[open-questions]]. +- Service API + the Node-side adapter; per-camera opt-in wiring. +- Reconciliation logic that consumes plate+vehicle witness vs. commanded opens (still unbuilt — see + [[append-only-event-chain]], [[reconciliation]]). diff --git a/wiki/entities/blocklist.md b/wiki/entities/blocklist.md new file mode 100644 index 0000000..22a2937 --- /dev/null +++ b/wiki/entities/blocklist.md @@ -0,0 +1,35 @@ +--- +type: entity +tags: [parking, domain, business, access-control] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Blocklist (Banlist) + +Plates or credentials the lot **refuses** — barred vehicles (non-payers, abusers, court orders) and +revoked/stolen cards. Checked in the entry flow. + +## Model + +- A `blocklist` table of `{ kind: 'plate' | 'card' | 'qr', value, reason, addedBy, addedAt }` — + admin-managed master data (mutable: add/lift a ban). +- **Entry check:** after identifying the vehicle ([[parking-session]] identity — plate via + [[opencv-anpr-service|vision]]/LPR, or card/QR), if it matches an active blocklist entry, **refuse + entry** and append a signed event (`anomaly` / a refused-entry record) so the attempt is logged. +- **Exit is never blocked** — a barred car already inside must still leave ([[fail-state-safety]]: + never trap a vehicle). A blocklist hit at exit is logged for follow-up, not used to detain. + +## Notes + +- Plate matching depends on capture quality — a blocklist-by-plate is only as good as the + [[opencv-anpr-service|vision]] read; treat a near-miss as a flag for a human, not an automatic + refusal that could strand a misread innocent car. +- Bans are attributed (`addedBy`) and their enforcement is logged, so the control is auditable + ([[reconciliation]]) rather than an invisible operator lever. + +## Open + +- Plate-match tolerance (exact vs. fuzzy) and the false-positive handling. +- Expiry / review of bans. diff --git a/wiki/entities/local-jwt-auth.md b/wiki/entities/local-jwt-auth.md index dfe1b5a..f0ee59d 100644 --- a/wiki/entities/local-jwt-auth.md +++ b/wiki/entities/local-jwt-auth.md @@ -13,7 +13,14 @@ Authentication and authorization, kept **fully local** — a direct consequence - `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no - insecure default — and mints tokens with an **8h expiry** (bound to a shift). + insecure default. +- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15). + Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is + forced to work two shifts in a row — a token that expired mid-duty would strand an active + operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**, + not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.) + > ⚠️ Code still mints an 8h-expiry token — this page records the decided design; the server + > change (drop `expiresIn`, persist until logout) is pending. - A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint). - Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier / diff --git a/wiki/entities/lpr-camera.md b/wiki/entities/lpr-camera.md index 43dbe50..f690d29 100644 --- a/wiki/entities/lpr-camera.md +++ b/wiki/entities/lpr-camera.md @@ -7,12 +7,17 @@ updated: 2026-06-15 # LPR Camera -License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For -**casual/transient** vehicles, the **plate acts as ticket + an independent record**. (See -[[parking-system-architecture]] §8, §9.) +License-plate-recognition camera. For **casual/transient** vehicles, the **plate acts as ticket + +an independent record**. (See [[parking-system-architecture]] §8, §9.) -- **Edge AI**: recognition runs **on-device**, so it keeps working with no internet — fits - [[offline-first]]. +> **Superseded direction (2026-06-15):** recognition now runs **host-side** on snapshots from +> ordinary Hikvision/Dahua cameras via the [[opencv-anpr-service]], **not** on a dedicated edge-AI +> LPR camera — see [[vision-service]]. The edge-AI camera below is kept as the original assumption / +> a fallback option, but is no longer the planned path. The host-side service also does **vehicle +> verification** (anti-plate-spoofing), which an edge-LPR camera does not. + +- **Edge AI (original assumption)**: recognition runs **on-device**, so it keeps working with no + internet — fits [[offline-first]]. - It's a **host-side** identity source: only the host sees the read; the host decides and commands the relay open (the [[uhppote-controller]] is demoted to a commanded relay for that lane). See [[entry-exit-readers]]. diff --git a/wiki/entities/opencv-anpr-service.md b/wiki/entities/opencv-anpr-service.md new file mode 100644 index 0000000..2634496 --- /dev/null +++ b/wiki/entities/opencv-anpr-service.md @@ -0,0 +1,85 @@ +--- +type: entity +tags: [parking, vision, anpr, anti-fraud, service] +sources: [] +updated: 2026-06-15 +status: open +--- + +# OpenCV ANPR / Vision Service + +A **local microservice** that analyses camera snapshots: reads the licence **plate** (ANPR) and +extracts **vehicle attributes** for verification. Built by us (decision 2026-06-15) to do +recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicated edge-AI +[[lpr-camera]]. See decision [[vision-service]]. + +## Two jobs + +1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing + `IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way + a plate-bound [[permit]] is matched. +2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum + `{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding). + This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives + in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at + entry vs. exit (and vs. the [[permit]]'s known car) can. A plate that entered on a red hatchback + but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role + the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]]. + +> The two jobs are why this is worth building rather than just plate-OCR: the service is both an +> **identity source** and an **independent witness**, the visual analogue of the whole system's +> "two records that must reconcile" thesis. + +## Architecture — separate localhost process + +- A **Python service** (e.g. FastAPI) running **on the appliance**, called by the Node backend over + **localhost HTTP** (`POST /analyze` with the JPEG bytes the camera driver already pulls — see + [[lpr-camera]] "driver/storage boundary": `Snapshot.bytes`). +- **Fully offline** ([[offline-first]]): all inference is local, no cloud. Model weights ship on the + appliance. +- **Process isolation is deliberate** — it keeps a heavy Python/native/AGPL stack out of the + Node app's process and license surface (see licensing below), and gives it its own failure + domain. If the service is down/slow, the host falls back (transient ticket path) rather than + blocking the lane. +- **Request/response (first cut):** + - `POST /analyze` → `{ plate: {text, confidence, bbox}|null, vehicle: {colour, bodyType, make?, model?, embedding?}, modelVersion, tookMs }` + - `GET /health` → readiness + model versions. +- The Node side wraps it behind an internal interface (like a device adapter) so the recognizer can + be swapped without touching business logic. + +## Licensing — scoped AGPL exception (amends the standing rule) + +The app is strictly **MIT/Apache/BSD** ([[technology-stack]], [[standing-decisions]]). Accurate +ANPR/vehicle models are mostly **AGPL** (YOLO/Ultralytics detectors, OpenALPR) or commercial. +Decision (2026-06-15): **allow AGPL inside this service only.** It is a **separate process**, not +linked into the app, so its obligations don't reach the Node/React codebase; the app's permissive +guarantee is preserved. Recorded as an explicit exception in [[standing-decisions]] / +[[vision-service]]. + +- OpenCV core itself is **Apache-2.0** (clean either way). +- AGPL note: if the appliance is ever offered as a network service to third parties, AGPL's + network-use clause could require offering the service's source — relevant only if productised + beyond the on-site appliance; flag at that point. + +## Anti-fraud / threat-model fit + +- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or + vs. a [[permit]]'s registered car → anomaly. Doesn't *block* on its own (recognition is + probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record. +- The recognition result and the source image both attach to the signed [[append-only-event-chain]] + entry, so the *evidence* is tamper-evident even though recognition itself is host-side and + fallible. +- Recognition is **advisory, never the sole authority** to open a barrier where money/access is at + stake — confidence thresholds + fallback to ticket/manual; a low-confidence read must not strand a + car ([[fail-state-safety]]). + +## Open + +- **Recognizer choice** (permissive-only vs. AGPL model) and accuracy targets — see + [[vision-service]]; AGPL now permitted in-service. +- **Vehicle fingerprint**: attribute classifier vs. embedding-similarity; what threshold makes a + mismatch an anomaly without false-positiving on lighting/angle. +- **Compute footprint** on the appliance (CPU-only vs. a small GPU/NPU) — procurement input + ([[bom]], [[open-questions]]). +- Per-camera **opt-in** ("optionally bound", user's word): which lanes/cameras route snapshots to + the service. diff --git a/wiki/entities/permit.md b/wiki/entities/permit.md new file mode 100644 index 0000000..6cbafe2 --- /dev/null +++ b/wiki/entities/permit.md @@ -0,0 +1,119 @@ +--- +type: entity +tags: [parking, domain, business, subscriptions, identity] +sources: [] +updated: 2026-06-15 +status: open +--- + +# Permit (Subscription) + +A **subscription**: a known holder authorized to enter/exit without paying per-stay, for a covered +period. The second of the "two populations" ([[entry-exit-readers]]); a valid permit +**short-circuits the payment step** of a [[parking-session]] ([[session-model]]). Transient is +built first; permits layer on top. + +## Credentials (how a permit is presented) — confirmed with operator 2026-06-15 + +A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity +paths: + +- **RF tag / chip / card.** An RFID/proximity credential. Read **host-side** (reader → host → + `pulseOpen`): autonomy isn't required (resolved below), and the [[dingtian-relay]] has no onboard + card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still + fine and keeps a future autonomous path open ([[entry-exit-readers]]), but isn't required. +- **QR code.** Read by the **optical reader** — inherently **host-side** ([[entry-exit-readers]]: + pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the + permit → decides. + +Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource` +already in the model) and whose value is the credential id. + +## Two optional, independent bindings — confirmed 2026-06-15 + +A permit has **two constraints the admin may or may not apply**, orthogonally. Either, both, or +neither — the four combinations are all valid. + +### 1. Car-count binding (default: 1) + +- **Optional.** By default a permit is bound to **1 car at a time**. The admin may raise the limit + (a household, a company fleet) or **unbind it entirely** (no cap on how many cars use it). +- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the + [[parking-session]] projection: at entry, count the permit's currently-open sessions; if + `< maxConcurrent` (or unbound) allow, else reject (allowance full). This is exactly why + sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold + over open entry/exit events, **not a counter someone can edit**. + +### 2. Plate binding (default: off) + +- **Optional.** By default a permit is **not** plate-bound — any car may use it (identity is the + card/QR). The admin may bind it to a set of specific licence plates. +- When **bound**, an allowed plate is an **accepted identity in its own right** — a valid + **card/QR OR a matching plate** opens the lane (either, not a second factor): + +``` +entry: read card/QR → find permit → car-count ok → open + OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open +``` + +- **Accepted tradeoff:** card-OR-plate is the most convenient but does **not** prevent + card-sharing (a lent card still opens). Fine for a trusted permit population; the signed + [[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible + to [[reconciliation]] after the fact. +- **Plate-spoofing defence:** a printed copy of a registered plate on a *different* car is caught + not here but by the [[opencv-anpr-service]]'s **vehicle-attribute verification** — the seen car + must reconcile with the permit's known car, not just the plate string. + +> The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept +> any plate. The binding fields are simply absent/null when a constraint isn't applied. + +## Data model (first cut — to firm up with [[session-model]]) + +A `permits` table (and supporting rows). Unlike the event log, reference/master data like permits +**is** mutable (an admin grants/revokes/renews) — but every *use* of a permit still produces a +signed `vehicle_entry`/`vehicle_exit` event in the [[append-only-event-chain]], so the audit trail +stays append-only even though the permit record itself is editable. + +| Field | Notes | +| --- | --- | +| `id`, `holderName`/contact | the subscriber | +| `credentials[]` | one or more: `{ kind: 'rf' \| 'qr', value }` | +| `maxConcurrent` | car-count binding; **default 1**, raise for fleets, or `null` = unbound | +| `plates[]` | plate binding; **default empty/false** = any car; when set, these plates are accepted identities | +| `validFrom`, `validTo` | coverage window | +| `status` | active / suspended / revoked | + +> Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate, +> identified by its card/QR". + +## Interaction with the session model + +- **Entry:** credential read → permit lookup → valid (active, in window, plate allowed **if + plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry` + (source = `wiegand`/`qr`/`lpr`), open barrier. No ticket, no fee. (A bare permit applies neither + extra check — just active + in window.) +- **Exit:** credential/plate read → matching open permit session → signed `vehicle_exit`, open. No + payment required. +- **Lapsed mid-stay:** permit expires while a car is parked → the uncovered time falls back to the + transient [[tariff]] (edge case to design). +- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or + refused, per policy (OPEN). + +## Resolved (2026-06-15) + +- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or + unbound) and plate-binding (`plates[]`, **default off** = any car). Either, both, or neither. +- **Plate vs. credential:** when plate-bound, **card/QR OR matching plate** — either is accepted + identity (not a second factor); card-sharing not prevented by design, caught by + [[reconciliation]] after. +- **Autonomy:** **host-in-the-loop for everything** — no onboard card list needed, so the + [[dingtian-relay]] stays sufficient (no new controller). Permit entry **fails closed** if the + host is down ([[fail-state-safety]]). One code path for transient + permit. + +## Open questions + +1. **Reader hardware** — confirm the RF reader and the QR/optical reader models (procurement; + relates to [[bom]] and [[open-questions]]). RF need not be Wiegand now that autonomy isn't + required, but a Wiegand-out reader keeps options open. +2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm + with operator. diff --git a/wiki/index.md b/wiki/index.md index 3d8086e..af210bc 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -7,7 +7,7 @@ updated: 2026-06-14 # Index Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. -Counts: 1 source · 15 entities · 12 concepts · 2 decision records. +Counts: 1 source · 18 entities · 21 concepts · 4 decision records. ## Overview & navigation - [[overview]] — the top-level synthesis and entry point. @@ -71,13 +71,29 @@ Counts: 1 source · 15 entities · 12 concepts · 2 decision records. - [[entry-exit-readers]] — two populations, two integration paths; both can share a relay. - [[uhppote-vs-esp32]] — comparison: detection vs. prevention. +## Concepts — business domain +- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table. +- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window. +- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS). +- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full; exit never blocked. +- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time. +- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log. +- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box. +- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt. +- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions. +- [[permit]] — subscription; RF/QR or plate identity, registered-cars + max-concurrent, host-in-loop; short-circuits payment. +- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness). +- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed. + ## Dev environment (reference) - [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin. - [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after. ## Decisions - [[standing-decisions]] — settled decisions (stack, platform, integrity, access control, readers). -- [[open-questions]] — 7 open items (6 procurement + JWT key choice); ESP32 device auth deferred. +- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred. - [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker). - [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state. - [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale. +- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types. +- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception. diff --git a/wiki/log.md b/wiki/log.md index da9a74f..0df0ffb 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -355,3 +355,117 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). `localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the self-contained Linux answer. - Updated [[wsl-dev-networking]]. + +## [2026-06-15] design | Business layer kickoff — parking session model +- Pivoted from the (hardware-verified) device/integrity layer to the business domain. Wiki-first. +- KEY DECISION: a [[parking-session]] is a PROJECTION over the signed [[append-only-event-chain]], + never a mutable table — a mutable sessions row with paid/owed would reopen the operator-fraud + hole the whole system closes. "Paid" = a signed `payment` event (unforgeable, undeletable). +- Scope (user): mixed site, TRANSIENT-FIRST; [[permit]] holders layered as a 2nd identity source + that short-circuits payment. Payment = PAY-ON-FOOT / pay station (decoupled from exit; exit lane + only validates paid + within walk-back grace). Matches [[autonomous-direction]]. +- New signed event types designed (not yet built): `vehicle_entry`, `vehicle_exit`, `payment`, + `void` — extend `input_received`. Lifecycle OPEN→PAID→CLOSED (+VOIDED); overstay top-up is the + one genuinely stateful edge case. +- New pages: [[parking-session]], [[tariff]] (pure/data-driven fee fn; gracePeriodExit is a real + pay-on-foot revenue param), decision [[session-model]]. Updated [[append-only-event-chain]], + [[index]]. Closes the dangling entry-flow thread from [[device-input-flow]]. +- [[permit]] drafted + RESOLVED from user input: credentials = RF tag/chip/card + QR (optical + reader). Car limits = two numbers: `registeredCars[]` whitelist + admin-set `maxConcurrent` (in + at once) — enforced as a fold over the permit's open sessions. Identity = card/QR OR matching + plate (either opens; card-sharing not prevented by design, caught by reconciliation). Autonomy = + host-in-the-loop for everything → Dingtian stays sufficient, no new controller; permit entry + fails closed if host down. Remaining open: reader hardware models; lapsed/revoked policy. +- NEXT: schema (`packages/db`: permits/tariffs + session projection) + the + input_received→vehicle_entry flow (closes the [[device-input-flow]] thread). + +## [2026-06-15] design | Host-side vision service (ANPR + vehicle verification) +- User: optionally bind camera images to an OpenCV service we build. Resolved scope: ANPR (plate → + `IdentitySource='lpr'`); a **separate local Python/OpenCV microservice** on the appliance (Node → + localhost HTTP), offline; it **replaces the dedicated edge-AI [[lpr-camera]]** (recognition on + ordinary Hikvision/Dahua snapshots — reuses `Snapshot.bytes`). +- LICENSING: best ANPR/vehicle models are AGPL/commercial vs. the MIT/Apache/BSD standing rule. + Decision: **scoped AGPL exception** — allowed INSIDE the vision service only (separate process, + not linked); app stays permissive. Amended [[standing-decisions]]. +- USER ANTI-FRAUD INSIGHT: a fraudster can print a registered plate and enter with a different car. + → service also does **vehicle-attribute / fingerprint verification**, so the *car* reconciles, not + just the plate. This fills the independent-witness gap [[append-only-event-chain]] calls out: + plate-on-different-car = anomaly. Recognition is advisory (confidence + ticket fallback), evidence + (read + image) attaches to the signed event. +- New pages: [[opencv-anpr-service]], decision [[vision-service]]. Updated [[standing-decisions]], + [[lpr-camera]] (host-side supersedes edge-AI), [[permit]] (plate-spoof defence), + [[append-only-event-chain]] (vision as witness), [[index]]. +- Open: recognizer/vehicle-model choice + accuracy; fingerprint method + anomaly threshold; appliance + compute (CPU vs GPU/NPU); per-camera opt-in; the still-unbuilt reconciliation logic. + +## [2026-06-15] design | Transient pricing — composable, versioned tariff +- User: pricing is unknown + constantly changing → must be **admin-composable at runtime**, currency + selectable, FX later. Reframed [[tariff]] from "config we ship with numbers" to a first-class + editable entity. +- DECISIONS: (1) rate structure = **stepped duration blocks + rolling-24h daily cap** (flat rate is + one block; expresses first-hour/taper/cap with no special cases); (2) overstay top-up = + **reprice the difference** (recompute entry→now − alreadyPaid); (3) tariffs are **effective-dated + immutable versions** — edits publish a new version, sessions reprice against the version in force, + the `payment` event records `tariffVersionId` (reproducible + fixed in the signed chain); (4) + **one active tariff per site**, but modelled with id/scope so multi-tariff needs no migration; + (5) **currency selectable (ISO 4217)**, money = `{minorUnits, currency}`, payment reserves a null + `fxRate` → FX-ready, **FX engine deferred** (needs offline rate source — new [[open-questions]] #8). +- Ships with **no rate card**; owner must compose+publish one (blank = free or gated, operator + policy — open). Numbers in the page are illustrative, not defaults. +- Wrote the pure integer fee algorithm into [[tariff]] (data model: `tariffs` + immutable + `tariff_versions`). Updated [[open-questions]] (#8 FX), [[index]]. +- NEXT: schema (`packages/db`) for tariffs/versions + permits + session projection, then the + composer UI + the input_received→vehicle_entry flow. + +## [2026-06-15] design | Shifts (manned-only) + Z-report; drop time-based token +- Q: what happens at operator shift end? Resolved scope, deliberately small. +- Shifts exist ONLY in manned mode — a human accountability boundary. The fully-automated/unmanned + system has NO shifts; the pay-station cash-collection cycle + [[reconciliation]] replace it. +- Shift is NOT time-based: relief arrives late / no-shows / one operator forced into a double. + → **drop the 8h token expiry**; login valid **until explicit logout** (updated [[local-jwt-auth]]; + code change pending). Start/End Shift are **explicit, independent of login** — one login spans many + shifts; a double = End then Start again, no re-login. +- End Shift = sum signed `payment` events in the shift by tender → append a signed `shift_z_report` + (type already in packages/shared, chained to prior Z) → **PRINT cash total + POS total (if a POS + is configured)**. That's the whole human-side ask. No blind count / variance gate / manager + override. Fraud control stays in the signed chain + later [[reconciliation]] (catch a skim after + the fact, not at close). Blind-count documented as an explicit optional add-on, not built. +- New page [[shift]]; updated [[local-jwt-auth]], [[index]]. +- Open: Z sums by payment-time (the operator who took the money) — confirm; X-report (read-only + mid-shift); per-operator vs per-booth vs per-site (ties to [[open-questions]] #1). `payment` event + needs a `tender` field (cash/card) — fold into the schema step. + +## [2026-06-15] design | Scope sweep — capacity, validation, reporting, integrity gaps +- "What else can a PMS do?" — swept the full feature surface against the design; user picked the + in-scope gaps. New pages: + - [[capacity-occupancy]] — occupancy = fold over open sessions; refuse entry + drive a FULL sign + when full; **exit never blocked** ([[fail-state-safety]]); zone-ready; counting-drift = anomaly. + - [[validation-discounts]] — merchant validates a ticket → **signed discount event** applied at + fee time ([[tariff]]); over-validation visible to [[reconciliation]]; payment records gross/disc/net. + - [[reporting-analytics]] — revenue/occupancy/stay/permit/anomaly reports as projections over the + chain; **plate-search** (admin looks up a session by plate IF captured — honest "not captured"). + - [[clock-integrity]] — fees depend on the host clock; offline box → backdating attack; monotonic + index catches reorder, clock-regression = `anomaly`, RTC + privileged-only time change. + - [[blocklist]] — barred plates/cards refused at **entry only**; signed + attributed. +- Folded into existing pages: **manual overrides** = signed reason-coded events (legitimate + counterpart to the out-of-band-open anomaly) + **lost-ticket admin-arbitrary amount** → + [[parking-session]] + [[tariff]]; **backup/restore** confirmed in-scope, expanded [[open-questions]] + #5 (restored copy must still verifyChain; doubles as the reconciliation export). +- NOT captured (flagged): **intercom/help-call** — user didn't select it, but it's the only human + fallback for an unmanned lane; revisit. Deferred roadmap: reservations, mobile app, EV, loyalty. +- Updated [[index]]. + +## [2026-06-15] design | Second sweep — ticket encoding + anti-passback; money corners deferred +- More gap-hunting. New pages: + - [[ticket-encoding]] — the transient session key: opaque/unguessable **ticket id printed as QR** + by [[rongta-printer]], **scanned at pay station + exit** (new ReaderDevice/imager behind the + adapter); plate-as-ticket ticketless alt coexists per lane. The physical backbone of the + transient flow (was only implied). + - [[anti-passback]] — one id can't enter while it already has an OPEN session (card/ticket-passing + over the fence); a fold over the chain, *under* permit `maxConcurrent`. Soft (flag `anomaly`) by + default vs. hard (refuse); honest dependence on reliable exit detection. +- DEFERRED (user): **receipts/VAT invoices** + **refunds/change/overpay** — depend on pay-station + hardware + manned/unmanned payment subsystem; recorded as [[open-questions]] #9, revisit at + procurement (may change what the `payment` event stores → flagged before schema). +- Still open & load-bearing: **lane topology** (#1) — not resolved; scopes sessions/occupancy/shifts. +- Updated [[open-questions]] (#9), [[index]].