В индустрии программной инженерии наступил фундаментальный кризис неструктурированного промптинга (Free-Text Prompting или Vibe Coding). При взаимодействии разработчика с LLM-агентом через свободный диалог на естественном языке кодовая база крупных enterprise-репозиториев деградирует уже на 4–6 итерации. Деградация обусловлена тремя объективными архитектурными факторами:
Spec-Driven Development (SDD) — это методология детерминированной разработки программного обеспечения, при которой машиночитаемая спецификация выступает компилятором намерений инженера для автономных агентов.
В парадигме SDD исходным кодом первого порядка (Source of Truth) становятся архитектурные спецификации (docs/specs/*.spec.md), архитектурные решения (docs/adr/*.md) и формальные схемы данных (Zod, JSON Schema, TypeSpec). Исполняемый код на TypeScript, Go или PHP рассматривается исключительно как производный артефакт, синтезируемый ИИ-агентом строго в рамках верифицируемых контрактов, инвариантов и конечных автоматов.
В классической разработке контракт API часто формулируется апостериори: сначала пишется код контроллера, а затем с помощью аннотаций или Swagger-плагинов генерируется документация. В агентной разработке такой подход фатален: агент изменяет реализацию, генератор меняет документацию, и архитектурный контракт тихо деградирует (Silent Drift).
SDD постулирует обратный порядок зависимостей:
Domain Invariants → (Formalize) → Living Spec → (Compile) → Runtime Schemas → (Enforce) → TDD Harness → (Synthesize) → ImplementationСпецификация обязана использовать строгие ключевые слова RFC 2119:
MUST / REQUIRED / SHALL: Абсолютное требование архитектуры. Нарушение приводит к блокировке коммита линтером или тест-раннером.MUST NOT / SHALL NOT: Строго запрещенное состояние (например, утечка хэша пароля в DTO или прямой SQL-запрос из контроллера).SHOULD / RECOMMENDED: Допустимо отклонение при наличии задокументированного ADR.MAY / OPTIONAL: Точка расширения для агента.В агентном цикле SDD спецификация выступает не пассивным документом, а активным фильтром и средой компиляции. Она преобразует недетерминированный поток токенов LLM в детерминированную машину состояний.
Single Source of Truth Contract Drift Domain Invariant Bounded Context+-------------------------------------------------------+
| ИНЖЕНЕР: Архитектурные инварианты |
| (RFC 2119, Given-When-Then, State Diagrams) |
+---------------------------+---------------------------+
|
v
+-------------------------------------------------------+
| LIVING SPECIFICATION (docs/specs/*.spec.md) |
| - Входные/выходные схемы данных |
| - Таблицы переходов состояний (FSM) |
| - Архитектурные запреты (Blacklist) |
+---------------------------+---------------------------+
|
+----------------------+----------------------+
| |
v v
+------------------------------+ +------------------------------+
| СИНТЕЗ ТИПОВ И СХЕМ | | СИНТЕЗ TDD HARNESS (RED) |
| (Zod, TypeSpec, JSON Schema) | | (Vitest / PHPUnit тесты) |
+--------------+---------------+ +--------------+---------------+
| |
+----------------------+----------------------+
|
v
+-------------------------------------------------------+
| LLM-АГЕНТ В ИЗОЛИРОВАННОЙ ПЕСОЧНИЦЕ (GREEN PHASE) |
| - Реализация бизнес-логики без сайд-эффектов |
| - Доступ только к белому списку библиотек |
+---------------------------+---------------------------+
|
v
+-------------------------------------------------------+
| АВТОМАТИЧЕСКАЯ ВЕРИФИКАЦИЯ (MUTATION & DRIFT AUDIT) |
| - Stryker Mutation Score >= 80% |
| - AST Drift Linter: 0 неспецифицированных экспортов |
+-------------------------------------------------------+Инженер фиксирует доменные границы, инварианты RFC 2119, сценарии Given-When-Then и схему FSM в Markdown-спеке.
Парсер спецификации генерирует неизменяемые Zod-схемы, TypeScript-типы и интерфейсы портов архитектуры.
Агент синтезирует набор юнит- и компонентных тестов, строго транслирующих Given-When-Then сценарии. Тесты запускаются и гарантированно падают.
Агент генерирует исходный код, добиваясь прохождения тестов без добавления недокументированных полей или абстракций.
CI проверяет мутационный индекс тестов через Stryker, контролирует AST-соответствие и генерирует отчет о синхронизации.
Почему при разработке с автономными LLM-агентами подход Code-First приводит к архитектурной деградации системы?
В банковской платформе обработка платежей требует абсолютной идемпотентности, строгого аудиторского следа и исключения отрицательных остатков. При классическом промптинге агент забывал проверить распределенные блокировки (SELECT FOR UPDATE или Redis Redlock) и создавал уязвимость состояния гонки (Race Condition).
В архитектуре SDD спецификация docs/specs/ledger-clearing.spec.md зафиксировала инвариант: [INV-LEDGER-01] Любая операция списания MUST использовать распределенный мьютекс по idempotency_key с TTL 30 секунд. Сгенерированные на основе спеки тесты имитировали 50 конкурентных потоков, заставив агента реализовать надежную транзакционную машину с пессимистическими блокировками.
Ценообразование в enterprise-ритейле зависит от геолокации покупателя, промокодов, B2B-контрактов и статуса НДС. Спецификация формализовала правила через таблицу решений (Decision Table). Агент получил контракт, в котором четко регламентирован приоритет скидок.
Попытка агента применить промокод поверх дилерской цены была пресечена на этапе компиляции Zod-схемы, содержащей метод .refine(), что предотвратило убытки в сотни тысяч долларов до попадания кода в релизную ветку.
Медицинские микросервисы обязаны гарантировать маскирование персональных данных (PII) и соблюдение протокола HIPAA/GDPR. В спецификации docs/specs/patient-records.spec.md инвариант [INV-SEC-04] Запрещено логирование полей 'ssn', 'birthDate', 'diagnosis' в открытом виде был транслирован в кастомное правило AST-валидатора.
Когда агент попытался добавить вызов console.log(patientPayload) для отладки, статический анализ заблокировал фазу сборки, указав агенту на нарушение контракта.
В мультитенантном сервисе аналитики критически важно исключить утечку данных между организациями (Cross-Tenant Data Leak). Спецификация постулировала: Каждый SQL-запрос MUST содержать предикат WHERE organization_id = :currentOrgId. Агент синтезировал Prisma/Kysely middleware, гарантирующий внедрение тенантного контекста на уровне базы данных, а не в прикладных сервисах.
Конвейер SDD связывает четыре звена в неразрывную цепочку: Спецификация → Схемы контрактов → Модульные тесты → Имплементация.
Спецификация оформляется в формате Markdown со строго стандартизированными секциями. Она содержит метаданные, ссылки на связанные ADR, доменные инварианты RFC 2119 и сценарии Given-When-Then.
# SPEC-089: Рекуррентный биллинг корпоративных подписок
## 1. Metadata & Architectural Context
- Bounded Context: Billing & Subscriptions
- Architectural Tier: Application Core
- Related ADRs: docs/adr/0014-stripe-webhooks-idempotency.md
- Compliance: PCI-DSS v4.0 Requirement 3.4
## 2. Domain Invariants
- [INV-BILL-01] Сумма счета MUST быть строго положительным целым числом в минимальных единицах валюты (центы/копейки).
- [INV-BILL-02] Переход подписки в статус `ACTIVE` возможен ТОЛЬКО при наличии подтвержденного события успешной авторизации платежа от платежного шлюза.
- [INV-BILL-03] При повторном списании (Retry Policy) интервал между попытками MUST рассчитываться по алгоритму Exponential Backoff с фактором дрожания (Full Jitter).
- [INV-BILL-04] DTO ответа MUST NOT содержать `cvv`, `pan` или сырые токены платежного провайдера.
## 3. Finite State Machine (FSM)
States: CREATED, PAYMENT_PENDING, ACTIVE, PAST_DUE, TERMINATED
Transitions:
- CREATED -> PAYMENT_PENDING (Trigger: InvoiceIssued)
- PAYMENT_PENDING -> ACTIVE (Trigger: PaymentConfirmed)
- PAYMENT_PENDING -> PAST_DUE (Trigger: PaymentFailed, attempts < 3)
- PAST_DUE -> PAYMENT_PENDING (Trigger: RetryScheduled)
- PAST_DUE -> TERMINATED (Trigger: MaxRetriesExceeded)
## 4. Behavioral Scenarios (Given-When-Then)
Scenario: Successful Subscription Renewal
Given активная подписка со статусом "PAYMENT_PENDING" и суммой 4900 центов
When поступает подтвержденный вебхук оплаты с валидным idempotency_key
Then баланс клиента дебетуется на 4900 центов
And статус подписки обновляется на "ACTIVE"
And период действия подписки продлевается ровно на 30 календарных дней
And генерируется доменное событие "SubscriptionRenewedEvent"
Scenario: Fraud Detection Rejection
Given подписка со статусом "PAYMENT_PENDING"
When платежный шлюз возвращает флаг риска "RISK_LEVEL_HIGH"
Then статус подписки немедленно переводится в "TERMINATED"
And генерируется событие безопасности "SecurityAuditAlertEvent"
And повторные списания блокируются навсегдаСпецификация компилируется в строгие схемы данных. Валидация выполняется не только по типам примитивов, но и по бизнес-правилам с помощью встроенных предикатов .refine().
import { z } from 'zod';
export const CurrencySchema = z.enum(['USD', 'EUR', 'GBP']);
export type Currency = z.infer<typeof CurrencySchema>;
export const SubscriptionStatusSchema = z.enum([
'CREATED',
'PAYMENT_PENDING',
'ACTIVE',
'PAST_DUE',
'TERMINATED',
]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatusSchema>;
// [INV-BILL-01]: Строго положительное целое число в минимальных неделимых единицах
export const MoneySchema = z.object({
amountInCents: z
.number()
.int('Сумма обязана быть целочисленной')
.positive('Сумма счета MUST быть строго положительной'),
currency: CurrencySchema,
});
export type Money = z.infer<typeof MoneySchema>;
export const ProcessPaymentInputSchema = z.object({
subscriptionId: z.string().uuid('Невалидный UUID подписки'),
idempotencyKey: z
.string()
.min(16, 'Ключ идемпотентности должен быть не менее 16 символов')
.max(64),
chargeAmount: MoneySchema,
paymentMethodToken: z.string().startsWith('pm_tok_', 'Некорректный префикс токена шлюза'),
});
export type ProcessPaymentInput = z.infer<typeof ProcessPaymentInputSchema>;
// [INV-BILL-04]: Публичный DTO ответа без утечки закрытых данных шлюза
export const PaymentExecutionResultSchema = z.object({
transactionId: z.string().uuid(),
subscriptionId: z.string().uuid(),
status: SubscriptionStatusSchema,
processedAt: z.date(),
nextBillingDate: z.date().nullable(),
});
export type PaymentExecutionResult = z.infer<typeof PaymentExecutionResultSchema>;
export interface IPaymentGatewayPort {
charge(token: string, amount: Money, idempotencyKey: string): Promise<{ success: boolean; externalId: string; riskScore: number }>;
}
export interface ISubscriptionRepositoryPort {
findById(id: string): Promise<{ id: string; status: SubscriptionStatus; periodEnd: Date } | null>;
updateStatus(id: string, status: SubscriptionStatus, nextPeriodEnd?: Date): Promise<void>;
recordTransaction(record: { id: string; subscriptionId: string; amount: Money }): Promise<void>;
}Агент транслирует сценарии Given-When-Then в исполняемый тестовый набор Vitest. На этапе RED код тестируемого сервиса либо отсутствует, либо содержит методы-заглушки, выбрасывающие NotImplementedError.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
ProcessPaymentInputSchema,
IPaymentGatewayPort,
ISubscriptionRepositoryPort
} from '../../src/contracts/billing.contract';
import { SubscriptionBillingService } from '../../src/services/SubscriptionBillingService';
describe('SPEC-089: Subscription Billing Lifecycle', () => {
let mockGateway: IPaymentGatewayPort;
let mockRepo: ISubscriptionRepositoryPort;
let service: SubscriptionBillingService;
beforeEach(() => {
mockGateway = { charge: vi.fn() };
mockRepo = {
findById: vi.fn(),
updateStatus: vi.fn(),
recordTransaction: vi.fn(),
};
service = new SubscriptionBillingService(mockGateway, mockRepo);
});
it('[INV-BILL-01] отклоняет платежные пейлоады с неположительными суммами', () => {
const invalidPayload = {
subscriptionId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
idempotencyKey: 'idemp_live_test_001_enterprise',
chargeAmount: { amountInCents: -500, currency: 'USD' as const },
paymentMethodToken: 'pm_tok_visa_4242',
};
const parseResult = ProcessPaymentInputSchema.safeParse(invalidPayload);
expect(parseResult.success).toBe(false);
if (!parseResult.success) {
expect(parseResult.error.issues[0]?.message).toContain('строго положительной');
}
});
it('успешно пролонгирует подписку при подтверждении транзакции', async () => {
const currentPeriodEnd = new Date('2026-11-01T00:00:00Z');
vi.mocked(mockRepo.findById).mockResolvedValueOnce({
id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
status: 'PAYMENT_PENDING',
periodEnd: currentPeriodEnd,
});
vi.mocked(mockGateway.charge).mockResolvedValueOnce({
success: true,
externalId: 'ch_ext_998124',
riskScore: 12,
});
const result = await service.executeRenewal({
subscriptionId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
idempotencyKey: 'idemp_live_test_001_enterprise',
chargeAmount: { amountInCents: 4900, currency: 'USD' },
paymentMethodToken: 'pm_tok_visa_4242',
});
expect(result.status).toBe('ACTIVE');
expect(mockRepo.updateStatus).toHaveBeenCalledWith(
'7c9e6679-7425-40de-944b-e07fc1f90ae7',
'ACTIVE',
expect.any(Date)
);
});
it('[INV-BILL-02] немедленно терминалует подписку при высоком риске фрода', async () => {
vi.mocked(mockRepo.findById).mockResolvedValueOnce({
id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
status: 'PAYMENT_PENDING',
periodEnd: new Date(),
});
vi.mocked(mockGateway.charge).mockResolvedValueOnce({
success: false,
externalId: 'ch_ext_fraud_flagged',
riskScore: 95, // Превышает порог фрода (High Risk)
});
await expect(
service.executeRenewal({
subscriptionId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
idempotencyKey: 'idemp_live_fraud_alert_99',
chargeAmount: { amountInCents: 4900, currency: 'USD' },
paymentMethodToken: 'pm_tok_visa_fraud',
})
).rejects.toThrow('HighRiskFraudDetectedError');
expect(mockRepo.updateStatus).toHaveBeenCalledWith(
'7c9e6679-7425-40de-944b-e07fc1f90ae7',
'TERMINATED'
);
});
});Имплементация пишется агентом строго под существующие контракты и падающие тесты. Никакие дополнительные методы или свойства, не описанные в спецификации, не допускаются.
import {
ProcessPaymentInput,
ProcessPaymentInputSchema,
PaymentExecutionResult,
IPaymentGatewayPort,
ISubscriptionRepositoryPort,
} from '../contracts/billing.contract';
export class HighRiskFraudDetectedError extends Error {
constructor(subscriptionId: string, public readonly score: number) {
super(`Фрод-мониторинг заблокировал транзакцию подписки ${subscriptionId}. Оценка риска: ${score}`);
this.name = 'HighRiskFraudDetectedError';
}
}
export class InvalidSubscriptionStateError extends Error {
constructor(subscriptionId: string, state: string) {
super(`Подписка ${subscriptionId} находится в недопустимом статусе: ${state}`);
this.name = 'InvalidSubscriptionStateError';
}
}
export class SubscriptionBillingService {
private static readonly FRAUD_RISK_THRESHOLD = 75;
private static readonly RENEWAL_PERIOD_DAYS = 30;
constructor(
private readonly gateway: IPaymentGatewayPort,
private readonly repository: ISubscriptionRepositoryPort
) {}
public async executeRenewal(rawInput: ProcessPaymentInput): Promise<PaymentExecutionResult> {
// 1. Валидация входных данных на границе домена через Zod
const input = ProcessPaymentInputSchema.parse(rawInput);
// 2. Верификация текущего состояния подписки в хранилище
const subscription = await this.repository.findById(input.subscriptionId);
if (!subscription) {
throw new Error(`Подписка ${input.subscriptionId} не найдена в системе.`);
}
if (subscription.status !== 'PAYMENT_PENDING' && subscription.status !== 'PAST_DUE') {
throw new InvalidSubscriptionStateError(input.subscriptionId, subscription.status);
}
// 3. Вызов шлюза с обеспечением идемпотентности
const chargeResult = await this.gateway.charge(
input.paymentMethodToken,
input.chargeAmount,
input.idempotencyKey
);
// 4. Анализ фрод-инварианта [INV-BILL-02]
if (chargeResult.riskScore >= SubscriptionBillingService.FRAUD_RISK_THRESHOLD) {
await this.repository.updateStatus(input.subscriptionId, 'TERMINATED');
throw new HighRiskFraudDetectedError(input.subscriptionId, chargeResult.riskScore);
}
if (!chargeResult.success) {
await this.repository.updateStatus(input.subscriptionId, 'PAST_DUE');
throw new Error(`Платежный шлюз отклонил авторизацию для подписки ${input.subscriptionId}`);
}
// 5. Вычисление новой даты окончания расчетного периода
const nextPeriodEnd = new Date(subscription.periodEnd.getTime() + SubscriptionBillingService.RENEWAL_PERIOD_DAYS * 86400000);
// 6. Транзакционная фиксация в репозитории
await this.repository.updateStatus(input.subscriptionId, 'ACTIVE', nextPeriodEnd);
await this.repository.recordTransaction({
id: crypto.randomUUID(),
subscriptionId: input.subscriptionId,
amount: input.chargeAmount,
});
return {
transactionId: crypto.randomUUID(),
subscriptionId: input.subscriptionId,
status: 'ACTIVE',
processedAt: new Date(),
nextBillingDate: nextPeriodEnd,
};
}
}Скрипт проверяет наличие всех обязательных секций (Invariants, FSM, Given-When-Then) и ключевых слов RFC 2119.
Генерация типов TypeScript и схем рантайм-валидации входных и выходных параметров без использования типа any.
Агент синтезирует падающие тесты, проверяет факт их падения и только затем пишет чистый код имплементации.
Запуск мутационного тестирования. Допустимый порог убитых мутантов для мерджа в основную ветку — не менее 80%.
Каким образом архитектурный пайплайн SDD гарантирует, что агент не внедрил скрытую недокументированную функциональность на этапе GREEN?
ИИ-агенты стремятся заполнять пробелы в спецификациях правдоподобными, но наивными предположениями. Если в инвариантах явно не зафиксирован лимит длины строки, допустимые диапазоны чисел или политика коллизии ключей, агент выберет простейшую реализацию без учета переполнения памяти или коллизий в базе данных. Требуйте формализации краевых условий в терминах RFC 2119.
Агенты теряют фокус рассуждения, когда в одной спецификации перемешаны SQL-индексы, CSS-селекторы и правила расчета скидок. Архитектурное разделение обязательно: инфраструктурные детали фиксируются в Architecture Decision Records (ADR), а спецификация определяет исключительно доменные контракты, инварианты и бизнес-поведение.
Если спецификация не связана с автоматическим пайплайном проверки, разработчики начинают править реализацию вручную в обход документации. Через 2–3 спринта спеки устаревают, и агент перестает понимать актуальную кодовую базу. В CI/CD необходима автоматическая проверка валидности контрактов: если интерфейс в коде отклоняется от спеки, пайплайн обязан падать.
Попытка передать агенту спецификацию на 100 страниц целиком приводит к деградации внимания модели и эффекту Lost in the Middle. Спецификации необходимо декомпозировать на атомарные срезы (Bounded Contexts) объемом 150–300 строк, каждый из которых покрывает ровно один агрегат или пользовательскую историю.