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

11 KiB
Raw Blame History

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, kindv1|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