POST /api/v1/internal/snapshots/{id}/detach — Отвязать snapshot (замена блока целиком, m2m news)
Перевод SelectionSnapshot из Active в Detached. Единственный способ «изменить» блок-подборку
опубликованного поста (INV-SEL-001): автор поста заменяет блок целиком — создаётся новый snapshot,
старый отвязывается этим методом. Второй сценарий — удаление поста в news.
Назначение
Snapshot иммутабелен (INV-SEL-001): ни владелец подборки, ни автор поста не редактируют его Content.
Когда автор поста хочет показать другой состав, news оркестрирует замену: сначала снимает новый
snapshot (create-selection-snapshot.md) и записывает
его id в блок поста, затем отвязывает прежний (DetachReason = BlockReplaced). Отвязанный snapshot не
удаляется никогда (INV-SNP-006) — история версий поста в news продолжает на него ссылаться.
Дом полей — ../domain/selection-snapshot.md.
| Поле | Значение |
|---|
| Сервис/контекст | selections |
| API area | internal |
| Feature group | snapshots |
| Статус | draft |
| Документ | docs/06-services/selections/api/internal/detach-selection-snapshot.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | ExternalSystem — m2m-клиент news (INV-SNP-003) |
| Auth policy / Permissions | authorization policy InternalNewsOnly |
| Scope (RBAC) | не применяется; право автора менять блок поста проверяет news до вызова |
| Record-level | не применяется (доверенный m2m) |
| Tenant isolation | не применяется — вне tenant (ADR-0052 п.3) |
| Audit | DetachedAt, DetachReason, UpdatedAt на snapshot-е; лог с RequestedByUserId |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/internal/snapshots/{id:guid}/detach |
| Success status | 200 (в т.ч. идемпотентный повтор по уже Detached) |
| Idempotency header | не нужен: переход Active → Detached однократен, повтор — no-op (INV-SNP-005) |
| Correlation | сквозной trace через заголовок traceparent, из news |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
SnapshotId | route ({id}) | Guid | Да | != Guid.Empty; snapshot существует (GetById) | — |
Reason | body | SnapshotDetachReason | Да | ∈ {BlockReplaced, PostDeleted} (enum, хранится как text) | trusted (m2m) |
RequestedByUserId | body | Guid | Да | != Guid.Empty — автор поста / инициатор удаления | аудит-поле |
Модель ответа DetachSelectionSnapshotResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
SnapshotId | Guid | Да | SelectionSnapshot.Id | — | Echo. |
Status | string (enum SnapshotStatus) | Да | SelectionSnapshot.Status | — | Всегда Detached после успеха. |
DetachedAt | DateTimeOffset | Да | SelectionSnapshot.DetachedAt | — | Момент отвязки (первичной — при идемпотентном повторе возвращается исходный). |
DetachReason | string (enum SnapshotDetachReason) | Да | SelectionSnapshot.DetachReason | — | Причина первичной отвязки (повтор с другой причиной её не перезаписывает). |
Алгоритм
- Контекст и доступ. Доверенный m2m
news (INV-SNP-003); отдельная проверка идемпотентности не
нужна — она встроена в переход (шаг 3).
- Проверка входа и предусловий. Вызыватель —
news (INV-SNP-003), иначе 403. SnapshotId
непустой; Reason ∈ enum (неизвестное значение → VALIDATION_ERROR); RequestedByUserId непустой.
Snapshot должен существовать, иначе SELECTIONS_SNAPSHOT_NOT_FOUND (404).
- Бизнес-правила и переходы. Отвязка (контент не читается и не меняется, INV-SEL-001):
- статус
Active → переход Active → Detached: проставляются DetachedAt, DetachReason,
UpdatedAt; фиксируется факт SelectionSnapshotDetachedEvent;
- статус
Detached → идемпотентный no-op (INV-SNP-005): поля не меняются (включая DetachReason —
история первичной причины сохраняется), события нет, результат — успех с текущим состоянием.
- Что меняется. Только при фактическом переходе — обновление snapshot-а в одном согласованном
изменении. Конфликт с гонкой «замена блока» × «удаление поста» → проигравший перечитывает и
попадает в идемпотентную ветку шага 3 (один внутренний повтор).
- Результат.
SnapshotId, Status = Detached, DetachedAt, DetachReason → 200.
Диаграмма (полный флоу замены блока целиком, INV-SEL-001)
Побочные эффекты
| Тип | Детали |
|---|
| Проекции / поиск | Content snapshot-а не изменяется (INV-SEL-001), читается историей постов news и после отвязки |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | Пустые Guid / Reason вне enum | «Некорректный запрос» | no retry |
SELECTIONS_SNAPSHOT_NOT_FOUND | 404 | Snapshot не существует | «Snapshot не найден» | no retry |
FORBIDDEN | 403 | Вызыватель не m2m news (INV-SNP-003) | — | no retry |
Повтор при сетевом сбое безопасен (INV-SNP-005); news может вызывать detach в saga удаления поста
любое число раз.
Кросс-сервисный контракт (selections ↔ news)
selections ожидает от news: валидный m2m токен (policy InternalNewsOnly); SnapshotId,
Reason ∈ {BlockReplaced, PostDeleted} и RequestedByUserId. news вызывает detach как шаг
оркестрации (замена блока целиком или удаление поста); порядок «создать новый → переключить блок →
отвязать старый» — ответственность news.
selections предоставляет news: гарантию однократного перехода Active → Detached
(INV-SNP-005) с идемпотентным повтором; snapshot никогда не удаляется (INV-SNP-006) — история
версий поста в news остаётся разрешимой. Content при отвязке не читается и не меняется
(INV-SEL-001).
- Гонки и восстановление: конфликт «замена блока» × «удаление поста» разрешается оптимистической
блокировкой — проигравший перечитывает и попадает в идемпотентную ветку; осиротевший
Active-дубль
при сбое между шагами убирает повторный detach-вызов news.
Предусловия и постусловия
- Предусловия: вызыватель — доверенный m2m
news; SnapshotId непустой; Reason ∈ enum; snapshot
существует.
- Постусловия при фактическом переходе (
Active → Detached): у snapshot-а проставлены
Status = Detached, DetachedAt, DetachReason, UpdatedAt; зафиксирован факт
SelectionSnapshotDetachedEvent; Content не изменён (INV-SEL-001); строка сохранена (INV-SNP-006).
- Постусловия при идемпотентном повторе (уже
Detached): поля не изменяются (включая исходный
DetachReason), событий нет, ответ — успех с текущим состоянием.
- Постусловия при отказе (400/403/404): статус snapshot-а не изменён.
Acceptance Criteria
- Given
Active-snapshot, When news вызывает detach с Reason = BlockReplaced, Then 200,
Status = Detached, заполнены DetachedAt/DetachReason; зафиксирован SelectionSnapshotDetachedEvent.
- Given только что отвязанный snapshot, When
news повторяет detach (тот же или другой Reason), Then
200 без изменений — DetachReason первичной отвязки не перезаписывается (INV-SNP-005),
повторного события нет.
- Given
SnapshotId, которого нет, When detach, Then 404 SELECTIONS_SNAPSHOT_NOT_FOUND.
- Given
Reason вне enum, When detach, Then 400 VALIDATION_ERROR.
- Given вызыватель не
news, When detach, Then 403 FORBIDDEN (INV-SNP-003).
- Given одновременные «замена блока» (
BlockReplaced) и «удаление поста» (PostDeleted) на один
snapshot, When обе доходят, Then первый переход фиксируется, второй — идемпотентный no-op (побеждает
первый по оптимистической блокировке; причина первичной отвязки сохраняется).
- Given отвязанный snapshot, When
news рендерит старую версию поста, Then Content по-прежнему
доступен через get-selection-snapshot.md (INV-SNP-006).
Совместимость и наблюдаемость
- Добавление значения в
SnapshotDetachReason — additive (неизвестное значение отклоняет валидатор
до обновления сервиса — деплой selections раньше news).
- Logs:
SnapshotId, Reason, RequestedByUserId, был ли no-op; metrics:
selections_snapshot_detached_total{reason,idempotent_hit}; traces: сквозной span из news.
Runbook: «пост показывает не тот состав» — сверка SnapshotId блока ↔ статусов snapshot-ов
(../README.md).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы