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
This commit is contained in:
@@ -62,7 +62,10 @@ export const PERMISSIONS: readonly Permission[] = [
|
||||
// a judgment about the operator, settled outside the app. See wiki/concepts/shift.md.
|
||||
"drawer:create", "drawer:review",
|
||||
"payment:read", "payment:create",
|
||||
"session:read",
|
||||
// session:create = the operator ISSUES an entry ticket when the physical entry button
|
||||
// is broken (a flagged mint, gated on real vehicle presence). Admin-revocable per role.
|
||||
// See wiki/concepts/operator-issued-entry.md.
|
||||
"session:read", "session:create",
|
||||
"event:read", "event:void",
|
||||
"report:read",
|
||||
"log:read",
|
||||
@@ -360,6 +363,10 @@ export const REASON_CODES = [
|
||||
// entry
|
||||
"entry.refused.full",
|
||||
"entry.held.noTicket",
|
||||
// operator-issued entry (physical button broken) — a flagged mint, gated on real
|
||||
// vehicle presence (radar + camera). See wiki/concepts/operator-issued-entry.md.
|
||||
"entry.operatorIssued",
|
||||
"entry.issue.noPresence",
|
||||
// exit refusals
|
||||
"exit.refused.closed",
|
||||
"exit.refused.noSession",
|
||||
@@ -373,6 +380,11 @@ export const REASON_CODES = [
|
||||
"exit.freeGrace",
|
||||
// manual / human-intervention barrier open
|
||||
"exit.manualOpen",
|
||||
// plate reconciliation: the exiting car's plate is already OPEN under a DIFFERENT
|
||||
// ticket (possible ticket-swap fraud). Suspected = flagged; Override = operator
|
||||
// consciously released it. See wiki/concepts/plate-reconciliation.md.
|
||||
"exit.plateSwapSuspected",
|
||||
"exit.plateSwapOverride",
|
||||
// subscriptions
|
||||
"sub.refused.notFound",
|
||||
"sub.refused.outOfWindow",
|
||||
@@ -397,6 +409,8 @@ export type ReasonCode = (typeof REASON_CODES)[number];
|
||||
export const REASON_EN: Record<ReasonCode, string> = {
|
||||
"entry.refused.full": "entry refused — lot full ({count}/{capacity})",
|
||||
"entry.held.noTicket": "entry held — ticket not printed: {detail}",
|
||||
"entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)",
|
||||
"entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry",
|
||||
"exit.refused.closed": "exit refused — session already closed",
|
||||
"exit.refused.noSession": "exit refused — no open session for ticket",
|
||||
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
|
||||
@@ -406,6 +420,8 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
||||
"exit.open.failed": "exit recorded, but the barrier did not open — open manually",
|
||||
"exit.freeGrace": "free entry-grace (no charge)",
|
||||
"exit.manualOpen": "manual barrier open (human intervention)",
|
||||
"exit.plateSwapSuspected": "possible ticket swap — plate {plate} is already inside under ticket {otherIdentity}",
|
||||
"exit.plateSwapOverride": "operator {operator} released a suspected ticket-swap exit (plate {plate}, also open under {otherIdentity})",
|
||||
"sub.refused.notFound": "subscription refused — not found",
|
||||
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
|
||||
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
||||
|
||||
Reference in New Issue
Block a user