Rental House — Architecture

architecture/rentals.md

Status: reconciled to the shipped system (2026-06). This file began as a pre-build design spec; large parts described an architecture that shipped differently or never shipped. It has been rewritten to describe what is actually in the repo. It is the rental-domain architecture — the lifecycle, the entities, the availability model, the two drive-surfaces, and the money model. For the code/route/auth/data architecture of the admin portal, see the rest of docs/architecture/ (cross-referenced throughout) rather than duplicating it here.

Goals

The system can:

  1. Show a curated public catalog of rentable gear, in EN and ES.
  2. Take reservations against specific date ranges, items, and studio spaces.
  3. Track each rental from inquiry through quote, hold, booking, pickup, return, inspection, and settlement, without double-booking against studio.chat's own production shoots.
  4. Hold deposits, condition reports, and damage claims in one place, retrievable by the desk in seconds.
  5. Stay cheap to run when volume is low.

Non-goals (v1)

  • Real-time direct checkout / charge-on-confirm.
  • Live payment capture (deposits are recorded by the desk; a payment-provider integration is designed but deferred — see docs/architecture/client-portal.md).
  • Inter-warehouse / multi-location inventory.
  • Real-time delivery dispatch.
  • A separate domain or brand from studio.chat.

These are deferred until usage justifies the complexity.

Rental gate (vetting and insurance removed)

The bar to rent is deliberately minimal (owner-decided, 2026-06):

  1. a 100% replacement-value deposit (the deposit-only protection model — see docs/business/pricing.md),
  2. ID for the responsible party, and
  3. a verified contact phone and email.

That is the whole gate. There is no vetting tier, no insurance, and no COI. The earlier Tier A/B/C vetting model, COI thresholds, insurance carriers, first-time-renter caps, and student discounts are all removed as policy. The supporting schema was also removed in migration 0005: the rental_tier enum type and the items.tier / items.requires_coi columns (plus kits.tier and the settings.tier_b_* / settings.tier_c_* thresholds) are gone. The clients.trust_tier enum is kept — it's client standing, not a vetting gate — though its blocked value was renamed to blacklisted in the same migration (see clients below).

High-level shape

                ┌───────────────────────────────────────────────────┐
                │              studio.chat (Vercel)                  │
                │                                                    │
                │  Public Next.js app (EN/ES, `[locale]` group)      │
                │  ┌───────────────────────────────────────────────┐│
                │  │ /services/rentals          (overview)          ││
                │  │ /services/rentals/catalog  (grid)              ││
                │  │ /services/rentals/catalog/[sku] (detail)       ││
                │  └───────────────────────────────────────────────┘│
                │                                                    │
                │  Client portal (`/portal` group — read-only)     │
                │  Admin office (`/office` group — gated, dark)      │
                │  ┌───────────────────────────────────────────────┐│
                │  │ reservations, assets, inventory, spaces,       ││
                │  │ blackouts, clients, links, finances, …       ││
                │  └───────────────────────────────────────────────┘│
                │                                                    │
                │  iOS staff app  ──HTTPS (Bearer JWT)──▶ /api/office │
                └───────────────────────────┬───────────────────────┘
                                            │
                       ┌────────────────────┴────────────────────┐
                       ▼                                          ▼
                 ┌────────────┐                            ┌────────────┐
                 │  Supabase  │                            │  Resend    │
                 │  Postgres  │                            │  (email —  │
                 │  Storage   │                            │  /contact  │
                 │  Auth      │                            │  only)     │
                 └────────────┘                            └────────────┘

The original spec drew Stripe and Wompi as live boxes. They are not wired (see "Money & payments" below). Resend carries /contact and the rental transactional emails (quote / confirmation / receipt / reminders).

Stack

See docs/architecture/office.md for the authoritative code/infra architecture and the five load-bearing facts the office was built around. In brief:

LayerChoiceNotes
Primary DBSupabase PostgresAll rental tables are RLS-locked "admin_only"; access is exclusively via the service-role client (supabaseAdmin()), strictly server-side.
Object storageSupabase StorageCondition photos, identity docs, contract PDFs. Private bucket, signed URLs. (No COI/insurance docs — there is no insurance requirement; see "Rental gate" below.)
Auth (office)Supabase Auth (Google OAuth; passwordless) + DB office roleAccess requires an accounts row holding an office role (account_roles); no env allowlist. Clients get magic links / optional passkeys. Web office is cookie/session-bound; the REST API authenticates a Supabase user JWT as Authorization: Bearer … and resolves the same role. See docs/architecture/auth.md.
EmailResend/contact, plus the rental transactional emails (quote / confirmation / receipt on status entry, T-1 pickup/return reminders via cron) — bilingual, audited, skip-path off production.
Admin UINext.js app routes under /office/*Same codebase + Tailwind tokens as the public site. No Retool/Airtable.
SearchPostgres ilike filtersNo Algolia/Meili at this size.

Not used: Heroku, Stripe, Wompi, DocuSign/HelloSign. The payment-provider enum (cash | stripe | wompi | transfer) exists in the schema and the deposit form, but no provider is integrated — payments are recorded manually by the desk today. Vercel Cron is live (hold expiry, T-1 reminders, overdue flags — see docs/business/processes.md). Contracts: in-office templates + per-reservation print/PDF + executed archive shipped 2026-06-11; signing itself is still manual (Google Docs e-signature as the designed stopgap; Slack notifications unwired) — see "Contracts" below.

Migration history (actual)

Supabase migrations live in supabase/migrations/*.sql, forward-only, applied via Supabase Branching. See docs/architecture/migrations.md for the workflow and environment topology. The real sequence is not the "items → … → production_lockouts" ordering the original spec imagined; the operational schema was present in the baseline from day one:

FileWhat it does
0001_baseline.sqlSchema-only dump of prod's public schema (2026-05-31). Already the full operational schema — reservations, reservation_items, units, areas, clients, payments, claims, condition_reports, item_blackouts, area_blackouts, audit_log, settings, items, kits, sync_events/errors, launch_subscribers — plus types, triggers, RLS-enable, and the ensure_rls event trigger.
0002_assets_spaces_settings.sqlConsolidation of seven in-flight changes: units → assets rename (incl. assigned_unit_ids → assigned_asset_ids, item_blackouts.unit_id → asset_id), areas → spaces rename (incl. area_blackouts → space_blackouts, area_id → space_id, items.bookable_online → reservable_online), bundle_items + short_links/short_link_scans tables, settings → append-only revision history, items dedupe by SKU + UNIQUE(sku), items.serialized.
0003_harden_function_security.sqlSecurity hardening — pins search_path on trigger functions, revokes the RPC grant on rls_auto_enable. No app-visible behavior change.
0004_soft_delete.sqlAdds nullable deleted_at to reservations, clients, short_links for the /office/data archive/restore/purge console.
0005_office_and_accounts.sqlConsolidation of seven in-flight changes (folded before push): office RPCs + the reservation_reference_counters table (create_reservation, assign_units, short_link_scan_leaderboard, find_or_create_account); removes the dead sheet-sync subsystem (sync_events/sync_errors, sync_event_status, settings.inventory_canonical_source, the /api/sync/inventory route + importer) and renames sync_sourceaudit_source (dropping sheet); sets the deposit default to 100%; adds lost to rental_condition; removes vetting/insurance tiering (rental_tier, items.tier, kits.tier, items.requires_coi, settings.tier_b/c_min_replacement_usd_cents) and relabels client_trust_tier blockedblacklisted; drops the vestigial clients COI columns (ID columns kept); and the identity overhaulclientsaccounts (single auth.users-linked identity for clients + staff; nullable user_id, source, preferred_languagelocale, trust_tierstanding) + a client_profiles extension, roles/account_roles (office RBAC: staff/manager/administrator + editor), account_verifications (proofs with expiry, replacing the single ID columns), reservations.client_idaccount_id, launch_subscriberssubscribers (+ subscription), and normalized stale object names (bookings_*reservations_*, areas_*spaces_*, clients_*accounts_*).

There was never a production_lockouts migration — the blackout mechanism is the asset_blackouts / space_blackouts pair (below).

Data model

This doc covers the operational tables. See 04-inventory-schema.md for items, assets, kits, bundle_items, spaces, and settings, and docs/architecture/data-model.md for the same tables from the code side.

Tables in the live schema: reservations, reservation_items, assets, spaces, items, kits, bundle_items, clients, short_links, short_link_scans, payments, claims, condition_reports, audit_log, settings, launch_subscribers, asset_blackouts, space_blackouts, sync_events, sync_errors, reservation_reference_counters (the last dropped in 0013).

Inventory policy (owner-decided, 2026-06). Prod already holds the catalog; items are added by hand later via the office (or the office agent), so there is no inventory-sheet importer — that CSV/sheet ingestion idea is gone, not pending. kits / bundle_items exist in the schema but bundles/kits as a client offering are deferred. Sub-rentals are not policed beyond the standard terms (paid, returned on time, in the same condition). Consumables are out of scope — a future store, not part of rentals.

clients

fieldtypenotes
iduuid
display_nametext
emailcitextunique; case-insensitive
phonetextE.164
preferred_languageenumen / es
companytext
standing (was trust_tier)enumkept as client standing, not a vetting gate. Renamed trust_tierstanding in 0005; 0023 slimmed it to neutral / blacklist (dropping the stored repeat, now derived from paid reservations >= 2, and folding standard/unverified/verified into neutral). blacklist routes future requests to owner approval; neutral is the unremarkable default. This is not the removed vetting tier — the rental gate is deposit + ID + verified contact for everyone (see "Rental gate").
internal_notestextdesk-only
deleted_attimestamptzsoft delete (0004)
created_at, updated_attimestamptz

The clients COI columns (coi_provider, coi_on_file_until, coi_doc_url) were dropped in 0005 (deposit-only, no insurance). The ID columns (identity_verified_at, identity_doc_url) are kept — "provide ID" is part of the gate. The office reads the core columns plus internal_notes. The trust_tier enum is not a vetting lever (there is no vetting tier — see "Rental gate"); it is kept as client standing, where only blacklisted changes behavior (routes to owner approval). See GAPS. Clients are auto-created from reservations via the find_or_create_client RPC (idempotent lookup-or-insert), not entered by hand first.

reservations

The central operational record. (The reference UNIQUE constraint is reservations_reference_key0005 normalized the legacy bookings_* constraint names.)

fieldtypenotes
iduuid
referencetexthuman-friendly, minted by create_reservation — random R-XXXXXX from a confusable-free alphabet since 0013 (the per-day counter table is gone); older rows keep R-YYYY-MMDD-NN
client_iduuidFK → clients
statusenum reservation_statussee state machine
pickup_at / return_attimestamptzscheduled
actual_pickup_at / actual_return_attimestamptzfilled at pickup / return
subtotal_usd_centsintbefore fees
discount_usd_centsintmanual desk adjustment only — there are no automatic volume/multi-item/student discounts (2026-06)
damage_waiver_usd_centsintvestigial — there is no damage waiver (deposit-only model); stays 0
deposit_usd_centsint
total_usd_centsint
currency_displaytextusd / cop (snapshot for invoice)
fx_rate_snapshotnumeric(10,2)COP-per-USD captured at create time
project_contexttextwhat the client wrote with the inquiry
how_heardtext
notes_internaltextdesk-only
notes_clienttext
contract_urltextlink to the executed contract. Today the desk uploads a signed PDF; the designed (not built) flow generates the contract in Google Docs and e-signs it there — see "Contracts" below
cancel_reasontext
held_at, confirmed_at, picked_up_at, returned_at, settled_at, closed_at, cancelled_attimestamptzstatus stamps (see below; confirmed_at carried the old booked name until 0010)
deleted_attimestamptzsoft delete (0004)
created_at, updated_attimestamptz

reservation_items

The line items — one row per item, kit, or studio space on the reservation.

fieldtypenotes
iduuid
reservation_iduuidFK
item_iduuidnullable; set for item rentals
kit_iduuidnullable; set for kit/bundle rentals
space_iduuidnullable; set for space rentals (renamed from area_id in 0002)
qtyintCHECK qty > 0
rate_day_usd_cents_snapshotinteffective rate frozen at create time
rate_week_usd_cents_snapshotint
line_total_usd_centsint
assigned_asset_idsuuid[]physical assets bound to the line (renamed from assigned_unit_ids in 0002); empty until assignment at pickup
notestext
created_attimestamptz

A CHECK constraint requires at least one of item_id / kit_id / space_id.

condition_reports

Pickup + return inspection.

fieldtypenotes
iduuid
reservation_iduuidFK
booking_line_iduuidFK → reservation_items. Legacy column name retained through the rename — still literally booking_line_id.
directionenumout / in
inspector_user_iduuiddesk staff
checklistjsonbstructured (see 02-business-processes.md §6)
photostext[]Supabase Storage paths
notestext
signed_by_clientbool
signed_attimestamptz
signature_nametexttyped name
created_attimestamptz

The condition-report write flow (pickup/return sign-off in the office) is designed but its wiring should be confirmed against the office code — see GAPS.

claims

Damage, loss, late fee.

fieldtypenotes
iduuid
reservation_iduuidFK
kindenum claim_kinddamage / loss / late / cleaning / other
severityenum claim_severitycosmetic / functional / total_loss
descriptiontext
amount_usd_centsintcharged to deposit / additional invoice
statusenumfiled as notified by fileClaim
created_at, resolved_attimestamptz

payments

Reservation deposit, balance, and refunds. Recorded by the desk; no live provider.

fieldtypenotes
iduuid
reservation_iduuidFK
providerenum payment_providercash / stripe / wompi / transfer (default cash). stripe/wompi are selectable but not integrated.
provider_idtextexternal id (unused until a provider is wired)
kindenum payment_kinddeposit / balance / refund / damage etc.
amount_usd_centsint
currencytextUSD (default) / COP
occurred_attimestamptz
metadatajsonb
created_attimestamptz

asset_blackouts and space_blackouts

The mechanism for reserving gear (or a space) for studio.chat's own shoots or maintenance. This replaces the spec's fictional production_lockouts table — that table never existed.

Precedence: a confirmed booking wins — studio.chat does not always win. A blackout protects internal needs only when it is set before a conflicting client reservation confirms. Once a reservation is confirmed, the gear it holds is unavailable to everyone, including internal studio.chat productions — there are no recalls. Conversely, an admin may cancel studio.chat's own unpaid internal plans to free dates for a paying client. There is no configurable lock-out lead-time window — blackout_default_days (default 14) was dropped in migration 0002 and was never enforced in app code.

asset_blackouts (one row per unit; was item_blackouts until migration 0010 made blackouts asset-scoped — blacking out a whole item means a row for every unit, which the /office/reservations/blackouts form creates by default):

fieldtypenotes
iduuid
labeltexte.g. "PCR Records music video — Sept 14–17"
asset_iduuidNOT NULL (was nullable item_id/asset_id before 0010)
starts_at / ends_atdateCHECK ends_at >= starts_at
reasontextshoot / maintenance / personal
created_by_account_iduuid
created_attimestamptz

space_blackouts (per studio space; renamed from area_blackouts in 0002):

fieldtypenotes
iduuid
labeltext
space_iduuidNOT NULL (renamed from area_id)
starts_at / ends_atdateCHECK ends_at >= starts_at
reasontext
created_by, created_at

The desk manages both at /office/reservations/blackouts.

Availability rule. An asset is available over a date range iff:

  • no active reservation occupies it on overlapping dates (active = held, confirmed, returned),
  • no asset_blackouts row for that asset overlaps,
  • the asset's condition is serviceable (like_new / good / fair — not service / retired / lost),
  • and it is at the rentable location.

Reservations never pin a serial before pickup, so the overlap guard (getReservationConflicts) works in quantities: an item line conflicts when the units requested plus other held/confirmed demand exceed the serviceable units not blacked out for the window.

A space is available iff no overlapping active reservation and no space_blackouts row overlaps.

Availability is enforced at pickup, when each pickup scan binds a free asset (see below), rather than pre-computed at quote time. A standalone "is this date range available?" query for the public catalog is not built — see GAPS.

audit_log

Every status change, payment, and claim, from either drive-surface.

fieldtypenotes
idbigserial
attimestamptz
actor_user_iduuidnullable for system / API events
entity_typetextreservations / assets / claims / payments / …
entity_iduuid
actiontextcreated / status_changed / payment_recorded / claim_filed / asset_picked_up / asset_returned / …
sourcetextuser (web office) or api (iOS REST)
from_state / to_statetextnullable; set on transitions
payloadjsonbstructured

Operational tables get purposeful audit rows written by each server action / API handler (richer than the generic fn_emit_audit trigger that covers the catalog tables — see docs/architecture/office.md fact 3).

State machine — reservations

Authoritative source: RESERVATION_TRANSITIONS in src/lib/office/reservations.ts. The reservation_status enum has exactly nine values (migration 0010 dropped the old gear-out and inspection statuses and renamed booked → confirmed, returning → returned); there is no quote_revised, hold_extended, or in_transit state (those were in the spec's draft and never shipped).

  inquired ──▶ quoted ──▶ held ──▶ confirmed ──▶ returned ──▶ settled ─▶ closed
     │           │   │      │          │             │            │  ▲
     │           │   └──────┴──────────┘             │            ▼  │
     │           │      (any → cancelled,            └────────▶ disputed ─▶ closed
     │           │       up to & incl. confirmed)
     └───────────┴──────────────────────▶ cancelled
       cancelled and closed are terminal

Gear being out is not a status — it's scan state: picked_up_at plus asset assignments inside the reservation window. A reservation still confirmed past its return_at is overdue. Inspection happens while the reservation sits in returned.

Transitions, exactly as coded:

fromallowed to
inquiredquoted, cancelled
quotedheld, confirmed, cancelled
heldconfirmed, cancelled
confirmedreturned, cancelled
returnedsettled, disputed
settledclosed, disputed
disputedsettled, closed
closed— (terminal)
cancelled— (terminal)

ACTIVE_STATUSES (occupies inventory) = held, confirmed, returned.

Status stamps. Entering a state writes a timestamp column: held → held_at, confirmed → confirmed_at, returned → returned_at, settled → settled_at, closed → closed_at, cancelled → cancelled_at. Additionally returned sets actual_return_at. The pickup stamps (picked_up_at / actual_pickup_at) are written by the first pickup scan (API path), not by a status change.

Asset side-effects on transition (web office path):

  • → returned or → cancelled: assets still assigned are released (current_reservation_id = null). Assets are assigned by pickup scans (the staff-app API), not by a transition.

The two drive-surfaces

A reservation is driven through its lifecycle by two surfaces that share the same database and audit log:

1. Web office (src/lib/office/reservations.ts)

  • createReservationcreate_reservation RPC: header + lines inserted atomically, reference minted atomically, client resolved via find_or_create_client.
  • transitionReservation validates the move against RESERVATION_TRANSITIONS, stamps the timestamp, runs the asset side-effect, and writes an audit row with source = 'user'.
  • Asset assignment is not a web-transition side-effect: assets are bound one at a time by pickup scans (the API path). A manual → returned (or a cancel) releases whatever is still bound.
  • Auth: cookie/session, office role in account_roles (no env allowlist).

2. iOS staff REST API (src/lib/office/api/rentals.ts, /api/office/*)

  • Scan-driven: binds one asset at a time (pickupAsset, returnAsset) keyed on a scanned QR short-code resolved to an asset.
  • Gear-out is scan state, not a status: pickups require the reservation to be confirmed and never change its status — the first asset out stamps picked_up_at/actual_pickup_at. Partial returns leave it confirmed; the last asset back flips confirmed → returned (stamping returned_at/actual_return_at) — the one status change the API drives, and a legal step of the canonical machine, so the audit trail never records an illegal jump. Returns tolerate returned for idempotent replays.
  • Naturally idempotent on (reservation, asset) — replaying a pickup that's already bound, or a return that's already freed, is a success no-op (covers the offline-outbox retry case; a dedicated Idempotency-Key store is a documented follow-up — see docs/architecture/ios-staff-app.md).
  • Auth: Authorization: Bearer <Supabase user JWT>, validated server-side, then resolved to an office role (minRole for writes).
  • Writes audit rows with source = 'api'.

See docs/architecture/rest-api.md for the full wire contract and docs/architecture/ios-staff-app.md for the client.

Public catalog (Next.js)

Shipped under the public [locale] group at src/app/[locale]/services/rentals/:

/services/rentals                    overview / how-it-works
/services/rentals/catalog            grid catalog
/services/rentals/catalog/[sku]      item detail (cover, specs, rate)

(The original spec placed these at /rentals/* with a /rentals/request inquiry form and a /rentals/kits/[slug] page. Those routes did not ship. There is currently no public inquiry/request form — reservations are created in the office. A client-facing request flow is a candidate for the /portal surface; see GAPS.)

Availability is not shown publicly — the catalog reads items server-side and the desk checks availability at booking/pickup.

Client portal (/portal)

A separate top-level route group (src/app/portal/**), a sibling of [locale] and office. Read-only today: a signed-in client sees their own reservations. A deposit-payment flow ("pay to turn a quote into a held reservation") is designed but not built, gated on the payment-provider decision. Full design in docs/architecture/client-portal.md.

Office (gated)

/office/* — Supabase-Auth-gated, office-role-gated (account_roles), dark theme. The real route tree (under the (dashboard) group; see docs/architecture/office.md):

/office                              dashboard
/office/reservations                 list + status filter pills + search
/office/reservations/[id]            detail: state machine, lines, payments, claims
/office/reservations/calendar        month calendar
/office/reservations/create          new reservation
/office/assets                       assets list
/office/assets/[assetId]             asset detail
/office/assets/[assetId]/label       printable QR label
/office/inventory                    items list
/office/inventory/[sku]              item detail
/office/spaces  /office/spaces/[id]  /office/spaces/create   studio spaces
/office/reservations/blackouts                    per-unit gear + space blackouts (NOT "lockouts")
/office/accounts  /office/accounts/[id]  /office/accounts/stats   accounts registry (every non-staff account; client detail + stats)
/office/verifications  /office/verifications/[id]   cross-account verification queue (detail = approve/reject/archive)
/office/links  /office/links/[code]  /office/links/create  /office/links/stats   QR short-links
/office/finances                     deposits held, earned, pipeline, ledger
/office/data                         soft-delete archive/restore/purge console
/office/settings  /office/settings/history   pricing/deposit config + revision history
/office/subscribers                  launch list + CSV export
/office/chat                         office assistant
plus /auth/sign-in, /auth/callback   (shared sign-in door; old /office/sign-in redirects here)

There is no /office/rentals/* prefix and no lockouts page.

Money & payments

  • USD cents are canonical. Every monetary column is *_usd_cents (subtotal_usd_cents, deposit_usd_cents, total_usd_cents, rate_day_usd_cents, line_total_usd_cents, …).
  • COP is display-only, converted at render time from settings.cop_per_usd_snapshot (default 4000).
  • Each reservation snapshots the rate in reservations.fx_rate_snapshot so an invoice reproduces exactly regardless of later rate changes; currency_display records which currency the client was quoted in.
  • Quote computation (computeQuote in src/lib/office/pricing.ts) snapshots the effective day/week rate onto each line at create time. Pricing, multipliers, deposit %, IVA, and per-class cost-recovery params live in settings; see docs/business/pricing.md.
  • Payments are recorded by the desk (addPayment) — no live capture. The payment_provider enum reserves stripe / wompi for a future integration. Provider design lives in docs/architecture/client-portal.md.

Payment-provider integration (deposit holds, capture/release, refunds, webhooks) is designed but not built — confirm before promising any of it to a client.

Contracts (designed, deferred)

The contract flow is designed, not built — document it, don't promise it (owner-decided, 2026-06):

  • The rental contract is generated as a Google Doc (not a custom PDF pipeline, not DocuSign/HelloSign).
  • It is emailed to the client and to studio.chat for e-signature using Google Docs' built-in e-sign feature — so a working email is required for every renter (this is also part of the verified-contact rental gate).
  • The system tracks execution — contract status plus the relevant timestamps surface in the office — and posts Slack notifications on the contract lifecycle (sent / signed).
  • The protection model behind the contract is deposit-only: the 100% replacement-value deposit covers loss or damage. No insurance, no COI, no damage waiver.

Until this ships, reservations.contract_url holds a manually uploaded signed PDF and there is no status/timestamp tracking or Slack notification. Full client-side framing lives in docs/architecture/client-portal.md.

Internationalization

  • Public catalog and form copy is bilingual via next-intl.
  • clients.preferred_language is captured for future client email.
  • Currency display follows the money model above.

Locale-aware client email and contracts (the spec described per-language templates and a dual-language contract attachment) are not built — there is no rental email pipeline. See GAPS.

Security & privacy

  • Supabase RLS is enabled with no policies on every table; the rls_auto_enable event trigger locks new public tables at create time. All office/API access goes through the service-role client server-side (supabaseAdmin()). See docs/architecture/office.md facts 1–2 and docs/architecture/auth.md.
  • Client-uploaded files (condition photos, contracts) sit in a private bucket with signed URLs.
  • Office access is a DB office role (account_roles), no env allowlist; the dev bypass is hard-disabled when VERCEL_ENV=production.
  • Soft delete (0004) archives reservations / clients / short_links rather than hard-deleting; the /office/data console restores or purges.

A retention policy for PII (auto-deletion N days after close) is not implemented. See GAPS.

Observability

  • Vercel logs (app) + Supabase logs (DB/storage).
  • audit_log is the durable record of every state transition, payment, and claim, tagged by source.
  • Runtime error reporting via the existing reportError helper.

Deploy / environments

Two long-lived environments via Supabase Branching (see docs/architecture/migrations.md):

EnvGit branchSupabase branch
Productionmainmain (default)
Developmentdevelopdevelop (persistent)

The Vercel-Supabase integration syncs the right DB env vars per deployment.

GAPS FOR HUMAN

Genuine unknowns the code did not settle — confirm before relying on these:

  1. clients extra columns. The COI columns (coi_provider, coi_on_file_until, coi_doc_url) were dropped in 0005 (deposit-only, no insurance). The ID columns (identity_verified_at, identity_doc_url) plus tags / address / tax_id remain; confirm which the office actually reads before relying on them (it reads the core columns plus internal_notes).
  2. Condition-report write flow. The condition_reports table exists, but whether the office actually creates/signs reports on pickup/return (vs. the table being unused so far) was not confirmed.
  3. Pre-quote availability check. There is no standalone "is this range available?" query verified in code (availability is enforced at pickup via assign_units). Confirm whether the desk has any pre-booking conflict warning, or whether that is still manual.
  4. Public / client request flow. No public inquiry form shipped. Confirm the intended path for a client to request a rental (office-entered today; /portal later?).
  5. Rental transactional email. No quote/receipt/reminder email pipeline exists. Confirm whether this is planned and where (Resend templates, trigger points).
  6. PII retention policy. No automated deletion of client documents after a reservation closes. Confirm the legal retention requirement and the concrete N-days value.
  7. Vetting/insurance schema teardown — done. Tiers, COI, and insurance were removed as policy (2026-06) and the supporting schema was dropped in migration 0005: the rental_tier enum type, items.tier, items.requires_coi, kits.tier, and the settings.tier_b_* / settings.tier_c_* thresholds are gone (along with the dead cartTier() helper and the office tier/COI UI rows). clients.trust_tier is kept as client standing (its blocked value renamed to blacklisted); it is no longer a gap.