Andrey Epifancev a4c23f3f0b Архив OpenSpec: wiki-history (#140)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:17:42 +04:00

310 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# wiki Specification
## Purpose
Модуль `wiki`: корень EVA Wiki у проекта, Markdown-копия дерева страниц, синхронизация через агента `wiki` (eva-agent), поиск, вложения по запросу и маршруты вики.
## Requirements
### Requirement: Корень вики проекта
Модуль `wiki` SHALL давать проекту поле `wiki_root` — код корневого документа Eva вида `DOC-<цифры>` (регистр
приводится к верхнему; ссылка на документ Eva с таким кодом принимается и сводится к коду) или `null`. Поле
необязательно при создании и изменяемо после (`PATCH /projects/{label}`). Задание или смена корня SHALL ставить пакет
синхронизации проекта; смена корня SHALL оставлять прежнюю копию до успешной синхронизации нового. Сброс в `null`
SHALL удалять копию проекта, его страницы, индекс и строки вложений. Модуль SHALL хранить для проекта `root_title`,
`synced_at`, `pages_count`, `sync_error`.
#### Scenario: Ссылка вместо кода
- **WHEN** `PATCH /projects/AWPCA {wiki_root: "https://eva.example.ru/project/Document/doc-001234"}`
- **THEN** 200, `wiki_root = DOC-001234`, поставлен `wiki_sync {label: AWPCA, root: DOC-001234}`
#### Scenario: Неверный код
- **WHEN** `PATCH /projects/AWPCA {wiki_root: "страница про статусы"}`
- **THEN** 422 с указанием поля `wiki_root`, корень прежний, пакета нет
#### Scenario: Сброс корня
- **WHEN** у `AWPCA` синхронизировано 120 страниц, `PATCH /projects/AWPCA {wiki_root: null}`
- **THEN** каталога `wiki/AWPCA/` нет, страниц и вложений проекта в базе нет; `GET /projects` — `wiki_root = null`
### Requirement: Синхронизация вики
Команда `wiki_sync {label, root}` SHALL быть командой чтения модуля `wiki` (без копии базы) в пакете с `project =
label`. Её SHALL ставить: `POST /wiki/sync {project}`, задание или смена корня, фоновая работа хаба — по каждому
проекту с корнем, если с последней успешной или неудачной синхронизации прошло больше `wiki_auto_sync_hours`
(настройка модуля, по умолчанию 24; `0` — выключено). Пока у проекта есть пакет `wiki_sync` не в терминальном статусе,
новый не ставится: `POST /wiki/sync` отвечает 409 `sync_in_progress` с `batch_id` текущего. Любая другая постановка
пакета с `wiki_sync` (`POST /batches`, повтор команды `POST /commands/{id}/retry`) SHALL отклоняться с 422, если у
проекта уже есть команда `wiki_sync` с тем же `root` в `queued` или `claimed`; текст ошибки называет пакет идущей
синхронизации. Команда с другим `root` не мешает: смена корня ставит синхронизацию нового корня, пока старая ещё
выдана агенту. Смена или сброс корня SHALL отменять невыданные (`queued`) пакеты `wiki_sync` проекта и при новом корне ставить новый; отчёт `done` по
команде, чей `root` не равен текущему корню проекта, SHALL отбрасываться без изменения копии и данных
(`result.discarded = "корень сменился"`).
Агент `wiki` SHALL прислать все страницы поддерева корня (включая корень) пачками `POST /agent/wiki/pages {command_id,
pages}` — каждая страница `{code, eva_id, title, parent_code, orderno, url, html, attachments: [{ref, url, name}],
links: [{ref, code}]}`, где `attachments` — ссылки и картинки на файлы Eva, `links` — ссылки на документы Eva, `ref` —
значение атрибута `src`/`href` ровно как в `html`, `url` — абсолютный адрес, `name` — `alt`/`title`, текст ссылки или
имя файла; в пачке не больше 50 страниц, — и отчитаться `done` с `result = {root_title, codes, eva_calls}`, где
`codes` — коды всех страниц дерева в порядке обхода. Хаб SHALL при приёме пачки переводить HTML в Markdown вне
транзакции записи и складывать результат во временную стадию команды, не трогая копию. Пачка по
команде не в `claimed` или не `wiki_sync` — 409; неверная форма страницы — 422 `bad_pages` с кодом страницы и полем.
По отчёту `done` хаб SHALL одной операцией заменить копию проекта (см. «Markdown-копия вики»), страницы, индекс поиска и
вложения и записать `synced_at`, `pages_count`, `root_title`, очистив `sync_error`. Код из `codes` без присланной
страницы — команда `failed` «нет страницы <код>». Отчёт `failed` или перевод в `failed` по пределу выдач SHALL записать
`sync_error`; копия, страницы и вложения остаются прежними. Повторная выдача команды SHALL очищать её стадию. Стадия
очищается при любом терминальном статусе команды.
#### Scenario: Первая синхронизация
- **WHEN** у `AWPCA` корень DOC-1 с детьми DOC-2, DOC-3; агент прислал три страницы одной пачкой и `done {root_title:
"АРМ КА", codes: [DOC-1, DOC-2, DOC-3]}`
- **THEN** в `wiki/AWPCA/` три `index.md`; `GET /projects` — `wiki_pages = 3`, `wiki_root_title = АРМ КА`,
`wiki_synced_at` заполнен
#### Scenario: Неудачная синхронизация не портит копию
- **WHEN** копия `AWPCA` из 3 страниц, следующая синхронизация завершилась `failed` «Eva: HTTP 503»
- **THEN** копия и поиск прежние; `wiki_sync_error = "Eva: HTTP 503"`
#### Scenario: Потерянная пачка
- **WHEN** отчёт `done` с `codes: [DOC-1, DOC-2]`, пачки пришли только для DOC-1
- **THEN** команда `failed` «нет страницы DOC-2», копия прежняя
#### Scenario: Страница удалена в Eva
- **WHEN** в прошлой копии были DOC-1, DOC-2, DOC-3, новая синхронизация вернула DOC-1, DOC-3
- **THEN** каталога DOC-2 нет, поиск его не находит, его вложения удалены
#### Scenario: Корень исправлен, пока агент не установлен
- **WHEN** у `AWPCA` корень DOC-12 и пакет `wiki_sync` в `queued`; `PATCH /projects/AWPCA {wiki_root: "DOC-21"}`
- **THEN** прежний пакет `cancelled`, поставлен `wiki_sync {root: DOC-21}`
#### Scenario: Поздний отчёт по старому корню
- **WHEN** `wiki_sync {root: DOC-12}` выдан агенту, корень сменён на DOC-21, агент отчитался `done`
- **THEN** копия и страницы `AWPCA` не изменились, команда `done` с `result.discarded`
#### Scenario: Повторный запрос синхронизации
- **WHEN** пакет `wiki_sync` проекта `AWPCA` выполняется, вызван `POST /wiki/sync {project: AWPCA}`
- **THEN** 409 `sync_in_progress` с `batch_id` текущего пакета, нового пакета нет
#### Scenario: Повтор упавшей синхронизации во время идущей
- **WHEN** команда `wiki_sync {label: AWPCA, root: DOC-1}` 40 в `failed`, фоновая работа поставила пакет 41 с
`wiki_sync {root: DOC-1}` (`queued`); `POST /commands/40/retry`
- **THEN** 422, в тексте — пакет 41; нового пакета нет
#### Scenario: Смена корня при выданной синхронизации
- **WHEN** `wiki_sync {root: DOC-12}` выдан агенту (`claimed`); `PATCH /projects/AWPCA {wiki_root: "DOC-21"}`
- **THEN** 200, поставлен `wiki_sync {root: DOC-21}`
### Requirement: Markdown-копия вики
Копия проекта SHALL лежать в `<SDLC_HUB_HOME>/wiki/<метка>/` (каталоги 0700): у каждой страницы — каталог
`NN_<название>` внутри каталога родителя (`NN` — двузначная позиция среди соседей по `orderno`, затем по коду; у корня
номера нет; название без символов `<>:"/\|?*` и управляющих, обрезано до 80 символов; совпадение у соседей —
суффикс `_2`, `_3`), в нём `index.md`: frontmatter `code`, `title`, `url`, `synced_at`, строка `# <title>` и текст. В
корне копии — `_tree.json`, список `{code, title, depth, path}` в порядке обхода. Копия SHALL собираться рядом
(`wiki/.<метка>.new/`) и подменять прежнюю переименованием; незавершённую подмену хаб SHALL доводить при старте.
HTML страницы SHALL переводиться в Markdown (заголовки `#`); ссылка или картинка на файл Eva (по списку `attachments` агента: адрес на хосте Eva
с путём `/files/…`, относительный адрес — тоже Eva; миниатюра `<адрес>.meta/thumbnail.jpg` означает файл `<адрес>`, а
ссылки на `static/…` — значки интерфейса — из текста удаляются) SHALL заменяться на
`[📎 <имя>](attachment:<id>)`, где `id` — строка вложения; ссылка на документ Eva с кодом `DOC-<цифры>` (в Eva — относительный
`project/Document/DOC-…`) — на `[<текст>](page:<код>)`; прочие ссылки остаются. Сырой HTML в копию не попадает.
Перевод SHALL давать корректный Markdown на любом HTML Eva:
- картинка внутри ссылки на то же вложение — на любой глубине вложенности (`<a><span><img></span></a>`) — даёт одну
ссылку на вложение;
- вложение внутри ссылки на документ Eva выводится отдельной ссылкой на вложение перед ссылкой на страницу; текст
ссылки на страницу — её текст без вложения, пустой — код страницы; ссылка внутри ссылки не появляется;
- при нескольких записях `attachments` или `links` с одним `ref` действует первая;
- пробел и круглые скобки в адресе внешней ссылки или картинки кодируются (`%20`, `%28`, `%29`);
- текст ссылки не может экранировать её закрывающую скобку: хвостовая `\` экранируется.
#### Scenario: Раскладка
- **WHEN** корень DOC-1 «АРМ КА», дети DOC-3 «Статусы» (`orderno` 1) и DOC-2 «Роли/права» (`orderno` 2)
- **THEN** `wiki/AWPCA/АРМ КА/index.md`, `…/01_Статусы/index.md`, `…/02_Роли_права/index.md`; `_tree.json` в порядке
DOC-1, DOC-3, DOC-2
#### Scenario: Картинка на странице
- **WHEN** в HTML DOC-3 `<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/scheme.png?version=1">`
- **THEN** в `index.md` — `[📎 scheme.png](attachment:<id>)`, у вложения `<id>` `state = none`, файл не скачан
#### Scenario: Прикреплённый файл с миниатюрой
- **WHEN** на странице миниатюра `…/<uuid>/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и
ссылка на `…/<uuid>/model.xlsx`
- **THEN** одно вложение `model.xlsx` с адресом файла; в тексте одна ссылка `[📎 model.xlsx](attachment:<id>)`, значка нет
#### Scenario: Миниатюра в обёртке
- **WHEN** `<a href="…/model.xlsx"><span><img src="…/model.xlsx.meta/thumbnail.jpg"></span></a> текст`
- **THEN** в тексте `[📎 model.xlsx](attachment:<id>) текст`
#### Scenario: Ссылка на другую страницу
- **WHEN** в HTML `<a href="project/Document/DOC-019834">Модель данных</a>`
- **THEN** в `index.md` — `[Модель данных](page:DOC-019834)`
#### Scenario: Картинка-ссылка на другую страницу
- **WHEN** `<a href="project/Document/DOC-1"><img src="…/scheme.png"></a>`, `scheme.png` — вложение
- **THEN** в тексте `[📎 scheme.png](attachment:<id>) [DOC-1](page:DOC-1)`
#### Scenario: Пробелы и скобки во внешнем адресе
- **WHEN** `<a href="http://ex.com/a b(c).html">док</a>`
- **THEN** в тексте `[док](http://ex.com/a%20b%28c%29.html)`
#### Scenario: Обратная косая черта в конце текста
- **WHEN** `<a href="project/Document/DOC-1">путь C:\</a>`
- **THEN** ссылка на `page:DOC-1` с текстом «путь C:\» остаётся ссылкой при разборе Markdown
### Requirement: Вложения вики по запросу
`POST /wiki/attachments/{id}/fetch` SHALL ставить пакет из одной команды чтения `wiki_fetch {attachment_id, url}` и
переводить вложение в `fetching`; для `fetching` — ответ текущим состоянием без нового пакета, для `ready` — сразу
`ready`. Вложение в `ready`, чьего файла нет в `files/`, SHALL считаться нескачанным: запрос ставит скачивание заново.
Агент `wiki` SHALL скачать файл с токеном Eva, отказаться от файла больше предела хранилища ядра (10 МБ) с
ошибкой «файл больше 10 МБ — откройте в Eva», загрузить файл `POST /agent/files` и отчитаться `done {file, size}`. Хаб
SHALL проверить, что файл есть в `files/` и размер совпадает, и перевести вложение в `ready`; иначе команда и вложение
`failed`. Адрес не на хосте Eva из конфига агента агент SHALL отвергать без запроса (`failed` «чужой хост»).
Файлы вложений в `ready` SHALL считаться ссылками для уборки `files/`. `GET /wiki/attachments/{id}` отдаёт
`{id, name, page_code, url, state, size, error}`, `GET /wiki/attachments/{id}/content` — содержимое (`ready`) или 409
`not_ready`.
#### Scenario: Скачивание картинки
- **WHEN** вложение 7 в `none`, `POST /wiki/attachments/7/fetch`, агент прислал файл 300 КБ
- **THEN** вложение 7 `ready`, `size = 307200`, `GET /wiki/attachments/7/content` — файл
#### Scenario: Слишком большой файл
- **WHEN** агент отчитался `failed` «файл больше 10 МБ — откройте в Eva»
- **THEN** вложение `failed` с этим текстом; `GET …/content` — 409 `not_ready`
#### Scenario: Вложение переживает синхронизацию
- **WHEN** вложение 7 `ready`, синхронизация вернула ту же страницу с той же ссылкой
- **THEN** у вложения тот же `id` 7 и `state = ready`, файл в `files/` не убран
#### Scenario: Файл вложения пропал
- **WHEN** вложение 7 `ready`, его файла нет в `files/`; `POST /wiki/attachments/7/fetch`
- **THEN** 202, вложение 7 `fetching`, поставлен пакет `wiki_fetch {attachment_id: 7}`
### Requirement: Маршруты вики
Хаб SHALL предоставлять под клиентским токеном (проект — `project=<метка>`, неизвестный — 404, без корня — 409
`no_root`):
- `GET /wiki/status?project=` — `{root, root_title, synced_at, pages, error, batch: {id, status} | null}`;
- `GET /wiki/tree?project=` — `{"pages": [{code, title, depth, parent_code, size}]}` в порядке обхода;
- `GET /wiki/pages/{code}?project=` — `{code, title, path, url, synced_at, markdown, attachments: [{id, name, state,
size, error, image}]}`; `image` — `true`, если имя файла в адресе вложения (последний сегмент пути без запроса,
раскодированный) оканчивается на `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp` без учёта регистра, иначе `false`
(имя вложения из Eva для этого не годится: у картинок это часто `alt` без расширения); страницы нет в копии — 404;
- `GET /wiki/search?project=&q=&limit=` (`limit` 1–50, по умолчанию 10) — `{"results": [{code, title, path, snippet}]}` по
убыванию релевантности; слова запроса ищутся по префиксу, пустой запрос — 422;
- `POST /wiki/sync {project, author?}` → 201 `{batch_id}` (409 — см. «Синхронизация вики»);
- маршруты вложений — см. «Вложения вики по запросу».
Под токеном агента `wiki` — `POST /agent/wiki/pages` (см. «Синхронизация вики»).
#### Scenario: Поиск
- **WHEN** в копии `AWPCA` страница DOC-3 «Статусы» содержит «статус «На согласовании» ставит аналитик»;
`GET /wiki/search?project=AWPCA&q=согласов`
- **THEN** первым — DOC-3 с фрагментом, содержащим «На согласовании»
#### Scenario: Проект без корня
- **WHEN** у `sfm_kedr` нет корня, `GET /wiki/tree?project=sfm_kedr`
- **THEN** 409 `no_root`
#### Scenario: Признак картинки
- **WHEN** у вложения 9 страницы DOC-3 имя «Схема расчёта суммы элиминации по тройке», адрес
`…/vgo-matching-1.png?version=1`; у вложения 8 имя `attachment`, адрес `…/model.xlsx`
- **THEN** в `GET /wiki/pages/DOC-3?project=AWPCA` у 9 `image = true`, у 8 `image = false`
### Requirement: История правок страниц
Хаб SHALL хранить опубликованные версии страниц дерева проекта: код версии Eva (`DOCH-…`), код страницы, время правки
(UTC), автора (`login`, `name`), Markdown версии и число строк, добавленных и удалённых против предыдущей версии той же
страницы (порядок версий — по времени, затем по коду; у первой — против пустого текста). Время правки — время создания
версии в Eva (`cmf_created_at`). Версии страниц, которых нет в дереве проекта, хаб MUST NOT хранить; неопубликованные
версии (черновики) агент MUST NOT присылать.
При выдаче `wiki_sync` хаб SHALL дописывать в параметры команды `known_versions` — объект `{<код страницы>: [<код
версии>…]}` по версиям, уже хранящимся у страниц текущей копии проекта (без копии — `{}`); в `command.params` в базе
поле MUST NOT записываться. Агент `wiki` SHALL после страниц прислать опубликованные версии страниц дерева, которых нет
в `known_versions` их страницы, пачками `POST /agent/wiki/versions {command_id, versions}`: версия — `{page_code, code,
created_at, author: {login, name}, html, attachments, links}` (`created_at` — ISO-8601 со смещением; `login` и `name`
— строки или `null`, если Eva автора не отдала; `attachments` и `links` — как у страниц, по `html` версии), в пачке не
больше 50 версий, — и отчитаться `done` с `result.versions` —
числом присланных версий. Пачка по команде не в `claimed` или не `wiki_sync` — 409; неверная форма версии — 422
`bad_versions` с кодом версии и полем. Хаб SHALL переводить HTML версии в Markdown при приёме вне транзакции записи
(ссылки на вложения — текстом `📎 <имя>` без ссылки, ссылки на страницы — `page:<код>`) и складывать в стадию команды.
По отчёту `done` с `result.versions` хаб SHALL в той же операции, что и копию (см. «Синхронизация вики»): добавить
версии из стадии к страницам, чьи коды есть в `codes`, отбросив остальные; удалить версии страниц, которых нет в
`codes`; посчитать «+/−» у новых версий и у версии, следующей за вставленной раньше неё; записать время применения
истории `history_at`. Присланных версий меньше `result.versions` — команда `failed` «нет версий: пришло N из M», копия и
история прежние. Отчёт `done` без `result.versions` (агент без истории) SHALL обновлять копию как прежде и не менять
историю и `history_at`. Отчёт `failed` и отброшенный отчёт по сменённому корню историю не меняют. Сброс корня SHALL
удалять историю проекта вместе с копией. Стадия версий очищается так же, как стадия страниц.
#### Scenario: Первая загрузка истории
- **WHEN** у `AWPCA` дерево DOC-1, DOC-2; в хабе версий нет; выдан `wiki_sync`
- **THEN** в параметрах выдачи `known_versions = {}`; агент прислал 3 версии DOC-1 и 2 версии DOC-2, отчёт `done
{versions: 5}`; в хабе 5 версий, у первой версии каждой страницы «+N / −0», `history_at` заполнен
#### Scenario: Догрузка новых версий
- **WHEN** у DOC-1 в хабе версии DOCH-10, DOCH-11; в Eva опубликована DOCH-12
- **THEN** при выдаче `known_versions = {"DOC-1": ["DOCH-10", "DOCH-11"], …}`; агент прислал только DOCH-12; у неё
«+/−» против DOCH-11
#### Scenario: Агент без истории
- **WHEN** агент v1 отчитался `done {root_title, codes, eva_calls}` без `versions`
- **THEN** копия обновлена, версии и `history_at` прежние
#### Scenario: Страница ушла из дерева
- **WHEN** у DOC-3 в хабе 4 версии; новая синхронизация вернула `codes` без DOC-3
- **THEN** версий DOC-3 в хабе нет
#### Scenario: Версия чужой страницы
- **WHEN** в стадии версия с `page_code = DOC-99`, которой нет в `codes`
- **THEN** версия не записана, команда `done`
#### Scenario: Потерянная пачка версий
- **WHEN** отчёт `done {versions: 5}`, в стадии 3 версии
- **THEN** команда `failed` «нет версий: пришло 3 из 5», копия и история прежние
### Requirement: Маршруты истории вики
Хаб SHALL предоставлять под клиентским токеном (проект — `project=<метка>`, неизвестный — 404, без корня — 409
`no_root`), читая только базу хаба:
- `GET /wiki/changes?project=&since=&until=&author=&limit=` — правки страниц текущей копии проекта со временем в
`[since, until]`. `since`, `until` — ISO-8601 со смещением или `Z` (без смещения — 422); без `until` — текущее время;
без `since` — `until` минус 24 часа; `author` — подстрока имени или логина без учёта регистра; `limit` — по умолчанию
500, 1–2000. Ответ — `{since, until, synced_at, history_at, truncated, changes: [{at, page_code, title, path,
version, author: {login, name}, added, removed}]}` по `at` по убыванию, затем по `version` по убыванию; правок больше
`limit` — первые `limit` и `truncated = true`. Неверное время, `since` позже `until`, `limit` вне 1..2000 — 422
`bad_request` с именем параметра. `history_at = null` — история проекта ещё не загружалась.
- `GET /wiki/pages/{code}/versions?project=` — `{code, title, versions: [{version, at, author: {login, name}, added,
removed}]}`, новые первыми; страницы нет в копии — 404.
- `GET /wiki/pages/{code}/diff?project=&version=&offset=` — правка `version` (без параметра — последняя) против
предыдущей версии страницы: `{code, title, path, version, prev_version, at, author: {login, name}, added, removed,
index, total, offset, next_offset, diff}`, где `diff` — unified diff Markdown (контекст 3 строки; у первой версии —
против пустого, `prev_version = null`) с символа `offset` до 20 000 символов, `next_offset` — начало следующей части
или `null`, `index` — номер версии от старой (с 1), `total` — число версий. Страницы нет в копии — 404; версии нет у
страницы — 404 `no_version`; у страницы нет версий — 404 `no_versions`.
`GET /wiki/pages/{code}` SHALL дополнительно отдавать `modified_at`, `modified_by: {login, name}` (последняя версия;
нет версий — `null`) и `versions` — число версий. `GET /wiki/status` SHALL дополнительно отдавать `history_at`.
#### Scenario: Что сделали за сегодня
- **WHEN** у `AWPCA` 27.10 в 17:59 и 18:02 (MSK) Соснов опубликовал версии DOC-101252, 26.10 Молчанов — версию DOC-2;
`GET /wiki/changes?project=AWPCA&since=2026-10-27T00:00:00+03:00`
- **THEN** две правки DOC-101252, новая первой, у каждой автор `{login: "sosnov-dvi@rshbeco.ru", name: "Дмитрий
Соснов"}`, `added`/`removed`, путь страницы в дереве; правки DOC-2 нет
#### Scenario: Фильтр по автору
- **WHEN** `GET /wiki/changes?project=AWPCA&since=…&author=соснов`
- **THEN** только правки Соснова
#### Scenario: Дифф последней правки
- **WHEN** у DOC-101252 4 версии; `GET /wiki/pages/DOC-101252/diff?project=AWPCA`
- **THEN** `index = 4`, `total = 4`, `prev_version` — третья версия, `diff` начинается с `--- ` и `+++ `
#### Scenario: Длинный дифф
- **WHEN** дифф правки 45 000 символов, запрос без `offset`
- **THEN** первые 20 000 символов, `next_offset = 20000`; запрос с `offset=40000` — остаток, `next_offset = null`
#### Scenario: История ещё не загружена
- **WHEN** у `AWPCA` копия есть, агент v1, `GET /wiki/changes?project=AWPCA`
- **THEN** 200, `history_at = null`, `changes = []`
#### Scenario: Последняя правка в странице
- **WHEN** в хабе 4 версии DOC-101252, последняя — Соснов, `created_at = 2026-10-27T23:48:02+03:00`;
`GET /wiki/pages/DOC-101252?project=AWPCA`
- **THEN** `modified_at = "2026-10-27T20:48:02+00:00"`, `modified_by.name = "Дмитрий Соснов"`, `versions = 4`