> ## Documentation Index
> Fetch the complete documentation index at: https://285e39fd5e337e58f16290.sightscreen.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture decision records

> Key architectural decisions made in the project, with context and rationale.

Architecture Decision Records (ADRs) capture significant technical decisions with their context, alternatives considered, and consequences. They live in `docs/architecture/adr/` in the source repo and are numbered sequentially.

## Backend infrastructure

<Accordion title="ADR-001: APNs HTTP/2 Connection Pooling" id="adr-001">
  |            |            |
  | ---------- | ---------- |
  | **Status** | Accepted   |
  | **Date**   | 2026-02-16 |

  **Context:** The APNs push service was creating a new HTTP/2 connection for every single push notification. For a batch of 100 device tokens, this meant 100 separate TCP+TLS handshakes (\~100-200ms each), resulting in \~15s total latency per batch during live matches.

  **Decision:** Replace per-push `http2.connect()` calls with a persistent, lazily-initialized HTTP/2 session reused across all push requests. HTTP/2 natively supports multiplexed streams, which is exactly the model Apple designed APNs around. Key design choices include lazy singleton via `getSession()`, connection deduplication via a shared promise, auto-reconnection on close/error events, GOAWAY handling, and graceful shutdown with a 5-second force-destroy fallback.

  **Consequences:**

  * \~30x latency reduction for batched pushes (100 pushes: \~15s down to \~0.5s)
  * Reduced resource usage — one long-lived connection instead of hundreds
  * Simpler call sites — `sendPush()` callers are unaware of connection management
  * Module-level mutable state makes unit testing harder (mitigated by `resetSession()` for tests)
</Accordion>

<Accordion title="ADR-002: Hybrid In-Memory + DynamoDB Ball Processing State" id="adr-002">
  |            |                                              |
  | ---------- | -------------------------------------------- |
  | **Status** | Superseded by LA v2 (`active-matches` table) |
  | **Date**   | 2026-02-16                                   |

  **Context:** The push job was stateless — every poll re-detected events on the last 6 balls, causing duplicate alerts (the same wicket fires on every poll while it remains in the window) and missed events after downtime (balls between the last processed one and the current 6-ball window were silently dropped).

  **Decision:** Introduce a hybrid in-memory + DynamoDB storage pattern for `lastProcessedBallId` per match. The in-memory `Map` serves as the primary read path (O(1)), with DynamoDB as a backup on cache miss and for persistence across restarts. Writes go to the Map synchronously and to DynamoDB as async fire-and-forget. A 7-day TTL on DynamoDB items provides self-cleaning.

  **Consequences:**

  * Each ball processed exactly once — the cursor guarantees no duplicates and no gaps
  * Survives restarts — DynamoDB backup means deploys during live matches don't lose progress
  * Sub-millisecond reads on the hot path
  * Assumes monotonically increasing ball IDs from SportMonks (historically true)

  **Superseded:** LA v2 replaced the `match-processing-state` table with `active-matches`, which stores `previousBallId` alongside full previous state (previousScore, previousBatting, previousBowling, last30Balls, cooldowns). The hybrid in-memory pattern was replaced by direct DynamoDB reads on every tick.
</Accordion>

<Accordion title="ADR-009: Cockatiel for SportMonks Polling Resilience" id="adr-009">
  |            |            |
  | ---------- | ---------- |
  | **Status** | Accepted   |
  | **Date**   | 2026-03-23 |

  **Context:** The LiveScoreJob polls SportMonks every 5 seconds during live matches. The hand-rolled scheduling left several failure modes unhandled: hanging requests (could expire the distributed lock), thundering herd (deterministic backoff), no circuit breaker (kept hammering a down API), and no jitter.

  **Decision:** Use [cockatiel](https://github.com/connor4312/cockatiel) (v3) to wrap the SportMonks API call in a composed policy stack: timeout (8s) -> circuit breaker (5 failures, 30s half-open) -> retry (2 attempts, exponential backoff + jitter). Cockatiel was chosen over opossum (CB only, CJS), p-retry+p-timeout (no CB), and mollitia (too few stars for production).

  **Consequences:**

  * Hanging requests are killed after 8s instead of blocking indefinitely
  * Circuit breaker stops hammering a down API and auto-recovers via half-open test
  * Jitter eliminates thundering herd on recovery
  * Circuit state exposed via `GET /admin/polling/status` for CloudWatch alarms
  * New dependency (zero-dep, 5KB), but module-level policy state is process-local
</Accordion>

## DynamoDB data model

<Accordion title="ADR-003: Devices Table Match Following Extension" id="adr-003">
  |            |                       |
  | ---------- | --------------------- |
  | **Status** | Superseded by ADR-004 |
  | **Date**   | 2026-02-17            |

  **Context:** The auto-follow and push-to-start Live Activities feature required tracking which matches a device is following and which team the user supports. The existing devices table had no concept of match-level following.

  **Decision:** Extend the `cricket-live-devices` table with a `followedMatches` map attribute containing per-match data (`startDate`, `supportedTeamId`). This kept the read path to a single `GetCommand`.

  **Consequences:** Shipped but was superseded by ADR-004 because the hot path ("who follows match X?") required a full table scan — the partition key was `userId`, not `matchId`.
</Accordion>

<Accordion title="ADR-004: DynamoDB Table Consolidation — 6 Tables to 3" id="adr-004">
  |            |                               |
  | ---------- | ----------------------------- |
  | **Status** | Accepted (supersedes ADR-003) |
  | **Date**   | 2026-02-19                    |

  **Context:** The system had grown to 6 DynamoDB tables with cross-table consistency issues (following a match required writes to 2-3 tables without transactions), redundant data (team preference stored in multiple tables), and the wrong primary access pattern for the hot path (toss detection required scanning instead of querying).

  **Decision:** Consolidate to 3 tables:

  1. **`cricket-devices`** — pure device registry (PK=userId, SK=deviceToken). Stripped of match-level concerns.
  2. **`cricket-match-follows`** — follow intent only (PK=matchId, SK=userId). One record per (match, user) with 48h TTL.
  3. **`cricket-activity-subscriptions`** (now `live-activities`) — Live Activity sessions per device (PK=matchId, SK=userId#deviceId). Separated from follows because LA tokens are per-device while follows are per-user.

  Both match-level tables use PK=matchId so event delivery ("notify all followers of match X") is a single `QueryCommand`.

  **Consequences:**

  * 3 tables instead of 6 — simpler to reason about
  * Hot-path optimized — event delivery and LA updates are single PK queries
  * Auto-cleanup via DynamoDB TTL — no manual cleanup jobs
  * Trade-off: "which matches does user X follow?" requires a scan (low-frequency, acceptable)

  **Note:** LA v2 further evolved `activity-subscriptions` into the `live-activities` table with a composite SK (`userId#deviceId`), a `userId-index` GSI, and session lifecycle tracking (status, endReason, suspendedAt).
</Accordion>

<Accordion title="ADR-005: Single Device Per User" id="adr-005">
  |            |            |
  | ---------- | ---------- |
  | **Status** | Accepted   |
  | **Date**   | 2026-02-27 |

  **Context:** Debugging revealed that when a user unfollows then re-follows a live match, the Live Activity fails to appear. Root causes included stale push-to-start tokens never cleared, orphaned device records from app reinstalls, cross-device token pollution from writing to ALL device records, and no token refresh before follow.

  **Decision:** Simplify `cricket-devices` to one record per user (latest device wins). `registerDevice()` now cleans up old records, explicitly REMOVEs `pushToStartToken` when not provided, and iOS refreshes the push-to-start token before every follow call.

  **Consequences:**

  * Eliminates orphan records, stale tokens, and cross-device pollution
  * Simpler code — all consumers work with `DeviceRecord | null` instead of arrays
  * Trade-off: no iPad + iPhone concurrent Live Activities (acceptable edge case)
</Accordion>

<Accordion title="ADR-008: DynamoDB Migration Tracking" id="adr-008">
  |            |            |
  | ---------- | ---------- |
  | **Status** | Accepted   |
  | **Date**   | 2026-03-02 |

  **Context:** The backend had 5 shell-script migrations with no tracking of which had been applied to which environment. Running migrations required manually remembering what had already been run.

  **Decision:** Track applied migrations in a per-environment DynamoDB table (`cricket-migrations-{env}`). A TypeScript runner checks the registry, computes pending migrations, executes each shell script in order, and records the result. Forward-only (no rollback scripts) — to undo a bad migration, deploy the previous code version.

  **Consequences:**

  * Each environment is fully independent — no risk of sandbox state leaking into production
  * Stop on first failure prevents cascading issues from dependent migrations
  * Custom over off-the-shelf because existing tools (umzug, node-migrate) don't handle bash script migrations well
</Accordion>

<Accordion title="ADR-011: Sparse GSI for Live Fixture Archival" id="adr-011">
  |            |                                              |
  | ---------- | -------------------------------------------- |
  | **Status** | Superseded by LA v2 (`active-matches` table) |
  | **Date**   | 2026-03-24                                   |

  **Context:** Once a match finishes, SportMonks removes it from `/livescores`. The poller never writes the final status, so fixtures freeze at whatever state the last poll captured — missing final status, stale `resultText`, missing `winnerTeamId`, and lingering replay fixtures.

  **Decision:** Add a sparse GSI (`live-index`) to the fixtures table. A `liveUpdatedAt` attribute is SET on every poller tick and REMOVED when the match is archived. Only items with `liveUpdatedAt` appear in the GSI. Each tick, the poller scans the GSI, diffs against current `/livescores`, and reconciles off-air matches by fetching their final state from SportMonks.

  **Consequences:**

  * Fixtures now get their final status written (Finished, Abandoned, etc.)
  * Sparse GSI contains only 10-20 items during peak — scan is under 1 RCU, under 10ms
  * Replay fixtures (negative matchIds) are cleaned up immediately instead of waiting for TTL
  * New `getFixture()` added to `LivescoreProvider` interface for reconciliation

  **Superseded:** LA v2 removed the `live-index` GSI. The `active-matches` table now serves as the source of truth for which matches are currently being polled. LiveMatchReconciler handles archival by diffing active-matches against SportMonks livescores.
</Accordion>

## iOS client

<Accordion title="ADR-010: ScrollView Paging Over TabView(.page)" id="adr-010">
  |            |            |
  | ---------- | ---------- |
  | **Status** | Accepted   |
  | **Date**   | 2026-03-24 |

  **Context:** The schedule view used `TabView(.page)` for horizontal date swiping. Two bugs emerged: `TabView(.page)` miscalculates scroll offset when adjacent pages have different content sizes (a [known Apple bug](https://developer.apple.com/forums/thread/748476)), and all pages shared a single data property causing a visual flash on swipe.

  **Decision:** Replace `TabView(.page)` with `ScrollView(.horizontal)` + `.scrollTargetBehavior(.paging)` (iOS 17+). Using `.containerRelativeFrame(.horizontal)` pins each page to the container width regardless of content size, and per-page data ownership eliminates the shared-state flash.

  **Consequences:**

  * Fixes both the misaligned pages and visual flash bugs
  * All future paged views should use this `ScrollView` pattern, never `TabView(.page)`
  * Requires iOS 17 minimum (already the deployment target)
</Accordion>

## Display and business logic

<Accordion title="ADR-006: Chase Win Probability Model" id="adr-006">
  |            |            |
  | ---------- | ---------- |
  | **Status** | Accepted   |
  | **Date**   | 2026-02-27 |

  **Context:** The tension meter during live chases used a naive run rate ratio (`rrr / crr`) that ignored wickets in hand, target difficulty, and balls remaining. A team needing 12 RPO with 8 wickets was treated identically to 12 RPO with 2 wickets.

  **Decision:** Replace the naive ratio with a polynomial logistic regression (degree 2, 5 features) trained on 1,472 recent T20I matches (2023+), achieving 83.9% accuracy. The 5 features are: balls remaining (normalized), wickets in hand, RRR differential, performance index (CRR x wickets), and target (normalized). The model ships as 31 hardcoded constants — pure arithmetic, sub-microsecond evaluation, no ML runtime.

  **Consequences:**

  * Wickets now matter — different probabilities for same run rate with different wickets in hand
  * Target-aware — chasing 220 treated differently from 130 at the same run rate
  * Zero runtime cost — one dot product, one sigmoid
  * Model is T20I-specific — may need retraining for franchise leagues
</Accordion>

<Accordion title="ADR-012: Display Mode — IPL-Only League Rendering" id="adr-012">
  |            |            |
  | ---------- | ---------- |
  | **Status** | Accepted   |
  | **Date**   | 2026-03-24 |

  **Context:** The `displayMode` field controlled how match cards render team identity: `league` mode (brand colors, no flags) vs `international` mode (SportMonks flags). Two large hardcoded sets mapped every league code to a mode, but in practice only IPL needed league mode — other franchise leagues had no complaints using SportMonks flags.

  **Decision:** Simplify `getDisplayMode()` to: IPL -> league, everything else -> international. Remove both `INTERNATIONAL_CODES` and `LEAGUE_CODES` sets.

  **Consequences:**

  * Match cards for PSL, BBL, BPL etc. now render with SportMonks flags instead of brand colors
  * If a league raises licensing concerns, adding its code back is a one-line change
  * Trivially understandable rendering logic
</Accordion>

## Decommissioned

<Accordion title="ADR-007: Test Strategy Decommission" id="adr-007">
  |            |            |
  | ---------- | ---------- |
  | **Status** | Accepted   |
  | **Date**   | 2026-03-02 |

  **Context:** The backend had 24 Vitest unit test files that were never enforced in CI, had drifted from production code, and relied heavily on mocked DynamoDB calls that verified SDK parameters rather than real behavior.

  **Decision:** Remove the entire Vitest test suite and related infrastructure (24 test files, 3 devDependencies, 3 npm scripts). Replace with a replay-based integration verification strategy that tests the full stack: backend APIs -> push notifications -> iOS Live Activities -> visual regression.

  **Consequences:**

  * Eliminated false sense of coverage from tests that were never gated on merge
  * Strategic investment in replay-based testing that provides higher confidence for a real-time data flow system
  * No unit tests exist during the transition period until the replay harness is complete
</Accordion>
