## Context Распознавание строит план в два приёма. Сперва разбирается ответ LLM: `parsePlan` вызывает `sanitizePlan`, и три человекочитаемых поля — `title`, `original_title`, `provider_hint` — чистятся как недоверенный вход. Потом идёт сверка с метабазой, и при подтверждённом матче `recognize.go` подменяет поля плана каноническими значениями: ```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␀ne` → `du 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`, «Сходимость базы папки при подтверждённом матче») наследует имя от живой папки-якоря и не печатает его заново из распознавания. Каталог с невидимыми символами, созданный до этой правки, останется якорем, и следующий сезон ляжет в него. Лечение — переименовать папку руками, после чего сходимость подхватит новое имя. Изменение закрывает появление новых таких каталогов, а не существующие.