# identity Specification

## Purpose

Как система идентифицирует сущности домена: ULID-ключи (канонический
lowercase-вид, нормализация и валидация на входных границах) и корреляция
сущностей в логах по id. Инфохэши загрузки (`download_infohash`),
дедупликация приёма и инвариант «не более одной активной загрузки на
infohash» (атомарный возврат в активное состояние) — в capability `ingest`.
## Requirements
### Requirement: ULID как первичный ключ сущностей

Каждая сущность домена SHALL иметь первичный ключ ULID — TEXT, 26 символов
Crockford base32, генерируемый приложением в момент создания записи через
единственную точку генерации (`internal/ident`). Сущности: `download`,
`recognition`, `hint`, `override`, `metadata_candidate`, `file_link`.
Канонический вид SHALL быть lowercase. Числовые AUTOINCREMENT-ключи в новых таблицах
использоваться SHALL NOT. Идентификатор партии раскладки (`apply_batch_id`)
SHALL генерироваться тем же способом.

#### Scenario: Создание загрузки

- **WHEN** принимается новая загрузка
- **THEN** её `id` — валидный ULID в lowercase
- **AND** `id` уникален глобально (не совпадает с id других сущностей)

#### Scenario: Хронологическая сортировка

- **GIVEN** две загрузки, созданные последовательно
- **WHEN** записи сортируются по `id` лексикографически
- **THEN** порядок совпадает с порядком создания

### Requirement: Нормализация и валидация id на входных границах

Внешние идентификаторы SHALL валидироваться как ULID и нормализоваться к
lowercase до обращения к хранилищу — это касается всех входных границ:
URL `/download/{id}`, параметры форм и команд. Синтаксически невалидный id SHALL обрабатываться как
несуществующая сущность (404 для страниц), без обращения к БД.

#### Scenario: Uppercase-вариант id в URL

- **GIVEN** существующая загрузка с id `01jz…` (lowercase)
- **WHEN** клиент открывает `/download/01JZ…` (uppercase)
- **THEN** открывается страница той же загрузки

#### Scenario: Мусор вместо id

- **WHEN** клиент открывает `/download/abc!!!`
- **THEN** ответ — 404, запрос к БД не выполняется

### Requirement: Корреляция сущностей в логах

Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте
`<entity>_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в
контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря
глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить
все записи журнала, относящиеся к сущности, независимо от имени поля.

Scoped-логгер SHALL передаваться через `context`, а не доклеиваться к каждой
записи руками. Отсюда обязанность вызывающего, и она ограничена наблюдаемым
исходом: **операция, работающая в контексте загрузки и делающая вызов внешнего
сервиса, SHALL положить scoped-логгер этой загрузки в `context` до такого
вызова** — включая команды, пришедшие с транспорта, а не только фоновый цикл
воркера. Однородность формы у команд, внешних вызовов не делающих, это
требование не нормирует: она принадлежит конвенциям кода.

Причина в том, что клиент внешнего сервиса своей доменной сущности не знает и
знать SHALL NOT — он берёт логгер из `context`. Поэтому вызов внешнего сервиса в
контексте загрузки SHALL давать запись с `download_id` и, когда он известен,
`infohash`; добавлять клиенту поля-дубликаты доменных идентификаторов ради этого
SHALL NOT — источник корреляции один.

Перечень клиентов, ведущих записи о внешних вызовах, живёт в
`docs/conventions/logging.md` и здесь не дублируется. Telegram-клиент таких
записей не ведёт, и уведомление отправляется вне контекста загрузки намеренно
(иначе оно умирало бы вместе с тиком) — это требование его не касается.

Отдельно это важно там, где внешний сервис не сообщает причину отказа: ответ
qBittorrent `Fails.` на добавление раздачи причины не несёт, и единственное, что
делает такую запись пригодной для разбора, — корреляция с загрузкой.

#### Scenario: Путь загрузки по логам

- **GIVEN** загрузка прошла приём, распознавание и раскладку
- **WHEN** журнал фильтруется по значению её `id`
- **THEN** находятся записи всех этапов (ingest, recognition, file-layout)

#### Scenario: Неуспешное добавление в qBittorrent с пути retry

- **GIVEN** загрузка в `failed` с известным инфохэшем, для которой оператор
  запросил retry
- **WHEN** qBittorrent отвечает на добавление отказом (`Fails.` либо не-200)
- **THEN** запись о вызове внешнего сервиса содержит `download_id` и `infohash`
  загрузки
- **AND** запись находится тем же фильтром по значению id, что и записи
  фонового пути добавления

### Requirement: Миграция существующих записей

Существующие записи SHALL получить ULID-идентификаторы одной миграцией с
сохранением всех связей (FK) и хронологии: timestamp-часть ULID SHALL
браться из `created_at` записи, чтобы лексикографический порядок новых id
соответствовал историческому порядку создания. Существующий
`download.infohash` SHALL быть перенесён в `download_infohash`
(нормализация к lowercase, `kind` по длине hex: 40 — `v1`, 64 — `v2`);
столбцы `download.infohash` и `download.idempotency_key` SHALL быть удалены.

#### Scenario: Связи и порядок после миграции

- **GIVEN** БД с загрузками, распознаваниями и файловыми ссылками на
  числовых id
- **WHEN** миграция выполнена
- **THEN** все FK-связи сохранены (распознавания/ссылки указывают на те же
  загрузки)
- **AND** порядок загрузок по `id` совпадает с порядком по `created_at`
- **AND** каждый прежний `infohash` представлен записью в
  `download_infohash`

