Перейти к основному содержимому

ADR 0023 — Закрытие Фазы 4b (сессия 2): работающий каталог, дедупликация, различающая, кто отказал, и очередь, переживающая раунд

  • Статус: принят
  • Дата: 2026-07-26
  • Фаза: 4b (закрытие сессии 2 — сама Anamnese — это ADR 0016)

Контекст​

ADR 0016 поставил Anamnese: планировщик по проекту, proficiency_profiles с доказательствами, каталог, выведенный из module_map, instruction_patch как proposed_action с diff LCS, append-only версионирование с откатом вперёд, очередь, замыкающая цикл, и два раздела UI. Ничего из этого не отменено.

Не хватало шага, который сессия 1 получила в ADR 0022: прогнать критерий приёмки и закрыть то, что проявляется только при прогоне. Аудит трёх приложений нашёл ~28 отклонений — семь ломали критерий, один пункт формулировки так и не был реализован, а критерий ни разу не выполнялся (не было demo:anamnese и способа запустить раунд по требованию).

Решения​

1. Каталог должен токенизировать составной стек — именно это всё блокировало​

deriveCatalog помещал stack из module_map целиком как ОДНУ компетенцию. Но ModuleMapModule.stack — это ОДНА строка свободного текста, написанная Arquiteto (через LLM), и на практике она перечисляет несколько технологий. При stack: "NestJS + Drizzle + Postgres" каталог получал компетенцию "nestjs + drizzle + postgres", а естественная выдача ("nestjs") выпадала — и поскольку validateProficiencyBatch отклоняет ВЕСЬ ПАКЕТ, раунд не записывал ни одного профиля.

Последствие было хуже, чем «на один профиль меньше»: только emit_proficiency делает halt в ToolLoop, поэтому раунд никогда не завершался, никогда не записывал anamnese_runs, а окно обрабатывалось повторно бесконечно. Anamnese не работала ни в одном реалистичном проекте. Существующий тест проверял только идеализированный случай единственного токена (deriveCatalog(['NestJS'])), а это как раз то, что написал бы человек, а не LLM.

deriveCatalog стал разбивать по +, ,, / и &, добавляя каждый токен И фразу целиком (тот, кто написал "Node.js" — токен с точкой, — не может перестать учитываться). Токен из 1 символа отбрасывается: расширять каталог шумом — ровно то, чего защитное ограждение делать не может, и есть явный тест на то, что токенизация не ослабляет отклонение чувствительных атрибутов.

Два соседних дефекта на том же пути записи:

  • компетенция записывалась СЫРОЙ, а unique — это (project, user, competency) — "NestJS" и "nestjs " создавали две строки для одного и того же. Теперь записывается нормализованной.
  • две записи для одного (userId, competency) в пакете натыкались на ON CONFLICT DO UPDATE command cannot affect row a second time (upsert — одна команда), превращаясь в непрозрачный 500 вместо исправимого tool-result. Теперь это отклонение с сообщением, говорящим, что делать.

2. Отказ ПОЛИТИКИ — не отказ пользователя​

«Отказ регистрируется, чтобы не предлагать то же самое снова» говорит о решении пользователя. Но дедупликация фильтровала только status === 'denied', а ProposeActionUseCase записывает тот же статус без решающего, когда decide отказывает по роли ниже maintainer или по permissions.json. Результат: patch, заблокированный политикой, был обречён навсегда — человек так и не видел diff, и даже исправив роль, нельзя было предложить его снова.

Теперь требуется decidedBy !== null. Это одна строка, и это разница между «пользователь сказал нет» и «система не позволила даже показать».

3. Потребление очереди — следствие существования patch​

emit_proficiency безусловно отправлял consumedQueueIds: ctx.queued_ids вместе с профилями. Раунд, прочитавший принятую гипотезу и не предложивший patch, сжигал запись очереди, и ничто не ставило её в очередь заново: критерий «принимаю гипотезу и вижу, что следующий patch на неё ссылается» молча падал и никогда не восстанавливался.

Мы перевернули ответственность: engine больше не трогает id очереди, а ProposeInstructionPatchUseCase помечает запись потреблённой, когда ссылающийся на неё patch РОЖДАЕТСЯ, в той же транзакции. Прочитанная и неиспользованная гипотеза остаётся ожидающей до следующего раунда.

Почему потреблять при ПРЕДЛОЖЕНИИ, а не при одобрении: гипотеза выполнила свою роль, когда стала patch, который человек может оценить. Если пользователь отклонит patch, повторение предотвращает дедупликация (решение 2) — повторная постановка в очередь привела бы к вечному предложению одного и того же patch.

Последствие для контракта: consumedQueueIds ушёл из внутреннего DTO, а markConsumed(ids) стал markConsumedByHypothesis(projectId, hypothesisId). projectId в фильтре попутно закрывает межтенантную дыру: старая версия была inArray(id, ids) без области, поэтому вызов мог пометить потреблённой очередь другого проекта.

4. hypothesisId проверяется на обоих концах​

Ни engine (по гипотезам раунда), ни api (по проекту) не проверяли id. Галлюцинированный id доходил до agent_instruction_versions.source_hypothesis_id, и трассируемость гипотеза→patch→версия указывала в никуда.

Engine отклоняет hypothesisId вне ctx.queued_hypothesis_ids, с сообщением, возвращающимся модели для исправления на следующем ходу (та же идиома, что и у отклонения доказательства Psicólogo); api перепроверяет, что гипотеза существует и принадлежит проекту. Обратите внимание, что ctx теперь несёт id ГИПОТЕЗ, а не строк очереди, — то, что prompt предлагает модели, и то, что должен нести patch, — это одно и то же.

5. «Команды, которые одобряет/отклоняет» входят в окно — отменяя отсрочку из 0016​

Пункт 1 формулировки перечисляет четыре сигнала: язык, исправления агентам, команды, которые одобряет/отклоняет, и уровень вопросов. Первые три находятся в event log; четвёртый живёт в proposed_actions.decided_at, и ADR 0016:170-174 отложил его чтение («естественное развитие контекста»). Один из четырёх сигналов вне, по замыслу.

Новый listDecidedInWindow(projectId, from, to) — только решения с человеком в качестве решающего, в том же окне, что engine использует для лога, — наполняет поле decisions[] в контексте, а prompt получил собственный раздел с rejectionReason. Причина отказа — самый богатый сигнал окна: она говорит, что человек счёл неправильным, его собственными словами («nunca use push --force, gere migration» стоит больше десяти сообщений чата).

Triage.should_run? стал считать решения вместе с событиями: окно, в котором пользователь только одобрял и отклонял действия, ЯВЛЯЕТСЯ материалом, а его отбрасывали как пустое.

6. Доказательство профиля имеет область ПРОЕКТА — UI нужно было разрешать сессию​

Чип доказательства всегда переходил в useLatestSession(projectId). Но окно Anamnese пересекает несколько сессий (ADR 0016, решение 10), поэтому любое доказательство из старой сессии попадало в «evento não encontrado nesta sessão». Это ТОТ ЖЕ дефект, который решение 1 ADR 0022 закрыло для Psicólogo, повторённый здесь, — а у ProficiencyProfile нет sessionId, чтобы исправить на клиенте.

Новый GET /projects/:projectId/events/:eventId поверх GetProjectEventUseCase: в отличие от сессионного endpoint сессии 1, здесь сессия — это ответ, а не проверка. Чип разрешает и затем переходит. Метка закреплённого события в SessionPage перестала говорить «citado pela hipótese» — теперь оно приходит из двух источников.

7. Приватность: каждый видит своё; кто администрирует — видит команду​

GET /proficiency был viewer и возвращал профиль ВСЕХ участников (компетенция, уровень, обоснования, доказательства). Хуже: удаление требовало developer, поэтому viewer профилировался и получал 403 при попытке удалить собственный профиль — пробивая «весь профиль видим и удаляем пользователем».

Профиль компетенций — данные о человеке, поэтому по умолчанию человек видит свой: owner/maintainer получают агрегированный вид (полезно для распределения работы), любая другая роль получает только собственный, через listByUser, который существовал и был мёртвым кодом. Удаление и opt-in стали требовать только viewer — быть участником. Мы отбросили «никто не видит ничьего»: это оставило бы экран пустым для тех, кто администрирует, без реального выигрыша в приватности внутри проекта, к которому человек решил присоединиться.

Две меньшие дыры того же ограждения: доказательство не было ограничено проектом (findById без проверки проекта, тогда как сообщение обещало «deste projeto»), а delete + opt-out были двумя отдельными await — падение между ними оставляло профиль удалённым и выводимым заново, то есть удаление было бы косметическим всё равно. Теперь это одна транзакция.

8. Раунд по требованию, потому что 15 минут не тестируемы​

Единственным способом провести раунд был тик планировщика. POST /projects/:projectId/anamnese/run (engine + api, maintainer по той же причине, что и повторный анализ Psicólogo: запускает ToolLoop и тратит бюджет) закрывает асимметрию и делает критерий приёмки исполнимым. Проект без сессии отвечает 409 — нет лога для анализа и места для повествования, как и на периодическом пути.

9. Паритет с тем, что укрепила сессия 1​

У Anamnese были, один за другим, те же дефекты, которые ADR 0022 закрыл в Psicólogo:

  • perform возвращал :ok при сбое контекста, с комментарием, что это позволяет Oban повторить, — :ok помечает completed, поэтому раунд проекта молча исчезал, а max_attempts: 3 был мёртвым грузом.
  • reason_for({:ok, ctx}) игнорировал ctx.last_error. Это было единственное из четырёх мест вызова (QA, Dev, Psicólogo, Anamnese) вне шаблона: упавший provider превращался в «encerrou sem emitir perfis».
  • окно шло в сообщении :pinned (которое ContextManager не может сжимать, намеренно — event ids доказательства должны выжить) с 500 событиями и inspect(payload) без усечения, против фиксированного context_window. Получило потолок событий через конфигурацию, усечённый payload и ВИДИМУЮ модели заметку об опущенном (она может цитировать только те id, которые видит). Попутно created_at — который собирался и отбрасывался — вошёл в строку события: «уровень вопросов» — это анализ тенденции, а тенденции нужно время.
  • все потолки были атрибутами модуля. Они переехали в runtime.exs и в compose с текущими значениями по умолчанию.
  • ActionPipeline был зарегистрирован в :pre_tool_use как постоянный no-op (ни один tool Anamnese не является terminal/write_file).

10. Один diff, один вид​

--diff-add/--diff-del специфицированы в design/COMPONENTS.md:127 и не существовали в design/tokens.css. ApprovalCard использовал var(--diff-add, #0e2e24) и поэтому всегда падал на зашитый hex; история версий, написанная позже, придумала цвет ТЕКСТА вместо фона. Один и тот же patch выглядел по-разному в зависимости от того, где вы смотрели. Токены определены в обеих темах (пара из спецификации откалибрована для тёмной), и оба места стали использовать одну идиому.

Также: удаление профиля получило подтверждение через уже существовавший ui/Modal (это необратимо, и голый клик был слишком), откат и opt-in получили состояние pending (двойной клик создавал две версии), opt-in стал инвалидировать query, а карточка patch отрисовывает все файлы — не только files[0], тогда как соседняя ветвь git_commit всегда перебирала их в цикле.

Выполненная проверка​

pnpm --filter api demo:anamnese — скрипт явно разделяет детерминированное (токенизированный каталог, ограждение, очередь, потребляемая только с patch, patch→одобрение→версия с sourceHypothesisId→откат, возвращающий содержимое с созданием новой версии, дедупликация по человеческому решению, opt-out, препятствующий повторному выводу) и зависящее от модели (раунд записывает профиль с разрешимым доказательством и предлагает patch). Выходит с кодом 1, перечисляя упавшее.

Наборы тестов: engine 250, api 500, web 105 — все зелёные. Недостававшие тесты включают первые web-тесты Anamnese (сессия 1 получила свои в ADR 0022; у этой не было ни одного) и два, охраняющие гарантии без покрытия: потолок «instruction_patch никогда не одобряется автоматически» в decide.ts — того же класса, что и запрет merge, у которого четыре теста, — и ExecuteInstructionPatchUseCase, единственный исполнитель без spec, передававшийся как undefined as never в соседних тестах.

Окно усекалось до секунды — и раунд молча пропускался​

Это проявилось только при реальном прогоне demo:anamnese и было НАСТОЯЩЕЙ причиной «профиль не приходит», которую я приписывал нестабильности локальной 7B. token_usage для актора anamnese был НОЛЬ: модель никогда не вызывалась.

session_events.created_at был объявлен :utc_datetime в схеме Ecto (точность до СЕКУНДЫ) против колонки timestamptz(6). Ecto усекает параметр при сравнении, поэтому created_at < window_to превращалось в < 15:39:32 вместо < 15:39:32.931 и отбрасывало всё, что произошло в текущую секунду. Измерено в базе: 0 событий в усечённом окне против 9 без усечения, при первом событии в 15:39:32.363.

Поскольку скрипт делает всё меньше чем за секунду, окно было пустым ВСЕГДА. В production 15-минутный тик почти всегда проскакивает (события уже из прошлых секунд), и поэтому это прошло аудит — который даже зафиксировал это как «незначительное, с оговорками». Это не было незначительным.

Два изменения:

  • :utc_datetime_usec в поле, в соответствии с колонкой. Регрессионный тест (event_window_test.exs — у окна по проекту не было ни одного) был проверен ПАДАЮЩИМ без исправления: тест, который прошёл бы в любом случае, ничего бы не доказал.
  • Пропущенный раунд стал оставлять след (Logger.info со счётчиками и порогом). Намеренно НЕ событие в логе: тик каждые 15 мин на проект стал бы шумом. Но именно полное молчание это и скрыло — раунд, который ничего не делает и ничего не говорит, недиагностируем.

С исправлением demo:anamnese прошёл ЦЕЛИКОМ: critério de aceite da Fase 4b (sessão 2) ATENDIDO.

Визуальный проход нашёл три вещи, которые не поймал бы ни один тест​

demo:* доказывает данные; он не доказывает, что экран показывает данные. Открыв UI в подопытном проекте:

  1. История инструкций была невидима для существующих агентов. Раздел делал fan-out по AGENT_LIST (статический реестр, с dev-backend/dev-frontend), но Фаза 4a создаёт dev agent НА МОДУЛЬ (dev-api, dev-web). При 3 версиях dev-api в базе экран говорил «Nenhum agente teve a instrução alterada ainda» — то есть пункт 5 формулировки (история с diff и откатом) был мёртв как раз для агентов, которых патчит Anamnese. Новый GET /projects/:projectId/instruction-versions возвращает тех, у кого ЕСТЬ история, и UI перестал угадывать slug.
  2. Повествования о patch и откате теряли версию. payloadField возвращал только string, а toVersion/restoredFrom приходят как число — поэтому лента говорила «instrução de dev-api atualizada» (без версии) и «revertida para a v?». Теперь принимает число.
  3. «Sempre permitir» появлялось в instruction_patch. Потолок decide.ts всегда навязывает require_approval, поэтому запись правила в permissions.json ничего не меняет: кнопка обещала несуществующий эффект. Скрыта для этого типа.

Кроме того, проверено на экране: уровень/обоснования/чипы профиля; чип доказательства, разрешающий ПРАВИЛЬНУЮ сессию (переход в сессию, содержащую событие, а не в самую свежую) с событием, закреплённым вверху лога; бейдж hipótese <id> в версии и в карточке одобрения; diff с новыми токенами в обоих местах; и — закрывая то, что ADR 0022 утверждал без проверки, — полоса анализов Psicólogo со стоимостью, действительно расходящейся (тяжёлый US$ 0,0068 vs лёгкий US$ 0,0001), и чип доказательства, указывающий на agent.response — тип, который лента скрывает, — доходящий до события.

Объём и допущения​

Вне этого закрытия: синхронизация объединения ActionType в web с 12 типами backend (ранее существовавший долг, ADR 0016); пересборка prompt в живом разговорном GenServer; индекс в session_events для окна по проекту; ручное редактирование инструкции через UI; индекс по source_hypothesis_id и обратный запрос (гипотеза → версии), который пока никому не нужен.

apply patch по-прежнему выполняется вне транзакции, обновляющей действие (ExecuteInstructionPatchUseCase): падение ровно между ними оставляет инструкцию пропатченной при действии в approved. Это зафиксировано, потому что реально, но исправление требует переосмыслить транзакционную границу ВСЕХ исполнителей (у исполнителей infra и git та же форма), а сделать это только здесь создало бы несогласованность между родственниками.