> ⛔ **ЛОЖНЫЙ ПУТЬ — ОТМЕНЕНО (пометка 2026-09-24). ТАК ДЕЛАТЬ НЕЛЬЗЯ.** > Архитектура отменённого захода (`kind: modifier` в YAML + реестр в генераторе). История. > Актуально: `NOTES/40_chat_summaries/CHAT_RESUME_IAC_2026-09-24.md`. # 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 безопасно всегда, или есть исключения? --- # Ответы Opus №2 (уточнения по 5 вопросам) ## 1. inverse-Delete — только декларативно в YAML, хардкод запрещён Обратный payload зависит от параметров: `needEnableAVI:false` валиден, а `virtualServicesCount` (`integer > 0`) обнулить нечем → `0` невозможен. Значит inverse-значения задаются **явным блоком в YAML**. Если хоть один параметр не имеет валидного inverse — стратегия `inverse` недопустима (fail-fast в загрузчике). Для `ip_space` inverse нет вообще → только `noop_warn`. ## 2. Точная схема YAML новых полей ```yaml operations: - kind: modifier modifier: network action: modify delete_strategy: noop_warn # enum: noop_warn | inverse | error idempotency: check_before_run # enum: none | check_before_run delete_params: # обязателен ТОЛЬКО при delete_strategy: inverse - code: needEnableAVI value: "false" params: [...] ``` Go-контракт (`OperationSpec`): ```go DeleteStrategy string `yaml:"delete_strategy,omitempty"` // "" → noop_warn Idempotency string `yaml:"idempotency,omitempty"` // "" → none DeleteParams []ParamSpec `yaml:"delete_params,omitempty"` ``` Дефолты: `delete_strategy` → `noop_warn`; `idempotency` → `none`. Fail-fast в `ValidateSpec`: значение вне enum → ошибка; `inverse` с пустым `delete_params` → ошибка; `delete_params.code` нет в `params` → ошибка; inverse-значение нарушает constraint параметра → ошибка на этапе генерации. `GenModifier` получает `DeleteStrategy`, `Idempotency`, `DeleteParams`. ## 3. Откуда читать current + как сравнивать **Читать из `state_params`, отдельный GET не делать** (это уже источник истины для Read; второй источник = риск рассогласования). Сравнение по типу: | Тип | Как сравнивать | |---|---| | bool/int/string | равенство после `normalizeUniversalValueV6` | | map-fixed | `JSONStringsEquivalent` (игнор порядка ключей) | | array-map-fixed | deep-equal с сохранением **порядка элементов** (порядок значим) | Порядок ключей map-fixed — нормализовать (не значим). Порядок элементов array-map-fixed — НЕ нормализовать (значим). ## 4. idempotency декларативно Поле `idempotency` на modify-операции в YAML → `ValidateSpec` → `GenModifier.Idempotency` → шаблон `modifier.go` в `reconcile()` эмитит pre-check `{{- if eq .Idempotency "check_before_run" }}`. Для `ip_space` — в YAML; для остальных — дефолт `none`. ## 5. Когда безопасно skip run при desired == current (НЕ всегда) Три условия безопасного skip: 1. **Инстанс idle** — если pending/in-progress, сначала `waitForInstanceIdle`, потом перечитать `state_params` (иначе mid-flight аллокация даст ложное «уже равно»). 2. **current из живого state_params, НЕ из TF-state** — после частичной ошибки TF-state может врать, а state_params отражает реальную платформу. 3. **Сравнение по всем полям, не по одному** — skip только при совпадении ВСЕХ полей. Итог: ``` idle? нет → wait, re-read всё-live == всё-desired? да → skip run иначе → reconcile (полный payload) ``` --- # Ответы Opus №3 (сверка с фактическим кодом, расхождения + сомнения) ## Факт №1: контракт — в lib, поведение — в GenModifier Подтверждено: `delete_strategy`/`idempotency`/`delete_params` добавляются в **`lib.OperationSpec`** (`TOOLS/lib/types.go`), resource-generator получает через алиас (`types.go:19`). Ссылка «types.go:39» была неточной — канон в lib. Граница: | Где | Что | |---|---| | `lib.OperationSpec` / `lib.ParamSpec` | всё из YAML, видно обоим генераторам | | `GenModifier` (локально) | производные для шаблона, флаги `Needs*`, готовый inverse-список | Правило: парсится из YAML → lib; вычисляется загрузчиком для шаблона → GenModifier. ## Факт №2: pre-check — в `core` (вариант A), экспортировать сравнение `normalizeUniversalValueV6` приватная и требует `universalCfsParam` — в `resources_core` этих данных нет. Pre-check делать **в `core`**, не в resources_core и не в шаблоне. Конкретно — экспортированный метод в `core`, вызывается из `operation_run_bycode.go` сразу после `fetchOperationCfsParams`: ```go func (c *UniversalClient) modifierDesiredEqualsCurrent( desired map[string]string, cfsParams []universalCfsParam) bool ``` Сравнение: нормализовать обе стороны через `normalizeUniversalValueV6`; для map-fixed/array-map-fixed — JSON-эквивалентность. Но `JSONStringsEquivalent` лежит в `resources_core` → импорт в `core` даст **цикл**. Вынести JSON-эквивалентность в нейтральный пакет (`core/jsonutil` или в сам `core`) и переиспользовать в обоих местах. Вариант C (только `JSONStringsEquivalent` без нормализации) — **отклонён** (ложный diff `true`/`1`). Управление: `RunInstanceOperationUniversalByCode` получает флаг `idempotent` (из `GenModifier.Idempotency` → шаблон → параметр вызова); idle-гейт выше pre-check. ## Дополнительные сомнения (ответы) 1. **Idempotency и полный payload НЕ конфликтуют** (разные уровни). Бинарно на весь модификатор: `ALL == ALL` → skip целиком; любое расхождение → полный payload. Полудельты нет. 2. **Частичный inverse — допустим и правилен.** `delete_params` покрывает только обратимые поля; необратимые/constraint просто не входят. Fail-fast смягчить: ошибка не «inverse обязан покрыть всё», а «код в delete_params обязан существовать в params и value удовлетворять constraint». Delete при inverse = modify с delete_params + досылка live остальных (полный payload). 3. **`noop_warn` дефолт — оставить, но критичные — вручную `error`.** Дефолт мягкий (`noop_warn`, всегда с `AddWarning`), а необратимые (`ip_space`) автор спеки явно помечает `delete_strategy: error` в YAML. Генератор сам не решает обратимо/необратимо.