explainer · methodology

Markdown как источник правды, LanceDB как производный индекс - и 32 нарушения, которые почти убили инвариант

Как несущий инвариант ADR-003 в ForgePlan стал compile-time enforced: 4 раунда аудита, 56 findings, pub(crate) lockdown на 32 call sites, и почему скучная дисциплина важнее умной архитектуры.

Инвариант в одном предложении

В ForgePlan markdown-файлы в .forgeplan/{prds,adrs,specs,rfcs,evidence,...}/*.md - это источник правды. Индекс LanceDB - производный и пересобираемый.

Каждая мутация артефакта идёт из markdown в LanceDB, никогда в обратную сторону. Если LanceDB и markdown расходятся, побеждает markdown, а forgeplan scan-import пересобирает LanceDB из файлов на диске.

Это ADR-003. Самое несущее архитектурное решение в проекте. И именно его мы едва не потеряли - дважды.

Это история о том, как инвариант стал compile-time enforced, чего это стоило (4 раунда аудита, 56 findings, pub(crate) lockdown на 32 call sites), и почему скучная дисциплина важнее умной архитектуры.

Почему только в одну сторону

Двусторонняя синхронизация между markdown и векторной базой данных - это ловушка. Сценарии поломки:

  1. Устаревший индекс - кто-то редактирует markdown напрямую (или git pull приносит изменения от другого агента), индекс этого не знает, семантический поиск пропускает результаты.
  2. Устаревший markdown - code path мутирует индекс без записи в markdown, файл превращается в ископаемое, git diff врёт, code review не замечает расхождения.
  3. Конфликт при пересборке - в индексе есть данные, которых нет в markdown, пересборка затирает их, данные теряются без предупреждения.

Мы выбрали одностороннюю схему: markdown авторитетен, LanceDB - кэш. Кэш можно удалить в любой момент и пересобрать через forgeplan scan-import. Именно поэтому .forgeplan/lance/ и .forgeplan/.fastembed_cache/ прописаны в gitignore - это производные артефакты.

CLI и MCP-сервер соблюдают эту схему через единственное правило: никогда не вызывать LanceStore::create_artifact / update_* / delete_* / add_relation / delete_relation напрямую из commands/*.rs или server.rs. Все мутации идут через forgeplan_core::projection::sync_file_to_store и render_projection.

Вот и всё правило. Одна строка. Оно не выживает при контакте с кодовой базой на 12.8K строк кода, к которой прикасаются AI-агенты и живые разработчики на протяжении шести месяцев. Поэтому.

Как правило нарушилось (PROB-048)

Шесть недель назад я запустил аудит с вопросом: «сколько мест вызывают мутаторы LanceStore::* напрямую?» Ожидаемый ответ - ноль.

Оказалось - 32.

Тридцать два места в commands/*.rs и server.rs делали прямые вызовы LanceStore::create_artifact(...) или LanceStore::add_relation(...). Они разошлись по истории коммитов: одни - copy-paste из старых паттернов, другие - «только для этого одного краевого случая», один - отладочный хелпер, который никто не удалил.

Каждое нарушение было тихой бомбой. Ни одно не вызывало немедленной поломки, потому что запись в markdown и запись в LanceDB происходили в одном коммите. Но это означало: инвариант проекции существовал только в голове у первоначального автора и нигде больше. Новый код, написанный новым агентом или новым разработчиком, читающим существующие паттерны, унаследовал бы нарушение как норму.

Я залогировал это как PROB-048. Зафиксировал исправление как PRD-073 с тремя фазами:

  • Фаза 3a - рефакторинг всех 32 call sites для использования хелперов проекции
  • Фаза 3b - введение 15 вспомогательных функций, оборачивающих канонические flow
  • Фаза 4 - изменение видимости опасных мутаторов LanceStore на pub(crate), чтобы компилятор физически запрещал commands/*.rs их вызывать

Четыре раунда аудита

Рефакторинг - это лёгкая часть. Рефакторинг без регрессий - тяжёлая. Я провёл четыре адверсарных раунда аудита, каждый с двумя AI-агентами (security reviewer и code reviewer), каждый с инструкцией найти минимум три проблемы. Ноль findings - перезапуск (подозрение на поверхностный review).

По раундам:

РаундЗакрытых findingsЧто сломалось
R119В первоначальном рефакторинге пропущены 7 call sites; 12 сигнатур хелперов не совпали с потребностями call sites
R218Изменение на pub(crate) вскрыло 11 unit-тестов, импортирующих LanceStore напрямую; 7 примеров в документации сломались
R313Краевой случай в forgeplan_link - путь удаления связи обходил хелпер проекции
R46Финальная зачистка: одна мёртвая функция в journal/, два устаревших doc-комментария
Итого56-

Регрессионный guard поставил финальную точку. tests/adr_003_invariant.rs - единственный интеграционный тест, который обходит дерево исходников, считает прямые вызовы LanceStore::create_artifact / update_* / delete_* в commands/ и server.rs, и роняет сборку, если счётчик превысит ноль. Десять строк кода. Самые важные десять строк в репозитории.

Часть на уровне compile-time (pub(crate)) блокирует новые нарушения. Тест-runtime (регрессионный guard) блокирует регрессии на случай, если кто-то добавит новый модуль под commands/ и забудет правило. Ремень и подтяжки.

Зачем я рассказываю эту историю

Три причины.

Первая: инварианты без compile-enforced защит - это фольклор.

ADR-003 был написан в первый день проекта. Он был ясным. Он был правильным. И за шесть месяцев его нарушили 32 раза. Любой архитектурный документ, который рассчитывает на «мы все запомним правило», проигрывает энтропии. Интересный вопрос не «каков правильный инвариант», а «как сборка ломается, когда кто-то об инварианте забывает?» Если ответ - «никак», ваш инвариант декоративный.

Вторая: кодовые базы с AI нуждаются в этой дисциплине больше, а не меньше.

Когда вы запускаете параллельные суб-агенты для рефакторинга или расширения кодовой базы, у каждого агента есть контекстное окно и частичная картина. Агент, читающий существующие паттерны, не видит ADR-003, если вы не вложили его в промпт агента. И даже тогда «запомни правило» - ненадёжное обязательство, когда агент делает 50 правок файлов за один проход. Компилятор - единственный reviewer, который не устаёт и не отвлекается.

Именно поэтому CLAUDE.md ForgePlan содержит явные красные линии, и именно поэтому каждая multi-agent диспетчеризация в нашей команде назначает явные сетки владения файлами. Цена того, что AI-агент своевольно обходит архитектурные инварианты, выплачивается спустя недели - когда следующий агент читает этот код и считает его каноническим.

Третья: dogfood проверяет методологию на прочность.

Весь смысл ForgePlan - «каждое решение оставляет след». Решение PROB-048 живёт в .forgeplan/problems/PROB-048-*.md. PRD-073 - в .forgeplan/prds/. Четыре сводки раундов аудита - это EvidencePacks. EVID-094 (закрывающий пак) получил R_eff=0.80, оценка A. Не идеально: один из показателей fuzz-бенчмарка после Фазы 4 не перегонялся с v0.28. Decay честный. Граф артефактов его отражает.

Когда я разбираю эту историю с командой, я запускаю forgeplan get PRD-073 вживую. Видно FR с отметками «выполнено», связанный EvidencePack, оценку R_eff, закрывающую заметку. Методология - не слайды. Это файл, который читает инструмент.

Что конкретно изменилось в кодовой базе

После Фазы 4 модуль forgeplan-core::store выглядит так:

  • Все мутаторы create_*, update_*, delete_*, add_relation, delete_relation - pub(crate).
  • 15 вспомогательных функций в forgeplan_core::projection оборачивают канонические flow: sync_file_to_store, render_projection, apply_link_change и другие.
  • Канонический пример CLI-команды для одиночной мутации - crates/forgeplan-cli/src/commands/deprecate.rs: 40 строк, никакого прямого доступа к хранилищу, каждый шаг идёт через проекцию.
  • Новые участники получают ошибку компиляции с первого дня, если пытаются сократить путь.
  • Регрессионный guard входит в каждый CI-прогон.

На момент написания cargo test прогонял 1995 тестов: 0 failures, 0 warnings на обеих конфигурациях фич. Lockdown не замедлил ничего измеримо - он устранил целый класс ошибок.

Что я сказал бы другому разработчику методологического инструментария

Если вы вынесете из этого поста только одно: правило соблюдается компилятором - или не соблюдается вовсе.

Документация необходима, но недостаточна. Code review ловит часть нарушений. CI-тесты ловят больше. Но если старший разработчик или AI-агент может написать код, который компилируется и выкатывается, нарушая инвариант, - этот инвариант будет размываться. Единственный способ сохранить его за год изменений - сделать нарушение физически невозможным.

pub(crate) - одна из дешевейших и самых недооценённых возможностей Rust. На ввод ушло десять минут - и это сохранило целый архитектурный слой.

Цена четырёх раундов аудита была реальной (около трёх недель calendar time, в основном адверсарные review и закрытие findings). Цена отказа от этой работы - медленный откат к 50 нарушениям к девятому месяцу, после чего распутывание становится многомесячным проектом вместо многонедельного.

Это и есть скучная дисциплина. Именно она отличает инструмент, который переживает второй год, от того, который тихо гниёт.