AI-first delivery: кейс Hublix на OpenSpec

Одна реальная задача от proposal до archive: артефакты, решения человека, что агент сделал сам, и где этот процесс не окупается

21 июля 2026

Полевая заметка

Про spec-driven development легко написать обзор фреймворков и списком перечислить, что теперь код пишет агент, а человек проверяет. Полезнее показать одну реальную задачу целиком. Hublix - платформа процессных AI-агентов в Telegram, Python-монорепозиторий на OpenSpec. Разберу, как в нём проходит одна фича - система баланса по тенантам - от /opsx:propose до /opsx:archive: какие решения принял человек, что агент нашёл сам, какие артефакты остались, и в каких случаях затраты на этот процесс не оправданы. Это разбор применения OpenSpec в конкретной задаче.

Контекст: что такое Hublix и почему OpenSpec

Hublix - платформа процессных AI-агентов в Telegram: четыре микросервиса на Python, React-панели, PostgreSQL, LLM в цикле обработки сообщения. Планирование идёт через OpenSpec - лёгкий spec-driven слой с тремя командами: propose (описать изменение), apply (разбить на задачи и реализовать), archive (сохранить финальное состояние). Никаких rigid phase gates - артефакт можно править в любой момент.

Инфраструктура контекста для агента выглядит так:

hublix/
├── CLAUDE.md                          # Конституция: стек, архитектура, trigger tables
├── .claude/agents/                    # Специализированные агенты по доменам
│   ├── orchestration-specialist.md
│   ├── identity-specialist.md
│   ├── state-engine-specialist.md
│   ├── frontend-specialist.md
│   ├── infra-specialist.md
│   └── pr-reviewer.md
├── .claude/rules/
│   ├── testing.md                     # 100% coverage, pytest conventions
│   ├── design.md                      # OpenSpec proposal до кода
│   └── security.md                    # 152-ФЗ, валидация, секреты
├── openspec/
│   ├── specs/                         # Холодная память по подсистемам
│   │   ├── auth-flows/
│   │   ├── orchestration-pipeline/
│   │   ├── tenant-isolation/
│   │   └── ...
│   └── changes/                       # Активные и архивные изменения
│       ├── yookassa-payments/         # В работе
│       └── archive/
│           └── 2026-04-01-tenant-balance-credits/
├── services/                          # 4 микросервиса
│   ├── identity/                      # Auth, tenants, balance
│   ├── messaging/                     # Webhook gateway
│   ├── orchestration/                 # FSM + LLM
│   └── scheduler/                     # Cron jobs
├── libs/                              # Shared code
│   ├── db/                            # SQLAlchemy, Alembic, repos
│   └── shared/                        # Pydantic types
└── apps/                              # React UI
    ├── admin/                         # Tenant admin
    └── backstage/                     # Platform admin

Конституция (CLAUDE.md) содержит trigger tables - маршрутизацию агентов по путям: при изменении файлов в services/orchestration/ подключается orchestration-specialist, при работе с services/identity/ - identity-specialist. Правило #9 прямое: use OpenSpec for planning - /opsx:propose before implementing non-trivial changes. Ключевое слово - non-trivial: для небольших изменений этот процесс не требуется.

Задача: Tenant Balance & Credits

Задача: добавить систему баланса в рублях, чтобы контролировать расход LLM-токенов по тенантам. Каждый вызов YandexGPT стоит денег, и тенант должен работать только пока баланс положительный. Это нетривиальное изменение - оно затрагивает домен тенантов, цикл обработки сообщения и админку. Значит, по правилу #9, начинаем с proposal.

Proposal: решения, зафиксированные до кода

/opsx:propose сгенерировал proposal.md. Важное здесь - не то, что его написал агент, а то, что ключевые решения принял человек и они зафиксированы текстом до первой строчки кода:

Proposal также зафиксировал scope (11 пунктов) и out of scope - YooKassa, per-tool pricing, аналитика уходят в будущие фазы. Именно раздел «out of scope» удерживает агента от того, чтобы «заодно» расширить задачу.

Суть шага: proposal - это место, где инженерные решения принимаются явно и один раз. Дальше они не обсуждаются в чате при каждой задаче, а лежат в файле, который читает и человек, и агент.

Design: артефакты - схема, API, sequence

Из proposal вырос design.md с технической спецификацией. Три артефакта, на которые дальше опирается вся реализация.

Схема данных - две таблицы в identity schema:

balance_transactions (append-only ledger)
───────────────────────────────────────────
id              UUID PK
tenant_id       UUID FK → tenants.id
type            VARCHAR(20)    -- initial_grant | topup | usage | adjustment
amount          INTEGER        -- копейки: положительное = credit, отрицательное = debit
reference_type  VARCHAR(50)    -- turn | manual | system
reference_id    VARCHAR(255)   -- turn_id, admin user_id, etc.
memo            TEXT           -- "Ручное пополнение", "LLM usage: 347 tokens"
created_at      TIMESTAMPTZ

tenant_balances (materialized cache)
───────────────────────────────────────────
tenant_id       UUID PK FK → tenants.id
balance         INTEGER DEFAULT 0    -- копейки
updated_at      TIMESTAMPTZ
CHECK (balance >= 0)                 -- предотвращает овердрафт на уровне БД

API-контракты - два уровня доступа:

Internal (без JWT, K8s network trust):
  POST /internal/balance/check   {tenant_id, estimated_kopecks: 40}
                                 → {allowed: true, balance: 48500}
  POST /internal/balance/deduct  {tenant_id, amount_kopecks: 28, turn_id}
                                 → {balance: 48472}

Backstage (JWT required):
  GET  /backstage/tenants/{id}/balance       → {balance_rub: "484.72", ...}
  POST /backstage/tenants/{id}/topup         {amount_rub: "100.00", memo: "..."}
  GET  /backstage/tenants/{id}/transactions  → paginated list

Sequence diagram - агент создал docs/diagrams/balance-credit-flow.puml с несколькими сценариями. Ключевой - обработка входящего сообщения с check и deduct:

Сценарий: входящее сообщение - balance check + deduct

User → Telegram → Messaging → Orchestration Worker → Identity Service → PostgreSQL

1. User отправляет "Привет" в Telegram
2. Webhook → Messaging → ARQ job → Orchestration Worker
3. Worker → Identity: POST /internal/balance/check {estimated_kopecks: 40}
4. Identity → DB: SELECT balance FROM tenant_balances
5. Identity → Worker: {allowed: true, balance: 48000}
6. Worker: FSM + LLM processing (tokens_used = 347)
7. Worker → Identity: POST /internal/balance/deduct {amount_kopecks: 28, turn_id}
8. Identity → DB: INSERT balance_transaction (-28) + UPDATE tenant_balances
9. Worker → Telegram: ответ пользователю

Сценарий: баланс исчерпан
  ...шаги 1-4...
5. Identity → Worker: {allowed: false, balance: 0}
6. Worker → Telegram: "Баланс исчерпан" (LLM не вызывается)

Design заодно описал error handling (fail-open при недоступности Identity - не блокировать пользователя из-за инфраструктурного сбоя), конфигурацию (4 env-переменные) и future extensibility: YooKassa и per-tool pricing лягут поверх той же схемы без миграций. Это тот раздел, который через задачу окупится.

Tasks: 11 задач и что агент сделал сам

/opsx:apply развернул design в tasks.md - 11 задач, каждая размером в одну агентную сессию, порядок задан зависимостями:

- [x] 1. ORM-модели BalanceTransaction + TenantBalance + Alembic-миграция
         (включая initial_grant для существующих тенантов) + тесты
- [x] 2. BalanceTransactionRepository + TenantBalanceRepository
         + wire в BackstageUnitOfWork и APIUnitOfWork + тесты
- [x] 3. BalanceService: get_balance, get_balance_summary, create_initial_grant,
         topup (₽ string → копейки), record_usage, list_transactions + тесты
- [x] 4. Internal API: POST /internal/balance/check, POST /internal/balance/deduct
         (копейки, без JWT) + тесты
- [x] 5. Backstage API: GET balance (₽), POST topup (₽ input → копейки),
         GET transactions (paginated) + тесты
- [x] 6. Wire initial_grant в tenant creation flow (auth_service +
         backstage_auth_service) + IdentityConfig + тесты
- [x] 7. BalanceChecker protocol + HttpBalanceChecker в Orchestration +
         balance gate в IncomingMessageProcessor (pre-check + post-deduct,
         token → копейки conversion) + config + тесты
- [x] 8. Backstage UI: баланс в ₽ на tenant detail, topup modal,
         transaction history tab
- [x] 9. PlantUML sequence diagram: docs/diagrams/balance-credit-flow.puml
- [x] 10. Update ERD: docs/diagrams/erd-data-model.puml
- [x] 11. Full quality gates: ruff check + ruff format + pytest --cov-fail-under=100

Задачи 1-3 - фундамент (модели, репозитории, сервис), 4-6 - API-слой, 7 - интеграция между сервисами, 8 - UI, 9-11 - документация и quality gates. Агент выполнял их последовательно, удерживая в контексте только текущую задачу плюс design.md, а не всю фичу целиком.

Самый показательный момент - задача 7. Агент обнаружил, что IncomingMessageProcessor не имел injection point для внешних зависимостей. Вместо эскалации он добавил BalanceChecker protocol и HttpBalanceChecker implementation, следуя dependency-injection паттерну, который уже применялся в проекте для других internal-клиентов. Решение он нашёл сам - не потому что «умный», а потому что паттерн лежал в кодовой базе и на него можно было опереться.

Граница человек / агент: человек решил, что баланс в копейках, ledger immutable, а constraint - на уровне БД. Агент реализовал эти решения и в рамках них нашёл локальное - как встроить balance gate в существующий процессор. Первое - решения, второе - реализация.

Archive: контекст живёт в артефактах, не в чате

После завершения - /opsx:archive:

openspec/changes/archive/2026-04-01-tenant-balance-credits/
├── proposal.md    # Зачем, scope, out of scope, key decisions, future phases
├── design.md      # Схема данных, API контракты, sequence diagram, config
└── tasks.md       # 11 задач, все [x]

Через месяц вопрос «почему баланс в копейках, а не в рублях» решается за минуту: ответ в proposal.md, ключевое решение #1 - rubles, not credits; no abstract unit conversion. Это и есть выгода archive: решение не приходится реконструировать по коду или вспоминать по переписке.

Следующая задача в pipeline - YooKassa payments - уже лежит в openspec/changes/yookassa-payments/. Её proposal ссылается на Phase 2 из balance proposal, а design использует balance_transactions с reference_type='yookassa' - ровно то расширение, что было заложено в разделе «Future Extensibility» предыдущей задачи. Контекст передаётся между фичами через артефакты, а не через Jira-описания и чат-историю.

Что показывает кейс

Где это не работает

Кейс - success story, и честно назвать границы важнее, чем красиво его подать. Где я бы этот процесс не применял или применял осторожно:

Короткая версия: этот процесс окупается на нетривиальных задачах в зрелой размеченной кодовой базе, где решение можно зафиксировать заранее и где артефакты кто-то поддерживает. Вне этих условий он либо тормозит, либо создаёт иллюзию контроля.