Skip to content

Instantly share code, notes, and snippets.

@ahoward
Created April 14, 2026 02:47
Show Gist options
  • Select an option

  • Save ahoward/13bce0ffd877271ed979c78c4c2e014d to your computer and use it in GitHub Desktop.

Select an option

Save ahoward/13bce0ffd877271ed979c78c4c2e014d to your computer and use it in GitHub Desktop.
VRPS Information Architecture — Current Model, Proposed Changes, Target Model

VRPS Information Architecture

Product-level design. Produced through 3 rounds of adversarial peer review (Claude + Gemini).


The Big Picture

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 Entities

Customer

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


Property

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


Lot

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.


Unit

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.


Manager

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.


Pass Type

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.


Offer

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.


Pass

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.


Consumer

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.


Vehicle

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.


Enforcement

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.


Key Design Decisions

The hierarchy is a tree, not a graph

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.

Peer lots without breaking the tree

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.

Offers unify three old patterns

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.

Pass types and terms are separate

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.

Units declare their lot access explicitly

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.

Consumers are separate from operators

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 Summary

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

Current Data Model

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"
Loading

Proposed Changes

The following changes address the structural problems identified in the current model. These are logical changes — the migration strategy is a separate document.

  • Rename accountsunits — "account" is overloaded and ambiguous. A unit is what it actually is: a residential unit, retail space, or office suite within a property.

  • Add customers table — currently missing from the core schema. Properties have no explicit top-level owner. A customers table anchors the hierarchy and is the billing entity.

  • properties.customer_id NOT NULL — properties must belong to a customer. No orphaned properties.

  • lots.property_id NOT NULL — lots must belong to a property. Drop lots.customer_id. Customer ownership is always via the property, never direct.

  • Add unit_lots junction — 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_access table — peer lots: a lot can be made accessible to units from another property without changing its ownership. Same-customer only.

  • Promote pass_template_families to a named entity — today it has no attributes (just an id). Give it a name, description, and lot_id. This is the "Pass Type" that operators actually manage.

  • Add pass_offers table, replacing account_claim_codes + groups — three pass acquisition paths (direct, claim code, group) are unified into a single offers concept with four typed variants (open, claim_code, group, direct). One model, one audit trail.

  • Add is_active flag to pass_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.

  • vehicles owned by consumers, not passes — 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 circular consumers.default_vehicle_id FK; use vehicles.is_default flag instead. Drop account_vehicles (separate registry added later).

  • Replace consumers.phone NOT NULL UNIQUE with consumer_identities table — phone as the primary key forecloses email and OAuth identity. A consumer_identities table (type: phone/email/oauth, value, verified) allows multiple identity methods per consumer.

  • Split users into users + operator_profiles + enforcement_profiles — the users table currently holds both customer_id (for operators) and enforcement_company_id (for enforcement officers). These are different populations. Thin users to a pure auth-linked record; domain context goes in profile child tables.

  • Replace enforcement_company_scopes (legacy) with enforcement_company_properties — a single, modern enforcement scope table with explicit property + optional lot_ids[] override.

  • Add pass_transactions + payouts + payout_items — no financial model exists today. Transactions record what was charged per pass (denormalized with property_id + lot_id for fast revenue reporting). Payouts track Stripe Connect transfers to customers.

  • Soft deletes on all spine entities — add deleted_at to customers, 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 the managers table. Use managers only.


Proposed Data Model

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"
Loading

What Is Not In Scope (Yet)

  • 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 enginepass_transactions and payouts provide the schema foundation. Invoicing and reconciliation logic is a separate workstream.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment