# identity — идентичность сущностей домена Как система идентифицирует сущности: ULID-ключи и их канонический вид, множество инфохэшей загрузки, дедупликация приёма, корреляция в логах. ## ADDED 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 иметь одну или более записей инфохэша (`download_infohash`: `infohash` lowercase hex, `kind` ∈ `v1`|`v2`). При приёме magnet-ссылки SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и btih (v1), и btmh (v2); `kind` определяется по длине hex (40 — `v1`, 64 — `v2`). Когда qBittorrent сообщает для раздачи оба хеша (`infohash_v1`, `infohash_v2`), система SHALL дописывать недостающие записи загрузке; усечённый хеш v2-only раздачи (поле `hash` qBittorrent, 40 hex от v2) записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой (поллинг, discover) SHALL выполняться по любому из известных хешей. Один и тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный приём после терминального состояния), но активной из них MUST быть не более одной. #### Scenario: Гибридный торрент раскрывает оба хеша - **GIVEN** загрузка принята по magnet с v1-хешем - **WHEN** qBittorrent отдаёт раздачу с заполненными `infohash_v1` и `infohash_v2` - **THEN** у загрузки появляются обе записи (`kind` = `v1` и `v2`) #### Scenario: Сопоставление по v2-хешу - **GIVEN** загрузка с записями v1- и v2-хешей - **WHEN** поллинг находит раздачу, совпавшую только по v2-хешу - **THEN** раздача сопоставляется с этой загрузкой ### Requirement: Дедупликация приёма по любому из хешей При приёме система SHALL искать **активную** (нетерминальную) загрузку по любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой. Проверка активности и вставка новой загрузки с её хешами SHALL выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не более одной активной загрузки на infohash». Отдельного снимаемого/ восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность выводится только из `state`. #### Scenario: Повторный приём при активной загрузке - **GIVEN** активная загрузка с infohash `h` - **WHEN** принимается magnet с тем же `h` - **THEN** новая загрузка не создаётся, возвращается существующая #### Scenario: Повторный приём после завершения - **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`) - **WHEN** принимается magnet с тем же `h` - **THEN** создаётся новая загрузка со своим ULID и записью `h` ### Requirement: Атомарность возврата загрузки в активное состояние Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути, возвращающем загрузку из терминального состояния в активное (ручной retry, воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её (приём, adopt чужой раздачи), что никакая другая активная загрузка не владеет любым из хешей этой, и при владении SHALL отказывать в переходе, сохраняя инвариант «не более одной активной загрузки на infohash». Отказ SHALL происходить до побочных эффектов во внешних системах (повторного добавления торрента в qBittorrent). Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие гибридного торрента): хеш, которым владеет другая активная загрузка, дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное состояние в обход этой проверки SHALL отклоняться хранилищем (механический бэкстоп вместо удалённого unique-индекса). #### Scenario: Retry при занятом хеше - **GIVEN** загрузка #1 в `failed` с хешем `h`, и другая активная загрузка #2 с тем же `h` - **WHEN** пользователь вызывает retry для #1 - **THEN** переход отклоняется с пояснением, #1 остаётся в `failed` - **AND** активной по `h` остаётся #2 ### Requirement: Корреляция сущностей в логах Записи журнала, относящиеся к сущности, SHALL содержать её id в атрибуте `_id` (`download_id`, `recognition_id`, `batch_id`, …); работа в контексте загрузки ведётся через scoped-логгер с `download_id`. Благодаря глобальной уникальности ULID поиск по значению id (grep/jq) SHALL находить все записи журнала, относящиеся к сущности, независимо от имени поля. #### Scenario: Путь загрузки по логам - **GIVEN** загрузка прошла приём, распознавание и раскладку - **WHEN** журнал фильтруется по значению её `id` - **THEN** находятся записи всех этапов (ingest, recognition, file-layout) ### 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`