docs(opus): задокументировать архитектуру модификаторов + открытые вопросы

This commit is contained in:
Repinoid
2026-09-22 20:15:59 +03:00
parent bea39508f5
commit 3b3cfc85c0
@@ -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. Связь с родителем
- `<service>_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
безопасно всегда, или есть исключения?