15 KiB
P0 Address Unification Plan
Split-Brain Diagnosis: marketplace.service_providers vs. system.addresses
Date: 2026-07-07
Author: Rendszer-Architect
Status: Discovery Complete — Awaiting Approval for Implementation
1. Executive Summary
The system suffers from a split-brain address architecture. Two models use the centralized system.addresses table via address_id UUID foreign keys correctly (fleet.organizations, identity.persons), but the marketplace.service_providers table still relies entirely on legacy flat columns for address storage.
The ServiceStaging model is in a hybrid state — it has both flat columns and an address_id FK — and the admin API (admin_providers.py) already has partial AddressManager integration. However, this creates a dual-write pattern: data flows to both the flat columns AND the normalized system.addresses table, with complex fallback logic.
The Organization model was already fully refactored — the deprecated flat columns (address_city, address_zip, etc.) were physically removed from the database via cleanup_ghost_columns_2026_07.sql. The ServiceProvider model needs the same treatment.
2. Full System Audit Results
2.1 Model Status Matrix
| Model | Table | Schema | Flat Columns | address_id FK |
Status |
|---|---|---|---|---|---|
Address |
system.addresses |
system |
❌ (central) | UUID PK (self) | ✅ Source of Truth |
Organization |
fleet.organizations |
fleet |
❌ (removed) | ✅ UUID FK (3x) | ✅ Fully Refactored |
Person |
identity.persons |
identity |
❌ | ✅ UUID FK | ✅ Already correct |
Branch |
fleet.branches |
fleet |
❌ | ✅ UUID FK | ✅ Already correct |
ServiceProfile |
marketplace.service_profiles |
marketplace |
❌ | ❌ (uses location Geometry) |
✅ Clean design |
ServiceStaging |
marketplace.service_staging |
marketplace |
✅ (city, postal_code, full_address) |
✅ UUID FK | ⚠️ Hybrid — needs cleanup |
ServiceProvider |
marketplace.service_providers |
marketplace |
✅ (city, address_zip, address_street_name, address_street_type, address_house_number) |
❌ NONE | 🔴 CORE PROBLEM |
2.2 ServiceProvider Model — The Core Problem
File: backend/app/models/identity/social.py
The ServiceProvider model at lines 23-71 defines these flat address columns:
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # line 38
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # line 39
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) # line 40
address_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # line 41
address_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # line 42
There is NO address_id UUID foreign key to system.addresses.
This means:
- Address data cannot be shared/deduplicated across entities
- No postal code normalization (no
GeoPostalCoderelationship) - The
AddressManagercreatessystem.addressesrecords but the results are mapped back to flat columns instead of storing the FK - The
_build_unified_providers_query()inadmin_providers.pymust use a complex UNION ALL with flat field extraction
2.3 ServiceStaging Model — Hybrid State
File: backend/app/models/marketplace/staged_data.py
The ServiceStaging model at line 81 already has the FK:
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
) # line 81
But it ALSO retains flat columns at lines 25-27:
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
postal_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
full_address: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
This hybrid state means the API writes to BOTH the address_id FK and the flat columns, creating data duplication.
2.4 Admin API — Partial Fix Complexity
File: backend/app/api/v1/endpoints/admin_providers.py
The update_provider() function at line 1157 has complex branching logic:
- Lines 1242-1262: For
ServiceStaging(which hasaddress_idFK) → usesAddressManager.create_or_update()then setsprovider_obj.address_id - Lines 1262-1302: For
ServiceProvider(NOaddress_idFK) → usesAddressManager.create_or_update()then maps the result back to 12+ flat columns viaaddr_mapping(lines 1277-1302)
This dual-write pattern is fragile and error-prone. The addr_mapping dict at lines 1277-1302 maps each AddressIn field to its flat column counterpart.
Similarly, on response:
- Lines 1507-1518: For
ServiceProvider→ tries to resolveaddress_detailfromaddress_id(which may be NULL for existing records) - Lines 1548-1560: For
ServiceStaging→ resolvesaddress_detailfromaddress_id
The _build_unified_providers_query() at line 284 uses UNION ALL across 3 tables with flat field extraction:
-- ServiceProvider branch (lines 306-320): selects flat columns
-- ServiceStaging branch (lines 335-352): selects flat columns
-- Organization branch (lines 369-390): JOINs with system.addresses
2.5 Frontend Status
| Page | Path | SharedAddressForm | address_detail | Flat Fallback |
|---|---|---|---|---|
| Provider Edit | providers/[id]/edit.vue |
✅ (line 343) | ✅ (lines 810-823) | ⚠️ (lines 1133-1134) |
| Provider Detail | providers/[id]/index.vue |
❌ | ✅ (lines 144-166) | ✅ (lines 146, 153) |
| Provider List | providers/index.vue |
❌ | ❌ uses provider.address only |
✅ (line 137) |
| Garage Edit | garages/[id]/index.vue |
✅ (line 534) | ✅ (lines 1014-1023) | ❌ |
| Garage List | garages/index.vue |
❌ | ❌ uses garage.city only |
✅ (line 157) |
2.6 Already-Refactored Endpoints (Reference)
These endpoints already use AddressManager correctly and serve as reference implementations:
| Endpoint | File | How |
|---|---|---|
| Admin Organizations | admin_organizations.py |
Lines 1097-1145: AddressOut.model_validate(org.address) for 3 address types |
| User Organizations | organizations.py |
Lines 71-82: AddressManager.create_or_update() on org creation |
| Admin Persons | admin_persons.py |
Lines 654-661: AddressManager.create_or_update() for person.address |
| User Profile | users.py |
Lines 355-374: GeoService.get_or_create_full_address() |
3. Migration Strategy: 3-Phase Plan
flowchart TD
subgraph Phase1["Phase 1: Database (Alembic)"]
A1[Add address_id UUID FK to marketplace.service_providers]
A2[Create data migration: backfill system.addresses from flat columns]
A3[Set FK constraint + add relationship to ServiceProvider model]
A4[Run sync_engine to verify schema]
end
subgraph Phase2["Phase 2: Backend API"]
B1[Simplify update_provider: remove addr_mapping logic]
B2[Refactor _build_unified_providers_query: JOIN with system.addresses]
B3[Update ProviderListItem/ProviderDetail schemas]
B4[Remove flat address field fallback in get_provider_detail]
end
subgraph Phase3["Phase 3: Frontend"]
C1[providers/index.vue: show address_detail instead of flat address]
C2[providers/[id]/index.vue: remove flat fallback]
C3[Clean up flat address fields from edit.vue]
C4[garages/index.vue: show address_detail instead of flat city]
end
Phase1 --> Phase2 --> Phase3
Phase 1 — Database Migration (Model + sync_engine)
Goal: Add address_id FK to ServiceProvider and migrate existing data.
Steps:
-
Add
address_idcolumn toServiceProvidermodel insocial.py:address_id: Mapped[Optional[uuid.UUID]] = mapped_column( PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True ) address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin") -
Create data migration script that:
- Iterates all
ServiceProviderrecords with flat address data and NULLaddress_id - For each record, creates a
system.addressesrecord viaAddressManager.create_or_update() - Sets
service_providers.address_idto the new UUID - Logs the migration results
- Iterates all
-
Run sync_engine:
docker exec sf_api python /app/backend/app/scripts/sync_engine.py -
Verify: Query
service_providersto confirmaddress_idis populated. -
Deferred: Drop flat columns in a future migration after Phase 3 is complete and verified.
Phase 2 — Backend API Refactor
Goal: Simplify admin_providers.py to use AddressManager directly without flat column mapping.
Key changes in admin_providers.py:
-
Simplify
update_provider()(lines 1242-1302):- Remove the
addr_mappingdict (lines 1277-1302) forServiceProvider - Both branches now just call
AddressManager.create_or_update()and setprovider_obj.address_id - Remove flat column updates for
address_zip,address_street_name, etc.
- Remove the
-
Refactor
_build_unified_providers_query()(lines 284-407):- For
ServiceProvider: Add LEFT JOIN tosystem.addressesand extract fields via relationship - Remove flat column SELECT for ServiceProvider
- Keep flat fallback temporarily for backward compatibility
- For
-
Update response schemas (lines 59-105, 134-151):
ProviderListItem: Addaddress_detail: Optional[AddressOut]ProviderDetail: Mark flataddressandcityas DEPRECATEDProviderUpdateInput: Keepaddress_detailas primary, mark flat fields deprecated
-
Simplify
get_provider_detail()(lines 534-659):- Remove flat field fallback (lines 586-587:
sp.address or "") - Always resolve from
address_idviaAddressManager.get_normalized()
- Remove flat field fallback (lines 586-587:
Phase 3 — Frontend Cleanup
Goal: All provider/garage pages display and edit addresses via address_detail only.
Detailed changes:
-
providers/index.vue(line 137): Replaceprovider.addresswithprovider.address_detail?.full_address_text || [provider.address_detail?.zip, ...].filter(Boolean).join(' ') -
providers/[id]/index.vue(lines 144-166): Remove|| provider.addressand|| provider.cityfallbacks (lines 146, 153) -
providers/[id]/edit.vue(lines 1133-1134): Removedata.addressfallback fromdata.address_detail || data.address || {} -
garages/index.vue(line 157): Replacegarage.city || '—'withgarage.address_detail?.city || '—'or similar
4. Risk Assessment
| Risk | Impact | Mitigation |
|---|---|---|
| Data loss during migration (flat → normalized) | High | Migration script must be transactional with rollback. Test on staging first. |
| Existing API consumers send flat address fields | Medium | Keep deprecated fields in schemas with [DEPRECATED] tags for one release cycle |
| Frontend list view breaks if address_detail is missing | Medium | Add fallback to flat fields in list view during transition period |
| Service robots (OSM Scout, OCR) write flat columns directly | Medium | Audit service_robot_1_scout_osm.py — it's in the modified files list |
| Unified query (3-way UNION ALL) breaks | High | Refactor query carefully to maintain backward compatibility |
5. Acceptance Criteria
Phase 1 — Database Migration
marketplace.service_providershasaddress_idUUID FK →system.addresses.id- All existing ServiceProvider records with flat address data have corresponding
system.addressesrecords linked viaaddress_id ServiceProvidermodel hasaddressrelationship withlazy="selectin"sync_enginereports no schema drift
Phase 2 — Backend API
update_provider()no longer mapsAddressInfields to flat columns forServiceProviderget_provider_detail()returnsaddress_detailfromaddress_idrelationship for all providerslist_providers()includesaddress_detailin each item_build_unified_providers_query()uses LEFT JOIN for ServiceProvider instead of flat column extraction- Flat address fields (
address,city) still work in API requests but are marked[DEPRECATED]in schemas
Phase 3 — Frontend
- Provider list page shows addresses from
address_detail(not flatprovider.address) - Provider detail page has no flat fallback for address fields
- Provider edit page sends
address_detailonly (no flat fields) - Garage list page shows city from
address_detail
6. References
- AddressManager service — 4 static methods:
resolve(),create_or_update(),get_normalized(),_resolve_postal_code() - AddressIn/AddressOut schemas — Pydantic models for address API contracts
- SharedAddressForm component — Vue form component (search for usage via
SharedAddressForm) - Address model — UUID PK,
postal_code_idFK →system.geo_postal_codes - Organization model (REFACTORED) — Reference implementation with 3
address_idFKs - Admin Persons (REFACTORED) — Reference API implementation
- Ghost columns cleanup SQL — Reference for how Organization's flat columns were removed