---
work_item: ORCH-098
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-10
model_used: claude-opus-4-8
---

# ADR-001: Машинный журнал уроков `lessons` — аддитивная таблица + observer-leaf

Work Item: **ORCH-098** — FND: машинный журнал уроков (структурированная база отклонений конвейера)
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0034-lessons-journal.md`** (решение
кросс-каттинговое: новый компонент + новая таблица на общей прод-БД + фундамент эпика
саморазвития).

## Статус
Proposed

## Контекст

ORCH-098 — шаг 1 («Фундамент», F2) эпика саморазвития: формализовать свободнотекстовые «уроки»
из `memory/` в **машинную структурированную таблицу отклонений конвейера**, на которой позже
встанут ретроспективщик (E2), приоритизатор RICE (E3) и Стрим. BRD/TRZ уже зафиксировали состав
полей, набор эндпоинтов и структуру leaf-модуля; нормативное требование Славы 10.06 — колонки
**атрибуции** в схеме **с самого начала** (нуллабельные), чтобы не переделывать схему на живой
общей прод-БД.

Сверено по коду (recon):
- **Образец observer-leaf**: `src/serial_gate.py`, `src/coverage_gate.py`, `src/metrics.py` —
  чистые leaf'ы, импортируют только `config`+`db`, `applies(repo)`-first, never-raise, `snapshot()`
  для `GET /queue`.
- **БД-паттерн**: `db.get_db() -> sqlite3.Connection` (`row_factory=sqlite3.Row`, `.close()` в
  `finally`); `db.init_db()` — `executescript` с `CREATE TABLE IF NOT EXISTS …`; идемпотентные
  миграции `_ensure_column(conn, table, column, decl)` (`src/db.py:341`). Эталон аддитивной таблицы
  — `repo_freeze`, `coverage_baseline`; атомарный helper — `ratchet_coverage_baseline` (`db.py:251`).
- **Choke-point'ы автозаписи** (точные сигнатуры):
  - `stage_engine._handle_qg_failure_rollbacks(task_id, current_stage, repo, work_item_id, branch,
    agent, qg_name, reason, result)` (`src/stage_engine.py:728`) — все нужные поля контекста
    локально доступны.
  - post-deploy `DEGRADED → set_repo_freeze` (`src/stage_engine.py:~1993`) — доступны `repo`,
    `work_item_id`, `branch`, локально собранный `reason`.
  - `merge_gate._handle_merge_verify(task_id, repo, work_item_id, branch, result)`
    (`src/merge_gate.py:1588`); ветка HOLD ставит `result.note="merge-not-verified-hold"` (~`:1695`).
  - `merge_gate._classify_merge_response(repo, branch, index, status_code) -> "transient"|"terminal"`
    (`src/merge_gate.py:811`).
  - `launcher._watchdog`/`stop_process` (timeout-kill) и `launcher._finalize_transient(job_id, agent,
    run_id, exit_code, job, retry_after)` (`src/agents/launcher.py:997`) — транзиент-requeue с
    бюджетом `transient_attempts`.
- **Конфиг-паттерн**: pydantic `BaseSettings` с авто-биндингом `ORCH_*`; пары `*_enabled` (bool) +
  `*_repos` (CSV); `is_self_hosting_repo(repo)` (`src/qg/checks.py:520`).

«Как есть» не годится: уроки в `memory/` не машиночитаемы — нельзя считать паттерны, нельзя
приоритизировать. Нужна структурированная таблица, но врезанная в **горячий путь** конвейера, что
на self-hosting прод-инстансе с общей БД (enduro-trails) требует жёсткой изоляции.

## Решение

### Сводка

Ввести **аддитивную идемпотентную таблицу `lessons`** + **чистый observer-leaf `src/lessons.py`**
(never-raise, kill-switch) по образцу `serial_gate`/`coverage_gate`/`metrics`. Leaf несёт
`record()` / `get()` / `update()` / `snapshot()`. Автозапись 4 типов отклонений — тонкими
best-effort врезками в существующие choke-point. Два-три HTTP-эндпоинта в `main.py`. Колонки
атрибуции — в схеме сразу, нуллабельные. **Конвейер (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/
machine-verdict) и схемы существующих таблиц — байт-в-байт не тронуты; enduro не затронут.**

### D1 — Таблица `lessons`: аддитивная, идемпотентная, forward-proof (BR-1, BR-2; AC-1, AC-2)

`CREATE TABLE IF NOT EXISTS lessons (…)` в `db.init_db()` (паттерн `repo_freeze`):

```sql
CREATE TABLE IF NOT EXISTS lessons (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    created_at    TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at    TEXT,
    lesson_type   TEXT NOT NULL,                 -- slug-конвенция, НЕ enum-констрейнт
    work_item_id  TEXT,
    task_id       INTEGER,
    stage         TEXT,
    agent         TEXT,
    repo          TEXT,
    root_cause    TEXT,
    suggestion    TEXT,
    status        TEXT NOT NULL DEFAULT 'new',    -- new|in_progress|closed|linked
    related_task  TEXT,
    attribution   TEXT,                           -- platform|project|both|unknown (NULLABLE)
    target_repo   TEXT,                           -- orchestrator|enduro-trails|… (NULLABLE)
    target_domain TEXT,                           -- reliability|quality|economy|features|scale (NULLABLE)
    source        TEXT,                           -- auto|manual
    detail        TEXT                            -- свободный JSON/текст (payload детектора)
);
CREATE INDEX IF NOT EXISTS idx_lessons_type_status ON lessons (lesson_type, status);
CREATE INDEX IF NOT EXISTS idx_lessons_repo        ON lessons (repo);
CREATE INDEX IF NOT EXISTS idx_lessons_wi_type     ON lessons (work_item_id, lesson_type);
```

**Инварианты:**
- Все три колонки **атрибуции создаются сразу и нуллабельны** (NFR-6, требование Славы 10.06): на
  живой уже-существующей таблице добавляются через `_ensure_column(conn, "lessons", "<col>",
  "TEXT")` — forward-safe, restart-safe, без миграции данных.
- **Нет `enum`/`CHECK`-констрейнта** на `lesson_type`/`attribution`/`target_domain` — значения суть
  конвенция строковых слагов (новый тип урока не требует миграции схемы; §6 допущений BRD).
- **Третий индекс `idx_lessons_wi_type`** добавлен сверх двух из TRZ — обслуживает дедуп-запрос
  автозаписи (D4) одним indexed-lookup'ом (NFR-5).

DDL-хелперы в `db.py` (стиль `coverage_baseline`): `record_lesson(...) -> int|None`,
`get_lessons(...) -> list[dict]`, `update_lesson(id, **fields) -> bool`, `lessons_snapshot() -> dict`.
Каждый открывает `get_db()` и закрывает в `finally`.

### D2 — Observer-leaf `src/lessons.py`: scope **kill-switch only**, НЕ repo-gated (BR-3/4/5/6; NFR-1/2/7)

Чистый leaf, импортирует только `config`+`db` (lazy `notifications` при необходимости); **никогда
не импортирует `stage_engine`/`merge_gate`/`launcher`** (анти-цикл). Публичный контракт:

```python
def record(lesson_type, *, work_item_id=None, task_id=None, stage=None, agent=None, repo=None,
           root_cause=None, suggestion=None, status="new", related_task=None, attribution=None,
           target_repo=None, target_domain=None, source="auto", detail=None) -> int | None
def get(*, lesson_type=None, status=None, repo=None, work_item_id=None, limit=None) -> list[dict]
def update(lesson_id, **fields) -> bool
def snapshot() -> dict
```

**Ключевое решение D2 — расхождение с шаблоном гейт-leaf'ов: журнал НЕ скоупится по repo.**
В отличие от `serial_gate`/`coverage_gate`/`bug_fast_track` (которые *действуют* на конкретный репо
и потому имеют пару `*_repos`), журнал — **observer-only**: запись строки никогда не влияет на
пайплайн ни одного репо. Поэтому:
- единственный регулятор — глобальный kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`,
  дефолт `True`); **`lessons_repos` НЕ вводится**;
- recorder пишет уроки про **любой** репо (включая enduro-trails) — урок про деградацию деплоя
  enduro ценен для петли самообучения; репо-скоуп терял бы этот сигнал;
- `repo`-разрез — на уровне **выборки** (`get(repo=…)`, фильтр `snapshot()`), как зафиксировано в
  §6 BRD «репо-скоуп через поле/фильтр выборки».
- **enduro не затронут (NFR-3):** запись observer-строки про enduro не меняет ни одной стадии/гейта
  enduro — это чистая память орка.

**never-raise (NFR-1, AC-6):** при `lessons_enabled is False` каждая функция — немедленный no-op
(`record→None`, `get→[]`, `update→False`, `snapshot→{}`) **без обращения к БД**. При `True` — тело в
`try/except Exception → logger.warning(...) + безопасный дефолт`. Журнал **не** деплоит, **не**
рестартит прод, **не** трогает `main`, **не** порождает процессов/сети (NFR-7).

### D3 — Точки автозаписи: 4 детектора, тонкая врезка одним вызовом (BR-3; FR-3; AC-3)

Каждая врезка = локальный импорт + один вызов `lessons.record(...)`, обёрнутый в защитный
`try/except` (паттерн post-deploy-freeze-врезки `stage_engine.py:~1993`), чтобы даже ошибка импорта
не пробилась в горячий путь:

| Тип (`lesson_type`) | Choke-point | Контекст врезки |
|---|---|---|
| `gate_failure` | `stage_engine._handle_qg_failure_rollbacks` (после решения об откате на `development`) | `work_item_id, task_id, stage=current_stage, agent, repo, root_cause=reason, detail=qg_name` |
| `merge_hold` | `merge_gate._handle_merge_verify` (ветка HOLD, `result.note="merge-not-verified-hold"`) | `work_item_id, task_id, repo, stage="deploy", root_cause="merge-not-verified-hold"` |
| `transient_retry` | **budget-exhaustion**: `merge_gate` (merge-retry исчерпан) и/или `launcher._finalize_transient` (исчерпан `transient_attempts`) | `work_item_id?, repo, agent?, stage?, detail=<код/причина>` |
| `deploy_degraded` | `stage_engine` post-deploy `DEGRADED → set_repo_freeze` | `work_item_id, repo, stage="deploy", root_cause=reason, attribution="unknown", target_repo=repo, target_domain="reliability"` |

Все врезки — `source="auto"`. Это **4 типа > минимума 2–3** (BR-3). `(г) deploy_degraded` (желаемый
по TRZ) включён как полноценный детектор: это урок слоя-3 «деплой OK / прод сломан» (ET-8),
ради которого Слава и потребовал атрибуцию.

### D4 — Дедуп автозаписи: один indexed-SELECT в окне (BR-3; FR-3 «решение архитектора»; NFR-5)

Риск: транзиент-ретраи/повторные откаты плодят дубли. Решение — **дешёвый дедуп только для
`source="auto"`** внутри `record()`: перед `INSERT` — один indexed-lookup
```sql
SELECT 1 FROM lessons
WHERE work_item_id = ? AND lesson_type = ? AND (stage IS ? OR ?)  -- stage-match
  AND created_at > datetime('now', ?)                            -- '-<window> seconds'
LIMIT 1;
```
по индексу `idx_lessons_wi_type` (D1). Найдено → no-op (`return None`, лог DEBUG). Окно —
`lessons_dedup_window_s` (env `ORCH_LESSONS_DEDUP_WINDOW_S`, дефолт `3600`). **`source="manual"`
дедуп НЕ проходит** (оператор/Стрим всегда может записать). Это один лёгкий `SELECT` (NFR-5), без
фоновых сканов.

**Доп. контроль флуда на самом шумном детекторе:** `transient_retry` пишется **только на исчерпании
бюджета ретраев** (а не на каждом backoff) — это и есть ценный сигнал «транзиенты исчерпаны», а не
шум каждой попытки. Так флуд гасится в источнике до дедупа.

### D5 — Эндпоинты `main.py`: read-only выборка + ручная запись/обновление (BR-4/5/6; FR-4/5; AC-4/5)

Стиль `GET /queue` / `POST /coverage/baseline`, все never-raise, при выключенном флаге →
`{"enabled": false}`:
- **`GET /lessons`** — query `type/status/repo/work_item/limit` (дефолт `lessons_query_limit_default`,
  напр. 100) → `{"enabled": bool, "lessons": [...]}`, всегда `200`, только чтение.
- **`POST /lessons`** — тело JSON, `lesson_type` обязателен → `lessons.record(..., source="manual")`
  → `{"id": <int>}`.
- **`POST /lessons/{id}`** — `lessons.update(id, status=…, attribution=…, target_repo=…,
  target_domain=…, related_task=…, root_cause=…, suggestion=…)` → `{"ok": bool}`; стампит
  `updated_at=datetime('now')`. Позволяет ретроспективщику/человеку доклассифицировать
  автозаписанный `unknown`.
- **`GET /queue`** — добавить read-only ключ `"lessons": lessons.snapshot()` рядом с
  `serial_gate`/`coverage`. `snapshot()` — лёгкие `GROUP BY`-счётчики (по типу/статусу) + последние
  N. Существующие ключи `/queue` и эндпоинты `/health|/status|/metrics` — **байт-в-байт прежние**.

### D6 — Изоляция от конвейера и гейтов (NFR-3; AC-8)

`STAGE_TRANSITIONS`, реестр `QG_CHECKS`, функции `check_*`, machine-verdict-ключи
(`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`) и
схемы существующих таблиц — **диффом не затрагиваются**. Журнал — наблюдатель, **не** Quality Gate;
он не участвует в решении о продвижении по стадиям. Никаких новых/изменённых QG-checks (FR-6).

## Альтернативы

- **Repo-скоуп `lessons_repos` (как у гейтов)** — отвергнуто: журнал observer-only, не действует на
  репо; скоуп терял бы ценные enduro-уроки. Скоуп — на выборке (D2).
- **Без дедупа в v1 (TRZ это допускает)** — отвергнуто как дефолт: транзиент-ретраи реально
  флудят таблицу; дешёвый indexed-дедуп (D4) дешевле, чем последующая чистка. Бюджет-exhaustion +
  окно дают двойную защиту при одном `SELECT`.
- **Запись `transient_retry` на каждом backoff** — отвергнуто: шум; ценен факт исчерпания бюджета.
- **Отдельная БД/файл для журнала** — отвергнуто: лишняя зависимость; общая SQLite-БД с аддитивной
  таблицей соответствует принципу «минимум зависимостей» и паттерну `repo_freeze`/`coverage_baseline`.
- **Фоновый агрегатор/ретенция-крон в v1** — отвергнуто: NFR-5 (без фоновых потоков/сканов);
  ретенция — будущая задача (см. `10-tech-risks.md` TR-2).
- **ORM** — отвергнуто: raw SQL достаточно (принцип «без ORM, если хватает raw SQL»).

## Последствия

- **+** Уроки становятся машиночитаемыми — фундамент для E2/E3/Стрим; атрибуция forward-proof
  (колонки сразу, переделки живой БД не будет).
- **+** Нулевая регрессия: kill-switch + never-raise + чистая аддитивность; enduro не затронут;
  конвейер байт-в-байт прежний.
- **+** Следует проверенному additive-observer-leaf шаблону (`serial_gate`/`coverage_gate`/`metrics`/
  `cancel`/`bug_fast_track`) — низкий архитектурный риск, не требует `arch:major-change` (см.
  `10-tech-risks.md` сводный вывод).
- **−** Рост таблицы со временем (автозапись на отклонениях). Митигейшн: лёгкие строки + дедуп (D4) +
  budget-exhaustion-only для транзиентов; ретенция — TR-2 (будущее).
- **−** Лёгкое усложнение `record()` дедуп-запросом. Митигейшн: один indexed-SELECT, только для
  `auto`, под окном; для `manual` пропускается.
- **Откат:** `ORCH_LESSONS_ENABLED=false` → весь функционал инертен мгновенно (no-op, нулевая
  регрессия). Полный откат — revert диффа; таблица `lessons` остаётся пустой/неиспользуемой,
  существующих таблиц не касается.

## Ссылки
- BRD: `docs/work-items/ORCH-098/01-brd.md`
- TRZ: `docs/work-items/ORCH-098/02-trz.md`
- Acceptance: `docs/work-items/ORCH-098/03-acceptance-criteria.md`
- Data: `docs/work-items/ORCH-098/08-data-requirements.md`
- Infra: `docs/work-items/ORCH-098/07-infra-requirements.md`
- Risks: `docs/work-items/ORCH-098/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0034-lessons-journal.md`
- Сверено по коду: `src/serial_gate.py`, `src/coverage_gate.py`, `src/metrics.py`, `src/db.py:251,341`,
  `src/stage_engine.py:728,~1993`, `src/merge_gate.py:811,1588`, `src/agents/launcher.py:997`,
  `src/main.py` (`GET /queue`, `POST /coverage/baseline`), `src/qg/checks.py:520`.
