ADR 0101 — Реляционная память как проекция event log; шаблоны потребляются без трогания GenServers
- Статус: Принят
- Дата: 2026-08-20
- Контекст: потребление фундамента графа (RN-416/417), компаньон ADR 0099/ ADR 0100
Контекст
Предыдущая волна построила фундамент графа (Neo4j, версионируемые
шаблоны, rag_search) без реального потребителя. Эта волна соединяет обе
стороны: (1) три агента начинают разрешать kickoff/идентичность из
шаблона графа, с fallback на inline-текст; (2) Psicólogo
и Anamnese начинают дополнять "N самых свежих"/"временное окно"
РЕЛЕВАНТНЫМИ фрагментами проекта через rag_search; (3) граф начинает
записываться сам, проекцией event log.
Два вопроса дизайна требовали ответа ДО распараллеливания: как потреблять шаблоны, не переписывая GenServers целиком, и как писать в граф, не открывая второй путь записи из engine.
Решение
Шаблоны: InstructionFiles получает источник :graph, не меняя контракт.
Итоговый приоритет: db > graph > dir > root — instruction_patch
пользователя по-прежнему побеждает всё (уже установленное бизнес-правило:
пользователь всегда может перезаписать); граф побеждает то, что на диске.
PromptAssembler не меняется; ни один GenServer агента не теряет ни строки
логики — каждый лишь начинает ПЫТАТЬСЯ разрешить шаблон перед использованием
inline-текста, который становится fallback. Кэш ETS переиспользован (никакого нового
параллельного кэша), короткий TTL.
Два флага, намеренно РАЗДЕЛЁННЫЕ: graph_templates_enabled?
(Psicólogo + Anamnese, один ключ, один потребитель kickoff) и
graph_instruction_templates_enabled? (ux-designer, через
InstructionFiles). Один ключ столкнулся бы с ПРОТИВОПОЛОЖНЫМИ значениями по умолчанию между
двумя направлениями, которые их писали параллельно, — а разрешение
config/2 в Elixir с дублированным ключом берёт ПОСЛЕДНЕЕ вхождение,
заглушая одно из двух БЕЗ КАКОЙ-ЛИБО ОШИБКИ. Два ключа с безошибочно различимыми
именами дешевле, чем согласовывать единое значение между параллельными
направлениями. Оба значения по умолчанию — false: новая возможность рождается выключенной,
пока не выполнится seed и кто-то сознательно её не включит — тот же критерий, что у
psychologist_enabled?/anamnese_enabled?.
Релевантность: композиция, никогда замена. Psicólogo и Anamnese
продолжают читать то, что всегда читали (недавние события / временное окно), и
ПОЛУЧАЮТ второй источник, rag_search, с запросом, выведенным из
ТРИГГЕРА анализа (классифицированная причина завершения, у Psicólogo;
компетенции ещё без профиля, у Anamnese — никогда свободный текст
гипотезы/колебания, по уже установленному запрету для Anamnese никогда не
выводить здоровье/личность/возраст/пол). Существующие пределы
Triage (max_prompt_events, max_payload_chars) ОСТАЮТСЯ общим
бюджетом — релевантные фрагменты вычитают места из окна
недавних, никогда не добавляются сверху. degraded: true из RAG появляется
явно в собранном контексте, у обоих агентов — никогда не скрыто.
Сбой RAG строго аддитивен: без попадания поведение
ИДЕНТИЧНО тому, что было до этой волны (именно это сохранило ~50
существовавших тестов зелёными, не тронув ни одного).
Реляционная память: проекция СУЩЕСТВУЮЩЕГО outbox, собственный aggregate_type.
Очевидная альтернатива — engine пишет в граф напрямую —
отброшена: она открыла бы ВТОРОЙ путь записи помимо event log,
нарушая гарантию, что event log — единственный источник истины.
Альтернатива переиспользовать aggregateType: 'session', который у outbox уже есть,
тоже отброшена: Engine.Outbox.Drain на стороне engine уже опустошает
этот тип каждые ~2 с и отмечает processed_at — потребитель на стороне api,
соревнующийся за тот же тип, почти всегда проигрывал бы гонку. Решение:
ВТОРАЯ строка outbox, та же транзакция, aggregateType: 'graph_projection' — значение, которому фильтр engine (IN ('session', 'task')) никогда не соответствует, тот же шаблон, что deny-action.use-case.ts уже использует
для записи в два aggregateType в одной транзакции. GraphProjector
(poller, ~2 с, той же формы, что DomainGaugesCollector) опустошает эту очередь и
вызывает уже существующие use cases записи (RecordHandoff,
RecordHypothesis, RecordAnamneseProfile, RecordInteraction) —
идемпотентность живёт В НИХ, projector не дублирует логику.
Последствия
- Граф остаётся восстановимым через replay:
graph_projection— ПРОИЗВОДНАЯ очередь event log, никогда не вторая первичная запись — потерять Neo4j и пересоздать с нуля — это replay, а не потеря данных. GraphUnavailableErrorпосреди пакета ОСТАНАВЛИВАЕТ весь цикл (не пытается обработать остаток пакета, который упал бы по той же причине) — строка остаётся необработанной и повторяется в следующем цикле. Повтор автоматический, без вмешательства.- Потребление объявлено ВНЕ этой поставки, по тому же шаблону честности предыдущих
ADR:
query_user_context(гипотезы со свидетельствами + профили графа) по-прежнему без открытого HTTP-маршрута — Psicólogo/Anamnese ещё не ЧИТАЮТ из графа, только RAG (pgvector) черезrag_search; проецируется толькоpsychologist.hypothesis_proposed(неaccepted/dismissed);context-manager-summarize— единственный из четырёх шаблонов первой партии, ещё не потребляемый ни одним агентом. - Оба флага (
graph_templates_enabled?,graph_instruction_templates_enabled?) остаютсяfalseво всех окружениях, пока кто-то не запустит seeder и сознательно не включит, — эта поставка сама по себе не меняет наблюдаемого поведения в продакшене.