ADR 0148 — Артефакты агентов, спроецированные в файлы, в папку docs/
Контекст
Каждый артефакт, который производят агенты, — brief Criativo, бизнес-правила
PO, диаграмма C4 и маршрутизация модулей Arquiteto, решения шести
разговорных агентов — живёт исключительно в event log.
Engine.Harness.Tools.EmitArtifact (apps/engine/lib/engine/harness/tools/emit_artifact.ex:3-5)
говорит прямо: "não há tabela de artefatos". Артефакт ЕСТЬ событие
artifact.<tipo>, неизменяемое, с автором и seq.
Это правильное решение для ИСТОЧНИКА, и здесь его не трогают. Проблема в другом: эта память не попадает ни в одно место, которое открывает человек. Она читаема через timeline сессии, по строке за раз, внутри продукта. Кто открывает папку проекта в редакторе — а именно там будет код, — не находит ничего из рассуждения, которое произвело этот код.
Дыра стала больше с решением отложить git (отложенное provisioning, в отдельном PR): между созданием проекта и handoff Arquiteto к Dev Lead проект существует вообще без репозитория. В этом промежутке всё, что продукт накопил о проекте, лежит в строках базы, а папка пользователя пуста.
В продукте есть точный прецедент решения этого, и он уже обсуждён и принят:
граф Neo4j
(ADR 0099/ADR 0101),
который заявленно является производной памятью от event log — никогда не
источником, его можно выбросить и пересобрать. GraphProjector
(apps/api/src/application/graph-projection/graph-projector.ts) осушает
outbox_events под собственным aggregate_type и пишет во внешнее место
назначения, деградируя, но никогда не роняя того, кто его питает.
Вопрос этого решения не в том, должна ли существовать папка, — владелец продукта попросил буквально "criar apenas uma pasta por ora chamada docs, onde os artefatos estarão de cada um dos agentes". Вопрос — каким механизмом и что это обязывает гарантировать.
Решение
Папка docs/ — это ПРОЕКЦИЯ event log, в том же строгом смысле, который
ADR 0101 дал графу. Источником остаётся событие; папку можно удалить
целиком и пересобрать; и ничто в продукте не читает из неё, чтобы что-либо
решить. Она существует, чтобы её открывал человек.
Из этого следует всё остальное.
1. Механизм — как у GraphProjector, деталь за деталью
ArtifactProjector
(apps/api/src/application/artifact-projection/artifact-projector.ts)
копирует форму, которая уже решила эту задачу: собственный poller
(setInterval с .unref(), очищаемый в onModuleDestroy), публичный
drainOnce(), независимый от таймера (точка входа тестов), флаг draining
против перекрывающихся циклов, партия по 50 и markProcessed только после
успеха.
Это не переиспользование кода, а переиспользование дизайна — два проектора пишут в места назначения, не имеющие ничего общего. Копирование формы — то, что позволяет читателю, знающему один, понять другой, не перечитывая.
2. Собственный aggregate_type, artifact_projection
AppendSessionEventUseCase пишет третью строку outbox, в ТОЙ ЖЕ
транзакции, для типов из ARTIFACT_PROJECTABLE_EVENT_TYPES. aggregate_type
новый по той же причине, что и у графа (задокументировано в
domain/graph/graph-projection-events.ts:7-26):
Engine.Outbox.Drain.run_once/0 осушает только session, task и
container, а строку 'session', которая есть у каждого события, он
помечает processed_at каждые ~2 с. Читать тот же aggregate_type значило
бы соревноваться с осушителем engine и почти всегда проигрывать.
Одно намеренное отличие от графа: aggregateId — это проект, а не
сессия. Папка принадлежит проекту, а исходное событие несёт только
sessionId — запись projectId здесь избавляет проектор от запроса на каждый
артефакт, чтобы узнать то, что use case уже держал в руках.
3. Список РАЗРЕШЁННЫХ и четыре типа, намеренно оставленные вне
ARTIFACT_PROJECTABLE_EVENT_TYPES
(apps/api/src/domain/artifacts/artifact-projection-events.ts) — это список
разрешённых, а не исключённых: новый тип артефакта рождается вне
проекции и входит, когда кто-то решит, что он является документом, — а не
появляется сам в папке, которую читает пользователь.
Вне остаются qa_verdict, secops_verdict, task_blocked и
infra_delegation_files. Это операционные исходы, а не документы
продукта: вердикт gate на каждую task, в проекте с десятками tasks, заполнил
бы docs/qa-lead/ файлами, которые никто не открывает, и папка потеряла бы
свойство, делающее её полезной, — помещаться в один взгляд. Они остаются в
event log, который является источником, и остаются в timeline.
4. По агенту, и версионируемое перезаписывается
Раскладка — docs/<agente>/<arquivo>.md. Навигация по папке отвечает на
«что произвёл Arquiteto?», ничего не открывая.
Четыре версионируемых типа (module_map, module_routing,
project_image, c4_diagram — те, что читаются сведением к наибольшему
version, см. obter-container-do-projeto.use-case.ts:22-25) всегда пишут в
один и тот же файл, перезаписывая: папка показывает ДЕЙСТВУЮЩИЙ, а вся
история остаётся в event log. Проецировать их как файл на версию дало бы
c4-diagram-v1.md, c4-diagram-v2.md и заставило бы открывающего выяснять,
какой действует, — ровно ту работу, от которой избавляет сведение по
version.
Остальные по природе только дополняются (решение не заменяет предыдущее: оба
произошли), и каждое становится собственным файлом, с seq события в имени.
seq не имеет пропусков в пределах сессии и уже упорядочивает event log,
поэтому это разрешение ничьей, не требующее нового состояния, — а без него
два артефакта одного типа с одним заголовком породили бы один и тот же файл,
и второй стёр бы первый: единственный способ, которым эта проекция могла бы
потерять информацию, имеющуюся в источнике.
5. Два барьера против файловой системы, и второй не избыточен
Имя файла выводится из заголовка, написанного моделью, — недоверенный ввод
во всём смысле термина. Защита не в том, чтобы экранировать пришедшее, а в
том, чтобы вывести новое имя из закрытого алфавита: у slugDeArquivo нет
пути возврата, на котором выжил бы разделитель, и он возвращает null, когда
ничего не остаётся (пустое имя столкнулось бы со всеми другими пустыми
именами).
FsArtifactFileStore проверяет снова, через relative(), перед
открытием файла. Это не паранойя: они отвечают на разные вопросы — первый
«какое имя я из этого вывожу», второй «что я на самом деле открою».
Полагаться только на первый значило бы сделать безопасность этой записи
зависимой от того, что ни одно будущее изменение slug никогда не пропустит
разделитель, — а это ровно та форма изоляции, которую
ADR 0130 отвергает принципиально: та, что
зависит от корректности вызывающего.
6. В режиме runner папка отклоняется в управляемый корень
pastaDeArtefatosDoProjeto
(apps/api/src/infrastructure/filesystem/project-workspaces-root.ts) —
сестра permissionsFilePath и делает то же отклонение, по той же
физической причине: пишет api, изнутри своего контейнера, а проект runner
намеренно без bind-mount. Канал runner (exec/exec_result) передаёт только
КОМАНДУ — примитива записи содержимого в файл нет, — так что даже
делегирование записи не помогло бы без изобретения нового механизма.
7. Запись НЕ проходит через proposed_action
И это не упущение. proposed_action — источник каждого внешнего эффекта
агента, просящего действовать. Здесь нет агента, который что-то просит:
артефакт уже выпущен и уже лежит в event log, когда этот класс выполняется.
То, что происходит дальше, — это система, материализующая по производному
пути читаемую копию того, что уже является фактом.
Пропуск проекции через очередь одобрения дал бы обратный задуманному эффект: заполнил бы очередь рутинными решениями — по одному на артефакт — и разъел бы именно тот потолок, который придаёт смысл клику на действиях, которые являются внешним эффектом.
Последствия
У пользователя в папке проекта появляется папка docs/, организованная по
агентам, с рассуждением, которое произвело код. Она появляется до того, как
существует git (отложенное provisioning), — именно в тот промежуток, когда
сегодня на диске ничего нет.
Проекция никогда не роняет источник. Ни один throw не выходит из
drainOnce: каждый элемент падает изолированно, логируется и остаётся
processed_at IS NULL для следующего цикла. Полный диск, отказ в доступе или
недостижимая папка не могут помешать артефакту быть ВЫПУЩЕННЫМ. У
outbox_events нет ни attempts, ни dead-letter — retry неявный и
бесконечный, ровно как у графа.
Намеренное отличие от GraphProjector: здесь нет аналога
GraphUnavailableError, останавливающего всю партию. Отказ записи обычно
относится к ЭЛЕМЕНТУ (странное имя, проект, чья папка исчезла), а не к месту
назначения; остановка партии из-за одного элемента заблокировала бы все, что
стоят за ним.
Событие, исчезнувшее из event log, помечается обработанным, а не повторяется вечно — повторять то, чего больше нет, не дало бы никакого эффекта, а строка оставалась бы в очереди бесконечно.
Объявленная цена 1: в режиме runner docs/ не лежит рядом с кодом. Кто
откроет папку проекта на своей машине, её не найдёт; она находится в
<PROJECT_WORKSPACES_ROOT>/<workspace_dir_name>/docs/. Это РЕАЛЬНАЯ потеря в
этом режиме, потому что папка существует именно для того, чтобы её открывали
вместе с кодом. Она избегает худшей альтернативы: проекция просто не
происходила бы или молча падала бы на каждом артефакте.
Объявленная цена 2: Markdown обобщённый. У файла есть заголовок (тип,
агент, когда, seq) и payload как блок JSON. Рендерера по типу нет — это было
бы тринадцать мест, которые устаревали бы при изменении схемы, а эта папка
должна доставлять читаемое и прослеживаемое содержимое, а не вёрстку. Более
богатый формат по типу — отдельное решение, когда появится реальное чтение,
которое его попросит.
Объявленная цена 3: папка может расходиться с event log между циклами, а
после устойчивого отказа может оставаться неполной бесконечно, без какого-либо
предупреждения. Это та же цена, которую уже принимает граф, и она терпима по
той же причине: источник цел, а проекция пересобираема. Сегодня нет команды
перепроецирования — удаление docs/ её не пересобирает, потому что строки
outbox уже помечены. Это тот же пробел, который BRB-018 уже фиксирует для
графа, теперь со вторым потребителем.
Чего этот ADR НЕ касается. decide.ts и абсолютных потолков;
proposed_action как источника каждого внешнего эффекта агента (см. пункт 7 —
проекция к ним не относится); неизменяемости event log; схем артефактов
engine, которые ничуть не меняются; и затвора образа
(ADR 0135).