## Context Фоновая сверка (`internal/worker/reconcile.go`) выводит состояние уже разложенной задачи из матрицы «источник × цель» (`deriveState`). Цель определяется `targetPresent` — проверкой `os.Lstat` по `dst_path` ссылок последнего батча со «статусом раскладки» (`isLaidOut`: `linked`/`copied`/ `exists`). Источник дебаунсится, цель — нет. Проблема: `targetPresent` проверяет существование **пути**, а не принадлежность лежащего там файла данной загрузке. Поэтому, когда другая загрузка разложилась по тому же `dst_path` (например, повторная закачка того же фильма в другом качестве), сверка прежней загрузки видит «цель вернулась» и ложно выводит её из `deleted` в `orphaned` (с уведомлением). `deleted` уже терминален для идемпотентности (`store.terminalStates` включает его — снимается `idempotency_key`), но при этом всё ещё присутствует в `desyncStates`, т.е. сверка его переоценивает. Обе точки раскладки (авто-апплай после распознавания и ручной `Apply`) сходятся в `linkPlan` (`internal/worker/review.go`), который вызывает `store.CreateFileLinks` и затем `transition` в `done`. ## Goals / Non-Goals **Goals:** - Цель загрузки при сверке считается присутствующей только если файлы по её путям — её собственная раскладка (инвариант «один путь — один владелец»). - При повторной раскладке на освободившийся чужой путь владение переходит к новой загрузке; прежние ссылки на этот путь выводятся из обращения. - `deleted` перестаёт переоцениваться сверкой (терминален и в этом смысле). - Документация (`docs/specs/`) и спека `state-reconciliation` приведены в соответствие с графом состояний (`deleted --> [*]`). **Non-Goals:** - Мульти-версии Jellyfin (4K + 1080p рядом) — отдельная фича, movie-only, не входит в этот change. - Изменение поведения коллизии (занятый путь → review) — остаётся как есть. - Идентификация цели по inode/устройству — сознательно отвергнута (см. Decisions). - Миграция `file-layout` в OpenSpec — вне рамок; правки раскладки идут в `docs/specs/`. ## Decisions ### Решение 1: владение выражаем статусом `file_link`, а не inode Вводим статус `superseded` (`layout.StatusSuperseded`). При успешной раскладке в `linkPlan` после `CreateFileLinks` помечаем чужие ссылки на те же `dst_path`: ```sql UPDATE file_link SET status = 'superseded' WHERE dst_path = ? AND download_id != ? AND status IN ('linked','copied','exists') ``` `targetPresent`/`isLaidOut` уже считают целью только `linked`/`copied`/ `exists`, поэтому `superseded` отсекается **без изменений** в логике сверки. Помечаем только пути, которые сами реально разложили (результаты со статусом из `isLaidOut`) — коллизии и пропуски владение не отбирают. **Почему не inode:** завязка на номер inode — низкоуровневая, не выражает домен, требует хранить и сверять числа, осмысленные только для ФС, и усложняет тесты. Статус `file_link` остаётся в нашей доменной модели и переиспользует существующую логику `isLaidOut`. Миграция схемы не нужна: `file_link.status` — `TEXT`. ### Решение 2: `deleted` вне сверки Убираем `store.StateDeleted` из `desyncStates`. Завершённую начисто задачу больше не переоценивают. Это безопасно: источник к терминальной задаче не вернётся (идемпотентность снимается только для активных), а цель отбирается переходом владения (Решение 1) — оба пути «воскрешения» закрыты. ### Решение 3: точка вызова supersede — `linkPlan` `linkPlan` — единственная воронка обеих раскладок (авто и ручной `Apply`). Supersede вызываем там после фиксации своих ссылок и до/рядом с переходом в `done`, под тем же `w.mu`, в той же логической операции. Отдельный метод стора (напр. `SupersedeForeignLinks(ctx, downloadID, dstPaths)`). ### Решение 4: приведение спеки и доков - `state-reconciliation` (дельта этого change): сверка не трогает `deleted`; присутствие цели — по владению; самовосстановление из `deleted` убрано. - `docs/specs/workflow.md` — в «Сверке с реальностью» уточнить, что `deleted` терминален (без healing); граф уже это рисует. - `docs/specs/jellyfin-layout.md` — добавить переход владения путём при повторной раскладке на освободившийся путь. ## Risks / Trade-offs - **Гонок нет:** и раскладка, и сверка идут под `w.mu` (per-worker), supersede и `CreateFileLinks` — последовательно в `linkPlan`. - **Частичное владение** (сериал, где новая раскладка заняла лишь часть путей прежней): прежняя загрузка теряет владение только перехваченными путями. Пока хоть одна её неперехваченная ссылка (`isLaidOut`) существует на ФС, `targetPresent` возвращает true и задача остаётся `done`; в `target_missing` она уйдёт, лишь когда пропадут и собственные файлы. Это корректное отражение реальности, не регресс. - **Старые данные:** ранее ложно «воскрешённые» задачи в БД останутся в своём состоянии до следующего тика; после деплоя `deleted`-задачи просто перестанут трогаться, а ошибочно ставшие `orphaned` исправятся вручную при необходимости (точечно, не автоматической миграцией — инцидент единичный). - **`superseded` — новое значение enum-а:** учесть в местах, где статус интерпретируется (undo/листинги UI), чтобы такие ссылки не показывались как активная цель и не участвовали в undo как «снимаемые».