
Третья, заключительная статья из цикла.
Часть 1 — где LLM теряет межсервисный контекст и почему локальных спек недостаточно.
Часть 2 — archspec как контракт вместо свободного Markdown.
Часть 3 — archspec: исследование фичи, обновление контрактов и реализация.
1. Напоминание: где ломался spec-driven и зачем контракт
В части 1 я показал, что spec-driven development с LLM начинает ошибаться, когда фича проходит через несколько микросервисов: по отдельности каждый сервис выглядит аккуратно, а вместе система работает не так, как нужно. Причина в том, что модель теряет межсервисный контекст — правила, которые живут на границах между сервисами, не записаны в одном месте, поэтому LLM их пропускает. В части 2 я собрал archspec: на каждый сервис генерируется машиночитаемый контракт SERVICE_MAP.yaml, который делает эти правила явными. В этой части я возвращаюсь к той же фиче и прогоняю её через /archspec:investigate поверх контрактов — поймает ли план межсервисные ошибки ещё до кода.
Напомню стенд. Это Go-проект из 12 микросервисов для поиска фрилансеров: gRPC для синхронных вызовов, брокер NATS для асинхронных событий, единая Clean Architecture в каждом сервисе. У task-service и matching-service есть Transactional Outbox, чтобы изменение состояния и событие записывались вместе. Схема та же, что я использую на протяжении всего цикла:

Фича для эксперимента — Smart Task Reassignment. Если выбранный фрилансер отказался от оффера, платформа сама находит следующего кандидата, отправляет ему новый оффер и уведомляет заказчика, а не отправляет задачу на ручную обработку. Правила переназначения такие:
отказ фрилансера запускает новый подбор;
кандидаты ранжируются по рейтингу, а при равном рейтинге — по расстоянию до города задачи;
заказчик получает уведомление о переназначении;
после трёх неудачных переназначений задача переходит в
failed.
В первый раз (task_1) я прогонял эту фичу без archspec: Claude читал локальные CLAUDE.md по сервисам, планировал на Sonnet 4.6, выдал план примерно на 180 строк и реализовал его. Затем я прогнал два независимых ревью — Claude в отдельной сессии и Codex со сверкой по эталонному решению и чек-листу. Оба нашли одну и ту же группу межсервисных ошибок, и полный сценарий отказа и переназначения не сошёлся. Итог — 6/10, чеклист ~64%.
Классы ошибок task_1 коротко: прямой вызов закрытых сервисов в обход worker-facade; придуманные методы, которых нет в proto; город передавался как имя вместо city_id; цикл вызовов review → worker-facade → review; N+1 вместо batch-методов; публикация события мимо Outbox. Был и критический баг: один match_id на все переназначения, из-за которого notification-service отбрасывал новые офферы как дубликаты, и фича не проходила сквозной сценарий. Все эти правила объединяет одно: они живут между сервисами и нигде не были записаны как единое ограничение, поэтому LLM их не видел.
В части 2 эти правила стали явными в контракте — каждое попало в SERVICE_MAP.yaml соответствующего сервиса. Теперь проверим, изменит ли это результат, если дать LLM не локальные Markdown-файлы, а контракты и /archspec:investigate.
2. Планирование заново: investigate поверх контрактов
Во втором прогоне (task_3) я меняю ровно две вещи относительно task_1. Промпт фичи тот же — то же описание Smart Task Reassignment, что в первой части. Модель та же — Claude Sonnet 4.6 на medium reasoning. Разница в окружении: теперь у каждого из 12 сервисов есть контракт SERVICE_MAP.yaml из второй части, и вместо свободного брейншторма я запускаю /archspec:investigate. Это read-only этап: он не трогает код и не трогает контракты. Единственный файл, который он пишет, — план.
2.1. Clarify-gate: investigate сначала спрашивает
investigate начинает с того, что читает срез контрактов затронутых сервисов. Дальше он не переходит сразу к планированию. Сначала он проходит по измерениям неоднозначности и задаёт уточняющие вопросы: откуда приходит триггер и через какой публичный вход, кто владелец состояния, откуда берётся идентификатор исполнителя, что именно считает лимит, по какому ключу связываются сущности, что происходит в терминальных ветках. Уточнение тоже остаётся read-only действием и кода не касается.

Что он спросил на этом проходе:
нужен ли reference/golden-спек для сверки именований — я ответил
skip;откуда приходит триггер отказа — решили, что это новый HTTP-endpoint в
api-gateway;что значит «максимум 3 переназначения» — 3 после первого оффера;
как резолвить гео-тай-брейк — по
city_idворкера иcity_idзадачи, с fail loudly, еслиcity_idнет.
Это ровно те места, где в task_1 модель приняла решение без вопроса и ошиблась: worker_id она взяла из тела запроса, город передала как имя вместо id, а на лимите получила ошибку на единицу. Здесь каждое из этих мест вынесено в явный вопрос до начала планирования.
2.2. Результат — план-артефакт
investigate сохраняет план в отдельный файл, а не оставляет его в чате. Это рабочий артефакт, который потом читает implement.
Само устройство плана я разбираю в секции 4, а здесь просто перечислю, что внутри:
на какие правила опирается фича — план цитирует конкретные строки из контрактов затронутых сервисов, так что каждое его утверждение можно перепроверить по контракту;
открытые вопросы — места, где требования допускают разные трактовки. Например, откуда брать
worker_idотказавшегося фрилансера. План собирает их в отдельный список и решает до кода;какие правки нужны в API до кода — например, добавить метод
DeclineOfferи полеcity_idв proto. Интерфейсы сервисов меняются раньше реализации;диаграмма нового сценария — как переназначение пройдёт по сервисам (показана ниже);
diff правок в контрактах — что именно поменяется в
SERVICE_MAP.yamlкаждого затронутого сервиса;проверка по всем 12 сервисам — для каждого нового события план находит, кто его публикует и кто слушает, чтобы не пропустить ни одного получателя;
кто чем владеет — для каждого куска данных указано, какой сервис за него отвечает и кто имеет право его менять;
список крайних случаев — каждый риск записан отдельным пунктом и сразу привязан к тесту, который его проверит;
отметки о самопроверке и независимом ревью плана.
Вот отрендеренная диаграмма последовательности из плана:

схема по частям
Это весь путь переназначения, который план нарисовал сам — от действия клиента до финала. Удобнее читать его по трём веткам.
Обычный отказ. Фрилансер нажимает «отказаться» — запрос приходит в api-gateway, тот достаёт worker_id из токена и зовёт task-service. task-service сам, через свой outbox, публикует событие offer.declined. Его слушает matching-service: он берёт следующего кандидата из уже посчитанного списка и отправляет ему новый оффер событием match.found. Параллельно заказчику уходит уведомление, что задачу переназначили.
Лимит исчерпан. Если отказ уже третий, task-service не запускает новый подбор, а публикует task.failed, и заказчик получает уведомление, что подобрать никого не удалось.
Кандидаты кончились раньше лимита. Если в списке не осталось кандидатов, matching-service шлёт match.exhausted, task-service переводит задачу в failed и тоже уведомляет заказчика.
На что смотреть на этой схеме. Во-первых, синхронные вызовы (gRPC) и асинхронные события (через NATS) нарисованы по-разному — сразу видно, где сервис дёргает другой напрямую, а где общается через брокер. Во-вторых, у каждой ветки есть конец: ни один путь не обрывается на полпути, у любого исхода задача меняет статус и заказчик получает уведомление. Это как раз те места, на которых спотыкался task_1.
2.3. Что план поймал заранее
На этапе плана закрыто то, на чём task_1 не проходил сквозной сценарий. Главным провалом task_1 была коллизия match_id: все переназначения получали один и тот же match_id, и notification-service отбрасывал их как дубликаты. Здесь:
новый
match_idна каждую попытку, плюс убранTaskID-fallback дедупа вnotification-service;city_idсделан обязательным и протянут в payloadtask.created;offer.declinedиtask.failedпубликуются изoutboxtask-service— топология полностью event-driven, без синхронногоmatching-service → task-service;добавлено уведомление заказчика на переназначении и на
task.failed;снят off-by-one лимита:
reassignment_count < 3проверяется до инкремента, что даёт 4 оффера и 3 переназначения.
То, в чём план был не уверен, он не спрятал, а вынес в открытые вопросы. Среди них — доверенный источник worker_id: план не стал додумывать его в коде, а вынес отдельным вопросом — OQ-1.
2.4. improve plan
С первого захода investigate не выдал законченный план. Часть решений он не стал принимать за меня и собрал в список открытых вопросов — это видно прямо в файле плана.

Я разобрал эти вопросы и попросил доработать план — командой improve plan. investigate прошёл по каждому и внёс решение прямо в план. Вот вопросы и что с ними стало:
Вопрос | В чём неоднозначность | Как решили в плане |
|---|---|---|
OQ-1: откуда брать | если читать из тела запроса, любой клиент сможет отказаться за чужого фрилансера | брать из токена; полноценную авторизацию в рамках этой фичи не делаем, решение записано как осознанный риск в |
OQ-3: | без | сделать обязательным: |
OQ-4: что делать, если кандидатов нет уже на первой попытке | задача может навсегда зависнуть в незавершённом статусе | тот же путь, что и при исчерпании списка: |
Всего план прошёл 4 прохода self-review и был принят независимым plan-review за 2 раунда.
2.5. Сравнение с task_1
Отдельное ревью оценило план в 9/10 против 6/10 у task_1. По чеклисту — рост с ~64% до ~98%. Критический баг с match_id закрыт ещё на этапе плана. И главное: в task_1 план не менялся вообще, а здесь он реально дорабатывался — сам себя проверил и прошёл независимое ревью.
Но один пробел план всё же пропустил. Поле city_id он протянул через все сервисы правильно. А вот то, что значения этого id у сервисов записаны по-разному (task-service хранит city-msk, а geo и worker-profile — moscow), план не заметил. Позже именно это сломало гео-расчёт в коде (подробнее — в секции 3).
Это не значит, что план плохой. Скорее это повод доработать инструмент: ему не хватает проверки, что код и правда соответствует плану. Зелёная сборка такой пробел не ловит — она проходит, даже когда id в сервисах не совпадают.
3. Реализация: implement по плану
План одобрен, теперь его нужно превратить в код. Я запускаю вторую команду на той же модели — Sonnet 4.6, medium reasoning:

Архплан обязателен: без него implement не запускается и отправляет обратно в investigate. Сначала план — потом код.
Как implement превращает план в код
Механику я разбираю в секции 4, здесь — только контур, чтобы было понятно, откуда берётся итог. implement сначала применяет YAML-патч к SERVICE_MAP.yaml затронутых сервисов и синхронизирует документацию — контракты меняются до кода. Затем строится план реализации, в котором каждое требование привязано к конкретной задаче и тесту. Дальше идёт реализация по TDD через записи edge_cases, затем несколько проверок, что код соответствует плану, и прогон /archspec:validate и /archspec:check-architecture. В конце готовые изменения проверяет отдельный агент, который их не писал, — и только после этого коммит.
Главное в этом шаге: implement не просто пишет код и останавливается. У него есть собственное ревью, которое гоняет реализацию по кругу, пока не закончатся замечания. На этой задаче оно за два круга само нашло и починило несколько реальных ошибок — лимит переназначений срабатывал не на той попытке, заказчику не уходило уведомление, не отрабатывал один из крайних случаев. Одну проблему implement чинить не стал, но и не спрятал: worker_id по-прежнему приходит от клиента, а не из токена. Её записали отдельным решением (ADR-001) как осознанный риск прототипа. В итоге все 15 крайних случаев закрыты тестами, сборка и тесты зелёные.
Зелёная сборка — необходимое, но не достаточное условие. Поэтому я прогнал по диффу два независимых ревью: одно Claude, второе — Codex. Они смотрели один и тот же код против эталона.
Что получилось
Оба ревью сходятся в том, что архитектурно решение task_3 заметно сильнее task_1. Важный контраст с первым прогоном:
Атомарный outbox для
offer.declinedиtask.failed. Вtask_1событие об отказе публиковал напрямуюapi-gateway— это и было главное архитектурное нарушение. Здесьapi-gatewayлишь проксирует gRPC, аtask-serviceатомарно пишет состояние и событие черезDeclineAndPublish/UpdateWithEvent. Это главное исправление относительноtask_1.Уникальный
match_idна каждую попытку. Вtask_1все переназначения получали одинmatch_id, иnotification-serviceотбрасывал их как дубликаты — фича ломалась на сквозном сценарии. Вtask_3CreateAttemptPendingIfAbsentвыдаёт новыйmatch-Nна каждую пару(task_id, attempt), дедуп вnotification-serviceидёт поmatch_idбез fallback наTaskID. Критический багtask_1устранён, сценарий проходит целиком.Корректные ключи идемпотентности:
(task_id, attempt)дляoffer.declined,match_idдляmatch.found,task_idдляtask.failed.city_idдобавлен в домен и proto и проброшен вmatchingиgeo— структурно того, чего вtask_1не было вовсе.Полностью событийная топология:
task-serviceподписан наmatch.foundиmatch.exhausted, без синхронных обратных gRPC.Снапшот кандидатов: на переназначении конвейер подбора не перезапускается, берётся следующий из уже найденного списка.
Уведомление клиента доходит, обработан путь «кандидаты исчерпаны».
По чеклисту эталона это рост с ~64% (task_1) до ~93% (19.5/21 у Claude). Оценки: Claude 8/10, Codex 6/10 как eval-решение (7/10 как прототип).
Где сместилось от плана
Главное расхождение оба ревьюера нашли в одном месте — в самом ядре фичи, в логике ранжирования.
Инвертированный тай-брейкер. При доступном geo код сортирует кандидатов только по расстоянию и теряет рейтинг:
// matching-service/usecase/matching.go — SortCandidatesByGeo
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].distance != entries[j].distance {
return entries[i].distance < entries[j].distance // первичный ключ — расстояние
}
return entries[i].w.ID < entries[j].w.ID // тай-брейк — worker_id, не рейтинг
})
out := make([]domain.MatchCandidate, len(entries))
for i, e := range entries {
out[i] = domain.MatchCandidate{WorkerID: e.w.ID, Name: e.w.Name} // рейтинг (Score) сюда не попадает
}Из-за этого «следующий лучший фрилансер» превращается в «ближайшего»: кандидат с рейтингом 3.0, но поближе к задаче, обходит кандидата с 5.0. А этот список потом переиспользуется на каждом отказе — значит, и переназначения идут по расстоянию, а не по рейтингу.
При этом сам план был прав: в нём расстояние — лишь способ выбрать между кандидатами с одинаковым рейтингом. Ошибка не в плане, а в реализации — она сделала расстояние главным критерием вместо рейтинга.
Рассинхрон city_id. Сервисы в проекте хранят данные в памяти и на старте заполняются демо-записями (задачи, воркеры, города). И вот в этих демо-данных task-service записывает город задачи как city-msk, а geo-service и worker-profile — как moscow. Когда matching-service просит geo-service посчитать расстояния, тот не находит city-msk среди своих городов и возвращает ошибку на весь запрос. matching-service эту ошибку проглатывает и просто работает дальше без расстояний.
Здесь свой пробел есть и у плана: он протянул поле city_id, но не зафиксировал единый набор значений id между сервисами (см. конец секции 2). В итоге ошибка в данных прячет баг тай-брейкера: до расчёта расстояний дело вообще не доходит. Поэтому тай-брейк не работает в любом случае — на текущих данных geo-service сразу отвечает ошибкой, а если выровнять id, включится уже сломанная сортировка из прошлого пункта. Это видно прямо в коде — task-service отдаёт один id, а geo-service знает другой:
// task-service: задача публикует city_id = "city-msk"
{"task-1", ..., "Moscow", "city-msk"}
// geo-service: знает только "moscow", "spb", … — пары "city-msk" у него нет
"moscow": {ID: "moscow", Name: "Moscow", RegionID: "moscow_region"}С city_id связан и ещё один промах, уже серьёзнее. Поле сделали обязательным в task-service, но api-gateway его не принимает и не передаёт дальше — поэтому создание задач через публичный API теперь стабильно падает, и теста на этот путь нет. Классический случай: новое поле добавили в один сервис, но не протянули до публичного входа.
Остальное:
в режиме без
geo(когда подключиться к сервису не удалось) спрятана ловушка: проверкаgeo != nilв Go проходит, даже когда клиент на самом деле пустой, поэтому следующий вызовgeoвсё равно роняетmatching-service;поздний или повторный
match.foundможет вернуть уже проваленную задачу обратно в статус «назначена»: обработчикHandleMatchFoundне проверяет, что задача уже в финальном статусе.
Тесты зелёные, логика не покрыта
15 edge-case-тестов зелёные, сборка зелёная — но центральная логика ранжирования «рейтинг первичен, расстояние тай-брейк» не покрыта ни одним из них. Один тест проверяет деградацию воркера без city_id, другой — TestSortCandidatesByGeo_TieBreakByWorkerID — закрепляет в изоляции уже ошибочное поведение сортировки, и рейтинг в нём вообще не участвует. Нет теста вида «воркер A: рейтинг 5.0, далеко; воркер B: рейтинг 4.0, близко — первым должен остаться A».
Это тот же паттерн, что и в task_1: код проходит CI, а дефект остаётся ровно в той логике, которую тесты обходят стороной. Разница в масштабе. В task_1 ломалась вся фича; здесь — одно из пяти требований задачи.
Вывод
Фича работает на сквозном сценарии — в отличие от task_1. Отказ, переназначение, новый оффер, уведомление клиента, лимит 3, task.failed — поток замкнут и протестирован. Но одно из пяти требований — тай-брейкер по расстоянию при равных рейтингах — функционально не выполнено. Код разошёлся с планом.
Хороший план заметно поднял качество и закрыл критический баг task_1 ещё до кода. Но он не гарантировал соответствия кода: зелёная сборка и зелёные юнит-тесты прошли при сломанном гео-пути. Значит, плагину нужны ещё итерации и более строгая проверка того, что код действительно соответствует плану, а не только зелёная сборка и тесты. Идея при этом рабочая.
Код решения — ветка task_3: https://github.com/krus210/freelance-marketplace/tree/task_3
4. Как это работает: принципы investigate
Предыдущие секции показали результат: план task_3 получил 9/10 против 6/10 у task_1, но один баг дрейфа всё равно доехал до кода. Чтобы понять, откуда взялась эта разница и почему она не дотянула до десяти, нужно посмотреть, как устроен investigate внутри. Разберу его по стадиям.
Общая идея одна: investigate старается поймать ошибку как можно раньше — на этапе плана, а не уже в готовом коде. В task_1 фича сначала писалась целиком, и только потом её ловили сквозные тесты и ревью — то есть в момент, когда коллизия match_id уже была закодирована в нескольких сервисах. investigate переносит проверку на момент, когда LLM ещё только собирает требования и рисует план: дешевле поймать ошибку в одной строке плана, чем в коде, разнесённом по нескольким сервисам. Механизм при этом не «ещё один свободный Markdown», а машиночитаемый контракт SERVICE_MAP.yaml на входе плюс дисциплина из нескольких стадий, где каждая закрывает свой конкретный класс ошибки из части 1. И investigate остаётся read-only: единственный файл, который он пишет, — план-артефакт; код и контракты он не трогает.
Дальше — стадии по порядку, и для каждой я отмечаю, какую ошибку она ловит.
Контракт как вход и устранение неоднозначности
investigate начинает не с кода и не с разрозненных Markdown, а с нужных частей контракта SERVICE_MAP.yaml затронутых сервисов: какие у сервиса есть методы (api.endpoints), какие события он публикует и какие слушает (events.published / events.consumed), как он записывает своё состояние (consistency.write_path — например, через outbox). Это закрывает первый класс ошибок из части 1 — неполную картину межсервисных правил. Восстанавливать архитектуру из кода LLM умеет плохо: часть правил в реализации не видна, часть живёт только в голове команды. Контракт даёт их явным списком.
Дальше — clarify-gate. Это запрет планировать, пока не закрыты девять измерений неоднозначности. Каждое измерение — про конкретный класс багов:
Измерение | Что фиксируется | Какую ошибку ловит |
|---|---|---|
Точка входа | через какой сервис входит запрос, есть ли публичный вход ( | запрос привязан не к тому сервису или нет публичного входа |
Кто владеет состоянием | какой сервис отвечает за каждый кусок данных | один сервис меняет данные, за которые отвечает другой |
Доверенная личность | откуда берётся | клиент выдаёт себя за другого |
Порядок событий | гонки с тем, что пишет другой поток событий | поздний или повторный |
Доставка и дубли | событие может прийти дважды — кто и чем отсекает дубли | дубликат считается новой попыткой |
Числовые лимиты | «3 переназначения» — до или после первой попытки | ошибка на единицу в лимите |
Ключи связи | по какому полю связывают: | связали по неверному полю, а ошибки нет |
Терминальные ветки | какой переход и какое уведомление на каждом тупике | тупик без действия — задача зависает |
Запись состояния | публикация идёт через | публикация в брокер в обход |
Важное правило: на каждый вопрос внутри измерения нужно ответить отдельно — ответ на один не закрывает соседний. Именно здесь task_1 действовал наугад: не задавал вопросов и принимал догадки за решения.
Диаграмма изменения и правки контракта
Любой поток, который пересекает границу между сервисами, investigate рисует диаграммой последовательности. Только она показывает сразу всё, что важно ревьюеру: кто кого вызывает, в каком порядке, напрямую или через событие, и чем заканчивается каждая ветка. Для отказа от оффера это выглядит так:

Польза в том, что на такой схеме сразу заметны две типичные межсервисные ошибки из части 1. Первая — где-то стоит прямой вызов между сервисами, хотя по смыслу там должно быть событие через брокер. Вторая — какая-то ветка обрывается на полпути: задача не переходит в финальный статус и заказчик не получает уведомление. На обычной блок-схеме такое разглядеть труднее.
Сами правки контракта investigate только предлагает, но не применяет — этап остаётся read-only. И если правка задевает что-то общее — меняет, какой сервис владеет данными, добавляет публикацию в обход outbox, ослабляет существующее правило, — investigate не вписывает её сам, а отдельно проговаривает и просит подтверждения. Иначе сгенерированная строка контракта незаметно узаконит дизайн, который человек не одобрял.
Все участники события и владельцы данных
Срез контракта на входе намеренно узкий. Но для каждого нового или изменённого события investigate делает исключение и просматривает все контракты в репозитории: находит всех, кто это событие шлёт, и всех, кто его слушает. Заодно проверяет, что новый ключ, по которому отсекают повторы, появился у каждого получателя, а не только у одного, и что одно событие не используют сразу для двух разных задач. Так ловится ошибка вида «дедуп починили в одном сервисе, а в соседнем забыли» — её не видно, если смотреть только на один сервис. Это и есть критический баг match_id из task_1: переиспользованный идентификатор notification-service отбрасывал как дубликат, и поломку нельзя было заметить, глядя только на matching-service.
Параллельно investigate строит карту владения данными: для каждого куска состояния — какой сервис им владеет и имеет право его менять. Так ловится случай, когда один сервис меняет данные другого напрямую, минуя их владельца. Например, увеличивает счётчик переназначений задачи сам, хотя владелец счётчика — task-service, и менять его можно только через его outbox. Любой такой обход помечается отдельно и требует подтверждения.
Каждый риск превращается в тест
Это самая важная стадия. Каждый риск, пробел, неоднозначность и тупик, всплывшие на предыдущих шагах, превращаются в запись edge_cases[] с путём к тесту. Так выглядит запись в документации archspec (иллюстративный пример из доки скила, не из разбираемого плана):
edge_cases:
- id: EC-014
description: "worker city joins to geo by city_id, not free-text city_name; an unresolved city_id must fail loudly, never silently collapse the distance tie-breaker to a default"
test: "services/matching-service/usecase/matching_geo_test.go::TestEC014"Почему это важно. Следующий агент, который пишет код, не перечитывает чат — он читает контракт. Замечание, оставленное только в переписке, теряется. А запись edge_cases[] остаётся в контракте, и её держат две проверки: коммит не пройдёт, пока нет файла теста (DET-003), а удалить запись без отдельного решения нельзя (DET-007). Так найденный риск доходит до кода в виде теста. В task_1 этого как раз не было — риски проговорили, но нигде не записали, и они потерялись.
Self-review loop
Если clarify-gate проверял требования, то здесь investigate проверяет уже сам нарисованный план: прогоняет свой черновик по списку из 18 типичных ошибок и повторяет проверку, пока очередной проход не перестанет находить новое (на первом проходе обычно что-то да находится). В списке, среди прочего:
при каждом переназначении весь пайплайн подбора (skill-analyzer, worker-facade, рейтинги, гео) запускается заново, хотя кандидатов достаточно посчитать один раз на первой попытке и сохранить снапшот для остальных;
консьюмер берёт номер попытки (
attempt) из своего состояния в памяти, а не из payload события: после рестарта сервиса или повторной доставки (replay) это состояние пустое — и старое событие повторно запускает весь флоу;хэндлер шлёт два события, которые должны попасть в один outbox-коммит (либо оба, либо ни одного), но добавляет их по отдельности — если между записями случится сбой, уйдёт только первое;
сервис обращается к другому по одному запросу на каждый элемент (классический N+1), хотя у того есть batch-метод (
GetWorkersBatch,GetDistancesBatch), который вернёт всё за один вызов;сервис меняет данные, которыми владеет другой сервис, синхронным RPC напрямую — вместо того чтобы владелец менял их сам, по событию через свой outbox;
команда объявлена идемпотентной, но это не прослежено под replay: если то же событие придёт дважды, нет dedup-ключа или CAS, который не дал бы выполнить эффект второй раз;
терминальную ветку (тупик) обработали только для последней попытки, хотя кандидатов может не оказаться уже на первой — и тогда задача зависнет без перехода в финальный статус.
Результат записывается отдельной строкой вида Self-review: <N> pass(es), так что видно, сколько проходов прошло. В task_3 их было 4.
План сохраняется в файл и проходит независимое ревью
План пишется в отдельный датированный файл с расширением .archplan.md. Главный урок task_1: план, который жил только в чате, при переходе к реализации терял топологию и инварианты. Файл переживает чат — implement читает файл, а не историю диалога.
Дальше план проходит независимое ревью — и это проверка, а не формальность. Самопроверка слаба тем, что перечитывает собственные допущения, поэтому план отдают отдельному сабагенту со свежим контекстом, без истории чата. Ему дают только файл плана, все SERVICE_MAP.yaml и proto и просят придирчиво искать причины отклонить план — по той же рубрике, что investigate проверял сам: нет ли придуманных методов; снапшотится ли дорогой подбор вместо пересчёта; используется ли batch-метод там, где он есть; идут ли события через outbox владельца, без синхронной записи в чужой агрегат; протянуто ли новое поле от публичного входа до всех потребителей и демо-данных; стоят ли dedup-ключи у всех консьюмеров; закрыт ли каждый тупик (включая первую попытку) переходом и уведомлением; совпадает ли диаграмма с правками контракта.
Если ревьюер возвращает REVISE, план правят и отдают новому ревьюеру со свежим контекстом — не больше 3 раундов. В task_3 это дало Plan-review: APPROVED после 2 раундов. А если запустить отдельных сабагентов нельзя, ревью честно помечается как Plan-review: SELF-ONLY (план перечитал сам себя), а не выдаётся за APPROVED — чтобы было видно, что независимой проверки не было.
Завершает investigate чеклист готовности (Definition of done) — явный список, где зелёный go build/go test не закрывает ни одного пункта. Закрыто только тогда, когда у каждого edge_cases[] есть реально работающий тест и когда /archspec:validate (а для кросс-сервисных изменений и /archspec:check-architecture) зелёный. Это прямой ответ на ловушку task_1, где зелёная сборка выдавалась за готовность.
Кратко про implement
implement устроен по тому же принципу: проверка перед фактом, а не после. Архплан для него обязателен — без .archplan.md он не запускается. Сначала меняются контракты: YAML-патч применяется и прогоняется через /archspec:sync раньше, чем пишется код. Затем строится план реализации, где каждое требование привязано к задаче и тесту, и отдельно проверяется, что каждый вызываемый метод реально есть в proto или контракте (грепом) — придуманных методов быть не должно. Дальше — реализация по TDD, задача за задачей. И главное: по всему диффу прогоняются 5 проверок, что код соответствует плану, каждая против своего класса бага — тех самых, что мы видели в секции 3:
Wiring — в точке сборки каждого сервиса (
main.go) нигде не переданnilвместо зависимости, и каждый клиент ходит на правильный порт. Ловит панику на первом же событии из-за незаведённой зависимости.Emission — каждое событие из контракта публикуется на каждом пути, где оно нужно, включая переназначение, а не только при первичном подборе. Ловит «
match.foundшлётся только на первом матче».Threading — новое поле протянуто от публичного API до всех потребителей и демо-данных, а задекларированный роут совпадает с роутером посимвольно. Это ровно тот класс, что в
task_3сломал создание задач:city_idдобавили во внутренний proto, но не протянули черезapi-gateway.Dedup — dedup-марка ставится атомарно с side-effect (или после него, но не до), и отдельно прослеживается повторная доставка события второй попытки через дедуп каждого консьюмера. Ловит коллизию
match_idизtask_1.Evidence — таблица «требование → конкретное место в коде (
file:line) → тест». Ловит требование, которое просто не написали, — как «рейтинг первичен, расстояние тай-брейк» вtask_3.
Каждый проход существует потому, что соответствующий баг реально уезжал в прод с зелёными юнит-тестами. После проходов — /archspec:validate, /archspec:check-architecture и независимое ревью диффа свежим сабагентом. Если запустить отдельного ревьюера нельзя, результат, как и на этапе плана, честно помечается SELF-ONLY — код проверил сам себя, а не независимый агент.
Три слоя проверок
Проверки выстроены от быстрых к тяжёлым: мгновенный автоматический слой отсекает тривиальные ошибки до того, как дойдёт до медленных агентных ревью с вызовами LLM:
Слой | Что проверяет | Когда |
|---|---|---|
Детерминированный | схема контракта, циклы, пути тестов, дрейф диаграмм, идемпотентность | на каждом коммите через pre-commit hook |
AI-линтеры по Go AST | идемпотентность хэндлеров, | на |
Агентные ревью-гейты | plan-review, 5 проверок кода по диффу, независимое diff-ревью | внутри |
Весь пайплайн целиком выглядит так:

Если коротко: investigate — это не генератор красивого Markdown. Это попытка зафиксировать как контракт те межсервисные правила, которые раньше всплывали только на ревью или в проде, и довести каждую находку до кода через тест, а не оставить её прозой в чате. Каждая стадия закрывает конкретный класс ошибки из task_1: clarify-gate — догадки без вопросов, обход всех контрактов — забытый dedup в соседнем сервисе, мост edge_cases — потерю находки в чате, plan-review — слабость самопроверки. Но набор стадий хорош ровно настолько, насколько строги его проверки. И как показала секция 3, слабое звено сейчас — проверка того, что код соответствует плану: план протянул поле city_id, но ни одна стадия не заставила зафиксировать единый набор значений city_id между сервисами, и в коде это вылилось в city-msk против moscow при зелёных тестах. Об этом — в выводах.
5. Выводы
Цикл проверял одну гипотезу: меняется ли результат LLM на одной и той же фиче, если перед реализацией дать ей не локальные CLAUDE.md, а машиночитаемые архитектурные контракты. Smart Task Reassignment прошла через две модели работы: task_1 без archspec и task_3 поверх контрактов из части 2. Промпт фичи и модель планирования совпадали, отличался только вход.
Главный результат — в контрасте. В task_1 план вообще не дорабатывался, и критический баг прошёл в реализацию незамеченным — по чеклисту эталона вышло ~64%. В task_3 промпт тот же, но поверх контрактов план прошёл реальную доработку (самопроверку и независимое ревью) и закрыл этот баг ещё до кода. По чеклисту план поднялся до ~98%, код — до ~93%. Хороший план заметно поднимает качество.
Но task_3 показал и границу метода: даже сильный план не гарантирует, что код ему соответствует. В двух местах реализация разошлась с планом — где-то код сделал не то, где-то план чего-то не дописал. И, что важнее, сборка была зелёной и все 15 тестов проходили, хотя одно из требований работало неправильно. Зелёные сборка и тесты ещё не значат, что фича работает.
Отсюда вывод про инструмент. Ценность investigate — в том, что он переносит проверку раньше, ещё до кода: на входе не свободный текст, а контракт; дальше уточняющие вопросы, поиск всех получателей каждого события, превращение каждого риска в тест и независимое ревью плана. Проблему ищут до реализации, а не после неё. Но task_3 показывает и следующий шаг развития: добавить проверку, что код действительно соответствует плану. Подход уже работает и приносит пользу, а этот пробел — понятная точка роста.
Что можно забрать даже без плагина:
машиночитаемый контракт сервиса как вход в задачу — вместо свободного Markdown, который расходится с кодом и устаревает;
разрешение неоднозначностей до плана: точка входа, владелец состояния (system-of-record), источник identity, числовые лимиты, ключи дедупликации;
проверка архитектурных инвариантов уже на этапе плана: события идут через
outboxвладельца, нет синхронной записи в чужой агрегат, каждая терминальная ветка закрыта переходом и уведомлением;трассировка каждого нового события по всем контрактам — все продюсеры, консьюмеры и dedup-ключ у каждого консьюмера;
каждый риск (edge case) фиксируется тестом прямо в контракте, а не остаётся в обсуждении;
независимое ревью плана: отдельный ревьюер со свежим контекстом целенаправленно ищет нарушения этих правил до начала реализации.
Ценность здесь не в генерации кода, а в дисциплине стадий. Эти принципы работают и сами по себе, а archspec превращает их в автоматические проверки — чтобы они держались на инструменте, а не на памяти команды.
archspec — open source, его можно пробовать как плагин для Claude Code. Если найдёте баг, неудобный сценарий или недостающее правило — заводите issue.
Все ссылки:
archspec: https://github.com/krus210/archspecfreelance-marketplace(демо-проект): https://github.com/krus210/freelance-marketplaceветка решения
task_3: https://github.com/krus210/freelance-marketplace/tree/task_3
На этом цикл закончен — спасибо, что дочитали.
























