# 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
