---
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: `<a href="/api/.../download" download="...">` — браузер сам качает | Один клик, нулевая JS-логика | Невозможно перехватить статус 4xx/5xx и показать toast (REQ-F-05 — обязателен); ошибочный JSON отрисуется в новой вкладке |
| A2 (**выбрано**): `fetch()` → `response.blob()` → `URL.createObjectURL` → `<a download="...">` → `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''<percent-encoded>`). При наличии расширенного
параметра — декодируем и используем; иначе fallback к ASCII `filename=`.
Если оба отсутствуют (defensive) — `track-<id>.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-экранирование (`&amp;`, `&lt;` в названии трека) — типичный источник 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-<id>`.
2. Заменить `/ \ : * ? " < > |` на `_`.
3. Заменить `\x00..\x1f` (управляющие) и `\x7f` на `_`.
4. Триммить пробелы и точки в начале/конце (Windows-нюанс).
5. Триммить до 80 символов по **байтам в UTF-8** (не code-point — чтобы
   `filename*` не превысил RFC-предел в 254 байта на параметр).
6. Если результат пуст после санитизации — `track-<id>`.
7. ASCII-fallback: транслит **не делаем** (BRD §A2), вместо этого —
   keep ASCII-printable (`32–126`), остальное в `_`; если пустота —
   `track-<id>`.
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 — следуем буквально. Тонкости, которые архитектор
фиксирует:

- `<metadata><time>` — формат `YYYY-MM-DDTHH:MM:SSZ` (UTC, ISO-8601 c
  `Z`). Если `tracks.created_at` в БД хранится с offset — нормализуем в
  UTC. Если NULL — элемент пропускается.
- `<trk><name>` — `tracks.name` или `"Без названия"` (REQ-F-03 уже
  предписывает).
- `<trk><type>` — `tracks.activity_type` буквально (`"enduro"`,
  `"moto"`, `"bicycle"`, `"hike"`, `"offroad"`, `"other"`). GPX-схема
  это допускает (свободный текст).
- Координаты в `<trkpt>` — формат `lat="%.6f" lon="%.6f"` (точность
  ≈ 0.11 м, достаточно для эндуро-навигации; экономит ~30% размера vs
  default Python float repr).
- `<copyright>` — только для OSM (license URL фиксирован
  `https://www.openstreetmap.org/copyright`). Для остальных источников
  — `<copyright author="Enduro Trails"><license>{external_urls[0]}</license></copyright>`
  если есть первый URL, иначе блок опускаем. Это сохраняет атрибуцию
  даже когда `download_allowed: true` для не-OSM источника (см.
  ADR-015).
- Корневой `<gpx>` без `<wpt>`, без `<rte>` — только `<metadata>` и
  один `<trk>`.

### Решение H — Запрос к БД

Один SQL-запрос на эндпоинт:

```sql
SELECT id, name, description, activity_type, user, created_at,
       length_m, points_count, geom, sources_json, external_urls_json
FROM tracks WHERE id = ?
```

Проверки в порядке:
1. `row is None` → 404 `{"detail": "track_not_found"}`.
2. `format not in {"gpx"}` → 400 `{"detail": "unsupported_format"}`.
3. `row.points_count > 200000` → 413 `{"detail": "track_too_large"}`.
4. License-check (ADR-015): первый разрешённый source ⇒ pass; иначе
   403 `{"detail": "source_forbidden", "external_urls": [...]}`.
5. `coords = _wkb_to_coords(geom)` — переиспользуем из `mvt.py`.
6. `build_gpx(...)` → 200.

Шаг 3 раньше шага 5 — отказываем без чтения geom (защита от patho).
Шаг 4 раньше шага 5 — отказываем без сборки XML (экономия CPU).

### Решение I — Где регистрируется route

Внутри `create_gps_router(db_path)` в `endpoint.py`, рядом с
существующими `@router.get(...)`. Декоратор: `@router.get("/{track_id}/download")`.

`track_id: int = Path(..., ge=1)` — встроенная FastAPI-валидация
защищает от path-traversal и SQL-инъекций (REQ-NF-07).

### Решение J — Логирование (REQ-F-07)

Используем стандартный `logging.getLogger("uvicorn.access")` — отдельный
формат не вводим. Перед `return Response(...)` добавляем:

```python
logger.info(
    "track_download id=%d sources=%s size_bytes=%d",
    track_id, sources_csv, len(xml_bytes),
)
```

IP клиента не пишем (это уже в uvicorn access-log). Это минимальный
журнал для REQ-NF-06 без отдельной таблицы / без файла.

## Решение

### 1. Новый модуль `src/api/gps_tracks/export.py`

Публичный API:

```python
def build_gpx(
    *,
    track_id: int,
    name: str | None,
    description: str | None,
    activity_type: str | None,
    user: str | None,
    created_at: str | None,
    sources: list[str],
    external_urls: list[str],
    coords: list[tuple[float, float]],  # (lon, lat)
) -> str:
    """Собирает GPX 1.1 как XML-строку (с XML-declaration)."""

def safe_filename(name: str | None, track_id: int) -> tuple[str, str]:
    """Возвращает (ascii_fallback, utf8_percent_quoted) без расширения."""
```

Реализация — на `xml.etree.ElementTree` (stdlib).

### 2. Новый route в `endpoint.py::create_gps_router`

```python
ALLOWED_FORMATS = {"gpx"}  # KML отложено (BRD Q-2)
MAX_POINTS_FOR_DOWNLOAD = 200_000  # REQ-NF-02

@router.get("/{track_id}/download")
async def download_track(
    track_id: int = Path(..., ge=1),
    format: str = Query("gpx"),
):
    if format not in ALLOWED_FORMATS:
        raise HTTPException(400, "unsupported_format")
    # ... SELECT, проверки 404/413/403, build_gpx, Response
```

`Path` и `Query` импортируются дополнительно из `fastapi`.

### 3. Изменения в `src/web/gps_tracks.js`

a. `_renderTrackPopupHtml(props)` — добавить в конец template, **перед**
   `sourcesHtml`, блок `actionsHtml`:

```html
<div class="track-popup-actions">
  <button type="button"
          class="track-popup-download-btn"
          aria-label="Скачать GPX"
          title="Скачать GPX"
          data-track-id="${props.id}">
    <svg …><!-- тот же icon-set, что и в sheet-route::downloadGPX --></svg>
  </button>
</div>
```

SVG-иконка — точная копия из `index.html:135–137` (download-arrow).

b. Обработчик клика делегируется на popup-контейнер (event-delegation):

```js
new maplibregl.Popup({…})
  .setLngLat(e.lngLat)
  .setHTML(_renderTrackPopupHtml(feature.properties))
  .addTo(map);

// после .addTo: получить .getElement(), повесить click-listener.
```

Внутри listener'а:

```js
async function _downloadPublicTrack(trackId, btnEl) {
  btnEl.classList.add('is-loading');
  try {
    const resp = await fetch(`/api/gps-tracks/${trackId}/download`);
    if (!resp.ok) {
      const body = await resp.json().catch(() => ({}));
      _handleDownloadError(resp.status, body);
      return;
    }
    const blob = await resp.blob();
    const filename = _parseFilenameFromCD(resp.headers.get('Content-Disposition'))
                     || `track-${trackId}.gpx`;
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = filename; a.click();
    URL.revokeObjectURL(url);
  } catch (err) {
    if (typeof showToast === 'function') showToast('Не удалось скачать. Попробуйте ещё раз.');
  } finally {
    btnEl.classList.remove('is-loading');
  }
}
```

`_parseFilenameFromCD(cd)`:
- читаем `filename*=UTF-8''<percent-encoded>` → `decodeURIComponent`;
- если нет — `filename="…"`;
- если нет — `null`.

`_handleDownloadError(status, body)`:
- 403 → toast «Источник запрещает скачивание. Откройте трек на сайте источника.» + если `body.external_urls?.length` — `window.open(...)` по нажатию на toast (опционально, как ссылка в самом toast'е).
- 404 → toast «Трек не найден.»
- 413 → toast «Трек слишком большой для скачивания.»
- иначе → «Не удалось скачать. Попробуйте ещё раз.»

c. CSS (в `app.css`) — стиль кнопки.

```css
.track-popup-actions { margin-top: 8px; display: flex; gap: 8px; }
.track-popup-download-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px;   /* REQ-NF-04: ≥ 32×32 CSS px */
  border: none; border-radius: 6px; cursor: pointer;
  background: var(--accent, #3b82f6); color: #fff;
}
.track-popup-download-btn svg { width: 18px; height: 18px; }
.track-popup-download-btn.is-loading { opacity: 0.6; pointer-events: none; }
/* тёмная тема — переменные --accent уже определены в стилях ET-005/PH-5 */
```

Точные цвета определит builder с оглядкой на текущую палитру —
ADR не фиксирует hex.

### 4. Конвенция размещения нового кода

| Файл | Действие | Размер |
|---|---|---|
| `src/api/gps_tracks/export.py` | **новый** | ≈ 130 строк |
| `src/api/gps_tracks/endpoint.py` | +1 route ≈ 50 строк | без рефакторинга существующего |
| `src/web/gps_tracks.js` | +1 функция `_downloadPublicTrack`, +1 helper `_parseFilenameFromCD`, +1 helper `_handleDownloadError`, правка `_renderTrackPopupHtml` (+10 строк HTML), правка `_setupGpsClickHandler` (event-delegation, +10 строк) | ≈ 80 строк |
| `src/web/app.css` | +CSS-блок `.track-popup-actions`, `.track-popup-download-btn`, `.is-loading` | ≈ 15 строк |
| `tests/api/test_gps_tracks_gpx_builder.py` | **новый** — UT-01..05 | ≈ 200 строк |
| `tests/api/test_gps_tracks_filename.py` | **новый** — UT-04 cases | ≈ 80 строк |
| `tests/api/test_gps_tracks_download.py` | **новый** — IT-01..08 | ≈ 250 строк |
| `tests/fixtures/gpx-1.1/gpx.xsd` | **новый** — XSD-схема topografix (~30 КБ) | one-shot file |
| `tests/web/test_track_download.spec.ts` | **новый** — E2E-01..04 | ≈ 200 строк |

### 5. Зависимости

- Runtime: **без изменений**. `xml.etree.ElementTree`, `urllib.parse`
  — stdlib Python 3.12.
- Test-only: добавить `lxml` в `requirements-dev.txt` для XSD-валидации
  (если ещё не присутствует через транзитивные).

### 6. Контракт API

Новый эндпоинт:

```
GET /api/gps-tracks/{track_id}/download[?format=gpx]
```

| Статус | Body | Headers |
|---|---|---|
| 200 | XML (GPX 1.1) | `Content-Type: application/gpx+xml; charset=utf-8`<br>`Content-Disposition: attachment; filename="…"; filename*=UTF-8''…`<br>`Access-Control-Allow-Origin: *` (наследуется из CORS middleware) |
| 400 | `{"detail": "unsupported_format"}` | стандартные |
| 403 | `{"detail": "source_forbidden", "external_urls": [...]}` | стандартные |
| 404 | `{"detail": "track_not_found"}` | стандартные |
| 413 | `{"detail": "track_too_large"}` | стандартные |
| 500 | `{"detail": "internal_error"}` | стандартные |

`Cache-Control: private, max-age=3600` — позволяет браузеру держать
файл в кэше час (treki иммутабельны до следующего pipeline-прогона).
ETag не выставляем (overkill).

### 7. Связь с ADR-015

ADR-015 фиксирует **политику разрешений** на скачивание по источнику
(per-source флаг `download_allowed`). ADR-014 использует эту политику
как точку проверки 403. Разделение: «как качаем» (ADR-014) vs «что
качать вообще можно» (ADR-015).

## Последствия

### Положительные

- **Нулевые новые runtime-зависимости** — stdlib хватает на сборку GPX
  и парсинг Content-Disposition.
- **Переиспользование** проверенного клиентского паттерна
  (`Blob+URL.createObjectURL+a.download`) — iOS Safari проблема R-1 в
  BRD уже de facto митигирована тем же кодом в `app.js::downloadGPX()`.
- **Унификация error-UX** через `showToast` — пользователь видит
  человекочитаемые сообщения для 403/404/413/5xx.
- **Чистая модульность** — `export.py` тестируется unit-ами без БД и
  без HTTP-моков; всё, что осталось — integration-тест endpoint'а.
- **Защита от patho-кейсов** — два уровня (cap REQ-NF-02 на 200k +
  валидация `format`-whitelist).
- **Соответствие схеме GPX 1.1** — гарантировано тестом UT-03 и IT-07
  через `lxml.etree.XMLSchema`.

### Отрицательные / ограничения

- **`lxml` в dev-deps** — небольшая (~3 МБ) транзитивная зависимость,
  только для XSD-валидации в тестах. Если избегать любых новых
  dev-deps — можно валидировать через subprocess `xmllint --schema`,
  но это вводит C-зависимость в CI-image. `lxml` через pip проще.
- **In-memory сборка** — для патологического 200k трека (≈ 20 МБ XML)
  один запрос — 20 МБ heap. На текущем железе test-сервера (1 ГБ RAM
  свободно у контейнера) — норма; 4 параллельных запроса = 80 МБ, не
  блокирует. Если когда-нибудь cap REQ-NF-02 поднимется выше 200k —
  переключаемся на C2 (StreamingResponse).
- **Не поддерживаем `<ele>` и `<time>` в точках** — это пожелание BRD
  A2; высоты не лежат в БД (одно из ограничений ET-008). При запросе
  пользователя «верните высоту» — нужен отдельный work item на
  обогащение точек через terrain DEM (out of scope ET-011).
- **Кнопка «Скачать» появляется во всех popup**, в том числе для
  треков, для которых backend отдаст 403 (Wikiloc/EnduroRussia/ttrails
  при дефолтной политике ADR-015). Альтернатива «прятать кнопку для
  запрещённых источников» требует знать `download_allowed` на клиенте —
  значит расширять `/health` или MVT-properties. Решение: оставляем
  кнопку всегда видимой, ошибку 403 показываем через toast с CTA «открыть
  на сайте источника». Это **сознательный** компромисс UX vs объём
  изменений: предотвращает запрос на расширение MVT-контракта; не
  фрустрирует пользователя из-за «непредсказуемо скрытой» кнопки.

### Нейтральные

- Регистрация route в `create_gps_router` не пересекается с
  существующими (`""`, `/tiles/{z}/{x}/{y}.mvt`, `/health`,
  `/cache/clear`). Конфликта префиксов нет.
- CORS — без изменений (middleware приложения уже отдаёт
  `Access-Control-Allow-Origin: *` для всего /api/).
- gzip — если включён `GZipMiddleware` (проверить в `src/api/main.py`
  или `app.py`), GPX-ответы сжимаются автоматически. Если не включён —
  не вводим (out of scope; build-output 800 КБ для типичного трека —
  ок без gzip).

## Классификация изменения

**Minor change.** Никаких новых сервисов, БД, портов, схем; добавляется
один эндпоинт в существующий router + один frontend-обработчик.

Лейбл `arch:major-change` **не выставляется**.

## Невыполнимость / эскалация

- **Q-2** (KML): отложено (BRD дефолт). Если Owner запросит KML — это
  новый ADR-update, расширение `ALLOWED_FORMATS` и нового
  `build_kml(...)`. Архитектурный риск ноль (контракт `format`-query
  уже whitelist).
- **R-1** (iOS Safari download): де факто митигирован переиспользованием
  паттерна `downloadGPX()`. Если в проде обнаружится регресс —
  возвращаемся в Build через `back-to:build`, добавляем fallback
  `window.location.href = url` (старый паттерн), но без revoke. Это не
  меняет ADR.
- **Q-1** (license whitelist): закрывается ADR-015. Если Owner закроет
  Q-1 как «всё разрешено» — ADR-015 переводится в `superseded`, REQ-F-06
  no-op, AC-11/IT-05/E2E-04 — out.

## Связанные документы

- `docs/work-items/ET-011/01-brd.md` §1–10
- `docs/work-items/ET-011/02-trz.md` REQ-F-01..F-07, REQ-NF-01..NF-07
- `docs/work-items/ET-011/03-acceptance-criteria.md` AC-1..AC-15
- `docs/work-items/ET-011/04-test-plan.yaml` UT-01..05, IT-01..08, E2E-01..04
- `docs/work-items/ET-011/06-adr/ADR-015-source-redistribution-policy.md` (этот пакет)
- `docs/work-items/ET-011/07-infra-requirements.md` (этот work item)
- `docs/work-items/ET-011/08-data-requirements.md` (этот work item)
- `docs/work-items/ET-011/10-tech-risks.md` (этот work item)
- `docs/work-items/ET-008/06-adr/ADR-005-storage-schema.md` — схема
  `tracks` (read-only)
- `docs/work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md` —
  существующий контракт API
- `docs/architecture/README.md` (обновлён в ET-011)
- `docs/architecture/adr/README.md` (обновлён в ET-011)
