Files
jellybit/openspec/specs/identity/spec.md
T
av d081ef1d30 ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add
- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
2026-08-06 18:20:12 +03:00

8.5 KiB
Raw Blame History

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