Files
jellybit/openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md
T
av 9aecf757e0 recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
2026-08-10 10:41:16 +03:00

19 KiB

Context

Распознавание строит план в два приёма. Сперва разбирается ответ LLM: parsePlan вызывает sanitizePlan, и три человекочитаемых поля — title, original_title, provider_hint — чистятся как недоверенный вход. Потом идёт сверка с метабазой, и при подтверждённом матче recognize.go подменяет поля плана каноническими значениями:

match, candidates := r.matchMetadata(ctx, plan)
if match != nil {
    plan.Title = match.Title      // ← значение внешнего сервиса, мимо чистки
    ...
}
dec := decide(plan, pre, match, ...)

Подстановка стоит после чистки, поэтому очищено ровно то, что чисткой потом и перезаписывается. Дальше plan.Title уходит в layout.titleYear и становится именем каталога библиотеки.

Ниже по потоку layout.sanitizeComponent снимает разделители пути, символы, недопустимые в SMB/NTFS, и байты < 0x20. Категорию Cf (zero-width, BOM, мягкий перенос) он не трогает и гомоглифы не сворачивает — они не мешают файловой системе и потому там не при чём.

Гейт авто-раскладки от этого не спасает: metadata.go сверяет normalize(c.Title) || normalize(c.OriginalTitle), то есть кандидат проходит по любому из двух полей, а в план и в путь уезжает только первое.

Инвариант «целевой путь строго под библиотекой» при этом держится — ревью проверило Dune/../../etc, " .. ", "..." и 400 символов, выхода из песочницы нет. Речь о предсказуемости имени, а не о песочнице.

Goals / Non-Goals

Goals:

  • Название из метабазы получает ту же чистку, что и название от LLM, до того как попадёт в план.
  • Название, схлопнувшееся чисткой в пустое, не порождает каталог с пустым именем и не роняет раскладку.
  • У чистки названий остаётся один дом: второй реализации не заводится.
  • Поведение TMDB на нормальных названиях не меняется — регресс на работающем провайдере дороже самого дефекта.

Non-Goals:

  • Полная нормализация Unicode (NFC/NFKC, конфузаблы по таблице Unicode). Рамка задачи это прямо исключает: цель — предсказуемое и сверяемое значение, а не исчерпывающая защита от визуального совпадения.
  • Расширение состава layout.sanitizeComponent. Он чистит компонент пути под требования файловой системы, и категория Cf ему не мешает.
  • Чистка director в плане. Действующее требование metadata-match относит её к рендеру отображаемого имени, и трогать это изменение не будет.
  • Пересмотр гейта матча (normalize(c.Title) || normalize(c.OriginalTitle)). Расхождение «сверяем по двум полям, подставляем одно» после этой правки перестаёт быть опасным: подставляется очищенное значение.

Decisions

Решение 1. Чистить на подстановке, в recognize

Санитайзинг применяется к match.Title в месте подстановки — там же, где сегодня стоит plan.Title = match.Title. Используется существующая sanitizeTitle, без новых функций.

Почему здесь. Место подстановки — единственная точка, где значение метабазы входит в план; всё, что ниже, работает уже с планом. Чистка здесь означает инвариант, который можно сформулировать одной фразой и проверить: план после матча не содержит несанитизированных человекочитаемых полей.

Рассмотрено и отвергнуто:

  • Закрыть категорию Cf и гомоглифы в layout.sanitizeComponent. Дало бы второй дом чистке названий и разошлось бы с первым: sanitizeTitle сворачивает гомоглифы потокенно по доминирующему скрипту — правило нетривиальное, и две его реализации разъедутся молча. Плюс sanitizeComponent работает и на FolderBase, прочитанной с диска, — там свёртка гомоглифов сломала бы сходимость к существующей папке.
  • Чистить в клиентах метабаз (internal/metadata/*.go). Пришлось бы повторить в трёх клиентах и повторять в каждом следующем; проверка «в плане нет грязных полей» перестала бы читаться в одном месте.
  • Прогнать sanitizePlan ещё раз после матча. Внешне дешевле всего, но вторым проходом чистит и то, что уже чисто, а главное — молчит о случае, когда название базы схлопнулось в пустое: p.Title стал бы пустым, и раскладка упала бы на layout: empty title after sanitization.

Решение 1a. Точек входа две, и закрываются обе

Ревью дизайна показало, что подстановка при авто-матче — не единственный вход значения метабазы в план. Второй: человек выбирает кандидата на ревью, его название закрепляется как override и попадает в план мимо распознавания. Спека metadata-match при этом сама называет ручной выбор основным путём подтверждения матча — закрыть только авто-путь значило бы починить менее употребимую половину и записать в спеку свойство, которого нет.

Закрываются обе одной и той же чисткой, но в трёх местах — по одному на каждую точку, где значение метабазы входит в домен:

  1. каноническое название матча — в buildMatch, единственной точке сборки Match. Дальше по потоку значение считается чистым: и подстановка в план, и решение auto/review, и диагностика сухого прогона берут его как есть. Ревью кода показало, чем плоха чистка на подстановке: условие пригодности пересчитывалось независимо в двух файлах, и первая же правка одного из них дала бы молчаливую авто-раскладку;
  2. названия кандидатов — в момент, когда кандидат копируется в список для ревью (matchMetadata, накопление candidates). Оттуда чистое значение уезжает разом в хранилище, на экран ревью и в карточку Telegram;
  3. набор пинов источникаsourcePins в worker. Это не дубль пункта 2, а ответ на два разных вопроса. Во-первых, через sourcePins идут и превью источника, и его закрепление, поэтому свойство «превью = применение» держится конструкцией: без чистки здесь экран показывал бы одно название, а раскладка делала другое. Во-вторых, пункт 2 чистит на записи, то есть гарантия держалась бы на времени записи строки — все кандидаты, сохранённые до этой правки и стоящие в очереди ревью, обошли бы её. Чистка идемпотентна, поэтому на новых строках пункт 3 не делает ничего.

Почему это не двигает гейт матча. Сильный матч ищется по cands — срезу, как его отдал провайдер, — а в ревью уходит копия в candidates. Чистка на копировании до сравнения не доходит. Проверять это пришлось отдельно: сравнение идёт через normalize, и она не эквивалентна санитайзингу — zero-width внутри слова normalize превращает в пробел (Du␀nedu ne), а санитайзинг удаляет (dune). То есть чистка всего списка cands до сравнения превратила бы часть нынешних «в review» в «авто», и это был бы сдвиг гейта, которого задача не заказывала.

Рассмотрено и отвергнуто: звать санитайзер прямо в chooseCandidateLocked — это закрыло бы закрепление и оставило превью считаться по сырому значению, то есть развело бы показанное и применённое. Правило живёт в sourcePins, потому что это единственный общий дом набора пинов; запланированный review-mapping-editor придёт туда же, а не заведёт четвёртый вызов.

Что при этом экспортируется: recognize.SanitizeTitle и recognize.UsableTitle — пара «почисти и проверь пригодность». Экспортировать пришлось обе: предикат без санитайзера обязывал бы вызывающего помнить порядок, а порядок прозой не проверяется.

Решение 2. Пустой результат чистки — прежнее название и уход в review

Если sanitizeTitle(match.Title) даёт пустую строку, подстановка не выполняется: в плане остаётся название от LLM (оно уже прошло чистку и непустое — иначе validateSchema отклонил бы план), а в причины решения добавляется строка, из-за которой раздача уходит в ревью.

Почему так. Из трёх исходов — упасть, подставить пустое, оставить прежнее — только третий сохраняет работоспособность и при этом не скрывает происшествие. Название из одних невидимых символов означает, что с записью базы что-то не так, и это ровно тот случай, ради которого ревью и существует: система не уверена — зовёт человека, а не заминает.

Рассмотрено и отвергнуто: отклонять матч целиком (терялись бы provider_id, год и режиссёр — а они верны); подставлять пустое и ловить это раскладкой (отказ приходит поздно, терминальным состоянием и текстом чужого слоя — ровно та боль, которую чинит соседняя задача long-title-to-review).

Решение 3. Год, режиссёр и провайдер не трогаются

match.Year — число, чистить нечего. match.Director в путь на диске не попадает, и его очистка по действующему требованию делается при рендере имени (naming.sanitize). provider/provider_id — идентификаторы, у них своя валидация.

original_title в план из матча не подставляется вовсе — сегодня recognize.go берёт из Match только название, год и режиссёра. Чистить там нечего, и требование про «поля плана после матча» это учитывает: значение original_title осталось тем, что пришло от LLM, то есть уже санитизированным.

Решение 4. Решение auto/review остаётся с одним производителем

Decision{Auto, Reasons} для разобранного плана рождается ровно в одном месте — decide в validate.go, и её доккомментарий утверждает исчерпывающий перечень условий авто. Новая причина появляется внутри decide, а не дописывается к готовому решению со сбросом Auto: иначе у того же решения появляется второй производитель, и следующая правка гейта (соседняя задача long-title-to-review заводит причину того же класса) будет выбирать между двумя местами.

Дополнительного входа у decide при этом не появилось, хотя сперва предполагался: раз Match.Title санитизируется в buildMatch (Решение 1a), decide отвечает на вопрос пригодности по уже чистому значению — match у неё и так на руках. Условие считается один раз и читается из одного поля.

Решение 5. Общая форма «название непригодно как компонент пути» отложена

Пустое-или-вырожденное название и название длиннее лимита файловой системы — один класс: значение не годится как компонент пути, исход один — review с названной причиной вместо отказа из слоя раскладки. Общее место для этого класса здесь не заводится: второй его случай — предмет соседней задачи long-title-to-review, и собирать общую форму из одного случая рано. Это записано, чтобы второй автор не изобретал её параллельно, а достроил.

Risks / Trade-offs

  • Правка меняет работающий путь TMDB. → Значение проходит через идемпотентную чистку, которая на нормальном названии не меняет ничего; критерий приёмки требует зелёных существующих тестов internal/recognize и internal/metadata без правки ожиданий — расхождение сразу видно.
  • Свёртка гомоглифов может тронуть честное название. → Правило потокенное: одно-скриптовый токен не трогается, и кириллические названия проходят как есть. Правило уже работает на выводе LLM и покрыто сценариями спеки; изменение лишь распространяет его на второй источник.
  • Гомоглифы сворачиваются, а конфузаблы шире таблицы — остаются. → Осознанный trade-off, названный в рамках задачи. Курируемая кирилло-латинская таблица закрывает реальный случай (русскоклавиатурные двойники); полная нормализация Unicode — отдельный разговор.
  • Название базы схлопнулось в пустое — пользователь видит название от LLM. → Раздача при этом в ревью, где название правится подсказкой, а причина названа словами.
  • Уже созданные «грязные» каталоги правкой не чинятся. → Правило сходимости базы папки (file-layout, «Сходимость базы папки при подтверждённом матче») наследует имя от живой папки-якоря и не печатает его заново из распознавания. Каталог с невидимыми символами, созданный до этой правки, останется якорем, и следующий сезон ляжет в него. Лечение — переименовать папку руками, после чего сходимость подхватит новое имя. Изменение закрывает появление новых таких каталогов, а не существующие.