diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 42ac859..94cde57 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -67,6 +67,13 @@ ADR-007 §6 licensing guard).
- z≥12 — GeoJSON через `GET /api/gps-tracks?bbox=...&activity=...&source=...`.
- z<8 — слой скрыт (защита от шторма запросов).
+Скачивание одного трека из popup карты (ET-011):
+`GET /api/gps-tracks/{track_id}/download` — отдаёт GPX 1.1 с
+правильным `Content-Disposition` и UTF-8 именем по RFC 5987. Разрешено
+только для источников с `download_allowed: true` в
+`config/gps_sources.yaml` (MVP: только `osm`). Cap 200000 точек →
+413 Payload Too Large. См. ADR-014 / ADR-015.
+
Health/observability: `GET /api/gps-tracks/health` — состояние БД,
число треков по источникам, последний прогон.
diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md
index c601608..3033041 100644
--- a/docs/architecture/adr/README.md
+++ b/docs/architecture/adr/README.md
@@ -17,3 +17,5 @@
| ADR-011 | ttrails.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md) |
| ADR-012 | Wikiloc — licensing: accepted с rate-limit 10s, graceful-stop на 403/429, обезличенное сохранение (без user/description) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md) |
| ADR-013 | Активация EnduroRussia + Wikiloc — конфиг-only изменения поверх pipeline ET-008 (URL-fix, новая запись wikiloc, регионы, стили, атрибуция) | accepted | 2026-06-01 | [ET-009](../../work-items/ET-009/06-adr/ADR-013-source-activation.md) |
+| ADR-014 | GPX-download эндпоинт публичного трека: `xml.etree.ElementTree`-builder + fetch+Blob на клиенте | accepted | 2026-06-03 | [ET-011](../../work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md) |
+| ADR-015 | Политика реэкспорта публичных треков: per-source `download_allowed` в `gps_sources.yaml`, default-deny (whitelist `osm` для MVP) | accepted | 2026-06-03 | [ET-011](../../work-items/ET-011/06-adr/ADR-015-source-redistribution-policy.md) |
diff --git a/docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md b/docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md
new file mode 100644
index 0000000..a5dcc8d
--- /dev/null
+++ b/docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md
@@ -0,0 +1,503 @@
+---
+type: adr
+work_item_id: ET-011
+adr_id: ADR-014
+title: "ADR-014: Эндпоинт скачивания GPX из popup трека — `xml.etree.ElementTree`-builder + fetch+Blob на клиенте"
+status: accepted
+created_at: 2026-06-03
+updated_at: 2026-06-03
+authors:
+ - "agent:architect"
+supersedes: []
+superseded_by: []
+labels:
+ - "ET-011:download"
+ - "minor-change"
+---
+
+# ADR-014 — Endpoint и формат скачивания публичного GPS-трека
+
+## Статус
+
+**Accepted.** Архитектурное решение для ET-011.
+
+## Контекст
+
+ET-008 ввёл публичный слой GPS-треков (`/api/gps-tracks/*`) и popup при
+клике (`gps_tracks.js::_renderTrackPopupHtml`, l. 463). В popup
+показывается метаинформация (название, активность, длина, точки, дата,
+источники), но **нет действия «забрать трек к себе»**: пользователь
+видит трек, но не может одним тапом скачать его GPX.
+
+ET-011 расширяет popup кнопкой «Скачать GPX» и добавляет новый эндпоинт
+`GET /api/gps-tracks/{track_id}/download`, который собирает GPX 1.1 из
+геометрии трека (WKB LineString в `tracks.geom`) и отдаёт файл с
+правильным `Content-Disposition` и UTF-8 именем по RFC 5987.
+
+Существующие активы, которые переиспользуем:
+
+- `src/api/gps_tracks/mvt.py::_wkb_to_coords()` — парсинг WKB LineString
+ в `[[lon, lat], ...]` (см. `endpoint.py:55–57`, уже используется в
+ GeoJSON-эндпоинте).
+- `src/api/gps_tracks/db.py::open_db/init_db` — открытие БД, спрайт уже
+ используется во всех роутах.
+- `src/web/app.js::downloadGPX()` (l. 1236–1249) — рабочий
+ desktop+iOS-mobile паттерн `Blob + URL.createObjectURL + a.download`.
+ Используется для скачивания **построенного** маршрута; для
+ публичного трека механика та же, но содержимое приходит с сервера.
+- `showToast(...)` (используется по всему `gps_tracks.js`) — UX для
+ ошибок.
+
+## Альтернативы и решения
+
+### Решение A — Транспорт от backend до файла на диске пользователя
+
+| Опция | Плюсы | Минусы |
+|---|---|---|
+| A1: `` — браузер сам качает | Один клик, нулевая JS-логика | Невозможно перехватить статус 4xx/5xx и показать toast (REQ-F-05 — обязателен); ошибочный JSON отрисуется в новой вкладке |
+| A2 (**выбрано**): `fetch()` → `response.blob()` → `URL.createObjectURL` → `` → `click()` → `revokeObjectURL` | Можно проверить статус и заголовки; toast при ошибке; реальный размер для UI; единый паттерн с `app.js::downloadGPX()` уже в проде | Чуть больше JS-кода; нужно прочесть `Content-Disposition` из ответа |
+| A3: ServiceWorker-перехват | Универсальный, контроль над прогресс-баром | Overkill: ET-008 без SW, добавлять ради одной кнопки — лишняя зависимость и риск (PH-9 PWA — отдельная фаза) |
+
+**Обоснование A2.** REQ-F-05 фиксирует обязательную обработку 403/404/5xx
+через `showToast` — это требует чтения HTTP-статуса. Без `fetch` это
+невозможно. Тот же `fetch+Blob` паттерн уже работает в `downloadGPX()`
+для построенного маршрута на iOS Safari, Android Chrome и desktop — то
+есть R-1 в BRD (iOS Safari `Content-Disposition`) уже митигирован
+через `a.download` от blob-URL.
+
+Имя файла на клиенте читается из `Content-Disposition` заголовка
+(`filename*=UTF-8''`). При наличии расширенного
+параметра — декодируем и используем; иначе fallback к ASCII `filename=`.
+Если оба отсутствуют (defensive) — `track-.gpx`. Парсер хедера —
+тривиальная regex на клиенте (~10 строк).
+
+### Решение B — Backend: как собирать GPX
+
+| Опция | Плюсы | Минусы |
+|---|---|---|
+| B1 (**выбрано**): `xml.etree.ElementTree` (stdlib) | Корректное XML-экранирование атрибутов и текста (защита от багов с `<`, `&`, `"` в `tracks.name`); без новых зависимостей; небольшие GPX в 50k точек собираются за ≤ 100 ms | Сериализация в строку через `tostring(root, encoding="unicode")` — один проход; в стрессе ≥ 200k уже cap-обрезано REQ-NF-02 |
+| B2: `lxml.etree` | Чуть быстрее (~1.5×); встроенная XSD-валидация | Новая транзитивная зависимость в runtime-образе; собранный XML тот же; для теста XSD-валидации `lxml` всё равно понадобится — но **только** в `tests/`, не в runtime |
+| B3: f-string шаблоны | Простота, копирует паттерн `app.js::generateGPX()` | Ручное XML-экранирование (`&`, `<` в названии трека) — типичный источник CVE; для UTF-8 имён почти всегда работает, но один спецсимвол — broken XML и провал AC-5 |
+
+**Обоснование B1.** Стандартная библиотека Python 3.12 содержит
+`xml.etree.ElementTree` (для **сборки** доверенного XML, не для парсинга
+input'а). Корректно экранирует `&`, `<`, `>`, `"` в текстовых узлах и
+атрибутах. Тест UT-03 валидирует результат против `gpx.xsd` через
+`lxml.etree.XMLSchema` — `lxml` добавляется **только** в test-deps
+(`requirements-dev.txt`), runtime-образ не растёт.
+
+Для **парсинга** внешних GPX (collector в ET-008) используется
+`defusedxml.ElementTree` (защита от XXE/billion-laughs); тут парсинг
+не нужен — мы только генерируем.
+
+### Решение C — In-memory ответ vs StreamingResponse
+
+| Опция | Плюсы | Минусы |
+|---|---|---|
+| C1 (**выбрано**): `Response(content=xml_str, media_type=..., headers=...)` | Простота; gzip из starlette middleware (если включён) работает сразу; для 50k точек XML ~5 МБ — нагрузка нормальная | Весь XML в памяти worker'а; при 200k точек (cap REQ-NF-02) ≈ 20 МБ на 1 запрос |
+| C2: `StreamingResponse` через генератор по `trkpt` | Меньше памяти на пик; first-byte быстрее | Сложнее правильно поставить `Content-Disposition`, `Content-Length` неизвестен (gzip-middleware всё равно стримит); REQ-NF-01 = 300 ms p95 у нас и так с запасом |
+
+**Обоснование C1.** Cap REQ-NF-02 (200k точек → 413) ограничивает
+память по одному запросу до ~20 МБ XML. Параллельные скачивания на
+test-сервере (1 worker uvicorn в проекте, реально 2–4 во время нагрузки)
+дадут пик ≤ 80 МБ — это меньше, чем уже использует MVT-кэш ET-008 в
+норме. Стриминг сэкономит ~50 ms first-byte, что несущественно для
+файла-скачивания (browser показывает прогресс в downloads, а не на
+странице).
+
+### Решение D — Поведение popup после клика
+
+| Опция | Плюсы | Минусы |
+|---|---|---|
+| D1 (**выбрано**): popup остаётся открытым после клика | Пользователь видит результат (toast / индикатор); консистентно с тем, что popup в проекте закрывается только по клику вне popup или повторному клику в карту (см. `closeOnClick: true` в `gps_tracks.js:528`) | Если пользователь хочет скачать и сразу закрыть — нужен один лишний тап вне popup (привычный жест) |
+| D2: автозакрытие сразу при клике | Чище визуально | Toast об ошибке окажется без контекста («что я пытался скачать?») |
+
+**Обоснование D1.** Согласуется с REQ-F-05.1 рекомендацией («не
+закрывать»). Если запрос > 200 ms — на кнопке появляется CSS-класс
+`.is-loading` (визуальный spinner через `::after` псевдоэлемент в CSS,
+без новых SVG). При успехе класс снимается, toast — опционально
+(скачивание визуально само себя анонсирует через download-bar браузера).
+
+### Решение E — Где живёт код сборки GPX
+
+| Опция | Плюсы | Минусы |
+|---|---|---|
+| E1 (**выбрано**): новый модуль `src/api/gps_tracks/export.py` | Единая ответственность; легко тестируется в unit; не загромождает `endpoint.py` (роутер уже 311 строк) | Один новый файл (минимальная цена) |
+| E2: функция в `endpoint.py` | Совсем рядом с route | Раздувает endpoint-модуль; затрудняет повторное использование (например, для будущего bulk-export через `gps-collector` CLI) |
+| E3: функция в `db.py` | DB и export — концептуально связаны | DB-модуль становится дом всему — нарушение single-responsibility |
+
+**Обоснование E1.** В `export.py` живут две публичные функции:
+- `build_gpx(track_row, sources, external_urls) -> str` — собирает XML.
+- `safe_filename(name: str | None, track_id: int) -> tuple[str, str]` —
+ возвращает `(ascii_fallback, utf8_for_filename_star)`.
+
+Обе чистые, без I/O — легко тестируются.
+
+### Решение F — Sanitization имени файла
+
+Один проход:
+1. Если `name` пустой / None — заменить на `track-`.
+2. Заменить `/ \ : * ? " < > |` на `_`.
+3. Заменить `\x00..\x1f` (управляющие) и `\x7f` на `_`.
+4. Триммить пробелы и точки в начале/конце (Windows-нюанс).
+5. Триммить до 80 символов по **байтам в UTF-8** (не code-point — чтобы
+ `filename*` не превысил RFC-предел в 254 байта на параметр).
+6. Если результат пуст после санитизации — `track-`.
+7. ASCII-fallback: транслит **не делаем** (BRD §A2), вместо этого —
+ keep ASCII-printable (`32–126`), остальное в `_`; если пустота —
+ `track-`.
+8. Кодирование UTF-8 для `filename*`: `urllib.parse.quote(name,
+ safe='', encoding='utf-8')`.
+
+Возврат: `(ascii_fallback="…", utf8_quoted="…")`. Сборка хедера:
+
+```
+Content-Disposition: attachment; filename="{ascii}.gpx"; filename*=UTF-8''{utf8_quoted}.gpx
+```
+
+Расширение `.gpx` (или `.kml` в Q-2-future) **не** санитизируется, но
+не входит в счётчик 80 байт.
+
+### Решение G — Структура GPX 1.1
+
+См. TRZ REQ-F-03 — следуем буквально. Тонкости, которые архитектор
+фиксирует:
+
+- `