# Architecture Decision Log This document consolidates the binding technical decisions for FablePool Kite Map. Each record follows the standard ADR format: **Context → Options → Decision → Consequences**. Where a decision is elaborated elsewhere (data sources in `docs/04`, scoring in `docs/05`, architecture in `docs/08`), this log is the index of record; the linked document is normative for the details. Status values: `Accepted` (binding for v1), `Superseded` (replaced; link to successor). --- ## ADR-001: Map renderer — MapLibre GL JS with vector tiles (Leaflet as documented fallback) **Status:** Accepted · **Date:** Milestone 1 · **Detail:** `docs/04-data-sources.md` ### Context We need an open-source, no-API-key, browser-based map with smooth pan/zoom, rotation (useful for orienting wind direction arrows relative to the viewer), and the ability to render thousands of spot markers and animated wind overlays without jank on mid-range mobile hardware. ### Options considered 1. **Leaflet + OSM raster tiles.** Mature, tiny (~42 KB), enormous plugin ecosystem. Raster-only; no map rotation; wind-particle overlays must be hand-rolled on a canvas layer; marker counts above ~2,000 require clustering plugins to stay responsive. 2. **MapLibre GL JS + vector tiles.** BSD-licensed fork of Mapbox GL JS v1. WebGL rendering, rotation/pitch, data-driven styling (color spot pins by live flyability score without re-rendering markers), built-in GeoJSON sources with clustering, custom layers API suitable for a wind-particle canvas. 3. **OpenLayers.** Very capable, but heavier API surface, smaller contributor familiarity, and weaker mobile gesture defaults out of the box. ### Decision **MapLibre GL JS (v4.x)** is the primary renderer, consuming vector tiles from a keyless community provider (OpenFreeMap as default style/tile source, with Protomaps PMTiles self-hosting documented as the zero-third-party option — see `docs/04`). Leaflet remains the documented fallback path for contributors targeting very-low-end devices or environments without WebGL; the frontend map module must therefore keep renderer-specific code behind a small adapter interface (`MapAdapter`: `addSpotLayer`, `addWindLayer`, `flyTo`, `onViewportChange`). ### Consequences - WebGL is required for the default experience; a no-WebGL detection path must show a static fallback (Leaflet build or server-rendered spot list). - Vector tile styling lives in a versioned `style.json` in the repo — themable, diffable. - No Mapbox dependency, no API keys, no usage-based billing — consistent with the open-source charter (`docs/09`). --- ## ADR-002: Weather data — Open-Meteo primary; NOAA/NWS for US alerts and buoy observations **Status:** Accepted · **Date:** Milestone 1 · **Detail:** `docs/04-data-sources.md` ### Context The product is unusable without reliable wind forecasts (speed, gusts, direction at 10 m) and precipitation. The charter forbids paid-API lock-in. Coverage must be global; many kite spots are coastal, so marine observations are a strong differentiator for kitesurfers. ### Options considered 1. **Open-Meteo.** Free for non-commercial use, no API key, global multi-model coverage (ICON, GFS, ECMWF IFS open data), hourly `wind_speed_10m`, `wind_gusts_10m`, `wind_direction_10m`, `precipitation`, `weather_code`; marine API for wave data; AGPL-licensed server that can be **self-hosted**, eliminating third-party dependence. 2. **NOAA/NWS API.** Free, authoritative, no key — but US-only for forecasts; gust data gridded; alerts endpoint (`api.weather.gov/alerts`) is the best source for thunderstorm and high-wind warnings; NDBC buoys give real-time observed marine wind. 3. **Met.no Locationforecast.** Free, global, but requires identifying User-Agent contact and has stricter ToS around redistribution. 4. **Commercial (OpenWeatherMap, Tomorrow.io, Windy API).** Rejected outright: key management, quotas, and pricing conflict with the charter. ### Decision **Open-Meteo is the primary and only required provider.** The backend's provider layer is an interface (`ForecastProvider`) with Open-Meteo as the reference implementation. **NOAA/NWS is an optional enrichment provider** (enabled by config) contributing: (a) active weather alerts for US spots, surfaced as hard no-fly flags in the scoring engine, and (b) NDBC buoy observations shown as "observed now" alongside forecasts at coastal spots. ### Consequences - All forecast responses are normalized into our own `ForecastHour` model (see `docs/06-database-schema.md`) so providers are swappable and cacheable uniformly. - Self-hosting Open-Meteo is the documented path for any future commercial deployment, keeping the project license-clean. - Non-US users lose alert enrichment; the scoring engine treats alerts as *additional* hard rules, never as a required input (graceful degradation). --- ## ADR-003: Datastore — PostgreSQL 16 + PostGIS **Status:** Accepted · **Date:** Milestone 1 · **Detail:** `docs/06-database-schema.md`, `db/schema.sql` ### Context Core queries are spatial ("spots within viewport bounding box", "nearest spots to me", "spots within 50 km sorted by current flyability") plus relational community data (spots, reviews, photos, hazard reports, users). ### Options considered 1. **PostgreSQL + PostGIS** — `GIST` indexes on `geography` columns make viewport and radius queries trivial and fast; one database for spatial + relational + forecast cache. 2. **SQLite + SpatiaLite** — attractive for tiny self-hosters, but concurrent-write limits hurt community features; kept as a *future* "lite mode" possibility, not v1. 3. **MongoDB geospatial** — adequate 2dsphere support, but loses relational integrity for reviews/users and adds a second mental model for contributors. ### Decision **PostgreSQL 16 with PostGIS 3.4** for everything, including the forecast cache table (`forecast_cache` keyed by snapped grid point + model run, see ADR-006). No separate cache store (Redis) in v1; Postgres `UNLOGGED` table + TTL cleanup is sufficient at the projected scale (< 100 req/s) and keeps self-hosting to a single dependency. ### Consequences - Self-host stack = one container for the API + one for Postgres/PostGIS. Documented in `docs/08-architecture.md`. - If load testing in a later milestone shows cache contention, promoting the forecast cache to Redis is an isolated change behind the `ForecastCache` interface. --- ## ADR-004: Flyability scoring — transparent rule-based rubric, no ML in v1 **Status:** Accepted · **Date:** Milestone 1 · **Detail:** `docs/05-flyability-scoring.md`, `docs/12-scoring-worked-examples.md` ### Context The headline feature is a 0–100 "flyability" score per spot per kite type per hour. Users must be able to *trust and verify* it — a wrong "Excellent" rating for a gusty offshore day is a genuine safety problem for kitesurfers. ### Options considered 1. **Rule-based weighted rubric** — wind-band trapezoid + gust ratio + precipitation + direction/obstacle sector + temperature comfort, with hard safety overrides (thunderstorm ⇒ 0; offshore wind for kitesurfing ⇒ capped + warning). 2. **ML model trained on session logs** — no training data exists yet; opaque scores are un-auditable and dangerous for safety-relevant output. 3. **Third-party scores (Windfinder-style ratings)** — proprietary, not redistributable. ### Decision **Rule-based rubric**, fully specified with exact constants and rounding rules in `docs/05`, pinned by executable test vectors in `docs/12-scoring-worked-examples.md` and `examples/scoring-fixtures.json`. Every API score response carries a `breakdown` object exposing each component so the UI can explain *why* (see `api/openapi.yaml`). Session logging is designed into the schema now so a data-informed recalibration of the rubric *constants* (not a black-box model) is possible in a later milestone. ### Consequences - Scoring is deterministic and unit-testable; the fixtures file is the acceptance contract for Milestone 2's implementation. - Rubric changes require a version bump (`scoring_version` field in API responses) so cached/compared scores are never silently inconsistent. --- ## ADR-005: API style — REST with OpenAPI 3.1 (no GraphQL in v1) **Status:** Accepted · **Date:** Milestone 1 · **Detail:** `docs/07-api-contract.md`, `api/openapi.yaml` ### Context Consumers are our own web frontend plus (per the charter) third-party open-source clients. Endpoints are few and shapes are stable: spots search, spot detail, forecast, score, community submissions. ### Decision Plain **REST + JSON**, contract-first via `api/openapi.yaml`. Reasons: HTTP caching (forecast responses are highly cacheable with `Cache-Control` + `ETag`, which GraphQL defeats), trivially curl-able for the self-hosting community, and codegen for clients from the OpenAPI file. GraphQL revisited only if third-party clients demonstrate real over/under-fetching pain. ### Consequences - The OpenAPI file is normative; CI in Milestone 2 must lint it and verify the server conforms (schema-driven contract tests). - Versioning is URL-prefixed (`/v1/`); additive changes only within a major version. --- ## ADR-006: Forecast fetching — server-side proxy with grid-snapped caching **Status:** Accepted · **Date:** Milestone 1 · **Detail:** `docs/08-architecture.md` ### Context Naively letting each browser call Open-Meteo per spot per pan event would (a) hammer a free community API in violation of fair-use, (b) leak no keys (none exist) but make client behavior the rate-limit surface, and (c) prevent us from computing scores server-side consistently. ### Decision All forecast access goes through our API. Requested coordinates are **snapped to a 0.1° grid** (~11 km — comparable to source model resolution, so no meaningful accuracy loss) before lookup, so all spots in the same cell share one upstream fetch. Cache entries are keyed `(grid_lat, grid_lon, provider, model_run)` with a TTL of 1 hour for hourly forecasts and 10 minutes for "current" observations. A single-flight lock (Postgres advisory lock on the cell key) prevents thundering-herd duplicate fetches. ### Consequences - Upstream request volume scales with *active map area*, not user count. - Scores and forecasts shown to all users in a region are identical and explainable. - The snapping resolution is a named constant (`FORECAST_GRID_DEG = 0.1`) and is part of the API documentation, since two nearby spots can legitimately share a forecast. --- ## ADR index | ID | Title | Status | |----|-------|--------| | ADR-001 | MapLibre GL JS + vector tiles | Accepted | | ADR-002 | Open-Meteo primary, NOAA/NWS enrichment | Accepted | | ADR-003 | PostgreSQL 16 + PostGIS | Accepted | | ADR-004 | Rule-based flyability scoring | Accepted | | ADR-005 | REST + OpenAPI 3.1 | Accepted | | ADR-006 | Grid-snapped server-side forecast cache | Accepted |