ADR-001 - Centralized BU-Level Membership
ADR-001: Centralized BU-Level Membership Model
Section titled “ADR-001: Centralized BU-Level Membership Model”Date: October 27, 2025
Status: ✅ Accepted
Deciders: Engineering Team, Product Team
Related: MEMBERSHIP_ARCHITECTURE_ANALYSIS.md
Context
Section titled “Context”CloudAlt operates 3 Business Units (BUs) with 6 total divisions and ~30+ branded apps:
BU #1: Hospitality (stays/)
- Division 1: Stay Overnight (apps: Pink Guest, Orange Guest, Green Host, Purple Guest)
- Division 2: Roommate Works (app: Roommate Works)
- Division 3: Stay Match (app: Stay Match)
BU #2: Discovery (discovery/)
- Division 4: AltFinder (apps: AltFinder, Pink Finder, Orange Finder)
BU #3: Services (services/)
- Division 5: Pride City (app: Pride City)
- Division 6: Bonjour Locker (app: Bonjour Locker)
The Problem
Section titled “The Problem”We need to decide how to structure user membership across divisions:
Option A: Centralized BU-Level Membership
- One membership table per Business Unit
- Single profile shared across all divisions within a BU
- Example: Kate joins Pink Guest → Gets membership “STAY-000001” → Can access Roommate Works and Stay Match with same account
Option B: Decentralized Division-Level Membership
- Separate membership table per division
- Each division has independent user accounts
- Example: Kate joins Pink Guest → Creates account → Opens Roommate Works → Must create new account
User Journey Example (Kate)
Section titled “User Journey Example (Kate)”- Downloads Pink Guest app (women-only travel, Stay Overnight division)
- Creates account → Where does her profile live?
- Later discovers Roommate Works → Should she create a new account or sign in?
- Wants to list on Stay Match → New account or existing?
Decision
Section titled “Decision”We will use centralized BU-level membership (Option A).
Each Business Unit will have one unified membership table that serves all divisions within that BU:
stays/members/MemberProfile→ Shared by Stay Overnight, Roommate Works, Stay Matchdiscovery/members/MemberProfile→ Used by AltFinder only (single division)services/members/MemberProfile→ Shared by Pride City, Bonjour Locker
Data Model
Section titled “Data Model”class MemberProfile(models.Model): """ Centralized membership for BU #1: Hospitality Shared across: Stay Overnight, Roommate Works, Stay Match """ # Universal identity supabase_user_id = models.UUIDField(unique=True, db_index=True) membership_id = models.CharField(max_length=20, unique=True) # "STAY-000001" email = models.EmailField(unique=True)
# Core profile (shared across all divisions) username = models.CharField(max_length=50, unique=True) real_name = models.CharField(max_length=100) age_range = models.CharField(max_length=10) gender = models.CharField(max_length=20) photo_url = models.URLField() bio = models.TextField(blank=True)
# Division enrollment tracking joined_stay_overnight = models.BooleanField(default=False) joined_stay_overnight_date = models.DateTimeField(null=True)
joined_roommate = models.BooleanField(default=False) joined_roommate_date = models.DateTimeField(null=True)
joined_stay_match = models.BooleanField(default=False) joined_stay_match_date = models.DateTimeField(null=True)
# Initial join tracking (for analytics and marketing) initial_division = models.CharField(max_length=50) # "stay_overnight", "roommate", "stay_match" initial_app = models.CharField(max_length=50) # "pink_guest", "orange_guest", "roommate_app", etc.
# App usage tracking apps_used = models.JSONField(default=list) # ["pink_guest", "roommate_app", ...]
# Trust & Safety (shared across all divisions) verified_email = models.BooleanField(default=False) verified_phone = models.BooleanField(default=False) government_id_verified = models.BooleanField(default=False)
# Division role flags is_host = models.BooleanField(default=False) # Stay Overnight host is_traveler = models.BooleanField(default=False) # Stay Overnight traveler is_roommate_seeker = models.BooleanField(default=False) # Roommate Works is_property_owner = models.BooleanField(default=False) # Stay MatchDivision-Specific Profiles
Section titled “Division-Specific Profiles”Division-specific data lives in OneToOne relationships:
class HostProfile(models.Model): member = models.OneToOneField('members.MemberProfile', on_delete=models.CASCADE) hosting_since = models.DateField() max_guests = models.IntegerField() # ... host-specific fields
class TravelerProfile(models.Model): member = models.OneToOneField('members.MemberProfile', on_delete=models.CASCADE) travel_style = models.CharField() # ... traveler-specific fields
# stays/roommate/models.pyclass RoommateProfile(models.Model): member = models.OneToOneField('members.MemberProfile', on_delete=models.CASCADE) budget_min = models.DecimalField() budget_max = models.DecimalField() # ... roommate-specific fieldsRationale
Section titled “Rationale”✅ Pros of Centralized BU-Level Membership
Section titled “✅ Pros of Centralized BU-Level Membership”1. Superior User Experience
- Single Sign-On within BU: Kate signs up on Pink Guest → Automatically logged into Roommate Works and Stay Match
- No duplicate accounts: One account per BU, not one per app
- Profile portability: Update photo once → shows everywhere
- Reputation carries over: Build trust in Stay Overnight → Recognized in Roommate Works
2. Cross-Brand Marketing Opportunities
# Show contextual offers based on division membershipif member.joined_stay_overnight and not member.joined_roommate: show_banner("Looking for a permanent roommate? Try Roommate Works!")
if member.is_host and not member.joined_stay_match: show_banner("Want to earn money hosting? List on Stay Match!")3. Business Intelligence & Analytics
-- How many Stay Overnight users also use Roommate?SELECT COUNT(*) FROM member_profilesWHERE joined_stay_overnight = TRUE AND joined_roommate = TRUE;
-- Which branded app drives most cross-division engagement?SELECT initial_app, COUNT(*) as total_users, AVG(CASE WHEN joined_roommate THEN 1 ELSE 0 END) as roommate_conversion_rateFROM member_profilesGROUP BY initial_app;
-- Lifetime value across divisionsSELECT membership_id, COUNT(divisions_joined) as divisions_count, SUM(total_bookings) as total_engagementFROM member_profilesWHERE is_cross_division_member = TRUE;4. Simpler Data Management
- GDPR compliance: One user record per BU to delete (not 3+ separate records)
- Unified support: Support team sees complete user history across divisions
- Account recovery: Reset password once, not per division
- Verification once: Email/phone verification carries across divisions
5. Natural Brand Architecture
- Users understand: “I have a CloudAlt Hospitality account”
- Not confusing: “I have separate accounts for Pink Guest, Roommate Works, and Stay Match”
- Builds brand loyalty and ecosystem lock-in
6. Enables Premium/Subscription Models
# Future: CloudAlt Hospitality Premiumclass MemberProfile(models.Model): is_premium = models.BooleanField(default=False) premium_since = models.DateField(null=True) # Premium applies to ALL divisions in BU⚠️ Cons (and Mitigations)
Section titled “⚠️ Cons (and Mitigations)”1. BU Coupling
- Risk: If we sell/spin off Roommate Works, need data migration
- Mitigation: Use separate Supabase databases per division (optional), maintain clear division boundaries in code
2. Schema Changes Affect Multiple Apps
- Risk: Adding field to MemberProfile affects all divisions
- Mitigation: Use JSONField for app-specific metadata, keep core fields minimal, version API schemas
3. Scaling Complexity
- Risk: 1M Stay Overnight users → Roommate queries slower
- Mitigation: Proper indexing, database read replicas, query optimization, consider horizontal sharding by division if needed
Consequences
Section titled “Consequences”Immediate Changes (Phase 4)
Section titled “Immediate Changes (Phase 4)”Database Schema Updates:
- Add division tracking fields to MemberProfile
- Add initial join tracking (division, app)
- Add apps_used JSON field
- Update indexes for new query patterns
API Changes:
- Update onboarding endpoint to set division flags
- Create “join another division” endpoint
- Add cross-division membership check to all endpoints
- Return division membership status in profile responses
Authentication Flow:
User opens Pink Guest ↓Supabase Auth (email/password) ↓Backend checks: member_profiles.supabase_user_id ↓If exists: Return existing profile (may already be in other divisions)If new: Create MemberProfile with initial_division="stay_overnight", initial_app="pink_guest"Medium-Term (Phase 5-6)
Section titled “Medium-Term (Phase 5-6)”- Create division-specific profile models (RoommateProfile, StayMatchProfile)
- Build “Discover Other CloudAlt Services” in-app promotions
- Analytics dashboard for cross-division conversion rates
- Unified notification system across divisions
Long-Term (Phase 10+)
Section titled “Long-Term (Phase 10+)”- Consider optional cross-BU linking (if we want “CloudAlt Universal Premium”)
- Unified wallet/credits system across all CloudAlt services
- Cross-BU recommendation engine
Database Strategy
Section titled “Database Strategy”Development (Current)
Section titled “Development (Current)”Docker Compose:├─ postgres_stays (port 5432)├─ postgres_discovery (port 5433)└─ postgres_services (port 5434)Production (Supabase)
Section titled “Production (Supabase)”Recommended: Single Database Per BU
stays_hospitality_db (Supabase)├─ member_profiles (shared table for all 3 divisions)├─ host_profiles (Stay Overnight)├─ traveler_profiles (Stay Overnight)├─ overnight_offerings (Stay Overnight)├─ roommate_profiles (Roommate Works)├─ roommate_listings (Roommate Works)├─ stay_match_properties (Stay Match)└─ ...
discovery_db (Supabase)└─ member_profiles (AltFinder only)
services_db (Supabase)├─ member_profiles (shared for Pride City + Bonjour Locker)└─ ...Alternative: Separate Databases with Shared Auth (if spin-off likely)
Each division gets own database, all link via supabase_user_idTrade-off: Better independence, more complex joinsExamples
Section titled “Examples”User Onboarding Flow
Section titled “User Onboarding Flow”# Kate signs up on Pink GuestPOST /api/v1/onboarding/{ "username": "kate_traveler", "real_name": "Kate Smith", "age_range": "25-34", "gender": "female", "photo_url": "https://...", "division": "stay_overnight", "app": "pink_guest"}
Response:{ "success": true, "membership_id": "STAY-000001", "message": "Welcome to CloudAlt Hospitality!", "divisions_joined": ["stay_overnight"], "can_join": ["roommate", "stay_match"]}Cross-Division Join
Section titled “Cross-Division Join”# Kate opens Roommate Works (already has STAY-000001)POST /api/v1/join-division/{ "division": "roommate", "app": "roommate_app"}
Response:{ "success": true, "membership_id": "STAY-000001", # Same ID! "message": "Welcome to Roommate Works, Kate!", "divisions_joined": ["stay_overnight", "roommate"], "profile_prefilled": true, "verification_status": { "email": true, # Already verified in Stay Overnight "phone": true }}Profile Response
Section titled “Profile Response”GET /api/v1/members/me/
Response:{ "membership_id": "STAY-000001", "username": "kate_traveler", "divisions_joined": ["stay_overnight", "roommate"], "initial_division": "stay_overnight", "initial_app": "pink_guest", "apps_used": ["pink_guest", "roommate_app"], "is_cross_division_member": true, "verified_email": true, "verified_phone": true, "is_host": false, "is_traveler": true, "is_roommate_seeker": true}Alternatives Considered
Section titled “Alternatives Considered”Alternative 1: Fully Decentralized (Division-Level)
Section titled “Alternative 1: Fully Decentralized (Division-Level)”Rejected because:
- ❌ Terrible UX: User must create separate accounts for each division
- ❌ Duplicate data: kate@example.com exists 3 times
- ❌ No cross-selling: Can’t market Roommate to Stay Overnight users
- ❌ Fragmented trust: Verification doesn’t carry over
- ❌ Complex GDPR: Must find and delete 3+ separate records
Alternative 2: Fully Centralized (Global CloudAlt Account)
Section titled “Alternative 2: Fully Centralized (Global CloudAlt Account)”Rejected because:
- ❌ Too much coupling: Can’t spin off any BU
- ❌ Schema conflicts: Hospitality fields mixed with Discovery fields
- ❌ Single point of failure: One database outage affects all BUs
- ❌ Complex permissions: BU-specific access controls harder
Alternative 3: Hybrid with Linking Table
Section titled “Alternative 3: Hybrid with Linking Table”Considered but deferred:
class UniversalCloudAltMembership(models.Model): supabase_user_id = models.UUIDField(unique=True) hospitality_member = models.ForeignKey('stays.MemberProfile') discovery_member = models.ForeignKey('discovery.MemberProfile') services_member = models.ForeignKey('services.MemberProfile')Why deferred:
- Adds complexity without immediate value
- Can be added later if we want cross-BU premium features
- YAGNI principle: Don’t build until needed
Success Metrics
Section titled “Success Metrics”User Experience:
- Cross-division signup rate (target: >30% of users join 2+ divisions)
- Account creation friction (target: <2 minutes for division 2+)
- Profile completion rate across divisions
Business Value:
- Lifetime value of cross-division users vs single-division users
- Cross-division conversion rate from in-app promotions
- Support ticket reduction (fewer duplicate accounts)
Technical:
- Query performance on member_profiles table (<100ms p95)
- GDPR deletion completion time (<5 minutes)
- Database size growth rate
References
Section titled “References”- MEMBERSHIP_ARCHITECTURE_ANALYSIS.md - Detailed analysis
- WORK_LOG.md - Implementation timeline
- Supabase Auth documentation
- Django REST Framework best practices
Revision History
Section titled “Revision History”| Date | Version | Changes | Author |
|---|---|---|---|
| 2025-10-27 | 1.0 | Initial decision | Engineering Team |