Data Persistence Map
Field · Source hub · Storage · Sensitivity · Consuming hubs · localStorage → DB migration
audit-log-forwarding · live kyc-orchestration · live
DBPostgres column
JSONBwizard_state scope
SENSprofiles_sensitive · user-only RLS
OBJSupabase Storage
CACHElocalStorage / component
NPIPIITXPUB
DB is the source of truth — localStorage is cache only
Neela — Data Map Overview
This is the canonical reference for where every exchange field lives. Each row maps field → source hub → storage → sensitivity → consuming hubs. The hard rule: NPI (SSN, DOB, full street, bank account) lives only in profiles_sensitive under user-only RLS or in encrypted closing_info — never in localStorage. Demo identity is Matt Belcher · 456 Sunset Blvd, Los Angeles CA · $850,000 (synthetic only).
Onboarding & KYC
Identity captured once at account creation; pre-fills every hub. NPI is isolated in profiles_sensitive.
KYC
Field Source Storage Sensitivity Consuming hub(s)
email User input Supabase Auth profiles.email PII All hubs
password User input Auth bcrypt app never sees plaintext NPI Auth only
first_name · last_name User input profiles.first_name / last_name PII All hubs (autofill)
phone User input profiles.phone PII Sales Purchase
address_city · address_state User input profiles.address_city / address_state PII Sales (W-9)
date_of_birth KYC (Persona) SENS profiles_sensitive.date_of_birth NPI KYC verify only
ssn_last_four · ssn_hash · ssn_encrypted User input + KYC SENS profiles_sensitive last-4 + hash + AES full NPI IRS filing
address_street · address_zip User input / KYC SENS profiles_sensitive.address_street / address_zip NPI Sales (W-9) IRS
tin_type · ein User input SENS profiles_sensitive.tin_type / ein NPI IRS form
documentFront · documentBack · selfie User upload OBJ storage/kyc/ via Persona Vectr workflow NPI KYC verify
is_identity_verified · is_kyc_complete Persona webhook profiles.is_identity_verified / is_kyc_complete PUB All hubs (gating)
Sales Hub (Phase 1)
Densest collection stage. Property + price + exchanger + sale-side documents. Seeds the 45/180-day deadline clock.
Sales
Field Source Storage Sensitivity Consuming hub(s)
exchange_type User radio JSONB wizard_state.taxpayer_info.exchangeType TX All hubs (flow gate)
sellingPrice / salesPrice User / Neela extract DB exchanges.relinquished_value (target; cache today) TX Escrow IRS line 14
originalPurchasePrice · mortgageBalance User input JSONB calculator_results.input TX IRS line 13 / Part III
salesPurchaseAgreement · preliminaryTitleReport (files) User upload OBJ storage/docs/ + documents row TX Identification (extraction)
exchangerPhaseOne.{firstName,middleName,lastName} Autofill from profiles JSONB wizard_state.taxpayer_info PII ID Purchase Disburse IRS
exchangerPhaseOne.taxIdNumber (SSN/EIN) Server-side, filing time SENS profiles_sensitive.ssn_encrypted never to localStorage NPI W-9 only
buyer.names · closingCompanySale.* User / extracted JSONB wizard_state.relinquished_property PII Notice of Assignment
relinquished.addressOrLegalDescription User / extracted JSONB wizard_state.relinquished_property TX ID (auto) IRS line 1
relinquished.closingDate User input DB exchanges.sale_close_date (canonical) TX All hubs (deadline clock)
identification_deadline · exchange_deadline Postgres trigger (+45d / +180d) DB exchanges.identification_deadline / exchange_deadline TX ID Purchase Disburse
exchange_agreement_signed · w9_signed (+ signature) User signature (DocuSign) DB exchange_hub_status.document_signatures + documents envelope NPI Audit IRS file
sales_hub_submitted (flag) System on submit DB exchange_hub_status.hub_submissions.sales PUB Hub-access gating
Escrow & Identification Hubs
Funds land in the segregated qualified-escrow account; the 45-day ID list is built and locked.
Escrow Identification
Field Source Storage Sensitivity Consuming hub(s)
syncedBalance / extractedBalance Bank sync / Neela extract DB exchanges.exchange_funds_held (kill localStorage shape) TX Disburse (balance) IRS line 14
fundedAt System (bank webhook) DB exchanges.exchange_funds_received_at TX Audit IRS
Harborline account · routing Server constant SENS server-only; never bundled NPI Escrow display
identificationForm.idRule User selection JSONB wizard_state.identified_properties 3-prop / 200% / 95% TX IRS Validation
identifiedProperties[].{address,estimatedFMV,allocationAmount,type} DST picker / like-kind form JSONB wizard_state.identified_properties[] TX Purchase (autofill) IRS line 12
hudOneUploaded (HUD-1 file) · identFormSigned User upload / signature OBJ storage/docs/ + exchange_hub_status.document_signatures NPI IRS Audit
identification_hub_submitted (+ date) System on submit DB exchange_hub_status.hub_submissions.identification PUB IRS line 5 Gating
Purchase & Disbursement Hubs
Replacement property closes; the QI authorizes the outbound wire. The most security-critical signature in the system.
Purchase Disbursement
Field Source Storage Sensitivity Consuming hub(s)
seller.names · closingCompanyPurchase.* User / extracted JSONB wizard_state.replacement_property PII Disburse (wire default)
replacement.addressOrLegalDescription User / extracted JSONB wizard_state.replacement_property TX IRS line 2 Disburse
replacement.closingDate User input DB exchanges.replacement_value + JSONB ref TX IRS line 6 Deadline check
Purchase Agreement · HUD-1 (files) · Assignment signed User upload / signature OBJ storage/docs/ + documents NPI IRS Audit
disbursementForm.amount User input JSONB wizard_state.closing_info must === exchange_funds_held TX Wire IRS
wireRecipientBankABA · wireRecipientBankAccount User input JSONB wizard_state.closing_info account ENCRYPTED at rest NPI Wire
disbursement_auth_signed (+ signature) User signature DB exchange_hub_status.document_signatures.disbursement_authorization NPI Money movement Audit
wire_confirmation_verified (file) Upload after wire executes OBJ storage/docs/ + exchange_hub_status.document_signatures.wire_confirmation TX Audit
IRS Reporting — Form 8824 assembly
No dedicated hub; assembled at preview time via mapToForm8824(), drawing read-only from every hub. SSN read server-side at filing time only.
IRS
Line Source field Sensitivity Originating hub
8824 · 1 relinquished.addressOrLegalDescription TX Sales
8824 · 2 replacement.addressOrLegalDescription TX Purchase
8824 · 4 relinquished.closingDate TX Sales
8824 · 5 identification_hub_submitted_date PUB Identification
8824 · 6 replacement.closingDate TX Purchase
8824 · 12–19 calculator_results.results.{boot,recognizedGain,deferredGain,…} TX Sales (calculator)
filer SSN/EIN profiles_sensitive.ssn_encrypted server-side at filing time only NPI KYC
Target storage layout
Five physical stores. Tenant isolation is enforced by Supabase RLS scoped via exchange_participants; NPI is further isolated in profiles_sensitive with user-only RLS the admin client cannot read. Every write produces an audit_log row forwarded to Vectr's log-api Durable Object.
Supabase Postgres
Tenant-scoped RLS · source of truth
profiles name · phone · city/state (PII)
exchanges price · deadlines · funds_held (TX)
exchange_participants cross-tenant access
exchange_hub_status submissions · signatures
documents file metadata
audit_log append-only (7-day cache)
profiles_sensitive
NPI · user-only RLS · AES at rest
ssn_last_four display
ssn_hash match
ssn_encrypted AES full
date_of_birth NPI
address_street · address_zip full street = NPI
tin_type · ein entity exchangers
wizard_state (JSONB)
Scoped per (exchange, scope)
taxpayer_info identifiers (NOT SSN)
relinquished_property + closing-co · buyer
replacement_property + closing-co · seller
identified_properties[] 45-day list
closing_info wire · account ENCRYPTED
Supabase Storage
Object store · signed-URL access
kyc/ ID front/back · selfie
docs/ sale + purchase agreements · HUD-1
signatures/ DocuSign signed PDFs
irs/ Form 8824 PDFs
External (via Vectr)
No direct calls from Pages Functions
Persona KYC parsed ID
DocuSign 4 envelopes
Plaid bank verification
Zillow / RentSpree FMV enrichment
Google Places address autocomplete
localStorage
Transient cache only — target state
hub_status_${exchangeId} read-through cache of exchange_hub_status
Hydrated by useExchangeHubStatus; reconciled from the API. No business data writes.
Data classification rule
NPI profiles_sensitive (user-only RLS) or storage/ with signed URL. AES at rest. Audit on every read. NEVER in localStorage; NEVER in audit_log details.
PII profiles or wizard_state JSONB. Encrypted at rest. Audit on write.
TX exchanges + wizard_state. At rest. Audit on write.
PUB Anywhere; cacheable. Audit on write only.
ADR-009 — DB as source of truth
The app today carries 43 distinct localStorage keys holding business state. The migration target is one key — hub_status_${exchangeId} — and it's a read-through cache only. Every other key becomes a DB column, a wizard_state JSONB scope, or a Storage object. Tier-1 rows (full SSN, signatures in localStorage) are GLBA / SOC 2 exposures and migrate first.
Legacy keys today 43 business state in localStorage
Target keys 1 read-through cache only
Tier-1 (NPI exposure) 3 SSN · signatures · KYC files
Source of truth Postgres + RLS + audit_log
Migration tracks — localStorage shape → DB-authoritative target
Ordered by compliance tier. Each track lists the legacy keys it deletes.
Full SSN / TIN out of localStorage Tier 1 · NPI
profiles_sensitive.ssn_encrypted only. UI shows last-4 + masked. W-9 PDF reads the full value server-side at generation time. wizard_state.taxpayer_info keeps tin_type + last_four — never the full string.
sale_wizard_v1_*_phase1.taxIdNumber
Signatures & signer data out of localStorage Tier 1
exchange_hub_status.document_signatures + DocuSign envelope ID in documents. Signature images live in storage/signatures/.
exchange_agreement_signed +_signature/_signed_date/_signed_by w9_signed +_signature disbursement_auth_signed +_signature identification_form_signed
KYC ID images get a persistence path Tier 1
storage/kyc/ via the Persona Vectr workflow. Today the ID front/back + selfie live only in component state until the onComplete callback.
documentFront · documentBack · selfie (component state)
Hub-submission flags consolidate Tier 2
exchange_hub_status.hub_submissions (DB, source of truth) + ephemeral hub_status_${exchangeId} cache. Drop the per-hub mirrors and the acquisition_hub_submitted alias.
sales_hub_submitted +_date identification_hub_submitted +_date purchase_hub_submitted +_date acquisition_hub_submitted (alias) disbursement_hub_submitted +_date escrow_hub_submitted +_date
Escrow balance · sale price · close date → DB columns Tier 2
exchanges.exchange_funds_held, exchanges.relinquished_value, exchanges.sale_close_date are canonical. The 45/180-day deadlines derive from sale_close_date via the calculate_exchange_deadlines() trigger — never a duplicate.
escrow_data_${userId} { principalAmount, fundedAt } calculator_results property_sale_date wire_confirmation_verified +_date identification_hud1_uploaded
Wizard scopes & edit tickets go server-side Tier 2
Each sale_wizard_v1_* scope maps to a wizard_state JSONB row keyed by (exchange, scope). Edit requests move to POST /exchanges/:id/edit-tickets.
sale_wizard_v1_*_phase1 sale_wizard_v1_*_exchange sale_wizard_v1_*_acquisition sale_wizard_v1_*_disbursement sale_wizard_v1_*_initial exchange_wizard_v1_*_initial edit_tickets
Demo flags scoped to demo tenant Tier 3
accounts.settings on the demo tenant only — never global localStorage. After this track lands, exactly one cache key remains.
demo_unlock_all demo_identity_verified demo_activities_logged hub_status_${exchangeId} (KEEP — read-through cache)
Standing rule (AGENTS.md): any new localStorage write of business data is an automatic Compliance + Code Quality rejection. localStorage is permitted only as a read-through cache reconciled from the API, or ephemeral UI state that does not survive a reload. After ADR-009, the read-through cache is the single surviving key.