chore(openspec): предложить fix-album-sync-churn-and-degradation и уточнить спеку phone-album-sync

Изменение описывает три дефекта двусторонней синхронизации: повторную
загрузку собственного превью, удаление серверной копии до подтверждения
замены и повторную разгрузку только что восстановленного оригинала.
This commit is contained in:
iu
2026-10-20 20:32:20 +03:00
parent cc85d792ea
commit 46d54b4b12
5 changed files with 450 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
@@ -0,0 +1,171 @@
# Design
## Context
Мотивацию и инцидент см. в `proposal.md`; требования — в дельте `specs/phone-album-sync/`.
Здесь только текущее состояние, задающее подход.
- Движок синхронизации (`android/.../data/sync/*`) считает диффом «MediaStore сейчас ↔ индекс
Room». Индекс: `SyncedAlbumEntity` (связь и режим), `DeviceAssetEntity` (`assetKey`,
`contentHash`, `serverPhotoId`, `state`, `origin`), `deleted_assets` (журнал удалений),
`upload_jobs` (очередь загрузок с `deviceAlbumId`, `assetKey`, `contentHash`, `syncMode`).
- `SyncPlanner.plan` — чистая функция: сравнивает `LocalAsset.contentHash` с
`IndexedAsset.contentHash` и отдаёт `Upload`, `Link`, `DeleteOnServer`, `RenameOnServer`.
Роль файла она различает только по `state == PREVIEW`; при расхождении состояния с диском
смена роли читается как «содержимое изменилось».
- `OffloadManager` пишет в превью метку провенанса (`ProvenanceStamp`:
`album`, `photo`, `hash`, `device`, `mode`, `asset`, `origin`), где `hash` — контрольная
сумма оригинала на момент разгрузки.
- Загрузка асинхронная (`UploadRepository`/`UploadWorker` на WorkManager); в задание
записывается `contentHash`, посчитанный на момент планирования, а байты читаются по URI уже
во время загрузки. `UploadWorker.recordProvenance` в режиме переноса разгружает оригинал
сразу после успешной загрузки — вне мьютекса `SyncCoordinator`.
- `LocalAssetProvider.hashCached` переиспользует посчитанный sha256, пока не изменилась пара
`(size, dateModified)` **из MediaStore**, которая после записи файла через дескриптор
остаётся старой (на инциденте: `size=169637` в приложении против `2 184 212` на диске).
- Room: версия 8, миграции объявлены явно (`MIGRATION_6_7`, `MIGRATION_7_8`),
`fallbackToDestructiveMigration` включён.
- Сервер менять не нужно: `DELETE /api/photos/{id}` (мягко, в корзину),
`POST /api/photos/{id}/restore` и `original_checksum` в ответе загрузки уже есть.
## Goals / Non-Goals
**Goals:**
- Инвариант «контрольная сумма = идентичность оригинала, состояние = роль локального файла»:
разгрузка и возврат не меняют содержимое и не запускают загрузку или удаление.
- Замена версии безопасна: прежняя серверная копия исчезает только после подтверждения новой.
- Расхождение состояния в индексе самоисправляется, а не превращается в разрушительное
действие.
- Разово вернуть два пострадавших оригинала и убрать деградировавшие копии.
**Non-Goals:**
- Серверная модель версий и идентичности содержимого; эндпоинт «заменить содержимое фото».
- Чистка кэша альбома на телефоне (пустые плитки удалённых фото — отдельная задача).
- Доверие к метаданным MediaStore при кэшировании хешей: остаётся как есть, риск закрывается
сверкой контрольной суммы (см. решение 4).
- Семантика зеркалирования чужих серверных фото и права на удаление.
## Decisions
### 1. Роль и содержимое различаются по метке превью, а не по состоянию индекса
Идентичность элемента — `IndexedAsset.contentHash` (контрольная сумма оригинала либо
последней подтверждённой новой версии). Роль локального файла определяется меткой провенанса:
если у файла есть метка, её `hash` совпадает с идентичностью элемента, а фактический хеш файла
от неё отличается — это созданное приложением превью, а не новая версия.
Альтернативы: доверять только `state` индекса — отклонено, потому что расхождение состояния и
есть причина инцидента (в `device_assets` элементы с превью на диске стояли как `LOCAL`);
сравнивать разрешение с серверной копией — отклонено, потому что законное уменьшение
(кроп/экспорт) тоже меньше по размеру, а серверная копия может быть уже деградировавшей.
Метка читается только тогда, когда хеш содержимого пересчитывался (промах кэша), то есть
примерно один раз на изменение файла.
Метка указывает на элемент, чьим превью является файл, — для фото это сам элемент, для
видео это исходное видео, уступившее место постеру под новым `MediaStore`-id. Поэтому
распознавание опирается на `asset` из метки, а не на совпадение ключей: постер
разгруженного видео связывается с видео, а «исчезнувший» с устройства элемент, который всё
ещё представлен постером, не считается удалённым и не удаляет серверную копию. Скачанные
(зеркальные) файлы метку-ключ не несут и по этому пути не обрабатываются.
### 2. Планировщик возвращает действие «выправить роль», а не молчит
Превью, принятое за оригинал, не просто игнорируется: планировщик отдаёт
`SyncAction.MarkPreview(assetKey)`, координатор ставит элементу состояние `PREVIEW`. Это
чинит и `GallerySyncPolicy` (иначе он в режиме переноса снова разгружает элемент как
`LOCAL`), и `reflectLostCopies` (переход `PREVIEW ↔ ORPHANED`). Альтернатива — чинить роль
только в координаторе до планирования — отклонена: тогда знание о метке пришлось бы
дублировать в двух местах.
### 3. Незавершённая загрузка блокирует разгрузку
`SyncCoordinator.offloadUploaded` пропускает элементы, для которых очередь загрузок знает
незавершённое задание (`SyncUploadQueue.isPending`). Так файл не может быть переписан
превью в момент, когда задание ещё читает байты по URI. Альтернатива — синхронный offload
внутри воркера после загрузки — оставлена, но только для уже подтверждённого фото этого
элемента, когда незавершённых заданий по нему нет.
Побочный эффект: разгрузка в режиме переноса завершается после того, как очередь опустеет,
то есть чуть позже, чем сегодня (требование «автоматическая разгрузка после появления копии
на сервере» сохраняется).
### 4. Замена версии: удаление прежней копии — только после подтверждения
При изменении содержимого планировщик отдаёт `SyncAction.ReplaceUpload(asset, stalePhotoId)`
вместо пары «`Upload` + `DeleteOnServer`»:
- координатор пишет в журнал удалений запись для `stalePhotoId` (надгробие блокирует
повторную материализацию прежней копии, пока идёт замена) и ставит задание загрузки с
новым полем `replacesPhotoId`;
- **`UploadWorker`** после загрузки сверяет `original_checksum` ответа с хешем задания; при
совпадении удаляет прежнюю копию (мягко, в корзину), записывает провенанс и разгружает
оригинал в режиме переноса;
- при ошибке или расхождении контрольной суммы прежняя копия остаётся, провенанс не
записывается, замена повторится в следующем проходе (надгробие сохраняется, задание
остаётся с ошибкой).
Сверка различает три исхода, и это принципиально: **совпало** — можно удалять прежнюю копию;
**разошлось** — загрузку нельзя принимать за новую версию (принятую копию отбрасываем мягко,
элемент не считается загруженным, прежняя копия остаётся); **не сообщено** — загрузка
остаётся состоявшейся, но прежняя копия не удаляется. Третий исход понадобился сразу: ответ
на загрузку не отдавал `original_checksum` (сервер писал контрольную сумму только в объект,
хотя требование способности `photos` «Контрольная сумма оригинала» распространяется на данные
фото вообще), и первая версия сверки, считавшая отсутствие контрольной суммы расхождением,
отклоняла все синхронные загрузки. Дефект сервера исправлен: `Service.Upload` заполняет
`p.OriginalChecksum` до ответа и публикации события; клиент при этом остаётся устойчивым к
инстансу, который контрольную сумму не сообщает.
Схема: `upload_jobs.replacesPhotoId TEXT` (Room 8 → 9). Задание — естественный носитель
одноразовой замены: альтернатива с колонкой в `device_assets` требует явной очистки после
подтверждения, а привязка надгробий к ассетам всё равно требует миграции. Немедленное
удаление как сегодня отклонено: неудачная загрузка оставляет альбом без живой копии.
Явное удаление элемента на устройстве (`SyncAction.DeleteOnServer`) сохраняет прежнее
поведение — файла уже нет, и намерение выражено пользователем.
### 5. Возврат оригинала оставляет состояние «оригинал + серверная копия»
`RestoreManager` после записи оригинала помечает элемент `UPLOADED`, а не `LOCAL`.
`LOCAL` остаётся значением для «оригинал есть, на сервер не загружался». Вместе с решением 2
это убирает повторную разгрузку вернувшихся оригиналов и петлю «вернул → выгрузил → залил».
### 6. Разовое восстановление двух пострадавших фото
Операция, а не постоянное поведение: для `IMG_20261004_163151.jpg` восстановить из корзины
оригинал `6d4e1897` в альбом `N2` и удалить деградировавшую копию `b0b29905`; для
`IMG_20261009_153136.jpg` — восстановить `991ea4ee` и удалить `1f49e8fb`. Нужны права
владельца инстанса; если креды недоступны, пользователю выдаются готовые команды. Синхронизация
альбома на телефоне к этому времени уже выключена, поэтому вмешательства в индекс не требуется.
## Risks / Trade-offs
- **[У файла нет метки провенанса]** (превью записано до появления метки или переписано
сторонним редактором) → роль берётся из состояния индекса, как раньше; деградацию
ограничивает решение 4: прежняя копия не удаляется, пока новая не подтверждена, поэтому
потеря оригиналов невозможна, а лишняя копия заметна и обратима через корзину.
- **[Сервер не сообщает контрольную сумму]** → загрузка остаётся состоявшейся, но прежняя
копия не удаляется и замена повторяется; так инстанс, отставший от спеки, не может
привести к отклонению всех синхронных загрузок (этот исход уже случался на живой проверке).
- **[Хеш посчитан в момент перезаписи файла]** (гонка с собственной записью) → возможна
загрузка с хешем, не совпадающим с байтами; сверка контрольной суммы (решение 4) отменяет
такую замену и не удаляет прежнюю копию.
- **[Правка собственного превью пользователем]** → элемент считается превью и правка не
уедет на сервер. Осознанный компромисс: файл несёт метку разгруженного превью, а
требование «метка утрачена» уже говорит не удалять и не загружать неопознанное повторно.
- **[Разгрузка откладывается до опустошения очереди]** → режим переноса освобождает место
позже. Освобождение остаётся автоматическим, но перестаёт конкурировать с загрузкой.
- **[Миграция Room 8 → 9]** → одна `ALTER TABLE` с nullable-колонкой; откат безопасен
(колонка просто игнорируется), `fallbackToDestructiveMigration` остаётся страховкой.
- **[Ручная операция по данным инстанса]** → восстановление из корзины обратимо, удаление
выполняется мягко; до и после сверяются размеры и контрольные суммы.
## Migration Plan
1. Миграция `MIGRATION_8_9` добавляет `upload_jobs.replacesPhotoId`; старые задания читаются
как «без замены».
2. Сборка и установка APK поверх существующего приложения: индекс и очередь сохраняются.
3. После обновления первый проход сам исправляет состояния элементов (решение 2) и
прекращает цикл на конкретном инстансе.
4. Разовое восстановление двух фото (решение 6) выполняется отдельно и не зависит от выкатки.
@@ -0,0 +1,70 @@
# Proposal
## Why
Режим переноса (C) может залить на сервер **превью вместо оригинала** и тут же удалить
устаревшую серверную копию, то есть молча уничтожить оригинал. Причина — планировщик не
отличает содержимое от роли локального файла: когда состояние элемента в локальном индексе
расходится с фактической ролью (в индексе «оригинал», на диске превью), смена роли читается
как «содержимое изменилось» и запускается цикл «удалил → залил → удалил».
Реальный инцидент: альбом устройства U2, режим переноса, серверный альбом `N2`. Из 20 фото
пять перезаливались по кругу (20:03 → 20:08 → 20:42 → 21:24), а два потеряли оригиналы —
`IMG_20261004_163151.jpg` (3 437 694 Б, 3072x4096 → 259 282 Б, 1200x1600) и
`IMG_20261009_153136.jpg` (2 226 043 Б → 192 651 Б). Лечиться это будет только сейчас:
у первого уцелевший оригинал лежит в корзине сервера, у второго — в корзине и в альбоме
«Первый». Побочно тот же цикл оставляет на телефоне элементы, которых на сервере уже нет:
в приложении альбом `N2` показывает 26 плиток вместо 20, шесть из них пустые.
## What Changes
- **Роль не является содержимым.** Приложение SHALL отличать созданное им превью (в том числе
по метке провенанса) от нового оригинала: превью никогда не загружается как новая версия и
не влечёт удаления серверной копии, а расхождение состояния в индексе выправляется.
Настоящее изменение содержимого, как и раньше, синхронизируется новой версией.
- **Удаление только после подтверждения.** Устаревшая собственная загрузка удаляется только
после того, как новая версия подтверждена сервером: сообщённая им контрольная сумма
принятого объекта совпала с отправленной. При расхождении загрузка отклоняется вместе с
принятой копией, прежняя серверная копия остаётся в альбоме, а замена повторяется при
следующей синхронизации. Если сервер контрольную сумму не сообщил (например, старая версия
инстанса), загрузка остаётся состоявшейся, но прежняя копия не удаляется.
- **Контрольная сумма в ответе на загрузку.** Сервер SHALL отдавать контрольную сумму
оригинала в данных созданного фото — как требует существующее требование способности
`photos`; сегодня ответ на загрузку её не содержит (дефект сервера, а не изменение API).
- **Разгрузка не гоняется с загрузкой.** Оригинал не заменяется превью, пока по этому
элементу есть незавершённая загрузка.
- **Возврат оригинала устойчив.** После возврата оригиналов на устройство элемент не
разгружается превью повторно, пока альбом снова не переведён в режим переноса.
- **Разовое восстановление двух пострадавших фото** из корзины сервера (и альбома «Первый»
для второго) с удалением деградировавших копий.
- **BREAKING**: нет. Успешные сценарии (загрузка нового, разгрузка, повторная синхронизация,
настоящее изменение содержимого) сохраняют поведение; меняются только защитные условия.
## Capabilities
### New Capabilities
_Нет — поведение относится к существующей способности синхронизации альбома устройства._
### Modified Capabilities
- `phone-album-sync`: различение роли и содержимого (метка превью против нового оригинала),
разгрузка только без незавершённой загрузки, удаление устаревшей загрузки только после
подтверждения новой версии, устойчивый возврат оригиналов.
## Impact
- **Код (Android):** `data/sync/SyncPlan` (различение роли и содержимого, отложенная замена),
`data/sync/SyncCoordinator` (выправление состояния, разгрузка без незавершённых загрузок,
постановка замены), `data/sync/LocalAssetProvider` (метка превью рядом с хешем),
`data/upload/UploadWorker` (сверка принятой контрольной суммы, удаление устаревшей копии
после подтверждения, без разгрузки вне координатора), `data/sync/RestoreManager` (состояние
после возврата оригинала), `data/db` (поле «заменяемое фото» в задании загрузки).
- **Схема Room:** версия 8 → 9, `ALTER TABLE upload_jobs ADD COLUMN replacesPhotoId TEXT`.
- **API и бэкенд:** не затрагиваются — используются существующие `DELETE /api/photos/{id}`
(мягко, в корзину), `POST /api/photos/{id}/restore` и `original_checksum` в ответе загрузки.
- **Данные инстанса:** разовое восстановление двух оригиналов и удаление деградировавших
копий (операция, а не постоянное поведение).
- **Не затрагивается:** серверная модель версий/идентичности, дедупликация вложения, чистка
кэша альбома на телефоне (пустые плитки удалённых фото — отдельная задача), доверие к
метаданным MediaStore при кэшировании хешей (сверка контрольной суммы закрывает риск).
@@ -0,0 +1,173 @@
# Spec Delta
## ADDED Requirements
### Requirement: Роль локального файла не является содержимым
Приложение SHALL отличать превью, созданное им самим при разгрузке (в том числе по метке
провенанса), от новой версии оригинала: разгруженный элемент SHALL NOT загружаться повторно
и SHALL NOT вызывать удаление серверной копии, а расхождение состояния в локальном индексе
SHALL выправляться. Это SHALL относиться и к постеру, которым в режиме переноса заменяется
видео.
#### Scenario: Разгруженный элемент не создаёт новую версию
- **WHEN** серверная копия элемента есть в целевом альбоме, а на устройстве лежит созданное приложением превью, в том числе если состояние локального индекса ошибочно указывает оригинал
- **THEN** приложение не ставит загрузку и не удаляет серверную копию
#### Scenario: Состояние выправляется
- **WHEN** файл на устройстве является созданным приложением превью, а индекс считает его оригиналом
- **THEN** приложение помечает элемент как разгруженный и последующие проходы не создают загрузок
#### Scenario: Постер разгруженного видео
- **WHEN** видео разгружено (заменено постером под новым элементом MediaStore), а его метка указывает на исходное видео
- **THEN** приложение связывает элемент с серверным фото видео и не удаляет это фото на сервере, а постер не загружается как новое фото
#### Scenario: Настоящее изменение содержимого
- **WHEN** содержимое медиа заменено содержимым, не созданным приложением при разгрузке
- **THEN** приложение загружает новую версию элемента
### Requirement: Подтверждение новой версии до удаления устаревшей
Устаревшая собственная загрузка элемента SHALL удаляться только после подтверждения новой
версии: сообщённая сервером контрольная сумма принятого объекта SHALL совпадать с
отправленной. При расхождении приложение SHALL отклонить загрузку, удалив принятую копию, и
SHALL NOT считать элемент загруженным. Если сервер контрольную сумму не сообщил, загрузка
SHALL считаться состоявшейся, но устаревшая копия SHALL NOT удаляться, а замена SHALL
повториться при следующей синхронизации.
#### Scenario: Новая версия подтверждена
- **WHEN** новая версия загружена, а сообщённая сервером контрольная сумма совпала с отправленной
- **THEN** приложение удаляет устаревшую собственную загрузку (мягко, в корзину)
#### Scenario: Контрольная сумма не совпала
- **WHEN** сервер сообщил для принятого объекта контрольную сумму, отличную от отправленной
- **THEN** приложение удаляет принятую копию, не считает элемент загруженным и оставляет прежнюю серверную копию в альбоме
#### Scenario: Контрольная сумма не сообщена
- **WHEN** сервер не сообщил контрольную сумму принятого объекта
- **THEN** загрузка считается состоявшейся, прежняя серверная копия остаётся в альбоме, а замена повторяется при следующей синхронизации
#### Scenario: Загрузка не удалась
- **WHEN** загрузка новой версии завершилась ошибкой
- **THEN** прежняя серверная копия остаётся в альбоме
### Requirement: Разгрузка без незавершённой загрузки
Приложение SHALL NOT заменять оригинал превью, пока для этого элемента есть незавершённая
загрузка (в очереди или выполняющаяся), чтобы загрузка не отправила превью вместо оригинала.
#### Scenario: Загрузка в очереди
- **WHEN** для элемента есть задание загрузки в очереди или выполняющееся
- **THEN** оригинал на устройстве не заменяется превью
#### Scenario: Загрузка подтверждена
- **WHEN** загрузка элемента подтверждена сервером
- **THEN** приложение выполняет обычную разгрузку в режиме переноса
## MODIFIED Requirements
### Requirement: Синхронизация изменений с сервером
Для каждого отслеженного изменения приложение SHALL применить его к целевому
серверному альбому: загрузить новое содержимое, переименовать и перенести через
API. Изменение содержимого SHALL трактоваться как новая загрузка, а устаревшая
собственная загрузка SHALL удаляться после подтверждения новой версии и SHALL NOT
удаляться из-за смены локальной роли файла (оригинал ↔ превью).
#### Scenario: Новое содержимое
- **WHEN** в альбоме устройства появилось медиа, которого нет на сервере
- **THEN** приложение загружает его в целевой серверный альбом
#### Scenario: Переименование доходит до сервера
- **WHEN** медиа переименовано на устройстве и уже синхронизировано
- **THEN** приложение переименовывает соответствующее серверное фото
#### Scenario: Изменение содержимого
- **WHEN** содержимое медиа изменено на устройстве
- **THEN** приложение загружает новую версию и после её подтверждения удаляет устаревшую собственную загрузку
### Requirement: Провенанс разгруженных превью
Разгруженные превью, постеры видео и скачанные с сервера элементы SHALL содержать
метаданные провенанса, включая признак происхождения (загружено клиентом или скачано с
сервера), достаточные для восстановления связи с серверным альбомом и принадлежности
загрузок после переустановки приложения. Метка SHALL также служить признаком того, что
локальный файл является созданным приложением превью: элемент с такой меткой SHALL NOT
считаться содержащим оригинал, даже если состояние локального индекса утверждает обратное.
#### Scenario: Восстановление после переустановки
- **WHEN** приложение переустановлено или его данные очищены, а альбом устройства содержит разгруженные превью
- **THEN** приложение восстанавливает связь с серверным альбомом и принадлежность своих загрузок из метаданных превью
#### Scenario: Метка скачанного элемента
- **WHEN** метка провенанса восстанавливается у элемента, скачанного с сервера
- **THEN** элемент восстанавливается как скачанный, а не как собственная загрузка клиента
#### Scenario: Метка утрачена
- **WHEN** метаданные провенанса недоступны (например, файл перезаписан сторонним редактором)
- **THEN** элемент считается неопознанным, не удаляется и не загружается повторно
#### Scenario: Метка против состояния индекса
- **WHEN** файл несёт метку созданного приложением превью, а состояние индекса указывает оригинал
- **THEN** элемент считается разгруженным, а не новым оригиналом
### Requirement: Возврат оригиналов и отвязка
При выходе из режима C приложение SHALL предложить вернуть оригиналы на устройство
и SHALL предупредить, если пользователь отказывается. Отвязка альбома и выключение
синхронизации SHALL NOT удалять фото с сервера, а скачанные с сервера файлы SHALL
оставаться в галерее как обычные. Выключение синхронизации SHALL быть явным и
устойчивым: восстановление происхождения SHALL NOT возобновлять связь, пока
пользователь не включит синхронизацию снова.
#### Scenario: Возврат оригиналов
- **WHEN** пользователь выходит из режима C и соглашается вернуть оригиналы
- **THEN** приложение возвращает оригиналы на устройство вместо превью
#### Scenario: Отказ от возврата
- **WHEN** пользователь выходит из режима C и отказывается возвращать оригиналы
- **THEN** приложение предупреждает, что на устройстве останутся только превью
#### Scenario: Отвязка
- **WHEN** пользователь отвязывает альбом устройства от серверного
- **THEN** фото на сервере сохраняются, а скачанные файлы остаются в галерее как обычные
#### Scenario: Выключение синхронизации
- **WHEN** пользователь выключает синхронизацию альбома устройства
- **THEN** связь с серверным альбомом снимается, а фото на сервере сохраняются
#### Scenario: Выключение не отменяется восстановлением
- **WHEN** пользователь выключил синхронизацию (или отвязал альбом), и затем выполняется проверка с восстановлением происхождения по меткам превью
- **THEN** связь не восстанавливается, а альбом остаётся локальным с выбранным режимом выключено
#### Scenario: Повторное включение
- **WHEN** пользователь снова включает синхронизацию после выключения
- **THEN** связь создаётся заново, а восстановление происхождения снова действует по обычным правилам
#### Scenario: Возврат устойчив
- **WHEN** оригиналы возвращены на устройство после выхода из режима переноса
- **THEN** приложение не разгружает их повторно, пока альбом не переведён в режим переноса снова
@@ -0,0 +1,34 @@
# Tasks
## 1. Роль локального файла вместо состояния индекса
- [x] 1.1 Добавить в `LocalAsset` (`data/sync/SyncPlan.kt`) признаки метки превью (`stampedOriginalHash`, `stampedAssetKey`), а в `DeviceLocalAssetProvider` читать `ProvenanceStamp` в момент пересчёта хеша файла; решение «локальный файл — созданное приложением превью» вынести в чистую функцию с юнит-тестом (`cd android && ./gradlew :app:testDebugUnitTest --tests "*ProvenanceStampTest" --tests "*SyncPlannerTest"`).
- [x] 1.2 В `SyncPlanner.plan` не загружать превью и не удалять из-за него серверную копию: использовать метку как признак роли и идентичность оригинала, отдавать новое действие `SyncAction.MarkPreview`; покрыть `SyncPlannerTest` случаями «состояние индекса `LOCAL`, файл — превью с меткой» (ожидается `MarkPreview`), «другое содержимое без метки» (ожидается загрузка) и «превью потерявшего копию элемента» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`).
- [x] 1.3 В `SyncCoordinator` применять `MarkPreview` (состояние `PREVIEW`) и не разгружать элементы с незавершённой загрузкой (`SyncUploadQueue.isPending`) в `offloadUploaded`; покрыть `SyncCoordinatorTest` случаями «расхождение состояния выправляется» и «загрузка в очереди — разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`).
- [x] 1.4 Распознавать постер разгруженного видео как превью исходного элемента по метке (`asset` указывает на видео, а не на постер) и не удалять серверную копию видео, уступившего место постеру; покрыть `SyncPlannerTest` случаями «постер связывается с видео, не загружается и не удаляется» и «видео без постера удаляется как раньше» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`).
## 2. Замена версии с подтверждением
- [x] 2.1 Добавить `replacesPhotoId` в `UploadJobEntity`, миграцию `MIGRATION_8_9` (версия 9) и ветку в `MigrationTest`; проверить `cd android && ./gradlew :app:testDebugUnitTest --tests "*MigrationTest" --tests "*UploadRepositoryTest"`.
- [x] 2.2 В `SyncPlanner` заменить пару «`Upload` + `DeleteOnServer`» для изменённого содержимого на `SyncAction.ReplaceUpload(asset, stalePhotoId)`, а в `SyncCoordinator` писать надгробие для прежней копии и ставить задание с полем замены; покрыть `SyncPlannerTest` (ожидается `ReplaceUpload`) и `SyncCoordinatorTest` (надгробие и задание с заменой) (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest" --tests "*SyncCoordinatorTest"`).
- [x] 2.3 Вынести сверку принятой контрольной суммы в чистую функцию и подключить её в `UploadWorker`: совпадение `original_checksum` с хешем задания удаляет прежнюю копию и записывает провенанс, расхождение или ошибка — не удаляет, не разгружает и оставляет задание с ошибкой; покрыть `SyncUploadTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncUploadTest"`).
- [x] 2.4 Сервер: заполнять `p.OriginalChecksum` в `Service.Upload` до ответа и публикации события (существующее требование `photos` «Контрольная сумма оригинала» распространяется на данные созданного фото, но ответ на загрузку её не отдавал — живая проверка показала, что из-за этого отклонялись все синхронные загрузки); покрыть тестом ответа на загрузку (`cd backend && go test ./internal/app/ -run TestUploadResponseExposesOriginalChecksum -v`).
- [x] 2.5 Клиент остаётся устойчивым к инстансу без контрольной суммы: «не сообщено» SHALL NOT считаться расхождением — загрузка принимается, а прежняя копия не удаляется; покрыть `SyncUploadTest` случаями «не сообщено» и «нет заявки на содержимое» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncUploadTest"`).
## 3. Состояние после возврата оригинала
- [x] 3.1 В `RestoreManager` помечать восстановленный элемент `UPLOADED` (оригинал и серверная копия) вместо `LOCAL`; покрыть `RestoreManagerTest` и `GallerySyncPolicyTest` случаем «после возврата повторной разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*RestoreManagerTest" --tests "*GallerySyncPolicyTest"`).
## 5. Интеграционная проверка
- [x] 5.1 Прогнать весь Android-набор и собрать debug-APK (`cd android && ./gradlew test :app:assembleDebug`).
## Workflow follow-up
- Восстановление пострадавших оригиналов (бывшая задача 4) — ручная процедура владельца инстанса, выполняется вне change, креды в репозиторий и артефакты не попадают:
- вернуть из корзины `6d4e1897-3a7e-4cba-9d85-5bbb4b06f5a4` (IMG_20261004_163151.jpg, 3 437 694 Б, 3072x4096) и `991ea4ee-debb-4ecb-bc26-e4d1a2140085` (IMG_20261009_153136.jpg, 2 226 043 Б, 3072x4096) в альбом `N2` (`POST /api/photos/{id}/restore`);
- удалить деградировавшие копии `b0b29905-712b-4143-9d8c-0b74aa567ce5` и `1f49e8fb-ea9c-4764-8daa-7b185be107e9` (`DELETE /api/photos/{id}`);
- проверить, что в альбоме осталось по одной записи с каждым именем и оригинал у обеих 3072x4096.
- Уборка после регрессии первой версии сверки контрольной суммы (живая проверка 2026-10-16): в новом альбоме `U2` (`e7ae31c9-805c-42f5-8fd2-ea2304f0c485`) лежат 19 мягко удалённых копий десяти файлов, которые приложение само же и отклонило. Их список (id и имена) — в отчёте; удаляются точечно `DELETE /api/photos/{id}/permanent`. Корзину целиком очищать нельзя, пока не восстановлены оригиналы `N2`: они лежат там же.
- Заархивировать изменение после прохождения ревью проекта.
- Проверить результат архивации.