Files
jellybit/openspec/specs/identity/spec.md
T
avandClaude Fable 5 37f2f6481a Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase,
internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши
загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп
и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба
хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится.

Инвариант «не более одной активной загрузки на infohash» вместо снятого
unique-индекса держат guarded-методы store в одной write-транзакции
(_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом
недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ
до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет
терминал→активное как механический бэкстоп.

Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при
включённых FK, backfill ULID с timestamp из created_at (хронология id
сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id
в URL/логах/Telegram, REST-поля id (string) и infohashes (список).

Новая конвенция docs/conventions/database.md (без числовых PK), корреляция
в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability
identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены
ревью дизайна и кода (по 8 углов), все находки исправлены с
регрессионными тестами.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:25:00 +03:00

162 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# identity Specification
## Purpose
Как система идентифицирует сущности домена: ULID-ключи (канонический
lowercase-вид, нормализация и валидация на входных границах), множество
инфохэшей загрузки (`download_infohash`), инвариант «не более одной
активной загрузки на infohash» (дедупликация приёма, атомарный возврат в
активное состояние), корреляция сущностей в логах по id.
## 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 в атрибуте
`<entity>_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`