diff --git a/HISTORY/OPUS/2026-09-22_modifier_architecture_project.md b/HISTORY/OPUS/2026-09-22_modifier_architecture_project.md new file mode 100644 index 0000000..1cd6d4e --- /dev/null +++ b/HISTORY/OPUS/2026-09-22_modifier_architecture_project.md @@ -0,0 +1,115 @@ +# Opus: архитектура модификаторов (project, полный) — 2026-09-22 + +Источник: ответ Opus на `prompt_for_opus_modifier_architecture_full.md`. + +## Ключевая модель + +Модификатор — **декларативная проекция подмножества полей родителя**, а не «действие». +Отсюда: +- один **reconcile** (Create ≡ Update), не два разных пути; +- payload всегда **полный по своим полям** (не дельта); +- источник истины — родитель; модификатор в state хранит только read-back. + +--- + +## 1. Сравнить и применить — полный payload, не дельта + +Дельта запрещена: бэкенд трактует отсутствующий параметр как reset-to-default (класс A). +Reconcile: +1. взять все `SchemaParams`; +2. заданные пользователем → значение из плана; +3. незаданные → live → default (уже в `operation_run_bycode.go`); +4. drift в Read — сравнение модели с `state_params` родителя (`state_refresh.go`). + +## 2. Досылка незаданных (заливы A и B) — канон + +`CompactParams` в шаблоне + досылка в клиенте — два конца одного бага. +Правило по приоритету (уже в `operation_run_bycode.go:98-118`): + +| Ситуация | Что слать | +|---|---| +| задан пользователем | значение из плана | +| не задан, есть live ParamValue | live | +| не задан, нет live, есть DefaultValue | дефолт | +| не задан, ничего нет | **пропустить** (не синтезировать) | + +**Дыра:** `CompactParams` в `modifier.go:92` выкидывает пустые ДО клиента (теряется +«задал пусто» vs «не задал»). → Убрать `CompactParams` из шаблона модификатора, +передавать map напрямую. Единственная точка решения — клиент. `CompactParams` +оставить только для instance-ресурсов. + +## 3. Delete / rollback + +No-op Delete = скрытый drift (класс D). Пока обратный payload не подтверждён — +допустимы 3 стратегии через флаг YAML `delete_strategy`: +1. `noop_warn` — удалить из state + `AddWarning` (дефолт для необратимых: `ip_space`); +2. `inverse` — если есть «выключающие» значения в modify (напр. `needEnableAVI:false`); +3. `error` — запретить destroy (`AddError`), если откат критичен. + +Обратный payload — та же modify с выключающими значениями. Для `ip_space` его нет → только `noop_warn`. + +## 4. Idempotency + ID + +- ID = **идентичность** (родитель + имя модификатора) = `instanceUID:modifierName`. + Это правильно и не должен меняться per-apply. opUid в ID **не класть** (иначе replace). + opUid — только в лог/приватный state. +- **Двойная аллокация (класс E)** защищается не ID, а **идемпотентностью modify**: + pre-check «desired == current» → пропустить run. Для `ip_space` перед modify читать + `state_params`; если целевое достигнуто — skip. + +## 5. Связь с родителем + +- `_id` — ссылка на родителя (Required, уже так). `depends_on` не нужен — + пользователь передаёт UUID. +- Borrow state не нужен: Read тянет `state_params` родителя по UUID. +- Родителя нет (`ShouldRemoveFromState`) → модификатор удаляется из state (уже есть). + +## 6. Create vs Update + +Единый `reconcile(ctx, plan)`; Create и Update вызывают его (устраняет дубль веток). + +## 7. Полный перечень кейсов (13 шт) + +| # | Кейс | Поведение | +|---|---|---| +| 1 | create родителя → create модификатора | reconcile, полный payload | +| 2 | изменение одного поля | полный payload, соседние не сбрасываются (A) | +| 3 | partial params | досылка live→default→skip (B) | +| 4 | `integer > 0` без значения/дефолта | пропустить (не слать `"0"`) | +| 5 | `is_modifiable:true` (`needEnableAVI`) | не CreateOnly, менять без replace (C) | +| 6 | destroy модификатора | по `delete_strategy` (D) | +| 7 | replace/taint | reconcile + idempotency pre-check (E) | +| 8 | повторный apply без изменений | desired==current → skip | +| 9 | родитель удалён | remove из state | +| 10 | API не вернул код в state_params | unknown→null (уже) | +| 11 | два модификатора разных типов | разные ID | +| 12 | operation in progress | waitForInstanceIdle (уже) | +| 13 | drift на платформе | Read → план показывает изменение | + +## Сводка мест правки + +| Место | Правка | +|---|---| +| `modifier.go:92` | убрать `CompactParams` → прямой map (п.2) | +| `modifier.go:77` | единый `reconcile()` (п.6) | +| `modifier.go:156` | `delete_strategy` (п.3) | +| `modifier.go:100` | ID = `instanceUID:modifierName` (п.4) | +| `RunOperationByCodeWithTimeout` / reconcile | idempotency pre-check (п.4,7) | +| `params.go:116` | учитывать modifier-канал/`is_modifiable` (класс C, кейс 5) | +| loader модификаторов | YAML-поля `delete_strategy`, `idempotency` | +| `operation_run_bycode.go` | оставить как есть (guard корректен) | + +## Открытые вопросы к Opus (не закрыты ответом) + +1. **Где брать значения для `inverse`-стратегии Delete?** Для `network` «выключающие» + значения — это хардкод per-modifier? Как их задать декларативно в YAML, без хардкода + в генераторе? +2. **Формат YAML новых полей.** Точная схема `delete_strategy` и `idempotency`: + enum-значения, дефолты, валидация (fail-fast на неизвестных). +3. **Pre-check «desired == current» — где читать current?** Через + `RefreshResourceState`/`state_params` или отдельный GET? Как сериализовать сравнение + для map-fixed/array-map-fixed (порядок ключей)? +4. **Как пометить модификатор «idempotency: check_before_run» на уровне YAML** + (а не хардкодом в коде reconcile)? +5. **Что если желаемое == текущее, но была «частичная» ошибка ранее** — пропускать run + безопасно всегда, или есть исключения?