revise August 2024 feature flag articles
Build and deploy / deploy (push) Successful in 18s

This commit is contained in:
2026-07-31 16:27:37 +03:00
parent c8bb90f435
commit 04dbb60f16
7 changed files with 1005 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 238 из 358 созданных материалов. Остальные 120 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 241 из 358 созданных материалов. Остальные 117 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+100
View File
@@ -0,0 +1,100 @@
# P78 — август 2024: feature flags — draft review
## Граница пакета
P78 заменяет только три августовских черновика через overlay `web/scripts/upgrade-2024-08.mjs`:
- `editorial-2024-08-practice-feature-flags` — «Фича-флаг как контракт выпуска: как не оставить переключатель навсегда»;
- `editorial-2024-08-mechanism-feature-flags` — «Где принимать решение флага: server, client и журнал exposure»;
- `editorial-2024-08-field-feature-flags` — «Выключить флаг — не значит удалить риск: rollout, rollback и cleanup».
Скрипт экспортирует только `revisions`; `web/data/articles.json` не менялся. В пакете нет registry, README, статуса, staged changes, commit или push. Фикстура работает только с versioned fixed synthetic objects в памяти: она не читает и не пишет flag store, файлы, сеть, часы, CI, telemetry, user data или production.
## Source report и историческая граница
| Источник | Закрепление и факт, используемый в тексте | Ограничение |
| --- | --- | --- |
| [OpenFeature Specification v0.6.0 release, 16.05.2023](https://github.com/open-feature/spec/releases/tag/v0.6.0) | Датированный versioned release, доступный к августу 2024; включает provider events, initialization и shutdown. | Не доказывает одинаковую реализацию provider и не задаёт exactly-once для какого-либо события. |
| [OpenFeature v0.6.0 — Flag Evaluation API](https://github.com/open-feature/spec/blob/v0.6.0/specification/sections/01-flag-evaluation.md) | Раздел versioned release: typed evaluation с flag key, default value и optional evaluation context; abnormal execution возвращает default. | API-контракт, не policy TTL, секретов, rollout или удаления флага. |
| [OpenFeature v0.6.0 — Evaluation Context](https://github.com/open-feature/spec/blob/v0.6.0/specification/sections/03-evaluation-context.md) | Раздел versioned release: targeting key идентифицирует subject, а context имеет определённый merge order. | Раздел experimental; не обещает distributed snapshot или непрерывную консистентность когорты. |
| [OpenFeature v0.6.0 — Events](https://github.com/open-feature/spec/blob/v0.6.0/specification/sections/05-events.md) | Раздел versioned release: provider readiness, error, configuration changed и stale events. | Раздел experimental; это не бизнес-exposure и не delivery contract. |
| [Unleash Feature Toggles — immutable commit `341703978af887a364a9d872d9e3a906ae4dbbcb`, 21.08.2024](https://github.com/Unleash/unleash/blob/341703978af887a364a9d872d9e3a906ae4dbbcb/website/docs/reference/feature-toggles.mdx) | Неизменяемый первичный Git-снимок в пределах августа 2024: type и description, activation strategies по environment, disabled toggle evaluates false. | Не задаёт owner, expiry, наблюдение, метрики или cleanup process произвольной команды. |
| [Unleash Front-end API — immutable commit `4ae65d6d5e095d5c056ae63c60afcb09dd6cfe5b`, 24.05.2024](https://github.com/Unleash/unleash/blob/4ae65d6d5e095d5c056ae63c60afcb09dd6cfe5b/website/docs/reference/front-end-api.md) | Неизменяемый первичный Git-снимок до августа 2024: Front-end API, FRONTEND token, CORS и refresh interval с random offset для Unleash 4.18+. | Не является переносимой гарантией момента refresh, client-side evaluation или единой конфигурации у всех клиентов. |
Исторические меняющиеся документы закреплены либо датированным релизом `v0.6.0`, либо точным immutable Git commit. Архивные URL и старый домен документации Unleash из P78 удалены.
### Обязательная проверка ссылок
Перед выпуском выполнен обычный HTTPS GET для всех шести URL выше:
```sh
curl -L --fail --silent --show-error --output /dev/null \
--write-out '%{http_code} %{url_effective}\n' URL
```
Команда запускалась без `-k`/`--insecure`, без TLS bypass, без заголовка авторизации, credentials или cookie jar. Результат для каждого URL — `200`; redirect ведёт на тот же публичный GitHub URL. Проверка выполнена 31.07.2026 для доступа к закреплённым историческим источникам, а не как утверждение об их текущем содержимом.
## Проход 1 — факты и техника
- У каждого материала первые два абзаца называют проблему и цену: долговая ветвистость release-флага; неясный authoritative evaluator и лишний context; false/100% без удаления runtime dependency.
- Цепочка в текстах одинаковая и проверяемая: «симптом → причина → проверка → действие». Это не рекламный нарратив и не runbook, притворяющийся production-инструментом.
- В mechanism-материале строго разделены evaluation, exposure candidate, render confirmation и domain effect. Provider events OpenFeature не выданы за бизнес-события. Ни одна статья не обещает exactly-once; при необходимости назван idempotency key на стороне потребителя.
- В practice-материале owner, expiry и deletion path честно названы командной policy, а не возможностью OpenFeature или Unleash.
- В field-материале 0/5/25/100, сигналы и сценарии — `fixed synthetic model`. Это не трафик, SLO, incident, пользовательские данные, интервью или production-метрики.
- Фикстура принимает только канонический fixed synthetic input. Она отвергает unknown keys, file/network/clock/telemetry/production markers, неверную версию и scope, разрежённые массивы, подменённые карточки/решения, циклический input/report и forged cleanup draft. Отдельная проверка доказывает, что некорректный input не меняет frozen fixed record.
## Проход 2 — редактирование и голос
Автор августа 2024 — системный практик: короткие технические абзацы, конкретный контекст и явная граница вывода. В каждом тексте есть постановка проблемы в первых двух абзацах, таблица решения, компактный воспроизводимый код или конфиг, ограничения и один следующий проверяемый шаг.
| Slug | Основное тело | Редакторский контроль |
| --- | ---: | --- |
| `editorial-2024-08-practice-feature-flags` | 9 586 знаков | Контракт флага проходит от owner и audience к наблюдению и removal; сигнал не назван доказательством эффекта. |
| `editorial-2024-08-mechanism-feature-flags` | 11 878 знаков | Граница server/client, cohort key и event semantics разобраны без магических гарантий provider. |
| `editorial-2024-08-field-feature-flags` | 12 045 знаков | Rollout, rollback и cleanup разведены; 100% и disabled явно не названы завершением удаления. |
Диапазон 5 000–15 000 знаков соблюдён для всех трёх тел. Английские термины оставлены только там, где они обозначают API, контракт или артефакт; рядом дано техническое значение, а не рекламная формулировка.
## Проход 3 — визуал и выпуск
Три SVG нарисованы вручную; в них нет `<script>`, `<foreignObject>`, `javascript:`, `data:image` или event handlers. После первого Sharp-render на 375 px длинные подписи cleanup/delivery были разбиты на короткие строки; затем сделан повторный визуальный просмотр всех трёх мобильных PNG.
| Артефакт | Смысл | Sharp-render на ширине 375 px |
| --- | --- | --- |
| `feature-flags-2024-decision-tree.svg` | Карточка контракта, граница решения, наблюдение и cleanup path. | `375×552` |
| `feature-flags-2024-rollout-timeline.svg` | Server/client decision flow и пунктирная граница delivery. | `375×510` |
| `feature-flags-2024-cleanup-gate.svg` | Synthetic rollout, rollback и отдельный cleanup gate. | `375×573` |
Выполненные команды и результаты:
```text
node --check web/scripts/upgrade-2024-08.mjs
exit 0
node web/scripts/upgrade-2024-08.mjs --verify-fixture
PASS fixture: 35/35 assertions
(cd web && npm run audit:draft -- scripts/upgrade-2024-08.mjs)
PASS editorial-2024-08-practice-feature-flags: 9586 body chars
PASS editorial-2024-08-mechanism-feature-flags: 11878 body chars
PASS editorial-2024-08-field-feature-flags: 12045 body chars
xmllint --noout <three P78 SVG files>
exit 0
rg -n -i '<(script|foreignObject)\\b|javascript:|data:image|[[:space:]]on[a-z]+[[:space:]]*=' <three P78 SVG files>
exit 1, no matches (expected clean result)
Sharp render of all three SVG files at width 375
exit 0; 375×552, 375×510, 375×573
```
Полная база не собиралась: это явно оставлено главному агенту после интеграции. P78 не staged, не committed и не pushed.
## Выпуск после трёх независимых проходов
1. **Факты и техника.** Редактор повторно сверил OpenFeature v0.6.0 release и raw tagged specification: typed evaluation, optional context, default value при abnormal execution, experimental context/events и их границы совпадают с текстом. Два Unleash immutable commits подтвердили environment strategies, false для disabled toggle, Front-end API, FRONTEND token, CORS и refresh с random offset. `node --check` и fixture — **PASS 35/35**.
2. **Редактура и голос.** В публичном field-материале служебное имя партии `P78` заменено на «учебная фикстура». Остальные три текста проверены на разные задачи: release contract, server/client boundary и cleanup. Объёмы после вычитки — **9 586**, **11 878**, **12 049** знаков; каждое решение начинает с наблюдаемой цены и заканчивается проверяемым действием.
3. **Визуал и выпуск.** Три SVG прошли XML/safety scan, повторный Sharp-render и ручной просмотр на 375 px: decision tree, delivery boundary и cleanup gate остаются читаемыми. После календарного подключения перед сентябрём `audit:articles` подтвердил для каждого slug 1 figure, 2 table и 1 code example; registry содержит **232** уникальные ревизии. `npm run build` завершился успешно: **374** статические страницы.
В выпуск включаются только пять файлов августа, обновления registry/production README и эта приёмка. Пользовательские изменения, черновики октября/ноября и исходный `articles.json` исключены.
+2
View File
@@ -74,6 +74,7 @@ import { revisions as april2024Revisions } from '../scripts/upgrade-2024-04.mjs'
import { revisions as may2024Revisions } from '../scripts/upgrade-2024-05.mjs';
import { revisions as june2024Revisions } from '../scripts/upgrade-2024-06.mjs';
import { revisions as july2024Revisions } from '../scripts/upgrade-2024-07.mjs';
import { revisions as august2024Revisions } from '../scripts/upgrade-2024-08.mjs';
import { revisions as september2024Revisions } from '../scripts/upgrade-2024-09.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
@@ -154,5 +155,6 @@ export const editorialRevisions = [
...may2024Revisions,
...june2024Revisions,
...july2024Revisions,
...august2024Revisions,
...september2024Revisions,
];
@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1100" role="img" aria-labelledby="title desc">
<title id="title">Синтетическая временная схема rollout, rollback и cleanup feature flag</title>
<desc id="desc">Схема показывает синтетические этапы rollout ноль, пять, двадцать пять и сто процентов. При stop condition выполняется rollback к fallback. После стабильного финального варианта отдельный cleanup gate требует удалить ветки и конфигурацию.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L10,5 L0,10 Z" fill="#74d5c0"/>
</marker>
<marker id="arrow-warn" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L10,5 L0,10 Z" fill="#e9b45f"/>
</marker>
</defs>
<rect width="720" height="1100" rx="28" fill="#0f1d2e"/>
<rect x="28" y="26" width="664" height="112" rx="20" fill="#16334b" stroke="#3f6680"/>
<text x="58" y="72" fill="#f5fbff" font-family="Arial, sans-serif" font-size="28" font-weight="700">Выключить — не значит удалить риск</text>
<text x="58" y="105" fill="#b8cedd" font-family="Arial, sans-serif" font-size="18">0 → 5 → 25 → 100% — fixed synthetic teaching model.</text>
<text x="70" y="183" fill="#89abc1" font-family="Arial, sans-serif" font-size="17" font-weight="700">ROLLOUT: один cohort key и заранее записанная stop condition</text>
<path d="M144 252 L576 252" fill="none" stroke="#74d5c0" stroke-width="4" marker-end="url(#arrow)"/>
<circle cx="144" cy="252" r="18" fill="#1d4a44" stroke="#74d5c0" stroke-width="3"/>
<circle cx="288" cy="252" r="18" fill="#1d4a44" stroke="#74d5c0" stroke-width="3"/>
<circle cx="432" cy="252" r="18" fill="#1d4a44" stroke="#74d5c0" stroke-width="3"/>
<circle cx="576" cy="252" r="18" fill="#1d4a44" stroke="#74d5c0" stroke-width="3"/>
<text x="116" y="215" fill="#e7fff7" font-family="Arial, sans-serif" font-size="23" font-weight="700">0%</text>
<text x="258" y="215" fill="#e7fff7" font-family="Arial, sans-serif" font-size="23" font-weight="700">5%</text>
<text x="394" y="215" fill="#e7fff7" font-family="Arial, sans-serif" font-size="23" font-weight="700">25%</text>
<text x="534" y="215" fill="#e7fff7" font-family="Arial, sans-serif" font-size="23" font-weight="700">100%</text>
<text x="82" y="297" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">baseline</text>
<text x="226" y="297" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">observe</text>
<text x="364" y="297" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">compare</text>
<text x="530" y="297" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">freeze?</text>
<rect x="72" y="338" width="576" height="112" rx="18" fill="#16334b" stroke="#3f6680" stroke-width="2"/>
<text x="102" y="378" fill="#f5fbff" font-family="Arial, sans-serif" font-size="21" font-weight="700">На каждом этапе: проверить заранее выбранные сигналы</text>
<text x="102" y="407" fill="#c2d9e8" font-family="Arial, sans-serif" font-size="17">outcome · error ratio · fallback path · effective config version</text>
<text x="102" y="434" fill="#86acc2" font-family="Arial, sans-serif" font-size="15">Схема не задаёт безопасный процент, окно наблюдения или production SLO.</text>
<path d="M360 450 L360 502" fill="none" stroke="#e9b45f" stroke-width="3" marker-end="url(#arrow-warn)"/>
<rect x="152" y="516" width="416" height="86" rx="18" fill="#513f25" stroke="#e9b45f" stroke-width="2"/>
<text x="182" y="551" fill="#fff1cf" font-family="Arial, sans-serif" font-size="22" font-weight="700">Stop condition сработала?</text>
<text x="182" y="579" fill="#f2d5a1" font-family="Arial, sans-serif" font-size="16">Решение фиксируется до включения следующей стадии.</text>
<path d="M152 559 L90 559 L90 688" fill="none" stroke="#e08091" stroke-width="3" marker-end="url(#arrow-warn)"/>
<text x="99" y="540" fill="#f4c979" font-family="Arial, sans-serif" font-size="17" font-weight="700">да</text>
<rect x="40" y="704" width="240" height="156" rx="18" fill="#4b2932" stroke="#e08091" stroke-width="2"/>
<text x="66" y="745" fill="#ffe9ed" font-family="Arial, sans-serif" font-size="21" font-weight="700">Rollback / fallback</text>
<text x="66" y="777" fill="#f7c6cf" font-family="Arial, sans-serif" font-size="16">Вернуть аудиторию к 0%.</text>
<text x="66" y="802" fill="#f7c6cf" font-family="Arial, sans-serif" font-size="16">Сохранить старый путь.</text>
<text x="66" y="827" fill="#f7c6cf" font-family="Arial, sans-serif" font-size="16">Расследовать отдельно.</text>
<text x="66" y="850" fill="#f1aab7" font-family="Arial, sans-serif" font-size="14">Disable — mitigation, не cleanup.</text>
<path d="M568 559 L630 559 L630 688" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<text x="570" y="540" fill="#9ee9d8" font-family="Arial, sans-serif" font-size="17" font-weight="700">нет</text>
<rect x="440" y="704" width="240" height="156" rx="18" fill="#1d4a44" stroke="#74d5c0" stroke-width="2"/>
<text x="466" y="745" fill="#e7fff7" font-family="Arial, sans-serif" font-size="21" font-weight="700">Cleanup gate</text>
<text x="466" y="777" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">final variant frozen</text>
<text x="466" y="802" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">branches removed</text>
<text x="466" y="827" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">config removed</text>
<text x="466" y="850" fill="#9ee9d8" font-family="Arial, sans-serif" font-size="14">behaviour test остаётся.</text>
<path d="M560 860 L560 916" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="72" y="930" width="576" height="136" rx="18" fill="#24415c" stroke="#a6d6ff" stroke-width="2"/>
<text x="102" y="970" fill="#eff9ff" font-family="Arial, sans-serif" font-size="22" font-weight="700">Только после gate: удалить runtime dependency</text>
<text x="102" y="998" fill="#c4e7ff" font-family="Arial, sans-serif" font-size="17">кодовые ветки · configuration references</text>
<text x="102" y="1023" fill="#c4e7ff" font-family="Arial, sans-serif" font-size="17">flag-specific tests</text>
<text x="102" y="1049" fill="#91bbd7" font-family="Arial, sans-serif" font-size="15">Результат: одна проверяемая ветка поведения.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1060" role="img" aria-labelledby="title desc">
<title id="title">Дерево решения для release feature flag</title>
<desc id="desc">Схема показывает, что до создания release-флага нужно заполнить карточку контракта. Без карточки работа уходит на review. После карточки отдельно выбираются server-side protected decision, client-side presentation и cleanup path.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L10,5 L0,10 Z" fill="#74d5c0"/>
</marker>
</defs>
<rect width="720" height="1060" rx="28" fill="#0f1d2e"/>
<rect x="28" y="26" width="664" height="112" rx="20" fill="#16334b" stroke="#3f6680"/>
<text x="58" y="72" fill="#f5fbff" font-family="Arial, sans-serif" font-size="28" font-weight="700">Фича-флаг — контракт выпуска</text>
<text x="58" y="105" fill="#b8cedd" font-family="Arial, sans-serif" font-size="18">Сначала граница решения и removal path, потом boolean.</text>
<rect x="72" y="174" width="576" height="88" rx="18" fill="#4b2932" stroke="#e08091" stroke-width="2"/>
<text x="102" y="211" fill="#ffe9ed" font-family="Arial, sans-serif" font-size="22" font-weight="700">Симптом</text>
<text x="102" y="239" fill="#f7c6cf" font-family="Arial, sans-serif" font-size="18">Временный switch создан без срока и владельца.</text>
<path d="M360 262 L360 304" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="152" y="316" width="416" height="94" rx="18" fill="#174760" stroke="#74d5c0" stroke-width="2"/>
<text x="182" y="354" fill="#eafff9" font-family="Arial, sans-serif" font-size="23" font-weight="700">Есть карточка контракта?</text>
<text x="182" y="383" fill="#b5e6da" font-family="Arial, sans-serif" font-size="17">owner · audience · fallback · expiry · delete</text>
<path d="M152 364 L96 364 L96 468" fill="none" stroke="#e9b45f" stroke-width="3" marker-end="url(#arrow)"/>
<text x="105" y="347" fill="#f4c979" font-family="Arial, sans-serif" font-size="17" font-weight="700">нет</text>
<rect x="42" y="480" width="212" height="130" rx="18" fill="#513f25" stroke="#e9b45f" stroke-width="2"/>
<text x="66" y="520" fill="#fff1cf" font-family="Arial, sans-serif" font-size="21" font-weight="700">Review, не флаг</text>
<text x="66" y="551" fill="#f2d5a1" font-family="Arial, sans-serif" font-size="16">Назначить owner,</text>
<text x="66" y="575" fill="#f2d5a1" font-family="Arial, sans-serif" font-size="16">описать fallback и</text>
<text x="66" y="599" fill="#f2d5a1" font-family="Arial, sans-serif" font-size="16">дату removal review.</text>
<path d="M568 364 L624 364 L624 468" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<text x="571" y="347" fill="#9ee9d8" font-family="Arial, sans-serif" font-size="17" font-weight="700">да</text>
<rect x="466" y="480" width="212" height="130" rx="18" fill="#1d4a44" stroke="#74d5c0" stroke-width="2"/>
<text x="490" y="520" fill="#e7fff7" font-family="Arial, sans-serif" font-size="20" font-weight="700">Граница решения</text>
<text x="490" y="551" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">protected input?</text>
<text x="490" y="575" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">server decision</text>
<text x="490" y="599" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">presentation → client</text>
<path d="M572 610 L572 662" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="112" y="676" width="496" height="150" rx="18" fill="#16334b" stroke="#3f6680" stroke-width="2"/>
<text x="142" y="718" fill="#f5fbff" font-family="Arial, sans-serif" font-size="22" font-weight="700">Наблюдение — отдельная обязанность</text>
<text x="142" y="750" fill="#c2d9e8" font-family="Arial, sans-serif" font-size="17">version ответа · outcome · render error · fallback path</text>
<text x="142" y="780" fill="#c2d9e8" font-family="Arial, sans-serif" font-size="17">Не превращать provider event в доказательство exposure.</text>
<text x="142" y="808" fill="#86acc2" font-family="Arial, sans-serif" font-size="15">Сигналы выбирает команда; схема не измеряет production.</text>
<path d="M360 826 L360 878" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="72" y="892" width="576" height="136" rx="18" fill="#24415c" stroke="#a6d6ff" stroke-width="2"/>
<text x="102" y="934" fill="#eff9ff" font-family="Arial, sans-serif" font-size="22" font-weight="700">Cleanup path записан до enable</text>
<text x="102" y="964" fill="#c4e7ff" font-family="Arial, sans-serif" font-size="17">freeze final variant → remove branches</text>
<text x="102" y="989" fill="#c4e7ff" font-family="Arial, sans-serif" font-size="17">remove config → keep behaviour test</text>
<text x="102" y="1014" fill="#91bbd7" font-family="Arial, sans-serif" font-size="15">Disable уменьшает риск; только cleanup удаляет зависимость.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 980" role="img" aria-labelledby="title desc">
<title id="title">Поток решения feature flag между сервером и клиентом</title>
<desc id="desc">Схема отделяет защищенный серверный контекст, evaluation, ответ с presentation value и configuration version, от клиентского rendering и candidate exposure event. Пунктирная линия подчёркивает отсутствие exactly-once гарантии.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L10,5 L0,10 Z" fill="#74d5c0"/>
</marker>
</defs>
<rect width="720" height="980" rx="28" fill="#0f1d2e"/>
<rect x="28" y="26" width="664" height="112" rx="20" fill="#16334b" stroke="#3f6680"/>
<text x="58" y="72" fill="#f5fbff" font-family="Arial, sans-serif" font-size="28" font-weight="700">Где принимается решение?</text>
<text x="58" y="105" fill="#b8cedd" font-family="Arial, sans-serif" font-size="18">Eligibility живёт рядом с protected input; UI получает результат.</text>
<text x="80" y="181" fill="#89abc1" font-family="Arial, sans-serif" font-size="17" font-weight="700">SERVER BOUNDARY</text>
<rect x="54" y="202" width="612" height="184" rx="20" fill="#16334b" stroke="#3f6680" stroke-width="2"/>
<rect x="82" y="234" width="242" height="118" rx="16" fill="#4d2a33" stroke="#e08091" stroke-width="2"/>
<text x="106" y="271" fill="#ffe9ed" font-family="Arial, sans-serif" font-size="20" font-weight="700">Защищённый context</text>
<text x="106" y="300" fill="#f7c6cf" font-family="Arial, sans-serif" font-size="16">entitlement, risk rule,</text>
<text x="106" y="323" fill="#f7c6cf" font-family="Arial, sans-serif" font-size="16">стабильный cohort key</text>
<path d="M324 293 L384 293" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="394" y="234" width="244" height="118" rx="16" fill="#1d4a44" stroke="#74d5c0" stroke-width="2"/>
<text x="418" y="271" fill="#e7fff7" font-family="Arial, sans-serif" font-size="20" font-weight="700">Evaluator</text>
<text x="418" y="300" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">flag key + context</text>
<text x="418" y="323" fill="#b9eadc" font-family="Arial, sans-serif" font-size="16">→ variant + config version</text>
<path d="M516 386 L516 442" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<rect x="142" y="456" width="436" height="96" rx="18" fill="#24415c" stroke="#a6d6ff" stroke-width="2"/>
<text x="172" y="494" fill="#eff9ff" font-family="Arial, sans-serif" font-size="21" font-weight="700">Ответ клиенту</text>
<text x="172" y="524" fill="#c4e7ff" font-family="Arial, sans-serif" font-size="17">presentation value + effective config version</text>
<path d="M360 552 L360 608" fill="none" stroke="#74d5c0" stroke-width="3" marker-end="url(#arrow)"/>
<text x="80" y="641" fill="#89abc1" font-family="Arial, sans-serif" font-size="17" font-weight="700">CLIENT BOUNDARY</text>
<rect x="54" y="660" width="612" height="146" rx="20" fill="#16334b" stroke="#3f6680" stroke-width="2"/>
<rect x="82" y="692" width="242" height="82" rx="16" fill="#24415c" stroke="#a6d6ff" stroke-width="2"/>
<text x="106" y="726" fill="#eff9ff" font-family="Arial, sans-serif" font-size="20" font-weight="700">Render</text>
<text x="106" y="752" fill="#c4e7ff" font-family="Arial, sans-serif" font-size="16">видит returned value</text>
<path d="M324 733 L384 733" fill="none" stroke="#e9b45f" stroke-width="3" stroke-dasharray="9 8" marker-end="url(#arrow)"/>
<rect x="394" y="692" width="244" height="82" rx="16" fill="#513f25" stroke="#e9b45f" stroke-width="2"/>
<text x="418" y="726" fill="#fff1cf" font-family="Arial, sans-serif" font-size="19" font-weight="700">Exposure candidate</text>
<text x="418" y="752" fill="#f2d5a1" font-family="Arial, sans-serif" font-size="16">event id + version</text>
<path d="M516 806 L516 838" fill="none" stroke="#e9b45f" stroke-width="3" stroke-dasharray="9 8" marker-end="url(#arrow)"/>
<rect x="108" y="850" width="504" height="104" rx="18" fill="#4b2932" stroke="#e08091" stroke-width="2"/>
<text x="138" y="883" fill="#ffe9ed" font-family="Arial, sans-serif" font-size="20" font-weight="700">Delivery boundary: retry possible</text>
<text x="138" y="908" fill="#ffe9ed" font-family="Arial, sans-serif" font-size="20" font-weight="700">exactly-once не обещан</text>
<text x="138" y="935" fill="#f7c6cf" font-family="Arial, sans-serif" font-size="16">Приёмник делает dedupe только там, где это нужно.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+737
View File
@@ -0,0 +1,737 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = Object.freeze([
Object.freeze({
title: 'OpenFeature Specification v0.6.0: release, 16.05.2023',
url: 'https://github.com/open-feature/spec/releases/tag/v0.6.0',
note: 'Первичный versioned release OpenFeature, опубликованный 16 мая 2023 и уже доступный к августу 2024. В выпуск вошли provider events, initialization и shutdown; это не доказательство одинаковой реализации у каждого provider или exactly-once доставки какого-либо события.',
}),
Object.freeze({
title: 'OpenFeature v0.6.0: Flag Evaluation API',
url: 'https://github.com/open-feature/spec/blob/v0.6.0/specification/sections/01-flag-evaluation.md',
note: 'Первичная спецификация typed evaluation: flag key, default value и optional evaluation context; при abnormal execution возвращается default value. Раздел помечен hardening и задаёт API-контракт, а не policy хранения секретов, TTL или жизненный цикл конкретного флага.',
}),
Object.freeze({
title: 'OpenFeature v0.6.0: Evaluation Context',
url: 'https://github.com/open-feature/spec/blob/v0.6.0/specification/sections/03-evaluation-context.md',
note: 'Первичная спецификация context: targeting key идентифицирует subject, а global, client и invocation context имеют порядок merge. Раздел experimental; он не обещает непрерывную консистентность когорты между сервисами, браузерами или версиями конфигурации.',
}),
Object.freeze({
title: 'OpenFeature v0.6.0: Events',
url: 'https://github.com/open-feature/spec/blob/v0.6.0/specification/sections/05-events.md',
note: 'Первичная experimental-спецификация provider events: readiness, error, configuration changed и stale. Она описывает обработчики состояния provider, но не является семантикой бизнес-exposure, не определяет delivery transport и не даёт exactly-once гарантию.',
}),
Object.freeze({
title: 'Unleash: Feature Toggles, immutable Git commit 3417039 (21.08.2024)',
url: 'https://github.com/Unleash/unleash/blob/341703978af887a364a9d872d9e3a906ae4dbbcb/website/docs/reference/feature-toggles.mdx',
note: 'Первичный неизменяемый Git-снимок Unleash от 21 августа 2024: флаг имеет type и description, а activation strategies настраиваются по environment; disabled toggle в environment evaluates false. Этот commit не утверждает, что у произвольной команды есть owner, срок удаления, метрика или единая стратегия cleanup.',
}),
Object.freeze({
title: 'Unleash: Front-end API access, immutable Git commit 4ae65d6 (24.05.2024)',
url: 'https://github.com/Unleash/unleash/blob/4ae65d6d5e095d5c056ae63c60afcb09dd6cfe5b/website/docs/reference/front-end-api.md',
note: 'Первичный неизменяемый Git-снимок Unleash от 24 мая 2024: отдельный front-end API, FRONTEND token, CORS boundary и refresh interval с random offset. Это конкретный механизм Unleash 4.18+, а не универсальная модель client-side evaluation или обещание моментальной смены всех клиентов.',
}),
]);
export const sourceReport = Object.freeze(sources.map(({ title, url, note }) => Object.freeze({ title, url, note })));
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const MODEL_VERSION = 'synthetic-feature-flags-2024-08-v1';
const INPUT_KIND = 'synthetic-feature-flag-input-v1';
const REPORT_KIND = 'synthetic-feature-flag-report-v1';
const PLAN_KIND = 'synthetic-feature-flag-review-draft-v1';
const SYNTHETIC_SCOPE = 'p78-feature-flags-2024-08';
const MODEL_LIMIT = 'versioned-fixed-synthetic-js-objects-in-memory-only-no-flag-store-no-files-no-network-no-clock-no-ci-no-telemetry-no-production-no-user-data';
const FIXED_CASES = Object.freeze({
'fixed-release-contract-v1': Object.freeze({
label: 'a release flag card with one owner, one audience, an expiry review and a named deletion path',
flagCard: Object.freeze({
key: 'synthetic-checkout-copy-v1',
class: 'release',
owner: 'synthetic-checkout-owner',
audience: 'synthetic-internal-beta-cohort',
expiryReview: 'synthetic-2024-09-30',
fallback: 'existing-checkout-copy',
signals: Object.freeze(['evaluation-outcome', 'client-render-error', 'synthetic-business-check-not-production']),
deletionPath: Object.freeze(['freeze-final-variant', 'remove-branches', 'replace-flag-test-with-behaviour-test', 'remove-config-after-code-review']),
}),
decision: Object.freeze({
code: 'record-release-contract-before-enable',
symptom: 'a temporary release switch is treated as a harmless boolean and has no named removal date',
cause: 'the delivery decision, owner, audience, observation boundary and cleanup work were never written as one contract',
check: 'verify that one card names the safe fallback, one owner, one audience, one expiry review and one deletion path before a real toggle is created',
action: 'create only a human review draft for the card; do not create, change or evaluate a real flag',
limitation: 'the fixed card is a teaching record, not evidence of a real release, incident, audience or measurement result',
}),
}),
'fixed-server-decision-v1': Object.freeze({
label: 'a protected eligibility decision stays on the server while the client receives only an already-decided presentation value',
flagCard: Object.freeze({
key: 'synthetic-protected-eligibility-v1',
decisionPoint: 'server',
protectedInput: 'synthetic-entitlement-not-sent-to-client',
clientPayload: 'already-decided-boolean-and-config-version',
cohortKey: 'synthetic-account-id',
contextFields: Object.freeze(['targetingKey', 'application', 'region']),
exposureBoundary: Object.freeze(['evaluation-attempt', 'event-idempotency-key', 'delivery-at-least-once-or-less-not-exactly-once']),
invalidationQuestion: 'what happens when the effective configuration version changes during an active client session',
}),
decision: Object.freeze({
code: 'keep-protected-decision-server-side',
symptom: 'the browser is asked to reconstruct a decision that depends on entitlement or another protected input',
cause: 'decision ownership, cohort key and client payload are mixed; an exposure event is mistaken for proof that the user saw an effect',
check: 'write the server input, the stable cohort key, the returned presentation value, the config version and the event delivery boundary separately',
action: 'keep the protected rule at the server boundary and make event handling idempotent where the receiving system requires it; do not claim exactly-once',
limitation: 'the fixed record does not call an SDK, store a context, send an event, observe a browser or establish cross-service consistency',
}),
}),
'fixed-rollout-cleanup-v1': Object.freeze({
label: 'a staged release model with explicit stop conditions, rollback to the fallback and cleanup only after code evidence',
flagCard: Object.freeze({
key: 'synthetic-search-rewrite-v1',
cohortKey: 'synthetic-account-id',
stagesPercent: Object.freeze([0, 5, 25, 100]),
requiredSignals: Object.freeze(['synthetic-success-ratio', 'synthetic-error-ratio', 'synthetic-fallback-path-check']),
rollback: Object.freeze(['return-audience-to-zero', 'keep-old-path-intact', 'preserve-change-record-for-human-review']),
cleanupGate: Object.freeze(['final-variant-frozen', 'all-runtime-branches-removed', 'config-reference-removed', 'behaviour-test-retained']),
}),
decision: Object.freeze({
code: 'stage-rollout-then-require-cleanup-gate',
symptom: 'a flag is turned off after a problem, while code, configuration and tests still preserve the alternate path',
cause: 'disablement is confused with deletion; rollout evidence, rollback and removal are not represented as separate states',
check: 'for each stage state the cohort key, observation window, stop condition, fallback action and the concrete evidence required before cleanup',
action: 'treat zero audience as mitigation, then remove runtime branches and configuration only after a separate cleanup review',
limitation: 'percentages and signals are fixed synthetic teaching values; they are not traffic, user data, production metrics or an incident timeline',
}),
}),
});
const REPORT_KEYS = Object.freeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'scope', 'caseId',
'caseLabel', 'flagCard', 'decision', 'evidence', 'productionEffect',
]);
const DRAFT_KEYS = Object.freeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'sourceReport',
'decisionCode', 'actions', 'flagStore', 'files', 'network', 'clock', 'ci', 'telemetry', 'production',
'productionEffect',
]);
const ACTIONS_BY_DECISION = Object.freeze({
'record-release-contract-before-enable': Object.freeze([
'record-synthetic-card-for-human-review',
'require-owner-audience-expiry-and-fallback-before-real-enable',
'record-deletion-path-separately-from-disablement',
]),
'keep-protected-decision-server-side': Object.freeze([
'keep-protected-input-out-of-client-payload',
'record-cohort-key-and-effective-config-version',
'treat-exposure-delivery-as-not-exactly-once',
]),
'stage-rollout-then-require-cleanup-gate': Object.freeze([
'record-synthetic-stage-and-stop-condition',
'return-to-fallback-before-changing-code-on-stop',
'remove-code-and-config-only-after-a-separate-cleanup-gate',
]),
});
function hasExactKeys(value, keys) {
try {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return (prototype === Object.prototype || prototype === null)
&& Object.keys(value).length === keys.length
&& keys.every((key) => Object.hasOwn(value, key));
} catch {
return false;
}
}
function hasDenseArray(value) {
try {
return Array.isArray(value)
&& Object.keys(value).length === value.length
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
} catch {
return false;
}
}
function hasMatchingDenseArrays(actual, expected) {
return Object.keys(expected).every((key) => !Array.isArray(expected[key])
|| (hasDenseArray(actual[key]) && actual[key].length === expected[key].length));
}
function hasSameCanonicalJson(actual, expected) {
try {
return JSON.stringify(actual) === JSON.stringify(expected);
} catch {
return false;
}
}
function rejected(reason) {
return Object.freeze({
kind: REPORT_KIND,
accepted: false,
syntheticOnly: true,
reason,
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
});
}
export function createFixedSyntheticFeatureFlagInput(caseId) {
if (!Object.hasOwn(FIXED_CASES, caseId)) {
return Object.freeze({ kind: 'unknown-synthetic-feature-flag-input', synthetic: false, caseId });
}
return Object.freeze({
kind: INPUT_KIND,
synthetic: true,
modelVersion: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
mode: 'fixed-memory-only',
caseId,
});
}
/**
* Inspects a single fixed record embedded in this file. It never reads a flag
* store, file, clock, network endpoint, SDK, browser, CI, telemetry, user
* data or production system. The values are teaching records, not evidence.
*/
export function inspectSyntheticFeatureFlag(input) {
if (!input || input.synthetic !== true || input.kind !== INPUT_KIND) return rejected('synthetic-fixed-input-required');
const allowed = ['kind', 'synthetic', 'modelVersion', 'scope', 'mode', 'caseId'];
if (!hasExactKeys(input, allowed)) return rejected('unexpected-input-field');
if (input.modelVersion !== MODEL_VERSION) return rejected('unexpected-model-version');
if (input.scope !== SYNTHETIC_SCOPE) return rejected('unexpected-synthetic-scope');
if (input.mode !== 'fixed-memory-only') return rejected('fixed-memory-mode-required');
if (typeof input.caseId !== 'string' || !Object.hasOwn(FIXED_CASES, input.caseId)) return rejected('unknown-fixed-synthetic-case');
const fixed = FIXED_CASES[input.caseId];
return Object.freeze({
kind: REPORT_KIND,
accepted: true,
syntheticOnly: true,
reason: 'fixed-versioned-synthetic-feature-flag-record-inspected',
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
scope: SYNTHETIC_SCOPE,
caseId: input.caseId,
caseLabel: fixed.label,
flagCard: fixed.flagCard,
decision: fixed.decision,
evidence: Object.freeze({
source: 'embedded-fixed-versioned-synthetic-js-objects-only',
flagStore: 'not-read',
files: 'not-read',
network: 'not-used',
clock: 'not-read',
ci: 'not-run',
telemetry: 'not-read',
userData: 'not-read',
production: 'not-contacted',
}),
productionEffect: 'not-attempted',
});
}
function canonicalReportFor(caseId) {
return inspectSyntheticFeatureFlag(createFixedSyntheticFeatureFlagInput(caseId));
}
function isCanonicalFeatureFlagReport(report) {
if (!hasExactKeys(report, REPORT_KEYS)
|| report.kind !== REPORT_KIND
|| report.accepted !== true
|| report.syntheticOnly !== true
|| report.reason !== 'fixed-versioned-synthetic-feature-flag-record-inspected'
|| report.modelVersion !== MODEL_VERSION
|| report.modelLimit !== MODEL_LIMIT
|| report.scope !== SYNTHETIC_SCOPE
|| typeof report.caseId !== 'string'
|| !Object.hasOwn(FIXED_CASES, report.caseId)
|| report.caseLabel !== FIXED_CASES[report.caseId].label
|| report.productionEffect !== 'not-attempted') return false;
const fixed = FIXED_CASES[report.caseId];
return hasExactKeys(report.flagCard, Object.keys(fixed.flagCard))
&& hasMatchingDenseArrays(report.flagCard, fixed.flagCard)
&& hasExactKeys(report.decision, ['code', 'symptom', 'cause', 'check', 'action', 'limitation'])
&& hasExactKeys(report.evidence, ['source', 'flagStore', 'files', 'network', 'clock', 'ci', 'telemetry', 'userData', 'production'])
&& hasSameCanonicalJson(report, canonicalReportFor(report.caseId));
}
function makeSyntheticFeatureFlagReviewDraft(fresh) {
return Object.freeze({
kind: PLAN_KIND,
accepted: true,
syntheticOnly: true,
reason: 'canonical-fixed-synthetic-feature-flag-review-draft',
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
sourceReport: fresh,
decisionCode: fresh.decision.code,
actions: ACTIONS_BY_DECISION[fresh.decision.code],
flagStore: 'not-read-or-written',
files: 'not-read-or-written',
network: 'not-used',
clock: 'not-read',
ci: 'not-run',
telemetry: 'not-read',
production: 'not-contacted',
productionEffect: 'not-attempted',
});
}
function isCanonicalFeatureFlagReviewDraft(plan) {
if (!hasExactKeys(plan, DRAFT_KEYS)
|| plan.kind !== PLAN_KIND
|| plan.accepted !== true
|| plan.syntheticOnly !== true
|| plan.reason !== 'canonical-fixed-synthetic-feature-flag-review-draft'
|| plan.modelVersion !== MODEL_VERSION
|| plan.modelLimit !== MODEL_LIMIT
|| !hasDenseArray(plan.actions)
|| plan.flagStore !== 'not-read-or-written'
|| plan.files !== 'not-read-or-written'
|| plan.network !== 'not-used'
|| plan.clock !== 'not-read'
|| plan.ci !== 'not-run'
|| plan.telemetry !== 'not-read'
|| plan.production !== 'not-contacted'
|| plan.productionEffect !== 'not-attempted'
|| !isCanonicalFeatureFlagReport(plan.sourceReport)) return false;
return hasSameCanonicalJson(plan, makeSyntheticFeatureFlagReviewDraft(canonicalReportFor(plan.sourceReport.caseId)));
}
/**
* Creates only an in-memory review draft after authenticating the complete
* canonical report. It cannot create, change, disable or delete a real flag.
*/
export function planSyntheticFeatureFlagReview(report) {
if (!report || report.kind !== REPORT_KIND || report.syntheticOnly !== true || report.accepted !== true) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'accepted-synthetic-report-required', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
if (!hasExactKeys(report, REPORT_KEYS)) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'unexpected-report-field', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
if (report.modelVersion !== MODEL_VERSION || report.scope !== SYNTHETIC_SCOPE || !Object.hasOwn(FIXED_CASES, report.caseId)) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'untrusted-synthetic-report-scope', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
const canonical = canonicalReportFor(report.caseId);
if (!isCanonicalFeatureFlagReport(report) || !hasSameCanonicalJson(report, canonical)) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'report-does-not-match-fixed-object', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
return makeSyntheticFeatureFlagReviewDraft(canonical);
}
/**
* Discards only an authenticated fixed synthetic review draft. It does not
* contact a feature service, change source code, send an event or touch state.
*/
export function rollbackSyntheticFeatureFlagReview(plan) {
if (!isCanonicalFeatureFlagReviewDraft(plan)) {
return Object.freeze({ restored: false, syntheticOnly: true, reason: 'no-accepted-synthetic-feature-flag-review-draft' });
}
return Object.freeze({
restored: true,
syntheticOnly: true,
reason: 'synthetic-feature-flag-review-draft-discarded',
flagStore: 'not-read-or-written',
files: 'not-read-or-written',
network: 'not-used',
ci: 'not-run',
telemetry: 'not-read',
production: 'not-contacted',
productionEffect: 'not-attempted',
});
}
export function runFeatureFlagsFixture() {
const contract = inspectSyntheticFeatureFlag(createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'));
const server = inspectSyntheticFeatureFlag(createFixedSyntheticFeatureFlagInput('fixed-server-decision-v1'));
const rollout = inspectSyntheticFeatureFlag(createFixedSyntheticFeatureFlagInput('fixed-rollout-cleanup-v1'));
const fixedContractBeforeInvalidInput = JSON.stringify(FIXED_CASES['fixed-release-contract-v1']);
const nonSynthetic = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), synthetic: false });
const unexpectedInput = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), flagStore: 'not-read' });
const fileInput = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), file: 'flag.yaml' });
const networkInput = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), endpoint: 'https://not-used.example' });
const clockInput = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), now: '2024-08-01T00:00:00Z' });
const telemetryInput = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), telemetry: { sample: 1 } });
const productionInput = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), production: true });
const badVersion = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), modelVersion: 'v99' });
const badScope = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), scope: 'another-scope' });
const badMode = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), mode: 'read-flag-store' });
const unknownCase = inspectSyntheticFeatureFlag({ ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1'), caseId: 'real-flag' });
const cyclicInput = { ...createFixedSyntheticFeatureFlagInput('fixed-release-contract-v1') };
cyclicInput.self = cyclicInput;
const cyclicInputResult = inspectSyntheticFeatureFlag(cyclicInput);
const contractPlan = planSyntheticFeatureFlagReview(contract);
const serverPlan = planSyntheticFeatureFlagReview(server);
const rolloutPlan = planSyntheticFeatureFlagReview(rollout);
const forgedDecisionPlan = planSyntheticFeatureFlagReview({ ...server, decision: { ...server.decision, code: 'evaluate-in-browser-with-secret' } });
const forgedCardPlan = planSyntheticFeatureFlagReview({ ...contract, flagCard: { ...contract.flagCard, owner: 'unowned' } });
const unexpectedReportPlan = planSyntheticFeatureFlagReview({ ...rollout, rolloutApiResult: 'not-accepted' });
const sparseStagePlan = planSyntheticFeatureFlagReview({
...rollout,
flagCard: { ...rollout.flagCard, stagesPercent: new Array(rollout.flagCard.stagesPercent.length) },
});
const cyclicDecision = { ...server.decision };
cyclicDecision.limitation = cyclicDecision;
const cyclicReportPlan = planSyntheticFeatureFlagReview({ ...server, decision: cyclicDecision });
const restored = rollbackSyntheticFeatureFlagReview(rolloutPlan);
const forgedRestore = rollbackSyntheticFeatureFlagReview({ ...rolloutPlan, actions: ['delete-real-flag'] });
const sparseRestore = rollbackSyntheticFeatureFlagReview({
...rolloutPlan,
actions: new Array(rolloutPlan.actions.length),
});
const reportAsDraft = rollbackSyntheticFeatureFlagReview(contract);
const fixedContractAfterInvalidInput = JSON.stringify(FIXED_CASES['fixed-release-contract-v1']);
return Object.freeze({
assertions: Object.freeze({
acceptsFixedContractCard: contract.accepted === true && contract.decision.code === 'record-release-contract-before-enable',
preservesCardFacts: contract.flagCard.owner === 'synthetic-checkout-owner' && contract.flagCard.expiryReview === 'synthetic-2024-09-30',
keepsCardSignalsAndDeletionDense: hasDenseArray(contract.flagCard.signals) && hasDenseArray(contract.flagCard.deletionPath),
acceptsProtectedServerCase: server.accepted === true && server.decision.code === 'keep-protected-decision-server-side',
keepsProtectedInputOutOfClientModel: server.flagCard.protectedInput === 'synthetic-entitlement-not-sent-to-client' && server.flagCard.clientPayload.includes('already-decided'),
labelsExposureWithoutExactlyOnceClaim: server.flagCard.exposureBoundary.includes('delivery-at-least-once-or-less-not-exactly-once'),
acceptsRolloutCleanupCase: rollout.accepted === true && rollout.decision.code === 'stage-rollout-then-require-cleanup-gate',
keepsSyntheticStagesDense: hasDenseArray(rollout.flagCard.stagesPercent) && rollout.flagCard.stagesPercent.join(',') === '0,5,25,100',
doesNotClaimOperationalEvidence: rollout.evidence.flagStore === 'not-read' && rollout.evidence.telemetry === 'not-read' && rollout.evidence.production === 'not-contacted',
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-fixed-input-required',
rejectsUnexpectedInputField: unexpectedInput.accepted === false && unexpectedInput.reason === 'unexpected-input-field',
rejectsFileInput: fileInput.accepted === false && fileInput.reason === 'unexpected-input-field',
rejectsNetworkInput: networkInput.accepted === false && networkInput.reason === 'unexpected-input-field',
rejectsClockInput: clockInput.accepted === false && clockInput.reason === 'unexpected-input-field',
rejectsTelemetryInput: telemetryInput.accepted === false && telemetryInput.reason === 'unexpected-input-field',
rejectsProductionInput: productionInput.accepted === false && productionInput.reason === 'unexpected-input-field',
rejectsWrongVersion: badVersion.accepted === false && badVersion.reason === 'unexpected-model-version',
rejectsWrongScope: badScope.accepted === false && badScope.reason === 'unexpected-synthetic-scope',
rejectsReadStoreMode: badMode.accepted === false && badMode.reason === 'fixed-memory-mode-required',
rejectsUnknownCase: unknownCase.accepted === false && unknownCase.reason === 'unknown-fixed-synthetic-case',
rejectsCyclicInputWithoutThrowing: cyclicInputResult.accepted === false && cyclicInputResult.reason === 'unexpected-input-field',
invalidInputDoesNotChangeFixedRecord: fixedContractBeforeInvalidInput === fixedContractAfterInvalidInput
&& Object.isFrozen(FIXED_CASES['fixed-release-contract-v1'])
&& Object.isFrozen(FIXED_CASES['fixed-release-contract-v1'].flagCard),
plansContractReviewOnly: contractPlan.accepted === true && contractPlan.actions.includes('record-deletion-path-separately-from-disablement'),
plansServerBoundaryOnly: serverPlan.accepted === true && serverPlan.actions.includes('treat-exposure-delivery-as-not-exactly-once'),
plansCleanupOnly: rolloutPlan.accepted === true && rolloutPlan.actions.includes('remove-code-and-config-only-after-a-separate-cleanup-gate'),
planHasNoOperationalEffect: rolloutPlan.flagStore === 'not-read-or-written' && rolloutPlan.files === 'not-read-or-written' && rolloutPlan.ci === 'not-run' && rolloutPlan.production === 'not-contacted',
rejectsForgedDecision: forgedDecisionPlan.accepted === false && forgedDecisionPlan.reason === 'report-does-not-match-fixed-object',
rejectsForgedCard: forgedCardPlan.accepted === false && forgedCardPlan.reason === 'report-does-not-match-fixed-object',
rejectsUnexpectedReportField: unexpectedReportPlan.accepted === false && unexpectedReportPlan.reason === 'unexpected-report-field',
rejectsSparseStages: sparseStagePlan.accepted === false && sparseStagePlan.reason === 'report-does-not-match-fixed-object',
rejectsCyclicReportWithoutThrowing: cyclicReportPlan.accepted === false && cyclicReportPlan.reason === 'report-does-not-match-fixed-object',
rollbackDiscardsOnlyCanonicalDraft: restored.restored === true && restored.flagStore === 'not-read-or-written' && restored.productionEffect === 'not-attempted',
rollbackRejectsForgedDraft: forgedRestore.restored === false && forgedRestore.reason === 'no-accepted-synthetic-feature-flag-review-draft',
rollbackRejectsSparseActions: sparseRestore.restored === false && sparseRestore.reason === 'no-accepted-synthetic-feature-flag-review-draft',
reportCannotBeRolledBackAsDraft: reportAsDraft.restored === false && reportAsDraft.reason === 'no-accepted-synthetic-feature-flag-review-draft',
}),
samples: Object.freeze({
contract, server, rollout, nonSynthetic, unexpectedInput, fileInput, networkInput, clockInput,
telemetryInput, productionInput, badVersion, badScope, badMode, unknownCase, cyclicInputResult, contractPlan, serverPlan,
rolloutPlan, forgedDecisionPlan, forgedCardPlan, unexpectedReportPlan, sparseStagePlan, cyclicReportPlan,
restored, forgedRestore, sparseRestore, reportAsDraft,
}),
});
}
const fixtureExample = [
"import {",
" createFixedSyntheticFeatureFlagInput,",
" inspectSyntheticFeatureFlag,",
" planSyntheticFeatureFlagReview,",
" runFeatureFlagsFixture,",
"} from './upgrade-2024-08.mjs';",
'',
"const input = createFixedSyntheticFeatureFlagInput('fixed-server-decision-v1');",
'const report = inspectSyntheticFeatureFlag(input);',
'const review = planSyntheticFeatureFlagReview(report);',
'',
'if (!Object.values(runFeatureFlagsFixture().assertions).every(Boolean)) {',
" throw new Error('fixed synthetic fixture failed');",
'}',
'',
'console.log({ decision: report.decision.code, actions: review.actions });',
'',
'// Только fixed synthetic objects в памяти.',
'// Не читаются flag store, файлы, сеть, часы, CI, telemetry, user data или production.',
'// Exposure boundary не означает exactly-once delivery и не доказывает effect.',
].join('\n');
const fixtureCommand = fixtureExample + '\n\nnode web/scripts/upgrade-2024-08.mjs --verify-fixture\n\n# PASS подтверждает только согласованность fixed synthetic records и отрицательных веток.';
const practice = revision({
slug: 'editorial-2024-08-practice-feature-flags',
title: 'Фича-флаг как контракт выпуска: как не оставить переключатель навсегда',
categories: ['Релизы', 'Инженерные практики'],
cover: '/assets/editorial/2024/feature-flags-2024-decision-tree.svg',
excerpt: 'Практический контракт release-флага: владелец, аудитория, срок пересмотра, сигналы, fallback и удаление — до того, как временный boolean попадёт в код.',
readingMinutes: 13,
}, [
p('В релизе появляется обычный boolean: новый checkout включается только для части аудитории. Через неделю задача считается законченной, но у флага нет владельца, даты пересмотра и ответа, что именно будет удалено после выбора варианта. Через месяц он уже участвует в трёх условиях: на сервере, в клиентском интерфейсе и в тестовой матрице. Цена ошибки не в одной строке конфигурации. Следующая доработка должна одновременно помнить старый путь, новый путь и все исключения, которые когда-то были нужны только для выпуска.'),
p('Вторая ошибка выглядит осторожной: после сбоя флаг выключают и считают риск снятым. Выключение уменьшает аудиторию нового пути, но не удаляет ветки, не отменяет различие данных и не показывает, какая проверка позволила выбрать итоговое поведение. Если в карточке нет safe fallback и плана удаления, команда получает не контроль релиза, а накопленный долг с хорошим названием. Поэтому release-флаг удобнее рассматривать как короткий контракт выпуска, а не как свободный переключатель.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> В pull request появляется фраза «добавим временный флаг», но нет человека, который потом удалит обе ветки.',
'<strong>Причина.</strong> Boolean хранит только значение, а решение выпуска включает аудиторию, fallback, наблюдение, срок пересмотра и условие удаления.',
'<strong>Проверка.</strong> До реального создания флага заполните одну карточку: owner, класс, аудитория, безопасное значение, срок review, сигналы, stop condition и путь удаления.',
'<strong>Действие.</strong> Если карточку нельзя заполнить, не расширяйте код условием. Сначала проведите короткий review выпуска: может оказаться, что нужен другой механизм, а не ещё один флаг.',
]),
h2('Что именно должен обещать release-флаг'),
p('Контракт не обязан быть большим. Ему достаточно назвать один вопрос, на который отвечает переключатель. Например: можно ли показать новую форму checkout выбранной синтетической когорте, сохраняя прежнюю форму как fallback. Вопрос не должен звучать как «включить новый checkout вообще»: в нём нет аудитории, границы и критерия остановки. Чем шире формулировка, тем легче превратить временный флаг в постоянный режим системы.'),
p('У карточки есть owner, но это не декоративное поле. Owner отвечает за следующий review: продлить срок с явной причиной, выбрать итоговый вариант или инициировать cleanup. Аудитория тоже описывается технически: какой ключ делает когорту стабильной в пределах одной зафиксированной конфигурации, и какие входы не должны покидать сервер. Для простого UI-флага можно вернуть клиенту уже вычисленное presentation value. Для entitlement, цены, права или другого защищённого ввода решение остаётся на серверной границе.'),
table('Минимальная карточка release-флага', ['Поле', 'Что фиксируем', 'Чего поле не доказывает'], [
['Ключ и класс', 'один ключ, release-класс и один вопрос выпуска', 'что флаг подходит для эксперимента, миграции данных или permanent policy'],
['Owner', 'человек или роль, принимающие review срока и cleanup', 'что владелец уже проверил каждую зависимость нового пути'],
['Аудитория', 'cohort key, environment и явно разрешённый контекст', 'что все сервисы мгновенно увидят одну и ту же версию конфигурации'],
['Fallback', 'какой старый путь должен остаться выполнимым при остановке', 'что rollback исправит данные, уже записанные новым путём'],
['Срок review', 'дата или событие, когда нужен новый выбор', 'автоматическое удаление кода без human review'],
['Сигналы', 'какие типы сигналов допустимы для решения: outcome, ошибка рендера, контролируемая проверка', 'причину любого отклонения или измеренный успех в production'],
['Удаление', 'последовательность: зафиксировать вариант, удалить ветки, обновить tests, убрать config', 'что disablement уже выполнил cleanup'],
]),
figure('/assets/editorial/2024/feature-flags-2024-decision-tree.svg', 'Дерево решения release-флага: сначала проверяются owner, аудитория, fallback и срок review; затем выделяются server-side protected decision, client-side presentation и отдельный путь cleanup. Схема показывает контракт, а не состояние реального feature-flag сервиса.', 'Флаг появляется только после заполнения границы решения. Ветка «нет карточки» ведёт к review, а не к новому boolean. Диаграмма не измеряет rollout, ошибки или использование флага.'),
h2('Сигналы нужны для решения, а не для декорации'),
p('У сигнала есть вопрос и граница. Событие evaluation говорит, что код попытался вычислить вариант; оно не доказывает, что пользователь увидел интерфейс и тем более не доказывает полезный эффект. Ошибка рендера может показать, что presentation-path не собрался, но не объясняет, какой внешний сервис вызвал сбой. Контролируемая функциональная проверка может подтвердить один ожидаемый маршрут, но не заменяет поток реальных пользовательских данных. В карточке лучше писать тип сигнала и его ограничение рядом.'),
table('Сигнал и допустимый вывод', ['Сигнал', 'На какой вопрос отвечает', 'Не допускает такой вывод', 'Следующее действие'], [
['Evaluation outcome', 'какой вариант вернул evaluator для согласованного context', 'пользователь обязательно увидел эффект; event доставлен ровно один раз', 'сверить key, version и context boundary'],
['Клиентская ошибка', 'сломался ли контролируемый presentation path', 'корень проблемы находится в самом флаге', 'разделить ошибку интерфейса, API и данных до изменения audience'],
['Проверка fallback', 'старый путь всё ещё способен выполнить объявленный контракт', 'новый путь безопасен при любой нагрузке', 'оставить fallback до отдельного cleanup review'],
['Изменение конфигурации', 'появилась новая effective configuration version', 'все клиенты синхронно сменили когорту', 'определить cache, refresh и правила для активной сессии'],
]),
h2('Короткий объект лучше списка обещаний'),
p('Ниже не конфигурация Unleash или другого сервиса. Это маленькая запись для review и одновременно пример границы: она не содержит URL, credential, реальную аудиторию или production-метрику. В ней есть только то, что нужно обсудить до создания настоящего флага. Значение срока не запускает timer; signals не включают telemetry; deletion path не удаляет файл. Так карточка остаётся документом решения, а не скрытым оператором инфраструктуры.'),
code([
"const releaseFlagCard = {",
" key: 'synthetic-checkout-copy-v1',",
" class: 'release',",
" owner: 'synthetic-checkout-owner',",
" audience: 'synthetic-internal-beta-cohort',",
" expiryReview: 'synthetic-2024-09-30',",
" fallback: 'existing-checkout-copy',",
" signals: ['evaluation-outcome', 'client-render-error'],",
" deletionPath: ['freeze-final-variant', 'remove-branches', 'remove-config'],",
"};",
'',
'// Учебная карточка: она не создаёт флаг и не считывает реальную конфигурацию.',
].join('\n')),
h2('Как срок превращается в техническую работу'),
p('Срок review не означает, что в указанную дату робот должен удалить флаг. Его задача другая: не дать boolean исчезнуть из внимания. В день review owner выбирает одно из трёх действий. Первое — итоговый вариант ещё не выбран: ограничить аудиторию или продлить контракт с новой причиной. Второе — выбран fallback: вернуть аудиторию к нулю и разобраться, какие побочные данные или зависимости остались. Третье — выбран новый путь: заморозить вариант, открыть cleanup и не добавлять новые rules в старый флаг.'),
p('Важно не называть продление нейтральным. Оно увеличивает стоимость тестирования и увеличивает число вариантов, которые должен понимать любой следующий разработчик. Поэтому к продлению полезно приложить тот же набор фактов, что к первому включению: текущая аудитория, причина задержки, владелец, безопасное значение, новый review и список branch, которые всё ещё существуют. Если этих фактов нет, срок продлевают не из-за неопределённости системы, а из-за отсутствия решения.'),
h2('Граница исторических источников'),
p('К августу 2024 OpenFeature v0.6.0 уже описывал typed evaluation, optional context и provider events, но sections имели статусы hardening или experimental. Неизменяемые commits Unleash от 21 августа и 24 мая 2024 подтверждают отдельные configuration, environment и front-end API boundaries, включая token, CORS и refresh semantics конкретного продукта. Ни один источник не задаёт универсальные поля owner, expiry или deletion path. Это сознательно добавленная командная policy, а не свойство SDK.'),
h2('Порядок внедрения без лишней платформы'),
ol([
'<strong>Ограничьте вопрос.</strong> Для одного нового изменения напишите old path, candidate path и безопасный fallback. Не объединяйте release, pricing policy и эксперимент в одном ключе.',
'<strong>Назначьте owner и review.</strong> Owner получает не право «забыть флаг», а обязанность вынести на review итог, продление или cleanup.',
'<strong>Определите audience boundary.</strong> Зафиксируйте environment, cohort key и чувствительные входы. Если решение зависит от закрытых данных, клиент получает результат, а не правило.',
'<strong>Запишите наблюдение.</strong> Для каждого сигнала назовите, что он подтверждает и чего не подтверждает. Не используйте event как обещание exactly-once.',
'<strong>Проверьте fallback.</strong> До расширения аудитории убедитесь, что старый путь ещё выполним в разрешённой среде и что возврат не маскирует изменение данных.',
'<strong>Откройте cleanup заранее.</strong> Когда новый вариант выбран, запрещайте новые rules в старом флаге и планируйте удаление кода, config, tests и документации отдельным change.',
]),
h2('Ограничения и следующий проверяемый шаг'),
p('Карточка не заменяет approval, threat model, миграцию схемы, canary-policy или SLA. Она не выбирает безопасный процент rollout и не говорит, какие поля context допустимы с точки зрения персональных данных. В частности, immutable commit Front-end API Unleash от 24 мая 2024 описывает один продукт и его refresh behavior; переносить его interval, token model или нагрузочную границу в другой provider нельзя. Context OpenFeature описывает форму API, но не гарантирует, что все ваши сервисы используют одинаковый ключ и одинаковое время обновления.'),
p('Следующий проверяемый шаг — взять один существующий release-флаг и выписать для него семь строк из первой таблицы. Затем найти ровно одну недостающую: owner, fallback, audience, expiry, signal или deletion path. Не исправляйте всё сразу. Сначала добавьте запись и назначьте review. Ожидаемый результат маленький, но проверяемый: следующий читатель флага сможет назвать, зачем он существует, кто примет решение и какие ветки будут удалены после этого решения.'),
]);
const mechanism = revision({
slug: 'editorial-2024-08-mechanism-feature-flags',
title: 'Где принимать решение флага: server, client и журнал exposure',
categories: ['Архитектура', 'Релизы'],
cover: '/assets/editorial/2024/feature-flags-2024-rollout-timeline.svg',
excerpt: 'Как выбрать точку вычисления флага, сохранить когортный ключ и отделить evaluation от exposure без мифа о exactly-once событии.',
readingMinutes: 14,
}, [
p('Флаг меняет не только интерфейс. Он решает, где живёт правило: на сервере, в браузере или одновременно в двух местах. Ошибка обычно проявляется после первого успешного rollout. Клиент получил достаточно входов, чтобы сам вычислить eligibility; сервер вернул один вариант, а браузер при следующем запросе — другой; аналитика назвала один event exposure, хотя пользователь не видел результат из-за ошибки рендера. Цена — не только утечка лишнего контекста. Команда теряет возможность объяснить, почему конкретный запрос увидел именно этот путь.'),
p('Противоположная крайность тоже плоха: каждый косметический элемент требует round trip к серверу, хотя правило зависит только от уже публичной настройки приложения. Здесь стоимость — задержка и лишняя связность. Вопрос звучит не «server или client лучше». Он звучит так: какие входы защищены, какой компонент владеет окончательным решением, какой ключ удерживает когорту и что именно означает запись exposure. Без этих четырёх ответов флаг превращается в распределённое угадывание.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Для одного account UI иногда показывает новый вариант, а API продолжает выполнять старую ветку, либо браузер получил entitlement, который ему не нужен.',
'<strong>Причина.</strong> Входы, evaluator, cohort key, config version и event semantics распределены по разным слоям без единого contract.',
'<strong>Проверка.</strong> Для каждого флага выпишите protected input, public presentation input, authoritative evaluator, stable targeting key, effective config version и тип события.',
'<strong>Действие.</strong> Оставьте решение там, где доступен минимальный набор допустимых входов. Клиенту передавайте только результат и данные, нужные для отображения; exposure делайте идемпотентным на стороне потребителя, не обещая exactly-once.',
]),
h2('Три слоя решения'),
p('Первый слой — input. Здесь важно различить публичный параметр интерфейса и защищённый факт. Локаль, объявленная тема или заранее опубликованный вариант текста могут быть client-safe, если они действительно уже доступны клиенту. Право доступа, индивидуальная скидка, fraud signal, внутренний account state и правило, раскрывающее их, не должны становиться материалом для browser evaluator только ради удобства rollout. Если клиенту нужен ответ «можно ли показать кнопку», сервер может вернуть boolean и version, не раскрывая причину.'),
p('Второй слой — evaluator. Он отвечает за вычисление варианта для конкретного context. OpenFeature v0.6.0 описывает typed evaluation с key, default value и optional context; это полезная форма, но не указание, где запускать provider. Источник позволяет разделить contract вызова и implementation: server-side evaluator может иметь защищённый context, client-side evaluator — только разрешённый поднабор. Решение о placement остаётся архитектурным: оно зависит от данных, latency, availability и доверенной границы.'),
p('Третий слой — presentation. Результат evaluation ещё не равен показанному эффекту. Между ними может быть network response, client cache, rendering, feature branch и пользовательское действие. Поэтому слово exposure нужно договорить. В этой статье exposure — попытка зафиксировать, что evaluator вернул конкретный вариант для заданного context и configuration version. Такая запись не доказывает показ интерфейса, не измеряет результат и не обязана быть доставлена ровно один раз.'),
table('Точка вычисления и её цена', ['Вариант', 'Допустимые входы', 'Сильная сторона', 'Главный риск', 'Проверка перед выбором'], [
['Server decision', 'protected entitlement, pricing rule, account state, server-only policy', 'клиент получает минимум данных и готовый verdict', 'лишний сетевой путь и рассинхрон при повторной client evaluation', 'вернуть только presentation value и config version; не дублировать правило в браузере'],
['Client presentation decision', 'уже публичная конфигурация, locale, layout preference, разрешённый anonymous context', 'быстрый UI без дополнительного endpoint', 'в клиент уезжает лишний context или меняется cohort key', 'проверить token, CORS, cache and refresh boundary конкретного SDK'],
['Split decision', 'server считает eligibility, client выбирает только presentation within returned contract', 'можно отделить policy от UI', 'два слоя могут незаметно менять один и тот же смысл', 'назвать single source of truth и запретить клиенту переоценивать protected rule'],
['Два независимых evaluator', 'разные наборы данных без согласованной версии', 'нет технического преимущества само по себе', 'один subject получает разные варианты и спор о виновнике', 'не использовать без явного consistency contract'],
]),
figure('/assets/editorial/2024/feature-flags-2024-rollout-timeline.svg', 'Схема потока решения: server получает защищённый context, вычисляет eligibility и возвращает клиенту presentation value вместе с configuration version; client фиксирует отдельный exposure candidate. Пунктиром показано, что доставку события нельзя считать exactly-once.', 'Диаграмма отделяет decision, rendering и event delivery. Она не показывает SDK traffic, реальную когорту, latency или результаты аналитики.'),
h2('Когорта начинается с ключа, а не с процента'),
p('Процентный rollout устойчив только относительно выбранного ключа и версии правила. Если сервер выбирает cohort по account ID, а браузер — по anonymous ID, один человек может попасть в разные варианты на соседних экранах. Если key меняется после логина, нужен отдельный переход: какая версия сохраняется в сессии, как объединяются pre-login и post-login события, какой вариант имеет приоритет. Молчаливо считать, что hash сам решит эту задачу, нельзя.'),
p('OpenFeature context v0.6.0 называет targeting key идентификатором subject и допускает custom fields; provider может требовать его для fractional evaluation. Это полезное ограничение: ключ нужен сделать явным. Но спецификация не обещает distributed snapshot. Когда effective configuration version меняется, когорту может корректно пересчитать следующий запрос. Система должна решить, допустимо ли это во время активной сессии, или нужна pinning policy. Такой выбор лучше записать рядом с флагом, чем искать его в cache invalidation после жалобы.'),
h2('Минимальный воспроизводимый пример'),
p('Следующий пример не подключает provider и не отправляет event. Он берёт только фиксированную synthetic карточку из этого overlay. Она помогает проверить форму решения: protected input остаётся за server boundary, client получает boolean и version, а exposure boundary прямо содержит запрет на exactly-once обещание. Никакого account, токена, endpoint, event broker или production config здесь нет.'),
code([
"const input = createFixedSyntheticFeatureFlagInput('fixed-server-decision-v1');",
'const report = inspectSyntheticFeatureFlag(input);',
'const draft = planSyntheticFeatureFlagReview(report);',
'',
"if (report.decision.code !== 'keep-protected-decision-server-side') {",
" throw new Error('unexpected teaching decision');",
'}',
'',
'console.log(report.flagCard.clientPayload);',
"// already-decided-boolean-and-config-version",
'console.log(draft.actions[2]);',
"// treat-exposure-delivery-as-not-exactly-once",
].join('\n')),
p('Эта fixture строит report только из object literal, заранее записанного в модуле. Любой field, похожий на flag store, URL, clock, CI, telemetry или production marker, отклоняется до анализа. Если подменить decision, cohort key, config version question или разрежить массив stages, plan не будет принят. Такой тест не проверяет реальный SDK. Он защищает пример от ложного вывода, что запись document уже стала вызовом production-системы.'),
h2('Evaluation, exposure и effect — разные факты'),
p('Evaluation — evaluator получил key и context и вернул вариант или default. Exposure candidate — приложение подготовило запись, что этот вариант был вычислен в определённой версии конфигурации. Rendered exposure — клиент действительно дошёл до точки показа; он всё ещё не равен успешному действию. Business effect требует отдельного доменного события и отдельной методологии сравнения. Склеивать эти уровни в одно слово «показали фичу» опасно: при сбое невозможно понять, что именно не произошло.'),
p('У event delivery есть собственная семантика. Provider events OpenFeature v0.6.0 относятся к готовности, ошибке, изменению configuration или stale state provider. Они не определяют бизнес-event ingestion. Даже если ваш transport повторяет сообщение при сбое, это не превращается в global exactly-once: retry, timeout, consumer crash и переигранная сессия остаются отдельными случаями. Если downstream требует дедупликацию, он должен иметь идемпотентный key и объяснение окна хранения. Текст события должен называть attempt или delivered record, а не «единственный факт показа».'),
table('Факт, который можно записать, и факт, который нельзя подменить', ['Запись', 'Что содержит', 'Чего не обещает'], [
['Evaluation record', 'flag key, returned variant, context boundary, effective config version', 'что UI успешно отрендерился и событие доставлено'],
['Exposure candidate', 'idempotency key, request or session boundary, chosen variant', 'exactly-once delivery, уникального пользователя или полезного действия'],
['Render confirmation', 'достижение конкретного client-side display point', 'что пользователь прочитал или применил результат'],
['Domain event', 'отдельное действие после показа и свой business contract', 'что изменение вызвано только флагом без методики контроля'],
]),
h2('Изменение конфигурации не обязано быть мгновенным'),
p('Immutable commit Front-end API Unleash от 24 мая 2024 описывает отдельный продуктовый путь: FRONTEND token, CORS boundary и refresh interval с random offset. Из этого следует практический вопрос, а не универсальный тайминг: когда именно ваш клиент узнаёт о новой версии и как он ведёт себя до этого. Server evaluator может получить configuration раньше браузера; Edge или proxy — иметь другой cache; локальный provider — другой lifecycle. В production contract стоит говорить «version returned by this evaluator», а не «все пользователи уже на новом проценте».'),
p('Когда нужны несколько evaluator, полезнее передавать result than rule. Server возвращает selected variant и effective config version. Client может кешировать presentation в допустимой границе, но не должен заново применять hidden targeting. Если UI обязан обновиться после config change, отдельно определите event, refresh, invalidation и user-visible transition. Это делает некрасивый случай видимым: пользователь может увидеть старый вариант до следующего refresh, и это не ошибка именно флага, пока contract говорит, что такое поведение разрешено.'),
h2('Порядок проектирования'),
ol([
'<strong>Классифицируйте inputs.</strong> Отделите protected facts от client-safe presentation fields. Список должен быть короче реального context, а не равен ему.',
'<strong>Выберите authoritative evaluator.</strong> Один слой владеет eligibility. Второй не пересчитывает то же правило с другим key или другой config version.',
'<strong>Зафиксируйте cohort key.</strong> Назовите subject, переход login/logout, version boundary и поведение активной сессии.',
'<strong>Опишите payload.</strong> В client response положите только verdict, variant и нужную version; не передавайте скрытый rule ради удобной отладки.',
'<strong>Разделите события.</strong> Evaluation, exposure candidate, render и domain effect должны иметь разные названия, schema и ограничения.',
'<strong>Проверьте delivery честно.</strong> Если нужен dedupe, определите idempotency key и consumer behavior. Не заменяйте это словом exactly-once.',
'<strong>Проведите один controlled check.</strong> В разрешённой среде сравните один subject, один key и одну config version на двух границах. Не меняйте одновременно key, cache и rollout percent.',
]),
h2('Ограничения и следующий проверяемый шаг'),
p('Этот механизм не выбирает SDK, cache TTL, payload schema, event transport или срок хранения дедупликационных ключей. Он не доказывает, что client token действительно ограничен, CORS правильно настроен или provider последовательно выдаёт один вариант. OpenFeature разделы про context и events на v0.6.0 были experimental; их нельзя читать как SLA. Immutable commit Front-end API Unleash от 24 мая 2024 описывает Unleash 4.18+ и не даёт переносимой гарантии для другой платформы.'),
p('Следующий проверяемый шаг — выбрать один существующий флаг, зависящий от account-level данных, и заполнить таблицу из шести строк: protected input, public payload, evaluator, cohort key, config version and event type. Затем найдите одно дублирование правила между server и client. Уберите дублирование или запишите контракт, почему оно необходимо. Ожидаемый результат — конкретный: по одному event и одному response можно объяснить, кто принял решение и чего это событие не доказывает.'),
]);
const field = revision({
slug: 'editorial-2024-08-field-feature-flags',
title: 'Выключить флаг — не значит удалить риск: rollout, rollback и cleanup',
categories: ['Релизы', 'Кейсы'],
cover: '/assets/editorial/2024/feature-flags-2024-cleanup-gate.svg',
excerpt: 'Полевой маршрут для gradual rollout: synthetic stages, stop condition, fallback и отдельный cleanup gate, который удаляет ветки и конфигурацию после выбора итогового поведения.',
readingMinutes: 13,
}, [
p('После неудачного шага rollout команда выключает флаг. Новый путь перестаёт получать аудиторию, тревога стихает, change закрывают. Но проблема остаётся: через два релиза тот же код всё ещё хранит обе ветки, config содержит старый ключ, тесты знают два ответа, а developer не уверен, какой из них должен быть единственным. Риск не исчез: он просто перестал проявляться у пользователей. Любая следующая доработка может снова активировать старый путь случайной конфигурацией или оставить несовместимое состояние в коде.'),
p('Другой частый сценарий обратный. Rollout дошёл до 100 процентов, и это называют завершением. Но 100 процентов означает лишь, что правило сейчас выбирает candidate для объявленной аудитории. Это не доказательство, что fallback больше не нужен, что данные совместимы или что безопасно удалить ключ. Пока не зафиксирован final variant, не собраны разрешённые evidence и не удалены runtime branches, флаг продолжает расширять поверхность ошибки. Поэтому rollout, rollback и cleanup нужно держать как три разные операции.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Флаг выключен или включён на 100 процентов, но в коде остаются две ветки и никто не может назвать дату удаления.',
'<strong>Причина.</strong> Аудитория, fallback, наблюдение и cleanup смешаны в одной операции enable/disable.',
'<strong>Проверка.</strong> Для каждого stage запишите cohort key, observation question, stop condition, fallback action и отдельно cleanup gate с доказательствами удаления.',
'<strong>Действие.</strong> При stop condition сначала верните аудиторию к fallback. После выбора final variant начните новый change: удалить branches, config references, флаговые tests и документацию, сохранив обычную behaviour-проверку.',
]),
h2('Четыре состояния вместо одной шкалы процента'),
p('Процент rollout — только один параметр delivery. Он не сообщает, применился ли новый путь к конкретной сессии, пришёл ли refresh к клиенту, завершился ли запрос и безопасно ли удалять старый код. Поэтому у шага должны быть как минимум пять полей: stage, cohort key, окно наблюдения, stop condition и действие на остановке. Числа 0, 5, 25 и 100 ниже — не рекомендованная лестница и не данные реальной команды. Это fixed synthetic объект fixture, удобный для обсуждения переходов.'),
table('Учебная лестница rollout и действия', ['Synthetic stage', 'Допустимый вопрос', 'Stop condition', 'Немедленное действие', 'Что остаётся открытым'], [
['0%', 'fallback path существует и его контракт ещё можно проверить', 'fallback не выполняет согласованный smoke scenario', 'не начинать rollout; вернуть change в design', 'все candidate branches и data assumptions'],
['5%', 'одна стабильная synthetic cohort получает candidate согласно declared key', 'согласованный synthetic signal нарушает заранее записанную границу', 'вернуть audience к 0%, сохранить change record', 'root cause и решение о повторном запуске'],
['25%', 'candidate и fallback можно сопоставить в одной разрешённой модели наблюдения', 'signal не интерпретируется или config version неизвестна', 'остановить увеличение аудитории, не менять одновременно rule и код', 'качество evidence и consistency boundary'],
['100%', 'вся объявленная аудитория получает selected variant в текущей config version', 'final variant ещё не утверждён либо fallback нужен для recovery', 'не удалять флаг; открыть final decision review', 'cleanup proof и сохранность данных'],
['Cleanup gate', 'code и config больше не требуют alternate branch', 'найдена хотя бы одна runtime reference или необходимый fallback', 'оставить key и вернуть cleanup в доработку', 'новый independent release удаления'],
]),
figure('/assets/editorial/2024/feature-flags-2024-cleanup-gate.svg', 'Временная схема отделяет rollout 0–5–25–100 процентов от rollback к fallback и от cleanup gate. После 100 процентов показан отдельный review, затем удаление веток, конфигурации и флаговых tests. Проценты — fixed synthetic model, не реальные показатели.', 'Выключение флага ведёт к mitigation и расследованию. Cleanup начинается только после выбора итогового поведения и проверки, что alternate path действительно удалён.'),
h2('Rollback сначала возвращает поведение, а не переписывает историю'),
p('Rollback нужен для короткого и понятного возврата к fallback. В момент stop condition не надо одновременно менять процент, evaluator, data migration и код. Иначе команда теряет причинную связь: неизвестно, что именно изменило наблюдение. Минимальная операция — вернуть объявленную аудиторию к нулю или к заранее выбранному safe value, оставить старый путь выполнимым и записать effective configuration version, на которой остановились. Этот action не лечит данные и не удаляет риск. Он только прекращает дальнейшее расширение candidate.'),
p('Если новый путь успел записать несовместимое состояние, rollback должен опираться на отдельный migration contract. Фича-флаг не заменяет backward compatibility. Нельзя обещать, что достаточно поставить false, если API response, database schema или side effect уже сменились. До первого rollout полезно спросить: может ли fallback прочитать состояние, созданное candidate, и какая команда владеет исправлением, если ответ нет. Если ответ не известен, это blocker дизайна, а не причина ускорить rollout маленьким процентом.'),
h2('Cleanup начинается с решения о единственном варианте'),
p('Флаг можно удалять только после того, как owner зафиксировал final variant. Это кажется очевидным, но без такого шага cleanup превращается в спор «оставим на всякий случай». Сначала замораживают правило: больше не добавляют conditions, segments, variants или новые client checks. Затем выносят отдельный change с областью удаления. В нём есть source files, server branches, client branches, configuration references, tests, docs и migration notes. Удалять всё одним глобальным search-and-replace рискованно: похожий ключ может быть частью другого текста, а feature flag может иметь несколько evaluator.'),
p('Проверка cleanup состоит из негативных утверждений. Runtime code больше не должен ветвиться по ключу. Конфигурация больше не должна содержать key, targeting rule или token permission, созданные только для него. Tests больше не должны тестировать два варианта, но обязаны сохранять проверку выбранного итогового поведения. Документация не должна приглашать новую команду включить уже отсутствующий путь. Если какая-то reference нужна для reversible migration, это не «почти удалено»: флаг всё ещё имеет ownership и review.'),
table('Cleanup gate: что ищем до удаления configuration', ['Область', 'Проверяемый артефакт', 'Причина отказа cleanup', 'Действие'], [
['Server code', 'нет evaluator call и alternate branch для ключа', 'защищённое решение всё ещё зависит от flag result', 'удалить branch или разделить change на migration и cleanup'],
['Client code', 'нет presentation toggle, stale cache key или exposure schema, привязанной только к флагу', 'UI может снова трактовать старый result', 'сохранить только итоговый UI contract и проверить fallback assumptions'],
['Flag configuration', 'нет targeting rules, variants, environment overrides и токенов, нужных только для ключа', 'код ещё ожидает result либо нужен rollback', 'не удалять configuration до устранения references'],
['Tests', 'остался test итогового поведения и удалены flag-specific forks', 'test matrix по-прежнему требует оба пути', 'переписать test вокруг business contract, не вокруг boolean'],
['Документация', 'карточка выпуска закрыта решением и ссылкой на cleanup change', 'следующая команда не понимает, почему key исчез', 'оставить короткую decision запись без инструкции вернуть флаг'],
]),
h2('Воспроизводимая модель не притворяется production-runbook'),
p('Учебная фикстура хранит stages 0, 5, 25 и 100 как плотный массив fixed synthetic object. Она не читает dashboard, feature store, Git, CI, event broker или clock. Поэтому прогон может проверить только дисциплину модели: stage имеет rollback, cleanup gate требует удалённых веток и config, а input с URL, реальным ключом, телеметрией или production marker отвергается. Это полезнее, чем код, который выглядит как оператор и молча скрывает доступ к реальному флагу.'),
code(fixtureCommand),
p('В case <code>fixed-rollout-cleanup-v1</code> arrays stagesPercent, requiredSignals, rollback и cleanupGate намеренно фиксированы. Если заменить stages на разрежённый массив, подменить stop action или добавить output от настоящего API, plan отказывает. Если сделать decision cyclic, проверка завершается отказом, а не исключением. PASS не означает, что 5 процентов безопасны или cleanup завершён. Он означает, что учебный объект не потерял свою границу и не стал каналом управления production.'),
h2('Как пройти от rollout к удалению'),
ol([
'<strong>До первого включения.</strong> Проверьте fallback contract и data compatibility. Зафиксируйте owner, cohort key, stop condition и один разрешённый source evidence.',
'<strong>На каждом stage.</strong> Меняйте только audience или только rule в одном change. Записывайте effective config version и вопрос наблюдения; не выдавайте случайный event за эффект.',
'<strong>При остановке.</strong> Сначала верните audience к fallback. Не удаляйте code, пока не известно, что candidate больше не нужен для diagnosis или migration.',
'<strong>После 100%.</strong> Проведите final decision review. «Все получают candidate» не является решением удалить fallback.',
'<strong>Откройте cleanup change.</strong> Заморозьте flag policy, перечислите references и разделите удаление кода, config, tests и документации на проверяемые шаги.',
'<strong>Проверьте отрицательные условия.</strong> Search, unit/integration tests и config review должны показать отсутствие runtime dependence. Затем deploy cleanup как отдельное изменение с обычным rollback plan.',
'<strong>Закройте карточку.</strong> Сохраните final variant, причину удаления, owner и ссылку на cleanup evidence. Не сохраняйте флаг «для памяти»: память хранится в decision record, не в выполняемой ветке.',
]),
h2('Почему disabled flag не является доказательством cleanup'),
p('Immutable commit Feature Toggles Unleash от 21 августа 2024 говорит, что disabled toggle в environment evaluates false. Это ценная механическая гарантия конкретного продукта: включённая стратегия не будет случайно выбрать true, пока флаг disabled в этом environment. Но из неё не следует, что server code перестал вычислять флаг, клиент перестал знать ключ, тесты перестали держать две ветки или секреты/permissions уже удалены. Даже false evaluation остаётся dependency, если приложение продолжает спрашивать ответ.'),
p('Именно поэтому policy cleanup намеренно сильнее disablement. Сначала нужно доказать, что выбранный final behavior существует без evaluator. Потом убрать configuration, чтобы будущая случайная смена не могла оживить старый путь. И только затем закроется долг. В некоторых системах порядок config/code может отличаться из-за deployment topology; это надо явно описать в change. Универсального «сначала delete flag» нет. Есть только правило: ни один шаг не должен оставлять исполняемый code без нужного ему contract.'),
h2('Ограничения и следующий проверяемый шаг'),
p('Материал не задаёт rollout percent, SLO, error budget, retention периода, способ миграции или команду для feature platform. Синтетическая шкала не описывает трафик, conversion, latency, incident или real cohort. Источники OpenFeature и Unleash задают узкие API/configuration facts, но не дают готового cleanup process вашей организации. Отдельно нужно проверить provider semantics, token scopes, cache, legal boundary контекста и совместимость данных конкретного изменения.'),
p('Следующий проверяемый шаг — выберите один отключённый флаг и проведите короткий cleanup gate из второй таблицы. Не удаляйте key сразу. Сначала найдите одну живую reference: server branch, client branch, config, test или документ. Назначьте её owner и откройте отдельный change удаления. Ожидаемый результат: флаг перестаёт быть тёмным углом репозитория, потому что есть одно решение, один список оставшихся references и путь к единственному поведению.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runFeatureFlagsFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length > 0) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
return;
}
const count = Object.keys(report.assertions).length;
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');