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. Важное здесь - не то, что его написал агент, а то, что ключевые решения принял человек и они зафиксированы текстом до первой строчки кода:
- Рубли, не абстрактные кредиты - тенант видит «у вас 500₽ на балансе», не «500 credits». Никакой конвертации в выдуманную единицу.
- Immutable transaction ledger - баланс = SUM(credits) - SUM(debits), а не мутабельный счётчик.
- Materialized balance с CHECK constraint -
balance >= 0на уровне БД: БД отклоняет сохранение отрицательного balance. Корректность списания при конкуренции также зависит от транзакции и способа обновления баланса. - Pre-check + post-deduct - проверка до вызова LLM (не жечь токены впустую), списание после (по реальному расходу).
- Identity owns the ledger - баланс принадлежит домену тенантов, orchestration ходит к нему через internal API.
Proposal также зафиксировал scope (11 пунктов) и out of scope - YooKassa, per-tool pricing, аналитика уходят в будущие фазы. Именно раздел «out of scope» удерживает агента от того, чтобы «заодно» расширить задачу.
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-клиентов. Решение он нашёл сам - не потому что «умный», а потому что паттерн лежал в кодовой базе и на него можно было опереться.
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-описания и чат-историю.
Что показывает кейс
- Proposal задаёт границы. Без раздела «out of scope» агент мог бы сразу приделать YooKassa и аналитику. Явные boundaries удержали его в рамках задачи.
- Design фиксирует решения до кода. «Копейки вместо float», «CHECK на уровне БД», «Identity owns the ledger» - приняты человеком, реализованы агентом.
- Tasks - атомарные единицы. Каждая выполнима за одну сессию; агент держит в голове задачу плюс design, а не всю фичу.
- Archive - это память. Решение через месяц читается в proposal, а не восстанавливается по коду.
- Фичи связаны через phases. YooKassa вырастает из future-раздела предыдущей задачи; цепочка формируется из артефактов.
- Quality gates обязательны. Каждая задача закрывается
ruff check + ruff format + pytest --cov --cov-fail-under=100. Без зелёного CI задача не закрыта.
Где это не работает
Кейс - success story, и честно назвать границы важнее, чем красиво его подать. Где я бы этот процесс не применял или применял осторожно:
- Тривиальные изменения. Правило #9 не зря говорит про non-trivial. На однострочный фикс или правку копирайта церемония proposal → design → tasks - чистые накладные расходы. Порог «когда включать OpenSpec» - вопрос вкуса, и слишком низкий порог убивает скорость.
- Незрелая или неоднородная кодовая база. Агент нашёл injection point потому, что DI-паттерн уже был в проекте. В greenfield или в коде без устоявшихся паттернов опереться не на что: агент либо чаще эскалирует, либо изобретает несогласованную структуру. Эффект прямо пропорционален тому, насколько хорошо размечена база.
- Качество ограничено человеком в proposal. Агент одинаково добросовестно реализует и хорошее решение, и ошибочное. Неверный вызов в design (не тот constraint, не та граница домена) чисто растекается по 11 задачам - и ловить его придётся уже в коде.
- Плохо очерченные задачи. Баланс - хорошо ограниченный домен, для него front-loading дизайна окупается. Для исследовательской работы, где спеку нельзя зафиксировать заранее, обязательный design-до-кода воюет с самой природой задачи.
- Артефакты живут, только пока их поддерживают. Archive окупается, если в него действительно возвращаются. Если команда перестаёт обновлять proposal и specs, они устаревают - и устаревшая спецификация хуже отсутствующей: и человек, и агент действуют уверенно по неверным правилам.
- PostgreSQL: Constraints
Короткая версия: этот процесс окупается на нетривиальных задачах в зрелой размеченной кодовой базе, где решение можно зафиксировать заранее и где артефакты кто-то поддерживает. Вне этих условий он либо тормозит, либо создаёт иллюзию контроля.