# Canonical ID Refactor Design **Date:** 2026-01-11 **Status:** Approved **Goal:** Eliminate UUID layer, use canonical IDs as the single identifier throughout the client ## Problem The codebase has 4 different stadium ID formats causing bugs: 1. Canonical IDs: `"stadium_mlb_fenway_park"` (stable identity) 2. Symbolic IDs: `"stadium_mlb_bos"` (team-based, achievements) 3. UUIDs: `UUID` type (runtime lookups) 4. UUID strings: `"550e8400-..."` (stored in visits) This led to the Green Monster/Ivy League achievement bug where visits stored UUID strings but achievements expected canonical IDs. ## Solution Use canonical IDs (`String`) as the single identifier everywhere on the client. CloudKit keeps reference integrity at the sync layer. ## Design ### 1. Domain Models ```swift struct Stadium: Identifiable, Codable, Hashable { let id: String // "stadium_mlb_fenway_park" - THE identity let name: String let city: String let state: String let latitude: Double let longitude: Double let capacity: Int let sport: Sport let yearOpened: Int? let imageURL: URL? } struct Team: Identifiable, Codable, Hashable { let id: String // "team_mlb_bos" let name: String let abbreviation: String let sport: Sport let city: String let stadiumId: String // FK: "stadium_mlb_fenway_park" // ... } struct Game: Identifiable, Codable, Hashable { let id: String // "game_mlb_2026_bos_nyy_0401" let homeTeamId: String // "team_mlb_bos" let awayTeamId: String // "team_mlb_nyy" let stadiumId: String // "stadium_mlb_fenway_park" let dateTime: Date let sport: Sport // ... } ``` ### 2. Visit Tracking ```swift @Model final class StadiumVisit { @Attribute(.unique) var id: UUID // Visit's own identity var stadiumId: String // "stadium_mlb_fenway_park" var stadiumNameAtVisit: String // Frozen: "Fenway Park" var visitDate: Date var sport: String var visitTypeRaw: String var gameId: String? var homeTeamId: String? var awayTeamId: String? // ... } ``` ### 3. Data Provider ```swift @MainActor final class AppDataProvider: ObservableObject { @Published private(set) var teams: [Team] = [] @Published private(set) var stadiums: [Stadium] = [] // Lookup dictionaries - keyed by canonical ID (String) private var teamsById: [String: Team] = [:] private var stadiumsById: [String: Stadium] = [:] // REMOVED: UUID mapping dictionaries } ``` ### 4. Achievement Engine ```swift // Achievement definitions use canonical IDs directly AchievementDefinition( id: "special_fenway", name: "Green Monster", requirement: .specificStadium("stadium_mlb_fenway_park") ) // Checking is direct comparison case .specificStadium(let stadiumId): return visitedStadiumIds.contains(stadiumId) // REMOVED: resolveSymbolicStadiumId() helper ``` ### 5. CloudKit Layer (Unchanged) SwiftData models keep their structure. CloudKit references ensure data integrity at sync time. The `toDomain()` conversion simplifies: ```swift extension CanonicalStadium { func toDomain() -> Stadium { Stadium( id: canonicalId, // Direct pass-through name: name, // ... ) } } extension CanonicalTeam { func toDomain() -> Team { // No more stadiumUUID parameter Team( id: canonicalId, stadiumId: stadiumCanonicalId, // ... ) } } ``` ## Migration Since we're in development, this is a clean break. ### Files to Modify (in order) | Phase | Files | Changes | |-------|-------|---------| | 1. Domain Models | `Stadium.swift`, `Team.swift`, `Game.swift` | `id: UUID` → `id: String` | | 2. SwiftData Converters | `CanonicalModels.swift` | Simplify `toDomain()` methods | | 3. Data Provider | `DataProvider.swift` | Remove UUID mapping dicts | | 4. Visit Model | `StadiumProgress.swift` | Remove `stadiumUUID`, simplify | | 5. Achievement Engine | `AchievementEngine.swift` | Delete resolution helper | | 6. Planning Engine | `GameDAGRouter.swift`, planners | Update dictionary key types | | 7. View Models | Various | Update UUID references | | 8. Tests | All test files | Update fixtures | ### Cleanup ```bash # Delete local SwiftData store rm -rf ~/Library/Developer/CoreSimulator/.../SportsTime/Library/Application\ Support/default.store ``` ## Testing ### Compile-time Safety ```swift // Wrong type won't compile let stadium = stadiums[someUUID] // Error: String key required ``` ### Key Test Cases ```swift // 1. ID format validation func test_stadiumId_followsCanonicalFormat() { for stadium in AppDataProvider.shared.stadiums { XCTAssertTrue(stadium.id.hasPrefix("stadium_")) } } // 2. FK integrity func test_allTeamStadiumIds_existInStadiums() { let stadiumIds = Set(dataProvider.stadiums.map { $0.id }) for team in dataProvider.teams { XCTAssertTrue(stadiumIds.contains(team.stadiumId)) } } // 3. Achievement matching func test_specificStadiumAchievement_matchesVisit() { let visit = StadiumVisit(stadiumId: "stadium_mlb_fenway_park", ...) let achievement = AchievementRegistry.achievement(byId: "special_fenway")! if case .specificStadium(let requiredId) = achievement.requirement { XCTAssertEqual(visit.stadiumId, requiredId) } } // 4. Round-trip consistency func test_canonicalModel_toDomain_preservesId() { let canonical = CanonicalStadium(canonicalId: "stadium_mlb_fenway_park", ...) let domain = canonical.toDomain() XCTAssertEqual(canonical.canonicalId, domain.id) } ``` ## Benefits 1. **Single source of truth** - canonical ID is the only identifier 2. **Human-readable** - see `stadium_mlb_fenway_park` in logs, not UUIDs 3. **No mapping layer** - eliminate conversion dictionaries 4. **Bug category eliminated** - ID format mismatches become impossible 5. **Stable across renames** - stadium renames don't break visits 6. **Compiler catches mistakes** - String vs UUID type mismatch won't compile