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:
- Show a curated public catalog of rentable gear, in EN and ES.
- Take reservations against specific date ranges, items, and studio spaces.
- Track each rental from inquiry through quote, hold, booking, pickup, return, inspection, and settlement, without double-booking against studio.chat's own production shoots.
- Hold deposits, condition reports, and damage claims in one place, retrievable by the desk in seconds.
- 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):
- a 100% replacement-value deposit (the deposit-only protection model — see
docs/business/pricing.md), - ID for the responsible party, and
- 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
/contactand 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:
| Layer | Choice | Notes |
|---|---|---|
| Primary DB | Supabase Postgres | All rental tables are RLS-locked "admin_only"; access is exclusively via the service-role client (supabaseAdmin()), strictly server-side. |
| Object storage | Supabase Storage | Condition 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 role | Access 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. |
| Resend | /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 UI | Next.js app routes under /office/* | Same codebase + Tailwind tokens as the public site. No Retool/Airtable. |
| Search | Postgres ilike filters | No 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:
| File | What it does |
|---|---|
0001_baseline.sql | Schema-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.sql | Consolidation 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.sql | Security hardening — pins search_path on trigger functions, revokes the RPC grant on rls_auto_enable. No app-visible behavior change. |
0004_soft_delete.sql | Adds nullable deleted_at to reservations, clients, short_links for the /office/data archive/restore/purge console. |
0005_office_and_accounts.sql | Consolidation 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_source → audit_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 blocked → blacklisted; drops the vestigial clients COI columns (ID columns kept); and the identity overhaul — clients → accounts (single auth.users-linked identity for clients + staff; nullable user_id, source, preferred_language → locale, trust_tier → standing) + 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_id → account_id, launch_subscribers → subscribers (+ 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_itemsexist 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
| field | type | notes |
|---|---|---|
| id | uuid | |
| display_name | text | |
| citext | unique; case-insensitive | |
| phone | text | E.164 |
| preferred_language | enum | en / es |
| company | text | |
| standing (was trust_tier) | enum | kept as client standing, not a vetting gate. Renamed trust_tier → standing 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_notes | text | desk-only |
| deleted_at | timestamptz | soft delete (0004) |
| created_at, updated_at | timestamptz |
The
clientsCOI columns (coi_provider,coi_on_file_until,coi_doc_url) were dropped in0005(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 plusinternal_notes. Thetrust_tierenum is not a vetting lever (there is no vetting tier — see "Rental gate"); it is kept as client standing, where onlyblacklistedchanges behavior (routes to owner approval). See GAPS. Clients are auto-created from reservations via thefind_or_create_clientRPC (idempotent lookup-or-insert), not entered by hand first.
reservations
The central operational record. (The reference UNIQUE constraint is
reservations_reference_key — 0005 normalized the legacy bookings_*
constraint names.)
| field | type | notes |
|---|---|---|
| id | uuid | |
| reference | text | human-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_id | uuid | FK → clients |
| status | enum reservation_status | see state machine |
| pickup_at / return_at | timestamptz | scheduled |
| actual_pickup_at / actual_return_at | timestamptz | filled at pickup / return |
| subtotal_usd_cents | int | before fees |
| discount_usd_cents | int | manual desk adjustment only — there are no automatic volume/multi-item/student discounts (2026-06) |
| damage_waiver_usd_cents | int | vestigial — there is no damage waiver (deposit-only model); stays 0 |
| deposit_usd_cents | int | |
| total_usd_cents | int | |
| currency_display | text | usd / cop (snapshot for invoice) |
| fx_rate_snapshot | numeric(10,2) | COP-per-USD captured at create time |
| project_context | text | what the client wrote with the inquiry |
| how_heard | text | |
| notes_internal | text | desk-only |
| notes_client | text | |
| contract_url | text | link 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_reason | text | |
| held_at, confirmed_at, picked_up_at, returned_at, settled_at, closed_at, cancelled_at | timestamptz | status stamps (see below; confirmed_at carried the old booked name until 0010) |
| deleted_at | timestamptz | soft delete (0004) |
| created_at, updated_at | timestamptz |
reservation_items
The line items — one row per item, kit, or studio space on the reservation.
| field | type | notes |
|---|---|---|
| id | uuid | |
| reservation_id | uuid | FK |
| item_id | uuid | nullable; set for item rentals |
| kit_id | uuid | nullable; set for kit/bundle rentals |
| space_id | uuid | nullable; set for space rentals (renamed from area_id in 0002) |
| qty | int | CHECK qty > 0 |
| rate_day_usd_cents_snapshot | int | effective rate frozen at create time |
| rate_week_usd_cents_snapshot | int | |
| line_total_usd_cents | int | |
| assigned_asset_ids | uuid[] | physical assets bound to the line (renamed from assigned_unit_ids in 0002); empty until assignment at pickup |
| notes | text | |
| created_at | timestamptz |
A CHECK constraint requires at least one of item_id / kit_id / space_id.
condition_reports
Pickup + return inspection.
| field | type | notes |
|---|---|---|
| id | uuid | |
| reservation_id | uuid | FK |
| booking_line_id | uuid | FK → reservation_items. Legacy column name retained through the rename — still literally booking_line_id. |
| direction | enum | out / in |
| inspector_user_id | uuid | desk staff |
| checklist | jsonb | structured (see 02-business-processes.md §6) |
| photos | text[] | Supabase Storage paths |
| notes | text | |
| signed_by_client | bool | |
| signed_at | timestamptz | |
| signature_name | text | typed name |
| created_at | timestamptz |
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.
| field | type | notes |
|---|---|---|
| id | uuid | |
| reservation_id | uuid | FK |
| kind | enum claim_kind | damage / loss / late / cleaning / other |
| severity | enum claim_severity | cosmetic / functional / total_loss |
| description | text | |
| amount_usd_cents | int | charged to deposit / additional invoice |
| status | enum | filed as notified by fileClaim |
| created_at, resolved_at | timestamptz |
payments
Reservation deposit, balance, and refunds. Recorded by the desk; no live provider.
| field | type | notes |
|---|---|---|
| id | uuid | |
| reservation_id | uuid | FK |
| provider | enum payment_provider | cash / stripe / wompi / transfer (default cash). stripe/wompi are selectable but not integrated. |
| provider_id | text | external id (unused until a provider is wired) |
| kind | enum payment_kind | deposit / balance / refund / damage etc. |
| amount_usd_cents | int | |
| currency | text | USD (default) / COP |
| occurred_at | timestamptz | |
| metadata | jsonb | |
| created_at | timestamptz |
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):
| field | type | notes |
|---|---|---|
| id | uuid | |
| label | text | e.g. "PCR Records music video — Sept 14–17" |
| asset_id | uuid | NOT NULL (was nullable item_id/asset_id before 0010) |
| starts_at / ends_at | date | CHECK ends_at >= starts_at |
| reason | text | shoot / maintenance / personal |
| created_by_account_id | uuid | |
| created_at | timestamptz |
space_blackouts (per studio space; renamed from area_blackouts in 0002):
| field | type | notes |
|---|---|---|
| id | uuid | |
| label | text | |
| space_id | uuid | NOT NULL (renamed from area_id) |
| starts_at / ends_at | date | CHECK ends_at >= starts_at |
| reason | text | |
| 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_blackoutsrow for that asset overlaps, - the asset's
conditionis serviceable (like_new/good/fair— notservice/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.
| field | type | notes |
|---|---|---|
| id | bigserial | |
| at | timestamptz | |
| actor_user_id | uuid | nullable for system / API events |
| entity_type | text | reservations / assets / claims / payments / … |
| entity_id | uuid | |
| action | text | created / status_changed / payment_recorded / claim_filed / asset_picked_up / asset_returned / … |
| source | text | user (web office) or api (iOS REST) |
| from_state / to_state | text | nullable; set on transitions |
| payload | jsonb | structured |
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:
| from | allowed to |
|---|---|
inquired | quoted, cancelled |
quoted | held, confirmed, cancelled |
held | confirmed, cancelled |
confirmed | returned, cancelled |
returned | settled, disputed |
settled | closed, disputed |
disputed | settled, 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):
→ returnedor→ 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)
createReservation→create_reservationRPC: header + lines inserted atomically, reference minted atomically, client resolved viafind_or_create_client.transitionReservationvalidates the move againstRESERVATION_TRANSITIONS, stamps the timestamp, runs the asset side-effect, and writes an audit row withsource = '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
confirmedand never change its status — the first asset out stampspicked_up_at/actual_pickup_at. Partial returns leave itconfirmed; the last asset back flipsconfirmed → returned(stampingreturned_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 toleratereturnedfor 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 — seedocs/architecture/ios-staff-app.md). - Auth:
Authorization: Bearer <Supabase user JWT>, validated server-side, then resolved to an office role (minRolefor 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_snapshotso an invoice reproduces exactly regardless of later rate changes;currency_displayrecords which currency the client was quoted in. - Quote computation (
computeQuoteinsrc/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 insettings; seedocs/business/pricing.md. - Payments are recorded by the desk (
addPayment) — no live capture. Thepayment_providerenum reservesstripe/wompifor a future integration. Provider design lives indocs/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_languageis 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_enableevent trigger locks newpublictables at create time. All office/API access goes through the service-role client server-side (supabaseAdmin()). Seedocs/architecture/office.mdfacts 1–2 anddocs/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 whenVERCEL_ENV=production. - Soft delete (0004) archives
reservations/clients/short_linksrather than hard-deleting; the/office/dataconsole 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_logis the durable record of every state transition, payment, and claim, tagged bysource.- Runtime error reporting via the existing
reportErrorhelper.
Deploy / environments
Two long-lived environments via Supabase Branching (see
docs/architecture/migrations.md):
| Env | Git branch | Supabase branch |
|---|---|---|
| Production | main | main (default) |
| Development | develop | develop (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:
clientsextra columns. The COI columns (coi_provider,coi_on_file_until,coi_doc_url) were dropped in0005(deposit-only, no insurance). The ID columns (identity_verified_at,identity_doc_url) plustags/address/tax_idremain; confirm which the office actually reads before relying on them (it reads the core columns plusinternal_notes).- Condition-report write flow. The
condition_reportstable exists, but whether the office actually creates/signs reports on pickup/return (vs. the table being unused so far) was not confirmed. - 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. - Public / client request flow. No public inquiry form shipped. Confirm
the intended path for a client to request a rental (office-entered today;
/portallater?). - Rental transactional email. No quote/receipt/reminder email pipeline exists. Confirm whether this is planned and where (Resend templates, trigger points).
- PII retention policy. No automated deletion of client documents after a reservation closes. Confirm the legal retention requirement and the concrete N-days value.
- Vetting/insurance schema teardown — done. Tiers, COI, and insurance were
removed as policy (2026-06) and the supporting schema was dropped in migration
0005: therental_tierenum type,items.tier,items.requires_coi,kits.tier, and thesettings.tier_b_*/settings.tier_c_*thresholds are gone (along with the deadcartTier()helper and the office tier/COI UI rows).clients.trust_tieris kept as client standing (itsblockedvalue renamed toblacklisted); it is no longer a gap.