---
type: adr
work_item_id: ET-011
adr_id: ADR-015
title: "ADR-015: Политика реэкспорта публичных треков — per-source флаг `download_allowed` в `gps_sources.yaml`, default-deny"
status: accepted
created_at: 2026-06-03
updated_at: 2026-06-03
authors:
  - "agent:architect"
supersedes: []
superseded_by: []
labels:
  - "ET-011:licensing"
  - "minor-change"
---

# ADR-015 — Политика реэкспорта публичных треков на скачивание

## Статус

**Accepted.** Архитектурное решение для ET-011. Закрывает BRD §9 Q-1
по дефолту «только OSM».

## Контекст

ET-008 разрешает **собирать** публичные треки в БД по licensing-ADR
каждого источника (ADR-009..012). Эти ADR описывают **что разрешено
сохранять** в БД и при каких условиях (обезличенно / без `description`
/ rate-limit / атрибуция). Решение «отдавать ли собранный трек на
скачивание» — **отдельное** юридическое решение:

- **OSM ODbL** — явно разрешает реэкспорт при условии атрибуции и
  same-license (ODbL); GPX-файл с `<copyright>...openstreetmap.org/copyright</copyright>`
  удовлетворяет условиям (ADR-009 §4).
- **EnduroRussia.ru** — публичный API, нет явных условий на реэкспорт;
  условие ADR-010 — обезличенно. Реэкспорт чужого контента третьим
  лицам без явного разрешения публикатора — серая зона; default-deny
  безопаснее.
- **Wikiloc** — proprietary, ToS запрещает массовый ре-экспорт; ADR-012
  разрешает только **некоммерческое тестовое** хранение в нашей БД.
  Отдача файла downstream — нарушение ToS.
- **ttrails.ru** — `proposed` (заблокирован) в ADR-011; не собирается
  и не отдаётся.

BRD §9 Q-1 — открытый вопрос; **дефолт BRD = «только OSM»**, что
формально и есть default-deny с whitelist'ом `["osm"]`.

ET-008 и ET-009 фиксируют licensing-policy **на collection-stage**.
Этот ADR-015 фиксирует **отдельную** licensing-policy на
**redistribution-stage**. Они независимы: трек может быть в БД (collect
разрешено), но не отдаваться по download (redistribute запрещено).

## Альтернативы и решения

### Решение A — Где живёт флаг

| Опция | Плюсы | Минусы |
|---|---|---|
| A1: hardcode `ALLOWED_SOURCES = ["osm"]` в `endpoint.py` | Минимум изменений; защищено от случайной правки конфига | Любое расширение списка требует деплоя; ops не может выключить «на горячо» |
| A2 (**выбрано**): per-source поле `download_allowed: bool` в `config/gps_sources.yaml` | Конфигурируемо без релиза; согласовано с уже существующим паттерном (поля `enabled`, `license_adr`, `attribution`); видно рядом с источником | Чуть больше кода для чтения конфига в API-роутере (раньше API не читал `gps_sources.yaml`) |
| A3: новое поле в license-ADR front-matter (`redistribution: allowed/forbidden`) | Лежит рядом с юридическим основанием решения | API-роутер тогда читает ADR-файлы на каждый запрос (медленно) или нужен кэш; затрудняет тестовую подмену; нарушает разделение «runtime config vs документация» |

**Обоснование A2.** Этот же файл уже читается pipeline-сервисом
`gps-collector` (`config.py::load_gps_sources`). Расширяем его одним
полем `download_allowed: bool` (default `false` если поле отсутствует
— default-deny). API-роутер при старте читает `gps_sources.yaml` один
раз и держит `ALLOWED_SOURCES: set[str]` в памяти; rebuild при
рестарте контейнера (тот же подход, что и для MVT-кэша).

Парсер конфига в `src/api/gps_tracks/config.py` уже есть (ET-008). Его
схема расширяется одним optional-полем.

### Решение B — Семантика разрешения для трека с несколькими источниками

Один трек может иметь `sources_json = ["osm", "wikiloc"]` после dedup-
merge (ADR-006 ET-008). Возможные правила:

| Правило | Плюсы | Минусы |
|---|---|---|
| B1 (**выбрано**): **ANY** — хотя бы один разрешённый source ⇒ download разрешён | Меньше ложных 403 для треков, существующих в нескольких источниках; OSM — авторитетный «первичный» исходник; геометрия одна и та же | Метаданные (`<name>`, `<desc>`) могут быть взяты с merge'нутого priority-источника (например, EnduroRussia) — могут содержать proprietary текст |
| B2: **ALL** — все sources в whitelist | Гарантирует, что ни байт metadata из запрещённого источника не утекает | Резко сужает выборку: если OSM-трек дедупится с Wikiloc-треком, download выключается, хотя OSM-факт сам по себе ODbL |

**Обоснование B1.** Геометрия (точки) — общее достояние двух
publisher'ов; если хотя бы один разрешил реэкспорт — отдаём. Чтобы
избежать «утечки» metadata из proprietary источника, **в момент сборки
GPX** ADR-014 §G предписывает:
- `<copyright>` фиксируется на OSM-license при `"osm" ∈ sources`;
- иначе `<copyright>` опускаем.
- `<link>` оставляем для **всех** `external_urls` — это **атрибуция**,
  даже на proprietary платформу (open в браузере по клику).

`<name>` / `<desc>` могут быть от не-OSM источника. Это компромисс:
название трека = «creative work» ниже порога копирайт-защиты в РФ
(краткие фразы), но осторожно — описание (`description`) может быть
длинным текстом. Митигация в ADR-014: для треков, где `"osm" ∉ sources`
**и** есть merge от других источников, в `<desc>` пишется только
`description` от OSM (если есть) или ничего; никогда — от Wikiloc/
EnduroRussia. Это требует дополнительной фильтрации в `build_gpx`:
поле `description` в `tracks` хранит merged-значение (priority-based),
без обратной связи с источником. Пока — упрощение: `description`
отдаём как есть, если хотя бы один source разрешён.

> **Уточнение** (closes potential review concern): если в Build-стадии
> окажется, что merged `description` действительно содержит proprietary
> текст (например, длинный отчёт с EnduroRussia), вернуть в Analysis для
> per-source-field tracking — это бóльшее изменение схемы БД и не
> в scope ET-011.

### Решение C — Дефолт нового поля при отсутствии в YAML

| Опция | Поведение |
|---|---|
| C1 (**выбрано**): отсутствует ⇒ `false` (deny) | Безопасно по умолчанию; защищает от случайного забывания при добавлении нового источника в будущем |
| C2: отсутствует ⇒ `true` | Удобство, но юридически рискованно: новый источник в `gps_sources.yaml` сразу выставляется на реэкспорт без отдельного review |

**Обоснование C1.** Pydantic-модель `GpsSourceConfig` в `config.py`
получает `download_allowed: bool = False`. Любое добавление нового
источника требует **явного** `download_allowed: true` + обновления
ADR-015 (или нового licensing-update ADR) с обоснованием.

### Решение D — Финальный whitelist для ET-011 (закрытие BRD Q-1)

Закрытие BRD §9 Q-1 по дефолту «только OSM»:

| Source | `download_allowed` | Обоснование |
|---|---|---|
| `osm` | **`true`** | ODbL разрешает реэкспорт при атрибуции; `<copyright>` ссылается на openstreetmap.org/copyright |
| `enduro_russia` | **`false`** | ADR-010 разрешает только collection (обезличенно); ToS платформы не содержит явного разрешения на ре-экспорт чужих треков |
| `wikiloc` | **`false`** | ADR-012 — proprietary, ToS запрещает массовый ре-экспорт; collection только для тестового non-commercial |
| `ttrails` | **`false`** | ADR-011 — proposed (blocked); поле для консистентности конфига |

В UI: для треков из 1+ запрещённых источников **без OSM** backend
вернёт 403 с `external_urls`. Frontend (ADR-014) покажет toast
«Источник запрещает скачивание. Откройте трек на сайте источника»
+ опциональную ссылку на первый `external_url`.

### Решение E — Если Owner закроет Q-1 как «всё разрешено»

Изменение **только** в `gps_sources.yaml`: выставить
`download_allowed: true` для трёх остальных источников + обновить
ADR-015 §«Решение D». Никаких изменений в коде, тестах или
архитектуре. Защищающая роль ADR — задокументировать **почему**
разрешено.

### Решение F — Где валидируется policy

В route-handler `download_track`, после 404-check и 413-check, перед
сборкой GPX:

```python
allowed_sources = router_state.allowed_sources  # set[str]
sources = json.loads(row["sources_json"] or "[]")
if not any(s in allowed_sources for s in sources):
    external_urls = json.loads(row["external_urls_json"] or "[]")
    raise HTTPException(
        status_code=403,
        detail={"detail": "source_forbidden", "external_urls": external_urls},
    )
```

`router_state.allowed_sources` инициализируется при создании router'а:

```python
def create_gps_router(db_path: str, sources_config_path: str | None = None) -> APIRouter:
    if sources_config_path:
        cfg = load_gps_sources(sources_config_path)
        allowed = {s.id for s in cfg.sources if s.download_allowed}
    else:
        allowed = {"osm"}  # safe-deny дефолт для unit-тестов
    ...
```

Подача `sources_config_path` — из `src/api/main.py` (или его аналога),
где уже монтируется `db_path`. Если конфиг недоступен на runtime
(test-fixture) — дефолт `{"osm"}` совпадает с production-выбором.

### Решение G — Контракт ответа 403

```json
{
  "detail": "source_forbidden",
  "external_urls": ["https://www.openstreetmap.org/way/123", ...]
}
```

Клиент может использовать `external_urls[0]` для CTA «Открыть на сайте
источника» в toast'е. Если массив пуст — просто текстовый toast.

### Решение H — Тестируемость

- **Unit (export.py)** — не зависят от политики; `build_gpx` чистая
  функция.
- **Integration** — фикстуры с `sources_config_path` указывают на
  тестовый YAML с разным набором whitelist'ов. Тест IT-05 (test-plan)
  проверяет 403 для `sources=["wikiloc"]`.
- **Test для CONFIG-парсера** — добавить кейсы в существующий
  `tests/api/test_gps_tracks_config.py` (или создать) — проверка дефолта
  `download_allowed=False` для записи без поля.

## Решение

### 1. Расширить `config/gps_sources.yaml`

```yaml
sources:
  - id: osm
    # ... существующие поля
    download_allowed: true                      # NEW (ET-011)

  - id: enduro_russia
    # ... существующие поля
    download_allowed: false                     # NEW (ET-011, default-deny)

  - id: wikiloc
    # ... существующие поля
    download_allowed: false                     # NEW (ET-011, default-deny)

  - id: ttrails
    # ... существующие поля
    download_allowed: false                     # NEW (ET-011, default-deny)
```

Поле опциональное в схеме (default `False`); для документации
явно прописано на всех четырёх источниках.

### 2. Расширить Pydantic-модель `GpsSourceConfig`

В `src/api/gps_tracks/config.py`:

```python
class GpsSourceConfig(BaseModel):
    id: str
    name: str
    enabled: bool
    license_adr: str
    # ...existing fields
    download_allowed: bool = False              # NEW (ET-011)
```

### 3. Передать конфиг в router

В `src/api/main.py` (точка сборки FastAPI-приложения, где уже
вызывается `create_gps_router(db_path)`) — добавить второй аргумент
`sources_config_path`:

```python
from src.api.gps_tracks.config import SOURCES_CONFIG_PATH
app.include_router(create_gps_router(GPS_DB_PATH, SOURCES_CONFIG_PATH))
```

Путь `SOURCES_CONFIG_PATH` уже определён в `config.py` ET-008 для
pipeline. Для unit-тестов — параметр опциональный (default {"osm"}).

### 4. Логика 403 в `download_track`

См. ADR-014 §H шаг 4. Реализация — 5 строк.

### 5. Frontend (ADR-014 §3.b)

`_handleDownloadError(403, body)` показывает:

```js
const url = body?.external_urls?.[0];
const msg = 'Источник запрещает скачивание.';
if (url && typeof showToast === 'function') {
  showToast(`${msg} Откройте трек на сайте источника: ${url}`);
  // builder может расширить showToast'ом, поддерживающим clickable link;
  // в минимальном варианте — текст в toast
} else if (typeof showToast === 'function') {
  showToast(msg);
}
```

### 6. Документация

- README архитектуры (`docs/architecture/README.md`) — короткая нота в
  §«Клиентский слой публичных треков»:
  > Скачивание GPX из popup трека (ET-011) разрешено только для
  > источников с `download_allowed: true` в `config/gps_sources.yaml`
  > (MVP: только `osm`). См. ADR-014 / ADR-015.
- `adr/README.md` — два новых ряда ADR-014 / ADR-015 в таблице индекса.

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

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

- **Default-deny** — добавление нового источника в будущем не открывает
  его на реэкспорт без явного решения.
- **Конфигурируемо без релиза** — ops может переключить флаг и
  перезапустить API (`docker compose up -d --no-deps app`, ≈ 5 сек
  простоя).
- **Разделение confidently distinct concerns**: collection-licensing
  (ADR-009..012) vs redistribution-licensing (ADR-015) — отдельные
  юридические основания фиксируются отдельными ADR.
- **Юридическая прозрачность** — ADR-015 явно перечисляет, **что
  разрешено** реэкспортировать и **на основании какого** условия
  licensing-ADR.
- **Тестируемость** — IT-05 / E2E-04 покрывают 403-путь.

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

- **UX-фрустрация** для треков из EnduroRussia / Wikiloc: пользователь
  видит кнопку, нажимает, получает toast. Митигация: чёткий текст
  с CTA на сайт источника; в release-notes — короткое объяснение, что
  «качаем пока только OSM-треки».
- **Treki от 1 не-OSM source с OSM-merge** проходят 403-чек (правило
  ANY), но в GPX попадает name/description от merged-priority-source.
  Это компромисс UX (см. Решение B); полное per-source-field tracking
  — отдельный work item на расширение схемы БД.
- **Конфиг-out-of-sync risk**: если в `gps_sources.yaml` забыли
  `download_allowed`, источник по умолчанию выключен на скачивание.
  Это **желаемое** поведение default-deny, но требует осознанности при
  добавлении новых источников.
- **API-роутер теперь читает `gps_sources.yaml` при старте** — новая
  зависимость на конфиг-файл. Если конфига нет на диске —
  fallback `{"osm"}` (см. Решение F). Логируется WARNING.

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

- БД не меняется. Скоринг dedup не меняется. Pipeline-collector не
  меняется. Не затрагивает PH-9 PWA (download-кнопка работает только
  online, как `app.js::downloadGPX` для маршрута).

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

**Minor change.** Один новый optional-поле в существующей конфиг-схеме
+ одна функция-проверка в API-роутере. Нет новых компонентов,
зависимостей, БД-схем.

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

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

- Если Owner ответит на BRD Q-1 как «разрешить всё» **до** merge'a
  ET-011 — править `gps_sources.yaml` (все `download_allowed: true`)
  + обновить ADR-015 §«Решение D»; IT-05 и E2E-04 отключить
  (`enabled_if: false`). Это **post-Architecture** правка без возврата
  в analysis.
- Если в Build обнаружится, что merged `description` действительно
  содержит proprietary текст из non-OSM источников и Owner это
  считает нарушением: `back-to:analysis` — расширение схемы БД на
  per-source поля.

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

- `docs/work-items/ET-011/01-brd.md` §6 R-4, §9 Q-1
- `docs/work-items/ET-011/02-trz.md` REQ-F-06
- `docs/work-items/ET-011/03-acceptance-criteria.md` AC-11
- `docs/work-items/ET-011/06-adr/ADR-014-gpx-download-endpoint.md` §G, §H
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (collection)
- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` (collection)
- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md` (collection, proposed)
- `docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md` (collection)
- `docs/work-items/ET-011/10-tech-risks.md` R-3, R-9 (этот work item)
- `docs/architecture/README.md` (обновлён в ET-011)
- `docs/architecture/adr/README.md` (обновлён в ET-011)
