Product-level design. Produced through 3 rounds of adversarial peer review (Claude + Gemini).
VRPS is organized as a simple hierarchy. Everything flows from the top down. Ownership and authorization are always explicit — never inferred.
Customer
└── Property
├── Units (residential units, retail spaces, offices)
│ └── Managers (people who manage the unit)
│ └── Lot Access (which lots this unit can issue passes for)
└── Lots (physical parking areas)
└── Pass Types (what kind of passes exist here)
└── Offers (how passes are made available)
└── Passes (a consumer's actual pass)
Consumer (the person parking) sits outside this hierarchy — they hold passes, own vehicles, and interact with lots. They are not part of the ownership tree.
Enforcement is an observer — it reads the tree (is this plate authorized?) but never modifies it.
The top-level organization. A property management company, a real estate group, a building owner. Everything belongs to a customer. Billing happens at this level.
Examples: Acme Property Management, Harbor Group, Downtown Parking LLC
A distinct physical location. A building, a complex, a campus. Belongs to one customer. A customer can have many properties.
Properties have their own branding (logo, colors) that appears on the lot landing page and consumer-facing materials.
Examples: Harbor View Apartments, The Meridian Office Complex, 400 Main Street
A physical parking area. Belongs to one property. A property can have many lots.
Each lot has a capacity, a mode (public / private / both), and a unique QR code that never changes once printed. Passes are always issued for a specific lot.
Examples: Harbor View Main Lot, P2 Underground, Visitor Surface Lot
Peer Lots: A lot can be made accessible to units from another property — for example, a shared surface lot between two adjacent buildings. The lot still belongs to one property (its owner). Access is granted explicitly, not inherited.
A named group of passes within a property. This is the operational unit that property managers work with day-to-day.
In residential buildings, a unit is literally an apartment unit — Unit 4B, Unit 12A. In commercial properties, it might be a retail tenant, an office suite, or a parking membership. In mixed-use properties, it's whatever the operator defines.
Units have:
- A name (e.g., "Unit 4B", "Retail Suite 101", "Visitor Account")
- A maximum number of concurrent active passes
- One or more managers (the people authorized to issue passes for it)
- Explicit access to one or more lots (declared, not assumed)
A unit in one property can be granted access to a peer lot from another property — that access is explicit and audited.
A person authorized to manage a unit. They can issue passes, view pass activity, and manage vehicles for their unit. A manager is a platform user (has a login). A unit can have multiple managers; a manager can manage multiple units.
Defines what kind of pass exists at a lot. This has two layers:
- Pass Type — the named product. "Monthly Resident Pass", "Visitor Day Pass", "Employee Annual". This is what operators name and manage. It belongs to a lot.
- Terms — the specific rules: rate, duration, seasonal restrictions. When a pass type's rate changes, new terms are created. Existing passes keep the terms they were issued under. Terms are immutable once a pass has been issued against them.
Operators manage pass types. The terms are the implementation detail.
How a consumer gets access to a pass. An offer is a standing authorization attached to a pass type. There are four kinds:
| Offer Type | How it works |
|---|---|
| Open | Any consumer at the lot can claim a pass. No code needed. Used for public lots. |
| Claim Code | Consumer enters a code to claim a pass. The manager distributes the code. Codes can be rotated. |
| Group | A block of passes with a share link and a capacity cap. Used for events, bulk resident move-ins, etc. |
| Direct | A manager issues a pass directly to a specific consumer. No code needed. |
Offers can have a capacity limit, a validity window, and an expiry date. When fully claimed, an offer is automatically exhausted. Operators can revoke an offer at any time.
The actual parking authorization held by a consumer. A pass is issued when a consumer claims an offer. At the moment of issuance, the current terms (rate, duration) are locked onto the pass — they do not change if the pass type's terms are later updated.
A pass belongs to a consumer, a vehicle, a lot, and optionally a unit.
Pass statuses: active → expiring soon → expired. Can also be revoked, suspended, or abandoned.
The person parking. Not an operator — consumers are residents, visitors, employees. They have one or more identity methods (phone, email) and one or more vehicles.
A specific plate registered to a consumer. A consumer can have multiple vehicles and designate one as their default. Enforcement looks up the plate to check authorization.
A third-party patrol company. Scoped to one or more properties (and optionally specific lots). Officers look up plates and log scan events and violations. They cannot issue or modify passes.
Every entity has exactly one parent. A lot belongs to one property. A property belongs to one customer. Authorization always flows up the tree. There are no shortcuts or parallel paths.
A lot is owned by one property. But it can be made accessible to units from another property. This is an explicit access grant (audited, reversible), not a change of ownership. The lot's parent property never changes.
Previously, passes could be acquired three different ways (direct issuance, claim codes, group share links), each with its own data model. These are now unified under a single "offer" concept with four typed variants.
Operators manage pass types (the named product). The underlying terms (rate, duration) are versioned and immutable once used. Changing the rate creates new terms; old passes are unaffected.
A unit doesn't automatically get access to all lots in its property. Access is declared explicitly. This makes authorization auditable and enables peer lot access across properties.
The people who park (consumers) and the people who manage properties and units (operators) are entirely different entities with different identity models and auth flows.
| Entity | Belongs To | Key Purpose |
|---|---|---|
| Customer | — | Top-level org, billing entity |
| Property | Customer | Physical location, branding |
| Lot | Property | Parking area, QR code, capacity |
| Unit | Property | Named group of passes (apartment, suite, etc.) |
| Manager | Unit | Operator who manages a unit |
| Pass Type | Lot | Named parking product |
| Terms | Pass Type | Rate + duration rules (immutable once used) |
| Offer | Lot + Pass Type | How a consumer gets a pass |
| Pass | Consumer + Vehicle + Lot | Active parking authorization |
| Consumer | — | Person parking, holds passes + vehicles |
| Vehicle | Consumer | Plate registered to a consumer |
| Enforcement Co. | — | Patrol company, reads-only |
The schema as it exists today. Core tables only — notifications, audit logs, and portal config omitted for clarity.
erDiagram
properties {
uuid id PK
text name
text timezone
jsonb branding_config
}
lots {
uuid id PK
uuid property_id FK
uuid customer_id FK
text name
text slug
int capacity
text lot_mode
int grace_period_minutes
}
accounts {
uuid id PK
uuid property_id FK
text name
int max_concurrent_passes
}
pass_template_families {
uuid id PK
}
pass_templates {
uuid id PK
uuid family_id FK
uuid lot_id FK
text name
text authorization_method
text rate_structure
int rate_amount
jsonb duration_rules
bool immutable
}
account_claim_codes {
uuid id PK
uuid account_id FK
uuid template_family_id FK
text code
}
groups {
uuid id PK
uuid account_id FK
uuid template_family_id FK
text share_code
int capacity
date start_date
date end_date
}
passes {
uuid id PK
uuid template_id FK
uuid account_id FK
uuid consumer_id FK
uuid lot_id FK
uuid group_id FK
uuid vehicle_id FK
text status
timestamptz start_at
timestamptz end_at
}
consumers {
uuid id PK
text phone
text email
uuid default_vehicle_id FK
}
vehicles {
uuid id PK
uuid pass_id FK
text plate
}
users {
uuid id PK
text email
text role
uuid customer_id FK
uuid enforcement_company_id FK
}
user_account_memberships {
uuid user_id FK
uuid account_id FK
}
enforcement_companies {
uuid id PK
text name
}
enforcement_company_scopes {
uuid enforcement_company_id FK
uuid property_id FK
uuid lot_id FK
}
properties ||--o{ lots : "has"
properties ||--o{ accounts : "has"
lots ||--o{ pass_templates : "has"
pass_template_families ||--o{ pass_templates : "versions"
accounts ||--o{ account_claim_codes : "has"
accounts ||--o{ groups : "has"
accounts ||--o{ passes : "issues"
pass_templates ||--o{ passes : "governs"
groups ||--o{ passes : "produces"
consumers ||--o{ passes : "holds"
vehicles ||--o{ passes : "on"
consumers ||--o{ vehicles : "owns"
users ||--o{ user_account_memberships : "member of"
accounts ||--o{ user_account_memberships : "has"
enforcement_companies ||--o{ enforcement_company_scopes : "scoped to"
enforcement_companies ||--o{ users : "employs"
The following changes address the structural problems identified in the current model. These are logical changes — the migration strategy is a separate document.
-
Rename
accounts→units— "account" is overloaded and ambiguous. A unit is what it actually is: a residential unit, retail space, or office suite within a property. -
Add
customerstable — currently missing from the core schema. Properties have no explicit top-level owner. Acustomerstable anchors the hierarchy and is the billing entity. -
properties.customer_idNOT NULL — properties must belong to a customer. No orphaned properties. -
lots.property_idNOT NULL — lots must belong to a property. Droplots.customer_id. Customer ownership is always via the property, never direct. -
Add
unit_lotsjunction — declare explicitly which lots a unit can issue passes for. Today this is implicit (a unit can use any lot in its property). Making it explicit enables peer lot support and makes authorization auditable. -
Add
lot_property_accesstable — peer lots: a lot can be made accessible to units from another property without changing its ownership. Same-customer only. -
Promote
pass_template_familiesto a named entity — today it has no attributes (just an id). Give it aname,description, andlot_id. This is the "Pass Type" that operators actually manage. -
Add
pass_offerstable, replacingaccount_claim_codes+groups— three pass acquisition paths (direct, claim code, group) are unified into a singleoffersconcept with four typed variants (open,claim_code,group,direct). One model, one audit trail. -
Add
is_activeflag topass_templates— with a partial unique index enforcing exactly one active template per family. Today there is no explicit mechanism for selecting the current active template. -
vehiclesowned byconsumers, notpasses— currently a vehicle is a child of a pass. A vehicle is persistent; a pass is temporary. Flip the ownership:vehicles.consumer_id. Drop the circularconsumers.default_vehicle_idFK; usevehicles.is_defaultflag instead. Dropaccount_vehicles(separate registry added later). -
Replace
consumers.phone NOT NULL UNIQUEwithconsumer_identitiestable — phone as the primary key forecloses email and OAuth identity. Aconsumer_identitiestable (type: phone/email/oauth,value,verified) allows multiple identity methods per consumer. -
Split
usersintousers+operator_profiles+enforcement_profiles— theuserstable currently holds bothcustomer_id(for operators) andenforcement_company_id(for enforcement officers). These are different populations. Thinusersto a pure auth-linked record; domain context goes in profile child tables. -
Replace
enforcement_company_scopes(legacy) withenforcement_company_properties— a single, modern enforcement scope table with explicit property + optionallot_ids[]override. -
Add
pass_transactions+payouts+payout_items— no financial model exists today. Transactions record what was charged per pass (denormalized withproperty_id+lot_idfor fast revenue reporting). Payouts track Stripe Connect transfers to customers. -
Soft deletes on all spine entities — add
deleted_attocustomers,properties,lots,units. Cascade via triggers: deleting a property soft-deletes its lots and units. No hard deletes on entities with financial history. -
Drop
user_account_memberships— redundant with themanagerstable. Usemanagersonly.
Core tables only — same omissions as above. Shows the target state after all changes are applied.
erDiagram
customers {
uuid id PK
text name
text status
text stripe_connect_id
}
properties {
uuid id PK
uuid customer_id FK
text name
text timezone
jsonb branding_config
}
lots {
uuid id PK
uuid property_id FK
text name
text slug
int total_capacity
text lot_mode
int grace_period_minutes
timestamptz deleted_at
}
lot_property_access {
uuid lot_id FK
uuid property_id FK
}
units {
uuid id PK
uuid property_id FK
text name
int max_concurrent_passes
timestamptz deleted_at
}
unit_lots {
uuid unit_id FK
uuid lot_id FK
text role
}
managers {
uuid id PK
uuid user_id FK
uuid unit_id FK
text status
}
pass_type_families {
uuid id PK
uuid lot_id FK
text name
text description
}
pass_templates {
uuid id PK
uuid family_id FK
text rate_structure
int rate_amount
jsonb duration_rules
bool is_active
timestamptz first_used_at
}
pass_offers {
uuid id PK
uuid lot_id FK
uuid family_id FK
uuid unit_id FK
text offer_type
text code
int capacity
int claimed_count
text status
}
passes {
uuid id PK
uuid offer_id FK
uuid template_id FK
uuid lot_id FK
uuid unit_id FK
uuid consumer_id FK
uuid vehicle_id FK
text plate
text status
timestamptz start_at
timestamptz end_at
}
consumers {
uuid id PK
uuid auth_user_id FK
timestamptz deleted_at
}
consumer_identities {
uuid id PK
uuid consumer_id FK
text type
text value
bool verified
bool preferred
}
vehicles {
uuid id PK
uuid consumer_id FK
text plate
bool is_default
}
users {
uuid id PK
uuid auth_user_id FK
text email
text[] roles
}
operator_profiles {
uuid user_id FK
uuid customer_id FK
text full_name
}
enforcement_profiles {
uuid user_id FK
uuid enforcement_company_id FK
text patrol_role
}
enforcement_companies {
uuid id PK
text name
}
enforcement_company_properties {
uuid enforcement_company_id FK
uuid property_id FK
uuid[] lot_ids
}
pass_transactions {
uuid id PK
uuid pass_id FK
uuid customer_id FK
uuid property_id FK
uuid lot_id FK
int amount_cents
text status
text stripe_charge_id
}
payouts {
uuid id PK
uuid customer_id FK
int amount_cents
text status
text stripe_payout_id
}
customers ||--o{ properties : "owns"
properties ||--o{ lots : "has"
properties ||--o{ units : "has"
lots ||--o{ lot_property_access : "accessible via"
lots ||--o{ unit_lots : "assigned to"
units ||--o{ unit_lots : "accesses"
units ||--o{ managers : "managed by"
lots ||--o{ pass_type_families : "has"
pass_type_families ||--o{ pass_templates : "versioned by"
pass_type_families ||--o{ pass_offers : "offered as"
pass_offers ||--o{ passes : "produces"
pass_templates ||--o{ passes : "terms locked at issuance"
consumers ||--o{ passes : "holds"
consumers ||--o{ consumer_identities : "identified by"
consumers ||--o{ vehicles : "owns"
vehicles ||--o{ passes : "on"
users ||--|| operator_profiles : "has"
users ||--|| enforcement_profiles : "has"
operator_profiles ||--o{ customers : "belongs to"
enforcement_companies ||--|| enforcement_profiles : "employs"
enforcement_companies ||--o{ enforcement_company_properties : "scoped to"
passes ||--o{ pass_transactions : "billed via"
customers ||--o{ payouts : "receives"
- Full role-based permissions — operators have a simple role set today. Granular per-action permissions are a future iteration.
- Cross-customer operators — a manager who manages units across multiple customers' properties.
- Availability engine peer lot support — the real-time capacity engine needs updating before peer lots go live. The schema supports it; the engine logic follows.
- Consumer merge tooling — UI for merging two consumer records that turn out to be the same person.
- Full billing engine —
pass_transactionsandpayoutsprovide the schema foundation. Invoicing and reconciliation logic is a separate workstream.