ADR 0046 — Продвижение истории с полномочиями пользователя
Контекст
Находка #13 dogfooding, дословно из
docs/missions/dogfooding-mission.md:669:
Не существует «продвинуть в ready»: продвижение автоматическое при создании.
TransitionStoryUseCaseпроверяет и испускаетbacklog.story_transitioned, но не подключён ни к одному маршруту — это мёртвый код. Вкладка Backlog только для чтения.
Она родилась с классификацией P2 и стала одной из трёх P1, которые убивает Фаза 12, по
причине, ставшей ясной только после прогона: из трёх находок эта —
единственная не об удобстве. Усыновление и перепланирование были трением —
человек вручную делал то, что должна была делать машина. Здесь наоборот: машина
сама делала то, что должен был решать человек, а CLAUDE.md говорит, в первой
строке о том, что такое продукт, что окончательные полномочия принадлежат пользователю. История,
становящаяся ready при создании, означает, что PO — агент LLM — сам решает,
что входит в очередь работы dev agents.
Три факта, проверенные в коде ДО проектирования, сформировали решение:
story.status— шлюз claim.TaskRepository.claimNextфильтрует поs.status = 'ready' AND s.module_ids ? module AND t.status = 'todo' AND t.blocked = false. Ничто, кроме статуса истории, не отделяет «предложенную работу» от «работы, доступной для взятия».- Проверка была продублирована и асимметрична. Создание вызывало
canBecomeReady; переход вызывалassertReady+assertModulesResolved. Две двери в одно и то же состояние, с разными замками. TransitionStoryUseCaseуже делал всё — проверял, испускал событие и, с Фазы 12b, записывал строку outboxtask.became_claimableна каждую освобождённую задачу. Не хватало вызывающего.
Решение
proposed_ready — это boolean, а не новое значение в enum статуса.
Это была первая отвергнутая попытка. story_status = 'proposed' был бы
выразительнее, но enum — это буквально шлюз claim (факт 1): добавление
значения обязало бы пересмотреть каждый запрос, сравнивающий статус, а забытое
место не дало бы ошибки — оно дало бы историю, доступную для взятия слишком рано, молча.
Boolean говорит, чем это является: ПРЕДЛОЖЕНИЕМ об истории, которая остаётся
draft. Состояние машины не меняется, потому что фактически ничего не изменилось; существует
ожидающее решение, адресованное пользователю.
Продвижение повторно использует TransitionStoryUseCase; нового перехода не написано.
Мёртвый код находки #13 снова вызывается, и вместе с ним бесплатно приходит
перепланирование 12b: продвижение записывает строки outbox, которые будят простаивающих dev
agents модуля. «Собственное» продвижение пришлось бы перереализовывать — и
первая версия, забывшая бы это, оставила бы пакет задач доступным для взятия без
оповещения кого-либо, а агент обнаружил бы это случайно при следующем несвязанном
событии.
execute получил опциональный параметр actor. Событие backlog.story_transitioned
неизменяемо и является тем, что читает аудит; записать agent/po при продвижении, которое было
решением пользователя, стёрло бы именно тот человеческий шаг, который возвращает эта фаза.
Проверка была объединена в assertPromotable ДО того, как сделать триггер
настраиваемым, и этот порядок — не деталь. Пока у двух путей были
разные замки, «продвинуть через UI» и «продвинуть при создании» были бы разными
правилами с одним именем, и режим manual был бы строже или мягче,
чем auto, по случайности реализации. Тест симметрии в
story-promotion.spec.ts — то, что сохраняет свойство: режим меняет, КТО
запускает, никогда — ЧТО проверяется.
Деталь, которая чуть не сломала режим auto молча: пустой moduleIds ПРОХОДИТ
проверку и должен проходить. При создании у истории ещё нет модулей — их
назначает Arquiteto, позже. Подключение assertPromotable к пути создания
без сохранения этого заставило бы режим auto больше никогда ничего не продвигать, без
единой ошибки.
Backfill направленный, а не слепой. Столбец рождается manual (новое значение по умолчанию), и
та же миграция переводит все существующие проекты в auto. Это противоположность
backfill RN-046, который мог быть слепым, потому что усыновления до него не существовало.
Здесь поведение существовало и использовалось: идущий проект не может
перестать производить из-за деплоя. Новое значение по умолчанию действует для тех, кто придёт
позже.
Отказ повторяет возврат gate к dev и инвертирует порядок rearm.
Причина становится ЗАКРЕПЛЁННЫМ сообщением в сессии PO — первым pinned вне
system prompt у разговорного агента, — с той же фразой приоритета, которую
correction_message/1 dev agent несёт со времён ADR 0020. Закреплённым, потому что
отказ — это ожидающее решение, а не реплика: сжатый ContextManager, PO
заново предложил бы ту же историю с тем же дефектом.
Запись идёт ДО вызова engine, в отличие от RearmDevAgentUseCase.
Там событие (dev.rearmed) утверждает что-то ОБ ENGINE, и записать его раньше было бы
ложью в log, если бы engine отказал. Здесь событие утверждает что-то о
ПОЛЬЗОВАТЕЛЕ — он отказал, и это правда, есть ли работающий PO, чтобы услышать,
или нет. Потерять решение, потому что процесс агента умер при перезапуске, вернуло бы
пользователя к началу без причины. Поэтому engine — best-effort, а внутренний маршрут
отвечает 404 при мёртвом PO вместо того, чтобы взорваться :noproc.
Сообщение возврата говорит, что PO МОЖЕТ сделать, и это принятое ограничение.
Инструмента редактирования истории нет — только create_story. Сообщение велит
создать исправленную версию или спросить пользователя, если причина
неясна. Велеть «исправить историю» значило бы просить невозможного, а модель
перед невозможной инструкцией выдумывает инструмент или повторяет вызов, пока
не исчерпает цикл, — так dev agent сжёг три исправления подряд при
приёмке ADR 0020.
Пакетное продвижение — не всё или ничего. Каждая история — своя
транзакция; та, что провалится, возвращается в failed с причиной, в 201. Реальный случай
конкретен: между предложением PO и решением пользователя модуль мог
уйти из module_map. Отмена всего пакета из-за этого перечеркнула бы ревью,
которое пользователь только что сделал по остальным.
Последствия
Значение по умолчанию изменилось, и это нарушение поведения для того, кто создаёт новый проект: backlog не движется сам, пока кто-то не продвинет. Это отмечено в CHANGELOG как несовместимое. Существующий проект ничего не почувствует.
UI Backlog перестал быть только для чтения. Это две единственные записи
backlog, принадлежащие пользователю, а не агенту, — всё остальное по-прежнему
входит через маршруты /internal/*.
POST /internal/sessions/:id/agent/revise — первый путь api→engine, который
возвращает работу РАЗГОВОРНОМУ агенту. До сих пор возвраты (gate → dev)
были внутренними для engine, в процессе. Маршрут унаследовал от rearm формат и
проверку существования перед вызовом.
Остаётся на потом, как backlog, а не как скрытый долг:
- Инструмент редактирования истории. Пока его нет, цикл отказа
закрывается пересозданием, а отклонённая история остаётся в
draftс записанной причиной. Это проверяемо, но оставляет мусор в backlog. - Пакетное продвижение с ревью бок о бок. Сегодня пакет — множественный выбор с развёрнутыми историями в самой очереди; выделенный экран ревью имеет смысл, когда объём вырастет.
- Понижение истории, продвинутой по ошибке.
assertTransitionдопускаетready → draft(так работаетstory_demotedRN-012), но у пользователя нет маршрута для этого. Путь сегодня — отказ ДО продвижения.