Update Delivery — доставка оновлень DOP клієнтам¶
Статус: 📋 архітектурне рішення (ADR), реалізація відкладена.
Контекст: desktop-installer.md описує первинну установку через tarball-based
DOP-Setup.exe. Цей документ закриває вже відкладене питання звідти (Частина 2 — Auto-update) і додає рівень self-hosted-розгортань (Linux daemon, Docker Compose, Kubernetes). Без коду — перший крок: зафіксувати рішення для дискусії, потім інкрементально реалізовувати фази.
Зміст¶
- Контекст і чому зараз ADR
- Цілі і не-цілі
- Сутності і поняття
- Канали релізів
- Версіонування
- Update Manifest
- Розповсюдження артефактів
- Міграції БД
- Rollback
- Auto-update agent
- Сумісність плагінів
- Phased rollout і kill-switch
- Trade-offs (зведено)
- Відкриті питання
- Roadmap
- Посилання
- 🔮 Deferred / Ideas
Контекст і чому зараз ADR¶
Сьогодні DOP доставляється клієнту трьома способами:
- Docker tarball —
docker save eswf-demo:latest -o ...+docker loadна машині колеги. Ручне, без оновлень. - Demo desktop installer (план у desktop-installer.md) —
DOP-Setup.exeз вшитим tarball, без auto-update. - Self-hosted — клонування репо + запуск через
docker-compose(для технічного користувача).
Жоден варіант не має відповіді на питання «у клієнта DOP версії 1.4.0, ми випустили 1.5.1 — як їй потрапити до нього?». До першого реального оновлення треба зафіксувати:
- модель версій і каналів,
- де живе джерело правди про «остання версія для каналу X»,
- як міграції БД і дані переживають апгрейд,
- як відкочуватись,
- як перевірити що плагіни не зламаються.
ADR не пропонує реалізації — він описує дизайн, який ми погоджуємо, і роадмап реалізації фазами.
Цілі і не-цілі¶
Цілі¶
- Однозначність версії. На кожному клієнті в будь-який момент відомо: яка версія core, які плагіни і чи є новіша.
- Передбачувані оновлення. Клієнт стабільного каналу не отримує сюрпризів — апгрейд лише з його згоди (manual mode) або у заплановане вікно (scheduled mode).
- Безпечні міграції. Перед накатом — обов'язковий снапшот; після — health-check; при провалі — автоматичний rollback.
- Підтримка двох форм-факторів — desktop (Docker Desktop + tray-launcher) і self-hosted (Linux daemon / Compose / K8s) через єдиний манифест-протокол.
- Сумісність плагінів за версіями core. Платні плагіни (logistic, containerhub, ...) декларують підтримуваний діапазон core-версій; апгрейд core блокується якщо встановлений плагін поза діапазоном.
Не-цілі (out of scope першої ітерації)¶
- Continuous delivery без даунтайму — для desktop-поставки приймемо короткий downtime (10–60 с) на час
migrate+ container restart. - Multi-region active-active — вертикальне масштабування поза скопом ESWF як продукту.
- Зворотньо-сумісні міграції (downgrade DB) — Django міграції forward-only за дизайном; rollback робимо через volume snapshot, не через
migrate <prev>. - Делта-оновлення (patch images) — викачуємо повний image; оптимізація на потім.
Сутності і поняття¶
| Термін | Що це |
|---|---|
| Реліз | Іменована, заморожена версія DOP core (1.4.2) і набір сумісних плагінів. |
| Канал | Логічна стрічка релізів (stable / beta / dev) з власною каденцією і вимогами якості. |
| Manifest | JSON-файл з описом «остання версія для каналу X», розміщений на well-known URL. Єдине джерело правди для агента. |
| Update agent | Невеликий процес на стороні клієнта, що періодично читає manifest і ініціює апгрейд. |
| Snapshot | Точка відновлення (volume + DB dump + image tag) перед апгрейдом. |
| Compat range | Діапазон версій core, з якими плагін гарантовано працює (>=1.4 <2.0). |
Канали релізів¶
Розглянуті варіанти¶
| Варіант | Плюси | Мінуси |
|---|---|---|
Один канал (latest) |
максимально просто | немає де обкатати «бету», ризик зламати всіх клієнтів одразу |
Два канали (stable, dev) |
мінімально достатньо | beta-тестери і dev-розробники змішані |
Три канали (stable / beta / dev) |
чітка градація: продакшн, ранній доступ, внутрішній CI | більше дисципліни релізного процесу |
| Чотири+ (LTS / stable / beta / dev / nightly) | гнучкість | поки немає аудиторії що вимагає LTS — overkill |
Обране рішення — три канали¶
| Канал | Призначення | Каденція | Хто на ньому | Quality gate |
|---|---|---|---|---|
stable |
продакшн-клієнти, default | ~раз/місяць | бухгалтери, менеджери, всі платні tenant-и | ручний QA + проходження beta ≥7 днів без P0 |
beta |
ранній доступ, opt-in | ~раз/2 тижні | tenant-и з прапором release_channel=beta в admin tools |
автотести + smoke на demo-tenant |
dev |
внутрішнє, CI-збірки | per-merge у master |
тільки розробники / Render demo | проходження автотестів |
Чому саме так:
- Один канал — занадто ризиковано: одна регресія валить усіх.
- Чотири — поки немає LTS-вимог від клієнтів (армія, держустанови). Додамо коли зʼявиться попит (🔮 Deferred).
- Перехід між каналами — рішення tenant-а в admin tools; даунгрейд (stable → beta → dev) дозволяється лише в межах major-версії і потребує snapshot-restore.
Маппінг каналу на артефакти¶
release-channel → git ref → docker tag → manifest URL
─────────────────────────────────────────────────────────────────────────────────
stable → tags/v1.4.2 → eswf/dop:stable → updates.eswf.dev/stable.json
beta → branches/release → eswf/dop:beta → updates.eswf.dev/beta.json
dev → master → eswf/dop:dev → updates.eswf.dev/dev.json
Версіонування¶
Core¶
SemVer 2.0 — MAJOR.MINOR.PATCH:
- MAJOR — несумісні зміни: домен (нові обовʼязкові поля без default), API (видалені/перейменовані ендпоінти), формат БД що потребує міграції з втратою даних. Очікувана каденція — 1–2 рази на рік.
- MINOR — нові фічі, нові модулі/плагіни, нові поля з default-ами, нові ендпоінти. Зворотньо-сумісно. Каденція ~раз/місяць.
- PATCH — виправлення без зміни схеми/контракту. Каденція on-demand.
Версія core — у backend/eswf/__version__.py (один single source of truth), з нього зчитується для:
- API ендпоінт GET /api/v1/system/version/ (фронтенд показує у footer/about),
- Docker labels (org.opencontainers.image.version=1.4.2),
- Update manifest.
Tag scheme для Docker-образів¶
| Tag | Семантика | Use-case |
|---|---|---|
eswf/dop:1.4.2 |
immutable конкретна версія | детермінований deploy, CI, debug |
eswf/dop:1.4 |
floating до останнього patch у minor | self-hosted з опт-ін на patch-апдейти |
eswf/dop:stable |
floating до останнього у каналі | desktop-tray-агент за замовчуванням |
eswf/dop:beta |
floating до останнього у каналі | beta-тестери |
eswf/dop:dev |
floating, CI per-merge | внутрішнє |
Принцип: агент за замовчуванням слухає manifest, а не :latest-тег. Тобто навіть якщо хтось перезапише :stable випадково — агент звірить версію з manifest і не оновиться, поки той не оновлений атомарно.
Плагіни — незалежні версії¶
Кожен платний плагін (eswf-logistic, eswf-containerhub, ...) має власний SemVer: eswf-logistic 0.9.3 не звʼязаний з dop core 1.4.2. Сумісність задається compat range у Product/манифесті плагіна: core_min_version="1.4", core_max_version="<2.0".
Update Manifest¶
JSON-файл фіксованого формату на well-known URL — https://updates.eswf.dev/<channel>.json:
{
"channel": "stable",
"version": "1.4.2",
"released_at": "2026-04-27T10:00:00Z",
"image": {
"registry": "ghcr.io/ynikolaenko/eswf",
"tag": "1.4.2",
"digest": "sha256:abc123...", // immutable, перевіряємо при pull
"size_bytes": 612482000
},
"migration_level": 142, // максимальний номер django migration
"min_supported_from": "1.3.0", // нижче цієї — апгрейд через проміжний reлiз
"plugin_compat": { // дозволені плагіни на цій версії
"logistic": ">=0.8 <1.0",
"containerhub": ">=0.5",
"essentials_quality": ">=0.2"
},
"release_notes_url": "https://eswf.dev/releases/1.4.2",
"kill_switch": false, // якщо true — клієнти на цій версії примушуються до апгрейду
"signature": "ed25519:...", // підпис над усім обʼєктом без поля signature
"signed_by": "release-key-2026"
}
Чому manifest а не сам registry: - Може хоститись на статичному CDN (CF Pages, S3, GitHub Pages) — нульова інфраструктура. - Дешева верифікація підпису без TLS-mutual-auth. - Незалежність від registry — registry може мінятись (Docker Hub → ghcr.io → власний), manifest лишається.
Розповсюдження артефактів¶
Варіанти¶
| Варіант | Плюси | Мінуси |
|---|---|---|
| Public registry (Docker Hub / ghcr.io public) | безкоштовно, CDN, прості docker pull |
образ публічний, не для приватних релізів |
| Private registry (Docker Hub paid / ghcr.io private) | приватність, той самий API | потрібен docker login на стороні клієнта (PAT, ротація) |
| Власний registry на VPS | повний контроль, брендинг (registry.eswf.dev) |
треба хостити, TLS, auth, моніторинг |
| Manifest + tarball на CDN | нуль registry-інфраструктури, офлайн-friendly | ручний docker load; складніше підписувати рівень шарів |
Обране рішення — гібрид¶
- Phase 1 (offline-first): manifest + tarball на статичному CDN (CF Pages або GitHub Releases). Агент завантажує
.tarза URL з manifest, робитьdocker load. Підходить для desktop-поставки і офлайн-середовищ. - Phase 2 (online): ghcr.io public для community-каналів (
stable,beta) + manifest як єдиний source of truth. Агент робитьdocker pullза digest. - Phase 3 (paid): ghcr.io private або власний
registry.eswf.devдля платних редакцій з ліцензійним гейтом.
Чому такий порядок¶
- Phase 1 повністю покриває MVP сценарій (бухгалтер → desktop installer → апдейт раз/місяць) без жодного хостингу окрім CF Pages.
- Phase 2 потрібна лише коли частота оновлень виросте і tarball-шлях стане незручним.
- Phase 3 — комерційний крок, потребує auth-flow (PAT / token), не блокує перші дві.
Міграції БД¶
Принципи¶
- Forward-only. Django міграції не призначені для downgrade на проді з даними. Не намагаємось зробити симетричні
0042_..._reverse.py. - Backwards-compatible bump. Між patch-версіями і у межах одного minor — нові поля тільки з
defaultабоnull=True; видалення колонок робимо через двофазну міграцію (release N+1 —RemoveField). - Atomic per-release. Один реліз — один
migrateзапуск. Не дозволяємо проміжний стан «половина міграцій застосовано». - Pre-flight check. Агент запускає
manage.py migrate --checkу новому контейнері до зупинки старого (ефемерно, на тестовій БД-копії), а не наосліп.
Алгоритм апгрейду (стандартний шлях)¶
1. agent: pull нового image за digest з manifest
2. agent: тригер snapshot (volume + db dump) — див. "Rollback"
3. agent: stop старого контейнера
4. agent: docker run --rm new_image manage.py migrate --noinput
└── якщо exit ≠ 0 → rollback з snapshot, abort
5. agent: docker run new_image (новий контейнер на тому ж volume)
6. agent: чекати health-check 60 с (`GET /api/health/`)
└── якщо timeout → rollback з snapshot
7. agent: помітити в локальному state «installed_version=X», відписати в telemetry (опційно)
8. agent: повідомити користувача «оновлено до 1.4.2»
Складні міграції (data migrations, довгі переіндексації)¶
Для міграцій що довше ~30 с на типовому tenant — окрема стратегія:
- Маркуємо їх is_long_running=True у манифесті (нове поле в MigrationMeta або через naming convention 0050_long_...).
- Агент переходить у явний maintenance mode — показує користувачу банер «оновлюємо, очікуйте N хвилин» замість мовчазного даунтайму.
- Розглянемо background migrations (Django RunPython з порціями) у 🔮 Deferred.
Rollback¶
Принцип¶
Rollback робимо через snapshot-restore, не через migration revert. Reverting Django migrations на проді з даними = data-loss-roulette. Snapshot — детермінований шлях.
Snapshot — що саме¶
Перед апгрейдом, обовʼязково:
- Volume snapshot. Для Docker Desktop —
docker run --rm -v dop-data:/from -v ./snapshots:/to alpine tar -czf /to/snap-$timestamp.tar.gz /from. Для self-hosted з ZFS/LVM — нативний snapshot. - DB dump. Через існуючий механізм backup-recovery.md —
manage.py dop_backup. - Image tag. Запамʼятовуємо який image-digest був попередній — щоб мати чим повернутись.
- Migration state. Нічого не зберігаємо окремо — стан у БД (
django_migrations), volume snapshot його містить.
Snapshot живе локально (%USERPROFILE%\Documents\DOP-Backups\snapshots\ на Windows, /var/lib/dop/snapshots/ на Linux). Retention — N останніх (default 3), старіші чистимо.
Тригери rollback¶
| Тригер | Дія |
|---|---|
migrate --check exit ≠ 0 |
abort — image ще не запущений, snapshot не потрібен |
migrate --noinput exit ≠ 0 |
restore volume → restart старого image → notify |
| Health-check timeout 60 с після старту нового | restore volume → restart старого image → notify |
| Користувач натиснув «Rollback» в admin tools (manual) | restore volume → restart попереднього image |
Кросс-major rollback (e.g. 2.0 → 1.4)¶
Особливий випадок: між major-версіями структура volume може бути несумісна з попереднім image (нові обовʼязкові колонки в раніше-чистих таблицях, нові індекси). Rollback дозволено лише на той snapshot, що був перед апгрейдом — не на довільну стару версію. Це фіксується у документації для адміна.
Auto-update agent¶
Розгортання — два форм-фактори¶
| Форм-фактор | Де живе агент | Стиль |
|---|---|---|
| Desktop (Docker Desktop + launcher) | вшитий у launcher-gui/ (Electron tray app, додаємо update-модуль) |
tray icon показує «оновлення доступне», користувач підтверджує |
| Self-hosted (Linux daemon) | окремий dop-updater systemd-сервіс або cron job |
конфіг в /etc/dop/updater.yml, лог у journald |
| Compose / K8s | side-car контейнер dop-updater:stable або external CI-pipeline |
патчить compose image: → docker compose up; для K8s — через operator (далеко в будущому) |
Режими¶
| Режим | Поведінка | Default для каналу |
|---|---|---|
| Manual | агент опитує manifest, нотифікує, чекає дії користувача | stable |
| Scheduled | агент опитує, апгрейдить у заплановане вікно (e.g. «3:00 ночі неділі») | self-hosted production |
| Aggressive | апгрейд при першій же детекції | dev, demo-tenant |
Перемикач — у AdminToolsPage → нова вкладка «Оновлення»: вибір каналу, режим, вікно scheduled, історія апгрейдів, кнопка «Перевірити зараз», кнопка «Rollback до попередньої».
Polling cadence¶
stable— раз/добу.beta— раз/6 годин.dev— раз/15 хв.
Polling — простий GET <manifest-url>, ETags для дешевизни. Без push-каналу (WebSocket / SSE) у MVP — додамо коли частота оновлень виросте.
Сумісність плагінів¶
Існуюча модель¶
Зараз (plugin-instruction.md) плагін описаний у shop.Product з полями code, plugin_key, pricing. Сумісність з версіями core ніяк не зафіксована — це чистий gap для апгрейдів.
Що додаємо¶
Розширення Product (або новий PluginManifest як 1:1 до Product):
| Поле | Семантика |
|---|---|
core_min_version |
мінімальна core-версія (>=1.4.0) |
core_max_version |
максимальна (exclusive) (<2.0.0) |
tested_versions |
масив явно перевірених — для UI («перевірено на 1.4.0, 1.4.1, 1.4.2») |
breaking_changes |
вільний текст для випадків коли upgrade плагіна потребує дій від адміна |
Алгоритм перевірки¶
Перед migrate нового core агент:
- Опитує локальний реєстр встановлених плагінів через
GET /api/v1/system/plugins/installed/(новий endpoint). - Для кожного звіряє
core_target_versionзcompat rangeплагіна. - Якщо хтось не вписується:
- Hard block — апгрейд core не починається.
- Користувачу показуємо список несумісних плагінів і два шляхи: «Оновити плагін до 0.9 → потім ретрай core», або «Видалити плагін → апгрейд core → перевстановити коли вийде сумісна версія».
- Якщо вписуються, але
tested_versionsне містить нову — попередження (yellow), не блок.
Manifest плагіна¶
Окремий manifest на canalу плагіна: https://updates.eswf.dev/plugins/logistic/<channel>.json. Той самий формат що й core, плюс core_compat поле. Плагіни оновлюються незалежно від core.
Phased rollout і kill-switch¶
Phased rollout (canary)¶
Ризиковані релізи (особливо MAJOR) розкочуємо поступово:
- День 0 — manifest для
betaоновлений. Beta-tenant-и оновлюються. - Дні 1–7 — моніторинг telemetry/підтримки.
- День 7+ —
stablemanifest оновлено,beta-версія промотується уstable.
Реалізується просто перемиканням манифесту — не потрібна окрема інфраструктура.
Kill-switch¶
Поле "kill_switch": true у манифесті каналу для попередньої версії (не нової). Семантика: «всі хто на 1.4.1 повинні негайно перейти на 1.4.2 — там критичний баг».
Поведінка агента:
- Якщо installed_version присутня у kill_switch списку — режим тимчасово примушується до aggressive, нотифікація показується як warning а не info.
- Користувач формально може ігнорувати — primary-кнопка все одно «Оновити зараз».
Не плутати з мандаторним блокуванням — у DOP не блокуємо роботу примусово (це може коштувати грошей бухгалтеру у дедлайн-день).
Trade-offs (зведено)¶
| Рішення | Виграємо | Платимо |
|---|---|---|
Manifest замість :latest |
детермінізм, підпис, незалежність від registry | додатковий артефакт що треба підтримувати у синку з образами |
| 3 канали | чітка градація QA | релізна дисципліна (треба робити QA перед stable) |
| Snapshot-rollback замість migration revert | надійність, проста ментальна модель | місце на диску (3 × volume size) |
| Manifest + tarball на CDN (Phase 1) | нуль інфраструктури registry | агент мусить вміти docker load (більше коду на Windows) |
| Hard block по plugin compat | захист від руїни | адмін може застрягнути якщо плагін давно не оновлювався — потрібен fallback «видалити плагін» |
| Polling замість push | нульова інфраструктура (без WS/SSE) | затримка до доби на stable (прийнятно) |
Відкриті питання¶
1. Хостинг manifest + registry¶
Кандидати: CF Pages (безкоштовно, статика), GitHub Releases (як CDN для tarball), власний VPS (registry + manifest), AWS S3 + CloudFront. Рішення відкладено до Phase 2 — для Phase 1 достатньо GitHub Releases.
2. Підпис manifest — який алгоритм¶
Варіанти: GPG (звично, але громіздко), ed25519 з ротацією ключів через manifest-of-manifests, Sigstore (cosign — індустріальний стандарт, але потребує Fulcio/Rekor). Рішення відкладено — Phase 1 без підпису (HTTPS достатньо), Phase 2 — ed25519.
3. Telemetry — чи треба знати які версії на яких клієнтах¶
За: оперувати kill-switch, планувати deprecation period, бачити чи апгрейд успішний.
Проти: privacy, GDPR, треба explicit consent.
Поточне нахилення: opt-in (галочка в admin tools), за замовчуванням вимкнено. Якщо ввімкнено — агент відсилає {tenant_id (hashed), installed_version, last_upgrade_at, last_upgrade_status} на https://telemetry.eswf.dev/v1/heartbeat раз/добу.
4. Apple Silicon / ARM64¶
Dockerfile.demo сьогодні linux/amd64. ARM64 — окрема збірка (docker buildx --platform linux/arm64), окремий manifest channel stable-arm64.json. Не пріоритет до Phase 2.
5. Зворотній порядок — як апгрейдити плагін до апгрейду core¶
Сценарій: новий core 1.5 потребує плагіна 0.9, але плагін вийшов раніше core. Тоді апгрейд core блокується (hard block за compat). Розвʼязка:
- Варіант A: плагіни випускаємо одночасно з core (release-train).
- Варіант B: плагін у compat range декларує і next_planned_version, агент попереджає заздалегідь.
- Варіант C: агент пропонує «попередньо оновити плагін» з тимчасовим --no-core-check — ризиковано.
Поточне нахилення — A (release-train), як в Linux distro.
6. Multi-tenant в одній інсталяції¶
DOP уже multi-tenant у БД, але апгрейд робиться на рівні install-у (один Docker volume), не на рівні tenant-а. Це нормально для desktop (single-tenant) і для self-hosted (admin вирішує за всіх). Per-tenant rolling upgrade — out of scope.
Roadmap¶
| Фаза | Скоп | Тригер реалізації |
|---|---|---|
| 0 — ADR (цей документ) | дизайн і обговорення | поточна сесія |
| 1 — Manual update via tarball | manifest на GitHub Releases, окрема manage.py upgrade_to <version> команда, snapshot+restore, без агента |
перший зовнішній клієнт desktop installer |
| 2 — Tray-агент + manual mode | update-модуль у launcher-gui/, polling, нотифікації, кнопка «Оновити», history-вкладка |
≥3 встановлень desktop installer |
| 3 — Plugin compat enforcement | поля в Product, endpoint /system/plugins/installed/, hard block |
перший платний плагін на новій major-версії |
| 4 — Self-hosted updater | dop-updater systemd-сервіс, scheduled mode |
перший клієнт self-hosted production |
| 5 — Manifest signing + ghcr.io registry | ed25519, ротація ключів, перехід з tarball на pull | частота оновлень >1/тиждень |
| 6 — Telemetry + kill-switch | heartbeat endpoint, opt-in, dashboard | поява критичного багу що вимагає массового апгрейду |
| 7 — Phased rollout automation | автопромоушен beta → stable після N днів без P0 |
релізна каденція стає тижневою |
Посилання¶
- desktop-installer.md — Windows installer (tarball-based) — фундамент Phase 1
- docker.md —
Dockerfile.demo, nginx, supervisord — артефакт для апгрейду - plugin-instruction.md — реєстрація плагіна, license/activation gate — основа для compat range
- backup-recovery.md — UI Backup & Recovery — переюзаємо для snapshot pre-upgrade
- SemVer 2.0
- Sigstore / cosign — для Phase 5
🔮 Deferred / Ideas¶
Частина 1 — LTS-канал¶
Мотивація: держустанови / великі бухгалтерії хочуть «один реліз на рік + патчі безпеки», без MINOR-апдейтів. Чому відкладено: немає аудиторії з такими вимогами; додавання каналу — простий manifest + reлiз-policy, не блокує ніщо. Trigger: перший клієнт зі SLA на LTS у договорі.
Частина 2 — Background data migrations (без maintenance window)¶
Мотивація: довгі data migrations (>5 хв) роблять апгрейд незручним — клієнт чекає у вікні «оновлюємо».
Чому відкладено: на поточних обʼємах даних найдовші міграції <30 с; інфраструктура RunPython порцiями вимагає окремого фреймворку.
Trigger: перший клієнт з БД >10 GB або скарга на «довге оновлення».
Частина 3 — Delta-оновлення (image layer diff)¶
Мотивація: повний образ ~600 MB → за рік клієнт викачує 7 GB на 12 апдейтів. Delta дала б ~50 MB на апдейт.
Чому відкладено: Docker layer caching уже частково вирішує (нові шари — лише змінені); справжня delta — окрема технологія (e.g. casync).
Trigger: скарги на трафік / повільне оновлення з мобільного інтернету.
Частина 4 — Push-нотифікації про реліз (WS/SSE)¶
Мотивація: замість polling раз/добу — миттєва нотифікація через WebSocket з api.eswf.dev.
Чому відкладено: polling достатньо для каденції раз/місяць.
Trigger: kill-switch епізод де доба-затримки виявилась критичною.
Частина 5 — K8s operator для self-hosted enterprise¶
Мотивація: один-нодовий self-hosted виходить дешево, але enterprise-клієнти хочуть HA + rolling upgrade у K8s. Чому відкладено: немає такої аудиторії, дизайн HA — окрема велика тема. Trigger: перший корпоративний клієнт зі ставкою на K8s.
Частина 6 — Per-tenant feature flags¶
Мотивація: ввімкнути новий ризикований модуль (e.g. AI-агент) лише для частини tenant-ів каналу stable без окремого каналу.
Чому відкладено: немає інфраструктури flags (рішення: GrowthBook / Unleash / власне). Канали поки покривають 80% потреби.
Trigger: перший випадок «треба випустити фічу тільки для tenant X для тестування».